From 438067fd28f287fbae71b890c3d7df47ffadd5c2 Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:02:44 +0100 Subject: [PATCH] add interactive comment moderation TUI (closes #6) --- .editorconfig | 2 +- pyproject.toml | 1 + src/ytstudio/commands/comments.py | 9 ++ src/ytstudio/commands/moderate.py | 178 ++++++++++++++++++++++++++++++ uv.lock | 57 ++++++++++ 5 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/ytstudio/commands/moderate.py diff --git a/.editorconfig b/.editorconfig index e096323..6cf7d1d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,4 +12,4 @@ insert_final_newline = true max_line_length = 100 [*.{yml,yaml,toml}] -indent_size = 2 \ No newline at end of file +indent_size = 2 diff --git a/pyproject.toml b/pyproject.toml index 9e74851..bbc9a37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "google-auth-oauthlib>=1.0.0", "google-api-python-client>=2.0.0", "packaging>=21.0", + "textual>=7.5.0", ] [dependency-groups] diff --git a/src/ytstudio/commands/comments.py b/src/ytstudio/commands/comments.py index a29e7b5..872cb56 100644 --- a/src/ytstudio/commands/comments.py +++ b/src/ytstudio/commands/comments.py @@ -174,3 +174,12 @@ def reject( """Reject comments (hide from public display)""" count = _set_moderation_status(comment_ids, "rejected", ban_author=ban) console.print(f"{count} comment(s) rejected") + + +def _register_moderate(): + from ytstudio.commands.moderate import moderate # noqa: PLC0415 + + app.command()(moderate) + + +_register_moderate() diff --git a/src/ytstudio/commands/moderate.py b/src/ytstudio/commands/moderate.py new file mode 100644 index 0000000..ccb6ccf --- /dev/null +++ b/src/ytstudio/commands/moderate.py @@ -0,0 +1,178 @@ +"""Interactive comment moderation TUI using Textual.""" + +from __future__ import annotations + +from typing import ClassVar + +import typer +from textual import work +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.widgets import DataTable, Footer, Header, Label + +from ytstudio.commands.comments import ( + Comment, + ModerationStatus, + SortOrder, + _set_moderation_status, + fetch_comments, +) +from ytstudio.services import get_data_service +from ytstudio.ui import console, truncate + + +class ModerationAction: + PUBLISH = "publish" + REJECT = "reject" + NONE = "" + + +class ModerateTUI(App): + CSS = """ + #status { dock: bottom; height: 1; background: $surface; padding: 0 1; } + #status.has-actions { background: $warning; color: $text; } + DataTable { height: 1fr; } + DataTable > .datatable--cursor { background: $accent; } + """ + + BINDINGS: ClassVar[list[Binding]] = [ + Binding("enter", "publish", "Publish", show=True), + Binding("h", "hide", "Reject", show=True), + Binding("space", "toggle", "Toggle", show=True), + Binding("a", "publish_all", "Publish All", show=True), + Binding("q", "quit_app", "Quit & Apply", show=True), + Binding("escape", "cancel", "Cancel", show=True), + ] + + def __init__(self, comments: list[Comment]): + super().__init__() + self.comments = {c.id: c for c in comments} + self.actions: dict[str, str] = {} + self.applied_publish = 0 + self.applied_reject = 0 + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Vertical(): + yield DataTable(cursor_type="row") + yield Label("No pending actions", id="status") + yield Footer() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.add_columns("Action", "Author", "Comment", "Likes", "Age") + for comment in self.comments.values(): + table.add_row( + "", + comment.author, + truncate(comment.text, 80), + str(comment.likes), + comment.published_at[:10], + key=comment.id, + ) + self.title = f"Comment Moderation ({len(self.comments)} held)" + + def _update_row_action(self, comment_id: str) -> None: + table = self.query_one(DataTable) + action = self.actions.get(comment_id, "") + label = {"publish": "✅ publish", "reject": "❌ reject"}.get(action, "") + row_idx = table.get_row_index(comment_id) + table.update_cell_at((row_idx, 0), label) + self._update_status() + + def _update_status(self) -> None: + status = self.query_one("#status", Label) + publish_count = sum(1 for a in self.actions.values() if a == ModerationAction.PUBLISH) + reject_count = sum(1 for a in self.actions.values() if a == ModerationAction.REJECT) + parts = [] + if publish_count: + parts.append(f"{publish_count} to publish") + if reject_count: + parts.append(f"{reject_count} to reject") + if parts: + status.update(", ".join(parts) + " — q to apply") + status.add_class("has-actions") + else: + status.update("No pending actions") + status.remove_class("has-actions") + + def _get_cursor_id(self) -> str | None: + table = self.query_one(DataTable) + if table.row_count == 0: + return None + return str(table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value) + + def action_publish(self) -> None: + cid = self._get_cursor_id() + if cid: + self.actions[cid] = ModerationAction.PUBLISH + self._update_row_action(cid) + table = self.query_one(DataTable) + table.action_cursor_down() + + def action_hide(self) -> None: + cid = self._get_cursor_id() + if cid: + self.actions[cid] = ModerationAction.REJECT + self._update_row_action(cid) + table = self.query_one(DataTable) + table.action_cursor_down() + + def action_toggle(self) -> None: + cid = self._get_cursor_id() + if cid: + current = self.actions.get(cid, "") + if current: + del self.actions[cid] + else: + self.actions[cid] = ModerationAction.PUBLISH + self._update_row_action(cid) + + def action_publish_all(self) -> None: + for cid in self.comments: + self.actions[cid] = ModerationAction.PUBLISH + self._update_row_action(cid) + + def action_quit_app(self) -> None: + self._apply_actions() + + def action_cancel(self) -> None: + self.exit() + + @work(thread=True) + def _apply_actions(self) -> None: + publish_ids = [cid for cid, a in self.actions.items() if a == ModerationAction.PUBLISH] + reject_ids = [cid for cid, a in self.actions.items() if a == ModerationAction.REJECT] + + if publish_ids: + self.applied_publish = _set_moderation_status(publish_ids, "published") + if reject_ids: + self.applied_reject = _set_moderation_status(reject_ids, "rejected") + + self.call_from_thread(self.exit) + + +def moderate( + video_id: str = typer.Option(None, "--video", "-v", help="Filter by video ID"), + limit: int = typer.Option(100, "--limit", "-n", help="Max comments to load"), +): + """Interactive TUI for batch comment moderation""" + service = get_data_service() + comments = fetch_comments(service, video_id, limit, SortOrder.time, ModerationStatus.held) + + if not comments: + console.print("[green]No held comments to review[/green]") + raise typer.Exit() + + tui = ModerateTUI(comments) + tui.run() + + # Print summary after exit + total = tui.applied_publish + tui.applied_reject + if total: + console.print( + f"[green]{tui.applied_publish} published, {tui.applied_reject} rejected[/green]" + ) + else: + console.print("[dim]No changes applied[/dim]") diff --git a/uv.lock b/uv.lock index 974c349..1213446 100644 --- a/uv.lock +++ b/uv.lock @@ -427,6 +427,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -439,6 +451,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -759,6 +788,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "textual" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/38/7d169a765993efde5095c70a668bf4f5831bb7ac099e932f2783e9b71abf/textual-7.5.0.tar.gz", hash = "sha256:c730cba1e3d704e8f1ca915b6a3af01451e3bca380114baacf6abf87e9dac8b6", size = 1592319, upload-time = "2026-01-30T13:46:39.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/78/96ddb99933e11d91bc6e05edae23d2687e44213066bcbaca338898c73c47/textual-7.5.0-py3-none-any.whl", hash = "sha256:849dfee9d705eab3b2d07b33152b7bd74fb1f5056e002873cc448bce500c6374", size = 718164, upload-time = "2026-01-30T13:46:37.635Z" }, +] + [[package]] name = "typer" version = "0.21.1" @@ -783,6 +829,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] + [[package]] name = "uritemplate" version = "4.2.0" @@ -825,6 +880,7 @@ dependencies = [ { name = "google-auth-oauthlib" }, { name = "packaging" }, { name = "rich" }, + { name = "textual" }, { name = "typer" }, ] @@ -844,6 +900,7 @@ requires-dist = [ { name = "google-auth-oauthlib", specifier = ">=1.0.0" }, { name = "packaging", specifier = ">=21.0" }, { name = "rich", specifier = ">=13.0.0" }, + { name = "textual", specifier = ">=7.5.0" }, { name = "typer", specifier = ">=0.9.0" }, ]