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):
else:
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())
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, 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,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
+ else:
+ 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):
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)
+ # we were successful so we know translation service is available.
+ # make our timeline aware of that right away.
+ self.can_translate = Option.YES
+ timeline.update_can_translate(Option.YES)
+ 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..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)
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,7 +159,12 @@ class Timeline(urwid.Columns):
self._emit("links", status)
return
- if key in ("t", "T"):
+ if key in ("n", "N"):
+ if self.can_translate != Option.NO:
+ self._emit("translate", status)
+ return
+
+ if key in ("r", "R"):
self._emit("thread", status)
return
@@ -226,9 +233,11 @@ class Timeline(urwid.Columns):
del(self.status_list.body[index])
self.refresh_status_details()
-
+ 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):
"""
Parameters
----------
@@ -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):
"[R]eply",
"So[u]rce",
"[Z]oom",
+ "Tra[n]slate" if self.can_translate == Option.YES \
+ else "Tra[n]slate?" if self.can_translate == Option.UNKNOWN \
+ else "",
"[H]elp",
]
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()
parser.feed(content)
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