From be5948bac8ada62ae92b25df2671c6f915766c89 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Wed, 7 Dec 2022 19:57:56 -0500 Subject: [PATCH] Added translate status feature (requires server side support) --- toot/api.py | 4 ++++ toot/tui/app.py | 55 +++++++++++++++++++++++++++++++++++++++++--- toot/tui/overlays.py | 1 + toot/tui/timeline.py | 23 ++++++++++++++---- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/toot/api.py b/toot/api.py index 883900b..1ce1ec0 100644 --- a/toot/api.py +++ b/toot/api.py @@ -211,6 +211,10 @@ def unbookmark(app, user, status_id): return _status_action(app, user, status_id, 'unbookmark') +def translate(app, user, status_id): + return _status_action(app, user, status_id, 'translate') + + def context(app, user, status_id): url = '/api/v1/statuses/{}/context'.format(status_id) diff --git a/toot/tui/app.py b/toot/tui/app.py index cb3a508..f11ef0a 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -106,6 +106,7 @@ class TUI(urwid.Frame): self.timeline = None self.overlay = None self.exception = None + self.can_translate = False super().__init__(self.body, header=self.header, footer=self.footer) @@ -206,6 +207,7 @@ class TUI(urwid.Frame): urwid.connect_signal(timeline, "source", _source) urwid.connect_signal(timeline, "links", _links) urwid.connect_signal(timeline, "zoom", _zoom) + urwid.connect_signal(timeline, "translate", self.async_translate) def build_timeline(self, name, statuses, local): def _close(*args): @@ -232,7 +234,7 @@ class TUI(urwid.Frame): self.loop.set_alarm_in(5, lambda *args: self.footer.clear_message()) config.save_config(self.config) - timeline = Timeline(name, statuses) + timeline = Timeline(name, statuses, self.can_translate) self.connect_default_timeline_signals(timeline) urwid.connect_signal(timeline, "next", _next) @@ -261,8 +263,8 @@ class TUI(urwid.Frame): statuses = ancestors + [status] + descendants focus = len(ancestors) - timeline = Timeline("thread", statuses, focus, is_thread=True) - + timeline = Timeline("thread", statuses, self.can_translate, focus, + is_thread=True) self.connect_default_timeline_signals(timeline) urwid.connect_signal(timeline, "close", _close) @@ -303,6 +305,11 @@ class TUI(urwid.Frame): Attempt to update max_toot_chars from instance data. Does not work on vanilla Mastodon, works on Pleroma. See: https://github.com/tootsuite/mastodon/issues/4915 + + Also attempt to update translation flag from instance + data. Translation is only present on Mastodon 4+ servers + where the administrator has enabled this feature. + See: https://github.com/mastodon/mastodon/issues/19328 """ def _load_instance(): return api.get_instance(self.app.instance) @@ -310,6 +317,17 @@ class TUI(urwid.Frame): def _done(instance): if "max_toot_chars" in instance: self.max_toot_chars = instance["max_toot_chars"] + if "translation" in instance: + # instance is advertising translation service + self.can_translate = instance["translation"]["enabled"] + else: + if "version" in instance: + # fallback check: + # get the major version number of the server + # this works for Mastodon and Pleroma version strings + # Mastodon versions < 4 do not have translation service + # Revisit this logic if Pleroma implements translation + self.can_translate = int(instance["version"][0]) > 3 return self.run_in_thread(_load_instance, done_callback=_done) @@ -484,6 +502,37 @@ class TUI(urwid.Frame): done_callback=_done ) + def async_translate(self, timeline, status): + def _translate(): + logger.info("Translating {}".format(status)) + self.footer.set_message("Translating status {}".format(status.id)) + + try: + response = api.translate(self.app, self.user, status.id) + except: + response = None + finally: + self.footer.clear_message() + + return response + + def _done(response): + if response is not None: + # Create a new Status that is translated + new_data = status.data + new_data["content"] = response["content"] + new_data["detected_source_language"] = response["detected_source_language"] + new_status = self.make_status(new_data) + + timeline.update_status(new_status) + self.footer.set_message(f"Translated status {status.id} from {response['detected_source_language']}") + else: + self.footer.set_error_message("Translate server error") + + self.loop.set_alarm_in(5, lambda *args: self.footer.clear_message()) + + self.run_in_thread(_translate, done_callback=_done ) + def async_delete_status(self, timeline, status): def _delete(): api.delete_status(self.app, self.user, status.id) diff --git a/toot/tui/overlays.py b/toot/tui/overlays.py index 2d9050c..37aa73c 100644 --- a/toot/tui/overlays.py +++ b/toot/tui/overlays.py @@ -164,6 +164,7 @@ class Help(urwid.Padding): yield urwid.Text(h(" [B] - Boost/unboost status")) yield urwid.Text(h(" [C] - Compose new status")) yield urwid.Text(h(" [F] - Favourite/unfavourite status")) + yield urwid.Text(h(" [N] - Translate status, if possible")) yield urwid.Text(h(" [R] - Reply to current status")) yield urwid.Text(h(" [S] - Show text marked as sensitive")) yield urwid.Text(h(" [T] - Show status thread (replies)")) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 53ecb78..7bab54c 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -28,19 +28,21 @@ class Timeline(urwid.Columns): "source", # Show status source "links", # Show status links "thread", # Show thread for status + "translate", # Translate status "save", # Save current timeline "zoom", # Open status in scrollable popup window ] - def __init__(self, name, statuses, focus=0, is_thread=False): + def __init__(self, name, statuses, can_translate, focus=0, is_thread=False): self.name = name self.is_thread = is_thread self.statuses = statuses + self.can_translate = can_translate self.status_list = self.build_status_list(statuses, focus=focus) try: - self.status_details = StatusDetails(statuses[focus], is_thread) + self.status_details = StatusDetails(statuses[focus], is_thread, can_translate) except IndexError: - self.status_details = StatusDetails(None, is_thread) + self.status_details = StatusDetails(None, is_thread, can_translate) super().__init__([ ("weight", 40, self.status_list), @@ -97,7 +99,7 @@ class Timeline(urwid.Columns): self.draw_status_details(status) def draw_status_details(self, status): - self.status_details = StatusDetails(status, self.is_thread) + self.status_details = StatusDetails(status, self.is_thread, self.can_translate) self.contents[2] = urwid.Padding(self.status_details, left=1), ("weight", 60, False) def keypress(self, size, key): @@ -157,6 +159,11 @@ class Timeline(urwid.Columns): self._emit("links", status) return + if key in ("n", "N"): + if self.can_translate: + self._emit("translate", status) + return + if key in ("t", "T"): self._emit("thread", status) return @@ -228,7 +235,7 @@ class Timeline(urwid.Columns): class StatusDetails(urwid.Pile): - def __init__(self, status, in_thread): + def __init__(self, status, in_thread, can_translate=False): """ Parameters ---------- @@ -239,6 +246,7 @@ class StatusDetails(urwid.Pile): Whether the status is rendered from a thread status list. """ self.in_thread = in_thread + self.can_translate = can_translate reblogged_by = status.author if status and status.reblog else None widget_list = list(self.content_generator(status.original, reblogged_by) if status else ()) @@ -290,10 +298,14 @@ class StatusDetails(urwid.Pile): application = application.get("name") yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "gray")) + + translated = status.data.get("detected_source_language") + yield ("pack", urwid.Text([ ("gray", "⤶ {} ".format(status.data["replies_count"])), ("yellow" if status.reblogged else "gray", "♺ {} ".format(status.data["reblogs_count"])), ("yellow" if status.favourited else "gray", "★ {}".format(status.data["favourites_count"])), + ("yellow" if translated else "gray", " · Translated from {} ".format(translated) if translated else ""), ("gray", " · {}".format(application) if application else ""), ])) @@ -310,6 +322,7 @@ class StatusDetails(urwid.Pile): "[R]eply", "So[u]rce", "[Z]oom", + "Tra[n]slate" if self.can_translate else "", "[H]elp", ] options = " ".join(o for o in options if o)