rewrite: Restructure to include GUI.

This commit is contained in:
2022-12-23 06:44:32 +02:00
parent 2b93354248
commit f328324747
224 changed files with 8 additions and 0 deletions

6
src/babel/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
from .translator import SOURCE_LOCALE, LeoBabel, LocalBabel, LazyStr, ctx_locale, ctx_translator
async def setup(bot):
from .cog import BabelCog
await bot.add_cog(BabelCog(bot))

300
src/babel/cog.py Normal file
View File

@@ -0,0 +1,300 @@
"""
Babel Cog.
Calculates and sets current locale before command runs (via check_once).
Also defines the relevant guild and user settings for localisation.
"""
from typing import Optional
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from meta import LionBot, LionCog, LionContext
from meta.errors import UserInputError
from settings import ModelData
from settings.setting_types import StringSetting, BoolSetting
from settings.groups import SettingGroup
from core.data import CoreData
from .translator import ctx_locale, ctx_translator, LocalBabel, SOURCE_LOCALE
babel = LocalBabel('babel')
_ = babel._
_p = babel._p
class LocaleSettings(SettingGroup):
class UserLocale(ModelData, StringSetting):
"""
User-configured locale.
Exposed via dedicated setting command.
"""
setting_id = 'user_locale'
display_name = _p('userset:locale', 'language')
desc = _p('userset:locale|desc', "Your preferred language for interacting with me.")
_model = CoreData.User
_column = CoreData.User.locale.name
@property
def update_message(self):
t = ctx_translator.get().t
if self.data is None:
return t(_p('userset:locale|response', "You have unset your language."))
else:
return t(_p('userset:locale|response', "You have set your language to `{lang}`.")).format(
lang=self.data
)
@classmethod
async def _parse_string(cls, parent_id, string, **kwargs):
translator = ctx_translator.get()
if string not in translator.supported_locales:
lang = string[:20]
raise UserInputError(
translator.t(
_p('userset:locale|error', "Sorry, we do not support the `{lang}` language at this time!")
).format(lang=lang)
)
return string
class ForceLocale(ModelData, BoolSetting):
"""
Guild configuration for whether to force usage of the guild locale.
Exposed via `/configure language` command and standard configuration interface.
"""
setting_id = 'force_locale'
display_name = _p('guildset:force_locale', 'force_language')
desc = _p('guildset:force_locale|desc',
"Whether to force all members to use the configured guild language when interacting with me.")
long_desc = _p(
'guildset:force_locale|long_desc',
"When enabled, commands in this guild will always use the configured guild language, "
"regardless of the member's personally configured language."
)
_outputs = {
True: _p('guildset:force_locale|output', 'Enabled (members will be forced to use the server language)'),
False: _p('guildset:force_locale|output', 'Disabled (members may set their own language)'),
None: 'Not Set' # This should be impossible, since we have a default
}
_default = False
_model = CoreData.Guild
_column = CoreData.Guild.force_locale.name
@property
def update_message(self):
t = ctx_translator.get().t
if self.data:
return t(_p(
'guildset:force_locale|response',
"I will always use the set language in this server."
))
else:
return t(_p(
'guildset:force_locale|response',
"I will now allow the members to set their own language here."
))
class GuildLocale(ModelData, StringSetting):
"""
Guild-configured locale.
Exposed via `/configure language` command, and standard configuration interface.
"""
setting_id = 'guild_locale'
display_name = _p('guildset:locale', 'language')
desc = _p('guildset:locale|desc', "Your preferred language for interacting with me.")
_model = CoreData.Guild
_column = CoreData.Guild.locale.name
@property
def update_message(self):
t = ctx_translator.get().t
if self.data is None:
return t(_p('guildset:locale|response', "You have reset the guild language."))
else:
return t(_p('guildset:locale|response', "You have set the guild language to `{lang}`.")).format(
lang=self.data
)
@classmethod
async def _parse_string(cls, parent_id, string, **kwargs):
translator = ctx_translator.get()
if string not in translator.supported_locales:
lang = string[:20]
raise UserInputError(
translator.t(
_p('guildset:locale|error', "Sorry, we do not support the `{lang}` language at this time!")
).format(lang=lang)
)
return string
class BabelCog(LionCog):
depends = {'CoreCog'}
def __init__(self, bot: LionBot):
self.bot = bot
self.settings = LocaleSettings()
self.t = self.bot.translator.t
async def cog_load(self):
if not self.bot.core:
raise ValueError("CoreCog must be loaded first!")
self.bot.core.guild_settings.attach(LocaleSettings.ForceLocale)
self.bot.core.guild_settings.attach(LocaleSettings.GuildLocale)
self.bot.core.user_settings.attach(LocaleSettings.UserLocale)
async def cog_unload(self):
pass
async def get_user_locale(self, userid):
"""
Fetch the best locale we can guess for this userid.
"""
data = await self.bot.core.data.User.fetch(userid)
if data:
return data.locale or data.locale_hint or SOURCE_LOCALE
else:
return SOURCE_LOCALE
async def bot_check_once(self, ctx: LionContext): # type: ignore # Type checker doesn't understand coro checks
"""
Calculate and inject the current locale before the command begins.
Locale resolution is calculated as follows:
If the guild has force_locale enabled, and a locale set,
then the guild's locale will be used.
Otherwise, the priority is
user_locale -> command_locale -> user_locale_hint -> guild_locale -> default_locale
"""
locale = None
if ctx.guild:
forced = ctx.alion.guild_settings['force_locale'].value
guild_locale = ctx.alion.guild_settings['guild_locale'].value
if forced:
locale = guild_locale
locale = locale or ctx.alion.user_settings['user_locale'].value
if ctx.interaction:
locale = locale or ctx.interaction.locale.value
if ctx.guild:
locale = locale or guild_locale
locale = locale or SOURCE_LOCALE
ctx_locale.set(locale)
ctx_translator.set(self.bot.translator)
return True
@cmds.hybrid_command(
name=LocaleSettings.UserLocale.display_name,
description=LocaleSettings.UserLocale.desc
)
async def cmd_language(self, ctx: LionContext, language: str):
"""
Dedicated user setting command for the `locale` setting.
"""
if not ctx.interaction:
# This command is not available as a text command
return
setting = await self.settings.UserLocale.get(ctx.author.id)
new_data = await setting._parse_string(ctx.author.id, language)
await setting.interactive_set(new_data, ctx.interaction)
@cmds.hybrid_command(
name=_p('cmd:configure_language', "configure_language"),
description=_p('cmd:configure_language|desc',
"Configure the default language I will use in this server.")
)
@appcmds.choices(
force_language=[
appcmds.Choice(name=LocaleSettings.ForceLocale._outputs[True], value=1),
appcmds.Choice(name=LocaleSettings.ForceLocale._outputs[False], value=0),
]
)
@appcmds.guild_only() # Can be removed when attached as a subcommand
async def cmd_configure_language(
self, ctx: LionContext, language: Optional[str] = None, force_language: Optional[appcmds.Choice[int]] = None
):
if not ctx.interaction:
# This command is not available as a text command
return
if not ctx.guild:
# This is impossible by decorators, but adding this guard for the type checker
return
t = self.t
# TODO: Setting group, and group setting widget
# We can attach the command to the setting group as an application command
# Then load it into the configure command group dynamically
lang_setting = await self.settings.GuildLocale.get(ctx.guild.id)
force_setting = await self.settings.ForceLocale.get(ctx.guild.id)
if language:
lang_data = await lang_setting._parse_string(ctx.guild.id, language)
if force_language is not None:
force_data = bool(force_language)
if force_language is not None and not (lang_data if language is not None else lang_setting.value):
# Setting force without having a language!
raise UserInputError(
t(_p(
'cmd:configure_language|error',
"You cannot enable `{force_setting}` without having a configured language!"
)).format(force_setting=t(LocaleSettings.ForceLocale.display_name))
)
# TODO: Really need simultaneous model writes, or batched writes
lines = []
if language:
lang_setting.data = lang_data
await lang_setting.write()
lines.append(lang_setting.update_message)
if force_language is not None:
force_setting.data = force_data
await force_setting.write()
lines.append(force_setting.update_message)
result = '\n'.join(
f"{self.bot.config.emojis.tick} {line}" for line in lines
)
# TODO: Setting group widget
await ctx.reply(
embed=discord.Embed(
colour=discord.Colour.green(),
title=t(_p('cmd:configure_language|success', "Language settings updated!")),
description=result
)
)
@cmd_configure_language.autocomplete('language')
async def cmd_configure_language_acmpl_language(self, interaction: discord.Interaction, partial: str):
# TODO: More friendly language names
supported = self.bot.translator.supported_locales
matching = [lang for lang in supported if partial.lower() in lang]
t = self.t
if not matching:
return [
appcmds.Choice(
name=t(_p(
'cmd:configure_language|acmpl:language',
"No supported languages matching {partial}"
)).format(partial=partial),
value='None'
)
]
else:
return [
appcmds.Choice(name=lang, value=lang)
for lang in matching
]

