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

Add dynamic language #47

Merged
merged 8 commits into from
Jan 2, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 10 additions & 1 deletion docs/examples/languages.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Modify language files
- If you want to modify the English language file: ``ez_en.json``.
- If you want to create a new language file: ``ez_[language].json``.

For example, if you want to create a French language file, the file name could be ``ez_fr.json``.
If you want to create a French language file, the file name could be ``ez_fr.json``.

2. Search the :ref:`language files <language>` and find keys you want to override.

Expand All @@ -30,6 +30,15 @@ Modify language files

bot = ezcord.Bot(language="fr") # French (loaded from ez_fr.json)

If your bot supports **multiple languages**, set ``language`` to ``auto`` to
automatically detect the language. You can set a fallback language with ``default_language``.

The fallback language is used when no language file is found for the detected language.

.. code-block:: python

bot = ezcord.Bot(language="auto", default_language="en")


.. _language:

Expand Down
34 changes: 18 additions & 16 deletions ezcord/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,8 @@
READY_TITLE,
EzConfig,
get_error_text,
load_lang,
print_custom_ready,
print_ready,
set_lang,
t,
)
from .internal.config import Blacklist
Expand Down Expand Up @@ -76,10 +74,12 @@ class Bot(_main_bot): # type: ignore
Whether to send the full error traceback. If this is ``False``,
only the most recent traceback will be sent. Defaults to ``False``.
language:
The language to use for user output.

The default languages are If you add your own language file as
described in :doc:`the language example </examples/languages>`, you can use that language as well.
The language to use for user output. If this is set to ``auto``,
the bot will use the language of the interaction locale whenever possible.
default_language:
The default language to use if the interaction locale is not available.
Defaults to ``"en"``. ``en`` and ``de`` are available by default, but you can add your own
language as described in :doc:`the language example </examples/languages>`.
ready_event:
The style for :meth:`on_ready_event`. Defaults to :attr:`.ReadyEvent.default`.
If this is ``None``, the event will be disabled.
Expand All @@ -96,7 +96,8 @@ def __init__(
error_webhook_url: str | None = None,
ignored_errors: list[Any] | None = None,
full_error_traceback: bool = False,
language: str = "en",
language: str = "auto",
default_language: str = "en",
ready_event: ReadyEvent | None = ReadyEvent.default,
**kwargs,
):
Expand All @@ -119,8 +120,9 @@ def __init__(
self.error_webhook_url = error_webhook_url
self.ignored_errors = ignored_errors or []
self.full_error_traceback = full_error_traceback
load_lang(language)
set_lang(language) if language != {} else set_lang("en")

EzConfig.lang = language
EzConfig.default_lang = default_language

self.error_event_added = False
if error_handler or error_webhook_url:
Expand Down Expand Up @@ -433,20 +435,20 @@ async def _error_event(self, ctx, error: discord.DiscordException):
or type(error) is commands.CheckFailure
):
if self.error_handler:
await error_emb(ctx, t("no_user_perms"))
await error_emb(ctx, t("no_user_perms", i=ctx))
return

if isinstance(error, commands.CommandOnCooldown):
if self.error_handler:
seconds = round(ctx.command.get_cooldown_retry_after(ctx))
cooldown_txt = t("cooldown", dc_timestamp(seconds))
await error_emb(ctx, cooldown_txt, title=t("cooldown_title"))
cooldown_txt = t("cooldown", dc_timestamp(seconds), i=ctx)
await error_emb(ctx, cooldown_txt, title=t("cooldown_title", i=ctx))

elif isinstance(error, checks.BotMissingPermissions):
if self.error_handler:
perms = "\n".join(error.missing_permissions)
perm_txt = f"{t('no_perms')} ```\n{perms}```"
await error_emb(ctx, perm_txt, title=t("no_perms_title"))
perm_txt = f"{t('no_perms', i=ctx)} ```\n{perms}```"
await error_emb(ctx, perm_txt, title=t("no_perms_title", i=ctx))

else:
if "original" in error.__dict__ and not self.full_error_traceback:
Expand All @@ -457,9 +459,9 @@ async def _error_event(self, ctx, error: discord.DiscordException):
error_msg = f"{error}"

