diff --git a/novem/cli/common.py b/novem/cli/common.py index 25b8d08..6496fcf 100644 --- a/novem/cli/common.py +++ b/novem/cli/common.py @@ -5,6 +5,7 @@ from novem import Grid, Job, Mail, Plot from novem.api_ref import Novem404, NovemAPI from novem.cli.editor import edit +from novem.cli.gql import NovemGQL, fetch_topics_gql, render_topics from novem.cli.setup import Share, Tag from novem.cli.vis import ( list_job_shares, @@ -149,6 +150,16 @@ def __call__(self, args: Dict[str, Any]) -> None: print(ts) return + # --comments: show topics and comment threads + if args.get("comments"): + if "profile" in args and args["profile"]: + args["config_profile"] = args["profile"] + gql = NovemGQL(**args) + topics = fetch_topics_gql(gql, self.fragment, name, author=usr) + me = gql._config.get("username", "") + print(render_topics(topics, me=me)) + return + # if we have the -e or edit flag then this takes presedence over all other # inputs if "edit" in args and args["edit"]: diff --git a/novem/cli/gql.py b/novem/cli/gql.py index 47cb251..e6affbb 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -5,13 +5,16 @@ while the core data operations remain REST-based. """ +import datetime import json import re +import shutil +import textwrap from typing import Any, Dict, List, Optional, Set import requests -from ..utils import API_ROOT, get_current_config +from ..utils import API_ROOT, cl, colors, get_current_config, parse_api_datetime def _get_gql_endpoint(api_root: str) -> str: @@ -104,6 +107,14 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + social { + views + } + topics { + num_comments + num_likes + num_dislikes + } } } """ @@ -128,6 +139,14 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + social { + views + } + topics { + num_comments + num_likes + num_dislikes + } } } """ @@ -152,6 +171,14 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + social { + views + } + topics { + num_comments + num_likes + num_dislikes + } } } """ @@ -176,6 +203,14 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict name type } + social { + views + } + topics { + num_comments + num_likes + num_dislikes + } last_run_status last_run_time run_count @@ -268,6 +303,16 @@ def _get_markers(tags: List[Dict[str, Any]]) -> str: return fav + like +def _aggregate_activity(item: Dict[str, Any]) -> Dict[str, int]: + """Sum topic-level comments, likes, and dislikes for a visualization.""" + topics = item.get("topics", []) or [] + return { + "_comments": sum(t.get("num_comments", 0) for t in topics) + len(topics), + "_likes": sum(t.get("num_likes", 0) for t in topics), + "_dislikes": sum(t.get("num_dislikes", 0) for t in topics), + } + + def _transform_vis_response(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Transform GraphQL visualization response to match REST format. @@ -288,6 +333,8 @@ def _transform_vis_response(items: List[Dict[str, Any]]) -> List[Dict[str, Any]] "updated": item.get("updated", ""), "shared": _transform_shared(item.get("public", False), item.get("shared", [])), "fav": _get_markers(item.get("tags", [])), + "_views": (item.get("social") or {}).get("views", 0), + **_aggregate_activity(item), } result.append(transformed) return result @@ -350,6 +397,7 @@ def _transform_jobs_response(items: List[Dict[str, Any]]) -> List[Dict[str, Any] "updated": item.get("updated", ""), "shared": _transform_shared(item.get("public", False), item.get("shared", [])), "fav": _get_markers(item.get("tags", [])), + "_views": (item.get("social") or {}).get("views", 0), "last_run_status": item.get("last_run_status", ""), "last_run_time": item.get("last_run_time", ""), "run_count": item.get("run_count", 0), @@ -357,6 +405,7 @@ def _transform_jobs_response(items: List[Dict[str, Any]]) -> List[Dict[str, Any] "current_step": item.get("current_step"), "schedule": item.get("schedule", ""), "triggers": item.get("triggers", []), + **_aggregate_activity(item), } result.append(transformed) return result @@ -1283,3 +1332,305 @@ def list_org_group_vis_gql(gql: NovemGQL, org_id: str, group_id: str, vis_type: variables = {"orgId": org_id} data = gql._query(LIST_ORG_GROUP_VIS_QUERY, variables) return _transform_org_group_vis_response(data, group_id, vis_type) + + +# --- Topics / Comments --- + +_COMMENT_FIELDS = """ + comment_id + slug + message + depth + deleted + edited + num_replies + likes + dislikes + my_reaction + created + updated + creator { username } +""" + + +def _build_comment_fragment(depth: int = 4) -> str: + """Build a nested comment/replies fragment to the given depth.""" + fragment = _COMMENT_FIELDS + for _ in range(depth): + fragment = f""" + {_COMMENT_FIELDS} + replies {{{fragment} + }}""" + return fragment + + +_TOPICS_QUERY_TPL = """ +query GetTopics($id: ID!, $author: String) {{ + {vis_type}(id: $id, author: $author) {{ + topics {{ + topic_id + slug + message + audience + status + num_comments + likes + dislikes + my_reaction + edited + created + updated + creator {{ username }} + comments {{{comment_fragment} + }} + }} + }} +}} +""" + + +def _build_topics_query(vis_type: str, depth: int = 3) -> str: + """Build a topics query for a given vis type (plots, grids, mails, etc.).""" + comment_fragment = _build_comment_fragment(depth) + return _TOPICS_QUERY_TPL.format(vis_type=vis_type, comment_fragment=comment_fragment) + + +def _has_truncated_replies(comments: List[Dict[str, Any]]) -> bool: + """Check if any comment has num_replies > 0 but empty replies list.""" + for c in comments: + replies = c.get("replies", []) or [] + if c.get("num_replies", 0) > 0 and not replies: + return True + if replies and _has_truncated_replies(replies): + return True + return False + + +def fetch_topics_gql(gql: NovemGQL, vis_type: str, vis_id: str, author: Optional[str] = None) -> List[Dict[str, Any]]: + """Fetch topics and comments, deepening the query if threads are truncated.""" + variables: Dict[str, Any] = {"id": vis_id} + if author: + variables["author"] = author + + depth = 3 + max_depth = 12 + topics: List[Dict[str, Any]] = [] + + while depth <= max_depth: + query = _build_topics_query(vis_type, depth=depth) + data = gql._query(query, variables) + items = data.get(vis_type, []) + if not items: + return [] + topics = items[0].get("topics", []) + + # Check if any topic has truncated comment trees + truncated = any(_has_truncated_replies(t.get("comments", [])) for t in topics) + if not truncated: + break + depth += 3 + + return topics + + +def _relative_time(dt: datetime.datetime) -> str: + """Return a human-friendly relative time string.""" + now = datetime.datetime.now(datetime.timezone.utc) + delta = now - dt + seconds = int(delta.total_seconds()) + + if seconds < 60: + return "just now" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + days = hours // 24 + if days < 30: + return f"{days}d ago" + return dt.strftime("%b %d, %Y") + + +def _visible_len(s: str) -> int: + """Return the visible length of a string, ignoring ANSI escape codes.""" + return len(re.sub(r"\033\[[0-9;]*m", "", s)) + + +def _wrap_text(text: str, prefix: str, width: int) -> List[str]: + """Wrap text to fit within width, prepending prefix to each line.""" + indent_width = _visible_len(prefix) + available = width - indent_width + if available < 20: + available = 20 + + result: List[str] = [] + for line in text.splitlines(): + if not line: + result.append(prefix) + else: + wrapped = textwrap.wrap(line, width=available) or [""] + for wl in wrapped: + result.append(f"{prefix}{wl}") + return result + + +def _get_term_width() -> int: + """Get terminal width, capped at 120.""" + return min(120, shutil.get_terminal_size().columns) + + +def _render_comment( + comment: Dict[str, Any], prefix: str, connector: str, child_prefix: str, width: int = 0, me: str = "" +) -> str: + """Render a single comment and its replies as a tree.""" + if not width: + width = _get_term_width() + + lines: List[str] = [] + + username = comment.get("creator", {}).get("username", "?") + message = comment.get("message", "") or "" + deleted = comment.get("deleted", False) + edited = comment.get("edited", False) + created_str = comment.get("created", "") + + # Timestamp + ts = "" + dt = parse_api_datetime(created_str) + if dt: + ts = _relative_time(dt) + + # Markers + markers: List[str] = [] + if edited: + markers.append("edited") + if deleted: + markers.append("deleted") + marker_str = f" {cl.FGGRAY}({', '.join(markers)}){cl.ENDC}" if markers else "" + + # Reactions + reactions: List[str] = [] + likes = comment.get("likes", 0) + dislikes = comment.get("dislikes", 0) + if likes: + reactions.append(f"+{likes}") + if dislikes: + reactions.append(f"-{dislikes}") + reaction_str = f" {cl.FGGRAY}[{' '.join(reactions)}]{cl.ENDC}" if reactions else "" + + # Header line + user_color = cl.WARNING if me and username == me else cl.OKCYAN + header = ( + f"{prefix}{connector}" + f"{user_color}@{username}{cl.ENDC}" + f" {cl.FGGRAY}·{cl.ENDC} " + f"{cl.FGGRAY}{ts}{cl.ENDC}" + f"{marker_str}{reaction_str}" + ) + lines.append(header) + + # Message body + body_prefix = f"{prefix}{child_prefix}" + if deleted: + lines.append(f"{body_prefix}{cl.FGGRAY}[deleted]{cl.ENDC}") + elif message: + lines.extend(_wrap_text(message, body_prefix, width)) + + # Replies + replies = comment.get("replies", []) or [] + for i, reply in enumerate(replies): + is_last = i == len(replies) - 1 + rc = "└ " if is_last else "├ " + rp = " " if is_last else "│ " + lines.append(_render_comment(reply, f"{prefix}{child_prefix}", rc, rp, width, me=me)) + + return "\n".join(lines) + + +def render_topics(topics: List[Dict[str, Any]], me: str = "") -> str: + """Render a list of topics with their comment trees.""" + colors() + + if not topics: + return f"{cl.FGGRAY}No topics{cl.ENDC}" + + width = _get_term_width() + parts: List[str] = [] + + for topic in topics: + lines: List[str] = [] + + username = topic.get("creator", {}).get("username", "?") + message = topic.get("message", "") or "" + audience = topic.get("audience", "") + status = topic.get("status", "") + num_comments = topic.get("num_comments", 0) + edited = topic.get("edited", False) + created_str = topic.get("created", "") + + # Timestamp + ts = "" + dt = parse_api_datetime(created_str) + if dt: + ts = _relative_time(dt) + + # Metadata tags + tags: List[str] = [] + if audience: + tags.append(audience) + if status and status != "active": + tags.append(status) + tag_str = f" {cl.FGGRAY}({', '.join(tags)}){cl.ENDC}" if tags else "" + + # Reactions + reactions: List[str] = [] + likes = topic.get("likes", 0) + dislikes = topic.get("dislikes", 0) + if likes: + reactions.append(f"+{likes}") + if dislikes: + reactions.append(f"-{dislikes}") + reaction_str = f" {cl.FGGRAY}[{' '.join(reactions)}]{cl.ENDC}" if reactions else "" + + edited_str = f" {cl.FGGRAY}(edited){cl.ENDC}" if edited else "" + + comment_count = f" {cl.FGGRAY}· {num_comments} comment{'s' if num_comments != 1 else ''}{cl.ENDC}" + + # Topic header + user_color = cl.WARNING if me and username == me else cl.OKCYAN + header = ( + f"{cl.BOLD}┌{cl.ENDC} " + f"{user_color}@{username}{cl.ENDC}" + f" {cl.FGGRAY}·{cl.ENDC} " + f"{cl.FGGRAY}{ts}{cl.ENDC}" + f"{tag_str}{edited_str}{reaction_str}{comment_count}" + ) + lines.append(header) + + # Topic body + body_prefix = f"{cl.BOLD}│{cl.ENDC} " + if message: + lines.extend(_wrap_text(message, body_prefix, width)) + + # Comments + comments = topic.get("comments", []) or [] + for i, comment in enumerate(comments): + is_last = i == len(comments) - 1 + connector = "├ " if not is_last else "└ " + child_prefix = "│ " if not is_last else " " + lines.append(_render_comment(comment, body_prefix, connector, child_prefix, width, me=me)) + + if not comments: + lines.append(f"{cl.BOLD}└{cl.ENDC} {cl.FGGRAY}(no comments){cl.ENDC}") + + # Cap the last line: replace topic-level bold │ with bold └ + topic_text = "\n".join(lines) + all_lines = topic_text.split("\n") + bold_pipe = f"{cl.BOLD}│{cl.ENDC}" + bold_cap = f"{cl.BOLD}└{cl.ENDC}" + all_lines[-1] = all_lines[-1].replace(bold_pipe, bold_cap, 1) + parts.append("\n".join(all_lines)) + + return "\n\n".join(parts) diff --git a/novem/cli/setup.py b/novem/cli/setup.py index 2c17edb..3bfde60 100644 --- a/novem/cli/setup.py +++ b/novem/cli/setup.py @@ -299,6 +299,15 @@ def setup(raw_args: Any = None) -> Tuple[Any, Dict[str, str]]: help="specify entity to view vis for, @username, +org, @username~usergroup or +org~orggroup are supported", ) + vis.add_argument( + "--comments", + dest="comments", + action="store_true", + required=False, + default=False, + help="show topics and comments for the visualisation", + ) + vis.add_argument( "--tree", metavar=("PATH"), diff --git a/novem/cli/vis.py b/novem/cli/vis.py index 90281eb..94c627c 100644 --- a/novem/cli/vis.py +++ b/novem/cli/vis.py @@ -24,6 +24,69 @@ ) +def _compact_num(n: int) -> str: + """Format a number compactly: 0→'-', 1–999 as-is, 1k, 1.2k, 1M, etc.""" + if not n: + return "-" + if n < 1000: + return str(n) + if n < 100_000: + v = n / 1000 + return f"{v:.1f}k".replace(".0k", "k") + if n < 1_000_000: + return f"{n // 1000}k" + v = n / 1_000_000 + return f"{v:.1f}M".replace(".0M", "M") + + +def _format_activity(plist: List[Dict[str, Any]]) -> None: + """Pre-format the _activity column with right-aligned, evenly-spaced components.""" + # Compute compact strings for each row + rows = [] + for p in plist: + c = _compact_num(p.get("_comments", 0)) + lk = _compact_num(p.get("_likes", 0)) + d = _compact_num(p.get("_dislikes", 0)) + rows.append((c, lk, d)) + + if not rows: + return + + # Max width per component + mc = max(len(r[0]) for r in rows) + ml = max(len(r[1]) for r in rows) + md = max(len(r[2]) for r in rows) + + # Total width: at least header "Activity" (8), at least content + 2 gaps + total = max(8, mc + ml + md + 2) + + # Distribute leftover space evenly across the 2 gaps + gap_total = total - mc - ml - md + gap1 = (gap_total + 1) // 2 # first gap gets extra char if odd + gap2 = gap_total // 2 + + s1 = " " * gap1 + s2 = " " * gap2 + + for p, (c, lk, d) in zip(plist, rows): + cs = c.rjust(mc) + ls = lk.rjust(ml) + ds = d.rjust(md) + p["_activity"] = f"{cs}{s1}{cl.OKBLUE}{ls}{cl.ENDFGC}{s2}{cl.FAIL}{ds}{cl.ENDFGC}" + + +def _format_views(plist: List[Dict[str, Any]]) -> None: + """Pre-format the _views column as a right-aligned compact number.""" + if not plist: + return + + rows = [_compact_num(p.get("_views", 0)) for p in plist] + mw = max(max(len(r) for r in rows), len("Views")) + + for p, r in zip(plist, rows): + p["_views_fmt"] = r.rjust(mw) + + def list_vis(args: Dict[str, Any], type: str) -> None: colors() # get current plot list @@ -166,6 +229,18 @@ def fav_fmt(markers: str, cl: cl) -> str: "fmt": share_fmt, "overflow": "keep", }, + { + "key": "_activity", + "header": "Activity", + "type": "text", + "overflow": "keep", + }, + { + "key": "_views_fmt", + "header": "Views", + "type": "text", + "overflow": "keep", + }, { "key": "name", "header": "Name", @@ -198,6 +273,9 @@ def fav_fmt(markers: str, cl: cl) -> str: if dt: p["updated"] = format_datetime_local(dt) + _format_activity(plist) + _format_views(plist) + striped: bool = config.get("cli_striped", False) ppl = pretty_format(plist, ppo, striped=striped) @@ -831,6 +909,18 @@ def fav_fmt(markers: str, cl: cl) -> str: "fmt": share_fmt, "overflow": "keep", }, + { + "key": "_activity", + "header": "Activity", + "type": "text", + "overflow": "keep", + }, + { + "key": "_views_fmt", + "header": "Views", + "type": "text", + "overflow": "keep", + }, { "key": "triggers", "header": "Trigger", @@ -884,6 +974,9 @@ def fav_fmt(markers: str, cl: cl) -> str: # Last run - format last_run_time as relative time p["_last_run"] = _format_time_ago(p.get("last_run_time", "")) + _format_activity(plist) + _format_views(plist) + # Calculate max widths for right-aligned columns (must be at least header width) max_steps = max(max((len(p["_steps"]) for p in plist), default=0), len("Steps")) max_runs = max(max((len(p["run_count"]) for p in plist), default=0), len("Runs")) diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py new file mode 100644 index 0000000..b455c7c --- /dev/null +++ b/tests/test_cli_comments.py @@ -0,0 +1,873 @@ +import json +import re + +from novem.cli.gql import ( + _aggregate_activity, + _build_comment_fragment, + _build_topics_query, + _get_gql_endpoint, + _has_truncated_replies, + _relative_time, + _visible_len, + _wrap_text, + render_topics, +) +from novem.cli.vis import _compact_num, _format_activity, _format_views +from novem.utils import API_ROOT, colors + +from .utils import write_config + +gql_endpoint = _get_gql_endpoint(API_ROOT) + +auth_req = { + "username": "demouser", + "password": "demopass", + "token_name": "demotoken", + "token_description": "test token", +} + + +def _strip_ansi(s: str) -> str: + """Remove ANSI escape codes for easier assertion.""" + return re.sub(r"\033\[[0-9;]*m", "", s) + + +# --- Unit tests for helpers --- + + +class TestVisibleLen: + def test_plain_text(self) -> None: + assert _visible_len("hello") == 5 + + def test_with_ansi(self) -> None: + assert _visible_len("\033[96m@alice\033[0m") == 6 + + def test_empty(self) -> None: + assert _visible_len("") == 0 + + def test_multiple_codes(self) -> None: + s = "\033[1m┌\033[0m \033[96m@bob\033[0m \033[38;5;246m· 3h ago\033[0m" + assert _visible_len(s) == len("┌ @bob · 3h ago") + + +class TestRelativeTime: + def test_just_now(self) -> None: + import datetime + + now = datetime.datetime.now(datetime.timezone.utc) + assert _relative_time(now) == "just now" + + def test_minutes(self) -> None: + import datetime + + dt = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=5) + assert _relative_time(dt) == "5m ago" + + def test_hours(self) -> None: + import datetime + + dt = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=3) + assert _relative_time(dt) == "3h ago" + + def test_days(self) -> None: + import datetime + + dt = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7) + assert _relative_time(dt) == "7d ago" + + def test_old_date(self) -> None: + import datetime + + dt = datetime.datetime(2024, 1, 15, tzinfo=datetime.timezone.utc) + result = _relative_time(dt) + assert "Jan 15, 2024" == result + + +class TestWrapText: + def test_short_text(self) -> None: + lines = _wrap_text("hello", "│ ", 80) + assert lines == ["│ hello"] + + def test_wraps_long_text(self) -> None: + text = "word " * 30 # 150 chars + lines = _wrap_text(text.strip(), "│ ", 40) + assert len(lines) > 1 + for line in lines: + assert line.startswith("│ ") + + def test_preserves_newlines(self) -> None: + lines = _wrap_text("line one\nline two", "│ ", 80) + assert lines == ["│ line one", "│ line two"] + + def test_empty_lines(self) -> None: + lines = _wrap_text("above\n\nbelow", "│ ", 80) + assert lines == ["│ above", "│ ", "│ below"] + + def test_minimum_width(self) -> None: + # Even with a very wide prefix, should use at least 20 chars + lines = _wrap_text("some text", "x" * 100, 50) + assert len(lines) >= 1 + + +# --- Unit tests for query building --- + + +class TestBuildCommentFragment: + def test_contains_comment_fields(self) -> None: + frag = _build_comment_fragment(1) + assert "comment_id" in frag + assert "message" in frag + assert "creator { username }" in frag + assert "replies" in frag + + def test_nesting_depth(self) -> None: + frag = _build_comment_fragment(3) + # Should have 3 levels of "replies {" + assert frag.count("replies {") == 3 + + +class TestBuildTopicsQuery: + def test_plots_query(self) -> None: + q = _build_topics_query("plots") + assert "plots(id: $id, author: $author)" in q + assert "topics {" in q + assert "comments {" in q + + def test_grids_query(self) -> None: + q = _build_topics_query("grids") + assert "grids(id: $id, author: $author)" in q + + def test_mails_query(self) -> None: + q = _build_topics_query("mails") + assert "mails(id: $id, author: $author)" in q + + def test_custom_depth(self) -> None: + q = _build_topics_query("plots", depth=6) + assert q.count("replies {") == 6 + + def test_default_depth(self) -> None: + q = _build_topics_query("plots") + assert q.count("replies {") == 3 + + +# --- Unit tests for _has_truncated_replies --- + + +class TestHasTruncatedReplies: + def test_empty(self) -> None: + assert _has_truncated_replies([]) is False + + def test_no_truncation(self) -> None: + comments = [_make_comment(num_replies=0, replies=[])] + assert _has_truncated_replies(comments) is False + + def test_replies_present(self) -> None: + reply = _make_comment(comment_id=2, num_replies=0, replies=[]) + comments = [_make_comment(num_replies=1, replies=[reply])] + assert _has_truncated_replies(comments) is False + + def test_truncated_leaf(self) -> None: + # num_replies > 0 but no replies data + comments = [_make_comment(num_replies=3, replies=[])] + assert _has_truncated_replies(comments) is True + + def test_truncated_nested(self) -> None: + # Truncation deep in the tree + deep = _make_comment(comment_id=3, num_replies=2, replies=[]) + mid = _make_comment(comment_id=2, num_replies=1, replies=[deep]) + comments = [_make_comment(num_replies=1, replies=[mid])] + assert _has_truncated_replies(comments) is True + + def test_mixed(self) -> None: + ok = _make_comment(comment_id=1, num_replies=0, replies=[]) + truncated = _make_comment(comment_id=2, num_replies=5, replies=[]) + assert _has_truncated_replies([ok, truncated]) is True + + +# --- Unit tests for render_topics --- + + +def _make_topic( + topic_id: int = 1, + username: str = "alice", + message: str = "Hello world", + audience: str = "public", + status: str = "active", + num_comments: int = 0, + likes: int = 0, + dislikes: int = 0, + edited: bool = False, + created: str = "Mon, 10 Feb 2026 12:00:00 UTC", + comments: list = None, +) -> dict: + return { + "topic_id": topic_id, + "slug": f"topic-{topic_id}", + "message": message, + "audience": audience, + "status": status, + "num_comments": num_comments, + "likes": likes, + "dislikes": dislikes, + "edited": edited, + "created": created, + "updated": created, + "creator": {"username": username}, + "comments": comments or [], + } + + +def _make_comment( + comment_id: int = 1, + username: str = "bob", + message: str = "Nice post", + depth: int = 0, + deleted: bool = False, + edited: bool = False, + likes: int = 0, + dislikes: int = 0, + num_replies: int = -1, + created: str = "Mon, 10 Feb 2026 12:05:00 UTC", + replies: list = None, +) -> dict: + return { + "comment_id": comment_id, + "slug": f"comment-{comment_id}", + "message": message, + "depth": depth, + "deleted": deleted, + "edited": edited, + "num_replies": num_replies if num_replies >= 0 else (len(replies) if replies else 0), + "likes": likes, + "dislikes": dislikes, + "created": created, + "updated": created, + "creator": {"username": username}, + "replies": replies or [], + } + + +class TestRenderTopics: + def test_no_topics(self) -> None: + result = _strip_ansi(render_topics([])) + assert result == "No topics" + + def test_topic_header(self) -> None: + topics = [_make_topic(username="alice", audience="public", num_comments=0)] + result = _strip_ansi(render_topics(topics)) + assert "@alice" in result + assert "(public)" in result + assert "0 comments" in result + + def test_topic_body(self) -> None: + topics = [_make_topic(message="This is the topic body")] + result = _strip_ansi(render_topics(topics)) + assert "This is the topic body" in result + + def test_no_comments_message(self) -> None: + topics = [_make_topic(comments=[])] + result = _strip_ansi(render_topics(topics)) + assert "(no comments)" in result + + def test_single_comment(self) -> None: + comment = _make_comment(username="bob", message="Great work") + topics = [_make_topic(num_comments=1, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + assert "@bob" in result + assert "Great work" in result + assert "1 comment" in result + # Should not say "1 comments" + assert "1 comments" not in result + + def test_plural_comments(self) -> None: + comments = [ + _make_comment(comment_id=1, username="bob", message="First"), + _make_comment(comment_id=2, username="carol", message="Second"), + ] + topics = [_make_topic(num_comments=2, comments=comments)] + result = _strip_ansi(render_topics(topics)) + assert "2 comments" in result + assert "@bob" in result + assert "@carol" in result + + def test_nested_replies(self) -> None: + reply = _make_comment(comment_id=2, username="carol", message="Reply here", depth=1) + comment = _make_comment(comment_id=1, username="bob", message="Top level", replies=[reply]) + topics = [_make_topic(num_comments=2, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + assert "@bob" in result + assert "Top level" in result + assert "@carol" in result + assert "Reply here" in result + + def test_deep_nesting(self) -> None: + c3 = _make_comment(comment_id=3, username="dave", message="Deep reply", depth=2) + c2 = _make_comment(comment_id=2, username="carol", message="Mid reply", depth=1, replies=[c3]) + c1 = _make_comment(comment_id=1, username="bob", message="Top", replies=[c2]) + topics = [_make_topic(num_comments=3, comments=[c1])] + result = _strip_ansi(render_topics(topics)) + assert "@dave" in result + assert "Deep reply" in result + + def test_deleted_comment(self) -> None: + comment = _make_comment(deleted=True, message="secret") + topics = [_make_topic(num_comments=1, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + assert "[deleted]" in result + # Deleted message body should not be shown + assert "secret" not in result + + def test_edited_markers(self) -> None: + comment = _make_comment(edited=True) + topics = [_make_topic(edited=True, num_comments=1, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + # Both topic and comment should show edited + assert result.count("(edited)") == 2 + + def test_reactions(self) -> None: + comment = _make_comment(likes=5, dislikes=2) + topics = [_make_topic(likes=3, num_comments=1, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + assert "[+3]" in result + assert "[+5 -2]" in result + + def test_archived_status(self) -> None: + topics = [_make_topic(status="archived")] + result = _strip_ansi(render_topics(topics)) + assert "archived" in result + + def test_active_status_not_shown(self) -> None: + topics = [_make_topic(status="active", audience="public")] + result = _strip_ansi(render_topics(topics)) + # "active" should be suppressed, only "public" shown + assert "active" not in result + + def test_multiple_topics(self) -> None: + topics = [ + _make_topic(topic_id=1, username="alice", message="First topic"), + _make_topic(topic_id=2, username="bob", message="Second topic"), + ] + result = _strip_ansi(render_topics(topics)) + assert "@alice" in result + assert "First topic" in result + assert "@bob" in result + assert "Second topic" in result + + def test_multiline_message(self) -> None: + topics = [_make_topic(message="Line one\nLine two\nLine three")] + result = _strip_ansi(render_topics(topics)) + assert "Line one" in result + assert "Line two" in result + assert "Line three" in result + + def test_no_bottom_separator_line(self) -> None: + comment = _make_comment() + topics = [_make_topic(num_comments=1, comments=[comment])] + result = _strip_ansi(render_topics(topics)) + # Should not have a full-width separator line + assert "──────" not in result + + def test_dense_output_no_blank_lines_between_siblings(self) -> None: + comments = [ + _make_comment(comment_id=1, username="bob", message="First"), + _make_comment(comment_id=2, username="carol", message="Second"), + ] + topics = [_make_topic(num_comments=2, comments=comments)] + result = _strip_ansi(render_topics(topics)) + lines = result.split("\n") + # No empty lines within the topic (except between topics) + for line in lines: + assert line.strip() != "" or line == "" # Allow only between topics + + def test_me_topic_creator_highlighted(self) -> None: + """Current user's username in topic header should use WARNING color.""" + colors() + from novem.utils import cl + + topics = [_make_topic(username="alice")] + result = render_topics(topics, me="alice") + # The topic creator should be colored with WARNING (orange) + assert f"{cl.WARNING}@alice{cl.ENDC}" in result + + def test_me_comment_creator_highlighted(self) -> None: + """Current user's username in comment header should use WARNING color.""" + colors() + from novem.utils import cl + + comment = _make_comment(username="alice", message="My comment") + topics = [_make_topic(username="bob", num_comments=1, comments=[comment])] + result = render_topics(topics, me="alice") + # Comment by "me" should be orange + assert f"{cl.WARNING}@alice{cl.ENDC}" in result + # Topic by someone else should be cyan + assert f"{cl.OKCYAN}@bob{cl.ENDC}" in result + + def test_me_nested_reply_highlighted(self) -> None: + """Current user's username in nested reply should use WARNING color.""" + colors() + from novem.utils import cl + + reply = _make_comment(comment_id=2, username="alice", message="My reply", depth=1) + comment = _make_comment(comment_id=1, username="bob", message="Top", replies=[reply]) + topics = [_make_topic(username="carol", num_comments=2, comments=[comment])] + result = render_topics(topics, me="alice") + assert f"{cl.WARNING}@alice{cl.ENDC}" in result + assert f"{cl.OKCYAN}@bob{cl.ENDC}" in result + assert f"{cl.OKCYAN}@carol{cl.ENDC}" in result + + def test_no_me_all_cyan(self) -> None: + """When me is empty, all usernames should use OKCYAN.""" + colors() + from novem.utils import cl + + comment = _make_comment(username="bob") + topics = [_make_topic(username="alice", num_comments=1, comments=[comment])] + result = render_topics(topics, me="") + assert f"{cl.OKCYAN}@alice{cl.ENDC}" in result + assert f"{cl.OKCYAN}@bob{cl.ENDC}" in result + # WARNING escape code should not appear (check raw code, not cl.WARNING which may be empty) + assert "\033[93m" not in result + + +# --- CLI integration tests --- + + +def test_comments_empty(cli, requests_mock, fs) -> None: + """Test --comments on a plot with no topics.""" + write_config(auth_req) + + gql_response = {"data": {"plots": [{"topics": []}]}} + requests_mock.register_uri("POST", gql_endpoint, text=lambda r, c: json.dumps(gql_response)) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/plots/my_plot", status_code=201) + + out, err = cli("-p", "my_plot", "--comments") + assert "No topics" in out + + +def test_comments_with_data(cli, requests_mock, fs) -> None: + """Test --comments shows topic and comment content.""" + write_config(auth_req) + + gql_response = { + "data": { + "plots": [ + { + "topics": [ + { + "topic_id": 1, + "slug": "test-topic", + "message": "Discussion about the chart", + "audience": "public", + "status": "active", + "num_comments": 1, + "likes": 0, + "dislikes": 0, + "edited": False, + "created": "Mon, 10 Feb 2026 12:00:00 UTC", + "updated": "Mon, 10 Feb 2026 12:00:00 UTC", + "creator": {"username": "alice"}, + "comments": [ + { + "comment_id": 1, + "slug": "comment-1", + "message": "Looks good!", + "depth": 0, + "deleted": False, + "edited": False, + "num_replies": 0, + "likes": 0, + "dislikes": 0, + "created": "Mon, 10 Feb 2026 12:05:00 UTC", + "updated": "Mon, 10 Feb 2026 12:05:00 UTC", + "creator": {"username": "bob"}, + "replies": [], + } + ], + } + ] + } + ] + } + } + requests_mock.register_uri("POST", gql_endpoint, text=lambda r, c: json.dumps(gql_response)) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/plots/my_plot", status_code=201) + + out, err = cli("-p", "my_plot", "--comments") + plain = _strip_ansi(out) + assert "@alice" in plain + assert "Discussion about the chart" in plain + assert "@bob" in plain + assert "Looks good!" in plain + + +def test_comments_sends_correct_gql_query(cli, requests_mock, fs) -> None: + """Test --comments sends a properly formed GQL query with vis ID.""" + write_config(auth_req) + + captured_query = None + + def capture_gql(request, context): + nonlocal captured_query + body = request.json() + captured_query = body.get("query", "") + return json.dumps({"data": {"plots": [{"topics": []}]}}) + + requests_mock.register_uri("POST", gql_endpoint, text=capture_gql) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/plots/test_chart", status_code=201) + + cli("-p", "test_chart", "--comments") + + assert captured_query is not None + assert "plots(id: $id, author: $author)" in captured_query + assert "topics" in captured_query + assert "comments" in captured_query + + +def test_comments_grid(cli, requests_mock, fs) -> None: + """Test --comments works with grids too.""" + write_config(auth_req) + + captured_query = None + + def capture_gql(request, context): + nonlocal captured_query + body = request.json() + captured_query = body.get("query", "") + return json.dumps({"data": {"grids": [{"topics": []}]}}) + + requests_mock.register_uri("POST", gql_endpoint, text=capture_gql) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/grids/my_grid", status_code=201) + + out, err = cli("-g", "my_grid", "--comments") + assert "No topics" in out + assert captured_query is not None + assert "grids(id: $id, author: $author)" in captured_query + + +def test_comments_mail(cli, requests_mock, fs) -> None: + """Test --comments works with mails too.""" + write_config(auth_req) + + def return_gql(request, context): + return json.dumps({"data": {"mails": [{"topics": []}]}}) + + requests_mock.register_uri("POST", gql_endpoint, text=return_gql) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/mails/my_mail", status_code=201) + + out, err = cli("-m", "my_mail", "--comments") + assert "No topics" in out + + +def test_comments_adaptive_depth(cli, requests_mock, fs) -> None: + """Test --comments re-fetches with deeper query when replies are truncated.""" + write_config(auth_req) + + call_count = 0 + + def adaptive_gql(request, context): + nonlocal call_count + call_count += 1 + body = request.json() + query = body.get("query", "") + depth = query.count("replies {") + + if depth <= 3: + # First fetch: return truncated comment (num_replies > 0 but no replies) + return json.dumps( + { + "data": { + "plots": [ + { + "topics": [ + { + "topic_id": 1, + "slug": "t1", + "message": "Topic", + "audience": "public", + "status": "active", + "num_comments": 2, + "likes": 0, + "dislikes": 0, + "my_reaction": None, + "edited": False, + "created": "Mon, 10 Feb 2026 12:00:00 UTC", + "updated": "Mon, 10 Feb 2026 12:00:00 UTC", + "creator": {"username": "alice"}, + "comments": [ + { + "comment_id": 1, + "slug": "c1", + "message": "Deep thread", + "depth": 0, + "deleted": False, + "edited": False, + "num_replies": 1, + "likes": 0, + "dislikes": 0, + "my_reaction": None, + "created": "Mon, 10 Feb 2026 12:05:00 UTC", + "updated": "Mon, 10 Feb 2026 12:05:00 UTC", + "creator": {"username": "bob"}, + "replies": [], + } + ], + } + ] + } + ] + } + } + ) + else: + # Deeper fetch: return full tree with replies resolved + return json.dumps( + { + "data": { + "plots": [ + { + "topics": [ + { + "topic_id": 1, + "slug": "t1", + "message": "Topic", + "audience": "public", + "status": "active", + "num_comments": 2, + "likes": 0, + "dislikes": 0, + "my_reaction": None, + "edited": False, + "created": "Mon, 10 Feb 2026 12:00:00 UTC", + "updated": "Mon, 10 Feb 2026 12:00:00 UTC", + "creator": {"username": "alice"}, + "comments": [ + { + "comment_id": 1, + "slug": "c1", + "message": "Deep thread", + "depth": 0, + "deleted": False, + "edited": False, + "num_replies": 1, + "likes": 0, + "dislikes": 0, + "my_reaction": None, + "created": "Mon, 10 Feb 2026 12:05:00 UTC", + "updated": "Mon, 10 Feb 2026 12:05:00 UTC", + "creator": {"username": "bob"}, + "replies": [ + { + "comment_id": 2, + "slug": "c2", + "message": "Deep reply", + "depth": 1, + "deleted": False, + "edited": False, + "num_replies": 0, + "likes": 0, + "dislikes": 0, + "my_reaction": None, + "created": "Mon, 10 Feb 2026 12:10:00 UTC", + "updated": "Mon, 10 Feb 2026 12:10:00 UTC", + "creator": {"username": "carol"}, + "replies": [], + } + ], + } + ], + } + ] + } + ] + } + } + ) + + requests_mock.register_uri("POST", gql_endpoint, text=adaptive_gql) + requests_mock.register_uri("PUT", "https://api.novem.io/v1/vis/plots/deep_plot", status_code=201) + + out, err = cli("-p", "deep_plot", "--comments") + plain = _strip_ansi(out) + + # Should have fetched twice (depth=3 truncated, depth=6 resolved) + assert call_count == 2 + # Deep reply should be present in output + assert "Deep reply" in plain + assert "@carol" in plain + + +# --- Unit tests for _compact_num --- + + +class TestCompactNum: + def test_zero(self) -> None: + assert _compact_num(0) == "-" + + def test_small(self) -> None: + assert _compact_num(1) == "1" + assert _compact_num(42) == "42" + assert _compact_num(999) == "999" + + def test_thousands(self) -> None: + assert _compact_num(1000) == "1k" + assert _compact_num(1200) == "1.2k" + assert _compact_num(1050) == "1.1k" + assert _compact_num(9999) == "10k" + assert _compact_num(10000) == "10k" + assert _compact_num(99999) == "100k" + + def test_large(self) -> None: + assert _compact_num(100000) == "100k" + assert _compact_num(999999) == "999k" + assert _compact_num(1000000) == "1M" + assert _compact_num(1500000) == "1.5M" + assert _compact_num(10000000) == "10M" + + +# --- Unit tests for _aggregate_activity --- + + +class TestAggregateActivity: + def test_no_topics(self) -> None: + result = _aggregate_activity({}) + assert result == {"_comments": 0, "_likes": 0, "_dislikes": 0} + + def test_empty_topics(self) -> None: + result = _aggregate_activity({"topics": []}) + assert result == {"_comments": 0, "_likes": 0, "_dislikes": 0} + + def test_single_topic(self) -> None: + result = _aggregate_activity({"topics": [{"num_comments": 5, "num_likes": 3, "num_dislikes": 1}]}) + assert result == {"_comments": 6, "_likes": 3, "_dislikes": 1} + + def test_multiple_topics(self) -> None: + result = _aggregate_activity( + { + "topics": [ + {"num_comments": 2, "num_likes": 1, "num_dislikes": 0}, + {"num_comments": 3, "num_likes": 4, "num_dislikes": 2}, + ] + } + ) + assert result == {"_comments": 7, "_likes": 5, "_dislikes": 2} + + +# --- Unit tests for _format_activity alignment --- + + +class TestFormatActivity: + def _plain(self, p: dict) -> str: + """Strip ANSI from the _activity value.""" + return _strip_ansi(p["_activity"]) + + def test_all_zeros(self) -> None: + colors() + plist = [{"_comments": 0, "_likes": 0, "_dislikes": 0}] + _format_activity(plist) + # 3 single-char components, total=8, gaps=5 → gap1=3, gap2=2 + assert self._plain(plist[0]) == "- - -" + + def test_single_digits(self) -> None: + colors() + plist = [{"_comments": 1, "_likes": 2, "_dislikes": 3}] + _format_activity(plist) + assert self._plain(plist[0]) == "1 2 3" + + def test_mixed_widths_align(self) -> None: + colors() + plist = [ + {"_comments": 1, "_likes": 50, "_dislikes": 0}, + {"_comments": 100, "_likes": 1, "_dislikes": 10}, + ] + _format_activity(plist) + p0 = self._plain(plist[0]) + p1 = self._plain(plist[1]) + # All rows should have the same visible width + assert len(p0) == len(p1) + # Components right-aligned within sub-columns, gaps evenly distributed + assert p0 == " 1 50 -" + assert p1 == "100 1 10" + + def test_thousands(self) -> None: + colors() + plist = [ + {"_comments": 1000, "_likes": 50, "_dislikes": 0}, + {"_comments": 1, "_likes": 10000, "_dislikes": 5}, + ] + _format_activity(plist) + p0 = self._plain(plist[0]) + p1 = self._plain(plist[1]) + assert len(p0) == len(p1) + assert "1k" in p0 + assert "10k" in p1 + + def test_millions(self) -> None: + colors() + plist = [ + {"_comments": 1000000, "_likes": 1500000, "_dislikes": 100}, + {"_comments": 50, "_likes": 1, "_dislikes": 10000000}, + ] + _format_activity(plist) + p0 = self._plain(plist[0]) + p1 = self._plain(plist[1]) + assert len(p0) == len(p1) + assert "1M" in p0 + assert "1.5M" in p0 + assert "10M" in p1 + + def test_wide_values_no_header_padding(self) -> None: + """When values are wider than 'Activity', no extra padding needed.""" + colors() + plist = [{"_comments": 10000, "_likes": 10000, "_dislikes": 10000}] + _format_activity(plist) + p = self._plain(plist[0]) + assert p == "10k 10k 10k" # 11 chars > 8 ("Activity"), no extra padding + + def test_empty_list(self) -> None: + colors() + plist: list = [] + _format_activity(plist) # should not raise + + +# --- Unit tests for _format_views --- + + +class TestFormatViews: + def test_zero_views(self) -> None: + colors() + plist = [{"_views": 0}] + _format_views(plist) + assert plist[0]["_views_fmt"].strip() == "-" + + def test_small_views(self) -> None: + colors() + plist = [{"_views": 42}] + _format_views(plist) + assert plist[0]["_views_fmt"].strip() == "42" + + def test_thousands(self) -> None: + colors() + plist = [{"_views": 1200}] + _format_views(plist) + assert plist[0]["_views_fmt"].strip() == "1.2k" + + def test_right_aligned(self) -> None: + colors() + plist = [{"_views": 5}, {"_views": 1200}] + _format_views(plist) + # Both should have same width (right-aligned) + assert len(plist[0]["_views_fmt"]) == len(plist[1]["_views_fmt"]) + assert plist[0]["_views_fmt"].endswith("5") + assert plist[1]["_views_fmt"].endswith("1.2k") + + def test_min_header_width(self) -> None: + """Column should be at least as wide as 'Views' (5 chars).""" + colors() + plist = [{"_views": 1}] + _format_views(plist) + assert len(plist[0]["_views_fmt"]) >= len("Views") + + def test_empty_list(self) -> None: + colors() + plist: list = [] + _format_views(plist) # should not raise diff --git a/tests/test_cli_grids.py b/tests/test_cli_grids.py index dcbb379..50bbd07 100644 --- a/tests/test_cli_grids.py +++ b/tests/test_cli_grids.py @@ -1,7 +1,8 @@ from functools import partial from novem.cli.gql import _get_gql_endpoint -from novem.utils import API_ROOT, format_datetime_local, parse_api_datetime, pretty_format +from novem.cli.vis import _format_activity, _format_views +from novem.utils import API_ROOT, colors, format_datetime_local, parse_api_datetime, pretty_format from .utils import write_config @@ -173,6 +174,7 @@ def test_grid_list(cli, requests_mock, fs): "summary": "Historical unemployment rate in the Nordic countries." " Data from IMFs World Economic Oulook published in October 2021" " Chart last updated as of 25 January 2022", + "_views": 0, }, { "id": "covid_us_state_breakdown", @@ -186,6 +188,7 @@ def test_grid_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "covid_us_trend", @@ -199,6 +202,7 @@ def test_grid_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "covid_us_trend_region", @@ -212,6 +216,7 @@ def test_grid_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "en_letter_frequency", @@ -225,6 +230,7 @@ def test_grid_list(cli, requests_mock, fs): " as published by the compilers. The chart above represents data" " taken from Pavel Micka's website, which cites Robert Lewand's" " Cryptological Mathematics.", + "_views": 0, }, { "id": "unemployment_noridc", @@ -237,6 +243,7 @@ def test_grid_list(cli, requests_mock, fs): "summary": "Historical unemployment rate in the Nordic " "countries. Data from IMFs World Economic Oulook published in" " October 2021 Chart last updated as of 25 January 2022", + "_views": 0, }, ] @@ -315,6 +322,18 @@ def fav_fmt(markers, cl): "type": "text", "overflow": "keep", }, + { + "key": "_activity", + "header": "Activity", + "type": "text", + "overflow": "keep", + }, + { + "key": "_views_fmt", + "header": "Views", + "type": "text", + "overflow": "keep", + }, { "key": "name", "header": "Name", @@ -341,11 +360,14 @@ def fav_fmt(markers, cl): "overflow": "truncate", }, ] + colors() plist = user_grid_list for p in plist: dt = parse_api_datetime(p["updated"]) if dt: p["updated"] = format_datetime_local(dt) + _format_activity(plist) + _format_views(plist) expected = pretty_format(plist, ppo) + "\n" diff --git a/tests/test_cli_plots.py b/tests/test_cli_plots.py index aa9a7cb..3d1a903 100644 --- a/tests/test_cli_plots.py +++ b/tests/test_cli_plots.py @@ -4,7 +4,8 @@ import pytest from novem.cli.gql import _get_gql_endpoint -from novem.utils import API_ROOT, format_datetime_local, parse_api_datetime, pretty_format +from novem.cli.vis import _format_activity, _format_views +from novem.utils import API_ROOT, colors, format_datetime_local, parse_api_datetime, pretty_format from tests.conftest import CliExit from .utils import write_config @@ -175,6 +176,7 @@ def test_plot_list(cli, requests_mock, fs): "summary": "Historical unemployment rate in the Nordic countries." " Data from IMFs World Economic Oulook published in October 2021" " Chart last updated as of 25 January 2022", + "_views": 0, }, { "id": "covid_us_state_breakdown", @@ -188,6 +190,7 @@ def test_plot_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "covid_us_trend", @@ -201,6 +204,7 @@ def test_plot_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "covid_us_trend_region", @@ -214,6 +218,7 @@ def test_plot_list(cli, requests_mock, fs): " capita broken down by US state. Raw data from the New York" " Times, calculations by Novem. Data last updated 23 November " "2021", + "_views": 0, }, { "id": "en_letter_frequency", @@ -227,6 +232,7 @@ def test_plot_list(cli, requests_mock, fs): " as published by the compilers. The chart above represents data" " taken from Pavel Micka's website, which cites Robert Lewand's" " Cryptological Mathematics.", + "_views": 0, }, { "id": "unemployment_noridc", @@ -239,6 +245,7 @@ def test_plot_list(cli, requests_mock, fs): "summary": "Historical unemployment rate in the Nordic " "countries. Data from IMFs World Economic Oulook published in" " October 2021 Chart last updated as of 25 January 2022", + "_views": 0, }, ] @@ -317,6 +324,18 @@ def fav_fmt(markers, cl): "type": "text", "overflow": "keep", }, + { + "key": "_activity", + "header": "Activity", + "type": "text", + "overflow": "keep", + }, + { + "key": "_views_fmt", + "header": "Views", + "type": "text", + "overflow": "keep", + }, { "key": "name", "header": "Name", @@ -343,11 +362,14 @@ def fav_fmt(markers, cl): "overflow": "truncate", }, ] + colors() plist = user_plot_list for p in plist: dt = parse_api_datetime(p["updated"]) if dt: p["updated"] = format_datetime_local(dt) + _format_activity(plist) + _format_views(plist) ppl = pretty_format(plist, ppo)