34
src/babel/enums.py Normal file
View File

@@ -0,0 +1,34 @@
from enum import Enum
class LocaleMap(Enum):
american_english = 'en-US'
british_english = 'en-GB'
bulgarian = 'bg'
chinese = 'zh-CN'
taiwan_chinese = 'zh-TW'
croatian = 'hr'
czech = 'cs'
danish = 'da'
dutch = 'nl'
finnish = 'fi'
french = 'fr'
german = 'de'
greek = 'el'
hindi = 'hi'
hungarian = 'hu'
italian = 'it'
japanese = 'ja'
korean = 'ko'
lithuanian = 'lt'
norwegian = 'no'
polish = 'pl'
brazil_portuguese = 'pt-BR'
romanian = 'ro'
russian = 'ru'
spain_spanish = 'es-ES'
swedish = 'sv-SE'
thai = 'th'
turkish = 'tr'
ukrainian = 'uk'
vietnamese = 'vi'

157
src/babel/translator.py Normal file
View File

@@ -0,0 +1,157 @@
import gettext
import logging
from contextvars import ContextVar
from collections import defaultdict
from enum import Enum
from discord.app_commands import Translator, locale_str
from discord.enums import Locale
logger = logging.getLogger(__name__)
SOURCE_LOCALE = 'en_uk'
ctx_locale: ContextVar[str] = ContextVar('locale', default=SOURCE_LOCALE)
ctx_translator: ContextVar['LeoBabel'] = ContextVar('translator', default=None) # type: ignore
null = gettext.NullTranslations()
class LeoBabel(Translator):
def __init__(self):
self.supported_locales = {loc.name for loc in Locale}
self.supported_domains = {}
self.translators = defaultdict(dict) # locale -> domain -> GNUTranslator
def read_supported(self):
"""
Load supported localisations and domains from the config.
"""
from meta import conf
locales = conf.babel.get('locales', '')
stripped = (loc.strip(', ') for loc in locales.split(','))
self.supported_locales = {loc for loc in stripped if loc}
domains = conf.babel.get('domains', '')
stripped = (dom.strip(', ') for dom in domains.split(','))
self.supported_domains = {dom for dom in stripped if dom}
async def load(self):
"""
Initialise the gettext translators for the supported_locales.
"""
self.read_supported()
for locale in self.supported_locales:
for domain in self.supported_domains:
if locale == SOURCE_LOCALE:
continue
try:
translator = gettext.translation(domain, "locales/", languages=[locale])
except OSError:
# Presume translation does not exist
logger.warning(f"Could not load translator for supported <locale: {locale}> <domain: {domain}>")
pass
else:
logger.debug(f"Loaded translator for <locale: {locale}> <domain: {domain}>")
self.translators[locale][domain] = translator
async def unload(self):
self.translators.clear()
def get_translator(self, locale, domain):
if locale == SOURCE_LOCALE:
return null
translator = self.translators[locale].get(domain, None)
if translator is None:
logger.warning(
f"Translator missing for requested <locale: {locale}> and <domain: {domain}>. Setting NullTranslator."
)
self.translators[locale][domain] = null
translator = null
return translator
def t(self, lazystr, locale=None):
domain = lazystr.domain
translator = self.get_translator(locale or lazystr.locale, domain)
return lazystr._translate_with(translator)
async def translate(self, string: locale_str, locale: Locale, context):
if locale.value in self.supported_locales:
domain = string.extras.get('domain', None)
if domain is None and isinstance(string, LazyStr):
logger.debug(
f"LeoBabel cannot translate a locale_str with no domain set. Context: {context}, String: {string}"
)
return None
translator = self.get_translator(locale.value, domain)
if not isinstance(string, LazyStr):
lazy = LazyStr(Method.GETTEXT, string.message)
else:
lazy = string
return lazy._translate_with(translator)
class Method(Enum):
GETTEXT = 'gettext'
NGETTEXT = 'ngettext'
PGETTEXT = 'pgettext'
NPGETTEXT = 'npgettext'
class LocalBabel:
def __init__(self, domain):
self.domain = domain
@property
def methods(self):
return (self._, self._n, self._p, self._np)
def _(self, message):
return LazyStr(Method.GETTEXT, message, domain=self.domain)
def _n(self, singular, plural, n):
return LazyStr(Method.NGETTEXT, singular, plural, n, domain=self.domain)
def _p(self, context, message):
return LazyStr(Method.PGETTEXT, context, message, domain=self.domain)
def _np(self, context, singular, plural, n):
return LazyStr(Method.NPGETTEXT, context, singular, plural, n, domain=self.domain)
class LazyStr(locale_str):
__slots__ = ('method', 'args', 'domain', 'locale')
def __init__(self, method, *args, locale=None, domain=None):
self.method = method
self.args = args
self.domain = domain
self.locale = locale or ctx_locale.get()
@property
def message(self):
return self._translate_with(null)
@property
def extras(self):
return {'locale': self.locale, 'domain': self.domain}
def __str__(self):
return self.message
def _translate_with(self, translator: gettext.GNUTranslations):
method = getattr(translator, self.method.value)
return method(*self.args)
def __repr__(self) -> str:
return f'{self.__class__.__name__}({self.method}, {self.args!r}, locale={self.locale}, domain={self.domain})'
def __eq__(self, obj: object) -> bool:
return isinstance(obj, locale_str) and self.message == obj.message
def __hash__(self) -> int:
return hash(self.args)