if self.error_handler:
error_txt = f"{t('error', f'```{error_msg}```')}"
error_txt = f"{t('error', f'```{error_msg}```', i=ctx)}"
try:
await error_emb(ctx, error_txt, title=t("error_title"))
await error_emb(ctx, error_txt, title=t("error_title", i=ctx))
except discord.HTTPException as e:
# ignore invalid interaction error, probably took too long to respond
if e.code != 10062:
Expand Down
3 changes: 1 addition & 2 deletions ezcord/cogs/blacklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
"""

import aiosqlite
import discord

from .. import emb
from ..blacklist import _BanDB
Expand Down Expand Up @@ -36,7 +35,7 @@ async def _check_blacklist(interaction: discord.Interaction) -> bool:
if EzConfig.blacklist.raise_error:
raise Blacklisted()
else:
await emb.error(interaction, t("no_perms"))
await emb.error(interaction, t("no_perms", i=interaction))
raise ErrorMessageSent()
return True

Expand Down
24 changes: 15 additions & 9 deletions ezcord/cogs/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ def __init__(self, bot: Bot):
async def help(self, ctx):
embed = self.bot.help.embed
if embed is None:
embed = discord.Embed(title=t("embed_title"), color=discord.Color.blue())
embed = discord.Embed(
title=t("embed_title", i=ctx.interaction), color=discord.Color.blue()
)
else:
interaction = ctx.interaction if PYCORD else ctx
embed = replace_embed_values(embed, interaction)
Expand Down Expand Up @@ -120,7 +122,7 @@ async def help(self, ctx):

desc = cog.description
if not cog.description:
desc = t("default_description", name)
desc = t("default_description", name, i=ctx)
if not desc:
log.warning(
f"The default description for cog '{name}' is invalid. "
Expand Down Expand Up @@ -184,7 +186,7 @@ async def help(self, ctx):
embed.add_field(name=field_name, value=desc, inline=False)

if len(options) == 0:
return await ctx.response.send_message(t("no_commands"), ephemeral=True)
return await ctx.response.send_message(t("no_commands", i=ctx), ephemeral=True)
if len(options) > 25 or len(embed.fields) > 25:
log.error(
f"Help command category limit reached. Only 25 out of {len(options)} are shown."
Expand All @@ -194,7 +196,7 @@ async def help(self, ctx):

sorted_options = sorted(options, key=lambda x: [char for char in x.label if char.isalpha()])
embed.fields = sorted(embed.fields, key=lambda x: x.name.lower())
view = CategoryView(sorted_options, self.bot, ctx.user, commands)
view = CategoryView(sorted_options, self.bot, ctx.user, commands, ctx)
for button in self.bot.help.buttons:
view.add_item(deepcopy(button))
await ctx.response.send_message(view=view, embed=embed, ephemeral=self.bot.help.ephemeral)
Expand All @@ -207,8 +209,11 @@ def __init__(
bot: Bot,
member: discord.Member | discord.User,
commands: dict[str, dict],
interaction,
):
super().__init__(min_values=1, max_values=1, placeholder=t("placeholder"), options=options)
super().__init__(
min_values=1, max_values=1, placeholder=t("placeholder", i=interaction), options=options
)
self.bot = bot
self.member = member
self.commands = commands
Expand All @@ -228,7 +233,7 @@ def get_mention(self, cmd) -> str:

async def callback(self, interaction: discord.Interaction):
if self.bot.help.author_only and interaction.user != self.member:
return await emb.error(interaction, t("wrong_user"))
return await emb.error(interaction, t("wrong_user", i=interaction))

cmds = self.commands[self.values[0]]
title = self.values[0].title()
Expand Down Expand Up @@ -296,9 +301,9 @@ async def callback(self, interaction: discord.Interaction):
break

if len(commands) == 0:
embed.description = t("no_commands")
embed.description = t("no_commands", i=interaction)

view = CategoryView(self.options, self.bot, self.member, self.commands)
view = CategoryView(self.options, self.bot, self.member, self.commands, interaction)
for button in self.bot.help.buttons:
view.add_item(deepcopy(button))
await interaction.response.edit_message(embed=embed, view=view)
Expand All @@ -311,10 +316,11 @@ def __init__(
bot: Bot,
member: discord.Member | discord.User,
commands: dict[str, dict],
interaction: discord.Interaction,
):
if PYCORD:
super().__init__(timeout=bot.help.timeout, disable_on_timeout=True)
else:
super().__init__(timeout=None)

self.add_item(CategorySelect(options, bot, member, commands))
self.add_item(CategorySelect(options, bot, member, commands, interaction))
1 change: 1 addition & 0 deletions ezcord/internal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class EzConfig:
BLACKLIST_COMMANDS = Literal["add", "remove", "show", "owner", "server", "show_servers"]

lang: str = "en"
default_lang: str = "de"
embed_templates: dict = {}

# Blacklist
Expand Down
8 changes: 6 additions & 2 deletions ezcord/internal/language/languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
from pathlib import Path

from ...logs import log
from ..config import EzConfig


@cache
def load_lang(language: str) -> dict[str, dict[str, str]]:
"""Loads the default language file and checks if the user provided a custom language file."""
"""Loads the given language file and checks if the user provided a custom language file."""

if language == "auto":
language = EzConfig.default_lang

lang = {}
parent = Path(__file__).parent.absolute()
Expand Down Expand Up @@ -37,7 +41,7 @@ def load_lang(language: str) -> dict[str, dict[str, str]]:
lang[category] = {}
lang[category][value] = values[value]

if lang == {}:
if EzConfig.lang != "auto" and lang == {}:
log.warn(f"Language file for language '{language}' not found. Falling back to 'en'.")

return lang
59 changes: 32 additions & 27 deletions ezcord/internal/translation.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Internal language utilities for the library."""

