This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch

[PATCH] support for server translation of status messages

Message ID
DKIM signature
Download raw message
Patch: +102 -17
This is a Mastodon 4 feature. It is not enabled on all Mastodon servers, 
but where it is available, you can now translate between supported 
language pairs (support will vary by server, as the admins can install 
different translation software).

Tested with toad.social which uses LibreTranslate on the back end. 
Successfully translates German to English, French to English.

diff --git a/toot/api.py b/toot/api.py
index 5868430..ffce8d3 100644
--- a/toot/api.py
+++ b/toot/api.py
@@ -209,6 +209,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/output.py b/toot/output.py
index 0c74f61..d58e477 100644
--- a/toot/output.py
+++ b/toot/output.py
@@ -165,7 +165,6 @@ def print_instance(instance):
                     print_out(f"{' ' * len(ordinal)} {line}")

 def print_account(account):
     print_out("<green>@{}</green> {}".format(account['acct'], account['display_name']))

diff --git a/toot/tui/app.py b/toot/tui/app.py
index cb3a508..5ad99e6 100644
--- a/toot/tui/app.py
+++ b/toot/tui/app.py
@@ -11,7 +11,7 @@ from .entities import Status
 from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom
 from .overlays import StatusDeleteConfirmation
 from .timeline import Timeline
from .utils import parse_content_links, show_media
from .utils import Option, parse_content_links, show_media, versiontuple

 logger = logging.getLogger(__name__)

@@ -106,6 +106,7 @@ class TUI(urwid.Frame):
         self.timeline = None
         self.overlay = None
         self.exception = None
        self.can_translate = Option.UNKNOWN

         super().__init__(self.body, header=self.header, footer=self.footer)

@@ -206,7 +207,8 @@ 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):
             raise urwid.ExitMainLoop()
@@ -232,7 +234,7 @@ class TUI(urwid.Frame):
             self.loop.set_alarm_in(5, lambda *args: self.footer.clear_message())

        timeline = Timeline(name, statuses)
        timeline = Timeline(name, statuses, self.can_translate)

         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, can_translate, focus,
         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,9 +317,20 @@ class TUI(urwid.Frame):
         def _done(instance):
             if "max_toot_chars" in instance:
                 self.max_toot_chars = instance["max_toot_chars"]

        return self.run_in_thread(_load_instance, done_callback=_done)

            if "translation" in instance:
                # instance is advertising translation service
                self.can_translate = Option.YES
                if "version" in instance and versiontuple(instance["version"])[0] < 4:
                    # Mastodon versions < 4 do not have translation service
                    self.can_translate = Option.NO

            # translation service for Mastodon version 4.0.0-4.0.2 that do not advertise
            # is indeterminate; as of now versions up to 4.0.2 cannot advertise
            # even if they provide the service, but future versions, perhaps 4.0.3+
            # will be able to advertise.

     def refresh_footer(self, timeline):
         """Show status details in footer."""
         status, index, count = timeline.get_focused_status_with_counts()
@@ -484,6 +502,41 @@ class TUI(urwid.Frame):

    def async_translate(self, timeline, status):
        def _translate():
            logger.info("Translating {}".format(status))
            self.footer.set_message("Translating status {}".format(status.id))
                response = api.translate(self.app, self.user, status.id)
                # we were successful so we know translation service is available.
                # make our timeline aware of that right away.
                self.can_translate = Option.YES
                response = None
            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)

                self.footer.set_message(f"Translated status {status.id} from {response['detected_source_language']}")
                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..6046ac5 100644
--- a/toot/tui/timeline.py
+++ b/toot/tui/timeline.py
@@ -4,7 +4,7 @@ import webbrowser

 from toot.utils import format_content

from .utils import highlight_hashtags, parse_datetime, highlight_keys
from .utils import Option, highlight_hashtags, parse_datetime, highlight_keys
 from .widgets import SelectableText, SelectableColumns

 logger = logging.getLogger("toot")
@@ -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)
            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)

             ("weight", 40, self.status_list),
@@ -97,7 +99,7 @@ class Timeline(urwid.Columns):

     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,7 +159,12 @@ class Timeline(urwid.Columns):
             self._emit("links", status)

        if key in ("t", "T"):
        if key in ("n", "N"):
            if self.can_translate != Option.NO:
                self._emit("translate", status)

        if key in ("r", "R"):
             self._emit("thread", status)

@@ -226,9 +233,11 @@ class Timeline(urwid.Columns):

    def update_can_translate(self, can_translate):
        self.can_translate = can_translate

 class StatusDetails(urwid.Pile):
    def __init__(self, status, in_thread):
    def __init__(self, status, in_thread, can_translate=Option.UNKNOWN):
@@ -239,6 +248,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 +300,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 +324,9 @@ class StatusDetails(urwid.Pile):
            "Tra[n]slate" if self.can_translate == Option.YES \
            else "Tra[n]slate?" if self.can_translate == Option.UNKNOWN \
            else "",
         options = " ".join(o for o in options if o)
diff --git a/toot/tui/utils.py b/toot/tui/utils.py
index a9ab122..42eb6d2 100644
--- a/toot/tui/utils.py
+++ b/toot/tui/utils.py
@@ -108,3 +108,14 @@ def parse_content_links(content):
     parser = LinkParser()
     return parser.links[:]

# This is not as robust as using distutils.version.LooseVersion but for our needs
# it works fine and doesn't require importing a ton of dependences

def versiontuple(v):
    return tuple(map(int, (v.split("."))))

class Option:
    NO = 0
    YES = 1
    UNKNOWN = 2
Message ID
<2d7dd3a1-041b-159a-7d2f-7752cd03303a@mini> (view parent)
DKIM signature
Download raw message
Hi Daniel,

This patch does not apply cleanly:
error: corrupt patch at line 114

Did you use git to send the email? Here's a good tutorial:

-- Ivan
Reply to thread Export thread (mbox)