Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds translate status feature (when connected to a Mastodon 4.x server with translation enabled) #252

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions toot/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 0 additions & 1 deletion toot/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']))

Expand Down
63 changes: 58 additions & 5 deletions toot/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
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__)

Expand Down Expand Up @@ -106,6 +106,7 @@ def __init__(self, app, user):
self.timeline = None
self.overlay = None
self.exception = None
self.can_translate = Option.UNKNOWN
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think using Option here is very beneficial and it complicates things. I would just set it to False here, and set it to True later when we determine if translate is available.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


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

Expand Down Expand Up @@ -206,6 +207,7 @@ def _zoom(timeline, status_details):
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):
Expand All @@ -232,7 +234,7 @@ def _toggle_save(timeline, status):
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)
Expand Down Expand Up @@ -261,8 +263,8 @@ def _close(*args):
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)

Expand Down Expand Up @@ -303,15 +305,31 @@ def async_load_instance(self):
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)

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 = Option.YES
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at mastodon/mastodon@8046cf3#diff-fdc9457d4797dfa4b6e351a974127f11b1efc7eaeadb84718976f98af3618026

Just having "translation" in instance info is not enough, we need to check if instance["translation"]["enabled"] is True.

Suggested change
self.can_translate = Option.YES
self.can_translate = instance["translation"]["enabled"]

(this is presuming we change this from an Option to bool)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

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.

return self.run_in_thread(_load_instance, done_callback=_done)
ihabunek marked this conversation as resolved.
Show resolved Hide resolved

def refresh_footer(self, timeline):
"""Show status details in footer."""
Expand Down Expand Up @@ -484,6 +502,41 @@ def _done(loop):
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)
Expand Down
1 change: 1 addition & 0 deletions toot/tui/overlays.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ def link(text, url):
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)"))
Expand Down
31 changes: 24 additions & 7 deletions toot/tui/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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")
Expand All @@ -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),
Expand Down Expand Up @@ -97,7 +99,7 @@ def refresh_status_details(self):
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):
Expand Down Expand Up @@ -157,7 +159,12 @@ def keypress(self, size, key):
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"):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You changed the key binding for thread here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ouch. Not sure how that happened. Fixed.

self._emit("thread", status)
return

Expand Down Expand Up @@ -226,9 +233,11 @@ def remove_status(self, status):
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
----------
Expand All @@ -239,6 +248,7 @@ def __init__(self, status, in_thread):
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 ())
Expand Down Expand Up @@ -290,10 +300,14 @@ def content_generator(self, status, reblogged_by):
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 ""),
]))

Expand All @@ -310,6 +324,9 @@ def content_generator(self, status, reblogged_by):
"[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)
Expand Down
11 changes: 11 additions & 0 deletions toot/tui/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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