From 335ffb774f20f700cd637032a6aece9dd0052748 Mon Sep 17 00:00:00 2001 From: SeoulSKY Date: Wed, 21 Feb 2024 19:58:54 -0600 Subject: [PATCH] Integrate Fluent API --- commands/arcaea.py | 14 +- commands/chat.py | 26 +-- commands/movie.py | 21 ++- commands/ping.py | 8 +- commands/translator.py | 35 ++-- .../{translate.py => translate_message.py} | 9 +- locales/en/commands/arcaea.ftl | 11 +- locales/en/commands/chat.ftl | 14 +- locales/en/commands/movie.ftl | 16 +- locales/en/commands/ping.ftl | 4 +- locales/en/commands/translator.ftl | 19 +- locales/en/context_menus/translate.ftl | 2 - .../en/context_menus/translate_message.ftl | 2 + main.py | 174 +++++++++++++----- utils/constants.py | 16 +- utils/translator.py | 142 +++++++++----- utils/ui.py | 6 +- 17 files changed, 342 insertions(+), 177 deletions(-) rename context_menus/{translate.py => translate_message.py} (50%) delete mode 100644 locales/en/context_menus/translate.ftl create mode 100644 locales/en/context_menus/translate_message.ftl diff --git a/commands/arcaea.py b/commands/arcaea.py index 7d6c4b1..7e6fc89 100644 --- a/commands/arcaea.py +++ b/commands/arcaea.py @@ -11,6 +11,7 @@ from discord.ext.commands import Bot from utils import templates, ui +from utils.constants import DEFAULT_LOCALE from utils.translator import Localization LINK_PLAY_LIFESPAN_MINUTES = 30 @@ -18,7 +19,7 @@ logger = logging.getLogger(__name__) -loc = Localization(["en"], [os.path.join("commands", "arcaea.ftl")]) +loc = Localization(DEFAULT_LOCALE, [os.path.join("commands", "arcaea.ftl")]) EMPTY_TEXT = loc.format_value("empty") @@ -140,13 +141,14 @@ class Arcaea(app_commands.Group): """ def __init__(self, bot: Bot): - super().__init__() + super().__init__(name=loc.format_value("arcaea-name"), description=loc.format_value("arcaea-description")) self.bot = bot - @app_commands.command(description=loc.format_value("description", { - "duration": LINK_PLAY_LIFESPAN_MINUTES - })) - @app_commands.describe(roomcode=loc.format_value("roomcode")) + @app_commands.command(name=loc.format_value("linkplay-name"), + description=loc.format_value("linkplay-description", { + "duration": LINK_PLAY_LIFESPAN_MINUTES + })) + @app_commands.describe(roomcode=loc.format_value("linkplay-roomcode-description")) async def linkplay(self, interaction: Interaction, roomcode: str): """ Create an embed to invite people to your Link Play diff --git a/commands/chat.py b/commands/chat.py index 670460e..8f0d52f 100644 --- a/commands/chat.py +++ b/commands/chat.py @@ -12,8 +12,11 @@ from discord.ext.commands import Bot from mongo.user import User, get_user, set_user +from utils.constants import DEFAULT_LOCALE from utils.templates import info, success, error -from utils.translator import is_english, languages, language_to_code, Localization, locale_to_language, Translator +from utils.translator import is_english, languages, language_to_code, Localization, Translator, code_to_language + +loc = Localization(DEFAULT_LOCALE, [os.path.join("commands", "chat.ftl")]) class Chat(app_commands.Group): @@ -21,10 +24,8 @@ class Chat(app_commands.Group): Commands related to AI chats """ - loc = Localization(["en"], [os.path.join("commands", "chat.ftl")]) - def __init__(self, bot: Bot): - super().__init__() + super().__init__(name=loc.format_value("chat-name"), description=loc.format_value("chat-description")) self.bot = bot self._logger = logging.getLogger(__name__) self._client = PyAsyncCAI(os.getenv("CAI_TOKEN")) @@ -67,11 +68,11 @@ async def on_message(message: Message): self.bot.add_listener(on_message) def _timeout_message(self) -> str: - return info(self.loc.format_value("timeout", {"name": self.bot.user.display_name})) + return info(loc.format_value("timeout", {"name": self.bot.user.display_name})) @staticmethod def _error_message() -> str: - return error(Chat.loc.format_value("error", {"name": "SeoulSKY"})) + return error(loc.format_value("error", {"name": "SeoulSKY"})) async def _create_new_chat(self, user: User, user_name: str): response = await self._client.chat.new_chat(os.getenv("CAI_CHAR_ID")) @@ -95,9 +96,10 @@ async def _send_message(self, user: User, text: str) -> str: return content - @app_commands.command(name=loc.format_value("name"), description=loc.format_value("description")) + @app_commands.command(name=loc.format_value("update-language-name"), + description=loc.format_value("update-language-description")) @app_commands.choices(language=[Choice(name=lang.title(), value=language_to_code(lang)) for lang in languages]) - @app_commands.describe(language=loc.format_value("language")) + @app_commands.describe(language=loc.format_value("update-language-language-description")) async def update_language(self, interaction: Interaction, language: str = None): """ Update the chat language to the current discord language @@ -107,11 +109,11 @@ async def update_language(self, interaction: Interaction, language: str = None): await set_user(user) await interaction.response.send_message( - self.loc.format_value("updated", {"language": locale_to_language(user.locale).title()}), + loc.format_value("updated", {"language": code_to_language(user.locale).title()}), ephemeral=True ) - @app_commands.command(name=loc.format_value("name2"), description=loc.format_value("description2")) + @app_commands.command(name=loc.format_value("clear-name"), description=loc.format_value("clear-description")) async def clear(self, interaction: Interaction): """ Clear the chat history between you and this bot @@ -119,7 +121,7 @@ async def clear(self, interaction: Interaction): user = await get_user(interaction.user.id) if user.chat_history_id is None: await interaction.response.send_message( - error(self.loc.format_value("no-history", {"name": interaction.client.user.display_name})), + error(loc.format_value("no-history", {"name": interaction.client.user.display_name})), ephemeral=True) return @@ -136,4 +138,4 @@ async def clear(self, interaction: Interaction): user.chat_history_tgt = None await set_user(user) - await interaction.followup.send(success(self.loc.format_value("deleted")), ephemeral=True) + await interaction.followup.send(success(loc.format_value("deleted")), ephemeral=True) diff --git a/commands/movie.py b/commands/movie.py index 2c46cd5..d1afe05 100644 --- a/commands/movie.py +++ b/commands/movie.py @@ -17,6 +17,7 @@ from tqdm import tqdm from utils import templates, constants +from utils.constants import ErrorCode, DEFAULT_LOCALE from utils.translator import Localization DESKTOP_CACHE_PATH = os.path.join(constants.CACHE_DIR, "movie", "desktop") @@ -69,6 +70,8 @@ Maximum value of RGB for each pixel """ +loc = Localization(DEFAULT_LOCALE, [os.path.join("commands", "movie.ftl")]) + class Movie(app_commands.Group): """ @@ -84,10 +87,8 @@ class Movie(app_commands.Group): _num_playing = 0 _lock = threading.Lock() - loc = Localization(["en"], [os.path.join("commands", "movie.ftl")]) - def __init__(self, bot: Bot): - super().__init__() + super().__init__(name=loc.format_value("movie-name"), description=loc.format_value("movie-description")) self.bot = bot if not os.path.exists(DESKTOP_CACHE_PATH) or not os.path.exists(MOBILE_CACHE_PATH): @@ -132,17 +133,17 @@ async def get_frames(name: str, is_on_mobile: bool) -> list[str]: return Movie._cache[path] - @app_commands.command(description=loc.format_value("description")) - @app_commands.describe(title=loc.format_value("title")) - @app_commands.describe(fps=loc.format_value("fps", { + @app_commands.command(name=loc.format_value("play-name"), description=loc.format_value("play-description")) + @app_commands.describe(title=loc.format_value("play-title-description")) + @app_commands.describe(fps=loc.format_value("play-fps-description", { "min": FPS_MIN, "max": FPS_MAX, "default": FPS_DEFAULT })) - @app_commands.describe(original_speed=loc.format_value("original-speed", { + @app_commands.describe(original_speed=loc.format_value("play-original-speed-description", { "default": str(ORIGINAL_SPEED_DEFAULT) })) @app_commands.choices(title=[ - Choice(name="Bad Apple", value="bad_apple"), - Choice(name="Ultra B+K", value="ultra_b+k") + Choice(name="Bad Apple!!", value="bad_apple"), + Choice(name="ULTRA B+K", value="ultra_b+k") ]) @app_commands.choices(fps=[Choice(name=str(i), value=i) for i in range(FPS_MIN, FPS_MAX + 1)]) async def play(self, interaction: Interaction, @@ -183,7 +184,7 @@ async def display(): except NotFound: # Message is deleted display.cancel() except HTTPException as ex: - if ex.code == 50027: + if ex.code == ErrorCode.MESSAGE_EXPIRED: message = await message.channel.fetch_message(message.id) await message.edit(embed=embed) else: diff --git a/commands/ping.py b/commands/ping.py index f188077..647efa4 100644 --- a/commands/ping.py +++ b/commands/ping.py @@ -6,15 +6,15 @@ import discord from discord import app_commands -from utils.constants import BOT_NAME +from utils.constants import BOT_NAME, DEFAULT_LOCALE from utils.templates import info from utils.translator import Localization -loc = Localization(["en"], [os.path.join("commands", "ping.ftl")]) +loc = Localization(DEFAULT_LOCALE, [os.path.join("commands", "ping.ftl")]) -@app_commands.command(name=loc.format_value("name"), - description=loc.format_value("description", {"name": BOT_NAME})) +@app_commands.command(name=loc.format_value("ping-name"), + description=loc.format_value("ping-description", {"name": BOT_NAME})) async def ping(interaction: discord.Interaction): """Ping this bot""" await interaction.response.send_message( diff --git a/commands/translator.py b/commands/translator.py index 11b5e3a..9440b5d 100644 --- a/commands/translator.py +++ b/commands/translator.py @@ -10,10 +10,12 @@ from mongo.channel import get_channel, set_channel from mongo.user import get_user, set_user from utils import templates, ui -from utils.constants import ErrorCode, Limit +from utils.constants import ErrorCode, Limit, DEFAULT_LOCALE from utils.templates import success from utils.translator import BatchTranslator, locale_to_code, Localization +loc = Localization(DEFAULT_LOCALE, [os.path.join("commands", "translator.ftl")]) + class ChannelLanguageSelect(ui.LanguageSelect): """ @@ -21,9 +23,8 @@ class ChannelLanguageSelect(ui.LanguageSelect): """ def __init__(self, locale: Locale): - self.localization = Localization([locale_to_code(locale)], - [os.path.join("commands", "translator.ftl")]) - super().__init__(self.localization.format_value("select-channel-languages")) + self.loc = Localization(locale_to_code(locale),[os.path.join("commands", "translator.ftl")]) + super().__init__(self.loc.format_value("select-channel-languages")) async def callback(self, interaction: Interaction): config = await get_channel(interaction.channel_id) @@ -31,7 +32,7 @@ async def callback(self, interaction: Interaction): await set_channel(config) await interaction.response.send_message( - success(self.localization.format_value("channel-languages-updated")), ephemeral=True + success(self.loc.format_value("channel-languages-updated")), ephemeral=True ) @@ -41,8 +42,7 @@ class UserLanguageSelect(ui.LanguageSelect): """ def __init__(self, locale: Locale): - self.loc = Localization([locale_to_code(locale)], - [os.path.join("commands", "translator.ftl")]) + self.loc = Localization(locale_to_code(locale),[os.path.join("commands", "translator.ftl")]) super().__init__(self.loc.format_value("select-your-languages")) async def callback(self, interaction: Interaction): @@ -60,10 +60,9 @@ class Translator(app_commands.Group): Commands related to translation """ - doc = Localization(["en"], [os.path.join("commands", "translator.ftl")]) - def __init__(self, bot: Bot): - super().__init__() + super().__init__(name=loc.format_value("translator-name"), + description=loc.format_value("translator-description")) self.bot = bot self._setup_user_listeners() @@ -137,7 +136,8 @@ def _split(string: str, count: int): for i in range(0, len(string), count): yield string[i: i + count] - @app_commands.command(name=doc.format_value("name"), description=doc.format_value("description")) + @app_commands.command(name=loc.format_value("set-your-languages-name"), + description=loc.format_value("set-your-languages-description")) async def set_your_languages(self, interaction: Interaction): """ Set languages to be translated for your messages @@ -146,7 +146,8 @@ async def set_your_languages(self, interaction: Interaction): view.add_item(UserLanguageSelect(interaction.locale)) await interaction.response.send_message(view=view, ephemeral=True) - @app_commands.command(name=doc.format_value("name2"), description=doc.format_value("description2")) + @app_commands.command(name=loc.format_value("set-channel-languages-name"), + description=loc.format_value("set-channel-languages-description")) @app_commands.checks.has_permissions(administrator=True) async def set_channel_languages(self, interaction: Interaction): """ @@ -156,7 +157,8 @@ async def set_channel_languages(self, interaction: Interaction): view.add_item(ChannelLanguageSelect(interaction.locale)) await interaction.response.send_message(view=view, ephemeral=True) - @app_commands.command(name=doc.format_value("name3"), description=doc.format_value("description3")) + @app_commands.command(name=loc.format_value("clear-your-languages-name"), + description=loc.format_value("clear-your-languages-description")) async def clear_your_languages(self, interaction: Interaction): """ Clear languages to be translated for your messages @@ -165,10 +167,11 @@ async def clear_your_languages(self, interaction: Interaction): user.translate_to = [] await set_user(user) - await interaction.response.send_message(success(self.doc.format_value("your-languages-cleared")), + await interaction.response.send_message(success(loc.format_value("your-languages-cleared")), ephemeral=True) - @app_commands.command(name=doc.format_value("name4"), description=doc.format_value("description4")) + @app_commands.command(name=loc.format_value("clear-channel-languages-name"), + description=loc.format_value("clear-channel-languages-description")) @app_commands.checks.has_permissions(administrator=True) async def clear_channel_languages(self, interaction: Interaction): """ @@ -178,5 +181,5 @@ async def clear_channel_languages(self, interaction: Interaction): channel.translate_to = [] await set_channel(channel) - await interaction.response.send_message(success(self.doc.format_value("channel-languages-cleared")), + await interaction.response.send_message(success(loc.format_value("channel-languages-cleared")), ephemeral=True) diff --git a/context_menus/translate.py b/context_menus/translate_message.py similarity index 50% rename from context_menus/translate.py rename to context_menus/translate_message.py index 9a8f048..2da8211 100644 --- a/context_menus/translate.py +++ b/context_menus/translate_message.py @@ -1,5 +1,5 @@ """ -Implements translate context menus +Implements a context menu to translate messages """ import os @@ -7,13 +7,14 @@ from discord import app_commands from utils import translator +from utils.constants import DEFAULT_LOCALE from utils.translator import Localization, locale_to_code -loc = Localization(["en"], [os.path.join("context_menus", "translate.ftl")]) +loc = Localization(DEFAULT_LOCALE, [os.path.join("context_menus", "translate_message.ftl")]) -@app_commands.context_menu(name=loc.format_value("name")) -async def translate(interaction: discord.Interaction, message: discord.Message): +@app_commands.context_menu(name=loc.format_value("translate-message-name")) +async def translate_message(interaction: discord.Interaction, message: discord.Message): """Translate this message into your language""" await interaction.response.send_message( translator.translate(message.content, locale_to_code(interaction.locale)), ephemeral=True diff --git a/locales/en/commands/arcaea.ftl b/locales/en/commands/arcaea.ftl index f918990..9ea6f06 100644 --- a/locales/en/commands/arcaea.ftl +++ b/locales/en/commands/arcaea.ftl @@ -1,7 +1,10 @@ -# Link Play Command -name = linkplay -description = Create an embed to invite people to your Link Play. It will last for { $duration } minutes -roomcode = Room code of your Arcaea Link Play +# Commands +arcaea-name = arcaea +arcaea-description = Commands related to Arcaea +linkplay-name = linkplay +linkplay-description = Create an embed to invite people to your Link Play. It will last for { $duration } minutes +linkplay-roomcode-name = roomcode +linkplay-roomcode-description = Room code of your Arcaea Link Play # Embed title = Arcaea Link Play diff --git a/locales/en/commands/chat.ftl b/locales/en/commands/chat.ftl index 895052e..6cb5f8f 100644 --- a/locales/en/commands/chat.ftl +++ b/locales/en/commands/chat.ftl @@ -1,10 +1,14 @@ # Commands -name = update_language -description = Update the chat language to the current discord language -language = The new chat language. Defaults to your current discord language +chat-name = chat +chat-description = Commands related to AI chats -name2 = clear -description2 = Clear the chat history between you and this bot +update-language-name = update_language +update-language-description = Update the chat language to the current discord language +update-language-language-name = language +update-language-language-description = The new chat language. Defaults to your current discord language + +clear-name = clear +clear-description = Clear the chat history between you and this bot # Successes updated = The chat language has been updated to `{ $language }` diff --git a/locales/en/commands/movie.ftl b/locales/en/commands/movie.ftl index 7163ec9..3423f92 100644 --- a/locales/en/commands/movie.ftl +++ b/locales/en/commands/movie.ftl @@ -1,4 +1,12 @@ -description = Play a movie -title = Title of the movie to play -fps = Number of frames to display per second. Range from { $min } to { $max } (inclusive). Default value is { $default } -original-speed = Play the movie at the original speed by skipping some frames. Default value is { $default } +# Commands +movie-name = movie +movie-description = Commands related to Movie + +play-name = play +play-description= Play a movie +play-title-name = title +play-title-description = Title of the movie to play +play-fps-name = fps +play-fps-description = Number of frames to display per second. Range from { $min } to { $max } (inclusive). Default value is { $default } +play-original-speed-name = original_speed +play-original-speed-description = Play the movie at the original speed by skipping some frames. Default value is { $default } diff --git a/locales/en/commands/ping.ftl b/locales/en/commands/ping.ftl index bd88b7b..ce1a90c 100644 --- a/locales/en/commands/ping.ftl +++ b/locales/en/commands/ping.ftl @@ -1,6 +1,6 @@ # Command -name = ping -description = Check the response time of { $name } +ping-name = ping +ping-description = Check the response time of { $name } # Successes latency = Latency: { $value }ms diff --git a/locales/en/commands/translator.ftl b/locales/en/commands/translator.ftl index b5b3708..e0f3447 100644 --- a/locales/en/commands/translator.ftl +++ b/locales/en/commands/translator.ftl @@ -1,15 +1,18 @@ # Commands -name = set_your_languages -description = Set languages to be translated for your messages +translator-name = translator +translator-description = Commands related to translation -name2 = set_channel_languages -description2 = [Admins only] Set languages to be translated for this channel +set-your-languages-name = set_your_languages +set-your-languages-description = Set languages to be translated for your messages -name3 = clear_your_languages -description3 = Clear languages to be translated for your messages +set-channel-languages-name = set_channel_languages +set-channel-languages-description = [Admins only] Set languages to be translated for this channel -name4 = clear_channel_languages -description4 = [Admins only] Clear languages to be translated for this channel +clear-your-languages-name = clear_your_languages +clear-your-languages-description = Clear languages to be translated for your messages + +clear-channel-languages-name = clear_channel_languages +clear-channel-languages-description = [Admins only] Clear languages to be translated for this channel # Select UI select-your-languages = Select the languages you want to translate your messages to diff --git a/locales/en/context_menus/translate.ftl b/locales/en/context_menus/translate.ftl deleted file mode 100644 index d291b7c..0000000 --- a/locales/en/context_menus/translate.ftl +++ /dev/null @@ -1,2 +0,0 @@ -# Context Menus -name = Translate Message diff --git a/locales/en/context_menus/translate_message.ftl b/locales/en/context_menus/translate_message.ftl new file mode 100644 index 0000000..4c1d61e --- /dev/null +++ b/locales/en/context_menus/translate_message.ftl @@ -0,0 +1,2 @@ +# Context Menus +translate-message-name = Translate Message diff --git a/main.py b/main.py index 3ad9b50..bb5b846 100644 --- a/main.py +++ b/main.py @@ -1,25 +1,27 @@ """ Main script where the program starts """ -import concurrent.futures + import itertools import logging import os -from concurrent.futures import ThreadPoolExecutor, Future +from concurrent.futures import ThreadPoolExecutor from importlib import import_module from logging.handlers import TimedRotatingFileHandler +from threading import Lock import discord from discord import app_commands, Interaction, Locale, AppCommandType from discord.app_commands import AppCommandError, MissingPermissions -from discord.ext.commands import Bot, MinimalHelpCommand +from discord.ext.commands import Bot from dotenv import load_dotenv from tqdm import tqdm from commands.movie import Movie -from utils.constants import Limit +from utils import translator +from utils.constants import Limit, DEFAULT_LOCALE from utils.templates import forbidden -from utils.translator import Translator, locale_to_code +from utils.translator import locale_to_code, Localization, CommandTranslator, Translator, has_localization, Translations load_dotenv() @@ -37,15 +39,6 @@ } -class EmptyHelpCommand(MinimalHelpCommand): - """ - A help commands that sends nothing - """ - - async def send_pages(self): - pass - - class LevelFilter(logging.Filter): # pylint: disable=too-few-public-methods """ Logger filter that filters only specific logging level @@ -65,8 +58,7 @@ class SoruSora(Bot): """ def __init__(self): - super().__init__(command_prefix="s!", intents=discord.Intents.all()) - self.help_command = EmptyHelpCommand() + super().__init__(command_prefix=None, intents=discord.Intents.all()) self._add_commands() def _add_commands(self): @@ -90,61 +82,145 @@ def _add_commands(self): # noinspection PyArgumentList self.tree.add_command(group_command_class(bot=self)) - def _translate_commands(self) -> dict[Locale, dict[str, str]]: + def _localize_commands(self, locales: list[str]) -> Translations: # pylint: disable=too-many-locals - """ - Translate the commands to all available locales - """ - result = {} - futures: list[Future] = [] + localizations = {} + lock = Lock() - def translate(text: str, locale: Locale, is_name: bool = False) -> tuple[Locale, bool, str, str]: - if len(text.strip()) == 0: - return locale, is_name, text, text + def translate(locale: str, text: str) -> None: + translated = translator.translate(text, locale, DEFAULT_LOCALE) - return locale, is_name, text, Translator(source="en", target=locale_to_code(locale)).translate(text) + with lock: + localizations[locale][text] = translated[:int(Limit.COMMAND_DESCRIPTION_LEN)] - with ThreadPoolExecutor() as executor: - for locale in Locale: - if locale in {Locale.british_english, Locale.american_english}: - continue + def localize(loc: Localization, msg_id: str, text: str, snake_case: bool) -> None: + result = loc.format_value(msg_id) + transformed = "".join(char for char in result if char.isalnum()).lower().replace(" ", "_") \ + if snake_case else result - result[locale] = {} + with lock: + localizations[loc.locales[0]][text] = transformed[:int(Limit.COMMAND_DESCRIPTION_LEN)] + with ThreadPoolExecutor() as executor: + for locale in locales: for command in self.tree.walk_commands(): - futures.append(executor.submit(translate, command.name, locale, True)) - futures.append(executor.submit(translate, command.description, locale)) + loc = Localization(locale, [os.path.join( + "commands", f"{command.root_parent.name if command.root_parent else command.name}.ftl" + )]) + + command_prefix = command.name.replace('_', '-') + + executor.submit(localize, loc, f"{command_prefix}-name", command.name, True) + executor.submit(localize, loc, f"{command_prefix}-description", command.description, False) if isinstance(command, app_commands.Group): continue - for name, description, choices in \ - [(param.name, param.description, param.choices) for param in command.parameters]: - futures.append(executor.submit(translate, name, locale, True)) - futures.append(executor.submit(translate, description, locale)) + for name, description, choices in [(param.name, param.description, param.choices) + for param in command.parameters]: + executor.submit(localize, loc, f"{command_prefix}-{name}-name", name, True) + executor.submit(localize, loc, f"{command_prefix}-{name}-description", description, False) for choice in choices: - futures.append(executor.submit(translate, choice.name, locale, True)) + executor.submit(translate, locale, choice.name) for context_menu in itertools.chain(self.tree.walk_commands(type=AppCommandType.message), self.tree.walk_commands(type=AppCommandType.user)): - futures.append(executor.submit(translate, context_menu.name, locale)) + loc = Localization(locale, [os.path.join( + "context_menus", f"{context_menu.name.lower().replace(' ', '_')}.ftl" + )]) + + executor.submit(localize, loc, f"{context_menu.name.lower().replace(' ', '-')}-name", + context_menu.name, False) + + executor.shutdown(wait=True) + + return localizations + + def _translate_commands(self, locales) -> Translations: + """ + Translate the commands to given locales + """ + + translations = {} + lock = Lock() + pbar = tqdm(total=0, desc="Translating commands", unit="locale") + + def translate_batch(locale: str, texts: list[str], snake_case: list[bool]) -> None: + with lock: + pbar.total += 1 - for future in tqdm(concurrent.futures.as_completed(futures), "Translating commands", - total=len(futures), unit="texts"): - locale, is_name, text, translated = future.result() + translated = Translator(DEFAULT_LOCALE, locale).translate_batch(texts) - if is_name: - translated = "".join(char for char in translated if char.isalnum()).lower().replace(" ", "_") + with lock: + for i, text in enumerate(texts): + result = "".join(char for char in translated[i] + if char.isalnum()).lower().replace(" ", "_" + ) if snake_case[i] else translated[i] + translations[locale][text] = result[:int(Limit.COMMAND_DESCRIPTION_LEN)] - result[locale][text] = translated[:int(Limit.COMMAND_DESCRIPTION_LEN)] + pbar.update() + pbar.set_description(f"Translated commands ({locale})") - return result + with ThreadPoolExecutor() as executor: + for locale in locales: + locale = locale_to_code(locale) + translations[locale] = {} + + batch = [] + snake_cases = [] + for command in self.tree.walk_commands(): + batch.append(command.name) + snake_cases.append(True) + + batch.append(command.description) + snake_cases.append(False) + + if isinstance(command, app_commands.Group): + continue + + for param in command.parameters: + batch.append(param.name) + snake_cases.append(True) + + batch.append(param.description) + snake_cases.append(False) + + batch.extend(choice.name for choice in param.choices) + snake_cases.extend(itertools.repeat(False, len(param.choices))) + + for context_menu in itertools.chain(self.tree.walk_commands(type=AppCommandType.message), + self.tree.walk_commands(type=AppCommandType.user)): + batch.append(context_menu.name) + snake_cases.append(False) + + executor.submit(translate_batch, locale, batch, snake_cases) + + executor.shutdown(wait=True) + + return translations async def setup_hook(self): - # cache = self._translate_commands() - # await self.tree.set_translator(CommandTranslator(cache)) + localized = [] + non_localized = [] + + for locale in map(locale_to_code, Locale): + if has_localization(locale): + localized.append(locale) + else: + non_localized.append(locale) + + translations = self._translate_commands(non_localized) + localizations = self._localize_commands(localized) + + # merge two dicts + for locale, localization in localizations.items(): + translations[locale] = localization + + localizations.clear() + + await self.tree.set_translator(CommandTranslator(translations)) if IS_DEV_ENV: self.tree.copy_global_to(guild=TEST_GUILD) @@ -154,7 +230,7 @@ async def setup_hook(self): synced_commands = [command.name for command in await self.tree.sync()] logging.info("Synced commands to all guilds: %s", str(synced_commands)) - # cache.clear() + translations.clear() bot = SoruSora() diff --git a/utils/constants.py b/utils/constants.py index 612353f..9701899 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -1,10 +1,17 @@ """ Provides list of constants +Classes: + ErrorCode + Limit + Constants: - EMBED_DESCRIPTION_MAX_LENGTH - MAX_NUM_EMBEDS_IN_MESSAGE - LANGUAGES + ROOT_DIR + CACHE_DIR + ASSETS_DIR + BOT_NAME + DATABASE_NAME + DEFAULT_LANGUAGE """ import os @@ -15,6 +22,7 @@ class ErrorCode(Enum): """ Provides error codes from discord API """ + MESSAGE_EXPIRED = 50027 MESSAGE_TOO_LONG = 50035 def __eq__(self, other): @@ -46,3 +54,5 @@ def __int__(self): BOT_NAME = "SoruSora" DATABASE_NAME = "SoruSora" + +DEFAULT_LOCALE = "en" diff --git a/utils/translator.py b/utils/translator.py index ac5119a..c54ff2c 100644 --- a/utils/translator.py +++ b/utils/translator.py @@ -7,9 +7,11 @@ BatchTranslator Functions: + has_localization translate is_english language_to_code + code_to_language locale_to_code locale_to_language @@ -61,6 +63,7 @@ "vietnamese", ] +Translations = dict[str, dict[str, str]] Translator: Type[BaseTranslator] = GoogleTranslator _translator = Translator() @@ -73,51 +76,26 @@ logger = logging.getLogger(__name__) -class Localization: - """ - Provides localization functionality +def translator_code(code: str) -> str: """ + Convert the language code to the code for translators - _loader = FluentResourceLoader(os.path.join("locales", "{locale}")) - - def __init__(self, locales: list[str], resources: list[str]): - self._localization = FluentLocalization(locales, resources, self._loader) - - def format_value(self, msg_id: str, args: Optional[dict[str, Any]] = None) -> str: - """ - Format the value of the message id with the arguments. If the message id is not found, translate it to the first - locale - - :param msg_id: The message id to format - :param args: The arguments to format the message with - :return: The formatted message - """ - - text = self._localization.format_value(msg_id, args) - if text != msg_id: # format_value() returns msg_id if not found - return text - - return translate(text, self._localization.locales[0]) - - @property - def locales(self) -> list[str]: - """ - Get the locales of the localization + :param code: The language code to convert + :return: The translator code + """ - :return: The locales of the localization - """ + return code.split("-")[0] - return self._localization.locales - @property - def resources(self) -> list[str]: - """ - Get the resources of the localization +def has_localization(locale: str) -> bool: + """ + Check if the locale has localization - :return: The resources of the localization - """ + :param locale: The locale to check + :return: True if the locale has localization + """ - return self._localization.resource_ids + return os.path.exists(os.path.join("locales", locale)) def translate(text: str, target: str, source: str = "auto") -> str: @@ -159,6 +137,24 @@ def language_to_code(language: str) -> str: return _LANGUAGES_TO_CODES[language] +def code_to_language(code: str) -> str: + """ + Get the language of the language code + + :param code: The language code to get the language of + :return: The language of the language code + :raises ValueError: If the language code is not supported by the translator + """ + + if code not in _CODES_TO_LANGUAGES: + code = translator_code(code) + + if code not in _CODES_TO_LANGUAGES: + raise ValueError(f"Language code {code} is not supported") + + return _CODES_TO_LANGUAGES[code] + + def locale_to_code(locale: Locale) -> str: """ Get the language code of the locale @@ -172,8 +168,8 @@ def locale_to_code(locale: Locale) -> str: if _translator.is_language_supported(language_code): return language_code - # try again with characters after '-' truncated - language_code = language_code.split("-", maxsplit=1)[0] + language_code = translator_code(language_code) + if _translator.is_language_supported(language_code): return language_code @@ -192,20 +188,76 @@ def locale_to_language(locale: Locale) -> str: return _CODES_TO_LANGUAGES[locale_to_code(locale)] +class Localization: + """ + Provides localization functionality + """ + + _loader = FluentResourceLoader(os.path.join("locales", "{locale}")) + + def __init__(self, locale: str, resources: list[str], fallbacks: Optional[list[str]] = None): + if len(resources) == 0: + raise ValueError("At least one resource must be provided") + + locales = [locale] + if fallbacks is not None: + locales.extend(fallbacks) + + self._loc = FluentLocalization(locales, resources, self._loader) + + def format_value(self, msg_id: str, args: Optional[dict[str, Any]] = None) -> str: + """ + Format the value of the message id with the arguments. + + :param msg_id: The message id to format + :param args: The arguments to format the message with + :return: The formatted message + :raises ValueError: If the message id is not found + """ + + result = self._loc.format_value(msg_id, args) + if result == msg_id: # format_value() returns msg_id if not found + raise ValueError(f"Localization '{self._loc.locales[0]}' not found for message id '{msg_id}' in resources" + f" '{self._loc.resource_ids}'") + + return result + + @property + def locales(self) -> list[str]: + """ + Get the locales of the localization + + :return: The locales of the localization + """ + + return self._loc.locales + + @property + def resources(self) -> list[str]: + """ + Get the resources of the localization + + :return: The resources of the localization + """ + + return self._loc.resource_ids + + class CommandTranslator(discord.app_commands.Translator): """ Translator for the commands """ - def __init__(self, cache: dict[Locale, dict[str, str]] = None): + def __init__(self, translations: Translations): super().__init__() - self._cache = cache or {} + self._translations = translations async def translate(self, string: locale_str, locale: Locale, context: TranslationContextTypes) -> Optional[str]: - if locale in {Locale.british_english, Locale.american_english}: + locale = locale_to_code(locale) + if is_english(locale): return None - if locale in self._cache and string.message in self._cache[locale]: - return self._cache[locale][string.message] + if locale in self._translations and string.message in self._translations[locale]: + return self._translations[locale][string.message] logger.warning("Translation of text '%s' not found for locale '%s'", string.message, locale) return None diff --git a/utils/ui.py b/utils/ui.py index 63109ae..9aa90a5 100644 --- a/utils/ui.py +++ b/utils/ui.py @@ -28,13 +28,13 @@ def __init__(self, confirmed_message: str, cancelled_message: str, locale: Local """ super().__init__() - self._localization = Localization([locale_to_code(locale)], [os.path.join("utils", "ui.ftl")]) + self._loc = Localization(locale_to_code(locale), [os.path.join("utils", "ui.ftl")]) self._confirmed_message = success(confirmed_message) self._cancelled_message = info(cancelled_message) - self.confirm.label = self._localization.format_value("confirm") - self.cancel.label = self._localization.format_value("cancel") + self.confirm.label = self._loc.format_value("confirm") + self.cancel.label = self._loc.format_value("cancel") self.is_confirmed = None """