From b47744e21994d242315902c7388f315c0c5aa7f9 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 27 Oct 2023 15:42:26 +0300 Subject: [PATCH] feat(skins): Implement custom skin backend. --- src/modules/__init__.py | 1 + src/modules/skins/__init__.py | 10 + src/modules/skins/cog.py | 340 ++++++++++++++++++ src/modules/skins/data.py | 117 ++++++ src/modules/skins/editor/__init__.py | 0 src/modules/skins/editor/layout.py | 0 src/modules/skins/editor/pages/__init__.py | 0 src/modules/skins/editor/pages/leaderboard.py | 0 src/modules/skins/editor/pages/monthly.py | 0 .../skins/editor/pages/monthly_goals.py | 0 src/modules/skins/editor/pages/profile.py | 0 src/modules/skins/editor/pages/stats.py | 0 src/modules/skins/editor/pages/summary.py | 0 src/modules/skins/editor/pages/weekly.py | 0 .../skins/editor/pages/weekly_goals.py | 0 src/modules/skins/editor/skineditor.py | 0 src/modules/skins/editor/skinsetting.py | 0 src/modules/skins/settings.py | 54 +++ src/modules/skins/settingui.py | 100 ++++++ src/modules/skins/skinlib.py | 175 +++++++++ src/modules/skins/userskinui.py | 0 21 files changed, 797 insertions(+) create mode 100644 src/modules/skins/__init__.py create mode 100644 src/modules/skins/cog.py create mode 100644 src/modules/skins/data.py create mode 100644 src/modules/skins/editor/__init__.py create mode 100644 src/modules/skins/editor/layout.py create mode 100644 src/modules/skins/editor/pages/__init__.py create mode 100644 src/modules/skins/editor/pages/leaderboard.py create mode 100644 src/modules/skins/editor/pages/monthly.py create mode 100644 src/modules/skins/editor/pages/monthly_goals.py create mode 100644 src/modules/skins/editor/pages/profile.py create mode 100644 src/modules/skins/editor/pages/stats.py create mode 100644 src/modules/skins/editor/pages/summary.py create mode 100644 src/modules/skins/editor/pages/weekly.py create mode 100644 src/modules/skins/editor/pages/weekly_goals.py create mode 100644 src/modules/skins/editor/skineditor.py create mode 100644 src/modules/skins/editor/skinsetting.py create mode 100644 src/modules/skins/settings.py create mode 100644 src/modules/skins/settingui.py create mode 100644 src/modules/skins/skinlib.py create mode 100644 src/modules/skins/userskinui.py diff --git a/src/modules/__init__.py b/src/modules/__init__.py index 74964ff5..37deba59 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -4,6 +4,7 @@ active = [ '.sysadmin', '.config', '.user_config', + '.skins', '.schedule', '.economy', '.ranks', diff --git a/src/modules/skins/__init__.py b/src/modules/skins/__init__.py new file mode 100644 index 00000000..93f32c19 --- /dev/null +++ b/src/modules/skins/__init__.py @@ -0,0 +1,10 @@ +import logging +from babel.translator import LocalBabel + +babel = LocalBabel('customskins') +logger = logging.getLogger(__name__) + + +async def setup(bot): + from .cog import CustomSkinCog + await bot.add_cog(CustomSkinCog(bot)) diff --git a/src/modules/skins/cog.py b/src/modules/skins/cog.py new file mode 100644 index 00000000..49d3a003 --- /dev/null +++ b/src/modules/skins/cog.py @@ -0,0 +1,340 @@ +from typing import Optional +import asyncio + +import discord +from discord.ext import commands as cmds +import discord.app_commands as appcmds +from cachetools import LRUCache +from bidict import bidict +from frozendict import frozendict + + +from meta import LionCog, LionBot, LionContext +from meta.logger import log_wrap +from utils.lib import MISSING, utc_now +from wards import sys_admin_ward, low_management_ward +from gui.base import AppSkin +from babel.translator import ctx_locale + +from . import logger, babel +from .data import CustomSkinData +from .skinlib import appskin_as_choice, FrozenCustomSkin, CustomSkin +from .settings import GlobalSkinSettings +from .settingui import GlobalSkinSettingUI + +_p = babel._p + + +class CustomSkinCog(LionCog): + def __init__(self, bot: LionBot): + self.bot = bot + self.data: CustomSkinData = bot.db.load_registry(CustomSkinData()) + self.bot_settings = GlobalSkinSettings() + + # Cache of app skin id -> app skin name + # After initialisation, contains all the base skins available for this app + self.appskin_names: bidict[int, str] = bidict() + + # TODO: Move this to a bijection + # Bijective cache of skin property ids <-> (card_id, property_name) tuples + self.skin_properties: bidict[int, tuple[str, str]] = bidict() + + # Cache of currently active user skins + # Invalidation handled by local event handler + self.active_user_skinids: LRUCache[int, Optional[int]] = LRUCache(maxsize=5000) + + # Cache of custom skin id -> frozen custom skin + self.custom_skins: LRUCache[int, FrozenCustomSkin] = LRUCache(maxsize=1000) + + async def cog_load(self): + await self.data.init() + + if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None: + leo_setting_cog.bot_setting_groups.append(self.bot_settings) + self.crossload_group(self.leo_group, leo_setting_cog.leo_group) + + if (config_cog := self.bot.get_cog('ConfigCog')) is not None: + self.crossload_group(self.admin_group, config_cog.admin_group) + + if (user_cog := self.bot.get_cog('UserConfigCog')) is not None: + self.crossload_group(self.my_group, user_cog.userconfig_group) + + await self._reload_appskins() + await self._reload_property_map() + + async def _reload_property_map(self): + """ + Reload the skin property id to (card_id, property_name) bijection. + """ + records = await self.data.skin_property_map.select_where() + cache = self.skin_properties + + cache.clear() + for record in records: + cache[record['property_id']] = (record['card_id'], record['property_name']) + + logger.info( + f"Loaded '{len(cache)}' custom skin properties." + ) + + async def _reload_appskins(self): + """ + Reload the global_available_skin id to the appskin name. + Create global_available_skins that don't already exist. + """ + cache = self.appskin_names + available = list(AppSkin.skins_data['skin_map'].keys()) + rows = await self.data.GlobalSkin.fetch_where(skin_name=available) + + cache.clear() + for row in rows: + cache[row.skin_id] = row.skin_name + + # Not caring about efficiency here because this essentially needs to happen once ever + missing = [name for name in available if name not in cache.values()] + for name in missing: + row = await self.data.GlobalSkin.create(skin_name=name) + cache[row.skin_id] = row.skin_name + + logger.info( + f"Loaded '{len(cache)}' global base skins." + ) + + # ----- Internal API ----- + def get_base(self, base_skin_id: int) -> AppSkin: + """ + Initialise a localised AppSkin for the given base skin id. + """ + if base_skin_id not in self.appskin_names: + raise ValueError(f"Unknown app skin id '{base_skin_id}'") + + return AppSkin.get( + skin_id=self.appskin_names[base_skin_id], + locale=ctx_locale.get(), + use_cache=True, + ) + + async def get_default_skin(self) -> Optional[str]: + """ + Get the current app-default skin, and return it as a skin name. + + May be None if there is no app-default set. + This should almost always hit cache. + """ + setting = self.bot_settings.DefaultSkin + instance = await setting.get(self.bot.appname) + return instance.value + + async def fetch_property_ids(self, *card_properties: tuple[str, str]) -> list[int]: + """ + Fetch the skin property ids for the given (card_id, property_name) tuples. + + Creates any missing properties. + """ + mapper = self.skin_properties.inverse + missing = [prop for prop in card_properties if prop not in mapper] + if missing: + # First insert missing properties + await self.data.skin_property_map.insert_many( + ('card_id', 'property_name'), + *missing + ) + await self._reload_property_map() + return [mapper[prop] for prop in card_properties] + + async def get_guild_skinid(self, guildid: int) -> Optional[int]: + """ + Fetch the custom_skin_id associated to the current guild. + + Returns None if the guild is not premium or has no custom skin set. + Usually hits cache (Specifically the PremiumGuild cache). + """ + cog = self.bot.get_cog('PremiumCog') + if not cog: + logger.error( + "Trying to get guild skinid without loaded premium cog!" + ) + return None + row = await cog.data.PremiumGuild.fetch(guildid) + return row.custom_skin_id if row else None + + async def get_user_skinid(self, userid: int) -> Optional[int]: + """ + Fetch the custom_skin_id of the active skin in the given user's skin inventory. + + Returns None if the user does not have an active skin. + Should usually be cached by `self.active_user_skinids`. + """ + skinid = self.active_user_skinids.get(userid, MISSING) + if skinid is MISSING: + rows = await self.data.UserSkin.fetch_where(userid=userid, active=True) + skinid = rows[0].custom_skin_id if rows else None + self.active_user_skinids[userid] = skinid + return skinid + + async def args_for_skin(self, skinid: int, cardid: str) -> dict[str, str]: + """ + Fetch the skin argument dictionary for the given custom_skin_id. + + Should usually be cached by `self.custom_skin_args`. + """ + skin = self.custom_skins.get(skinid, None) + if skin is None: + custom_skin = await CustomSkin.fetch(self.bot, skinid) + skin = custom_skin.freeze() + self.custom_skins[skinid] = skin + return skin.args_for(cardid) + + # ----- External API ----- + async def get_skinargs_for(self, + guildid: Optional[int], userid: Optional[int], card_id: str + ) -> dict[str, str]: + """ + Get skin arguments for a standard GUI render with the given guild, user, and for the given card. + + Takes into account the global defaults, guild custom skin, and user active skin. + """ + args = {} + + if userid and (skinid := await self.get_user_skinid(userid)): + skin_args = await self.args_for_skin(skinid, card_id) + args.update(skin_args) + elif guildid and (skinid := await self.get_guild_skinid(guildid)): + skin_args = await self.args_for_skin(skinid, card_id) + args.update(skin_args) + + default = await self.get_default_skin() + if default: + args.setdefault("base_skin_id", default) + + return args + + # ----- Event Handlers ----- + @LionCog.listener('on_userset_skin') + async def refresh_user_skin(self, userid: int): + """ + Update cached user active skinid. + """ + self.active_user_skinids.pop(userid, None) + await self.get_user_skinid(userid) + + @LionCog.listener('on_skin_updated') + async def refresh_custom_skin(self, skinid: int): + """ + Update cached args for given custom skin id. + """ + self.custom_skins.pop(skinid, None) + custom_skin = await CustomSkin.fetch(skinid) + if custom_skin is not None: + skin = custom_skin.freeze() + self.custom_skins[skinid] = skin + + # ----- Userspace commands ----- + @LionCog.placeholder_group + @cmds.hybrid_group("my", with_app_command=False) + async def my_group(self, ctx: LionContext): + ... + + @my_group.command( + name=_p('cmd:my_skin', "skin"), + description=_p( + 'cmd:my_skin|desc', + "Change the colours of your interface" + ) + ) + async def cmd_my_skin(self, ctx: LionContext): + if not ctx.interaction: + return + # TODO + ... + + # ----- Adminspace commands ----- + @LionCog.placeholder_group + @cmds.hybrid_group("admin", with_app_command=False) + async def admin_group(self, ctx: LionContext): + ... + + @admin_group.command( + name=_p('cmd:admin_brand', "brand"), + description=_p( + 'cmd:admin_brand|desc', + "Fully customise my default interface for your members!" + ) + ) + @low_management_ward + async def cmd_admin_brand(self, ctx: LionContext): + if not ctx.interaction: + return + if not ctx.guild: + return + # TODO + ... + + # ----- Owner commands ----- + @LionCog.placeholder_group + @cmds.hybrid_group("leo", with_app_command=False) + async def leo_group(self, ctx: LionContext): + ... + + @leo_group.command( + name=_p('cmd:leo_skin', "skin"), + description=_p( + 'cmd:leo_skin|desc', + "View and update the global skin settings" + ) + ) + @appcmds.rename( + default_skin=_p('cmd:leo_skin|param:default_skin', "default_skin"), + ) + @appcmds.describe( + default_skin=_p( + 'cmd:leo_skin|param:default_skin|desc', + "Set the global default skin." + ) + ) + @sys_admin_ward + async def cmd_leo_skin(self, ctx: LionContext, + default_skin: Optional[str] = None): + if not ctx.interaction: + return + + await ctx.interaction.response.defer(thinking=True) + modified = [] + + if default_skin is not None: + setting = self.bot_settings.DefaultSkin + instance = await setting.from_string(self.bot.appname, default_skin) + modified.append(instance) + + for instance in modified: + await instance.write() + + # No update_str, just show the config window + ui = GlobalSkinSettingUI(self.bot, self.bot.appname, ctx.channel.id) + await ui.run(ctx.interaction) + await ui.wait() + + + @cmd_leo_skin.autocomplete('default_skin') + async def cmd_leo_skin_acmpl_default_skin(self, interaction: discord.Interaction, partial: str): + babel = self.bot.get_cog('BabelCog') + ctx_locale.set(await babel.get_user_locale(interaction.user.id)) + + choices = [] + for skinid in self.appskin_names: + appskin = self.get_base(skinid) + match = partial.lower() + if match in appskin.skin_id.lower() or match in appskin.display_name.lower(): + choices.append(appskin_as_choice(appskin)) + if not choices: + t = self.bot.translator.t + choices = [ + appcmds.Choice( + name=t(_p( + 'cmd:leo_skin|acmpl:default_skin|error:no_match', + "No app skins matching {partial}" + )).format(partial=partial)[:100], + value=partial + ) + ] + return choices diff --git a/src/modules/skins/data.py b/src/modules/skins/data.py new file mode 100644 index 00000000..742310a6 --- /dev/null +++ b/src/modules/skins/data.py @@ -0,0 +1,117 @@ +from data import Registry, RowModel, Table +from data.columns import Integer, Bool, Timestamp, String + + +class CustomSkinData(Registry): + class GlobalSkin(RowModel): + """ + Schema + ------ + CREATE TABLE global_available_skins( + skin_id SERIAL PRIMARY KEY, + skin_name TEXT NOT NULL + ); + CREATE INDEX global_available_skin_names ON global_available_skins (skin_name); + """ + _tablename_ = 'global_available_skins' + _cache_ = {} + + skin_id = Integer(primary=True) + skin_name = String() + + class CustomisedSkin(RowModel): + """ + Schema + ------ + CREATE TABLE customised_skins( + custom_skin_id SERIAL PRIMARY KEY, + base_skin_id INTEGER REFERENCES global_available_skins (skin_id), + _timestamp TIMESTAMPTZ DEFAULT now() + ); + """ + _tablename_ = 'customised_skins' + + custom_skin_id = Integer(primary=True) + base_skin_id = Integer() + + _timestamp = Timestamp() + + """ + Schema + ------ + CREATE TABLE customised_skin_property_ids( + property_id SERIAL PRIMARY KEY, + card_id TEXT NOT NULL, + property_name TEXT NOT NULL, + UNIQUE(card_id, property_name) + ); + """ + skin_property_map = Table('customised_skin_property_ids') + + """ + Schema + ------ + CREATE TABLE customised_skin_properties( + custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id), + property_id INTEGER NOT NULL REFERENCES customised_skin_property_ids (property_id), + value TEXT NOT NULL, + PRIMARY KEY (custom_skin_id, property_id) + ); + CREATE INDEX customised_skin_property_skin_id ON customised_skin_properties(custom_skin_id); + """ + skin_properties = Table('customised_skin_properties') + + """ + Schema + ------ + CREATE VIEW customised_skin_data AS + SELECT + skins.custom_skin_id AS custom_skin_id, + skins.base_skin_id AS base_skin_id, + properties.property_id AS property_id, + prop_ids.card_id AS card_id, + prop_ids.property_name AS property_name, + properties.value AS value + FROM + customised_skins skins + LEFT JOIN customised_skin_properties properties ON skins.custom_skin_id = properties.custom_skin_id + LEFT JOIN customised_skin_property_ids prop_ids ON properties.property_id = prop_ids.property_id; + """ + custom_skin_info = Table('customised_skin_data') + + class UserSkin(RowModel): + """ + Schema + ------ + CREATE TABLE user_skin_inventory( + itemid SERIAL PRIMARY KEY, + userid BIGINT NOT NULL REFERENCES user_config (userid) ON DELETE CASCADE, + custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id) ON DELETE CASCADE, + transactionid INTEGER REFERENCES gem_transactions (transactionid), + active BOOLEAN NOT NULL DEFAULT FALSE, + acquired_at TIMESTAMPTZ DEFAULT now(), + expires_at TIMESTAMPTZ + ); + CREATE INDEX user_skin_inventory_users ON user_skin_inventory(userid); + CREATE UNIQUE INDEX user_skin_inventory_active ON user_skin_inventory(userid) WHERE active = TRUE; + """ + _tablename_ = 'user_skin_inventory' + + itemid = Integer(primary=True) + userid = Integer() + custom_skin_id = Integer() + transactionid = Integer() + active = Bool() + acquired_at = Timestamp() + expires_at = Timestamp() + + """ + Schema + ------ + CREATE VIEW user_active_skins AS + SELECT + * + FROM user_skin_inventory + WHERE active=True; + """ + user_active_skins = Table('user_active_skins') diff --git a/src/modules/skins/editor/__init__.py b/src/modules/skins/editor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/layout.py b/src/modules/skins/editor/layout.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/__init__.py b/src/modules/skins/editor/pages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/leaderboard.py b/src/modules/skins/editor/pages/leaderboard.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/monthly.py b/src/modules/skins/editor/pages/monthly.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/monthly_goals.py b/src/modules/skins/editor/pages/monthly_goals.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/profile.py b/src/modules/skins/editor/pages/profile.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/stats.py b/src/modules/skins/editor/pages/stats.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/summary.py b/src/modules/skins/editor/pages/summary.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/weekly.py b/src/modules/skins/editor/pages/weekly.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/pages/weekly_goals.py b/src/modules/skins/editor/pages/weekly_goals.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/skineditor.py b/src/modules/skins/editor/skineditor.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/editor/skinsetting.py b/src/modules/skins/editor/skinsetting.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/skins/settings.py b/src/modules/skins/settings.py new file mode 100644 index 00000000..2c248942 --- /dev/null +++ b/src/modules/skins/settings.py @@ -0,0 +1,54 @@ +from meta.errors import UserInputError +from settings.data import ModelData +from settings.setting_types import StringSetting +from settings.groups import SettingGroup + +from wards import sys_admin_iward +from core.data import CoreData +from gui.base import AppSkin +from babel.translator import ctx_translator + +from . import babel + +_p = babel._p + + +class GlobalSkinSettings(SettingGroup): + class DefaultSkin(ModelData, StringSetting): + setting_id = 'default_app_skin' + _write_ward = sys_admin_iward + + _display_name = _p( + 'botset:default_app_skin', "default_skin" + ) + _desc = _p( + 'botset:default_app_skin|desc', + "The skin name of the app skin to use as the global default." + ) + _long_desc = _p( + 'botset:default_app_skin|long_desc', + "The skin name, as given in the `skins.json` file," + " of the client default interface skin." + " Guilds and users will be able to apply this skin" + "regardless of whether it is set as visible in the skin configuration." + ) + _accepts = _p( + 'botset:default_app_skin|accepts', + "A valid skin name as given in skins.json" + ) + + _model = CoreData.BotConfig + _column = CoreData.BotConfig.default_skin.name + + @classmethod + async def _parse_string(cls, parent_id, string, **kwargs): + t = ctx_translator.get().t + if string and not AppSkin.get_skin_path(string): + raise UserInputError( + t(_p( + 'botset:default_app_skin|parse|error:invalid', + "Provided `{string}` is not a valid skin id!" + )).format(string=string) + ) + return string or None + diff --git a/src/modules/skins/settingui.py b/src/modules/skins/settingui.py new file mode 100644 index 00000000..705db102 --- /dev/null +++ b/src/modules/skins/settingui.py @@ -0,0 +1,100 @@ +import asyncio + +import discord +from discord.ui.select import select, Select + +from utils.ui import ConfigUI +from utils.lib import MessageArgs +from meta import LionBot + +from . import babel, logger +from .settings import GlobalSkinSettings as Settings +from .skinlib import appskin_as_option + +_p = babel._p + + +class GlobalSkinSettingUI(ConfigUI): + setting_classes = ( + Settings.DefaultSkin, + ) + + def __init__(self, bot: LionBot, appname: str, channelid: int, **kwargs): + self.cog = bot.get_cog('CustomSkinCog') + super().__init__(bot, appname, channelid, **kwargs) + + # ----- UI Components ----- + @select( + cls=Select, + placeholder="DEFAULT_APP_MENU_PLACEHOLDER", + min_values=0, max_values=1 + ) + async def default_app_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=False) + setting = self.instances[0] + + if selected.values: + setting.data = selected.values[0] + await setting.write() + else: + setting.data = None + await setting.write() + + async def default_app_menu_refresh(self): + menu = self.default_app_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:appskins|menu:default_app|placeholder', + "Select Default Skin" + )) + options = [] + for skinid in self.cog.appskin_names: + appskin = self.cog.get_base(skinid) + option = appskin_as_option(appskin) + option.default = ( + self.instances[0].value == appskin.skin_id + ) + options.append(option) + if options: + menu.options = options + else: + menu.disabled = True + menu.options = [ + discord.SelectOption(label='DUMMY') + ] + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + title = t(_p( + 'ui:appskins|embed|title', + "Leo Global Skin Settings" + )) + embed = discord.Embed( + title=title, + colour=discord.Colour.orange() + ) + for setting in self.instances: + embed.add_field(**setting.embed_field, inline=False) + + return MessageArgs(embed=embed) + + async def refresh_components(self): + to_refresh = ( + self.edit_button_refresh(), + self.close_button_refresh(), + self.reset_button_refresh(), + self.default_app_menu_refresh(), + ) + await asyncio.gather(*to_refresh) + + self.set_layout( + (self.edit_button, self.reset_button, self.close_button,), + (self.default_app_menu,), + ) + + async def reload(self): + self.instances = [ + await setting.get(self.bot.appname) + for setting in self.setting_classes + ] diff --git a/src/modules/skins/skinlib.py b/src/modules/skins/skinlib.py new file mode 100644 index 00000000..f3f0cb63 --- /dev/null +++ b/src/modules/skins/skinlib.py @@ -0,0 +1,175 @@ +from collections import defaultdict +from typing import Optional + +from frozendict import frozendict +import discord +from discord.components import SelectOption +from discord.app_commands import Choice + +from gui.base import AppSkin +from meta import LionBot +from meta.logger import log_wrap + +from .data import CustomSkinData + + +def appskin_as_option(skin: AppSkin) -> SelectOption: + """ + Create a SelectOption from the given localised AppSkin + """ + return SelectOption( + label=skin.display_name, + description=skin.description, + value=skin.skin_id, + ) + + +def appskin_as_choice(skin: AppSkin) -> Choice[str]: + """ + Create an appcmds.Choice from the given localised AppSkin + """ + return Choice( + name=skin.display_name, + value=skin.skin_id, + ) + + +class FrozenCustomSkin: + __slots__ = ('base_skin_name', 'properties') + + def __init__(self, base_skin_name: Optional[str], properties: dict[str, dict[str, str]]): + self.base_skin_name = base_skin_name + self.properties = frozendict((card, frozendict(props)) for card, props in properties.items()) + + def args_for(self, card_id: str): + args = {} + if self.base_skin_name is not None: + args["base_skin_id"] = self.base_skin_name + if card_id in self.properties: + args.update(self.properties[card_id]) + return args + + +class CustomSkin: + def __init__(self, + bot: LionBot, + base_skin_name: Optional[str]=None, + properties: dict[str, dict[str, str]] = {}, + data: Optional[CustomSkinData.CustomisedSkin]=None, + ): + self.bot = bot + self.data = data + + self.base_skin_name = base_skin_name + self.properties = properties + + @property + def cog(self): + return self.bot.get_cog('CustomSkinCog') + + @property + def skinid(self) -> Optional[int]: + return self.data.custom_skin_id if self.data else None + + @property + def base_skin_id(self) -> Optional[int]: + if self.base_skin_name is not None: + return self.cog.appskin_names.inverse[self.base_skin_name] + + @classmethod + async def fetch(cls, bot: LionBot, skinid: int) -> Optional['CustomSkin']: + """ + Fetch the specified skin from data. + """ + cog = bot.get_cog('CustomSkinCog') + row = await cog.data.CustomisedSkin.fetch(skinid) + if row is not None: + records = await cog.data.custom_skin_info.select_where( + custom_skin_id=skinid + ) + properties = defaultdict(dict) + for record in records: + card_id = record['card_id'] + prop_name = record['property_name'] + prop_value = record['property_value'] + properties[card_id][prop_name] = prop_value + if row.base_skin_id is not None: + base_skin_name = cog.appskin_names[row.base_skin_id] + else: + base_skin_name = None + self = cls(bot, base_skin_name, properties, data=row) + return self + + @log_wrap(action='Save Skin') + async def save(self): + if self.data is None: + raise ValueError("Cannot save a dataless CustomSkin") + + async with self.bot.db.connection() as conn: + self.bot.db.conn = conn + async with conn.transaction(): + skinid = self.skinid + await self.data.update(base_skin_id=self.base_skin_id) + await self.cog.data.skin_properties.delete_where(skinid=skinid) + + props = { + (card, name): value + for card, card_props in self.properties.items() + for name, value in card_props.items() + if value is not None + } + # Ensure the properties exist in cache + await self.cog.fetch_property_ids(*props.keys()) + + # Now bulk insert + await self.cog.data.skin_properties.insert_many( + ('custom_skin_id', 'property_id', 'value'), + *( + (skinid, self.cog.skin_properties[propkey], value) + for propkey, value in props.items() + ) + ) + + def resolve_propid(self, propid: int) -> tuple[str, str]: + return self.cog.skin_properties[propid] + + def __getitem__(self, propid: int) -> Optional[str]: + card, name = self.resolve_propid(propid) + return self.properties.get(card, {}).get(name, None) + + def __setitem__(self, propid: int, value: Optional[str]): + card, name = self.resolve_propid(propid) + cardprops = self.properties.get(card, None) + if value is None: + if cardprops is not None: + cardprops.pop(name, None) + else: + if cardprops is None: + cardprops = self.properties[card] = {} + cardprops[name] = value + + def __delitem__(self, propid: int): + card, name = self.resolve_propid(propid) + self.properties.get(card, {}).pop(name, None) + + def freeze(self) -> FrozenCustomSkin: + """ + Freeze the custom skin data into a memory efficient FrozenCustomSkin. + """ + return FrozenCustomSkin(self.base_skin_name, self.properties) + + def load_frozen(self, frozen: FrozenCustomSkin): + """ + Update state from the given frozen state. + """ + self.base_skin_name = frozen.base_skin_name + self.properties = dict((card, dict(props)) for card, props in frozen.properties) + return self + + def args_for(self, card_id: str): + args = {} + if self.base_skin_name is not None: + args["base_skin_id"] = self.base_skin_name + if card_id in self.properties: + args.update(self.properties[card_id]) + return args diff --git a/src/modules/skins/userskinui.py b/src/modules/skins/userskinui.py new file mode 100644 index 00000000..e69de29b