from __future__ import annotations

import inspect
from functools import cache
from pathlib import Path

from ..internal.dc import discord
from ..logs import log
from .config import EzConfig
from .language.languages import load_lang
Expand Down Expand Up @@ -110,7 +112,9 @@ def plural_fr(amount: int, word: str) -> str:
return word


def tp(key: str, amount: int, *args: str, relative: bool = True) -> str:
def tp(
key: str, amount: int, *args: str, relative: bool = True, i: discord.Interaction | None = None
) -> str:
"""Load a string in the selected language and pluralize it.

Parameters
Expand All @@ -123,9 +127,11 @@ def tp(key: str, amount: int, *args: str, relative: bool = True) -> str:
The arguments to format the string with.
relative:
Whether to use relative time. Defaults to ``True``.
i:
The interaction to get the language from. Defaults to ``None``.
"""
word = t(key, *args)
lang = get_lang()
word = t(key, *args, i=i)
lang = EzConfig.lang

if lang == "de":
return plural_de(amount, word, relative)
Expand All @@ -137,7 +143,20 @@ def tp(key: str, amount: int, *args: str, relative: bool = True) -> str:
return plural_en(amount, word)


def t(key: str, *args: str):
def get_locale(interaction: discord.Interaction | None) -> str:
if not interaction and EzConfig.lang == "auto":
return EzConfig.default_lang

if interaction and EzConfig.lang == "auto":
locale = interaction.guild_locale if interaction.guild_locale else interaction.locale
locale = locale.split("-")[0]
else:
locale = EzConfig.lang

return locale


def t(key: str, *args: str, i: discord.Interaction | None = None) -> str:
"""Load a string in the selected language.

Parameters
Expand All @@ -146,6 +165,8 @@ def t(key: str, *args: str):
The text to load.
*args:
The arguments to format the string with.
i:
The interaction to get the language from. Defaults to ``None``.
"""
n = 1
origin_file = Path(inspect.stack()[n].filename).stem
Expand All @@ -154,32 +175,16 @@ def t(key: str, *args: str):
n += 1
origin_file = Path(inspect.stack()[n].filename).stem

lang = get_lang()
lang = EzConfig.lang
locale = get_locale(i)

try:
string = load_lang(lang)[origin_file][key]
if not string:
return None
lang_dict = load_lang(locale)
string = lang_dict[origin_file][key]
return string.format(*args)
except KeyError:
# fallback to english if the key is not in the custom language file
# provided by the user
log.warn(f"Key '{key}' not found in language file '{lang}'. Falling back to 'en'.")
if lang != "auto":
log.warn(f"Key '{key}' not found in language file '{lang}'. Falling back to 'en'.")
return load_lang("en")[origin_file][key].format(*args)


@cache
def get_lang():
"""Get the language from the config class."""
return EzConfig.lang


def set_lang(lang: str):
"""Set the language for the bot.

Parameters
----------
lang:
The language to set.
"""
EzConfig.lang = lang
Loading