mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-06 11:18:54 -05:00
401 lines
12 KiB
Python
401 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import functools
|
|
import io
|
|
import os
|
|
import logging
|
|
import discord
|
|
|
|
from pathlib import Path
|
|
from typing import Callable, TYPE_CHECKING, Union, Dict, Optional, TypeVar
|
|
|
|
import babel.localedata
|
|
from babel.core import Locale
|
|
|
|
from redbot.core import _i18n
|
|
from redbot.core._i18n import (
|
|
current_locale as _current_locale,
|
|
current_regional_format as _current_regional_format,
|
|
set_contextual_locale as _set_contextual_locale,
|
|
set_contextual_regional_format as _set_contextual_regional_format,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from redbot.core.bot import Red
|
|
|
|
|
|
__all__ = (
|
|
"get_locale",
|
|
"get_regional_format",
|
|
"set_contextual_locale",
|
|
"set_contextual_regional_format",
|
|
"get_locale_from_guild",
|
|
"get_regional_format_from_guild",
|
|
"set_contextual_locales_from_guild",
|
|
"Translator",
|
|
"get_babel_locale",
|
|
"get_babel_regional_format",
|
|
"cog_i18n",
|
|
)
|
|
|
|
log = logging.getLogger("red.i18n")
|
|
|
|
WAITING_FOR_MSGID = 1
|
|
IN_MSGID = 2
|
|
WAITING_FOR_MSGSTR = 3
|
|
IN_MSGSTR = 4
|
|
|
|
MSGID = 'msgid "'
|
|
MSGSTR = 'msgstr "'
|
|
|
|
|
|
def get_locale() -> str:
|
|
"""
|
|
Get locale in a current context.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
Current locale's language code with country code included, e.g. "en-US".
|
|
"""
|
|
return _current_locale.get(_i18n.current_locale_default)
|
|
|
|
|
|
def get_regional_format() -> str:
|
|
"""
|
|
Get regional format in a current context.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
Current regional format's language code with country code included, e.g. "en-US".
|
|
"""
|
|
regional_format = _current_regional_format.get(_i18n.current_regional_format_default)
|
|
if regional_format is None:
|
|
return _current_locale.get(_i18n.current_locale_default)
|
|
return regional_format
|
|
|
|
|
|
def set_contextual_locale(language_code: str, /) -> str:
|
|
"""
|
|
Set contextual locale (without regional format) to the given value.
|
|
|
|
Parameters
|
|
----------
|
|
language_code: str
|
|
Locale's language code with country code included, e.g. "en-US".
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
Standardized locale name.
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
Language code is invalid.
|
|
"""
|
|
return _set_contextual_locale(language_code, verify_language_code=True)
|
|
|
|
|
|
def set_contextual_regional_format(language_code: Optional[str], /) -> Optional[str]:
|
|
"""
|
|
Set contextual regional format to the given value.
|
|
|
|
Parameters
|
|
----------
|
|
language_code: str, optional
|
|
Contextual regional's language code with country code included, e.g. "en-US"
|
|
or ``None`` if regional format should inherit the contextual locale's value.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
Standardized locale name or ``None`` if ``None`` was passed.
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
Language code is invalid.
|
|
"""
|
|
return _set_contextual_regional_format(language_code, verify_language_code=True)
|
|
|
|
|
|
async def get_locale_from_guild(bot: Red, guild: Optional[discord.Guild]) -> str:
|
|
"""
|
|
Get locale set for the given guild.
|
|
|
|
Parameters
|
|
----------
|
|
bot: Red
|
|
The bot's instance.
|
|
guild: Optional[discord.Guild]
|
|
The guild contextual locale is set for.
|
|
Use `None` if the context doesn't involve guild.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
Guild locale's language code with country code included, e.g. "en-US".
|
|
"""
|
|
return await bot._i18n_cache.get_locale(guild)
|
|
|
|
|
|
async def get_regional_format_from_guild(bot: Red, guild: Optional[discord.Guild]) -> str:
|
|
"""
|
|
Get regional format for the given guild.
|
|
|
|
Parameters
|
|
----------
|
|
bot: Red
|
|
The bot's instance.
|
|
guild: Optional[discord.Guild]
|
|
The guild contextual locale is set for.
|
|
Use `None` if the context doesn't involve guild.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
Guild regional format's language code with country code included, e.g. "en-US".
|
|
"""
|
|
return await bot._i18n_cache.get_regional_format(guild)
|
|
|
|
|
|
async def set_contextual_locales_from_guild(bot: Red, guild: Optional[discord.Guild]) -> None:
|
|
"""
|
|
Set contextual locales (locale and regional format) for given guild context.
|
|
|
|
Parameters
|
|
----------
|
|
bot: Red
|
|
The bot's instance.
|
|
guild: Optional[discord.Guild]
|
|
The guild contextual locale is set for.
|
|
Use `None` if the context doesn't involve guild.
|
|
"""
|
|
locale = await get_locale_from_guild(bot, guild)
|
|
regional_format = await get_regional_format_from_guild(bot, guild)
|
|
_set_contextual_locale(locale)
|
|
_set_contextual_regional_format(regional_format)
|
|
|
|
|
|
def _parse(translation_file: io.TextIOWrapper) -> Dict[str, str]:
|
|
"""
|
|
Custom gettext parsing of translation files.
|
|
|
|
Parameters
|
|
----------
|
|
translation_file : io.TextIOWrapper
|
|
An open text file containing translations.
|
|
|
|
Returns
|
|
-------
|
|
Dict[str, str]
|
|
A dict mapping the original strings to their translations. Empty
|
|
translated strings are omitted.
|
|
|
|
"""
|
|
step = None
|
|
untranslated = ""
|
|
translated = ""
|
|
translations = {}
|
|
locale = get_locale()
|
|
|
|
translations[locale] = {}
|
|
|
|
for line in translation_file:
|
|
line = line.strip()
|
|
|
|
if line.startswith(MSGID):
|
|
# New msgid
|
|
if step is IN_MSGSTR and translated:
|
|
# Store the last translation
|
|
translations[locale][_unescape(untranslated)] = _unescape(translated)
|
|
step = IN_MSGID
|
|
untranslated = line[len(MSGID) : -1]
|
|
elif line.startswith('"') and line.endswith('"'):
|
|
if step is IN_MSGID:
|
|
# Line continuing on from msgid
|
|
untranslated += line[1:-1]
|
|
elif step is IN_MSGSTR:
|
|
# Line continuing on from msgstr
|
|
translated += line[1:-1]
|
|
elif line.startswith(MSGSTR):
|
|
# New msgstr
|
|
step = IN_MSGSTR
|
|
translated = line[len(MSGSTR) : -1]
|
|
|
|
if step is IN_MSGSTR and translated:
|
|
# Store the final translation
|
|
translations[locale][_unescape(untranslated)] = _unescape(translated)
|
|
return translations
|
|
|
|
|
|
def _unescape(string):
|
|
string = string.replace(r"\\", "\\")
|
|
string = string.replace(r"\t", "\t")
|
|
string = string.replace(r"\r", "\r")
|
|
string = string.replace(r"\n", "\n")
|
|
string = string.replace(r"\"", '"')
|
|
return string
|
|
|
|
|
|
def _get_locale_path(cog_folder: Path, extension: str) -> Path:
|
|
"""
|
|
Gets the folder path containing localization files.
|
|
|
|
:param Path cog_folder:
|
|
The cog folder that we want localizations for.
|
|
:param str extension:
|
|
Extension of localization files.
|
|
:return:
|
|
Path of possible localization file, it may not exist.
|
|
"""
|
|
return cog_folder / "locales" / "{}.{}".format(get_locale(), extension)
|
|
|
|
|
|
class Translator(Callable[[str], str]):
|
|
"""Function to get translated strings at runtime."""
|
|
|
|
def __init__(self, name: str, file_location: Union[str, Path, os.PathLike]):
|
|
"""
|
|
Initializes an internationalization object.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
Your cog name.
|
|
file_location : `str` or `pathlib.Path`
|
|
This should always be ``__file__`` otherwise your localizations
|
|
will not load.
|
|
|
|
"""
|
|
self.cog_folder = Path(file_location).resolve().parent
|
|
self.cog_name = name
|
|
self.translations = {}
|
|
|
|
_i18n.translators.append(self)
|
|
|
|
self.load_translations()
|
|
|
|
def __call__(self, untranslated: str) -> str:
|
|
"""Translate the given string.
|
|
|
|
This will look for the string in the translator's :code:`.pot` file,
|
|
with respect to the current locale.
|
|
"""
|
|
locale = get_locale()
|
|
try:
|
|
return self.translations[locale][untranslated]
|
|
except KeyError:
|
|
return untranslated
|
|
|
|
def load_translations(self):
|
|
"""
|
|
Loads the current translations.
|
|
"""
|
|
locale = get_locale()
|
|
|
|
if locale.lower() == "en-us":
|
|
# Red is written in en-US, no point in loading it
|
|
return
|
|
if locale in self.translations:
|
|
# Locales cannot be loaded twice as they have an entry in
|
|
# self.translations
|
|
return
|
|
|
|
locale_path = _get_locale_path(self.cog_folder, "po")
|
|
with contextlib.suppress(IOError, FileNotFoundError):
|
|
with locale_path.open(encoding="utf-8") as file:
|
|
self._parse(file)
|
|
|
|
def _parse(self, translation_file):
|
|
self.translations.update(_parse(translation_file))
|
|
|
|
def _add_translation(self, untranslated, translated):
|
|
untranslated = _unescape(untranslated)
|
|
translated = _unescape(translated)
|
|
if translated:
|
|
self.translations[untranslated] = translated
|
|
|
|
|
|
@functools.lru_cache()
|
|
def _get_babel_locale(red_locale: str) -> babel.core.Locale:
|
|
supported_locales = babel.localedata.locale_identifiers()
|
|
try: # Handles cases where red_locale is already Babel supported
|
|
babel_locale = Locale(*babel.parse_locale(red_locale))
|
|
except (ValueError, babel.core.UnknownLocaleError):
|
|
try:
|
|
babel_locale = Locale(*babel.parse_locale(red_locale, sep="-"))
|
|
except (ValueError, babel.core.UnknownLocaleError):
|
|
# ValueError is Raised by `parse_locale` when an invalid Locale is given to it
|
|
# Lets handle it silently and default to "en_US"
|
|
try:
|
|
# Try to find a babel locale that's close to the one used by red
|
|
babel_locale = Locale(Locale.negotiate([red_locale], supported_locales, sep="-"))
|
|
except (ValueError, TypeError, babel.core.UnknownLocaleError):
|
|
# If we fail to get a close match we will then default to "en_US"
|
|
babel_locale = Locale("en", "US")
|
|
return babel_locale
|
|
|
|
|
|
def get_babel_locale(locale: Optional[str] = None) -> babel.core.Locale:
|
|
"""Function to convert a locale to a `babel.core.Locale`.
|
|
|
|
Parameters
|
|
----------
|
|
locale : Optional[str]
|
|
The locale to convert, if not specified it defaults to the bot's locale.
|
|
|
|
Returns
|
|
-------
|
|
babel.core.Locale
|
|
The babel locale object.
|
|
"""
|
|
if locale is None:
|
|
locale = get_locale()
|
|
return _get_babel_locale(locale)
|
|
|
|
|
|
def get_babel_regional_format(regional_format: Optional[str] = None) -> babel.core.Locale:
|
|
"""Function to convert a regional format to a `babel.core.Locale`.
|
|
|
|
If ``regional_format`` parameter is passed, this behaves the same as `get_babel_locale`.
|
|
|
|
Parameters
|
|
----------
|
|
regional_format : Optional[str]
|
|
The regional format to convert, if not specified it defaults to the bot's regional format.
|
|
|
|
Returns
|
|
-------
|
|
babel.core.Locale
|
|
The babel locale object.
|
|
"""
|
|
if regional_format is None:
|
|
regional_format = get_regional_format()
|
|
return _get_babel_locale(regional_format)
|
|
|
|
|
|
# This import to be down here to avoid circular import issues.
|
|
# This will be cleaned up at a later date
|
|
# noinspection PyPep8
|
|
from . import commands
|
|
|
|
_TypeT = TypeVar("_TypeT", bound=type)
|
|
|
|
|
|
def cog_i18n(translator: Translator) -> Callable[[_TypeT], _TypeT]:
|
|
"""Get a class decorator to link the translator to this cog."""
|
|
|
|
def decorator(cog_class: _TypeT) -> _TypeT:
|
|
cog_class.__translator__ = translator
|
|
for name, attr in cog_class.__dict__.items():
|
|
if isinstance(attr, (commands.Group, commands.Command)):
|
|
attr.translator = translator
|
|
setattr(cog_class, name, attr)
|
|
return cog_class
|
|
|
|
return decorator
|