diff --git a/README.md b/README.md index c57771f..8132ae2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Ruby Cogs -## This repository is being specifically built for our fork of Red-DiscordBot -- This will contain every cog we use on the instance, and modified accordingly for our use. +## This repository is being specifically gathered and modified for our instance of Red-DiscordBot +- This will contain every cog we use on our instance called 'Ruby', and modified accordingly for our use. Credits: [AAA3A Cogs](https://github.com/AAA3A-AAA3A/AAA3A-cogs) @@ -28,4 +28,5 @@ Credits: [Vex Cogs](https://github.com/Vexed01/Vex-Cogs) [x26 Cogs](https://github.com/Twentysix26/x26-Cogs) [Toxic Cogs](https://github.com/NeuroAssassin/Toxic-Cogs) -[Karlo Cogs](https://github.com/karlsbjorn/karlo-cogs) \ No newline at end of file +[Karlo Cogs](https://github.com/karlsbjorn/karlo-cogs) +[Mister-42 Cogs](https://github.com/Mister-42/mr42-cogs) \ No newline at end of file diff --git a/appeals/README.md b/appeals/README.md new file mode 100644 index 0000000..2c7e869 --- /dev/null +++ b/appeals/README.md @@ -0,0 +1,148 @@ +Turn a secondary Discord into a ban appeal server + +# [p]appealsfor +Get all appeal submissions for a specific user + - Usage: `[p]appealsfor ` + - Checks: `ensure_appeal_system_ready and ensure_db_connection` +# [p]viewappeal +View an appeal submission by ID + - Usage: `[p]viewappeal ` + - Checks: `ensure_appeal_system_ready and ensure_db_connection` +# [p]appeal +Configure appeal server settings + - Usage: `[p]appeal` + - Restricted to: `ADMIN` + - Aliases: `appeals and appealset` + - Checks: `server_only` +## [p]appeal view +View the current appeal server settings + - Usage: `[p]appeal view` + - Checks: `ensure_db_connection` +## [p]appeal nukedb +Nuke the entire appeal database + - Usage: `[p]appeal nukedb ` + - Restricted to: `BOT_OWNER` + - Checks: `ensure_db_connection` +## [p]appeal addquestion +Add a question to the appeal form + - Usage: `[p]appeal addquestion ` + - Checks: `ensure_db_connection` +## [p]appeal server +Set the server ID where users will be unbanned from + +**NOTES** +- This is the first step to setting up the appeal system +- This server will be the appeal server +- You must be the owner of the target server + - Usage: `[p]appeal server ` + - Restricted to: `GUILD_OWNER` + - Checks: `ensure_db_connection` +## [p]appeal editquestion +Edit a question in the appeal form + - Usage: `[p]appeal editquestion ` + - Checks: `ensure_appeal_system_ready and ensure_db_connection` +## [p]appeal wipeappeals +Wipe all appeal submissions + - Usage: `[p]appeal wipeappeals ` + - Checks: `ensure_db_connection` +## [p]appeal removequestion +Remove a question from the appeal form + - Usage: `[p]appeal removequestion ` + - Checks: `ensure_db_connection` +## [p]appeal refresh +Refresh the appeal message with the current appeal form + - Usage: `[p]appeal refresh` + - Checks: `ensure_db_connection` +## [p]appeal channel +Set the channel where submitted appeals will go + +`channel_type` must be one of: pending, approved, denied + +**NOTE**: All 3 channel types must be set for the appeal system to work properly. + - Usage: `[p]appeal channel ` + - Checks: `ensure_db_connection` +## [p]appeal sortorder +Set the sort order for a question in the appeal form + - Usage: `[p]appeal sortorder ` + - Checks: `ensure_appeal_system_ready and ensure_db_connection` +## [p]appeal approve +Approve an appeal submission by ID + - Usage: `[p]appeal approve ` + - Checks: `ensure_db_connection` +## [p]appeal questions +Menu to view questions in the appeal form + - Usage: `[p]appeal questions` + - Checks: `ensure_appeal_system_ready and ensure_db_connection` +## [p]appeal buttonstyle +Set the style of the appeal button + - Usage: `[p]appeal buttonstyle \n\n" + + source_path.read_text().strip() + + f"\n\n" + ) + + """ + { + "title": str, + "description": str, + "stat": str, + "stats": [{"position": int, "name": str, "id": int, "stat": str}] + "total": str, + "type": leaderboard type, // lb or weekly + "user_position": int, // Index of the user in the leaderboard + } + """ + res: dict = await asyncio.to_thread( + formatter.get_leaderboard, + bot=self.bot, + guild=guild, + db=self.db, + stat=stat, + lbtype=lbtype, + is_global=False, + member=guild.get_member(user.id), + use_displayname=True, + dashboard=True, + ) + data = { + "user_id": user.id, + "users": res["stats"], + "stat": stat, + "total": res["description"].replace("`", ""), + "type": lbtype, + "page": int(kwargs["extra_kwargs"].get("page", 1)), + } + content = { + "status": 0, + "web_content": { + "source": source, + "data": data, + "stat": stat, + "statname": res["stat"], + "expanded": True, + }, + } + return content + + @dashboard_page(name="leaderboard", description="Display the guild leaderboard.") + async def leaderboard_page( + self, user: discord.User, guild: discord.Guild, stat: str = None, **kwargs + ) -> t.Dict[str, t.Any]: + stat = stat if stat is not None and stat in {"exp", "messages", "voice", "stars"} else "exp" + return await self.get_dashboard_leaderboard(user, guild, "lb", stat, **kwargs) + + @dashboard_page(name="weekly", description="Display the guild weekly leaderboard.") + async def weekly_page( + self, user: discord.User, guild: discord.Guild, stat: str = None, **kwargs + ) -> t.Dict[str, t.Any]: + stat = stat if stat is not None and stat in {"exp", "messages", "voice", "stars"} else "exp" + return await self.get_dashboard_leaderboard(user, guild, "weekly", stat, **kwargs) + + # @dashboard_page(name="settings", description="Configure the leveling system.", methods=("GET", "POST")) + async def cog_settings(self, user: discord.User, guild: discord.Guild, **kwargs): + import wtforms # pip install WTForms + from flask_wtf import FlaskForm # pip install Flask-WTF + + log.info(f"Getting settings for {guild.name} by {user.name}") + member = guild.get_member(user.id) + if not member: + log.warning(f"Member {user.name} not found in guild {guild.name}") + return { + "status": 1, + "error_title": _("Member not found"), + "error_message": _("You are not a member of this guild."), + } + if not await self.bot.is_admin(member): + log.warning(f"Member {user.name} is not an admin in guild {guild.name}") + return { + "status": 1, + "error_title": _("Insufficient permissions"), + "error_message": _("You need to be an admin to access this page."), + } + + conf = self.db.get_conf(guild) + + class SettingsForm(kwargs["Form"]): + def __init__(self): + super().__init__(prefix="levelup_settings_form_") + + # General settings + enabled = wtforms.BooleanField(_("Enabled:"), default=conf.enabled) + algo_base = wtforms.IntegerField( + _("Algorithm Base:"), default=conf.algorithm.base, validators=[wtforms.validators.InputRequired()] + ) + algo_multiplier = wtforms.FloatField( + _("Algorithm Multiplier:"), default=conf.algorithm.exp, validators=[wtforms.validators.InputRequired()] + ) + + # Submit button + submit = wtforms.SubmitField(_("Save Settings")) + + form: FlaskForm = SettingsForm() + + # Handle form submission + if form.validate_on_submit() and await form.validate_dpy_converters(): + log.info(f"Form validated for {guild.name} by {user.name}") + conf.enabled = form.enabled.data + conf.algorithm.base = form.algo_base.data or 100 + conf.algorithm.exp = form.algo_multiplier.data or 2.0 + self.save() + return { + "status": 0, + "notifications": [{"message": _("Settings saved"), "category": "success"}], + "redirect_url": kwargs["request_url"], + } + source = (templates / "settings.html").read_text() + return { + "status": 0, + "web_content": {"source": source, "settings_form": form}, + } diff --git a/levelup/dashboard/locales/de-DE.po b/levelup/dashboard/locales/de-DE.po new file mode 100644 index 0000000..24f718f --- /dev/null +++ b/levelup/dashboard/locales/de-DE.po @@ -0,0 +1,35 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n" +"X-Crowdin-File-ID: 164\n" +"Language: de_DE\n" + +#: levelup\dashboard\integration.py:42 +msgid "Weekly stats disabled" +msgstr "" + +#: levelup\dashboard\integration.py:43 +msgid "Weekly stats are disabled for this guild." +msgstr "" + +#: levelup\dashboard\integration.py:48 +msgid "No Weekly stats available" +msgstr "" + +#: levelup\dashboard\integration.py:49 +msgid "There is no data for the weekly leaderboard yet, please chat a bit first." +msgstr "" + diff --git a/levelup/dashboard/locales/es-ES.po b/levelup/dashboard/locales/es-ES.po new file mode 100644 index 0000000..e979883 --- /dev/null +++ b/levelup/dashboard/locales/es-ES.po @@ -0,0 +1,35 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n" +"X-Crowdin-File-ID: 164\n" +"Language: es_ES\n" + +#: levelup\dashboard\integration.py:42 +msgid "Weekly stats disabled" +msgstr "Estadísticas semanales desactivadas" + +#: levelup\dashboard\integration.py:43 +msgid "Weekly stats are disabled for this guild." +msgstr "Las estadísticas semanales están desactivadas para este gremio." + +#: levelup\dashboard\integration.py:48 +msgid "No Weekly stats available" +msgstr "No hay estadísticas semanales disponibles" + +#: levelup\dashboard\integration.py:49 +msgid "There is no data for the weekly leaderboard yet, please chat a bit first." +msgstr "Todavía no hay datos para la clasificación semanal, por favor, chatea un poco antes." + diff --git a/levelup/dashboard/locales/fr-FR.po b/levelup/dashboard/locales/fr-FR.po new file mode 100644 index 0000000..c0baadd --- /dev/null +++ b/levelup/dashboard/locales/fr-FR.po @@ -0,0 +1,35 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n" +"X-Crowdin-File-ID: 164\n" +"Language: fr_FR\n" + +#: levelup\dashboard\integration.py:42 +msgid "Weekly stats disabled" +msgstr "" + +#: levelup\dashboard\integration.py:43 +msgid "Weekly stats are disabled for this guild." +msgstr "" + +#: levelup\dashboard\integration.py:48 +msgid "No Weekly stats available" +msgstr "" + +#: levelup\dashboard\integration.py:49 +msgid "There is no data for the weekly leaderboard yet, please chat a bit first." +msgstr "" + diff --git a/levelup/dashboard/locales/hr-HR.po b/levelup/dashboard/locales/hr-HR.po new file mode 100644 index 0000000..119b8ba --- /dev/null +++ b/levelup/dashboard/locales/hr-HR.po @@ -0,0 +1,35 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: hr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n" +"X-Crowdin-File-ID: 164\n" +"Language: hr_HR\n" + +#: levelup\dashboard\integration.py:42 +msgid "Weekly stats disabled" +msgstr "" + +#: levelup\dashboard\integration.py:43 +msgid "Weekly stats are disabled for this guild." +msgstr "" + +#: levelup\dashboard\integration.py:48 +msgid "No Weekly stats available" +msgstr "" + +#: levelup\dashboard\integration.py:49 +msgid "There is no data for the weekly leaderboard yet, please chat a bit first." +msgstr "" + diff --git a/levelup/dashboard/locales/ko-KR.po b/levelup/dashboard/locales/ko-KR.po new file mode 100644 index 0000000..05af04c --- /dev/null +++ b/levelup/dashboard/locales/ko-KR.po @@ -0,0 +1,35 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Korean\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ko\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n" +"X-Crowdin-File-ID: 164\n" +"Language: ko_KR\n" + +#: levelup\dashboard\integration.py:42 +msgid "Weekly stats disabled" +msgstr "" + +#: levelup\dashboard\integration.py:43 +msgid "Weekly stats are disabled for this guild." +msgstr "" + +#: levelup\dashboard\integration.py:48 +msgid "No Weekly stats available" +msgstr "" + +#: levelup\dashboard\integration.py:49 +msgid "There is no data for the weekly leaderboard yet, please chat a bit first." +msgstr "" + diff --git a/levelup/dashboard/locales/messages.pot b/levelup/dashboard/locales/messages.pot new file mode 100644 index 0000000..cd3794a --- /dev/null +++ b/levelup/dashboard/locales/messages.pot @@ -0,0 +1,29 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" + +#: levelup\dashboard\integration.py:42 +msgid "Weekly stats disabled" +msgstr "" + +#: levelup\dashboard\integration.py:43 +msgid "Weekly stats are disabled for this guild." +msgstr "" + +#: levelup\dashboard\integration.py:48 +msgid "No Weekly stats available" +msgstr "" + +#: levelup\dashboard\integration.py:49 +msgid "" +"There is no data for the weekly leaderboard yet, please chat a bit first." +msgstr "" diff --git a/levelup/dashboard/locales/pt-PT.po b/levelup/dashboard/locales/pt-PT.po new file mode 100644 index 0000000..b499d08 --- /dev/null +++ b/levelup/dashboard/locales/pt-PT.po @@ -0,0 +1,35 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: pt-PT\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n" +"X-Crowdin-File-ID: 164\n" +"Language: pt_PT\n" + +#: levelup\dashboard\integration.py:42 +msgid "Weekly stats disabled" +msgstr "" + +#: levelup\dashboard\integration.py:43 +msgid "Weekly stats are disabled for this guild." +msgstr "" + +#: levelup\dashboard\integration.py:48 +msgid "No Weekly stats available" +msgstr "" + +#: levelup\dashboard\integration.py:49 +msgid "There is no data for the weekly leaderboard yet, please chat a bit first." +msgstr "" + diff --git a/levelup/dashboard/locales/ru-RU.po b/levelup/dashboard/locales/ru-RU.po new file mode 100644 index 0000000..991a06a --- /dev/null +++ b/levelup/dashboard/locales/ru-RU.po @@ -0,0 +1,35 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n" +"X-Crowdin-File-ID: 164\n" +"Language: ru_RU\n" + +#: levelup\dashboard\integration.py:42 +msgid "Weekly stats disabled" +msgstr "" + +#: levelup\dashboard\integration.py:43 +msgid "Weekly stats are disabled for this guild." +msgstr "" + +#: levelup\dashboard\integration.py:48 +msgid "No Weekly stats available" +msgstr "" + +#: levelup\dashboard\integration.py:49 +msgid "There is no data for the weekly leaderboard yet, please chat a bit first." +msgstr "" + diff --git a/levelup/dashboard/locales/tr-TR.po b/levelup/dashboard/locales/tr-TR.po new file mode 100644 index 0000000..8fdd6ec --- /dev/null +++ b/levelup/dashboard/locales/tr-TR.po @@ -0,0 +1,35 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: tr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n" +"X-Crowdin-File-ID: 164\n" +"Language: tr_TR\n" + +#: levelup\dashboard\integration.py:42 +msgid "Weekly stats disabled" +msgstr "" + +#: levelup\dashboard\integration.py:43 +msgid "Weekly stats are disabled for this guild." +msgstr "" + +#: levelup\dashboard\integration.py:48 +msgid "No Weekly stats available" +msgstr "" + +#: levelup\dashboard\integration.py:49 +msgid "There is no data for the weekly leaderboard yet, please chat a bit first." +msgstr "" + diff --git a/levelup/dashboard/static/css/leaderboard.css b/levelup/dashboard/static/css/leaderboard.css new file mode 100644 index 0000000..0caef1b --- /dev/null +++ b/levelup/dashboard/static/css/leaderboard.css @@ -0,0 +1,110 @@ +/* Trippy search bar styling */ +@keyframes gradient-shift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@keyframes glow-pulse { + 0% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.7); } + 50% { box-shadow: 0 0 20px rgba(0, 255, 255, 0.9); } + 100% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.7); } +} + +@keyframes wobble { + 0%, 100% { transform: translateX(0) rotate(0); } + 25% { transform: translateX(-5px) rotate(-1deg); } + 75% { transform: translateX(5px) rotate(1deg); } +} + +/* Default search input styles */ +.search-container input[type="text"] { + transition: all 0.3s ease; +} + +/* Trippy styles applied conditionally */ +.search-container input[type="text"].trippy-search { + background: linear-gradient(45deg, #ff00e1, #00ffff, #ff00a2, #00ff9d, #8400ff, #00e1ff); + background-size: 600% 600%; + animation: gradient-shift 10s ease infinite, glow-pulse 3s infinite; + color: white; + text-shadow: 1px 1px 2px black; + font-weight: bold; + border: 2px solid transparent; + /* border-image: linear-gradient(to right, violet, indigo, blue, green, yellow, orange, red); */ + border-image-slice: 1; +} + +.search-container input[type="text"].trippy-search:focus { + animation: gradient-shift 3s ease infinite, glow-pulse 1.5s infinite, wobble 2s ease-in-out infinite; + transform: scale(1.02); + outline: none; +} + +.search-container input[type="text"].trippy-search::placeholder { + color: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); +} + +.search-container input[type="text"].trippy-search:not(:placeholder-shown) { + font-weight: bold; + letter-spacing: 0.5px; +} + +/* Toggle switch styling */ +.form-switch .form-check-input { + cursor: pointer; +} + +.form-check-label { + font-size: 0.8rem; + cursor: pointer; +} + +/* Sleek Toggle Switch */ +.trippy-toggle { + position: relative; + display: inline-block; +} + +.trippy-toggle .toggle-input { + opacity: 0; + width: 0; + height: 0; +} + +.trippy-toggle .toggle-label { + position: relative; + display: inline-block; + width: 30px; + height: 16px; + background-color: #ccc; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.trippy-toggle .toggle-label:before { + position: absolute; + content: ''; + height: 12px; + width: 12px; + left: 2px; + bottom: 2px; + background-color: white; + border-radius: 50%; + transition: 0.3s; +} + +.trippy-toggle .toggle-input:checked + .toggle-label { + background: linear-gradient(90deg, #ff00e1, #00ffff); + box-shadow: 0 0 5px rgba(0, 255, 255, 0.5); +} + +.trippy-toggle .toggle-input:checked + .toggle-label:before { + transform: translateX(14px); +} + +.search-input-container { + position: relative; +} diff --git a/levelup/dashboard/static/css/settings.css b/levelup/dashboard/static/css/settings.css new file mode 100644 index 0000000..e69de29 diff --git a/levelup/dashboard/static/js/leaderboard.js b/levelup/dashboard/static/js/leaderboard.js new file mode 100644 index 0000000..42da1ab --- /dev/null +++ b/levelup/dashboard/static/js/leaderboard.js @@ -0,0 +1,176 @@ +document.addEventListener('alpine:init', () => { + Alpine.data('leaderBoard', (data = {}) => ({ + // DATA INITIALIZATION + searchQuery: '', + page: data.page, + perPage: 100, + users: data.users, + sortStat: data.stat, // Can be exp, messages, voice, stars + sortOptions: ['exp', 'messages', 'voice', 'stars'], + sortMenuOpen: false, + currentUserId: data.user_id, + total: data.total, + open: false, // Whether dropdown is open + trippyMode: false, + trippyEnabled: false, + typingTimer: null, + + // USER PAGINATION FUNCTIONS + getPageUsers() { + console.log("Fetching users for page", this.page); + console.log(`There are ${this.users.length} users in total`); + if (this.searchQuery === "") { + return this.users.slice((this.page - 1) * this.perPage, this.page * this.perPage); + } else { + // Need to filter users based on search query + const filteredUsers = this.filterUsers(); + return filteredUsers.slice((this.page - 1) * this.perPage, this.page * this.perPage); + } + }, + nextPage() { + if (this.page * this.perPage < this.users.length) { + this.page++; + this.updateUrlParam('page', this.page); + } + }, + prevPage() { + if (this.page > 1) { + this.page--; + this.updateUrlParam('page', this.page); + } + }, + setPerPage(value) { + if (this.perPage !== value) { + this.perPage = value; + this.page = 1; // Reset to first page when changing items per page + localStorage.setItem('leaderBoardPerPage', value); + this.updateUrlParam('perPage', value); + this.getPageUsers(); + } + }, + getPageCount() { + if (this.searchQuery === "") { + return Math.ceil(this.users.length / this.perPage); + } else { + // Need to filter users based on search query + const filteredUsers = this.filterUsers(); + return Math.ceil(filteredUsers.length / this.perPage); + } + }, + + // URL AND BROWSER HISTORY MANAGEMENT + updateUrlParam(param, value) { + // Get current URL and parse its query parameters + const url = new URL(window.location.href); + // Update or add the parameter + url.searchParams.set(param, value); + // Update browser history without refreshing + history.pushState({}, '', url.toString()); + }, + + // SEARCH AND FILTERING + filterUsers() { + // Filter users based on search query + if (this.searchQuery === "") { + return this.users; + } + return this.users.filter(user => user.name.toLowerCase().includes(this.searchQuery.toLowerCase())); + }, + + // TRIPPY MODE FUNCTIONALITY + toggleTrippy() { + this.trippyEnabled = !this.trippyEnabled; + localStorage.setItem('leaderBoardTrippy', this.trippyEnabled); + + // If we're enabling it, immediately show effects + if (this.trippyEnabled) { + this.trippyMode = true; + } else { + this.trippyMode = false; + } + }, + + handleTyping() { + if (this.trippyEnabled) { + this.trippyMode = true; + + // Clear previous timer + if (this.typingTimer) { + clearTimeout(this.typingTimer); + } + + // Set a timer to disable trippy mode after user stops typing + this.typingTimer = setTimeout(() => { + this.trippyMode = false; + }, 1500); + } + }, + + // INITIALIZATION AND LOCAL STORAGE + init() { + console.log(`Leaderboard initialized starting on page ${this.page}`); + + // Store the URL-provided page (if any) + const urlProvidedPage = this.page; + + // Only use localStorage page if no page was specified in the URL (or was specified as 1) + if (!urlProvidedPage || urlProvidedPage === 1) { + const storedPage = localStorage.getItem('leaderBoardPage'); + if (storedPage !== null) { + this.page = parseInt(storedPage) || 1; + } + } + + // Retrieve stored values if available + const storedSearch = localStorage.getItem('leaderBoardSearch'); + if (storedSearch !== null) { + this.searchQuery = storedSearch; + } + + // Get stored perPage preference + const storedPerPage = localStorage.getItem('leaderBoardPerPage'); + if (storedPerPage !== null) { + this.perPage = parseInt(storedPerPage) || 100; + } + + // Load trippy preference + const storedTrippy = localStorage.getItem('leaderBoardTrippy'); + if (storedTrippy !== null) { + this.trippyEnabled = storedTrippy === 'true'; + // If trippy is enabled at load, show effect briefly + if (this.trippyEnabled) { + this.trippyMode = true; + setTimeout(() => { + if (!this.searchQuery) { // Only turn off if not actively searching + this.trippyMode = false; + } + }, 1500); + } + } + + const storedAutoTrippy = localStorage.getItem('leaderBoardAutoTrippy'); + if (storedAutoTrippy !== null) { + this.autoTrippy = storedAutoTrippy === 'true'; + } else { + // Default to true for auto-trippy + this.autoTrippy = true; + } + + // WATCHERS + // Watch for changes to store them + this.$watch('searchQuery', (newquery, oldquery) => { + console.log(`Search query changed from ${oldquery} to ${newquery}`); + localStorage.setItem('leaderBoardSearch', newquery); + this.page = 1; + localStorage.setItem('leaderBoardPage', '1'); + this.getPageUsers(); + + // Call typing handler when search query changes + this.handleTyping(); + }); + this.$watch('page', (newpage, oldpage) => { + localStorage.setItem('leaderBoardPage', newpage); + }); + }, + })); +}); diff --git a/levelup/dashboard/static/js/settings.js b/levelup/dashboard/static/js/settings.js new file mode 100644 index 0000000..972f00b --- /dev/null +++ b/levelup/dashboard/static/js/settings.js @@ -0,0 +1,53 @@ +document.addEventListener('alpine:init', () => { + Alpine.data('levelUpSettings', (data = {}) => ({ + // DATA INITIALIZATION + settings: data.settings || {}, + users: data.users || [], + emojis: data.emojis || [], + roles: data.roles || [], + newLevelRole: { level: 1, role: '' }, + + // METHODS + async saveSettings(event) { + console.log(`Event: ${event}`); + console.log("Saving settings:", this.settings); + try { + // Get CSRF token from span element's data-value attribute + const csrfToken = document.querySelector('#settings-csrf-token').value; + console.log("CSRF token:", csrfToken); + + if (!csrfToken) { + console.error("CSRF token not found"); + alert("CSRF token not found. Please refresh the page and try again."); + return; + } + + // Wrap settings in the expected structure + const requestData = { + save: true, + new_data: this.settings + }; + + const response = await fetch(window.location.href, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken // Changed from X-CSRF-Token to X-CSRFToken + }, + credentials: 'same-origin', // Added to ensure cookies are sent + body: JSON.stringify(requestData) + }); + + const result = await response.json(); + if (result.status === 0) { + alert(result.success_message); + } else { + alert(`Error: ${result.error_message}`); + } + } catch (error) { + console.error("Error saving settings:", error); + alert("An error occurred while saving settings."); + } + } + })); +}); diff --git a/levelup/dashboard/templates/leaderboard.html b/levelup/dashboard/templates/leaderboard.html new file mode 100644 index 0000000..8c13d21 --- /dev/null +++ b/levelup/dashboard/templates/leaderboard.html @@ -0,0 +1,177 @@ + + + + + + + + + Sort by + + + {% if stat == "exp" %}{% endif %}Exp + {% if stat == "messages" %}{% endif %}Messages + {% if stat == "voice" %}{% endif %}Voice + {% if stat == "stars" %}{% endif %}Stars + + + {% if position_url %} + + Go to my position + + {% endif %} + + + + + + + + + + + + + + Previous + + + + + + per page + + + 25 + 50 + 100 + All + + + + + Next + + + + + + + + # + Name: + {{ statname }}: + + + + + + + + + + + + + + + + + diff --git a/levelup/dashboard/templates/settings.html b/levelup/dashboard/templates/settings.html new file mode 100644 index 0000000..74491a0 --- /dev/null +++ b/levelup/dashboard/templates/settings.html @@ -0,0 +1,9 @@ + + + + This page is a work in progress! + {{ settings_form|safe }} + diff --git a/levelup/data/backgrounds/card01.webp b/levelup/data/backgrounds/card01.webp new file mode 100644 index 0000000..453fe44 Binary files /dev/null and b/levelup/data/backgrounds/card01.webp differ diff --git a/levelup/data/backgrounds/card02.webp b/levelup/data/backgrounds/card02.webp new file mode 100644 index 0000000..5315f58 Binary files /dev/null and b/levelup/data/backgrounds/card02.webp differ diff --git a/levelup/data/backgrounds/card03.webp b/levelup/data/backgrounds/card03.webp new file mode 100644 index 0000000..749fcfc Binary files /dev/null and b/levelup/data/backgrounds/card03.webp differ diff --git a/levelup/data/backgrounds/card04.webp b/levelup/data/backgrounds/card04.webp new file mode 100644 index 0000000..ba3ad35 Binary files /dev/null and b/levelup/data/backgrounds/card04.webp differ diff --git a/levelup/data/backgrounds/card05.webp b/levelup/data/backgrounds/card05.webp new file mode 100644 index 0000000..0a3fe97 Binary files /dev/null and b/levelup/data/backgrounds/card05.webp differ diff --git a/levelup/data/backgrounds/card06.webp b/levelup/data/backgrounds/card06.webp new file mode 100644 index 0000000..c88a26e Binary files /dev/null and b/levelup/data/backgrounds/card06.webp differ diff --git a/levelup/data/backgrounds/card07.webp b/levelup/data/backgrounds/card07.webp new file mode 100644 index 0000000..a613ffb Binary files /dev/null and b/levelup/data/backgrounds/card07.webp differ diff --git a/levelup/data/backgrounds/card08.webp b/levelup/data/backgrounds/card08.webp new file mode 100644 index 0000000..a6808e7 Binary files /dev/null and b/levelup/data/backgrounds/card08.webp differ diff --git a/levelup/data/backgrounds/card09.webp b/levelup/data/backgrounds/card09.webp new file mode 100644 index 0000000..f90be1b Binary files /dev/null and b/levelup/data/backgrounds/card09.webp differ diff --git a/levelup/data/backgrounds/card10.webp b/levelup/data/backgrounds/card10.webp new file mode 100644 index 0000000..d9d09ca Binary files /dev/null and b/levelup/data/backgrounds/card10.webp differ diff --git a/levelup/data/backgrounds/card11.webp b/levelup/data/backgrounds/card11.webp new file mode 100644 index 0000000..81e0c31 Binary files /dev/null and b/levelup/data/backgrounds/card11.webp differ diff --git a/levelup/data/backgrounds/card12.webp b/levelup/data/backgrounds/card12.webp new file mode 100644 index 0000000..fa21d8e Binary files /dev/null and b/levelup/data/backgrounds/card12.webp differ diff --git a/levelup/data/backgrounds/card13.webp b/levelup/data/backgrounds/card13.webp new file mode 100644 index 0000000..2bbdca6 Binary files /dev/null and b/levelup/data/backgrounds/card13.webp differ diff --git a/levelup/data/backgrounds/card14.webp b/levelup/data/backgrounds/card14.webp new file mode 100644 index 0000000..c52f791 Binary files /dev/null and b/levelup/data/backgrounds/card14.webp differ diff --git a/levelup/data/backgrounds/card15.webp b/levelup/data/backgrounds/card15.webp new file mode 100644 index 0000000..13a1a23 Binary files /dev/null and b/levelup/data/backgrounds/card15.webp differ diff --git a/levelup/data/backgrounds/card16.webp b/levelup/data/backgrounds/card16.webp new file mode 100644 index 0000000..9c9d3c0 Binary files /dev/null and b/levelup/data/backgrounds/card16.webp differ diff --git a/levelup/data/backgrounds/card17.webp b/levelup/data/backgrounds/card17.webp new file mode 100644 index 0000000..af72f38 Binary files /dev/null and b/levelup/data/backgrounds/card17.webp differ diff --git a/levelup/data/backgrounds/card18.webp b/levelup/data/backgrounds/card18.webp new file mode 100644 index 0000000..9c6d20e Binary files /dev/null and b/levelup/data/backgrounds/card18.webp differ diff --git a/levelup/data/backgrounds/card19.webp b/levelup/data/backgrounds/card19.webp new file mode 100644 index 0000000..d7d725e Binary files /dev/null and b/levelup/data/backgrounds/card19.webp differ diff --git a/levelup/data/backgrounds/card20.webp b/levelup/data/backgrounds/card20.webp new file mode 100644 index 0000000..c8160a8 Binary files /dev/null and b/levelup/data/backgrounds/card20.webp differ diff --git a/levelup/data/backgrounds/card21.webp b/levelup/data/backgrounds/card21.webp new file mode 100644 index 0000000..ff77cc8 Binary files /dev/null and b/levelup/data/backgrounds/card21.webp differ diff --git a/levelup/data/backgrounds/card22.webp b/levelup/data/backgrounds/card22.webp new file mode 100644 index 0000000..898756a Binary files /dev/null and b/levelup/data/backgrounds/card22.webp differ diff --git a/levelup/data/backgrounds/card23.webp b/levelup/data/backgrounds/card23.webp new file mode 100644 index 0000000..c58345f Binary files /dev/null and b/levelup/data/backgrounds/card23.webp differ diff --git a/levelup/data/backgrounds/card24.webp b/levelup/data/backgrounds/card24.webp new file mode 100644 index 0000000..28dfa43 Binary files /dev/null and b/levelup/data/backgrounds/card24.webp differ diff --git a/levelup/data/backgrounds/card25.webp b/levelup/data/backgrounds/card25.webp new file mode 100644 index 0000000..e1d8ca1 Binary files /dev/null and b/levelup/data/backgrounds/card25.webp differ diff --git a/levelup/data/backgrounds/card26.webp b/levelup/data/backgrounds/card26.webp new file mode 100644 index 0000000..a4fda6d Binary files /dev/null and b/levelup/data/backgrounds/card26.webp differ diff --git a/levelup/data/backgrounds/card27.webp b/levelup/data/backgrounds/card27.webp new file mode 100644 index 0000000..61026fe Binary files /dev/null and b/levelup/data/backgrounds/card27.webp differ diff --git a/levelup/data/backgrounds/card28.webp b/levelup/data/backgrounds/card28.webp new file mode 100644 index 0000000..93ade8c Binary files /dev/null and b/levelup/data/backgrounds/card28.webp differ diff --git a/levelup/data/backgrounds/card29.webp b/levelup/data/backgrounds/card29.webp new file mode 100644 index 0000000..37caff1 Binary files /dev/null and b/levelup/data/backgrounds/card29.webp differ diff --git a/levelup/data/backgrounds/card30.webp b/levelup/data/backgrounds/card30.webp new file mode 100644 index 0000000..2adcaf2 Binary files /dev/null and b/levelup/data/backgrounds/card30.webp differ diff --git a/levelup/data/backgrounds/card31.webp b/levelup/data/backgrounds/card31.webp new file mode 100644 index 0000000..ed7db41 Binary files /dev/null and b/levelup/data/backgrounds/card31.webp differ diff --git a/levelup/data/backgrounds/card32.webp b/levelup/data/backgrounds/card32.webp new file mode 100644 index 0000000..49cee31 Binary files /dev/null and b/levelup/data/backgrounds/card32.webp differ diff --git a/levelup/data/backgrounds/card33.webp b/levelup/data/backgrounds/card33.webp new file mode 100644 index 0000000..f0c8270 Binary files /dev/null and b/levelup/data/backgrounds/card33.webp differ diff --git a/levelup/data/backgrounds/card34.webp b/levelup/data/backgrounds/card34.webp new file mode 100644 index 0000000..14b49ea Binary files /dev/null and b/levelup/data/backgrounds/card34.webp differ diff --git a/levelup/data/backgrounds/card35.webp b/levelup/data/backgrounds/card35.webp new file mode 100644 index 0000000..0151f77 Binary files /dev/null and b/levelup/data/backgrounds/card35.webp differ diff --git a/levelup/data/backgrounds/card36.webp b/levelup/data/backgrounds/card36.webp new file mode 100644 index 0000000..90e747a Binary files /dev/null and b/levelup/data/backgrounds/card36.webp differ diff --git a/levelup/data/fonts/Avenger.ttf b/levelup/data/fonts/Avenger.ttf new file mode 100644 index 0000000..12b35db Binary files /dev/null and b/levelup/data/fonts/Avenger.ttf differ diff --git a/levelup/data/fonts/BebasNeue.ttf b/levelup/data/fonts/BebasNeue.ttf new file mode 100644 index 0000000..76e22b8 Binary files /dev/null and b/levelup/data/fonts/BebasNeue.ttf differ diff --git a/levelup/data/fonts/Bubblegum.ttf b/levelup/data/fonts/Bubblegum.ttf new file mode 100644 index 0000000..a9e9902 Binary files /dev/null and b/levelup/data/fonts/Bubblegum.ttf differ diff --git a/levelup/data/fonts/Chalice.ttf b/levelup/data/fonts/Chalice.ttf new file mode 100644 index 0000000..c48d799 Binary files /dev/null and b/levelup/data/fonts/Chalice.ttf differ diff --git a/levelup/data/fonts/Disney.ttf b/levelup/data/fonts/Disney.ttf new file mode 100644 index 0000000..a6aff8b Binary files /dev/null and b/levelup/data/fonts/Disney.ttf differ diff --git a/levelup/data/fonts/Halo.ttf b/levelup/data/fonts/Halo.ttf new file mode 100644 index 0000000..a61d13a Binary files /dev/null and b/levelup/data/fonts/Halo.ttf differ diff --git a/levelup/data/fonts/Hogwarts.ttf b/levelup/data/fonts/Hogwarts.ttf new file mode 100644 index 0000000..220e69a Binary files /dev/null and b/levelup/data/fonts/Hogwarts.ttf differ diff --git a/levelup/data/fonts/KrabbyPatty.ttf b/levelup/data/fonts/KrabbyPatty.ttf new file mode 100644 index 0000000..d73f0cb Binary files /dev/null and b/levelup/data/fonts/KrabbyPatty.ttf differ diff --git a/levelup/data/fonts/OldLondon.ttf b/levelup/data/fonts/OldLondon.ttf new file mode 100644 index 0000000..f24a340 Binary files /dev/null and b/levelup/data/fonts/OldLondon.ttf differ diff --git a/levelup/data/fonts/Party.ttf b/levelup/data/fonts/Party.ttf new file mode 100644 index 0000000..42fe071 Binary files /dev/null and b/levelup/data/fonts/Party.ttf differ diff --git a/levelup/data/fonts/Pokemon.ttf b/levelup/data/fonts/Pokemon.ttf new file mode 100644 index 0000000..918fe22 Binary files /dev/null and b/levelup/data/fonts/Pokemon.ttf differ diff --git a/levelup/data/fonts/Roboto.ttf b/levelup/data/fonts/Roboto.ttf new file mode 100644 index 0000000..7d9a6c4 Binary files /dev/null and b/levelup/data/fonts/Roboto.ttf differ diff --git a/levelup/data/fonts/Runescape.ttf b/levelup/data/fonts/Runescape.ttf new file mode 100644 index 0000000..0c6eefb Binary files /dev/null and b/levelup/data/fonts/Runescape.ttf differ diff --git a/levelup/data/fonts/Space.ttf b/levelup/data/fonts/Space.ttf new file mode 100644 index 0000000..86c16d7 Binary files /dev/null and b/levelup/data/fonts/Space.ttf differ diff --git a/levelup/data/fonts/StarWars.ttf b/levelup/data/fonts/StarWars.ttf new file mode 100644 index 0000000..5f93355 Binary files /dev/null and b/levelup/data/fonts/StarWars.ttf differ diff --git a/levelup/data/fonts/Truckin.ttf b/levelup/data/fonts/Truckin.ttf new file mode 100644 index 0000000..ba80a67 Binary files /dev/null and b/levelup/data/fonts/Truckin.ttf differ diff --git a/levelup/data/fonts/Vampire.ttf b/levelup/data/fonts/Vampire.ttf new file mode 100644 index 0000000..ebc0b65 Binary files /dev/null and b/levelup/data/fonts/Vampire.ttf differ diff --git a/levelup/data/fonts/Varsity.ttf b/levelup/data/fonts/Varsity.ttf new file mode 100644 index 0000000..c261600 Binary files /dev/null and b/levelup/data/fonts/Varsity.ttf differ diff --git a/levelup/data/fonts/Vogue.ttf b/levelup/data/fonts/Vogue.ttf new file mode 100644 index 0000000..15c1aa1 Binary files /dev/null and b/levelup/data/fonts/Vogue.ttf differ diff --git a/levelup/data/stock/colortable.webp b/levelup/data/stock/colortable.webp new file mode 100644 index 0000000..808427c Binary files /dev/null and b/levelup/data/stock/colortable.webp differ diff --git a/levelup/data/stock/defaultpfp.webp b/levelup/data/stock/defaultpfp.webp new file mode 100644 index 0000000..241df85 Binary files /dev/null and b/levelup/data/stock/defaultpfp.webp differ diff --git a/levelup/data/stock/dnd.webp b/levelup/data/stock/dnd.webp new file mode 100644 index 0000000..a8fbd7b Binary files /dev/null and b/levelup/data/stock/dnd.webp differ diff --git a/levelup/data/stock/idle.webp b/levelup/data/stock/idle.webp new file mode 100644 index 0000000..074bb8c Binary files /dev/null and b/levelup/data/stock/idle.webp differ diff --git a/levelup/data/stock/offline.webp b/levelup/data/stock/offline.webp new file mode 100644 index 0000000..791ef87 Binary files /dev/null and b/levelup/data/stock/offline.webp differ diff --git a/levelup/data/stock/online.webp b/levelup/data/stock/online.webp new file mode 100644 index 0000000..9083a91 Binary files /dev/null and b/levelup/data/stock/online.webp differ diff --git a/levelup/data/stock/profile-assets/defaultpfp.png b/levelup/data/stock/profile-assets/defaultpfp.png new file mode 100644 index 0000000..74e4694 Binary files /dev/null and b/levelup/data/stock/profile-assets/defaultpfp.png differ diff --git a/levelup/data/stock/profile-assets/dnd.png b/levelup/data/stock/profile-assets/dnd.png new file mode 100644 index 0000000..5fee004 Binary files /dev/null and b/levelup/data/stock/profile-assets/dnd.png differ diff --git a/levelup/data/stock/profile-assets/idle.png b/levelup/data/stock/profile-assets/idle.png new file mode 100644 index 0000000..cb30687 Binary files /dev/null and b/levelup/data/stock/profile-assets/idle.png differ diff --git a/levelup/data/stock/profile-assets/offline.png b/levelup/data/stock/profile-assets/offline.png new file mode 100644 index 0000000..f5dd858 Binary files /dev/null and b/levelup/data/stock/profile-assets/offline.png differ diff --git a/levelup/data/stock/profile-assets/online.png b/levelup/data/stock/profile-assets/online.png new file mode 100644 index 0000000..cb10b52 Binary files /dev/null and b/levelup/data/stock/profile-assets/online.png differ diff --git a/levelup/data/stock/profile-assets/star.png b/levelup/data/stock/profile-assets/star.png new file mode 100644 index 0000000..465b3b4 Binary files /dev/null and b/levelup/data/stock/profile-assets/star.png differ diff --git a/levelup/data/stock/profile-assets/streaming.png b/levelup/data/stock/profile-assets/streaming.png new file mode 100644 index 0000000..64e6522 Binary files /dev/null and b/levelup/data/stock/profile-assets/streaming.png differ diff --git a/levelup/data/stock/rs-assets/rs-diamond.webp b/levelup/data/stock/rs-assets/rs-diamond.webp new file mode 100644 index 0000000..5346f35 Binary files /dev/null and b/levelup/data/stock/rs-assets/rs-diamond.webp differ diff --git a/levelup/data/stock/rs-assets/rs-star.webp b/levelup/data/stock/rs-assets/rs-star.webp new file mode 100644 index 0000000..bb4cd3c Binary files /dev/null and b/levelup/data/stock/rs-assets/rs-star.webp differ diff --git a/levelup/data/stock/rs-assets/rs-swords.webp b/levelup/data/stock/rs-assets/rs-swords.webp new file mode 100644 index 0000000..d59afa1 Binary files /dev/null and b/levelup/data/stock/rs-assets/rs-swords.webp differ diff --git a/levelup/data/stock/rs-assets/runescapeui.xcf b/levelup/data/stock/rs-assets/runescapeui.xcf new file mode 100644 index 0000000..e56d89c Binary files /dev/null and b/levelup/data/stock/rs-assets/runescapeui.xcf differ diff --git a/levelup/data/stock/runescapeui_nogold.webp b/levelup/data/stock/runescapeui_nogold.webp new file mode 100644 index 0000000..89e6acd Binary files /dev/null and b/levelup/data/stock/runescapeui_nogold.webp differ diff --git a/levelup/data/stock/runescapeui_noicons.webp b/levelup/data/stock/runescapeui_noicons.webp new file mode 100644 index 0000000..add1e40 Binary files /dev/null and b/levelup/data/stock/runescapeui_noicons.webp differ diff --git a/levelup/data/stock/runescapeui_withgold.webp b/levelup/data/stock/runescapeui_withgold.webp new file mode 100644 index 0000000..d66caf0 Binary files /dev/null and b/levelup/data/stock/runescapeui_withgold.webp differ diff --git a/levelup/data/stock/star.webp b/levelup/data/stock/star.webp new file mode 100644 index 0000000..4750c53 Binary files /dev/null and b/levelup/data/stock/star.webp differ diff --git a/levelup/data/stock/streaming.webp b/levelup/data/stock/streaming.webp new file mode 100644 index 0000000..62f3b8c Binary files /dev/null and b/levelup/data/stock/streaming.webp differ diff --git a/levelup/generator/README.md b/levelup/generator/README.md new file mode 100644 index 0000000..7eb3545 --- /dev/null +++ b/levelup/generator/README.md @@ -0,0 +1,133 @@ +# LevelUp API Service + +This repository contains the code for the `LevelUp API`, a companion API for a leveling cog for the `Red-DiscordBot`. This guide will help you clone the repository and set up your own self-hosted API. + +## Prerequisites + +- Python 3.10 or higher +- pip (Python package installer) +- Virtual environment (`venv`) +- git +- systemd (for setting up the service) + +## Installation and Setup + +### 1. Create a Virtual Environment + +It's best practice to use a virtual environment to manage dependencies: + +```bash +python3 -m venv apienv +``` + +### 2. Activate the Virtual Environment + +Before installing the required packages, activate the virtual environment: + +```bash +source apienv/bin/activate +``` + +### 3. Clone the Repository + +```bash +git clone https://github.com/vertyco/vrt-cogs.git +cd vrt-cogs/levelup/generator +``` + +### 4. Install Dependencies + +Install the required Python packages using `pip`: + +```bash +pip install -r requirements.txt +``` + +### 5. Test the API + +Before setting up the service, you can test the API to ensure it's working: + +```bash +cd /home/ubuntu/vrt-cogs/levelup/generator +``` + +```bash +uvicorn api:app --host 0.0.0.0 --port 8888 --app-dir /home/ubuntu/vrt-cogs/levelup/generator +``` + +### 6. Set Up the systemd Service + +Create a new systemd service file for the API: + +```bash +sudo nano /etc/systemd/system/levelup.service +``` + +Add the following content to the file: + +```ini +[Unit] +Description=LevelUp API Service +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/home/ubuntu/redenv/bin/uvicorn api:app --host 0.0.0.0 --port 8888 --workers 4 --app-dir /home/ubuntu/vrt-cogs/levelup/generator +User=ubuntu +Group=ubuntu +Restart=always +RestartSec=15 + +[Install] +WantedBy=multi-user.target +``` + +### 7. Reload systemd Daemon + +Reload the systemd configuration to apply the changes: + +```bash +sudo systemctl daemon-reload +``` + +### 8. Start and Enable the Service + +Start the service: + +```bash +sudo systemctl start levelup.service +``` + +Enable the service to start on boot: + +```bash +sudo systemctl enable levelup.service +``` + +### 9. Check the Service Status + +You can check the status of the service to ensure it is running correctly: + +```bash +sudo systemctl status levelup.service +``` + +### 10. View Logs + +You can view the logs for the service using: + +```bash +sudo journalctl -u levelup.service -f +``` + +## Troubleshooting + +If you encounter any issues, please check the logs for more details: + +```bash +sudo journalctl -u levelup.service -n 50 -f +``` + +## DISCLAIMER + +By using this API it is assumed you know your way around Python and hosting services. This guide is not exhaustive and may require additional steps based on your environment. I will not be providing support for setting up the API on your server. diff --git a/levelup/generator/__init__.py b/levelup/generator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/levelup/generator/api.py b/levelup/generator/api.py new file mode 100644 index 0000000..e656bde --- /dev/null +++ b/levelup/generator/api.py @@ -0,0 +1,306 @@ +import asyncio +import base64 +import logging +import logging.config +import multiprocessing as mp +import os +import sys +import typing as t +from contextlib import asynccontextmanager +from logging.handlers import RotatingFileHandler +from pathlib import Path + +import psutil +import uvicorn +import uvicorn.config +from decouple import config +from dotenv import load_dotenv +from fastapi import FastAPI, Request +from uvicorn.config import LOGGING_CONFIG +from uvicorn.logging import AccessFormatter, ColourizedFormatter + +try: + # Running from the cog + from .levelalert import generate_level_img + from .styles.default import generate_default_profile + from .styles.runescape import generate_runescape_profile + + SERVICE = False +except ImportError: + # Running as separate service + from levelalert import generate_level_img + from styles.default import generate_default_profile + from styles.runescape import generate_runescape_profile + + SERVICE = True + +load_dotenv() + +datefmt = "%m/%d %I:%M:%S %p" +access = "%(asctime)s - %(levelname)s %(client_addr)s - '%(request_line)s' %(status_code)s" +default = "%(asctime)s - %(levelname)s - %(message)s" +LOGGING_CONFIG["formatters"]["access"]["fmt"] = access +LOGGING_CONFIG["formatters"]["access"]["datefmt"] = datefmt +LOGGING_CONFIG["formatters"]["default"]["fmt"] = default +LOGGING_CONFIG["formatters"]["default"]["datefmt"] = datefmt + +default_formatter = ColourizedFormatter(fmt=default, datefmt=datefmt, use_colors=False) +access_formatter = AccessFormatter(fmt=access, datefmt=datefmt, use_colors=False) + +IS_WINDOWS: bool = sys.platform.startswith("win") +DEFAULT_WORKERS: int = os.cpu_count() or 1 +ROOT = Path(__file__).parent +LOG_DIR = Path.home() / "levelup-api-logs" +PROC: t.Union[mp.Process, asyncio.subprocess.Process] = None + + +if SERVICE: + LOG_DIR.mkdir(exist_ok=True, parents=True) + filehandler = RotatingFileHandler(str(LOG_DIR / "api.log"), maxBytes=51200, backupCount=2) + filehandler.setFormatter(default_formatter) + streamhandler = logging.StreamHandler() + streamhandler.setFormatter(default_formatter) + log = logging.getLogger("uvicorn") + log.handlers = [filehandler, streamhandler] + logging.getLogger("uvicorn.access").handlers = [filehandler, streamhandler] + log.info("API running as service") +else: + log = logging.getLogger("red.vrt.levelup.api") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + if PROC: + log.info("Shutting down API") + kill(PROC) + + +app = FastAPI(title="LevelUp API", version="0.0.1a") + + +# Utility to parse color strings to tuple +def parse_color(color_str: str) -> t.Union[None, t.Tuple[int, int, int]]: + # Convert a string like (255, 255, 255) to a tuple + if not color_str or not isinstance(color_str, str): + return None + color: t.Tuple[int, int, int] = tuple(map(int, color_str.strip("()").split(", "))) + return color + + +def get_kwargs(form_data: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: + kwargs = {} + for k, v in form_data.items(): + if hasattr(v, "file"): + kwargs[k] = v.file.read() + continue + elif isinstance(v, str) and v.isdigit(): + kwargs[k] = int(v) + elif isinstance(v, str) and v.lower() in ("true", "false"): + kwargs[k] = v.lower() == "true" + else: + try: + kwargs[k] = int(float(v)) + except ValueError: + kwargs[k] = v + + # Some values have the following + # ValueError: unknown color specifier: '(255, 255, 255)' + # The following kwargs need to be evaluated as tuples + if form_data.get("base_color") is not None: + kwargs["base_color"] = parse_color(form_data.get("base_color")) + if form_data.get("user_color") is not None: + kwargs["user_color"] = parse_color(form_data.get("user_color")) + if form_data.get("stat_color") is not None: + kwargs["stat_color"] = parse_color(form_data.get("stat_color")) + if form_data.get("level_bar_color") is not None: + kwargs["level_bar_color"] = parse_color(form_data.get("level_bar_color")) + if form_data.get("color") is not None: + kwargs["color"] = parse_color(form_data.get("color")) + + return kwargs + + +@app.post("/fullprofile") +async def fullprofile(request: Request): + form_data = await request.form() + kwargs = get_kwargs(form_data) + log.info(f"Generating full profile for {kwargs['username']}") + img_bytes, animated = await asyncio.to_thread(generate_default_profile, **kwargs) + encoded = base64.b64encode(img_bytes).decode("utf-8") + return {"b64": encoded, "animated": animated} + + +@app.post("/runescape") +async def runescape(request: Request): + form_data = await request.form() + kwargs = get_kwargs(form_data) + log.info(f"Generating runescape profile for {kwargs['username']}") + img_bytes, animated = await asyncio.to_thread(generate_runescape_profile, **kwargs) + encoded = base64.b64encode(img_bytes).decode("utf-8") + return {"b64": encoded, "animated": animated} + + +@app.post("/levelup") +async def levelup(request: Request): + form_data = await request.form() + kwargs = get_kwargs(form_data) + log.info("Generating levelup image") + img_bytes, animated = await asyncio.to_thread(generate_level_img, **kwargs) + encoded = base64.b64encode(img_bytes).decode("utf-8") + return {"b64": encoded, "animated": animated} + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +def port_in_use(port: int) -> bool: + for conn in psutil.net_connections(): + if conn.laddr.port == port: + # Get the process name + try: + proc = psutil.Process(conn.pid) + print(f"Process {proc.name()} is using port {port}") + except psutil.NoSuchProcess: + continue + return True + return False + + +def kill_process_on_port(port: int): + """ + Find and kill the process that's using the specified port. + """ + for proc in psutil.process_iter(["pid", "name"]): + for conn in proc.connections(kind="inet"): + if conn.laddr.port == port: + try: + proc.terminate() # Send SIGTERM signal + proc.wait(timeout=3) # Wait for the process to terminate + log.warning(f"Killed process {proc.info['pid']} ({proc.info['name']}) using port {port}") + return True + except Exception as e: + log.error(f"Failed to kill process {proc.info['pid']} ({proc.info['name']}): {e}") + + log.info(f"No process found using port {port}") + return False + + +async def run( + port: t.Optional[int] = 8888, + log_dir: t.Optional[t.Union[Path, str]] = None, + host: t.Optional[str] = None, +) -> t.Union[mp.Process, asyncio.subprocess.Process]: + if not port: + port = 8888 + if log_dir: + global LOG_DIR + LOG_DIR = log_dir if isinstance(log_dir, Path) else Path(log_dir) + + # Check if port is being used + if port_in_use(port): + raise Exception("Port already in use") + + APP_DIR = str(ROOT) + log.info(f"Running API from {APP_DIR}") + log.info(f"Log directory: {LOG_DIR} (As Service: {SERVICE})") + log.info(f"Spinning up {DEFAULT_WORKERS} workers on port {port} in 5s...") + await asyncio.sleep(5) + + if IS_WINDOWS: + kwargs = { + "workers": DEFAULT_WORKERS, + "port": port, + "app_dir": APP_DIR, + "log_config": LOGGING_CONFIG, + "use_colors": False, + } + if SERVICE and not host: + kwargs["host"] = "0.0.0.0" + elif host: + kwargs["host"] = host + log.info(f"Kwargs: {kwargs}") + proc = mp.Process( + target=uvicorn.run, + args=("api:app",), + kwargs=kwargs, + ) + proc.start() + return proc + + # Linux + exe_path = sys.executable + cmd = [ + f"{exe_path} -m uvicorn api:app", + f"--workers {DEFAULT_WORKERS}", + f"--port {port}", + f"--app-dir {APP_DIR}", + ] + if SERVICE and not host: + cmd.append("--host 0.0.0.0") + elif host: + cmd.append(f"--host {host}") + + cmd = " ".join(cmd) + log.info(f"Command: {cmd}") + proc = await asyncio.create_subprocess_exec(*cmd.split(" ")) + + global PROC + PROC = proc + return proc + + +def kill(proc: t.Union[mp.Process, asyncio.subprocess.Process]) -> None: + try: + parent = psutil.Process(proc.pid) + except psutil.NoSuchProcess: + try: + proc.terminate() + except Exception as e: + log.error("Failed to terminate process", exc_info=e) + return + for child in parent.children(recursive=True): + child.kill() + proc.terminate() + + +if __name__ == "__main__": + """ + If running this script directly, spin up the API. + + Usage: + python api.py [port] [log_dir] [host] + + Alternatively you can use a .env file with the following: + LEVELUP_PORT=8888 + LEVELUP_LOG_DIR=/path/to/log/dir + LEVELUP_HOST= + """ + + logging.basicConfig(level=logging.INFO) + loop = asyncio.ProactorEventLoop() if IS_WINDOWS else asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + port = config("LEVELUP_PORT", default=8888, cast=int) + log_dir = config("LEVELUP_LOG_DIR", default=None) + host = config("LEVELUP_HOST", default=None) + if len(sys.argv) > 1: + port = int(sys.argv[1]) + if len(sys.argv) > 2: + log_dir = sys.argv[2] + if len(sys.argv) > 3: + host = sys.argv[3] + + try: + loop.create_task(run(port, log_dir)) + loop.run_forever() + except KeyboardInterrupt: + print("CTRL+C detected") + except Exception as e: + log.error("API failed to start", exc_info=e) + finally: + log.info("Shutting down API...") + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() diff --git a/levelup/generator/imgtools.py b/levelup/generator/imgtools.py new file mode 100644 index 0000000..c851660 --- /dev/null +++ b/levelup/generator/imgtools.py @@ -0,0 +1,392 @@ +import logging +import math +import random +import typing as t +from io import BytesIO +from pathlib import Path +from typing import Union + +import colorgram +import requests +from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont, ImageSequence +from redbot.core.i18n import Translator + +ROOT = Path(__file__).parent.parent +ASSETS = ROOT / "data" +DEFAULT_BACKGROUNDS = ASSETS / "backgrounds" +DEFAULT_FONTS = ASSETS / "fonts" +DEFAULT_FONT = DEFAULT_FONTS / "BebasNeue.ttf" +STOCK = ASSETS / "stock" +STAR = Image.open(STOCK / "star.webp") +DEFAULT_PFP = Image.open(STOCK / "defaultpfp.webp") +RS_TEMPLATE = Image.open(STOCK / "runescapeui_nogold.webp") +RS_TEMPLATE_BALANCE = Image.open(STOCK / "runescapeui_withgold.webp") +COLORTABLE = STOCK / "colortable.webp" +STATUS = { + "online": Image.open(STOCK / "online.webp"), + "offline": Image.open(STOCK / "offline.webp"), + "idle": Image.open(STOCK / "idle.webp"), + "dnd": Image.open(STOCK / "dnd.webp"), + "streaming": Image.open(STOCK / "streaming.webp"), +} + +log = logging.getLogger("red.vrt.levelup.imagetools") +_ = Translator("LevelUp", __file__) + + +def download_image(url: str) -> t.Union[bytes, None]: + """Get an image from a URL""" + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0"} + try: + response = requests.get(url, headers=headers) + if response.status_code == 404: + return None + response.raise_for_status() + return response.content + except requests.HTTPError as e: + log.warning(f"Failed to download image URL: {url}\n{e}") + return None + except Exception as e: + log.error(f"Failed to download image URL: {url}", exc_info=e) + return None + + +def abbreviate_number(number: int) -> str: + """Abbreviate a number""" + abbreviations = [(1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")] + for num, abbrev in abbreviations: + if number >= num: + return f"{number // num}{abbrev}" + return str(number) + + +def abbreviate_time(delta: int, short: bool = False) -> str: + """Format time in seconds into an extra short human readable string""" + s = int(delta) + m, s = divmod(delta, 60) + h, m = divmod(m, 60) + d, h = divmod(h, 24) + y, d = divmod(d, 365) + + if not any([s, m, h, d, y]): + return _("None") + if not any([m, h, d, y]): + if short: + return f"{int(s)}S" + return f"{int(s)}s" + if not any([h, d, y]): + if short: + return f"{int(m)}M" + return f"{int(m)}m {int(s)}s" + if not any([d, y]): + if short: + return f"{int(h)}H" + return f"{int(h)}h {int(m)}m" + if not y: + if short: + return f"{int(d)}D" + return f"{int(d)}d {int(h)}h" + if short: + return f"{int(y)}Y" + return f"{int(y)}y {int(d)}d" + + +def make_circle_outline(thickness: int, color: tuple) -> Image.Image: + """Make a transparent circle""" + size = (1080, 1080) + img = Image.new("RGBA", size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + draw.ellipse((0, 0, size[0], size[1]), outline=color, width=thickness * 3) + return img + + +def make_profile_circle( + pfp: Image.Image, + method: Image.Resampling = Image.Resampling.LANCZOS, +) -> Image.Image: + """Crop an image into a circle""" + # Create a mask at 4x size (So we can scale down to smooth the edges later) + mask = Image.new("L", (pfp.width * 4, pfp.height * 4), 0) + draw = ImageDraw.Draw(mask) + draw.ellipse((0, 0, mask.width, mask.height), fill=255) + # Resize the mask to the image size + mask = mask.resize(pfp.size, method) + # Apply the mask + pfp.putalpha(mask) + return pfp + + +def get_rounded_corner_mask(image: Image.Image, radius: int) -> Image.Image: + """Get a mask for rounded corners""" + mask = Image.new("L", (image.width * 4, image.height * 4), 0) + draw = ImageDraw.Draw(mask) + draw.rounded_rectangle( + (0, 0, mask.width, mask.height), + fill=255, + radius=radius * 4, + ) + mask = mask.resize(image.size, Image.Resampling.LANCZOS) + return mask + + +def round_image_corners(image: Image.Image, radius: int) -> Image.Image: + mask = get_rounded_corner_mask(image, radius) + image.putalpha(mask) + return image + + +def blur_section(image: Image.Image, bbox: t.Tuple[int, int, int, int]) -> Image.Image: + """Blur a section of an image""" + section = image.crop(bbox) + section = section.filter(ImageFilter.GaussianBlur(3)) + # Darken the image + section = ImageEnhance.Brightness(section).enhance(0.8) + return section + + +def clean_gif_frame(image: Image.Image) -> Image.Image: + """Clean up a GIF frame""" + alpha = image.getchannel("A") + mask = Image.eval(alpha, lambda a: 255 if a > 128 else 0) + image.putalpha(mask) + return image + + +def make_progress_bar( + width: int, + height: int, + progress: float, # 0.0 - 1.0 + color: t.Tuple[int, int, int] = None, + background_color: t.Tuple[int, int, int] = None, +) -> Image.Image: + """Make a pretty rounded progress bar.""" + if not color: + # White + color = (255, 255, 255) + if not background_color: + # Dark grey + background_color = (100, 100, 100) + # Ensure progress is within 0.0 - 1.0 + progress = max(0.0, min(1.0, progress)) + scale = 4 + scaled_width = width * scale + scaled_height = height * scale + radius = scaled_height // 2 + img = Image.new("RGBA", (scaled_width, scaled_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + # Draw the progress + if progress > 0: + # Length of progress bar + bar_length = int(scaled_width * progress) + # Draw the rounded rectangle for the progress + draw.rounded_rectangle([(0, 0), (max(bar_length, scaled_height), scaled_height)], radius, fill=color) + + # Draw the background (empty bar) + placement = [(0, 0), (scaled_width, scaled_height)] + draw.rounded_rectangle(placement, radius, outline=background_color, width=scale * 4) + + # Scale down to smooth edges + img = img.resize((width, height), resample=Image.Resampling.LANCZOS) + return img + + +def format_fonts(filepaths: t.List[str]) -> Image.Image: + """Format fonts into an image""" + filepaths.sort(key=lambda x: Path(x).stem) + count = len(filepaths) + fontsize = 50 + img = Image.new("RGBA", (650, fontsize * count + (count * 15)), 0) + color = (255, 255, 255) + draw = ImageDraw.Draw(img) + for idx, path in enumerate(filepaths): + font = ImageFont.truetype(path, fontsize) + draw.text((5, idx * (fontsize + 15)), Path(path).stem, color, font=font, stroke_width=1, stroke_fill=(0, 0, 0)) + return img + + +def format_backgrounds(filepaths: t.List[str]) -> Image.Image: + """Format backgrounds into an image""" + filepaths.sort(key=lambda x: Path(x).stem) + images: t.List[t.Tuple[Image.Image, str]] = [] + for path in filepaths: + if Path(path).suffix.endswith(("py", "pyc")): + continue + if Path(path).is_dir(): + continue + try: + img = Image.open(path) + img = fit_aspect_ratio(img, (1050, 450)) + # Resize so all images are the same width + new_w, new_h = 1000, int(img.height / img.width * 1000) + img = img.resize((new_w, new_h), Image.Resampling.NEAREST) + draw = ImageDraw.Draw(img) + name = Path(path).stem + draw.text( + (10, 10), + name, + font=ImageFont.truetype(str(DEFAULT_FONT), 100), + fill=(255, 255, 255), + stroke_width=5, + stroke_fill="#000000", + ) + if not img: + log.error(f"Failed to load image for default background '{path}`") + continue + images.append((img, Path(path).name)) + except Exception as e: + log.warning(f"Failed to prep background image: {path}", exc_info=e) + + # Merge the images into a single image and try to make it even + # It can be a little taller than wide + rowcount = math.ceil(len(images) ** 0.35) + colcount = math.ceil(len(images) / rowcount) + max_height = max(images, key=lambda x: x[0].height)[0].height + width = 1000 * rowcount + height = max_height * colcount + new = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + for idx, (img, name) in enumerate(images): + x = 1000 * (idx % rowcount) + y = max_height * (idx // rowcount) + new.paste(img, (x, y)) + return new + + +def concat_img_v(im1: Image, im2: Image) -> Image.Image: + """Merge two images vertically""" + new = Image.new("RGBA", (im1.width, im1.height + im2.height)) + new.paste(im1, (0, 0)) + new.paste(im2, (0, im1.height)) + return new + + +def concat_img_h(im1: Image, im2: Image) -> Image.Image: + """Merge two images horizontally""" + new = Image.new("RGBA", (im1.width + im2.width, im1.height)) + new.paste(im1, (0, 0)) + new.paste(im2, (im1.width, 0)) + return new + + +def get_img_colors( + img: Union[Image.Image, str, bytes, BytesIO], + amount: int, +) -> t.List[t.Tuple[int, int, int]]: + """Extract colors from an image using colorgram.py""" + try: + colors = colorgram.extract(img, amount) + extracted = [color.rgb for color in colors] + return extracted + except Exception as e: + log.error("Failed to extract image colors", exc_info=e) + extracted: t.List[t.Tuple[int, int, int]] = [(0, 0, 0) for _ in range(amount)] + return extracted + + +def distance(color1: t.Tuple[int, int, int], color2: t.Tuple[int, int, int]) -> float: + """Calculate the Euclidean distance between two RGB colors""" + # Values + x1, y1, z1 = color1 + x2, y2, z2 = color2 + # Distances + dx = x1 - x2 + dy = y1 - y2 + dz = z1 - z2 + # Final distance + return math.sqrt(dx**2 + dy**2 + dz**2) + + +def inv_rgb(rgb: t.Tuple[int, int, int]) -> t.Tuple[int, int, int]: + """Invert an RGB color tuple""" + return 255 - rgb[0], 255 - rgb[1], 255 - rgb[2] + + +def rand_rgb() -> t.Tuple[int, int, int]: + """Generate a random RGB color tuple""" + r = random.randint(0, 256) + g = random.randint(0, 256) + b = random.randint(0, 256) + return r, g, b + + +def calc_aspect_ratio(width: int, height: int) -> t.Tuple[int, int]: + """Calculate the aspect ratio of an image""" + divisor = math.gcd(width, height) + return width // divisor, height // divisor + + +def fit_aspect_ratio( + image: Image.Image, + desired_size: t.Tuple[int, int], # (1050, 450) + preserve: bool = False, + method: Image.Resampling = Image.Resampling.LANCZOS, +) -> Image.Image: + """ + Crop image to fit aspect ratio + + We will either need to chop off the sides or chop off the top and bottom (or add transparent space) + + Args: + image (Image): Image to fit + aspect_ratio (t.Tuple[int, int], optional): Fit the image to the aspect ratio. Defaults to (21, 9). + preserve (bool, optional): Rather than cropping, add transparent space. Defaults to False. + + Returns: + Image + """ + # If the image is already the correct size, return it + if image.size == desired_size: + return image + + if preserve: + # Rather than cropping, add transparent space + new = Image.new("RGBA", desired_size, (0, 0, 0, 0)) + x = (desired_size[0] - image.width) // 2 + y = (desired_size[1] - image.height) // 2 + new.paste(image, (x, y)) + return new + else: + # Crop the image to fit the aspect ratio + aspect_ratio = calc_aspect_ratio(*desired_size) + if image.width / image.height > aspect_ratio[0] / aspect_ratio[1]: + # Image is wider than desired aspect ratio + new_width = image.height * aspect_ratio[0] // aspect_ratio[1] + x = (image.width - new_width) // 2 + y = 0 + image = image.crop((x, y, x + new_width, image.height)) + else: + # Image is taller than desired aspect ratio + new_height = image.width * aspect_ratio[1] // aspect_ratio[0] + x = 0 + y = (image.height - new_height) // 2 + image = image.crop((x, y, image.width, y + new_height)) + return image.resize(desired_size, method) + + +def get_random_background() -> Image.Image: + """Get a random background image""" + files = list(DEFAULT_BACKGROUNDS.glob("*.webp")) + if not files: + raise FileNotFoundError("No background images found") + return Image.open(random.choice(files)) + + +def get_avg_duration(image: Image.Image) -> int: + """Get the average duration of a GIF""" + if not getattr(image, "is_animated", False): + log.warning("Image is not animated") + return 0 + + try: + durations = [frame.info["duration"] for frame in ImageSequence.Iterator(image)] + # durations = [] + # for frame in range(1, image.n_frames): + # image.seek(frame) + # durations.append(image.info.get("duration", 0)) + return sum(durations) // len(durations) + except Exception as e: + log.error("Failed to get average duration of GIF", exc_info=e) + return 0 + + +if __name__ == "__main__": + print(calc_aspect_ratio(200, 70)) diff --git a/levelup/generator/levelalert.py b/levelup/generator/levelalert.py new file mode 100644 index 0000000..a9e8728 --- /dev/null +++ b/levelup/generator/levelalert.py @@ -0,0 +1,273 @@ +"""Generate LevelUp Image + +Args: + background_bytes (t.Optional[bytes], optional): The background image as bytes. Defaults to None. + avatar_bytes (t.Optional[bytes], optional): The avatar image as bytes. Defaults to None. + level (t.Optional[int], optional): The level number. Defaults to 1. + color (t.Optional[t.Tuple[int, int, int]], optional): The color of the level text as a tuple of RGB values. Defaults to None. + font (t.Optional[t.Union[str, Path]], optional): The path to the font file or the name of the font. Defaults to None. + render_gif (t.Optional[bool], optional): Whether to render the image as a GIF. Defaults to False. + debug (t.Optional[bool], optional): Whether to show the generated image for debugging purposes. Defaults to False. + +Returns: + bytes: The generated image as bytes. +""" + +import logging +import math +import typing as t +from io import BytesIO +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont, ImageSequence, UnidentifiedImageError +from redbot.core.i18n import Translator + +try: + from . import imgtools +except ImportError: + import imgtools + +log = logging.getLogger("red.vrt.levelup.generator.levelalert") +_ = Translator("LevelUp", __file__) + + +def generate_level_img( + background_bytes: t.Optional[t.Union[bytes, str]] = None, + avatar_bytes: t.Optional[t.Union[bytes, str]] = None, + level: int = 1, + color: t.Optional[t.Tuple[int, int, int]] = None, + font_path: t.Optional[t.Union[str, Path]] = None, + render_gif: bool = False, + debug: bool = False, + **kwargs, +) -> t.Tuple[bytes, bool]: + if isinstance(background_bytes, str) and background_bytes.startswith("http"): + log.debug("Background image is a URL, attempting to download") + background_bytes = imgtools.download_image(background_bytes) + + if isinstance(avatar_bytes, str) and avatar_bytes.startswith("http"): + log.debug("Avatar image is a URL, attempting to download") + avatar_bytes = imgtools.download_image(avatar_bytes) + + if background_bytes: + try: + card = Image.open(BytesIO(background_bytes)) + except UnidentifiedImageError as e: + log.error("Error opening background image", exc_info=e) + card = imgtools.get_random_background() + else: + card = imgtools.get_random_background() + if avatar_bytes: + pfp = Image.open(BytesIO(avatar_bytes)) + else: + pfp = imgtools.DEFAULT_PFP + + pfp_animated = getattr(pfp, "is_animated", False) + bg_animated = getattr(card, "is_animated", False) + log.debug(f"PFP animated: {pfp_animated}, BG animated: {bg_animated}") + + desired_card_size = (200, 70) + # 3 layers: card, profile, text + + # PREPARE THE TEXT LAYER + text_layer = Image.new("RGBA", desired_card_size, (0, 0, 0, 0)) + tw, th = text_layer.size + fontsize = 30 + font_path = font_path or imgtools.DEFAULT_FONT + if isinstance(font_path, str): + font_path = Path(font_path) + if not font_path.exists(): # Hosted api specified a font that doesn't exist on the server + if (imgtools.DEFAULT_FONTS / font_path.name).exists(): + font_path = imgtools.DEFAULT_FONTS / font_path.name + else: + font_path = imgtools.DEFAULT_FONT + font_path = str(font_path) + font = ImageFont.truetype(font_path, fontsize) + text = _("Level {}").format(level) + placement_area_center_x = th + ((tw - th) / 2) + while font.getlength(text) > (tw - th) - 10: + fontsize -= 1 + font = ImageFont.truetype(font_path, fontsize) + draw = ImageDraw.Draw(text_layer) + draw.text( + xy=(placement_area_center_x, int(th / 2)), + text=text, + fill=color or imgtools.rand_rgb(), + font=font, + anchor="mm", + stroke_width=3, + stroke_fill=(0, 0, 0), + ) + # FINALIZE IMAGE + if not render_gif or (not pfp_animated and not bg_animated): + # Render a static pfp on a static background + if not card.mode == "RGBA": + card = card.convert("RGBA") + if not pfp.mode == "RGBA": + pfp = pfp.convert("RGBA") + card = imgtools.fit_aspect_ratio(card, desired_card_size) + pfp = pfp.resize((card.height, card.height), Image.Resampling.LANCZOS) + pfp = imgtools.make_profile_circle(pfp) + card.paste(text_layer, (0, 0), text_layer) + card.paste(pfp, (0, 0), pfp) + card = imgtools.round_image_corners(card, card.height) + if debug: + card.show(title="LevelUp Image") + buffer = BytesIO() + card.save(buffer, format="WEBP") + card.close() + return buffer.getvalue(), False + if pfp_animated and not bg_animated: + # Render an animated pfp on a static background + if not card.mode == "RGBA": + card = card.convert("RGBA") + card = imgtools.fit_aspect_ratio(card, desired_card_size) + avg_duration = imgtools.get_avg_duration(pfp) + log.debug(f"Average frame duration: {avg_duration}") + frames: t.List[Image.Image] = [] + for frame in range(pfp.n_frames): + pfp_frame = ImageSequence.Iterator(pfp)[frame] + card_frame = card.copy() + if not pfp_frame.mode == "RGBA": + pfp_frame = pfp_frame.convert("RGBA") + + pfp_frame = pfp_frame.resize((card.height, card.height), Image.Resampling.LANCZOS) + pfp_frame = imgtools.make_profile_circle(pfp_frame) + + card_frame.paste(text_layer, (0, 0), text_layer) + card_frame.paste(pfp_frame, (0, 0), pfp_frame) + card_frame = imgtools.round_image_corners(card_frame, card_frame.height) + card_frame = imgtools.clean_gif_frame(card_frame) + frames.append(card_frame) + buffer = BytesIO() + frames[0].save( + buffer, + save_all=True, + append_images=frames[1:], + format="GIF", + duration=avg_duration, + loop=0, + quality=75, + optimize=True, + ) + buffer.seek(0) + if debug: + Image.open(buffer).show() + return buffer.getvalue(), True + if bg_animated and not pfp_animated: + # Render a static pfp on an animated background + if not pfp.mode == "RGBA": + pfp = pfp.convert("RGBA") + pfp = pfp.resize((desired_card_size[1], desired_card_size[1]), Image.Resampling.LANCZOS) + pfp = imgtools.make_profile_circle(pfp) + avg_duration = imgtools.get_avg_duration(card) + log.debug(f"Average frame duration: {avg_duration}") + frames: t.List[Image.Image] = [] + for frame in range(card.n_frames): + bg_frame = ImageSequence.Iterator(card)[frame] + card_frame = bg_frame.copy() + if not card_frame.mode == "RGBA": + card_frame = card_frame.convert("RGBA") + card_frame = imgtools.fit_aspect_ratio(card_frame, desired_card_size) + card_frame = imgtools.round_image_corners(card_frame, card_frame.height) + card_frame = imgtools.clean_gif_frame(card_frame) + card_frame.paste(text_layer, (0, 0), text_layer) + card_frame.paste(pfp, (0, 0), pfp) + frames.append(card_frame) + + buffer = BytesIO() + frames[0].save( + buffer, + save_all=True, + append_images=frames[1:], + format="GIF", + duration=avg_duration, + loop=0, + quality=75, + optimize=True, + ) + buffer.seek(0) + if debug: + Image.open(buffer).show() + return buffer.getvalue(), True + + # If we're here, both the pfp and the background are animated + card_duration = imgtools.get_avg_duration(card) + pfp_duration = imgtools.get_avg_duration(pfp) + log.debug(f"Card duration: {card_duration}, PFP duration: {pfp_duration}") + # Round to the nearest 10ms + card_duration = round(card_duration, -1) + pfp_duration = round(pfp_duration, -1) + # Get the least common multiple of the two durations + combined_duration = math.lcm(card_duration, pfp_duration) + # Soft cap it + max_duration = max(card_duration, pfp_duration) + if combined_duration > max_duration * 1.2: + combined_duration = max_duration * 1.2 + + total_pfp_duration = pfp.n_frames * pfp_duration + total_card_duration = card.n_frames * card_duration + total_duration_lcm = math.lcm(total_pfp_duration, total_card_duration) + + # Get the number of frames to render + num_frames = total_duration_lcm // combined_duration + # Also soft cap max amount of frames so we dont get a huge gif + max_frame_count = max(pfp.n_frames, card.n_frames) * 1.2 + max_frame_count = min(round(max_frame_count), num_frames) + log.debug(f"Max frame count: {max_frame_count}") + + frames: t.List[Image.Image] = [] + for frame_num in range(max_frame_count): + time = frame_num * combined_duration + + card_frame_index = (time // card_duration) % card.n_frames + pfp_frame_index = (time // pfp_duration) % pfp.n_frames + + card_frame: Image.Image = ImageSequence.Iterator(card)[int(card_frame_index)] + pfp_frame: Image.Image = ImageSequence.Iterator(pfp)[int(pfp_frame_index)] + + card_frame = imgtools.fit_aspect_ratio(card_frame, desired_card_size) + pfp_frame = pfp_frame.resize((card_frame.height, card_frame.height), Image.Resampling.LANCZOS) + pfp_frame = imgtools.make_profile_circle(pfp_frame) + if not card_frame.mode == "RGBA": + card_frame = card_frame.convert("RGBA") + if not pfp_frame.mode == "RGBA": + pfp_frame = pfp_frame.convert("RGBA") + + card_frame = imgtools.round_image_corners(card_frame, card_frame.height) + card_frame = imgtools.clean_gif_frame(card_frame) + card_frame.paste(text_layer, (0, 0), text_layer) + card_frame.paste(pfp_frame, (0, 0), pfp_frame) + frames.append(card_frame) + buffer = BytesIO() + frames[0].save( + buffer, + save_all=True, + append_images=frames[1:], + format="GIF", + duration=combined_duration, + loop=0, + quality=75, + optimize=True, + ) + buffer.seek(0) + if debug: + Image.open(buffer).show() + return buffer.getvalue(), True + + +if __name__ == "__main__": + # Setup console logging + logging.basicConfig(level=logging.DEBUG) + logging.getLogger("PIL").setLevel(logging.INFO) + test_banner = (imgtools.ASSETS / "tests" / "banner3.gif").read_bytes() + test_avatar = (imgtools.ASSETS / "tests" / "tree.gif").read_bytes() + res, animated = generate_level_img( + background_bytes=test_banner, + avatar_bytes=test_avatar, + level=10, + debug=True, + render_gif=True, + ) + result_path = imgtools.ASSETS / "tests" / "level.gif" + result_path.write_bytes(res) diff --git a/levelup/generator/locales/de-DE.po b/levelup/generator/locales/de-DE.po new file mode 100644 index 0000000..ca2c524 --- /dev/null +++ b/levelup/generator/locales/de-DE.po @@ -0,0 +1,148 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/locales/messages.pot\n" +"X-Crowdin-File-ID: 166\n" +"Language: de_DE\n" + +#: levelup\generator\api.py:170 +#, docstring +msgid "\n" +" Find and kill the process that's using the specified port.\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:38 +#, docstring +msgid "Get an image from a URL" +msgstr "" + +#: levelup\generator\imgtools.py:55 +#, docstring +msgid "Abbreviate a number" +msgstr "" + +#: levelup\generator\imgtools.py:64 +#, docstring +msgid "Format time in seconds into an extra short human readable string" +msgstr "" + +#: levelup\generator\imgtools.py:72 +msgid "None" +msgstr "" + +#: levelup\generator\imgtools.py:95 +#, docstring +msgid "Make a transparent circle" +msgstr "" + +#: levelup\generator\imgtools.py:107 +#, docstring +msgid "Crop an image into a circle" +msgstr "" + +#: levelup\generator\imgtools.py:120 +#, docstring +msgid "Get a mask for rounded corners" +msgstr "" + +#: levelup\generator\imgtools.py:139 +#, docstring +msgid "Blur a section of an image" +msgstr "" + +#: levelup\generator\imgtools.py:148 +#, docstring +msgid "Clean up a GIF frame" +msgstr "" + +#: levelup\generator\imgtools.py:162 +#, docstring +msgid "Make a pretty rounded progress bar." +msgstr "" + +#: levelup\generator\imgtools.py:194 +#, docstring +msgid "Format fonts into an image" +msgstr "" + +#: levelup\generator\imgtools.py:208 +#, docstring +msgid "Format backgrounds into an image" +msgstr "" + +#: levelup\generator\imgtools.py:255 +#, docstring +msgid "Merge two images vertically" +msgstr "" + +#: levelup\generator\imgtools.py:263 +#, docstring +msgid "Merge two images horizontally" +msgstr "" + +#: levelup\generator\imgtools.py:274 +#, docstring +msgid "Extract colors from an image using colorgram.py" +msgstr "" + +#: levelup\generator\imgtools.py:286 +#, docstring +msgid "Calculate the Euclidean distance between two RGB colors" +msgstr "" + +#: levelup\generator\imgtools.py:299 +#, docstring +msgid "Invert an RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:304 +#, docstring +msgid "Generate a random RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:312 +#, docstring +msgid "Calculate the aspect ratio of an image" +msgstr "" + +#: levelup\generator\imgtools.py:323 +#, docstring +msgid "\n" +" Crop image to fit aspect ratio\n\n" +" We will either need to chop off the sides or chop off the top and bottom (or add transparent space)\n\n" +" Args:\n" +" image (Image): Image to fit\n" +" aspect_ratio (t.Tuple[int, int], optional): Fit the image to the aspect ratio. Defaults to (21, 9).\n" +" preserve (bool, optional): Rather than cropping, add transparent space. Defaults to False.\n\n" +" Returns:\n" +" Image\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:366 +#, docstring +msgid "Get a random background image" +msgstr "" + +#: levelup\generator\imgtools.py:374 +#, docstring +msgid "Get the average duration of a GIF" +msgstr "" + +#: levelup\generator\levelalert.py:86 +msgid "Level {}" +msgstr "" + diff --git a/levelup/generator/locales/es-ES.po b/levelup/generator/locales/es-ES.po new file mode 100644 index 0000000..78381ec --- /dev/null +++ b/levelup/generator/locales/es-ES.po @@ -0,0 +1,159 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/locales/messages.pot\n" +"X-Crowdin-File-ID: 166\n" +"Language: es_ES\n" + +#: levelup\generator\api.py:170 +#, docstring +msgid "\n" +" Find and kill the process that's using the specified port.\n" +" " +msgstr "\n" +" Encuentra y mata el proceso que está utilizando el puerto especificado.\n" +" " + +#: levelup\generator\imgtools.py:38 +#, docstring +msgid "Get an image from a URL" +msgstr "" + +#: levelup\generator\imgtools.py:55 +#, docstring +msgid "Abbreviate a number" +msgstr "Abreviar un número" + +#: levelup\generator\imgtools.py:64 +#, docstring +msgid "Format time in seconds into an extra short human readable string" +msgstr "Formatear el tiempo en segundos en una cadena de texto extra corta y legible por humanos" + +#: levelup\generator\imgtools.py:72 +msgid "None" +msgstr "Ninguno" + +#: levelup\generator\imgtools.py:95 +#, docstring +msgid "Make a transparent circle" +msgstr "Hacer un círculo transparente" + +#: levelup\generator\imgtools.py:107 +#, docstring +msgid "Crop an image into a circle" +msgstr "Recortar una imagen en un círculo" + +#: levelup\generator\imgtools.py:120 +#, docstring +msgid "Get a mask for rounded corners" +msgstr "Obtener una máscara para esquinas redondeadas" + +#: levelup\generator\imgtools.py:139 +#, docstring +msgid "Blur a section of an image" +msgstr "Desenfocar una sección de una imagen" + +#: levelup\generator\imgtools.py:148 +#, docstring +msgid "Clean up a GIF frame" +msgstr "Limpiar un cuadro de GIF" + +#: levelup\generator\imgtools.py:162 +#, docstring +msgid "Make a pretty rounded progress bar." +msgstr "Hacer una barra de progreso redondeada y bonita." + +#: levelup\generator\imgtools.py:194 +#, docstring +msgid "Format fonts into an image" +msgstr "Formatear fuentes en una imagen" + +#: levelup\generator\imgtools.py:208 +#, docstring +msgid "Format backgrounds into an image" +msgstr "Formatear fondos en una imagen" + +#: levelup\generator\imgtools.py:255 +#, docstring +msgid "Merge two images vertically" +msgstr "Unir dos imágenes verticalmente" + +#: levelup\generator\imgtools.py:263 +#, docstring +msgid "Merge two images horizontally" +msgstr "Unir dos imágenes horizontalmente" + +#: levelup\generator\imgtools.py:274 +#, docstring +msgid "Extract colors from an image using colorgram.py" +msgstr "Extraer colores de una imagen usando colorgram.py" + +#: levelup\generator\imgtools.py:286 +#, docstring +msgid "Calculate the Euclidean distance between two RGB colors" +msgstr "Calcular la distancia euclidiana entre dos colores RGB" + +#: levelup\generator\imgtools.py:299 +#, docstring +msgid "Invert an RGB color tuple" +msgstr "Invertir una tupla de color RGB" + +#: levelup\generator\imgtools.py:304 +#, docstring +msgid "Generate a random RGB color tuple" +msgstr "Generar una tupla de color RGB aleatoria" + +#: levelup\generator\imgtools.py:312 +#, docstring +msgid "Calculate the aspect ratio of an image" +msgstr "Calcular la relación de aspecto de una imagen" + +#: levelup\generator\imgtools.py:323 +#, docstring +msgid "\n" +" Crop image to fit aspect ratio\n\n" +" We will either need to chop off the sides or chop off the top and bottom (or add transparent space)\n\n" +" Args:\n" +" image (Image): Image to fit\n" +" aspect_ratio (t.Tuple[int, int], optional): Fit the image to the aspect ratio. Defaults to (21, 9).\n" +" preserve (bool, optional): Rather than cropping, add transparent space. Defaults to False.\n\n" +" Returns:\n" +" Image\n" +" " +msgstr "\n" +" Recortar imagen para ajustar la relación de aspecto\n\n" +" Tendremos que cortar los lados o cortar la parte superior e inferior (o agregar espacio transparente)\n\n" +" Args:\n" +" imagen (Imagen): Imagen a ajustar\n" +" relación_de_aspecto (t.Tuple[int, int], opcional): Ajustar la imagen a la relación de aspecto. Predeterminado es (21, 9).\n" +" preservar (bool, opcional): En lugar de recortar, agregar espacio transparente. Predeterminado es False.\n\n" +" Returns:\n" +" Imagen\n" +" " + +#: levelup\generator\imgtools.py:366 +#, docstring +msgid "Get a random background image" +msgstr "Obtener una imagen de fondo aleatoria" + +#: levelup\generator\imgtools.py:374 +#, docstring +msgid "Get the average duration of a GIF" +msgstr "Obtener la duración promedio de un GIF" + +#: levelup\generator\levelalert.py:86 +msgid "Level {}" +msgstr "Nivel {}" + diff --git a/levelup/generator/locales/fr-FR.po b/levelup/generator/locales/fr-FR.po new file mode 100644 index 0000000..9fd225c --- /dev/null +++ b/levelup/generator/locales/fr-FR.po @@ -0,0 +1,148 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/locales/messages.pot\n" +"X-Crowdin-File-ID: 166\n" +"Language: fr_FR\n" + +#: levelup\generator\api.py:170 +#, docstring +msgid "\n" +" Find and kill the process that's using the specified port.\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:38 +#, docstring +msgid "Get an image from a URL" +msgstr "" + +#: levelup\generator\imgtools.py:55 +#, docstring +msgid "Abbreviate a number" +msgstr "" + +#: levelup\generator\imgtools.py:64 +#, docstring +msgid "Format time in seconds into an extra short human readable string" +msgstr "" + +#: levelup\generator\imgtools.py:72 +msgid "None" +msgstr "" + +#: levelup\generator\imgtools.py:95 +#, docstring +msgid "Make a transparent circle" +msgstr "" + +#: levelup\generator\imgtools.py:107 +#, docstring +msgid "Crop an image into a circle" +msgstr "" + +#: levelup\generator\imgtools.py:120 +#, docstring +msgid "Get a mask for rounded corners" +msgstr "" + +#: levelup\generator\imgtools.py:139 +#, docstring +msgid "Blur a section of an image" +msgstr "" + +#: levelup\generator\imgtools.py:148 +#, docstring +msgid "Clean up a GIF frame" +msgstr "" + +#: levelup\generator\imgtools.py:162 +#, docstring +msgid "Make a pretty rounded progress bar." +msgstr "" + +#: levelup\generator\imgtools.py:194 +#, docstring +msgid "Format fonts into an image" +msgstr "" + +#: levelup\generator\imgtools.py:208 +#, docstring +msgid "Format backgrounds into an image" +msgstr "" + +#: levelup\generator\imgtools.py:255 +#, docstring +msgid "Merge two images vertically" +msgstr "" + +#: levelup\generator\imgtools.py:263 +#, docstring +msgid "Merge two images horizontally" +msgstr "" + +#: levelup\generator\imgtools.py:274 +#, docstring +msgid "Extract colors from an image using colorgram.py" +msgstr "" + +#: levelup\generator\imgtools.py:286 +#, docstring +msgid "Calculate the Euclidean distance between two RGB colors" +msgstr "" + +#: levelup\generator\imgtools.py:299 +#, docstring +msgid "Invert an RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:304 +#, docstring +msgid "Generate a random RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:312 +#, docstring +msgid "Calculate the aspect ratio of an image" +msgstr "" + +#: levelup\generator\imgtools.py:323 +#, docstring +msgid "\n" +" Crop image to fit aspect ratio\n\n" +" We will either need to chop off the sides or chop off the top and bottom (or add transparent space)\n\n" +" Args:\n" +" image (Image): Image to fit\n" +" aspect_ratio (t.Tuple[int, int], optional): Fit the image to the aspect ratio. Defaults to (21, 9).\n" +" preserve (bool, optional): Rather than cropping, add transparent space. Defaults to False.\n\n" +" Returns:\n" +" Image\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:366 +#, docstring +msgid "Get a random background image" +msgstr "" + +#: levelup\generator\imgtools.py:374 +#, docstring +msgid "Get the average duration of a GIF" +msgstr "" + +#: levelup\generator\levelalert.py:86 +msgid "Level {}" +msgstr "" + diff --git a/levelup/generator/locales/hr-HR.po b/levelup/generator/locales/hr-HR.po new file mode 100644 index 0000000..5c88c8f --- /dev/null +++ b/levelup/generator/locales/hr-HR.po @@ -0,0 +1,148 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: hr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/locales/messages.pot\n" +"X-Crowdin-File-ID: 166\n" +"Language: hr_HR\n" + +#: levelup\generator\api.py:170 +#, docstring +msgid "\n" +" Find and kill the process that's using the specified port.\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:38 +#, docstring +msgid "Get an image from a URL" +msgstr "" + +#: levelup\generator\imgtools.py:55 +#, docstring +msgid "Abbreviate a number" +msgstr "" + +#: levelup\generator\imgtools.py:64 +#, docstring +msgid "Format time in seconds into an extra short human readable string" +msgstr "" + +#: levelup\generator\imgtools.py:72 +msgid "None" +msgstr "" + +#: levelup\generator\imgtools.py:95 +#, docstring +msgid "Make a transparent circle" +msgstr "" + +#: levelup\generator\imgtools.py:107 +#, docstring +msgid "Crop an image into a circle" +msgstr "" + +#: levelup\generator\imgtools.py:120 +#, docstring +msgid "Get a mask for rounded corners" +msgstr "" + +#: levelup\generator\imgtools.py:139 +#, docstring +msgid "Blur a section of an image" +msgstr "" + +#: levelup\generator\imgtools.py:148 +#, docstring +msgid "Clean up a GIF frame" +msgstr "" + +#: levelup\generator\imgtools.py:162 +#, docstring +msgid "Make a pretty rounded progress bar." +msgstr "" + +#: levelup\generator\imgtools.py:194 +#, docstring +msgid "Format fonts into an image" +msgstr "" + +#: levelup\generator\imgtools.py:208 +#, docstring +msgid "Format backgrounds into an image" +msgstr "" + +#: levelup\generator\imgtools.py:255 +#, docstring +msgid "Merge two images vertically" +msgstr "" + +#: levelup\generator\imgtools.py:263 +#, docstring +msgid "Merge two images horizontally" +msgstr "" + +#: levelup\generator\imgtools.py:274 +#, docstring +msgid "Extract colors from an image using colorgram.py" +msgstr "" + +#: levelup\generator\imgtools.py:286 +#, docstring +msgid "Calculate the Euclidean distance between two RGB colors" +msgstr "" + +#: levelup\generator\imgtools.py:299 +#, docstring +msgid "Invert an RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:304 +#, docstring +msgid "Generate a random RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:312 +#, docstring +msgid "Calculate the aspect ratio of an image" +msgstr "" + +#: levelup\generator\imgtools.py:323 +#, docstring +msgid "\n" +" Crop image to fit aspect ratio\n\n" +" We will either need to chop off the sides or chop off the top and bottom (or add transparent space)\n\n" +" Args:\n" +" image (Image): Image to fit\n" +" aspect_ratio (t.Tuple[int, int], optional): Fit the image to the aspect ratio. Defaults to (21, 9).\n" +" preserve (bool, optional): Rather than cropping, add transparent space. Defaults to False.\n\n" +" Returns:\n" +" Image\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:366 +#, docstring +msgid "Get a random background image" +msgstr "" + +#: levelup\generator\imgtools.py:374 +#, docstring +msgid "Get the average duration of a GIF" +msgstr "" + +#: levelup\generator\levelalert.py:86 +msgid "Level {}" +msgstr "" + diff --git a/levelup/generator/locales/ko-KR.po b/levelup/generator/locales/ko-KR.po new file mode 100644 index 0000000..7a71913 --- /dev/null +++ b/levelup/generator/locales/ko-KR.po @@ -0,0 +1,148 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Korean\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ko\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/locales/messages.pot\n" +"X-Crowdin-File-ID: 166\n" +"Language: ko_KR\n" + +#: levelup\generator\api.py:170 +#, docstring +msgid "\n" +" Find and kill the process that's using the specified port.\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:38 +#, docstring +msgid "Get an image from a URL" +msgstr "" + +#: levelup\generator\imgtools.py:55 +#, docstring +msgid "Abbreviate a number" +msgstr "" + +#: levelup\generator\imgtools.py:64 +#, docstring +msgid "Format time in seconds into an extra short human readable string" +msgstr "" + +#: levelup\generator\imgtools.py:72 +msgid "None" +msgstr "" + +#: levelup\generator\imgtools.py:95 +#, docstring +msgid "Make a transparent circle" +msgstr "" + +#: levelup\generator\imgtools.py:107 +#, docstring +msgid "Crop an image into a circle" +msgstr "" + +#: levelup\generator\imgtools.py:120 +#, docstring +msgid "Get a mask for rounded corners" +msgstr "" + +#: levelup\generator\imgtools.py:139 +#, docstring +msgid "Blur a section of an image" +msgstr "" + +#: levelup\generator\imgtools.py:148 +#, docstring +msgid "Clean up a GIF frame" +msgstr "" + +#: levelup\generator\imgtools.py:162 +#, docstring +msgid "Make a pretty rounded progress bar." +msgstr "" + +#: levelup\generator\imgtools.py:194 +#, docstring +msgid "Format fonts into an image" +msgstr "" + +#: levelup\generator\imgtools.py:208 +#, docstring +msgid "Format backgrounds into an image" +msgstr "" + +#: levelup\generator\imgtools.py:255 +#, docstring +msgid "Merge two images vertically" +msgstr "" + +#: levelup\generator\imgtools.py:263 +#, docstring +msgid "Merge two images horizontally" +msgstr "" + +#: levelup\generator\imgtools.py:274 +#, docstring +msgid "Extract colors from an image using colorgram.py" +msgstr "" + +#: levelup\generator\imgtools.py:286 +#, docstring +msgid "Calculate the Euclidean distance between two RGB colors" +msgstr "" + +#: levelup\generator\imgtools.py:299 +#, docstring +msgid "Invert an RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:304 +#, docstring +msgid "Generate a random RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:312 +#, docstring +msgid "Calculate the aspect ratio of an image" +msgstr "" + +#: levelup\generator\imgtools.py:323 +#, docstring +msgid "\n" +" Crop image to fit aspect ratio\n\n" +" We will either need to chop off the sides or chop off the top and bottom (or add transparent space)\n\n" +" Args:\n" +" image (Image): Image to fit\n" +" aspect_ratio (t.Tuple[int, int], optional): Fit the image to the aspect ratio. Defaults to (21, 9).\n" +" preserve (bool, optional): Rather than cropping, add transparent space. Defaults to False.\n\n" +" Returns:\n" +" Image\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:366 +#, docstring +msgid "Get a random background image" +msgstr "" + +#: levelup\generator\imgtools.py:374 +#, docstring +msgid "Get the average duration of a GIF" +msgstr "" + +#: levelup\generator\levelalert.py:86 +msgid "Level {}" +msgstr "" + diff --git a/levelup/generator/locales/messages.pot b/levelup/generator/locales/messages.pot new file mode 100644 index 0000000..c609e2f --- /dev/null +++ b/levelup/generator/locales/messages.pot @@ -0,0 +1,146 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" + +#: levelup\generator\api.py:170 +#, docstring +msgid "" +"\n" +" Find and kill the process that's using the specified port.\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:38 +#, docstring +msgid "Get an image from a URL" +msgstr "" + +#: levelup\generator\imgtools.py:55 +#, docstring +msgid "Abbreviate a number" +msgstr "" + +#: levelup\generator\imgtools.py:64 +#, docstring +msgid "Format time in seconds into an extra short human readable string" +msgstr "" + +#: levelup\generator\imgtools.py:72 +msgid "None" +msgstr "" + +#: levelup\generator\imgtools.py:95 +#, docstring +msgid "Make a transparent circle" +msgstr "" + +#: levelup\generator\imgtools.py:107 +#, docstring +msgid "Crop an image into a circle" +msgstr "" + +#: levelup\generator\imgtools.py:120 +#, docstring +msgid "Get a mask for rounded corners" +msgstr "" + +#: levelup\generator\imgtools.py:139 +#, docstring +msgid "Blur a section of an image" +msgstr "" + +#: levelup\generator\imgtools.py:148 +#, docstring +msgid "Clean up a GIF frame" +msgstr "" + +#: levelup\generator\imgtools.py:162 +#, docstring +msgid "Make a pretty rounded progress bar." +msgstr "" + +#: levelup\generator\imgtools.py:194 +#, docstring +msgid "Format fonts into an image" +msgstr "" + +#: levelup\generator\imgtools.py:208 +#, docstring +msgid "Format backgrounds into an image" +msgstr "" + +#: levelup\generator\imgtools.py:255 +#, docstring +msgid "Merge two images vertically" +msgstr "" + +#: levelup\generator\imgtools.py:263 +#, docstring +msgid "Merge two images horizontally" +msgstr "" + +#: levelup\generator\imgtools.py:274 +#, docstring +msgid "Extract colors from an image using colorgram.py" +msgstr "" + +#: levelup\generator\imgtools.py:286 +#, docstring +msgid "Calculate the Euclidean distance between two RGB colors" +msgstr "" + +#: levelup\generator\imgtools.py:299 +#, docstring +msgid "Invert an RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:304 +#, docstring +msgid "Generate a random RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:312 +#, docstring +msgid "Calculate the aspect ratio of an image" +msgstr "" + +#: levelup\generator\imgtools.py:323 +#, docstring +msgid "" +"\n" +" Crop image to fit aspect ratio\n" +"\n" +" We will either need to chop off the sides or chop off the top and bottom (or add transparent space)\n" +"\n" +" Args:\n" +" image (Image): Image to fit\n" +" aspect_ratio (t.Tuple[int, int], optional): Fit the image to the aspect ratio. Defaults to (21, 9).\n" +" preserve (bool, optional): Rather than cropping, add transparent space. Defaults to False.\n" +"\n" +" Returns:\n" +" Image\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:366 +#, docstring +msgid "Get a random background image" +msgstr "" + +#: levelup\generator\imgtools.py:374 +#, docstring +msgid "Get the average duration of a GIF" +msgstr "" + +#: levelup\generator\levelalert.py:86 +msgid "Level {}" +msgstr "" diff --git a/levelup/generator/locales/pt-PT.po b/levelup/generator/locales/pt-PT.po new file mode 100644 index 0000000..4cc5e01 --- /dev/null +++ b/levelup/generator/locales/pt-PT.po @@ -0,0 +1,148 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: pt-PT\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/locales/messages.pot\n" +"X-Crowdin-File-ID: 166\n" +"Language: pt_PT\n" + +#: levelup\generator\api.py:170 +#, docstring +msgid "\n" +" Find and kill the process that's using the specified port.\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:38 +#, docstring +msgid "Get an image from a URL" +msgstr "" + +#: levelup\generator\imgtools.py:55 +#, docstring +msgid "Abbreviate a number" +msgstr "" + +#: levelup\generator\imgtools.py:64 +#, docstring +msgid "Format time in seconds into an extra short human readable string" +msgstr "" + +#: levelup\generator\imgtools.py:72 +msgid "None" +msgstr "" + +#: levelup\generator\imgtools.py:95 +#, docstring +msgid "Make a transparent circle" +msgstr "" + +#: levelup\generator\imgtools.py:107 +#, docstring +msgid "Crop an image into a circle" +msgstr "" + +#: levelup\generator\imgtools.py:120 +#, docstring +msgid "Get a mask for rounded corners" +msgstr "" + +#: levelup\generator\imgtools.py:139 +#, docstring +msgid "Blur a section of an image" +msgstr "" + +#: levelup\generator\imgtools.py:148 +#, docstring +msgid "Clean up a GIF frame" +msgstr "" + +#: levelup\generator\imgtools.py:162 +#, docstring +msgid "Make a pretty rounded progress bar." +msgstr "" + +#: levelup\generator\imgtools.py:194 +#, docstring +msgid "Format fonts into an image" +msgstr "" + +#: levelup\generator\imgtools.py:208 +#, docstring +msgid "Format backgrounds into an image" +msgstr "" + +#: levelup\generator\imgtools.py:255 +#, docstring +msgid "Merge two images vertically" +msgstr "" + +#: levelup\generator\imgtools.py:263 +#, docstring +msgid "Merge two images horizontally" +msgstr "" + +#: levelup\generator\imgtools.py:274 +#, docstring +msgid "Extract colors from an image using colorgram.py" +msgstr "" + +#: levelup\generator\imgtools.py:286 +#, docstring +msgid "Calculate the Euclidean distance between two RGB colors" +msgstr "" + +#: levelup\generator\imgtools.py:299 +#, docstring +msgid "Invert an RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:304 +#, docstring +msgid "Generate a random RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:312 +#, docstring +msgid "Calculate the aspect ratio of an image" +msgstr "" + +#: levelup\generator\imgtools.py:323 +#, docstring +msgid "\n" +" Crop image to fit aspect ratio\n\n" +" We will either need to chop off the sides or chop off the top and bottom (or add transparent space)\n\n" +" Args:\n" +" image (Image): Image to fit\n" +" aspect_ratio (t.Tuple[int, int], optional): Fit the image to the aspect ratio. Defaults to (21, 9).\n" +" preserve (bool, optional): Rather than cropping, add transparent space. Defaults to False.\n\n" +" Returns:\n" +" Image\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:366 +#, docstring +msgid "Get a random background image" +msgstr "" + +#: levelup\generator\imgtools.py:374 +#, docstring +msgid "Get the average duration of a GIF" +msgstr "" + +#: levelup\generator\levelalert.py:86 +msgid "Level {}" +msgstr "" + diff --git a/levelup/generator/locales/ru-RU.po b/levelup/generator/locales/ru-RU.po new file mode 100644 index 0000000..5e593ce --- /dev/null +++ b/levelup/generator/locales/ru-RU.po @@ -0,0 +1,148 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/locales/messages.pot\n" +"X-Crowdin-File-ID: 166\n" +"Language: ru_RU\n" + +#: levelup\generator\api.py:170 +#, docstring +msgid "\n" +" Find and kill the process that's using the specified port.\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:38 +#, docstring +msgid "Get an image from a URL" +msgstr "" + +#: levelup\generator\imgtools.py:55 +#, docstring +msgid "Abbreviate a number" +msgstr "" + +#: levelup\generator\imgtools.py:64 +#, docstring +msgid "Format time in seconds into an extra short human readable string" +msgstr "" + +#: levelup\generator\imgtools.py:72 +msgid "None" +msgstr "" + +#: levelup\generator\imgtools.py:95 +#, docstring +msgid "Make a transparent circle" +msgstr "" + +#: levelup\generator\imgtools.py:107 +#, docstring +msgid "Crop an image into a circle" +msgstr "" + +#: levelup\generator\imgtools.py:120 +#, docstring +msgid "Get a mask for rounded corners" +msgstr "" + +#: levelup\generator\imgtools.py:139 +#, docstring +msgid "Blur a section of an image" +msgstr "" + +#: levelup\generator\imgtools.py:148 +#, docstring +msgid "Clean up a GIF frame" +msgstr "" + +#: levelup\generator\imgtools.py:162 +#, docstring +msgid "Make a pretty rounded progress bar." +msgstr "" + +#: levelup\generator\imgtools.py:194 +#, docstring +msgid "Format fonts into an image" +msgstr "" + +#: levelup\generator\imgtools.py:208 +#, docstring +msgid "Format backgrounds into an image" +msgstr "" + +#: levelup\generator\imgtools.py:255 +#, docstring +msgid "Merge two images vertically" +msgstr "" + +#: levelup\generator\imgtools.py:263 +#, docstring +msgid "Merge two images horizontally" +msgstr "" + +#: levelup\generator\imgtools.py:274 +#, docstring +msgid "Extract colors from an image using colorgram.py" +msgstr "" + +#: levelup\generator\imgtools.py:286 +#, docstring +msgid "Calculate the Euclidean distance between two RGB colors" +msgstr "" + +#: levelup\generator\imgtools.py:299 +#, docstring +msgid "Invert an RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:304 +#, docstring +msgid "Generate a random RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:312 +#, docstring +msgid "Calculate the aspect ratio of an image" +msgstr "" + +#: levelup\generator\imgtools.py:323 +#, docstring +msgid "\n" +" Crop image to fit aspect ratio\n\n" +" We will either need to chop off the sides or chop off the top and bottom (or add transparent space)\n\n" +" Args:\n" +" image (Image): Image to fit\n" +" aspect_ratio (t.Tuple[int, int], optional): Fit the image to the aspect ratio. Defaults to (21, 9).\n" +" preserve (bool, optional): Rather than cropping, add transparent space. Defaults to False.\n\n" +" Returns:\n" +" Image\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:366 +#, docstring +msgid "Get a random background image" +msgstr "" + +#: levelup\generator\imgtools.py:374 +#, docstring +msgid "Get the average duration of a GIF" +msgstr "" + +#: levelup\generator\levelalert.py:86 +msgid "Level {}" +msgstr "" + diff --git a/levelup/generator/locales/tr-TR.po b/levelup/generator/locales/tr-TR.po new file mode 100644 index 0000000..4e26d12 --- /dev/null +++ b/levelup/generator/locales/tr-TR.po @@ -0,0 +1,148 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: tr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/locales/messages.pot\n" +"X-Crowdin-File-ID: 166\n" +"Language: tr_TR\n" + +#: levelup\generator\api.py:170 +#, docstring +msgid "\n" +" Find and kill the process that's using the specified port.\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:38 +#, docstring +msgid "Get an image from a URL" +msgstr "" + +#: levelup\generator\imgtools.py:55 +#, docstring +msgid "Abbreviate a number" +msgstr "" + +#: levelup\generator\imgtools.py:64 +#, docstring +msgid "Format time in seconds into an extra short human readable string" +msgstr "" + +#: levelup\generator\imgtools.py:72 +msgid "None" +msgstr "" + +#: levelup\generator\imgtools.py:95 +#, docstring +msgid "Make a transparent circle" +msgstr "" + +#: levelup\generator\imgtools.py:107 +#, docstring +msgid "Crop an image into a circle" +msgstr "" + +#: levelup\generator\imgtools.py:120 +#, docstring +msgid "Get a mask for rounded corners" +msgstr "" + +#: levelup\generator\imgtools.py:139 +#, docstring +msgid "Blur a section of an image" +msgstr "" + +#: levelup\generator\imgtools.py:148 +#, docstring +msgid "Clean up a GIF frame" +msgstr "" + +#: levelup\generator\imgtools.py:162 +#, docstring +msgid "Make a pretty rounded progress bar." +msgstr "" + +#: levelup\generator\imgtools.py:194 +#, docstring +msgid "Format fonts into an image" +msgstr "" + +#: levelup\generator\imgtools.py:208 +#, docstring +msgid "Format backgrounds into an image" +msgstr "" + +#: levelup\generator\imgtools.py:255 +#, docstring +msgid "Merge two images vertically" +msgstr "" + +#: levelup\generator\imgtools.py:263 +#, docstring +msgid "Merge two images horizontally" +msgstr "" + +#: levelup\generator\imgtools.py:274 +#, docstring +msgid "Extract colors from an image using colorgram.py" +msgstr "" + +#: levelup\generator\imgtools.py:286 +#, docstring +msgid "Calculate the Euclidean distance between two RGB colors" +msgstr "" + +#: levelup\generator\imgtools.py:299 +#, docstring +msgid "Invert an RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:304 +#, docstring +msgid "Generate a random RGB color tuple" +msgstr "" + +#: levelup\generator\imgtools.py:312 +#, docstring +msgid "Calculate the aspect ratio of an image" +msgstr "" + +#: levelup\generator\imgtools.py:323 +#, docstring +msgid "\n" +" Crop image to fit aspect ratio\n\n" +" We will either need to chop off the sides or chop off the top and bottom (or add transparent space)\n\n" +" Args:\n" +" image (Image): Image to fit\n" +" aspect_ratio (t.Tuple[int, int], optional): Fit the image to the aspect ratio. Defaults to (21, 9).\n" +" preserve (bool, optional): Rather than cropping, add transparent space. Defaults to False.\n\n" +" Returns:\n" +" Image\n" +" " +msgstr "" + +#: levelup\generator\imgtools.py:366 +#, docstring +msgid "Get a random background image" +msgstr "" + +#: levelup\generator\imgtools.py:374 +#, docstring +msgid "Get the average duration of a GIF" +msgstr "" + +#: levelup\generator\levelalert.py:86 +msgid "Level {}" +msgstr "" + diff --git a/levelup/generator/pilmojisrc/__init__.py b/levelup/generator/pilmojisrc/__init__.py new file mode 100644 index 0000000..67385b6 --- /dev/null +++ b/levelup/generator/pilmojisrc/__init__.py @@ -0,0 +1,2 @@ +# This directory is a modified fork of the pilmoji package. +# Pilmoji source: https://github.com/jay3332/pilmoji diff --git a/levelup/generator/pilmojisrc/core.py b/levelup/generator/pilmojisrc/core.py new file mode 100644 index 0000000..3806097 --- /dev/null +++ b/levelup/generator/pilmojisrc/core.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import logging +import math +from typing import ( + TYPE_CHECKING, + Dict, + Optional, + SupportsInt, + Tuple, + Type, + TypeVar, + Union, +) + +from PIL import Image, ImageDraw, ImageFont + +from .helpers import NodeType, getsize, to_nodes +from .source import BaseSource, HTTPBasedSource, Twemoji, _has_requests + +if TYPE_CHECKING: + from io import BytesIO + + FontT = Union[ImageFont.ImageFont, ImageFont.FreeTypeFont, ImageFont.TransposedFont] + ColorT = Union[int, Tuple[int, int, int], Tuple[int, int, int, int], str] + +P = TypeVar("P", bound="Pilmoji") +log = logging.getLogger("red.vrt.pilmoji") +__all__ = ("Pilmoji",) + + +class Pilmoji: + """The main emoji rendering interface. + + .. note:: + This should be used in a context manager. + + Parameters + ---------- + image: :class:`PIL.Image.Image` + The Pillow image to render on. + source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]] + The emoji image source to use. + This defaults to :class:`~.TwitterEmojiSource`. + cache: bool + Whether or not to cache emojis given from source. + Enabling this is recommended and by default. + draw: :class:`PIL.ImageDraw.ImageDraw` + The drawing instance to use. If left unfilled, + a new drawing instance will be created. + render_discord_emoji: bool + Whether or not to render Discord emoji. Defaults to `True` + emoji_scale_factor: float + The default rescaling factor for emojis. Defaults to `1` + emoji_position_offset: Tuple[int, int] + A 2-tuple representing the x and y offset for emojis when rendering, + respectively. Defaults to `(0, 0)` + """ + + def __init__( + self, + image: Image.Image, + *, + source: Union[BaseSource, Type[BaseSource]] = Twemoji, + cache: bool = True, + draw: Optional[ImageDraw.ImageDraw] = None, + render_discord_emoji: bool = True, + emoji_scale_factor: float = 1.0, + emoji_position_offset: Tuple[int, int] = (0, 0), + ) -> None: + self.image: Image.Image = image + self.draw: ImageDraw.ImageDraw = draw + + if isinstance(source, type): + if not issubclass(source, BaseSource): + raise TypeError(f"source must inherit from BaseSource, not {source}.") + + source = source() + + elif not isinstance(source, BaseSource): + raise TypeError(f"source must inherit from BaseSource, not {source.__class__}.") + + self.source: BaseSource = source + + self._cache: bool = cache + self._closed: bool = False + self._new_draw: bool = False + + self._render_discord_emoji: bool = render_discord_emoji + self._default_emoji_scale_factor: float = emoji_scale_factor + self._default_emoji_position_offset: Tuple[int, int] = emoji_position_offset + + self._emoji_cache: Dict[str, BytesIO] = {} + self._discord_emoji_cache: Dict[int, BytesIO] = {} + + self._create_draw() + + def open(self) -> None: + """Re-opens this renderer if it has been closed. + This should rarely be called. + + Raises + ------ + ValueError + The renderer is already open. + """ + if not self._closed: + raise ValueError("Renderer is already open.") + + if _has_requests and isinstance(self.source, HTTPBasedSource): + from requests import Session + + self.source._requests_session = Session() + + self._create_draw() + self._closed = False + + def close(self) -> None: + """Safely closes this renderer. + + .. note:: + If you are using a context manager, this should not be called. + + Raises + ------ + ValueError + The renderer has already been closed. + """ + if self._closed: + raise ValueError("Renderer has already been closed.") + + if self._new_draw: + del self.draw + self.draw = None + + if _has_requests and isinstance(self.source, HTTPBasedSource): + self.source._requests_session.close() + + if self._cache: + for stream in self._emoji_cache.values(): + stream.close() + + for stream in self._discord_emoji_cache.values(): + stream.close() + + self._emoji_cache = {} + self._discord_emoji_cache = {} + + self._closed = True + + def _create_draw(self) -> None: + if self.draw is None: + self._new_draw = True + self.draw = ImageDraw.Draw(self.image) + + def _get_emoji(self, emoji: str, /) -> Optional[BytesIO]: + if self._cache and emoji in self._emoji_cache: + entry = self._emoji_cache[emoji] + entry.seek(0) + return entry + + if stream := self.source.get_emoji(emoji): + if self._cache: + self._emoji_cache[emoji] = stream + + stream.seek(0) + return stream + + def _get_discord_emoji(self, id: SupportsInt, /) -> Optional[BytesIO]: + id = int(id) + + if self._cache and id in self._discord_emoji_cache: + entry = self._discord_emoji_cache[id] + entry.seek(0) + return entry + + if stream := self.source.get_discord_emoji(id): + if self._cache: + self._discord_emoji_cache[id] = stream + + stream.seek(0) + return stream + + def getsize( + self, + text: str, + font: FontT = None, + *, + spacing: int = 4, + emoji_scale_factor: float = None, + ) -> Tuple[int, int]: + """Return the width and height of the text when rendered. + This method supports multiline text. + + Parameters + ---------- + text: str + The text to use. + font + The font of the text. + spacing: int + The spacing between lines, in pixels. + Defaults to `4`. + emoji_scalee_factor: float + The rescaling factor for emojis. + Defaults to the factor given in the class constructor, or `1`. + """ + if emoji_scale_factor is None: + emoji_scale_factor = self._default_emoji_scale_factor + + return getsize(text, font, spacing=spacing, emoji_scale_factor=emoji_scale_factor) + + def text( + self, + xy: Tuple[int, int], + text: str, + fill: ColorT = None, + font: FontT = None, + anchor: str = None, + spacing: int = 4, + align: str = "left", + direction: str = None, + features: str = None, + language: str = None, + stroke_width: int = 0, + stroke_fill: ColorT = None, + embedded_color: bool = False, + *args, + emoji_scale_factor: float = None, + emoji_position_offset: Tuple[int, int] = None, + **kwargs, + ) -> None: + """Draws the string at the given position, with emoji rendering support. + This method supports multiline text. + + .. note:: + Some parameters have not been implemented yet. + + .. note:: + The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`. + + .. note:: + Not all parameters are listed here. + + Parameters + ---------- + xy: Tuple[int, int] + The position to render the text at. + text: str + The text to render. + fill + The fill color of the text. + font + The font to render the text with. + spacing: int + How many pixels there should be between lines. Defaults to `4` + emoji_scale_factor: float + The rescaling factor for emojis. This can be used for fine adjustments. + Defaults to the factor given in the class constructor, or `1`. + emoji_position_offset: Tuple[int, int] + The emoji position offset for emojis. This can be used for fine adjustments. + Defaults to the offset given in the class constructor, or `(0, 0)`. + """ + + if emoji_scale_factor is None: + emoji_scale_factor = self._default_emoji_scale_factor + + if emoji_position_offset is None: + emoji_position_offset = self._default_emoji_position_offset + + if font is None: + font = ImageFont.load_default() + + args = ( + fill, + font, + anchor, + spacing, + align, + direction, + features, + language, + stroke_width, + stroke_fill, + embedded_color, + *args, + ) + + x, y = xy + original_x = x + nodes = to_nodes(text) + + for line in nodes: + x = original_x + + for node in line: + content = node.content + + try: + width = font.getbbox(content)[2] + except AttributeError: + width = int(font.getlength(content)) + + if node.type is NodeType.text: + self.draw.text((x, y), content, *args, **kwargs) + x += width + continue + + stream = None + if node.type is NodeType.emoji: + stream = self._get_emoji(content) + + elif self._render_discord_emoji and node.type is NodeType.discord_emoji: + stream = self._get_discord_emoji(content) + + if not stream: + self.draw.text((x, y), content, *args, **kwargs) + x += width + continue + + with Image.open(stream).convert("RGBA") as asset: + width = int(emoji_scale_factor * font.size) + size = max(30, width), max(30, math.ceil(asset.height / asset.width * width)) + log.debug("Resizing emoji %r to %r", content, size) + asset = asset.resize(size, Image.Resampling.LANCZOS) + + ox, oy = emoji_position_offset + self.image.paste(asset, (x + ox, y + oy), asset) + + x += width + y += spacing + font.size + + def __enter__(self: P) -> P: + return self + + def __exit__(self, *_) -> None: + self.close() + + def __repr__(self) -> str: + return f"" diff --git a/levelup/generator/pilmojisrc/helpers.py b/levelup/generator/pilmojisrc/helpers.py new file mode 100644 index 0000000..4c9488a --- /dev/null +++ b/levelup/generator/pilmojisrc/helpers.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import re +from enum import Enum +from typing import TYPE_CHECKING, Dict, Final, List, NamedTuple, Tuple + +import emoji +import emoji.core +from emoji import unicode_codes +from PIL import ImageFont + +if TYPE_CHECKING: + from .core import FontT + +# This is actually way faster than it seems +if emoji.__version__ >= "2.12.0": + language_pack = { + v["en"]: k for k, v in emoji.EMOJI_DATA.items() if "en" in v and v["status"] <= emoji.STATUS["fully_qualified"] + } +else: + language_pack: Dict[str, str] = unicode_codes.get_emoji_unicode_dict("en") + + +_UNICODE_EMOJI_REGEX = "|".join(map(re.escape, sorted(language_pack.values(), key=len, reverse=True))) +_DISCORD_EMOJI_REGEX = "" + +EMOJI_REGEX: Final[re.Pattern[str]] = re.compile(f"({_UNICODE_EMOJI_REGEX}|{_DISCORD_EMOJI_REGEX})") + +__all__ = ("EMOJI_REGEX", "Node", "NodeType", "to_nodes", "getsize") + + +class NodeType(Enum): + """|enum| + + Represents the type of a :class:`~.Node`. + + Attributes + ---------- + text + This node is a raw text node. + emoji + This node is a unicode emoji. + discord_emoji + This node is a Discord emoji. + """ + + text = 0 + emoji = 1 + discord_emoji = 2 + + +class Node(NamedTuple): + """Represents a parsed node inside of a string. + + Attributes + ---------- + type: :class:`~.NodeType` + The type of this node. + content: str + The contents of this node. + """ + + type: NodeType + content: str + + def __repr__(self) -> str: + return f"" + + +def _parse_line(line: str, /) -> List[Node]: + nodes = [] + + for i, chunk in enumerate(EMOJI_REGEX.split(line)): + if not chunk: + continue + + if not i % 2: + nodes.append(Node(NodeType.text, chunk)) + continue + + if len(chunk) > 18: # This is guaranteed to be a Discord emoji + node = Node(NodeType.discord_emoji, chunk.split(":")[-1][:-1]) + else: + node = Node(NodeType.emoji, chunk) + + nodes.append(node) + + return nodes + + +def to_nodes(text: str, /) -> List[List[Node]]: + """Parses a string of text into :class:`~.Node`s. + + This method will return a nested list, each element of the list + being a list of :class:`~.Node`s and representing a line in the string. + + The string ``'Hello\nworld'`` would return something similar to + ``[[Node('Hello')], [Node('world')]]``. + + Parameters + ---------- + text: str + The text to parse into nodes. + + Returns + ------- + List[List[:class:`~.Node`]] + """ + return [_parse_line(line) for line in text.splitlines()] + + +def getsize(text: str, font: FontT = None, *, spacing: int = 4, emoji_scale_factor: float = 1) -> Tuple[int, int]: + """Return the width and height of the text when rendered. + This method supports multiline text. + + Parameters + ---------- + text: str + The text to use. + font + The font of the text. + spacing: int + The spacing between lines, in pixels. + Defaults to `4`. + emoji_scale_factor: float + The rescaling factor for emojis. + Defaults to `1`. + """ + if font is None: + font = ImageFont.load_default() + + x, y = 0, 0 + nodes = to_nodes(text) + + for line in nodes: + this_x = 0 + for node in line: + content = node.content + + if node.type is not NodeType.text: + width = int(emoji_scale_factor * font.size) + else: + width = int(font.getlength(content)) + # try: + # width, _ = font.getsize(content) + # except AttributeError: + # width = int(font.getlength(content)) + + this_x += width + + y += spacing + int(font.size) + + if this_x > x: + x = this_x + + return x, y - spacing diff --git a/levelup/generator/pilmojisrc/locales/de-DE.po b/levelup/generator/pilmojisrc/locales/de-DE.po new file mode 100644 index 0000000..9a194f8 --- /dev/null +++ b/levelup/generator/pilmojisrc/locales/de-DE.po @@ -0,0 +1,315 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/pilmojisrc/locales/messages.pot\n" +"X-Crowdin-File-ID: 168\n" +"Language: de_DE\n" + +#: levelup\generator\pilmojisrc\core.py:33 +#, docstring +msgid "The main emoji rendering interface.\n\n" +" .. note::\n" +" This should be used in a context manager.\n\n" +" Parameters\n" +" ----------\n" +" image: :class:`PIL.Image.Image`\n" +" The Pillow image to render on.\n" +" source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]\n" +" The emoji image source to use.\n" +" This defaults to :class:`~.TwitterEmojiSource`.\n" +" cache: bool\n" +" Whether or not to cache emojis given from source.\n" +" Enabling this is recommended and by default.\n" +" draw: :class:`PIL.ImageDraw.ImageDraw`\n" +" The drawing instance to use. If left unfilled,\n" +" a new drawing instance will be created.\n" +" render_discord_emoji: bool\n" +" Whether or not to render Discord emoji. Defaults to `True`\n" +" emoji_scale_factor: float\n" +" The default rescaling factor for emojis. Defaults to `1`\n" +" emoji_position_offset: Tuple[int, int]\n" +" A 2-tuple representing the x and y offset for emojis when rendering,\n" +" respectively. Defaults to `(0, 0)`\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:99 +#, docstring +msgid "Re-opens this renderer if it has been closed.\n" +" This should rarely be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer is already open.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:119 +#, docstring +msgid "Safely closes this renderer.\n\n" +" .. note::\n" +" If you are using a context manager, this should not be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer has already been closed.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:192 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scalee_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:233 +#, docstring +msgid "Draws the string at the given position, with emoji rendering support.\n" +" This method supports multiline text.\n\n" +" .. note::\n" +" Some parameters have not been implemented yet.\n\n" +" .. note::\n" +" The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.\n\n" +" .. note::\n" +" Not all parameters are listed here.\n\n" +" Parameters\n" +" ----------\n" +" xy: Tuple[int, int]\n" +" The position to render the text at.\n" +" text: str\n" +" The text to render.\n" +" fill\n" +" The fill color of the text.\n" +" font\n" +" The font to render the text with.\n" +" spacing: int\n" +" How many pixels there should be between lines. Defaults to `4`\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis. This can be used for fine adjustments.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" emoji_position_offset: Tuple[int, int]\n" +" The emoji position offset for emojis. This can be used for fine adjustments.\n" +" Defaults to the offset given in the class constructor, or `(0, 0)`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:33 +#, docstring +msgid "|enum|\n\n" +" Represents the type of a :class:`~.Node`.\n\n" +" Attributes\n" +" ----------\n" +" text\n" +" This node is a raw text node.\n" +" emoji\n" +" This node is a unicode emoji.\n" +" discord_emoji\n" +" This node is a Discord emoji.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:53 +#, docstring +msgid "Represents a parsed node inside of a string.\n\n" +" Attributes\n" +" ----------\n" +" type: :class:`~.NodeType`\n" +" The type of this node.\n" +" content: str\n" +" The contents of this node.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:92 +#, docstring +msgid "Parses a string of text into :class:`~.Node`s.\n\n" +" This method will return a nested list, each element of the list\n" +" being a list of :class:`~.Node`s and representing a line in the string.\n\n" +" The string ``'Hello\n" +"world'`` would return something similar to\n" +" ``[[Node('Hello')], [Node('world')]]``.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to parse into nodes.\n\n" +" Returns\n" +" -------\n" +" List[List[:class:`~.Node`]]\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:113 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:41 +#, docstring +msgid "The base class for an emoji image source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:45 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given emoji.\n\n" +" Parameters\n" +" ----------\n" +" emoji: str\n" +" The emoji to retrieve.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:63 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji.\n\n" +" Parameters\n" +" ----------\n" +" id: int\n" +" The snowflake ID of the Discord emoji.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:84 +#, docstring +msgid "Represents an HTTP-based source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:95 +#, docstring +msgid "Makes a GET request to the given URL.\n\n" +" If the `requests` library is installed, it will be used.\n" +" If it is not installed, :meth:`urllib.request.urlopen` will be used instead.\n\n" +" Parameters\n" +" ----------\n" +" url: str\n" +" The URL to request from.\n\n" +" Returns\n" +" -------\n" +" bytes\n\n" +" Raises\n" +" ------\n" +" Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]\n" +" There was an error requesting from the URL.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:133 +#, docstring +msgid "A mixin that adds Discord emoji functionality to another source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:152 +#, docstring +msgid "A base source that fetches emojis from https://emojicdn.elk.sh/." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:176 +#, docstring +msgid "A source that uses Twitter-style emojis. These are also the ones used in Discord." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:182 +#, docstring +msgid "A source that uses Apple emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:188 +#, docstring +msgid "A source that uses Google emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:194 +#, docstring +msgid "A source that uses Microsoft emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:200 +#, docstring +msgid "A source that uses Samsung emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:206 +#, docstring +msgid "A source that uses WhatsApp emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:212 +#, docstring +msgid "A source that uses Facebook emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:218 +#, docstring +msgid "A source that uses Facebook Messenger's emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:224 +#, docstring +msgid "A source that uses JoyPixels' emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:230 +#, docstring +msgid "A source that uses Openmoji emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:236 +#, docstring +msgid "A source that uses Emojidex emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:242 +#, docstring +msgid "A source that uses Mozilla's emojis." +msgstr "" + diff --git a/levelup/generator/pilmojisrc/locales/es-ES.po b/levelup/generator/pilmojisrc/locales/es-ES.po new file mode 100644 index 0000000..d102795 --- /dev/null +++ b/levelup/generator/pilmojisrc/locales/es-ES.po @@ -0,0 +1,471 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/pilmojisrc/locales/messages.pot\n" +"X-Crowdin-File-ID: 168\n" +"Language: es_ES\n" + +#: levelup\generator\pilmojisrc\core.py:33 +#, docstring +msgid "The main emoji rendering interface.\n\n" +" .. note::\n" +" This should be used in a context manager.\n\n" +" Parameters\n" +" ----------\n" +" image: :class:`PIL.Image.Image`\n" +" The Pillow image to render on.\n" +" source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]\n" +" The emoji image source to use.\n" +" This defaults to :class:`~.TwitterEmojiSource`.\n" +" cache: bool\n" +" Whether or not to cache emojis given from source.\n" +" Enabling this is recommended and by default.\n" +" draw: :class:`PIL.ImageDraw.ImageDraw`\n" +" The drawing instance to use. If left unfilled,\n" +" a new drawing instance will be created.\n" +" render_discord_emoji: bool\n" +" Whether or not to render Discord emoji. Defaults to `True`\n" +" emoji_scale_factor: float\n" +" The default rescaling factor for emojis. Defaults to `1`\n" +" emoji_position_offset: Tuple[int, int]\n" +" A 2-tuple representing the x and y offset for emojis when rendering,\n" +" respectively. Defaults to `(0, 0)`\n" +" " +msgstr "La interfaz principal de renderizado de emojis.\n\n" +" .. note::\n" +" Esto debe usarse en un gestor de contexto.\n\n" +" Parámetros\n" +" ----------\n" +" image: :class:`PIL.Image.Image`\n" +" La imagen de Pillow para renderizar.\n" +" source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]\n" +" La fuente de imagen de emoji a usar.\n" +" Esto por defecto es :class:`~.TwitterEmojiSource`.\n" +" cache: bool\n" +" Si se deben o no almacenar en caché los emojis dados por la fuente.\n" +" Habilitar esto es recomendado y por defecto.\n" +" draw: :class:`PIL.ImageDraw.ImageDraw`\n" +" La instancia de dibujo a usar. Si se deja vacía,\n" +" se creará una nueva instancia de dibujo.\n" +" render_discord_emoji: bool\n" +" Si se deben o no renderizar los emojis de Discord. Por defecto es `True`\n" +" emoji_scale_factor: float\n" +" El factor de reescalado predeterminado para los emojis. Por defecto es `1`\n" +" emoji_position_offset: Tuple[int, int]\n" +" Una 2-tupla representando el desplazamiento en x y en y para los emojis cuando se renderizan,\n" +" respectivamente. Por defecto es `(0, 0)`\n" +" " + +#: levelup\generator\pilmojisrc\core.py:99 +#, docstring +msgid "Re-opens this renderer if it has been closed.\n" +" This should rarely be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer is already open.\n" +" " +msgstr "Reabre este renderer si ha sido cerrado.\n" +" Esto rara vez debería llamarse.\n\n" +" Lanza\n" +" ------\n" +" ValueError\n" +" El renderer ya está abierto.\n" +" " + +#: levelup\generator\pilmojisrc\core.py:119 +#, docstring +msgid "Safely closes this renderer.\n\n" +" .. note::\n" +" If you are using a context manager, this should not be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer has already been closed.\n" +" " +msgstr "Cierra de forma segura este renderer.\n\n" +" .. note::\n" +" Si estás usando un gestor de contexto, no debería llamarse esto.\n\n" +" Lanza\n" +" ------\n" +" ValueError\n" +" El renderer ya ha sido cerrado.\n" +" " + +#: levelup\generator\pilmojisrc\core.py:192 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scalee_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" " +msgstr "Devuelve el ancho y el alto del texto cuando se renderiza.\n" +" Este método admite texto de varias líneas.\n\n" +" Parámetros\n" +" ----------\n" +" text: str\n" +" El texto a usar.\n" +" font\n" +" La fuente del texto.\n" +" spacing: int\n" +" El espacio entre líneas, en píxeles.\n" +" Por defecto es `4`.\n" +" emoji_scale_factor: float\n" +" El factor de reescalado para los emojis.\n" +" Por defecto es el factor dado en el constructor de la clase, o `1`.\n" +" " + +#: levelup\generator\pilmojisrc\core.py:233 +#, docstring +msgid "Draws the string at the given position, with emoji rendering support.\n" +" This method supports multiline text.\n\n" +" .. note::\n" +" Some parameters have not been implemented yet.\n\n" +" .. note::\n" +" The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.\n\n" +" .. note::\n" +" Not all parameters are listed here.\n\n" +" Parameters\n" +" ----------\n" +" xy: Tuple[int, int]\n" +" The position to render the text at.\n" +" text: str\n" +" The text to render.\n" +" fill\n" +" The fill color of the text.\n" +" font\n" +" The font to render the text with.\n" +" spacing: int\n" +" How many pixels there should be between lines. Defaults to `4`\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis. This can be used for fine adjustments.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" emoji_position_offset: Tuple[int, int]\n" +" The emoji position offset for emojis. This can be used for fine adjustments.\n" +" Defaults to the offset given in the class constructor, or `(0, 0)`.\n" +" " +msgstr "Dibuja la cadena en la posición dada, con soporte para renderizado de emojis.\n" +" Este método admite texto de varias líneas.\n\n" +" .. note::\n" +" Algunos parámetros aún no se han implementado.\n\n" +" .. note::\n" +" La firma de esta función es un superset de la firma de `ImageDraw.text` de Pillow.\n\n" +" .. note::\n" +" No todos los parámetros están listados aquí.\n\n" +" Parámetros\n" +" ----------\n" +" xy: Tuple[int, int]\n" +" La posición para renderizar el texto.\n" +" text: str\n" +" El texto a renderizar.\n" +" fill\n" +" El color de relleno del texto.\n" +" font\n" +" La fuente para renderizar el texto.\n" +" spacing: int\n" +" Cuántos píxeles debe haber entre las líneas. Por defecto es `4`\n" +" emoji_scale_factor: float\n" +" El factor de reescalado para los emojis. Esto puede usarse para ajustes finos.\n" +" Por defecto es el factor dado en el constructor de la clase, o `1`.\n" +" emoji_position_offset: Tuple[int, int]\n" +" El desplazamiento de la posición de los emojis. Esto puede usarse para ajustes finos.\n" +" Por defecto es el desplazamiento dado en el constructor de la clase, o `(0, 0)`.\n" +" " + +#: levelup\generator\pilmojisrc\helpers.py:33 +#, docstring +msgid "|enum|\n\n" +" Represents the type of a :class:`~.Node`.\n\n" +" Attributes\n" +" ----------\n" +" text\n" +" This node is a raw text node.\n" +" emoji\n" +" This node is a unicode emoji.\n" +" discord_emoji\n" +" This node is a Discord emoji.\n" +" " +msgstr "|enum|\n\n" +" Representa el tipo de un :class:`~.Node`.\n\n" +" Atributos\n" +" ----------\n" +" text\n" +" Este nodo es un nodo de texto en bruto.\n" +" emoji\n" +" Este nodo es un emoji unicode.\n" +" discord_emoji\n" +" Este nodo es un emoji de Discord.\n" +" " + +#: levelup\generator\pilmojisrc\helpers.py:53 +#, docstring +msgid "Represents a parsed node inside of a string.\n\n" +" Attributes\n" +" ----------\n" +" type: :class:`~.NodeType`\n" +" The type of this node.\n" +" content: str\n" +" The contents of this node.\n" +" " +msgstr "Representa un nodo parseado dentro de una cadena.\n\n" +" Atributos\n" +" ----------\n" +" type: :class:`~.NodeType`\n" +" El tipo de este nodo.\n" +" content: str\n" +" El contenido de este nodo.\n" +" " + +#: levelup\generator\pilmojisrc\helpers.py:92 +#, docstring +msgid "Parses a string of text into :class:`~.Node`s.\n\n" +" This method will return a nested list, each element of the list\n" +" being a list of :class:`~.Node`s and representing a line in the string.\n\n" +" The string ``'Hello\n" +"world'`` would return something similar to\n" +" ``[[Node('Hello')], [Node('world')]]``.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to parse into nodes.\n\n" +" Returns\n" +" -------\n" +" List[List[:class:`~.Node`]]\n" +" " +msgstr "Parsea una cadena de texto en :class:`~.Node`s.\n\n" +" Este método devolverá una lista anidada, cada elemento de la lista\n" +" siendo una lista de :class:`~.Node`s y representando una línea en la cadena.\n\n" +" La cadena ``'Hello\n" +"world'`` devolvería algo similar a\n" +" ``[[Node('Hello')], [Node('world')]]``.\n\n" +" Parámetros\n" +" ----------\n" +" text: str\n" +" El texto a parsear en nodos.\n\n" +" Devuelve\n" +" -------\n" +" List[List[:class:`~.Node`]]\n" +" " + +#: levelup\generator\pilmojisrc\helpers.py:113 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to `1`.\n" +" " +msgstr "Devuelve el ancho y la altura del texto cuando se renderiza.\n" +" Este método soporta texto en múltiples líneas.\n\n" +" Parámetros\n" +" ----------\n" +" text: str\n" +" El texto a usar.\n" +" font\n" +" La fuente del texto.\n" +" spacing: int\n" +" El espaciado entre líneas, en píxeles.\n" +" Por defecto es `4`.\n" +" emoji_scale_factor: float\n" +" El factor de reescalado para los emojis.\n" +" Por defecto es `1`.\n" +" " + +#: levelup\generator\pilmojisrc\source.py:41 +#, docstring +msgid "The base class for an emoji image source." +msgstr "La clase base para una fuente de imágenes de emojis." + +#: levelup\generator\pilmojisrc\source.py:45 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given emoji.\n\n" +" Parameters\n" +" ----------\n" +" emoji: str\n" +" The emoji to retrieve.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "Recupera un flujo de :class:`io.BytesIO` para la imagen del emoji dado.\n\n" +" Parámetros\n" +" ----------\n" +" emoji: str\n" +" El emoji a recuperar.\n\n" +" Devuelve\n" +" -------\n" +" :class:`io.BytesIO`\n" +" Un flujo de bytes del emoji.\n" +" None\n" +" No se pudo encontrar una imagen para el emoji.\n" +" " + +#: levelup\generator\pilmojisrc\source.py:63 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji.\n\n" +" Parameters\n" +" ----------\n" +" id: int\n" +" The snowflake ID of the Discord emoji.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "Recupera un flujo de :class:`io.BytesIO` para la imagen del emoji de Discord dado.\n\n" +" Parámetros\n" +" ----------\n" +" id: int\n" +" El ID de copo de nieve del emoji de Discord.\n\n" +" Devuelve\n" +" -------\n" +" :class:`io.BytesIO`\n" +" Un flujo de bytes del emoji.\n" +" None\n" +" No se pudo encontrar una imagen para el emoji.\n" +" " + +#: levelup\generator\pilmojisrc\source.py:84 +#, docstring +msgid "Represents an HTTP-based source." +msgstr "Representa una fuente basada en HTTP." + +#: levelup\generator\pilmojisrc\source.py:95 +#, docstring +msgid "Makes a GET request to the given URL.\n\n" +" If the `requests` library is installed, it will be used.\n" +" If it is not installed, :meth:`urllib.request.urlopen` will be used instead.\n\n" +" Parameters\n" +" ----------\n" +" url: str\n" +" The URL to request from.\n\n" +" Returns\n" +" -------\n" +" bytes\n\n" +" Raises\n" +" ------\n" +" Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]\n" +" There was an error requesting from the URL.\n" +" " +msgstr "Realiza una solicitud GET a la URL dada.\n\n" +" Si la biblioteca `requests` está instalada, será usada.\n" +" Si no está instalada, se usará :meth:`urllib.request.urlopen` en su lugar.\n\n" +" Parámetros\n" +" ----------\n" +" url: str\n" +" La URL desde la que solicitar.\n\n" +" Devuelve\n" +" -------\n" +" bytes\n\n" +" Lanza\n" +" ------\n" +" Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]\n" +" Hubo un error al solicitar desde la URL.\n" +" " + +#: levelup\generator\pilmojisrc\source.py:133 +#, docstring +msgid "A mixin that adds Discord emoji functionality to another source." +msgstr "Un mixin que agrega funcionalidad de emojis de Discord a otra fuente." + +#: levelup\generator\pilmojisrc\source.py:152 +#, docstring +msgid "A base source that fetches emojis from https://emojicdn.elk.sh/." +msgstr "Una fuente base que obtiene emojis de https://emojicdn.elk.sh/." + +#: levelup\generator\pilmojisrc\source.py:176 +#, docstring +msgid "A source that uses Twitter-style emojis. These are also the ones used in Discord." +msgstr "Una fuente que usa emojis estilo Twitter. Estos también son los que se usan en Discord." + +#: levelup\generator\pilmojisrc\source.py:182 +#, docstring +msgid "A source that uses Apple emojis." +msgstr "Una fuente que usa emojis de Apple." + +#: levelup\generator\pilmojisrc\source.py:188 +#, docstring +msgid "A source that uses Google emojis." +msgstr "Una fuente que usa emojis de Google." + +#: levelup\generator\pilmojisrc\source.py:194 +#, docstring +msgid "A source that uses Microsoft emojis." +msgstr "Una fuente que usa emojis de Microsoft." + +#: levelup\generator\pilmojisrc\source.py:200 +#, docstring +msgid "A source that uses Samsung emojis." +msgstr "Una fuente que usa emojis de Samsung." + +#: levelup\generator\pilmojisrc\source.py:206 +#, docstring +msgid "A source that uses WhatsApp emojis." +msgstr "Una fuente que usa emojis de WhatsApp." + +#: levelup\generator\pilmojisrc\source.py:212 +#, docstring +msgid "A source that uses Facebook emojis." +msgstr "Una fuente que usa emojis de Facebook." + +#: levelup\generator\pilmojisrc\source.py:218 +#, docstring +msgid "A source that uses Facebook Messenger's emojis." +msgstr "Una fuente que usa emojis de Facebook Messenger." + +#: levelup\generator\pilmojisrc\source.py:224 +#, docstring +msgid "A source that uses JoyPixels' emojis." +msgstr "Una fuente que usa emojis de JoyPixels." + +#: levelup\generator\pilmojisrc\source.py:230 +#, docstring +msgid "A source that uses Openmoji emojis." +msgstr "Una fuente que usa emojis de Openmoji." + +#: levelup\generator\pilmojisrc\source.py:236 +#, docstring +msgid "A source that uses Emojidex emojis." +msgstr "Una fuente que usa emojis de Emojidex." + +#: levelup\generator\pilmojisrc\source.py:242 +#, docstring +msgid "A source that uses Mozilla's emojis." +msgstr "Una fuente que usa emojis de Mozilla." + diff --git a/levelup/generator/pilmojisrc/locales/fr-FR.po b/levelup/generator/pilmojisrc/locales/fr-FR.po new file mode 100644 index 0000000..6d5f5a7 --- /dev/null +++ b/levelup/generator/pilmojisrc/locales/fr-FR.po @@ -0,0 +1,315 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/pilmojisrc/locales/messages.pot\n" +"X-Crowdin-File-ID: 168\n" +"Language: fr_FR\n" + +#: levelup\generator\pilmojisrc\core.py:33 +#, docstring +msgid "The main emoji rendering interface.\n\n" +" .. note::\n" +" This should be used in a context manager.\n\n" +" Parameters\n" +" ----------\n" +" image: :class:`PIL.Image.Image`\n" +" The Pillow image to render on.\n" +" source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]\n" +" The emoji image source to use.\n" +" This defaults to :class:`~.TwitterEmojiSource`.\n" +" cache: bool\n" +" Whether or not to cache emojis given from source.\n" +" Enabling this is recommended and by default.\n" +" draw: :class:`PIL.ImageDraw.ImageDraw`\n" +" The drawing instance to use. If left unfilled,\n" +" a new drawing instance will be created.\n" +" render_discord_emoji: bool\n" +" Whether or not to render Discord emoji. Defaults to `True`\n" +" emoji_scale_factor: float\n" +" The default rescaling factor for emojis. Defaults to `1`\n" +" emoji_position_offset: Tuple[int, int]\n" +" A 2-tuple representing the x and y offset for emojis when rendering,\n" +" respectively. Defaults to `(0, 0)`\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:99 +#, docstring +msgid "Re-opens this renderer if it has been closed.\n" +" This should rarely be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer is already open.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:119 +#, docstring +msgid "Safely closes this renderer.\n\n" +" .. note::\n" +" If you are using a context manager, this should not be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer has already been closed.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:192 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scalee_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:233 +#, docstring +msgid "Draws the string at the given position, with emoji rendering support.\n" +" This method supports multiline text.\n\n" +" .. note::\n" +" Some parameters have not been implemented yet.\n\n" +" .. note::\n" +" The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.\n\n" +" .. note::\n" +" Not all parameters are listed here.\n\n" +" Parameters\n" +" ----------\n" +" xy: Tuple[int, int]\n" +" The position to render the text at.\n" +" text: str\n" +" The text to render.\n" +" fill\n" +" The fill color of the text.\n" +" font\n" +" The font to render the text with.\n" +" spacing: int\n" +" How many pixels there should be between lines. Defaults to `4`\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis. This can be used for fine adjustments.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" emoji_position_offset: Tuple[int, int]\n" +" The emoji position offset for emojis. This can be used for fine adjustments.\n" +" Defaults to the offset given in the class constructor, or `(0, 0)`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:33 +#, docstring +msgid "|enum|\n\n" +" Represents the type of a :class:`~.Node`.\n\n" +" Attributes\n" +" ----------\n" +" text\n" +" This node is a raw text node.\n" +" emoji\n" +" This node is a unicode emoji.\n" +" discord_emoji\n" +" This node is a Discord emoji.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:53 +#, docstring +msgid "Represents a parsed node inside of a string.\n\n" +" Attributes\n" +" ----------\n" +" type: :class:`~.NodeType`\n" +" The type of this node.\n" +" content: str\n" +" The contents of this node.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:92 +#, docstring +msgid "Parses a string of text into :class:`~.Node`s.\n\n" +" This method will return a nested list, each element of the list\n" +" being a list of :class:`~.Node`s and representing a line in the string.\n\n" +" The string ``'Hello\n" +"world'`` would return something similar to\n" +" ``[[Node('Hello')], [Node('world')]]``.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to parse into nodes.\n\n" +" Returns\n" +" -------\n" +" List[List[:class:`~.Node`]]\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:113 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:41 +#, docstring +msgid "The base class for an emoji image source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:45 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given emoji.\n\n" +" Parameters\n" +" ----------\n" +" emoji: str\n" +" The emoji to retrieve.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:63 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji.\n\n" +" Parameters\n" +" ----------\n" +" id: int\n" +" The snowflake ID of the Discord emoji.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:84 +#, docstring +msgid "Represents an HTTP-based source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:95 +#, docstring +msgid "Makes a GET request to the given URL.\n\n" +" If the `requests` library is installed, it will be used.\n" +" If it is not installed, :meth:`urllib.request.urlopen` will be used instead.\n\n" +" Parameters\n" +" ----------\n" +" url: str\n" +" The URL to request from.\n\n" +" Returns\n" +" -------\n" +" bytes\n\n" +" Raises\n" +" ------\n" +" Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]\n" +" There was an error requesting from the URL.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:133 +#, docstring +msgid "A mixin that adds Discord emoji functionality to another source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:152 +#, docstring +msgid "A base source that fetches emojis from https://emojicdn.elk.sh/." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:176 +#, docstring +msgid "A source that uses Twitter-style emojis. These are also the ones used in Discord." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:182 +#, docstring +msgid "A source that uses Apple emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:188 +#, docstring +msgid "A source that uses Google emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:194 +#, docstring +msgid "A source that uses Microsoft emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:200 +#, docstring +msgid "A source that uses Samsung emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:206 +#, docstring +msgid "A source that uses WhatsApp emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:212 +#, docstring +msgid "A source that uses Facebook emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:218 +#, docstring +msgid "A source that uses Facebook Messenger's emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:224 +#, docstring +msgid "A source that uses JoyPixels' emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:230 +#, docstring +msgid "A source that uses Openmoji emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:236 +#, docstring +msgid "A source that uses Emojidex emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:242 +#, docstring +msgid "A source that uses Mozilla's emojis." +msgstr "" + diff --git a/levelup/generator/pilmojisrc/locales/hr-HR.po b/levelup/generator/pilmojisrc/locales/hr-HR.po new file mode 100644 index 0000000..960805e --- /dev/null +++ b/levelup/generator/pilmojisrc/locales/hr-HR.po @@ -0,0 +1,315 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: hr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/pilmojisrc/locales/messages.pot\n" +"X-Crowdin-File-ID: 168\n" +"Language: hr_HR\n" + +#: levelup\generator\pilmojisrc\core.py:33 +#, docstring +msgid "The main emoji rendering interface.\n\n" +" .. note::\n" +" This should be used in a context manager.\n\n" +" Parameters\n" +" ----------\n" +" image: :class:`PIL.Image.Image`\n" +" The Pillow image to render on.\n" +" source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]\n" +" The emoji image source to use.\n" +" This defaults to :class:`~.TwitterEmojiSource`.\n" +" cache: bool\n" +" Whether or not to cache emojis given from source.\n" +" Enabling this is recommended and by default.\n" +" draw: :class:`PIL.ImageDraw.ImageDraw`\n" +" The drawing instance to use. If left unfilled,\n" +" a new drawing instance will be created.\n" +" render_discord_emoji: bool\n" +" Whether or not to render Discord emoji. Defaults to `True`\n" +" emoji_scale_factor: float\n" +" The default rescaling factor for emojis. Defaults to `1`\n" +" emoji_position_offset: Tuple[int, int]\n" +" A 2-tuple representing the x and y offset for emojis when rendering,\n" +" respectively. Defaults to `(0, 0)`\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:99 +#, docstring +msgid "Re-opens this renderer if it has been closed.\n" +" This should rarely be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer is already open.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:119 +#, docstring +msgid "Safely closes this renderer.\n\n" +" .. note::\n" +" If you are using a context manager, this should not be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer has already been closed.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:192 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scalee_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:233 +#, docstring +msgid "Draws the string at the given position, with emoji rendering support.\n" +" This method supports multiline text.\n\n" +" .. note::\n" +" Some parameters have not been implemented yet.\n\n" +" .. note::\n" +" The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.\n\n" +" .. note::\n" +" Not all parameters are listed here.\n\n" +" Parameters\n" +" ----------\n" +" xy: Tuple[int, int]\n" +" The position to render the text at.\n" +" text: str\n" +" The text to render.\n" +" fill\n" +" The fill color of the text.\n" +" font\n" +" The font to render the text with.\n" +" spacing: int\n" +" How many pixels there should be between lines. Defaults to `4`\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis. This can be used for fine adjustments.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" emoji_position_offset: Tuple[int, int]\n" +" The emoji position offset for emojis. This can be used for fine adjustments.\n" +" Defaults to the offset given in the class constructor, or `(0, 0)`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:33 +#, docstring +msgid "|enum|\n\n" +" Represents the type of a :class:`~.Node`.\n\n" +" Attributes\n" +" ----------\n" +" text\n" +" This node is a raw text node.\n" +" emoji\n" +" This node is a unicode emoji.\n" +" discord_emoji\n" +" This node is a Discord emoji.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:53 +#, docstring +msgid "Represents a parsed node inside of a string.\n\n" +" Attributes\n" +" ----------\n" +" type: :class:`~.NodeType`\n" +" The type of this node.\n" +" content: str\n" +" The contents of this node.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:92 +#, docstring +msgid "Parses a string of text into :class:`~.Node`s.\n\n" +" This method will return a nested list, each element of the list\n" +" being a list of :class:`~.Node`s and representing a line in the string.\n\n" +" The string ``'Hello\n" +"world'`` would return something similar to\n" +" ``[[Node('Hello')], [Node('world')]]``.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to parse into nodes.\n\n" +" Returns\n" +" -------\n" +" List[List[:class:`~.Node`]]\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:113 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:41 +#, docstring +msgid "The base class for an emoji image source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:45 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given emoji.\n\n" +" Parameters\n" +" ----------\n" +" emoji: str\n" +" The emoji to retrieve.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:63 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji.\n\n" +" Parameters\n" +" ----------\n" +" id: int\n" +" The snowflake ID of the Discord emoji.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:84 +#, docstring +msgid "Represents an HTTP-based source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:95 +#, docstring +msgid "Makes a GET request to the given URL.\n\n" +" If the `requests` library is installed, it will be used.\n" +" If it is not installed, :meth:`urllib.request.urlopen` will be used instead.\n\n" +" Parameters\n" +" ----------\n" +" url: str\n" +" The URL to request from.\n\n" +" Returns\n" +" -------\n" +" bytes\n\n" +" Raises\n" +" ------\n" +" Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]\n" +" There was an error requesting from the URL.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:133 +#, docstring +msgid "A mixin that adds Discord emoji functionality to another source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:152 +#, docstring +msgid "A base source that fetches emojis from https://emojicdn.elk.sh/." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:176 +#, docstring +msgid "A source that uses Twitter-style emojis. These are also the ones used in Discord." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:182 +#, docstring +msgid "A source that uses Apple emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:188 +#, docstring +msgid "A source that uses Google emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:194 +#, docstring +msgid "A source that uses Microsoft emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:200 +#, docstring +msgid "A source that uses Samsung emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:206 +#, docstring +msgid "A source that uses WhatsApp emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:212 +#, docstring +msgid "A source that uses Facebook emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:218 +#, docstring +msgid "A source that uses Facebook Messenger's emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:224 +#, docstring +msgid "A source that uses JoyPixels' emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:230 +#, docstring +msgid "A source that uses Openmoji emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:236 +#, docstring +msgid "A source that uses Emojidex emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:242 +#, docstring +msgid "A source that uses Mozilla's emojis." +msgstr "" + diff --git a/levelup/generator/pilmojisrc/locales/ko-KR.po b/levelup/generator/pilmojisrc/locales/ko-KR.po new file mode 100644 index 0000000..cf2e146 --- /dev/null +++ b/levelup/generator/pilmojisrc/locales/ko-KR.po @@ -0,0 +1,315 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Korean\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ko\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/pilmojisrc/locales/messages.pot\n" +"X-Crowdin-File-ID: 168\n" +"Language: ko_KR\n" + +#: levelup\generator\pilmojisrc\core.py:33 +#, docstring +msgid "The main emoji rendering interface.\n\n" +" .. note::\n" +" This should be used in a context manager.\n\n" +" Parameters\n" +" ----------\n" +" image: :class:`PIL.Image.Image`\n" +" The Pillow image to render on.\n" +" source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]\n" +" The emoji image source to use.\n" +" This defaults to :class:`~.TwitterEmojiSource`.\n" +" cache: bool\n" +" Whether or not to cache emojis given from source.\n" +" Enabling this is recommended and by default.\n" +" draw: :class:`PIL.ImageDraw.ImageDraw`\n" +" The drawing instance to use. If left unfilled,\n" +" a new drawing instance will be created.\n" +" render_discord_emoji: bool\n" +" Whether or not to render Discord emoji. Defaults to `True`\n" +" emoji_scale_factor: float\n" +" The default rescaling factor for emojis. Defaults to `1`\n" +" emoji_position_offset: Tuple[int, int]\n" +" A 2-tuple representing the x and y offset for emojis when rendering,\n" +" respectively. Defaults to `(0, 0)`\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:99 +#, docstring +msgid "Re-opens this renderer if it has been closed.\n" +" This should rarely be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer is already open.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:119 +#, docstring +msgid "Safely closes this renderer.\n\n" +" .. note::\n" +" If you are using a context manager, this should not be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer has already been closed.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:192 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scalee_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:233 +#, docstring +msgid "Draws the string at the given position, with emoji rendering support.\n" +" This method supports multiline text.\n\n" +" .. note::\n" +" Some parameters have not been implemented yet.\n\n" +" .. note::\n" +" The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.\n\n" +" .. note::\n" +" Not all parameters are listed here.\n\n" +" Parameters\n" +" ----------\n" +" xy: Tuple[int, int]\n" +" The position to render the text at.\n" +" text: str\n" +" The text to render.\n" +" fill\n" +" The fill color of the text.\n" +" font\n" +" The font to render the text with.\n" +" spacing: int\n" +" How many pixels there should be between lines. Defaults to `4`\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis. This can be used for fine adjustments.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" emoji_position_offset: Tuple[int, int]\n" +" The emoji position offset for emojis. This can be used for fine adjustments.\n" +" Defaults to the offset given in the class constructor, or `(0, 0)`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:33 +#, docstring +msgid "|enum|\n\n" +" Represents the type of a :class:`~.Node`.\n\n" +" Attributes\n" +" ----------\n" +" text\n" +" This node is a raw text node.\n" +" emoji\n" +" This node is a unicode emoji.\n" +" discord_emoji\n" +" This node is a Discord emoji.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:53 +#, docstring +msgid "Represents a parsed node inside of a string.\n\n" +" Attributes\n" +" ----------\n" +" type: :class:`~.NodeType`\n" +" The type of this node.\n" +" content: str\n" +" The contents of this node.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:92 +#, docstring +msgid "Parses a string of text into :class:`~.Node`s.\n\n" +" This method will return a nested list, each element of the list\n" +" being a list of :class:`~.Node`s and representing a line in the string.\n\n" +" The string ``'Hello\n" +"world'`` would return something similar to\n" +" ``[[Node('Hello')], [Node('world')]]``.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to parse into nodes.\n\n" +" Returns\n" +" -------\n" +" List[List[:class:`~.Node`]]\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:113 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:41 +#, docstring +msgid "The base class for an emoji image source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:45 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given emoji.\n\n" +" Parameters\n" +" ----------\n" +" emoji: str\n" +" The emoji to retrieve.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:63 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji.\n\n" +" Parameters\n" +" ----------\n" +" id: int\n" +" The snowflake ID of the Discord emoji.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:84 +#, docstring +msgid "Represents an HTTP-based source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:95 +#, docstring +msgid "Makes a GET request to the given URL.\n\n" +" If the `requests` library is installed, it will be used.\n" +" If it is not installed, :meth:`urllib.request.urlopen` will be used instead.\n\n" +" Parameters\n" +" ----------\n" +" url: str\n" +" The URL to request from.\n\n" +" Returns\n" +" -------\n" +" bytes\n\n" +" Raises\n" +" ------\n" +" Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]\n" +" There was an error requesting from the URL.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:133 +#, docstring +msgid "A mixin that adds Discord emoji functionality to another source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:152 +#, docstring +msgid "A base source that fetches emojis from https://emojicdn.elk.sh/." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:176 +#, docstring +msgid "A source that uses Twitter-style emojis. These are also the ones used in Discord." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:182 +#, docstring +msgid "A source that uses Apple emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:188 +#, docstring +msgid "A source that uses Google emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:194 +#, docstring +msgid "A source that uses Microsoft emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:200 +#, docstring +msgid "A source that uses Samsung emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:206 +#, docstring +msgid "A source that uses WhatsApp emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:212 +#, docstring +msgid "A source that uses Facebook emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:218 +#, docstring +msgid "A source that uses Facebook Messenger's emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:224 +#, docstring +msgid "A source that uses JoyPixels' emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:230 +#, docstring +msgid "A source that uses Openmoji emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:236 +#, docstring +msgid "A source that uses Emojidex emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:242 +#, docstring +msgid "A source that uses Mozilla's emojis." +msgstr "" + diff --git a/levelup/generator/pilmojisrc/locales/messages.pot b/levelup/generator/pilmojisrc/locales/messages.pot new file mode 100644 index 0000000..53212ba --- /dev/null +++ b/levelup/generator/pilmojisrc/locales/messages.pot @@ -0,0 +1,348 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" + +#: levelup\generator\pilmojisrc\core.py:33 +#, docstring +msgid "" +"The main emoji rendering interface.\n" +"\n" +" .. note::\n" +" This should be used in a context manager.\n" +"\n" +" Parameters\n" +" ----------\n" +" image: :class:`PIL.Image.Image`\n" +" The Pillow image to render on.\n" +" source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]\n" +" The emoji image source to use.\n" +" This defaults to :class:`~.TwitterEmojiSource`.\n" +" cache: bool\n" +" Whether or not to cache emojis given from source.\n" +" Enabling this is recommended and by default.\n" +" draw: :class:`PIL.ImageDraw.ImageDraw`\n" +" The drawing instance to use. If left unfilled,\n" +" a new drawing instance will be created.\n" +" render_discord_emoji: bool\n" +" Whether or not to render Discord emoji. Defaults to `True`\n" +" emoji_scale_factor: float\n" +" The default rescaling factor for emojis. Defaults to `1`\n" +" emoji_position_offset: Tuple[int, int]\n" +" A 2-tuple representing the x and y offset for emojis when rendering,\n" +" respectively. Defaults to `(0, 0)`\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:99 +#, docstring +msgid "" +"Re-opens this renderer if it has been closed.\n" +" This should rarely be called.\n" +"\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer is already open.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:119 +#, docstring +msgid "" +"Safely closes this renderer.\n" +"\n" +" .. note::\n" +" If you are using a context manager, this should not be called.\n" +"\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer has already been closed.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:192 +#, docstring +msgid "" +"Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n" +"\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scalee_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:233 +#, docstring +msgid "" +"Draws the string at the given position, with emoji rendering support.\n" +" This method supports multiline text.\n" +"\n" +" .. note::\n" +" Some parameters have not been implemented yet.\n" +"\n" +" .. note::\n" +" The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.\n" +"\n" +" .. note::\n" +" Not all parameters are listed here.\n" +"\n" +" Parameters\n" +" ----------\n" +" xy: Tuple[int, int]\n" +" The position to render the text at.\n" +" text: str\n" +" The text to render.\n" +" fill\n" +" The fill color of the text.\n" +" font\n" +" The font to render the text with.\n" +" spacing: int\n" +" How many pixels there should be between lines. Defaults to `4`\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis. This can be used for fine adjustments.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" emoji_position_offset: Tuple[int, int]\n" +" The emoji position offset for emojis. This can be used for fine adjustments.\n" +" Defaults to the offset given in the class constructor, or `(0, 0)`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:33 +#, docstring +msgid "" +"|enum|\n" +"\n" +" Represents the type of a :class:`~.Node`.\n" +"\n" +" Attributes\n" +" ----------\n" +" text\n" +" This node is a raw text node.\n" +" emoji\n" +" This node is a unicode emoji.\n" +" discord_emoji\n" +" This node is a Discord emoji.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:53 +#, docstring +msgid "" +"Represents a parsed node inside of a string.\n" +"\n" +" Attributes\n" +" ----------\n" +" type: :class:`~.NodeType`\n" +" The type of this node.\n" +" content: str\n" +" The contents of this node.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:92 +#, docstring +msgid "" +"Parses a string of text into :class:`~.Node`s.\n" +"\n" +" This method will return a nested list, each element of the list\n" +" being a list of :class:`~.Node`s and representing a line in the string.\n" +"\n" +" The string ``'Hello\n" +"world'`` would return something similar to\n" +" ``[[Node('Hello')], [Node('world')]]``.\n" +"\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to parse into nodes.\n" +"\n" +" Returns\n" +" -------\n" +" List[List[:class:`~.Node`]]\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:113 +#, docstring +msgid "" +"Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n" +"\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:41 +#, docstring +msgid "The base class for an emoji image source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:45 +#, docstring +msgid "" +"Retrieves a :class:`io.BytesIO` stream for the image of the given emoji.\n" +"\n" +" Parameters\n" +" ----------\n" +" emoji: str\n" +" The emoji to retrieve.\n" +"\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:63 +#, docstring +msgid "" +"Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji.\n" +"\n" +" Parameters\n" +" ----------\n" +" id: int\n" +" The snowflake ID of the Discord emoji.\n" +"\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:84 +#, docstring +msgid "Represents an HTTP-based source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:95 +#, docstring +msgid "" +"Makes a GET request to the given URL.\n" +"\n" +" If the `requests` library is installed, it will be used.\n" +" If it is not installed, :meth:`urllib.request.urlopen` will be used instead.\n" +"\n" +" Parameters\n" +" ----------\n" +" url: str\n" +" The URL to request from.\n" +"\n" +" Returns\n" +" -------\n" +" bytes\n" +"\n" +" Raises\n" +" ------\n" +" Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]\n" +" There was an error requesting from the URL.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:133 +#, docstring +msgid "A mixin that adds Discord emoji functionality to another source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:152 +#, docstring +msgid "A base source that fetches emojis from https://emojicdn.elk.sh/." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:176 +#, docstring +msgid "" +"A source that uses Twitter-style emojis. These are also the ones used in " +"Discord." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:182 +#, docstring +msgid "A source that uses Apple emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:188 +#, docstring +msgid "A source that uses Google emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:194 +#, docstring +msgid "A source that uses Microsoft emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:200 +#, docstring +msgid "A source that uses Samsung emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:206 +#, docstring +msgid "A source that uses WhatsApp emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:212 +#, docstring +msgid "A source that uses Facebook emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:218 +#, docstring +msgid "A source that uses Facebook Messenger's emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:224 +#, docstring +msgid "A source that uses JoyPixels' emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:230 +#, docstring +msgid "A source that uses Openmoji emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:236 +#, docstring +msgid "A source that uses Emojidex emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:242 +#, docstring +msgid "A source that uses Mozilla's emojis." +msgstr "" diff --git a/levelup/generator/pilmojisrc/locales/pt-PT.po b/levelup/generator/pilmojisrc/locales/pt-PT.po new file mode 100644 index 0000000..8908d10 --- /dev/null +++ b/levelup/generator/pilmojisrc/locales/pt-PT.po @@ -0,0 +1,315 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: pt-PT\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/pilmojisrc/locales/messages.pot\n" +"X-Crowdin-File-ID: 168\n" +"Language: pt_PT\n" + +#: levelup\generator\pilmojisrc\core.py:33 +#, docstring +msgid "The main emoji rendering interface.\n\n" +" .. note::\n" +" This should be used in a context manager.\n\n" +" Parameters\n" +" ----------\n" +" image: :class:`PIL.Image.Image`\n" +" The Pillow image to render on.\n" +" source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]\n" +" The emoji image source to use.\n" +" This defaults to :class:`~.TwitterEmojiSource`.\n" +" cache: bool\n" +" Whether or not to cache emojis given from source.\n" +" Enabling this is recommended and by default.\n" +" draw: :class:`PIL.ImageDraw.ImageDraw`\n" +" The drawing instance to use. If left unfilled,\n" +" a new drawing instance will be created.\n" +" render_discord_emoji: bool\n" +" Whether or not to render Discord emoji. Defaults to `True`\n" +" emoji_scale_factor: float\n" +" The default rescaling factor for emojis. Defaults to `1`\n" +" emoji_position_offset: Tuple[int, int]\n" +" A 2-tuple representing the x and y offset for emojis when rendering,\n" +" respectively. Defaults to `(0, 0)`\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:99 +#, docstring +msgid "Re-opens this renderer if it has been closed.\n" +" This should rarely be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer is already open.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:119 +#, docstring +msgid "Safely closes this renderer.\n\n" +" .. note::\n" +" If you are using a context manager, this should not be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer has already been closed.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:192 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scalee_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:233 +#, docstring +msgid "Draws the string at the given position, with emoji rendering support.\n" +" This method supports multiline text.\n\n" +" .. note::\n" +" Some parameters have not been implemented yet.\n\n" +" .. note::\n" +" The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.\n\n" +" .. note::\n" +" Not all parameters are listed here.\n\n" +" Parameters\n" +" ----------\n" +" xy: Tuple[int, int]\n" +" The position to render the text at.\n" +" text: str\n" +" The text to render.\n" +" fill\n" +" The fill color of the text.\n" +" font\n" +" The font to render the text with.\n" +" spacing: int\n" +" How many pixels there should be between lines. Defaults to `4`\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis. This can be used for fine adjustments.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" emoji_position_offset: Tuple[int, int]\n" +" The emoji position offset for emojis. This can be used for fine adjustments.\n" +" Defaults to the offset given in the class constructor, or `(0, 0)`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:33 +#, docstring +msgid "|enum|\n\n" +" Represents the type of a :class:`~.Node`.\n\n" +" Attributes\n" +" ----------\n" +" text\n" +" This node is a raw text node.\n" +" emoji\n" +" This node is a unicode emoji.\n" +" discord_emoji\n" +" This node is a Discord emoji.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:53 +#, docstring +msgid "Represents a parsed node inside of a string.\n\n" +" Attributes\n" +" ----------\n" +" type: :class:`~.NodeType`\n" +" The type of this node.\n" +" content: str\n" +" The contents of this node.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:92 +#, docstring +msgid "Parses a string of text into :class:`~.Node`s.\n\n" +" This method will return a nested list, each element of the list\n" +" being a list of :class:`~.Node`s and representing a line in the string.\n\n" +" The string ``'Hello\n" +"world'`` would return something similar to\n" +" ``[[Node('Hello')], [Node('world')]]``.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to parse into nodes.\n\n" +" Returns\n" +" -------\n" +" List[List[:class:`~.Node`]]\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:113 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:41 +#, docstring +msgid "The base class for an emoji image source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:45 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given emoji.\n\n" +" Parameters\n" +" ----------\n" +" emoji: str\n" +" The emoji to retrieve.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:63 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji.\n\n" +" Parameters\n" +" ----------\n" +" id: int\n" +" The snowflake ID of the Discord emoji.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:84 +#, docstring +msgid "Represents an HTTP-based source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:95 +#, docstring +msgid "Makes a GET request to the given URL.\n\n" +" If the `requests` library is installed, it will be used.\n" +" If it is not installed, :meth:`urllib.request.urlopen` will be used instead.\n\n" +" Parameters\n" +" ----------\n" +" url: str\n" +" The URL to request from.\n\n" +" Returns\n" +" -------\n" +" bytes\n\n" +" Raises\n" +" ------\n" +" Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]\n" +" There was an error requesting from the URL.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:133 +#, docstring +msgid "A mixin that adds Discord emoji functionality to another source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:152 +#, docstring +msgid "A base source that fetches emojis from https://emojicdn.elk.sh/." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:176 +#, docstring +msgid "A source that uses Twitter-style emojis. These are also the ones used in Discord." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:182 +#, docstring +msgid "A source that uses Apple emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:188 +#, docstring +msgid "A source that uses Google emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:194 +#, docstring +msgid "A source that uses Microsoft emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:200 +#, docstring +msgid "A source that uses Samsung emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:206 +#, docstring +msgid "A source that uses WhatsApp emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:212 +#, docstring +msgid "A source that uses Facebook emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:218 +#, docstring +msgid "A source that uses Facebook Messenger's emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:224 +#, docstring +msgid "A source that uses JoyPixels' emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:230 +#, docstring +msgid "A source that uses Openmoji emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:236 +#, docstring +msgid "A source that uses Emojidex emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:242 +#, docstring +msgid "A source that uses Mozilla's emojis." +msgstr "" + diff --git a/levelup/generator/pilmojisrc/locales/ru-RU.po b/levelup/generator/pilmojisrc/locales/ru-RU.po new file mode 100644 index 0000000..6343738 --- /dev/null +++ b/levelup/generator/pilmojisrc/locales/ru-RU.po @@ -0,0 +1,315 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/pilmojisrc/locales/messages.pot\n" +"X-Crowdin-File-ID: 168\n" +"Language: ru_RU\n" + +#: levelup\generator\pilmojisrc\core.py:33 +#, docstring +msgid "The main emoji rendering interface.\n\n" +" .. note::\n" +" This should be used in a context manager.\n\n" +" Parameters\n" +" ----------\n" +" image: :class:`PIL.Image.Image`\n" +" The Pillow image to render on.\n" +" source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]\n" +" The emoji image source to use.\n" +" This defaults to :class:`~.TwitterEmojiSource`.\n" +" cache: bool\n" +" Whether or not to cache emojis given from source.\n" +" Enabling this is recommended and by default.\n" +" draw: :class:`PIL.ImageDraw.ImageDraw`\n" +" The drawing instance to use. If left unfilled,\n" +" a new drawing instance will be created.\n" +" render_discord_emoji: bool\n" +" Whether or not to render Discord emoji. Defaults to `True`\n" +" emoji_scale_factor: float\n" +" The default rescaling factor for emojis. Defaults to `1`\n" +" emoji_position_offset: Tuple[int, int]\n" +" A 2-tuple representing the x and y offset for emojis when rendering,\n" +" respectively. Defaults to `(0, 0)`\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:99 +#, docstring +msgid "Re-opens this renderer if it has been closed.\n" +" This should rarely be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer is already open.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:119 +#, docstring +msgid "Safely closes this renderer.\n\n" +" .. note::\n" +" If you are using a context manager, this should not be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer has already been closed.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:192 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scalee_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:233 +#, docstring +msgid "Draws the string at the given position, with emoji rendering support.\n" +" This method supports multiline text.\n\n" +" .. note::\n" +" Some parameters have not been implemented yet.\n\n" +" .. note::\n" +" The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.\n\n" +" .. note::\n" +" Not all parameters are listed here.\n\n" +" Parameters\n" +" ----------\n" +" xy: Tuple[int, int]\n" +" The position to render the text at.\n" +" text: str\n" +" The text to render.\n" +" fill\n" +" The fill color of the text.\n" +" font\n" +" The font to render the text with.\n" +" spacing: int\n" +" How many pixels there should be between lines. Defaults to `4`\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis. This can be used for fine adjustments.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" emoji_position_offset: Tuple[int, int]\n" +" The emoji position offset for emojis. This can be used for fine adjustments.\n" +" Defaults to the offset given in the class constructor, or `(0, 0)`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:33 +#, docstring +msgid "|enum|\n\n" +" Represents the type of a :class:`~.Node`.\n\n" +" Attributes\n" +" ----------\n" +" text\n" +" This node is a raw text node.\n" +" emoji\n" +" This node is a unicode emoji.\n" +" discord_emoji\n" +" This node is a Discord emoji.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:53 +#, docstring +msgid "Represents a parsed node inside of a string.\n\n" +" Attributes\n" +" ----------\n" +" type: :class:`~.NodeType`\n" +" The type of this node.\n" +" content: str\n" +" The contents of this node.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:92 +#, docstring +msgid "Parses a string of text into :class:`~.Node`s.\n\n" +" This method will return a nested list, each element of the list\n" +" being a list of :class:`~.Node`s and representing a line in the string.\n\n" +" The string ``'Hello\n" +"world'`` would return something similar to\n" +" ``[[Node('Hello')], [Node('world')]]``.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to parse into nodes.\n\n" +" Returns\n" +" -------\n" +" List[List[:class:`~.Node`]]\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:113 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:41 +#, docstring +msgid "The base class for an emoji image source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:45 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given emoji.\n\n" +" Parameters\n" +" ----------\n" +" emoji: str\n" +" The emoji to retrieve.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:63 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji.\n\n" +" Parameters\n" +" ----------\n" +" id: int\n" +" The snowflake ID of the Discord emoji.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:84 +#, docstring +msgid "Represents an HTTP-based source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:95 +#, docstring +msgid "Makes a GET request to the given URL.\n\n" +" If the `requests` library is installed, it will be used.\n" +" If it is not installed, :meth:`urllib.request.urlopen` will be used instead.\n\n" +" Parameters\n" +" ----------\n" +" url: str\n" +" The URL to request from.\n\n" +" Returns\n" +" -------\n" +" bytes\n\n" +" Raises\n" +" ------\n" +" Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]\n" +" There was an error requesting from the URL.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:133 +#, docstring +msgid "A mixin that adds Discord emoji functionality to another source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:152 +#, docstring +msgid "A base source that fetches emojis from https://emojicdn.elk.sh/." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:176 +#, docstring +msgid "A source that uses Twitter-style emojis. These are also the ones used in Discord." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:182 +#, docstring +msgid "A source that uses Apple emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:188 +#, docstring +msgid "A source that uses Google emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:194 +#, docstring +msgid "A source that uses Microsoft emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:200 +#, docstring +msgid "A source that uses Samsung emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:206 +#, docstring +msgid "A source that uses WhatsApp emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:212 +#, docstring +msgid "A source that uses Facebook emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:218 +#, docstring +msgid "A source that uses Facebook Messenger's emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:224 +#, docstring +msgid "A source that uses JoyPixels' emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:230 +#, docstring +msgid "A source that uses Openmoji emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:236 +#, docstring +msgid "A source that uses Emojidex emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:242 +#, docstring +msgid "A source that uses Mozilla's emojis." +msgstr "" + diff --git a/levelup/generator/pilmojisrc/locales/tr-TR.po b/levelup/generator/pilmojisrc/locales/tr-TR.po new file mode 100644 index 0000000..eb34902 --- /dev/null +++ b/levelup/generator/pilmojisrc/locales/tr-TR.po @@ -0,0 +1,315 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: tr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/pilmojisrc/locales/messages.pot\n" +"X-Crowdin-File-ID: 168\n" +"Language: tr_TR\n" + +#: levelup\generator\pilmojisrc\core.py:33 +#, docstring +msgid "The main emoji rendering interface.\n\n" +" .. note::\n" +" This should be used in a context manager.\n\n" +" Parameters\n" +" ----------\n" +" image: :class:`PIL.Image.Image`\n" +" The Pillow image to render on.\n" +" source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]\n" +" The emoji image source to use.\n" +" This defaults to :class:`~.TwitterEmojiSource`.\n" +" cache: bool\n" +" Whether or not to cache emojis given from source.\n" +" Enabling this is recommended and by default.\n" +" draw: :class:`PIL.ImageDraw.ImageDraw`\n" +" The drawing instance to use. If left unfilled,\n" +" a new drawing instance will be created.\n" +" render_discord_emoji: bool\n" +" Whether or not to render Discord emoji. Defaults to `True`\n" +" emoji_scale_factor: float\n" +" The default rescaling factor for emojis. Defaults to `1`\n" +" emoji_position_offset: Tuple[int, int]\n" +" A 2-tuple representing the x and y offset for emojis when rendering,\n" +" respectively. Defaults to `(0, 0)`\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:99 +#, docstring +msgid "Re-opens this renderer if it has been closed.\n" +" This should rarely be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer is already open.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:119 +#, docstring +msgid "Safely closes this renderer.\n\n" +" .. note::\n" +" If you are using a context manager, this should not be called.\n\n" +" Raises\n" +" ------\n" +" ValueError\n" +" The renderer has already been closed.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:192 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scalee_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\core.py:233 +#, docstring +msgid "Draws the string at the given position, with emoji rendering support.\n" +" This method supports multiline text.\n\n" +" .. note::\n" +" Some parameters have not been implemented yet.\n\n" +" .. note::\n" +" The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.\n\n" +" .. note::\n" +" Not all parameters are listed here.\n\n" +" Parameters\n" +" ----------\n" +" xy: Tuple[int, int]\n" +" The position to render the text at.\n" +" text: str\n" +" The text to render.\n" +" fill\n" +" The fill color of the text.\n" +" font\n" +" The font to render the text with.\n" +" spacing: int\n" +" How many pixels there should be between lines. Defaults to `4`\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis. This can be used for fine adjustments.\n" +" Defaults to the factor given in the class constructor, or `1`.\n" +" emoji_position_offset: Tuple[int, int]\n" +" The emoji position offset for emojis. This can be used for fine adjustments.\n" +" Defaults to the offset given in the class constructor, or `(0, 0)`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:33 +#, docstring +msgid "|enum|\n\n" +" Represents the type of a :class:`~.Node`.\n\n" +" Attributes\n" +" ----------\n" +" text\n" +" This node is a raw text node.\n" +" emoji\n" +" This node is a unicode emoji.\n" +" discord_emoji\n" +" This node is a Discord emoji.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:53 +#, docstring +msgid "Represents a parsed node inside of a string.\n\n" +" Attributes\n" +" ----------\n" +" type: :class:`~.NodeType`\n" +" The type of this node.\n" +" content: str\n" +" The contents of this node.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:92 +#, docstring +msgid "Parses a string of text into :class:`~.Node`s.\n\n" +" This method will return a nested list, each element of the list\n" +" being a list of :class:`~.Node`s and representing a line in the string.\n\n" +" The string ``'Hello\n" +"world'`` would return something similar to\n" +" ``[[Node('Hello')], [Node('world')]]``.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to parse into nodes.\n\n" +" Returns\n" +" -------\n" +" List[List[:class:`~.Node`]]\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\helpers.py:113 +#, docstring +msgid "Return the width and height of the text when rendered.\n" +" This method supports multiline text.\n\n" +" Parameters\n" +" ----------\n" +" text: str\n" +" The text to use.\n" +" font\n" +" The font of the text.\n" +" spacing: int\n" +" The spacing between lines, in pixels.\n" +" Defaults to `4`.\n" +" emoji_scale_factor: float\n" +" The rescaling factor for emojis.\n" +" Defaults to `1`.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:41 +#, docstring +msgid "The base class for an emoji image source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:45 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given emoji.\n\n" +" Parameters\n" +" ----------\n" +" emoji: str\n" +" The emoji to retrieve.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:63 +#, docstring +msgid "Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji.\n\n" +" Parameters\n" +" ----------\n" +" id: int\n" +" The snowflake ID of the Discord emoji.\n\n" +" Returns\n" +" -------\n" +" :class:`io.BytesIO`\n" +" A bytes stream of the emoji.\n" +" None\n" +" An image for the emoji could not be found.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:84 +#, docstring +msgid "Represents an HTTP-based source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:95 +#, docstring +msgid "Makes a GET request to the given URL.\n\n" +" If the `requests` library is installed, it will be used.\n" +" If it is not installed, :meth:`urllib.request.urlopen` will be used instead.\n\n" +" Parameters\n" +" ----------\n" +" url: str\n" +" The URL to request from.\n\n" +" Returns\n" +" -------\n" +" bytes\n\n" +" Raises\n" +" ------\n" +" Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]\n" +" There was an error requesting from the URL.\n" +" " +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:133 +#, docstring +msgid "A mixin that adds Discord emoji functionality to another source." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:152 +#, docstring +msgid "A base source that fetches emojis from https://emojicdn.elk.sh/." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:176 +#, docstring +msgid "A source that uses Twitter-style emojis. These are also the ones used in Discord." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:182 +#, docstring +msgid "A source that uses Apple emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:188 +#, docstring +msgid "A source that uses Google emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:194 +#, docstring +msgid "A source that uses Microsoft emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:200 +#, docstring +msgid "A source that uses Samsung emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:206 +#, docstring +msgid "A source that uses WhatsApp emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:212 +#, docstring +msgid "A source that uses Facebook emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:218 +#, docstring +msgid "A source that uses Facebook Messenger's emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:224 +#, docstring +msgid "A source that uses JoyPixels' emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:230 +#, docstring +msgid "A source that uses Openmoji emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:236 +#, docstring +msgid "A source that uses Emojidex emojis." +msgstr "" + +#: levelup\generator\pilmojisrc\source.py:242 +#, docstring +msgid "A source that uses Mozilla's emojis." +msgstr "" + diff --git a/levelup/generator/pilmojisrc/source.py b/levelup/generator/pilmojisrc/source.py new file mode 100644 index 0000000..ede301c --- /dev/null +++ b/levelup/generator/pilmojisrc/source.py @@ -0,0 +1,250 @@ +from abc import ABC, abstractmethod +from io import BytesIO +from typing import Any, ClassVar, Dict, Optional +from urllib.error import HTTPError +from urllib.parse import quote_plus +from urllib.request import Request, urlopen + +try: + import requests + + _has_requests = True +except ImportError: + requests = None + _has_requests = False + +__all__ = ( + "BaseSource", + "HTTPBasedSource", + "DiscordEmojiSourceMixin", + "EmojiCDNSource", + "TwitterEmojiSource", + "AppleEmojiSource", + "GoogleEmojiSource", + "MicrosoftEmojiSource", + "FacebookEmojiSource", + "MessengerEmojiSource", + "EmojidexEmojiSource", + "JoyPixelsEmojiSource", + "SamsungEmojiSource", + "WhatsAppEmojiSource", + "MozillaEmojiSource", + "OpenmojiEmojiSource", + "TwemojiEmojiSource", + "FacebookMessengerEmojiSource", + "Twemoji", + "Openmoji", +) + + +class BaseSource(ABC): + """The base class for an emoji image source.""" + + @abstractmethod + def get_emoji(self, emoji: str, /) -> Optional[BytesIO]: + """Retrieves a :class:`io.BytesIO` stream for the image of the given emoji. + + Parameters + ---------- + emoji: str + The emoji to retrieve. + + Returns + ------- + :class:`io.BytesIO` + A bytes stream of the emoji. + None + An image for the emoji could not be found. + """ + raise NotImplementedError + + @abstractmethod + def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]: + """Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji. + + Parameters + ---------- + id: int + The snowflake ID of the Discord emoji. + + Returns + ------- + :class:`io.BytesIO` + A bytes stream of the emoji. + None + An image for the emoji could not be found. + """ + raise NotImplementedError + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}>" + + +class HTTPBasedSource(BaseSource): + """Represents an HTTP-based source.""" + + REQUEST_KWARGS: ClassVar[Dict[str, Any]] = { + "headers": {"User-Agent": "Mozilla/5.0"} + } + + def __init__(self) -> None: + if _has_requests: + self._requests_session = requests.Session() + + def request(self, url: str) -> bytes: + """Makes a GET request to the given URL. + + If the `requests` library is installed, it will be used. + If it is not installed, :meth:`urllib.request.urlopen` will be used instead. + + Parameters + ---------- + url: str + The URL to request from. + + Returns + ------- + bytes + + Raises + ------ + Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`] + There was an error requesting from the URL. + """ + if _has_requests: + with self._requests_session.get(url, **self.REQUEST_KWARGS) as response: + if response.ok: + return response.content + else: + req = Request(url, **self.REQUEST_KWARGS) + with urlopen(req) as response: + return response.read() + + @abstractmethod + def get_emoji(self, emoji: str, /) -> Optional[BytesIO]: + raise NotImplementedError + + @abstractmethod + def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]: + raise NotImplementedError + + +class DiscordEmojiSourceMixin(HTTPBasedSource): + """A mixin that adds Discord emoji functionality to another source.""" + + BASE_DISCORD_EMOJI_URL: ClassVar[str] = "https://cdn.discordapp.com/emojis/" + + @abstractmethod + def get_emoji(self, emoji: str, /) -> Optional[BytesIO]: + raise NotImplementedError + + def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]: + url = self.BASE_DISCORD_EMOJI_URL + str(id) + ".png" + _to_catch = HTTPError if not _has_requests else requests.HTTPError + + try: + return BytesIO(self.request(url)) + except _to_catch: + pass + + +class EmojiCDNSource(DiscordEmojiSourceMixin): + """A base source that fetches emojis from https://emojicdn.elk.sh/.""" + + BASE_EMOJI_CDN_URL: ClassVar[str] = "https://emojicdn.elk.sh/" + STYLE: ClassVar[str] = None + + def get_emoji(self, emoji: str, /) -> Optional[BytesIO]: + if self.STYLE is None: + raise TypeError("STYLE class variable unfilled.") + + url = ( + self.BASE_EMOJI_CDN_URL + + quote_plus(emoji) + + "?style=" + + quote_plus(self.STYLE) + ) + _to_catch = HTTPError if not _has_requests else requests.HTTPError + + try: + return BytesIO(self.request(url)) + except _to_catch: + pass + + +class TwitterEmojiSource(EmojiCDNSource): + """A source that uses Twitter-style emojis. These are also the ones used in Discord.""" + + STYLE = "twitter" + + +class AppleEmojiSource(EmojiCDNSource): + """A source that uses Apple emojis.""" + + STYLE = "apple" + + +class GoogleEmojiSource(EmojiCDNSource): + """A source that uses Google emojis.""" + + STYLE = "google" + + +class MicrosoftEmojiSource(EmojiCDNSource): + """A source that uses Microsoft emojis.""" + + STYLE = "microsoft" + + +class SamsungEmojiSource(EmojiCDNSource): + """A source that uses Samsung emojis.""" + + STYLE = "samsung" + + +class WhatsAppEmojiSource(EmojiCDNSource): + """A source that uses WhatsApp emojis.""" + + STYLE = "whatsapp" + + +class FacebookEmojiSource(EmojiCDNSource): + """A source that uses Facebook emojis.""" + + STYLE = "facebook" + + +class MessengerEmojiSource(EmojiCDNSource): + """A source that uses Facebook Messenger's emojis.""" + + STYLE = "messenger" + + +class JoyPixelsEmojiSource(EmojiCDNSource): + """A source that uses JoyPixels' emojis.""" + + STYLE = "joypixels" + + +class OpenmojiEmojiSource(EmojiCDNSource): + """A source that uses Openmoji emojis.""" + + STYLE = "openmoji" + + +class EmojidexEmojiSource(EmojiCDNSource): + """A source that uses Emojidex emojis.""" + + STYLE = "emojidex" + + +class MozillaEmojiSource(EmojiCDNSource): + """A source that uses Mozilla's emojis.""" + + STYLE = "mozilla" + + +# Aliases +Openmoji = OpenmojiEmojiSource +FacebookMessengerEmojiSource = MessengerEmojiSource +TwemojiEmojiSource = Twemoji = TwitterEmojiSource diff --git a/levelup/generator/requirements.txt b/levelup/generator/requirements.txt new file mode 100644 index 0000000..78a39fc --- /dev/null +++ b/levelup/generator/requirements.txt @@ -0,0 +1,10 @@ +colorgram.py +emoji +fastapi +Pillow +psutil +python-decouple +python-dotenv +Red-DiscordBot +requests +uvicorn diff --git a/levelup/generator/styles/__init__.py b/levelup/generator/styles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/levelup/generator/styles/default.py b/levelup/generator/styles/default.py new file mode 100644 index 0000000..e38f163 --- /dev/null +++ b/levelup/generator/styles/default.py @@ -0,0 +1,684 @@ +import importlib.util +import logging +import math +import sys +import typing as t +from io import BytesIO +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont, ImageSequence, UnidentifiedImageError +from redbot.core.i18n import Translator +from redbot.core.utils.chat_formatting import humanize_number + +try: + # Loaded from cog + from .. import imgtools + from ..pilmojisrc.core import Pilmoji +except ImportError: + # Running in vscode "Run Python File in Terminal" + # Add parent directory to sys.path to enable imports + parent_dir = Path(__file__).parent.parent + sys.path.insert(0, str(parent_dir)) + + # Import imgtools directly + imgtools_path = parent_dir / "imgtools.py" + if imgtools_path.exists(): + spec = importlib.util.spec_from_file_location("imgtools", imgtools_path) + imgtools = importlib.util.module_from_spec(spec) + sys.modules["imgtools"] = imgtools + spec.loader.exec_module(imgtools) + else: + raise ImportError(f"Could not find imgtools at {imgtools_path}") + + # Set up pilmojisrc as a package + pilmoji_dir = parent_dir / "pilmojisrc" + if not pilmoji_dir.exists(): + raise ImportError(f"Could not find pilmojisrc directory at {pilmoji_dir}") + + # Create and register the pilmojisrc package + pilmojisrc_init = pilmoji_dir / "__init__.py" + if pilmojisrc_init.exists(): + spec = importlib.util.spec_from_file_location("pilmojisrc", pilmojisrc_init) + pilmojisrc = importlib.util.module_from_spec(spec) + sys.modules["pilmojisrc"] = pilmojisrc + spec.loader.exec_module(pilmojisrc) + else: + # Create an empty module if __init__.py doesn't exist + pilmojisrc = type(sys)("pilmojisrc") + sys.modules["pilmojisrc"] = pilmojisrc + + # Import helpers module first (since core depends on it) + helpers_path = pilmoji_dir / "helpers.py" + if helpers_path.exists(): + spec = importlib.util.spec_from_file_location("pilmojisrc.helpers", helpers_path) + helpers = importlib.util.module_from_spec(spec) + pilmojisrc.helpers = helpers + sys.modules["pilmojisrc.helpers"] = helpers + spec.loader.exec_module(helpers) + else: + raise ImportError(f"Could not find helpers module at {helpers_path}") + + # Now import core module + core_path = pilmoji_dir / "core.py" + if core_path.exists(): + spec = importlib.util.spec_from_file_location("pilmojisrc.core", core_path) + core = importlib.util.module_from_spec(spec) + pilmojisrc.core = core + sys.modules["pilmojisrc.core"] = core + spec.loader.exec_module(core) + Pilmoji = core.Pilmoji + else: + raise ImportError(f"Could not find core module at {core_path}") + + +log = logging.getLogger("red.vrt.levelup.generator.styles.default") +_ = Translator("LevelUp", __file__) + + +def generate_default_profile( + background_bytes: t.Optional[t.Union[bytes, str]] = None, + avatar_bytes: t.Optional[t.Union[bytes, str]] = None, + username: str = "Spartan117", + status: str = "online", + level: int = 3, + messages: int = 420, + voicetime: int = 3600, + stars: int = 69, + prestige: int = 0, + prestige_emoji: t.Optional[t.Union[bytes, str]] = None, + balance: int = 0, + currency_name: str = "Credits", + previous_xp: int = 100, + current_xp: int = 125, + next_xp: int = 200, + position: int = 3, + role_icon: t.Optional[t.Union[bytes, str]] = None, + blur: bool = False, + base_color: t.Tuple[int, int, int] = (255, 255, 255), + user_color: t.Optional[t.Tuple[int, int, int]] = None, + stat_color: t.Optional[t.Tuple[int, int, int]] = None, + level_bar_color: t.Optional[t.Tuple[int, int, int]] = None, + font_path: t.Optional[t.Union[str, Path]] = None, + render_gif: bool = False, + debug: bool = False, + reraise: bool = False, + square: bool = False, + **kwargs, +) -> t.Tuple[bytes, bool]: + """ + Generate a full profile image with customizable parameters. + If the avatar is animated and not the background, the avatar will be rendered as a gif. + If the background is animated and not the avatar, the background will be rendered as a gif. + If both are animated, the avatar will be rendered as a gif and the background will be rendered as a static image. + To optimize performance, the profile will be generated in 3 layers, the background, the avatar, and the stats. + The stats layer will be generated as a separate image and then pasted onto the background. + + Args: + background (t.Optional[bytes], optional): The background image as bytes. Defaults to None. + avatar (t.Optional[bytes], optional): The avatar image as bytes. Defaults to None. + username (t.Optional[str], optional): The username. Defaults to "Spartan117". + status (t.Optional[str], optional): The status. Defaults to "online". + level (t.Optional[int], optional): The level. Defaults to 1. + messages (t.Optional[int], optional): The number of messages. Defaults to 0. + voicetime (t.Optional[int], optional): The voicetime. Defaults to 3600. + stars (t.Optional[int], optional): The number of stars. Defaults to 0. + prestige (t.Optional[int], optional): The prestige level. Defaults to 0. + prestige_emoji (t.Optional[bytes], optional): The prestige emoji as bytes. Defaults to None. + balance (t.Optional[int], optional): The balance. Defaults to 0. + currency_name (t.Optional[str], optional): The name of the currency. Defaults to "Credits". + previous_xp (t.Optional[int], optional): The previous XP. Defaults to 0. + current_xp (t.Optional[int], optional): The current XP. Defaults to 0. + next_xp (t.Optional[int], optional): The next XP. Defaults to 0. + position (t.Optional[int], optional): The position. Defaults to 0. + role_icon (t.Optional[bytes, str], optional): The role icon as bytes or url. Defaults to None. + blur (t.Optional[bool], optional): Whether to blur the box behind the stats. Defaults to False. + user_color (t.Optional[t.Tuple[int, int, int]], optional): The color for the user. Defaults to None. + base_color (t.Optional[t.Tuple[int, int, int]], optional): The base color. Defaults to None. + stat_color (t.Optional[t.Tuple[int, int, int]], optional): The color for the stats. Defaults to None. + level_bar_color (t.Optional[t.Tuple[int, int, int]], optional): The color for the level bar. Defaults to None. + font_path (t.Optional[t.Union[str, Path], optional): The path to the font file. Defaults to None. + render_gif (t.Optional[bool], optional): Whether to render as gif if profile or background is one. Defaults to False. + debug (t.Optional[bool], optional): Whether to raise any errors rather than suppressing. Defaults to False. + reraise (t.Optional[bool], optional): Whether to raise any errors rather than suppressing. Defaults to False. + square (t.Optional[bool], optional): Whether to render the profile as a square. Defaults to False. + **kwargs: Additional keyword arguments. + + Returns: + t.Tuple[bytes, bool]: The generated full profile image as bytes, and whether the image is animated. + """ + user_color = user_color or base_color + stat_color = stat_color or base_color + level_bar_color = level_bar_color or base_color + + if isinstance(background_bytes, str) and background_bytes.startswith("http"): + log.debug("Background image is a URL, attempting to download") + background_bytes = imgtools.download_image(background_bytes) + + if isinstance(avatar_bytes, str) and avatar_bytes.startswith("http"): + log.debug("Avatar image is a URL, attempting to download") + avatar_bytes = imgtools.download_image(avatar_bytes) + + if isinstance(prestige_emoji, str) and prestige_emoji.startswith("http"): + log.debug("Prestige emoji is a URL, attempting to download") + prestige_emoji = imgtools.download_image(prestige_emoji) + + if isinstance(role_icon, str) and role_icon.startswith("http"): + log.debug("Role icon is a URL, attempting to download") + role_icon_bytes = imgtools.download_image(role_icon) + else: + role_icon_bytes = role_icon + + if background_bytes: + try: + card = Image.open(BytesIO(background_bytes)) + except UnidentifiedImageError as e: + if reraise: + raise e + log.error( + f"Failed to open background image ({type(background_bytes)} - {len(background_bytes)})", exc_info=e + ) + card = imgtools.get_random_background() + else: + card = imgtools.get_random_background() + if avatar_bytes: + pfp = Image.open(BytesIO(avatar_bytes)) + else: + pfp = imgtools.DEFAULT_PFP + + pfp_animated = getattr(pfp, "is_animated", False) + bg_animated = getattr(card, "is_animated", False) + log.debug(f"PFP animated: {pfp_animated}, BG animated: {bg_animated}") + + # Setup + default_fill = (0, 0, 0) # Default fill color for text + stroke_width = 2 # Width of the stroke around text + + if square: + desired_card_size = (450, 450) + # aspect_ratio = imgtools.calc_aspect_ratio(*desired_card_size) + name_y = 35 # Upper bound of username placement + stats_y = 160 # Upper bound of stats texts + blur_edge = 450 # Left bound of blur edge + bar_width = 550 # Length of level bar + bar_height = 40 # Height of level bar + bar_start = 475 # Left bound of level bar + bar_top = 380 # Top bound of level bar + stat_bottom = bar_top - 10 # Bottom bound of all stats + stat_start = bar_start + 10 # Left bound of all stats + stat_split = stat_start + 210 # Split between left and right stats + stat_end = 990 # Right bound of all stats + stat_offset = 45 # Offset between stats + circle_x = 60 # Left bound of profile circle + circle_y = 60 # Top bound of profile circle + star_text_x = 910 # Left bound of star text + star_text_y = 35 # Top bound of star text + star_icon_x = 850 # Left bound of star icon + star_icon_y = 35 # Top bound of star icon + else: + # Ensure the card is the correct size and aspect ratio + desired_card_size = (1050, 450) + # aspect_ratio = imgtools.calc_aspect_ratio(*desired_card_size) + name_y = 35 # Upper bound of username placement + stats_y = 160 # Upper bound of stats texts + blur_edge = 450 # Left bound of blur edge + bar_width = 550 # Length of level bar + bar_height = 40 # Height of level bar + bar_start = 475 # Left bound of level bar + bar_top = 380 # Top bound of level bar + stat_bottom = bar_top - 10 # Bottom bound of all stats + stat_start = bar_start + 10 # Left bound of all stats + stat_split = stat_start + 210 # Split between left and right stats + stat_end = 990 # Right bound of all stats + stat_offset = 45 # Offset between stats + circle_x = 60 # Left bound of profile circle + circle_y = 60 # Top bound of profile circle + star_text_x = 910 # Left bound of star text + star_text_y = 35 # Top bound of star text + star_icon_x = 850 # Left bound of star icon + star_icon_y = 35 # Top bound of star icon + + # Establish layer for all text and accents + stats = Image.new("RGBA", desired_card_size, (0, 0, 0, 0)) + + # Setup progress bar + progress = (current_xp - previous_xp) / (next_xp - previous_xp) + level_bar = imgtools.make_progress_bar( + bar_width, + bar_height, + progress, + level_bar_color, + ) + stats.paste(level_bar, (bar_start, bar_top), level_bar) + + # Establish font + font_path = font_path or imgtools.DEFAULT_FONT + if isinstance(font_path, str): + font_path = Path(font_path) + if not font_path.exists(): + # Hosted api on another server? Check if we have it + if (imgtools.DEFAULT_FONTS / font_path.name).exists(): + font_path = imgtools.DEFAULT_FONTS / font_path.name + else: + font_path = imgtools.DEFAULT_FONT + # Convert back to string + font_path = str(font_path) + + draw = ImageDraw.Draw(stats) + # ---------------- Username text ---------------- + fontsize = 60 + font = ImageFont.truetype(font_path, fontsize) + with Pilmoji(stats) as pilmoji: + # Ensure text doesnt pass star_icon_x + while pilmoji.getsize(username, font)[0] + stat_start > star_icon_x - 10: + fontsize -= 1 + font = ImageFont.truetype(font_path, fontsize) + pilmoji.text( + xy=(stat_start, name_y), + text=username, + fill=user_color, + stroke_width=stroke_width, + stroke_fill=default_fill, + font=font, + ) + # ---------------- Prestige text ---------------- + if prestige: + text = _("(Prestige {})").format(f"{humanize_number(prestige)}") + fontsize = 40 + font = ImageFont.truetype(font_path, fontsize) + # Ensure text doesnt pass stat_end + while font.getlength(text) + stat_start > stat_end: + fontsize -= 1 + font = ImageFont.truetype(font_path, fontsize) + draw.text( + xy=(stat_start, name_y + 70), + text=text, + fill=stat_color, + stroke_width=stroke_width, + stroke_fill=default_fill, + font=font, + ) + if prestige_emoji: + prestige_icon = Image.open(BytesIO(prestige_emoji)).resize((50, 50), Image.Resampling.LANCZOS) + if prestige_icon.mode != "RGBA": + prestige_icon = prestige_icon.convert("RGBA") + placement = (round(stat_start + font.getlength(text) + 10), name_y + 65) + stats.paste(prestige_icon, placement, prestige_icon) + # ---------------- Stars text ---------------- + text = humanize_number(stars) + fontsize = 60 + font = ImageFont.truetype(font_path, fontsize) + # Ensure text doesnt pass stat_end + while font.getlength(text) + star_text_x > stat_end: + fontsize -= 1 + font = ImageFont.truetype(font_path, fontsize) + draw.text( + xy=(star_text_x, star_text_y), + text=text, + fill=stat_color, + stroke_width=stroke_width, + stroke_fill=default_fill, + font=font, + ) + stats.paste(imgtools.STAR, (star_icon_x, star_icon_y), imgtools.STAR) + # ---------------- Rank text ---------------- + text = _("Rank: {}").format(f"#{humanize_number(position)}") + fontsize = 40 + font = ImageFont.truetype(font_path, fontsize) + # Ensure text doesnt pass stat_split point + while font.getlength(text) + stat_start > stat_split - 5: + fontsize -= 1 + font = ImageFont.truetype(font_path, fontsize) + draw.text( + xy=(stat_start, stats_y), + text=text, + fill=stat_color, + stroke_width=stroke_width, + stroke_fill=default_fill, + font=font, + ) + # ---------------- Level text ---------------- + text = _("Level: {}").format(humanize_number(level)) + fontsize = 40 + font = ImageFont.truetype(font_path, fontsize) + # Ensure text doesnt pass the stat_split point + while font.getlength(text) + stat_start > stat_split - 5: + fontsize -= 1 + font = ImageFont.truetype(font_path, fontsize) + draw.text( + xy=(stat_start, stats_y + stat_offset), + text=text, + fill=stat_color, + stroke_width=stroke_width, + stroke_fill=default_fill, + font=font, + ) + # ---------------- Messages text ---------------- + text = _("Messages: {}").format(humanize_number(messages)) + fontsize = 40 + font = ImageFont.truetype(font_path, fontsize) + # Ensure text doesnt pass the stat_end + while font.getlength(text) + stat_split > stat_end: + fontsize -= 1 + font = ImageFont.truetype(font_path, fontsize) + draw.text( + xy=(stat_split, stats_y), + text=text, + fill=stat_color, + stroke_width=stroke_width, + stroke_fill=default_fill, + font=font, + ) + # ---------------- Voice text ---------------- + text = _("Voice: {}").format(imgtools.abbreviate_time(voicetime)) + fontsize = 40 + font = ImageFont.truetype(font_path, fontsize) + # Ensure text doesnt pass the stat_end + while font.getlength(text) + stat_split > stat_end: + fontsize -= 1 + font = ImageFont.truetype(font_path, fontsize) + draw.text( + xy=(stat_split, stats_y + stat_offset), + text=text, + fill=stat_color, + stroke_width=stroke_width, + stroke_fill=default_fill, + font=font, + ) + # ---------------- Balance text ---------------- + if balance: + text = _("Balance: {}").format(f"{humanize_number(balance)} {currency_name}") + font = ImageFont.truetype(font_path, 40) + with Pilmoji(stats) as pilmoji: + # Ensure text doesnt pass the stat_end + while pilmoji.getsize(text, font)[0] + stat_start > stat_end: + fontsize -= 1 + font = ImageFont.truetype(font_path, fontsize) + placement = (stat_start, stat_bottom - stat_offset * 2) + pilmoji.text( + xy=placement, + text=text, + fill=stat_color, + stroke_width=stroke_width, + stroke_fill=default_fill, + font=font, + ) + # ---------------- Experience text ---------------- + current = current_xp - previous_xp + goal = next_xp - previous_xp + text = _("Exp: {} ({} total)").format( + f"{humanize_number(current)}/{humanize_number(goal)}", humanize_number(current_xp) + ) + fontsize = 40 + font = ImageFont.truetype(font_path, fontsize) + # Ensure text doesnt pass the stat_end + while font.getlength(text) + stat_start > stat_end: + fontsize -= 1 + font = ImageFont.truetype(font_path, fontsize) + draw.text( + xy=(stat_start, stat_bottom - stat_offset), + text=text, + fill=stat_color, + stroke_width=stroke_width, + stroke_fill=default_fill, + font=font, + ) + # ---------------- Profile Accents ---------------- + # Draw a circle outline around where the avatar is + # Calculate the circle outline's placement around the avatar + circle = imgtools.make_circle_outline(thickness=5, color=user_color) + outline_size = (380, 380) + circle = circle.resize(outline_size, Image.Resampling.LANCZOS) + placement = (circle_x - 25, circle_y - 25) + stats.paste(circle, placement, circle) + # Place status icon + status_icon = imgtools.STATUS[status].resize((75, 75), Image.Resampling.LANCZOS) + stats.paste(status_icon, (circle_x + 260, circle_y + 260), status_icon) + # Paste role icon on top left of profile circle + if role_icon_bytes: + try: + role_icon_img = Image.open(BytesIO(role_icon_bytes)).resize((70, 70), Image.Resampling.LANCZOS) + stats.paste(role_icon_img, (10, 10), role_icon_img) + except ValueError as e: + if reraise: + raise e + err = ( + f"Failed to paste role icon image for {username}" + if isinstance(role_icon, bytes) + else f"Failed to paste role icon image for {username}: {role_icon}" + ) + log.error(err, exc_info=e) + + # ---------------- Start finalizing the image ---------------- + # Resize the profile image + desired_pfp_size = (330, 330) + if not render_gif or (not pfp_animated and not bg_animated): + if card.mode != "RGBA": + log.debug(f"Converting card mode '{card.mode}' to RGBA") + card = card.convert("RGBA") + if pfp.mode != "RGBA": + log.debug(f"Converting pfp mode '{pfp.mode}' to RGBA") + pfp = pfp.convert("RGBA") + card = imgtools.fit_aspect_ratio(card, desired_card_size) + if blur: + blur_section = imgtools.blur_section(card, (blur_edge, 0, card.width, card.height)) + # Paste onto the stats + card.paste(blur_section, (blur_edge, 0), blur_section) + card = imgtools.round_image_corners(card, 45) + pfp = pfp.resize(desired_pfp_size, Image.Resampling.LANCZOS) + # Crop the profile image into a circle + pfp = imgtools.make_profile_circle(pfp) + # Paste the items onto the card + card.paste(stats, (0, 0), stats) + card.paste(pfp, (circle_x, circle_y), pfp) + if debug: + card.show() + buffer = BytesIO() + card.save(buffer, format="WEBP") + card.close() + return buffer.getvalue(), False + + if pfp_animated and not bg_animated: + if card.mode != "RGBA": + log.debug(f"Converting card mode '{card.mode}' to RGBA") + card = card.convert("RGBA") + card = imgtools.fit_aspect_ratio(card, desired_card_size) + if blur: + blur_section = imgtools.blur_section(card, (blur_edge, 0, card.width, card.height)) + # Paste onto the stats + card.paste(blur_section, (blur_edge, 0), blur_section) + + card.paste(stats, (0, 0), stats) + + avg_duration = imgtools.get_avg_duration(pfp) + log.debug(f"Rendering pfp as gif with avg duration of {avg_duration}ms") + frames: t.List[Image.Image] = [] + for frame in range(pfp.n_frames): + pfp.seek(frame) + # Prepare copies of the card, stats, and pfp + card_frame = card.copy() + pfp_frame = pfp.copy() + if pfp_frame.mode != "RGBA": + pfp_frame = pfp_frame.convert("RGBA") + # Resize the profile image for each frame + pfp_frame = pfp_frame.resize(desired_pfp_size, Image.Resampling.NEAREST) + # Crop the profile image into a circle + pfp_frame = imgtools.make_profile_circle(pfp_frame, method=Image.Resampling.NEAREST) + # Paste the profile image onto the card + card_frame.paste(pfp_frame, (circle_x, circle_y), pfp_frame) + frames.append(card_frame) + + buffer = BytesIO() + frames[0].save( + buffer, + format="GIF", + save_all=True, + append_images=frames[1:], + duration=avg_duration, + loop=0, + quality=75, + optimize=True, + ) + buffer.seek(0) + if debug: + Image.open(buffer).show() + return buffer.getvalue(), True + elif bg_animated and not pfp_animated: + avg_duration = imgtools.get_avg_duration(card) + log.debug(f"Rendering card as gif with avg duration of {avg_duration}ms") + frames: t.List[Image.Image] = [] + + if pfp.mode != "RGBA": + log.debug(f"Converting pfp mode '{pfp.mode}' to RGBA") + pfp = pfp.convert("RGBA") + pfp = pfp.resize(desired_pfp_size, Image.Resampling.LANCZOS) + # Crop the profile image into a circle + pfp = imgtools.make_profile_circle(pfp) + for frame in range(card.n_frames): + card.seek(frame) + # Prepare copies of the card and stats + card_frame = card.copy() + card_frame = imgtools.fit_aspect_ratio(card_frame, desired_card_size) + if card_frame.mode != "RGBA": + card_frame = card_frame.convert("RGBA") + + # Paste items onto the card + if blur: + blur_section = imgtools.blur_section(card_frame, (blur_edge, 0, card_frame.width, card_frame.height)) + card_frame.paste(blur_section, (blur_edge, 0), blur_section) + + card_frame.paste(pfp, (circle_x, circle_y), pfp) + card_frame.paste(stats, (0, 0), stats) + + frames.append(card_frame) + + buffer = BytesIO() + frames[0].save( + buffer, + format="GIF", + save_all=True, + append_images=frames[1:], + duration=avg_duration, + loop=0, + quality=75, + optimize=True, + ) + buffer.seek(0) + if debug: + Image.open(buffer).show() + return buffer.getvalue(), True + + # If we're here, both the avatar and background are gifs + # Figure out how to merge the two frame counts and durations together + # Calculate frame durations based on the LCM + pfp_duration = imgtools.get_avg_duration(pfp) # example: 50ms + card_duration = imgtools.get_avg_duration(card) # example: 100ms + log.debug(f"PFP duration: {pfp_duration}ms, Card duration: {card_duration}ms") + # Figure out how to round the durations + # Favor the card's duration time over the pfp + # Round both durations to the nearest X ms based on what will get the closest to the LCM + pfp_duration = round(card_duration, -1) # Round to the nearest 10ms + card_duration = round(card_duration, -1) # Round to the nearest 10ms + + log.debug(f"Modified PFP duration: {pfp_duration}ms, Card duration: {card_duration}ms") + combined_duration = math.lcm(pfp_duration, card_duration) # example: 100ms would be the LCM of 50 and 100 + log.debug(f"Combined duration: {combined_duration}ms") + # The combined duration should be no more than 20% offset from the image with the highest duration + max_duration = max(pfp_duration, card_duration) + if combined_duration > max_duration * 1.2: + log.debug(f"Combined duration is more than 20% offset from the max duration ({max_duration}ms)") + combined_duration = max_duration + + total_pfp_duration = pfp.n_frames * pfp_duration # example: 2250ms + total_card_duration = card.n_frames * card_duration # example: 3300ms + # Total duration for the combined animation cycle (LCM of 2250 and 3300) + total_duration = math.lcm(total_pfp_duration, total_card_duration) # example: 9900ms + num_combined_frames = total_duration // combined_duration + + # The maximum frame count should be no more than 20% offset from the image with the highest frame count to avoid filesize bloat + max_frame_count = max(pfp.n_frames, card.n_frames) * 1.2 + max_frame_count = min(round(max_frame_count), num_combined_frames) + log.debug(f"Max frame count: {max_frame_count}") + # Create a list to store the combined frames + combined_frames = [] + for frame_num in range(max_frame_count): + time = frame_num * combined_duration + + # Calculate the frame index for both the card and pfp + card_frame_index = (time // card_duration) % card.n_frames + pfp_frame_index = (time // pfp_duration) % pfp.n_frames + + # Get the frames for the card and pfp + card_frame = ImageSequence.Iterator(card)[card_frame_index] + pfp_frame = ImageSequence.Iterator(pfp)[pfp_frame_index] + + card_frame = imgtools.fit_aspect_ratio(card_frame, desired_card_size) + if card_frame.mode != "RGBA": + card_frame = card_frame.convert("RGBA") + + if blur: + blur_section = imgtools.blur_section(card_frame, (blur_edge, 0, card_frame.width, card_frame.height)) + # Paste onto the stats + card_frame.paste(blur_section, (blur_edge, 0), blur_section) + if pfp_frame.mode != "RGBA": + pfp_frame = pfp_frame.convert("RGBA") + + pfp_frame = pfp_frame.resize(desired_pfp_size, Image.Resampling.NEAREST) + pfp_frame = imgtools.make_profile_circle(pfp_frame, method=Image.Resampling.NEAREST) + + card_frame.paste(pfp_frame, (circle_x, circle_y), pfp_frame) + card_frame.paste(stats, (0, 0), stats) + + combined_frames.append(card_frame) + + buffer = BytesIO() + combined_frames[0].save( + buffer, + format="GIF", + save_all=True, + append_images=combined_frames[1:], + loop=0, + duration=combined_duration, + quality=75, + optimize=True, + ) + buffer.seek(0) + + if debug: + Image.open(buffer).show() + + return buffer.getvalue(), True + + +if __name__ == "__main__": + # Setup console logging + logging.basicConfig(level=logging.DEBUG) + logging.getLogger("PIL").setLevel(logging.INFO) + + test_banner = (imgtools.ASSETS / "tests" / "banner3.gif").read_bytes() + test_avatar = (imgtools.ASSETS / "tests" / "tree.gif").read_bytes() + test_icon = (imgtools.ASSETS / "tests" / "icon.png").read_bytes() + font_path = imgtools.ASSETS / "fonts" / "BebasNeue.ttf" + res, animated = generate_default_profile( + background_bytes=test_banner, + avatar_bytes=test_avatar, + username="Vertyco", + status="online", + level=999, + messages=420, + voicetime=399815, + stars=693333, + prestige=2, + prestige_emoji=test_icon, + balance=1000000, + currency_name="Coinz 💰", + previous_xp=1000, + current_xp=1258, + next_xp=5000, + role_icon=test_icon, + blur=True, + font_path=font_path, + render_gif=True, + debug=True, + ) + result_path = imgtools.ASSETS / "tests" / "result.gif" + result_path.write_bytes(res) diff --git a/levelup/generator/styles/locales/de-DE.po b/levelup/generator/styles/locales/de-DE.po new file mode 100644 index 0000000..2e319b5 --- /dev/null +++ b/levelup/generator/styles/locales/de-DE.po @@ -0,0 +1,47 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/styles/locales/messages.pot\n" +"X-Crowdin-File-ID: 186\n" +"Language: de_DE\n" + +#: levelup\generator\styles\default.py:201 +msgid "(Prestige {})" +msgstr "" + +#: levelup\generator\styles\default.py:240 +msgid "Rank: {}" +msgstr "" + +#: levelup\generator\styles\default.py:256 +msgid "Level: {}" +msgstr "" + +#: levelup\generator\styles\default.py:272 +msgid "Messages: {}" +msgstr "" + +#: levelup\generator\styles\default.py:288 +msgid "Voice: {}" +msgstr "" + +#: levelup\generator\styles\default.py:305 +msgid "Balance: {}" +msgstr "" + +#: levelup\generator\styles\default.py:324 +msgid "Exp: {} ({} total)" +msgstr "" + diff --git a/levelup/generator/styles/locales/es-ES.po b/levelup/generator/styles/locales/es-ES.po new file mode 100644 index 0000000..0313d2d --- /dev/null +++ b/levelup/generator/styles/locales/es-ES.po @@ -0,0 +1,47 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/styles/locales/messages.pot\n" +"X-Crowdin-File-ID: 186\n" +"Language: es_ES\n" + +#: levelup\generator\styles\default.py:201 +msgid "(Prestige {})" +msgstr "(Prestigio {})" + +#: levelup\generator\styles\default.py:240 +msgid "Rank: {}" +msgstr "Rango: {}" + +#: levelup\generator\styles\default.py:256 +msgid "Level: {}" +msgstr "Nivel: {}" + +#: levelup\generator\styles\default.py:272 +msgid "Messages: {}" +msgstr "Mensajes: {}" + +#: levelup\generator\styles\default.py:288 +msgid "Voice: {}" +msgstr "Voz: {}" + +#: levelup\generator\styles\default.py:305 +msgid "Balance: {}" +msgstr "Equilibrio: {}" + +#: levelup\generator\styles\default.py:324 +msgid "Exp: {} ({} total)" +msgstr "Exp: {} ({} total)" + diff --git a/levelup/generator/styles/locales/fr-FR.po b/levelup/generator/styles/locales/fr-FR.po new file mode 100644 index 0000000..2d045af --- /dev/null +++ b/levelup/generator/styles/locales/fr-FR.po @@ -0,0 +1,47 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/styles/locales/messages.pot\n" +"X-Crowdin-File-ID: 186\n" +"Language: fr_FR\n" + +#: levelup\generator\styles\default.py:201 +msgid "(Prestige {})" +msgstr "" + +#: levelup\generator\styles\default.py:240 +msgid "Rank: {}" +msgstr "" + +#: levelup\generator\styles\default.py:256 +msgid "Level: {}" +msgstr "" + +#: levelup\generator\styles\default.py:272 +msgid "Messages: {}" +msgstr "" + +#: levelup\generator\styles\default.py:288 +msgid "Voice: {}" +msgstr "" + +#: levelup\generator\styles\default.py:305 +msgid "Balance: {}" +msgstr "" + +#: levelup\generator\styles\default.py:324 +msgid "Exp: {} ({} total)" +msgstr "" + diff --git a/levelup/generator/styles/locales/hr-HR.po b/levelup/generator/styles/locales/hr-HR.po new file mode 100644 index 0000000..556728d --- /dev/null +++ b/levelup/generator/styles/locales/hr-HR.po @@ -0,0 +1,47 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: hr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/styles/locales/messages.pot\n" +"X-Crowdin-File-ID: 186\n" +"Language: hr_HR\n" + +#: levelup\generator\styles\default.py:201 +msgid "(Prestige {})" +msgstr "" + +#: levelup\generator\styles\default.py:240 +msgid "Rank: {}" +msgstr "" + +#: levelup\generator\styles\default.py:256 +msgid "Level: {}" +msgstr "" + +#: levelup\generator\styles\default.py:272 +msgid "Messages: {}" +msgstr "" + +#: levelup\generator\styles\default.py:288 +msgid "Voice: {}" +msgstr "" + +#: levelup\generator\styles\default.py:305 +msgid "Balance: {}" +msgstr "" + +#: levelup\generator\styles\default.py:324 +msgid "Exp: {} ({} total)" +msgstr "" + diff --git a/levelup/generator/styles/locales/ko-KR.po b/levelup/generator/styles/locales/ko-KR.po new file mode 100644 index 0000000..c4fb591 --- /dev/null +++ b/levelup/generator/styles/locales/ko-KR.po @@ -0,0 +1,47 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Korean\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ko\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/styles/locales/messages.pot\n" +"X-Crowdin-File-ID: 186\n" +"Language: ko_KR\n" + +#: levelup\generator\styles\default.py:201 +msgid "(Prestige {})" +msgstr "" + +#: levelup\generator\styles\default.py:240 +msgid "Rank: {}" +msgstr "" + +#: levelup\generator\styles\default.py:256 +msgid "Level: {}" +msgstr "" + +#: levelup\generator\styles\default.py:272 +msgid "Messages: {}" +msgstr "" + +#: levelup\generator\styles\default.py:288 +msgid "Voice: {}" +msgstr "" + +#: levelup\generator\styles\default.py:305 +msgid "Balance: {}" +msgstr "" + +#: levelup\generator\styles\default.py:324 +msgid "Exp: {} ({} total)" +msgstr "" + diff --git a/levelup/generator/styles/locales/messages.pot b/levelup/generator/styles/locales/messages.pot new file mode 100644 index 0000000..9677f81 --- /dev/null +++ b/levelup/generator/styles/locales/messages.pot @@ -0,0 +1,40 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" + +#: levelup\generator\styles\default.py:201 +msgid "(Prestige {})" +msgstr "" + +#: levelup\generator\styles\default.py:240 +msgid "Rank: {}" +msgstr "" + +#: levelup\generator\styles\default.py:256 +msgid "Level: {}" +msgstr "" + +#: levelup\generator\styles\default.py:272 +msgid "Messages: {}" +msgstr "" + +#: levelup\generator\styles\default.py:288 +msgid "Voice: {}" +msgstr "" + +#: levelup\generator\styles\default.py:305 +msgid "Balance: {}" +msgstr "" + +#: levelup\generator\styles\default.py:324 +msgid "Exp: {} ({} total)" +msgstr "" diff --git a/levelup/generator/styles/locales/pt-PT.po b/levelup/generator/styles/locales/pt-PT.po new file mode 100644 index 0000000..e88706d --- /dev/null +++ b/levelup/generator/styles/locales/pt-PT.po @@ -0,0 +1,47 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: pt-PT\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/styles/locales/messages.pot\n" +"X-Crowdin-File-ID: 186\n" +"Language: pt_PT\n" + +#: levelup\generator\styles\default.py:201 +msgid "(Prestige {})" +msgstr "" + +#: levelup\generator\styles\default.py:240 +msgid "Rank: {}" +msgstr "" + +#: levelup\generator\styles\default.py:256 +msgid "Level: {}" +msgstr "" + +#: levelup\generator\styles\default.py:272 +msgid "Messages: {}" +msgstr "" + +#: levelup\generator\styles\default.py:288 +msgid "Voice: {}" +msgstr "" + +#: levelup\generator\styles\default.py:305 +msgid "Balance: {}" +msgstr "" + +#: levelup\generator\styles\default.py:324 +msgid "Exp: {} ({} total)" +msgstr "" + diff --git a/levelup/generator/styles/locales/ru-RU.po b/levelup/generator/styles/locales/ru-RU.po new file mode 100644 index 0000000..93795fd --- /dev/null +++ b/levelup/generator/styles/locales/ru-RU.po @@ -0,0 +1,47 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/styles/locales/messages.pot\n" +"X-Crowdin-File-ID: 186\n" +"Language: ru_RU\n" + +#: levelup\generator\styles\default.py:201 +msgid "(Prestige {})" +msgstr "" + +#: levelup\generator\styles\default.py:240 +msgid "Rank: {}" +msgstr "" + +#: levelup\generator\styles\default.py:256 +msgid "Level: {}" +msgstr "" + +#: levelup\generator\styles\default.py:272 +msgid "Messages: {}" +msgstr "" + +#: levelup\generator\styles\default.py:288 +msgid "Voice: {}" +msgstr "" + +#: levelup\generator\styles\default.py:305 +msgid "Balance: {}" +msgstr "" + +#: levelup\generator\styles\default.py:324 +msgid "Exp: {} ({} total)" +msgstr "" + diff --git a/levelup/generator/styles/locales/tr-TR.po b/levelup/generator/styles/locales/tr-TR.po new file mode 100644 index 0000000..7fa0f12 --- /dev/null +++ b/levelup/generator/styles/locales/tr-TR.po @@ -0,0 +1,47 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: tr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/styles/locales/messages.pot\n" +"X-Crowdin-File-ID: 186\n" +"Language: tr_TR\n" + +#: levelup\generator\styles\default.py:201 +msgid "(Prestige {})" +msgstr "" + +#: levelup\generator\styles\default.py:240 +msgid "Rank: {}" +msgstr "" + +#: levelup\generator\styles\default.py:256 +msgid "Level: {}" +msgstr "" + +#: levelup\generator\styles\default.py:272 +msgid "Messages: {}" +msgstr "" + +#: levelup\generator\styles\default.py:288 +msgid "Voice: {}" +msgstr "" + +#: levelup\generator\styles\default.py:305 +msgid "Balance: {}" +msgstr "" + +#: levelup\generator\styles\default.py:324 +msgid "Exp: {} ({} total)" +msgstr "" + diff --git a/levelup/generator/styles/runescape.py b/levelup/generator/styles/runescape.py new file mode 100644 index 0000000..1b17a59 --- /dev/null +++ b/levelup/generator/styles/runescape.py @@ -0,0 +1,243 @@ +""" +Generate a full profile image with customizable parameters. + +Args: + avatar_bytes (t.Optional[bytes], optional): The avatar image as bytes. Defaults to None. + status (t.Optional[str], optional): The status. Defaults to "online". + level (t.Optional[int], optional): The level. Defaults to 1. + messages (t.Optional[int], optional): The number of messages. Defaults to 0. + voicetime (t.Optional[int], optional): The voicetime. Defaults to 3600. + prestige (t.Optional[int], optional): The prestige level. Defaults to 0. + prestige_emoji (t.Optional[bytes], optional): The prestige emoji as bytes. Defaults to None. + balance (t.Optional[int], optional): The balance. Defaults to 0. + previous_xp (t.Optional[int], optional): The previous XP. Defaults to 0. + current_xp (t.Optional[int], optional): The current XP. Defaults to 4. + next_xp (t.Optional[int], optional): The next XP. Defaults to 10. + position (t.Optional[int], optional): The position. Defaults to 1. + stat_color (t.Optional[t.Tuple[int, int, int]], optional): The color for the stats. Defaults to (0, 255, 68). + render_gif (t.Optional[bool], optional): Whether to render as gif. Defaults to False. + debug (t.Optional[bool], optional): Whether to show the generated image. Defaults to False. + +Returns: + bytes: The generated full profile image as bytes. +""" + +import logging +import typing as t +from io import BytesIO + +from PIL import Image, ImageDraw, ImageFont + +try: + from .. import imgtools +except ImportError: + import imgtools + +log = logging.getLogger("red.vrt.levelup.generator.styles.default") + + +def generate_runescape_profile( + avatar_bytes: t.Optional[bytes] = None, + status: str = "online", + level: int = 1, + messages: int = 0, + voicetime: int = 3600, + prestige: int = 0, + balance: int = 0, + previous_xp: int = 0, + current_xp: int = 4, + next_xp: int = 10, + position: int = 1, + stat_color: t.Tuple[int, int, int] = (0, 255, 68), # Green + render_gif: bool = False, + debug: bool = False, + **kwargs, +): + if isinstance(avatar_bytes, str) and avatar_bytes.startswith("http"): + log.debug("Avatar image is a URL, attempting to download") + avatar_bytes = imgtools.download_image(avatar_bytes) + + if avatar_bytes: + pfp = Image.open(BytesIO(avatar_bytes)) + else: + pfp = imgtools.DEFAULT_PFP + + pfp_animated = getattr(pfp, "is_animated", False) + log.debug(f"PFP animated: {pfp_animated}") + + profile_size = (219, 192) + # Create blank transparent image at 219 x 192 to put everything on + card = Image.new("RGBA", profile_size, (0, 0, 0, 0)) + # Template also at 219 x 192 + template = imgtools.RS_TEMPLATE_BALANCE.copy() if balance else imgtools.RS_TEMPLATE.copy() + # Place status icon + status_icon = imgtools.STATUS[status].resize((25, 25), Image.Resampling.LANCZOS) + card.paste(status_icon, (197, -2), status_icon) + + draw = ImageDraw.Draw(template) + # Draw stats + font_path = imgtools.ASSETS / "fonts" / "Runescape.ttf" + # Draw balance + if balance: + balance_text = f"{imgtools.abbreviate_number(balance)}" + balance_size = 20 + balance_font = ImageFont.truetype(str(font_path), balance_size) + draw.text( + xy=(44, 23), + text=balance_text, + font=balance_font, + fill=stat_color, + anchor="mm", + ) + # Draw prestige + if prestige: + prestige_text = f"{imgtools.abbreviate_number(prestige)}" + prestige_size = 35 + prestige_font = ImageFont.truetype(str(font_path), prestige_size) + draw.text( + xy=(197, 149), + text=prestige_text, + font=prestige_font, + fill=stat_color, + anchor="mm", + stroke_width=1, + ) + + # Draw level + level_text = f"{imgtools.abbreviate_number(level)}" + level_size = 20 + level_font = ImageFont.truetype(str(font_path), level_size) + draw.text( + xy=(20, 58), + text=level_text, + font=level_font, + fill=stat_color, + align="center", + anchor="mm", + ) + # Draw rank + rank_text = f"#{imgtools.abbreviate_number(position)}" + rank_size = 20 + rank_font = ImageFont.truetype(str(font_path), rank_size) + lb, rb = 2, 32 + while rank_font.getlength(rank_text) > rb - lb: + rank_size -= 1 + rank_font = ImageFont.truetype(str(font_path), rank_size) + draw.text( + xy=(17, 93), + text=rank_text, + font=rank_font, + fill=stat_color, + anchor="mm", + ) + # Draw messages + messages_text = f"{imgtools.abbreviate_number(messages)}" + messages_size = 20 + messages_font = ImageFont.truetype(str(font_path), messages_size) + draw.text( + xy=(27, 127), + text=messages_text, + font=messages_font, + fill=stat_color, + align="center", + anchor="mm", + ) + # Draw voicetime + voicetime_text = f"{imgtools.abbreviate_time(voicetime, short=True)}" + voicetime_size = 20 + voicetime_font = ImageFont.truetype(str(font_path), voicetime_size) + lb, rb = 30, 65 + while voicetime_font.getlength(voicetime_text) > rb - lb: + voicetime_size -= 1 + voicetime_font = ImageFont.truetype(str(font_path), voicetime_size) + draw.text( + xy=(46, 155), + text=voicetime_text, + font=voicetime_font, + fill=stat_color, + align="center", + anchor="mm", + ) + # Draw xp + current = imgtools.abbreviate_number(current_xp - previous_xp) + goal = imgtools.abbreviate_number(next_xp - previous_xp) + percent = round((current_xp - previous_xp) / (next_xp - previous_xp) * 100) + xp_text = f"{current}/{goal} ({percent}%)" + xp_size = 20 + xp_font = ImageFont.truetype(str(font_path), xp_size) + draw.text( + xy=(105, 182), + text=xp_text, + font=xp_font, + fill=stat_color, + align="center", + anchor="mm", + ) + # ---------------- Start finalizing the image ---------------- + if pfp_animated and render_gif: + avg_duration = imgtools.get_avg_duration(pfp) + frames: t.List[Image.Image] = [] + for frame in range(pfp.n_frames): + pfp.seek(frame) + # Prep each frame + card_frame = card.copy() + pfp_frame = pfp.copy() + if pfp_frame.mode != "RGBA": + pfp_frame = pfp_frame.convert("RGBA") + pfp_frame = pfp_frame.resize((145, 145), Image.Resampling.NEAREST) + pfp_frame = imgtools.make_profile_circle(pfp_frame, Image.Resampling.NEAREST) + # Place the pfp + card_frame.paste(pfp_frame, (65, 9), pfp_frame) + # Place the template + card_frame.paste(template, (0, 0), template) + frames.append(card_frame) + + buffer = BytesIO() + frames[0].save( + buffer, + format="GIF", + save_all=True, + append_images=frames[1:], + duration=avg_duration, + loop=0, + ) + buffer.seek(0) + if debug: + Image.open(buffer).show() + return buffer.getvalue(), True + + # Place the pfp + if pfp.mode != "RGBA": + pfp = pfp.convert("RGBA") + pfp = pfp.resize((145, 145), Image.Resampling.LANCZOS) + pfp = imgtools.make_profile_circle(pfp) + card.paste(pfp, (65, 9), pfp) + # Place the template + card.paste(template, (0, 0), template) + if debug: + card.show() + buffer = BytesIO() + card.save(buffer, format="WEBP") + return buffer.getvalue(), False + + +if __name__ == "__main__": + # Setup console logging + logging.basicConfig(level=logging.DEBUG) + test_avatar = (imgtools.ASSETS / "tests" / "tree.gif").read_bytes() + test_icon = (imgtools.ASSETS / "tests" / "icon.png").read_bytes() + font_path = imgtools.ASSETS / "fonts" / "Runescape.ttf" + res, animated = generate_runescape_profile( + avatar_bytes=test_avatar, + prestige=2, + username="vertyco", + level=0, + debug=True, + balance=1000000, + status="dnd", + position=100000, + render_gif=True, + role_icon=test_icon, + ) + result_path = imgtools.ASSETS / "tests" / "result.gif" + result_path.write_bytes(res) diff --git a/levelup/generator/tenor/converter.py b/levelup/generator/tenor/converter.py new file mode 100644 index 0000000..be7454d --- /dev/null +++ b/levelup/generator/tenor/converter.py @@ -0,0 +1,121 @@ +# THANKS TRUSTY! +# This converter/api is a modified snippet of the notsobot cog from TrustyJAID. +# https://github.com/TrustyJAID/Trusty-cogs/blob/b2842005c88451f4670bc25f5c000ce6aed79c8c/notsobot/converter.py +"""MIT License + +Copyright (c) 2017-present TrustyJAID + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.""" + +from __future__ import annotations + +import re +import typing as t +from dataclasses import dataclass + +import aiohttp +from red_commons.logging import getLogger +from redbot.core import commands + +IMAGE_LINKS: t.Pattern = re.compile( + r"(https?:\/\/[^\"\'\s]*\.(?Ppng|jpg|jpeg|gif)" + r"(?P\?(?:ex=(?P\w+)&)(?:is=(?P\w+)&)(?:hm=(?P\w+)&))?)", # Discord CDN info + flags=re.I, +) +TENOR_REGEX: t.Pattern[str] = re.compile(r"https:\/\/tenor\.com\/view\/(?P[a-zA-Z0-9-]+-(?P\d+))") +EMOJI_REGEX: t.Pattern = re.compile(r"(<(?Pa)?:[a-zA-Z0-9\_]+:([0-9]+)>)") +MENTION_REGEX: t.Pattern = re.compile(r"<@!?([0-9]+)>") +ID_REGEX: t.Pattern = re.compile(r"[0-9]{17,}") + +VALID_CONTENT_TYPES = ("image/png", "image/jpeg", "image/jpg", "image/gif") + +log = getLogger("red.trusty-cogs.NotSoBot") + + +class TenorError(Exception): + pass + + +@dataclass +class TenorMedia: + url: str + duration: int + preview: str + dims: t.List[int] + size: int + + @classmethod + def from_json(cls, data: dict) -> TenorMedia: + return cls(**data) + + +@dataclass +class TenorPost: + id: str + title: str + media_formats: t.Dict[str, TenorMedia] + created: float + content_description: str + itemurl: str + url: str + tags: t.List[str] + flags: t.List[str] + hasaudio: bool + + @classmethod + def from_json(cls, data: dict) -> TenorPost: + media = {k: TenorMedia.from_json(v) for k, v in data.pop("media_formats", {}).items()} + return cls(**data, media_formats=media) + + +class TenorAPI: + def __init__(self, token: str, client: str): + self._token = token + self._client = client + self.session = aiohttp.ClientSession(base_url="https://tenor.googleapis.com") + + async def posts(self, ids: t.List[str]): + params = {"key": self._token, "ids": ",".join(i for i in ids), "client_key": self._client} + async with self.session.get("/v2/posts", params=params) as resp: + data = await resp.json() + if "error" in data: + raise TenorError(data) + return [TenorPost.from_json(i) for i in data.get("results", [])] + + +async def sanitize_url(url: str, ctx: commands.Context) -> str: + match = IMAGE_LINKS.match(url) + if match: + return match.group(1) + tenor = TENOR_REGEX.match(url) + if not tenor: + return url + api = ctx.cog.tenor + if not api: + return url + try: + posts = await api.posts([tenor.group("image_id")]) + for post in posts: + if "gif" in post.media_formats: + return post.media_formats["gif"].url + except TenorError as e: + log.error("Error getting tenor image information. %s", e) + except Exception as e: + log.error("Unknown Error getting tenor image information. %s", e) + return url diff --git a/levelup/generator/tenor/locales/de-DE.po b/levelup/generator/tenor/locales/de-DE.po new file mode 100644 index 0000000..2cdd36b --- /dev/null +++ b/levelup/generator/tenor/locales/de-DE.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/tenor/locales/messages.pot\n" +"X-Crowdin-File-ID: 194\n" +"Language: de_DE\n" + diff --git a/levelup/generator/tenor/locales/es-ES.po b/levelup/generator/tenor/locales/es-ES.po new file mode 100644 index 0000000..bf8f1ea --- /dev/null +++ b/levelup/generator/tenor/locales/es-ES.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/tenor/locales/messages.pot\n" +"X-Crowdin-File-ID: 194\n" +"Language: es_ES\n" + diff --git a/levelup/generator/tenor/locales/fr-FR.po b/levelup/generator/tenor/locales/fr-FR.po new file mode 100644 index 0000000..6e54728 --- /dev/null +++ b/levelup/generator/tenor/locales/fr-FR.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/tenor/locales/messages.pot\n" +"X-Crowdin-File-ID: 194\n" +"Language: fr_FR\n" + diff --git a/levelup/generator/tenor/locales/hr-HR.po b/levelup/generator/tenor/locales/hr-HR.po new file mode 100644 index 0000000..2547caa --- /dev/null +++ b/levelup/generator/tenor/locales/hr-HR.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: hr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/tenor/locales/messages.pot\n" +"X-Crowdin-File-ID: 194\n" +"Language: hr_HR\n" + diff --git a/levelup/generator/tenor/locales/ko-KR.po b/levelup/generator/tenor/locales/ko-KR.po new file mode 100644 index 0000000..7d065b1 --- /dev/null +++ b/levelup/generator/tenor/locales/ko-KR.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Korean\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ko\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/tenor/locales/messages.pot\n" +"X-Crowdin-File-ID: 194\n" +"Language: ko_KR\n" + diff --git a/levelup/generator/tenor/locales/messages.pot b/levelup/generator/tenor/locales/messages.pot new file mode 100644 index 0000000..4651114 --- /dev/null +++ b/levelup/generator/tenor/locales/messages.pot @@ -0,0 +1,12 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" diff --git a/levelup/generator/tenor/locales/pt-PT.po b/levelup/generator/tenor/locales/pt-PT.po new file mode 100644 index 0000000..3e1b30c --- /dev/null +++ b/levelup/generator/tenor/locales/pt-PT.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: pt-PT\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/tenor/locales/messages.pot\n" +"X-Crowdin-File-ID: 194\n" +"Language: pt_PT\n" + diff --git a/levelup/generator/tenor/locales/ru-RU.po b/levelup/generator/tenor/locales/ru-RU.po new file mode 100644 index 0000000..5e7d0f0 --- /dev/null +++ b/levelup/generator/tenor/locales/ru-RU.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/tenor/locales/messages.pot\n" +"X-Crowdin-File-ID: 194\n" +"Language: ru_RU\n" + diff --git a/levelup/generator/tenor/locales/tr-TR.po b/levelup/generator/tenor/locales/tr-TR.po new file mode 100644 index 0000000..a56df35 --- /dev/null +++ b/levelup/generator/tenor/locales/tr-TR.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: tr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/generator/tenor/locales/messages.pot\n" +"X-Crowdin-File-ID: 194\n" +"Language: tr_TR\n" + diff --git a/levelup/info.json b/levelup/info.json new file mode 100644 index 0000000..414983a --- /dev/null +++ b/levelup/info.json @@ -0,0 +1,48 @@ +{ + "author": ["Vertyco"], + "description": "Your friendly neighborhood leveling system", + "disabled": false, + "end_user_data_statement": "This cog stores Discord IDs, counts of user messages, and their time spent in voice channels. No private info is stored about users.", + "hidden": false, + "install_msg": "Thank you for installing LevelUp! To enable leveling in this server type `[p]lset toggle`.\n\nDOCUMENTATION: https://github.com/vertyco/vrt-cogs/blob/main/levelup/README.md", + "min_bot_version": "3.5.0", + "min_python_version": [3, 9, 0], + "permissions": [ + "read_messages", + "send_messages", + "manage_roles", + "attach_files", + "embed_links" + ], + "required_cogs": {}, + "requirements": [ + "aiocache", + "aiohttp>=3.9.5", + "colorgram.py", + "emoji", + "fastapi", + "msgpack", + "plotly", + "pydantic", + "python-multipart", + "python-decouple", + "python-dotenv", + "psutil", + "requests", + "tenacity", + "ujson", + "uvicorn" + ], + "short": "Discord leveling system", + "tags": [ + "activity", + "level", + "leveler", + "leveling", + "levelup", + "rank", + "utility", + "vert" + ], + "type": "COG" +} diff --git a/levelup/listeners/__init__.py b/levelup/listeners/__init__.py new file mode 100644 index 0000000..19f1def --- /dev/null +++ b/levelup/listeners/__init__.py @@ -0,0 +1,17 @@ +from ..abc import CompositeMetaClass +from .guild import GuildListener +from .members import MemberListener +from .messages import MessageListener +from .reactions import ReactionListener +from .voice import VoiceListener + + +class Listeners( + GuildListener, + MemberListener, + MessageListener, + ReactionListener, + VoiceListener, + metaclass=CompositeMetaClass, +): + """Subclass all listener classes""" diff --git a/levelup/listeners/guild.py b/levelup/listeners/guild.py new file mode 100644 index 0000000..cc21b23 --- /dev/null +++ b/levelup/listeners/guild.py @@ -0,0 +1,20 @@ +import logging + +import discord +from redbot.core import commands + +from ..abc import MixinMeta + +log = logging.getLogger("red.levelup.listeners.guild") + + +class GuildListener(MixinMeta): + @commands.Cog.listener() + async def on_guild_remove(self, old_guild: discord.Guild): + if not self.db.auto_cleanup: + return + if old_guild.id not in self.db.configs: + return + del self.db.configs[old_guild.id] + log.info(f"Purged config for {old_guild.name} ({old_guild.id})") + self.save() diff --git a/levelup/listeners/locales/de-DE.po b/levelup/listeners/locales/de-DE.po new file mode 100644 index 0000000..80d0b06 --- /dev/null +++ b/levelup/listeners/locales/de-DE.po @@ -0,0 +1,24 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/listeners/locales/messages.pot\n" +"X-Crowdin-File-ID: 170\n" +"Language: de_DE\n" + +#: levelup\listeners\__init__.py:15 +#, docstring +msgid "Subclass all listener classes" +msgstr "" + diff --git a/levelup/listeners/locales/es-ES.po b/levelup/listeners/locales/es-ES.po new file mode 100644 index 0000000..e68bc4e --- /dev/null +++ b/levelup/listeners/locales/es-ES.po @@ -0,0 +1,24 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/listeners/locales/messages.pot\n" +"X-Crowdin-File-ID: 170\n" +"Language: es_ES\n" + +#: levelup\listeners\__init__.py:15 +#, docstring +msgid "Subclass all listener classes" +msgstr "Subclase todas las clases de escuchadores" + diff --git a/levelup/listeners/locales/fr-FR.po b/levelup/listeners/locales/fr-FR.po new file mode 100644 index 0000000..0319b00 --- /dev/null +++ b/levelup/listeners/locales/fr-FR.po @@ -0,0 +1,24 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/listeners/locales/messages.pot\n" +"X-Crowdin-File-ID: 170\n" +"Language: fr_FR\n" + +#: levelup\listeners\__init__.py:15 +#, docstring +msgid "Subclass all listener classes" +msgstr "" + diff --git a/levelup/listeners/locales/hr-HR.po b/levelup/listeners/locales/hr-HR.po new file mode 100644 index 0000000..b9c0082 --- /dev/null +++ b/levelup/listeners/locales/hr-HR.po @@ -0,0 +1,24 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: hr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/listeners/locales/messages.pot\n" +"X-Crowdin-File-ID: 170\n" +"Language: hr_HR\n" + +#: levelup\listeners\__init__.py:15 +#, docstring +msgid "Subclass all listener classes" +msgstr "" + diff --git a/levelup/listeners/locales/ko-KR.po b/levelup/listeners/locales/ko-KR.po new file mode 100644 index 0000000..c8afc64 --- /dev/null +++ b/levelup/listeners/locales/ko-KR.po @@ -0,0 +1,24 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Korean\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ko\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/listeners/locales/messages.pot\n" +"X-Crowdin-File-ID: 170\n" +"Language: ko_KR\n" + +#: levelup\listeners\__init__.py:15 +#, docstring +msgid "Subclass all listener classes" +msgstr "" + diff --git a/levelup/listeners/locales/messages.pot b/levelup/listeners/locales/messages.pot new file mode 100644 index 0000000..cb00eef --- /dev/null +++ b/levelup/listeners/locales/messages.pot @@ -0,0 +1,17 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" + +#: levelup\listeners\__init__.py:15 +#, docstring +msgid "Subclass all listener classes" +msgstr "" diff --git a/levelup/listeners/locales/pt-PT.po b/levelup/listeners/locales/pt-PT.po new file mode 100644 index 0000000..8ad19b0 --- /dev/null +++ b/levelup/listeners/locales/pt-PT.po @@ -0,0 +1,24 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: pt-PT\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/listeners/locales/messages.pot\n" +"X-Crowdin-File-ID: 170\n" +"Language: pt_PT\n" + +#: levelup\listeners\__init__.py:15 +#, docstring +msgid "Subclass all listener classes" +msgstr "" + diff --git a/levelup/listeners/locales/ru-RU.po b/levelup/listeners/locales/ru-RU.po new file mode 100644 index 0000000..5b0db23 --- /dev/null +++ b/levelup/listeners/locales/ru-RU.po @@ -0,0 +1,24 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/listeners/locales/messages.pot\n" +"X-Crowdin-File-ID: 170\n" +"Language: ru_RU\n" + +#: levelup\listeners\__init__.py:15 +#, docstring +msgid "Subclass all listener classes" +msgstr "" + diff --git a/levelup/listeners/locales/tr-TR.po b/levelup/listeners/locales/tr-TR.po new file mode 100644 index 0000000..2ed0f3b --- /dev/null +++ b/levelup/listeners/locales/tr-TR.po @@ -0,0 +1,24 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: tr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/listeners/locales/messages.pot\n" +"X-Crowdin-File-ID: 170\n" +"Language: tr_TR\n" + +#: levelup\listeners\__init__.py:15 +#, docstring +msgid "Subclass all listener classes" +msgstr "" + diff --git a/levelup/listeners/members.py b/levelup/listeners/members.py new file mode 100644 index 0000000..394cc6e --- /dev/null +++ b/levelup/listeners/members.py @@ -0,0 +1,23 @@ +import logging + +import discord +from redbot.core import commands + +from ..abc import MixinMeta + +log = logging.getLogger("red.levelup.listeners.members") + + +class MemberListener(MixinMeta): + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + if member.guild.id not in self.db.configs: + return + conf = self.db.get_conf(member.guild) + if not conf.enabled: + return + added, removed = await self.ensure_roles(member, conf, "Member rejoined") + if added: + log.info(f"Added {len(added)} roles to {member} in {member.guild}") + if removed: + log.info(f"Removed {len(removed)} roles from {member} in {member.guild}") diff --git a/levelup/listeners/messages.py b/levelup/listeners/messages.py new file mode 100644 index 0000000..3be4b7b --- /dev/null +++ b/levelup/listeners/messages.py @@ -0,0 +1,154 @@ +import logging +import random +from time import perf_counter + +import discord +from redbot.core import commands + +from ..abc import MixinMeta + +log = logging.getLogger("red.vrt.levelup.listeners.messages") + + +class MessageListener(MixinMeta): + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + # If message object is None for some reason + if not message: + return + # If message wasn't sent in a guild + if not message.guild: + return + # If message was from a bot + if message.author.bot and self.db.ignore_bots: + return + # Check if guild is in the master ignore list + if str(message.guild.id) in self.db.ignored_guilds: + return + # Ignore webhooks + if not isinstance(message.author, discord.Member): + return + # Check if cog is disabled + if await self.bot.cog_disabled_in_guild(self, message.guild): + return + try: + roles = list(message.author.roles) + role_ids = [role.id for role in roles] + except AttributeError: + # User sent messange and left immediately? + return + conf = self.db.get_conf(message.guild) + if not conf.enabled: + return + + user_id = message.author.id + if user_id in conf.ignoredusers: + # If we're specifically ignoring a user we don't want to see them anywhere + return + + profile = conf.get_profile(user_id).add_message() + weekly = None + if conf.weeklysettings.on: + weekly = conf.get_weekly_profile(message.author).add_message() + + if perf_counter() - self.last_save > 300: + # Save at least every 5 minutes + self.save() + + prefixes = await self.bot.get_valid_prefixes(guild=message.guild) + if not conf.command_xp and message.content.startswith(tuple(prefixes)): + # Don't give XP for commands + return + + if conf.allowedchannels: + # Make sure the channel is allowed + if message.channel.id not in conf.allowedchannels: + # See if its category or parent channel is allowed then + if isinstance(message.channel, (discord.Thread, discord.ForumChannel)): + channel_id = message.channel.parent_id + if channel_id not in conf.allowedchannels: + # Mabe the parent channel's category is allowed? + category_id = message.channel.parent.category_id + if category_id not in conf.allowedchannels: + # Nope, not allowed + return + else: + channel_id = message.channel.category_id + if channel_id and channel_id not in conf.allowedchannels: + return + + if message.channel.id in conf.ignoredchannels: + return + if ( + isinstance( + message.channel, + ( + discord.Thread, + discord.ForumChannel, + ), + ) + and message.channel.parent_id in conf.ignoredchannels + ): + return + elif message.channel.category_id and message.channel.category_id in conf.ignoredchannels: + return + + if conf.allowedroles: + # Make sure the user has at least one allowed role + if not any(role in conf.allowedroles for role in role_ids): + return + + if any(role in conf.ignoredroles for role in role_ids): + return + now = perf_counter() + last_messages = self.lastmsg.setdefault(message.guild.id, {}) + addxp = False + if len(message.content) > conf.min_length: + if user_id not in last_messages: + addxp = True + elif now - last_messages[user_id] > conf.cooldown: + addxp = True + + if not addxp: + return + + self.lastmsg[message.guild.id][user_id] = now + + xp_to_add = random.randint(conf.xp[0], conf.xp[1]) + # Add channel bonus if it exists + channel_bonuses = conf.channelbonus.msg + category = None + if isinstance(message.channel, discord.Thread): + parent = message.channel.parent + if parent: + category = parent.category + else: + category = message.channel.category + cat_id = category.id if category else 0 + + if message.channel.id in channel_bonuses: + xp_to_add += random.randint(*channel_bonuses[message.channel.id]) + elif cat_id in channel_bonuses: + xp_to_add += random.randint(*channel_bonuses[cat_id]) + # Stack all role bonuses + for role_id, (bonus_min, bonus_max) in conf.rolebonus.msg.items(): + if role_id in role_ids: + xp_to_add += random.randint(bonus_min, bonus_max) + # Add the xp to the role groups + for role_id in role_ids: + if role_id in conf.role_groups: + conf.role_groups[role_id] += xp_to_add + # Add the xp to the user's profile + log.debug(f"Adding {xp_to_add} xp to {message.author.name} in {message.guild.name}") + profile.xp += xp_to_add + if weekly: + weekly.xp += xp_to_add + # Check for levelups + await self.check_levelups( + guild=message.guild, + member=message.author, + profile=profile, + conf=conf, + message=message, + channel=message.channel, + ) diff --git a/levelup/listeners/reactions.py b/levelup/listeners/reactions.py new file mode 100644 index 0000000..5f4bc9e --- /dev/null +++ b/levelup/listeners/reactions.py @@ -0,0 +1,72 @@ +import logging +from datetime import datetime, timedelta + +import discord +from redbot.core import commands +from redbot.core.i18n import Translator + +from ..abc import MixinMeta + +log = logging.getLogger("red.vrt.levelup.listeners.reactions") +_ = Translator("LevelUp", __file__) + + +class ReactionListener(MixinMeta): + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): + if not payload: + return + if not payload.guild_id: + return + if not payload.member: + return + if payload.emoji.name != "\N{WHITE MEDIUM STAR}": + return + if not payload.member: + return + if payload.member.bot: + return + guild = self.bot.get_guild(payload.guild_id) + if not guild: + return + if guild.id in self.db.ignored_guilds: + return + channel = guild.get_channel(payload.channel_id) + if not channel: + return + try: + msg = await channel.fetch_message(payload.message_id) + except (discord.NotFound, discord.Forbidden): + return + if not msg: + return + if msg.author.bot: + return + if msg.author.id == payload.member.id: + return + + last_used = self.stars.setdefault(guild.id, {}).get(payload.member.id) + conf = self.db.get_conf(guild) + now = datetime.now() + + if last_used: + can_use_after = last_used + timedelta(seconds=conf.starcooldown) + if now < can_use_after: + return + + self.stars[guild.id][payload.member.id] = now + profile = conf.get_profile(msg.author) + profile.stars += 1 + if conf.weeklysettings.on: + weekly = conf.get_weekly_profile(msg.author) + weekly.stars += 1 + self.save() + txt = _("{} just gave a star to {}!").format( + f"**{payload.member.display_name}**", + f"**{msg.author.display_name}**", + ) + if conf.starmention and channel.permissions_for(guild.me).send_messages: + kwargs = {"content": txt} + if conf.starmentionautodelete: + kwargs["delete_after"] = conf.starmentionautodelete + await channel.send(**kwargs) diff --git a/levelup/listeners/voice.py b/levelup/listeners/voice.py new file mode 100644 index 0000000..27eb127 --- /dev/null +++ b/levelup/listeners/voice.py @@ -0,0 +1,277 @@ +import asyncio +import logging +import random +from copy import copy +from time import perf_counter + +import discord +from redbot.core import commands + +from ..abc import MixinMeta +from ..common.models import GuildSettings, VoiceTracking + +log = logging.getLogger("red.vrt.levelup.listeners.voice") + + +class VoiceListener(MixinMeta): + async def initialize_voice_states(self) -> int: + self.voice_tracking.clear() + + def _init() -> int: + initialized = 0 + perf = perf_counter() + for guild in self.bot.guilds: + if guild.id not in self.db.configs: + continue + conf = self.db.get_conf(guild) + if not conf.enabled: + continue + for member in guild.members: + if member.voice and member.voice.channel: + self.get_init_state(conf, member, perf) + initialized += 1 + return initialized + + return await asyncio.to_thread(_init) + + def get_init_state( + self, + conf: GuildSettings, + member: discord.Member, + perf: float = None, + state: discord.VoiceState = None, + ) -> VoiceTracking: + earning_xp = self.can_gain_exp(conf, member, state or member.voice) + return self.voice_tracking[member.guild.id].setdefault( + member.id, + VoiceTracking( + joined=perf or perf_counter(), + not_gaining_xp=not earning_xp, + not_gaining_xp_time=0.0, + stopped_gaining_xp_at=perf if not earning_xp else None, + ), + ) + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: + # If roles changed and user is in VC, we need to check if they can gain exp + if before.roles != after.roles and (after.voice or before.voice): + await self._on_voice_state_update(after, before.voice, after.voice) + + @commands.Cog.listener() + async def on_presence_update(self, before: discord.Member, after: discord.Member) -> None: + # If user goes offline/online and they're in VC, we need to check if they can gain exp + if before.status != after.status and (after.voice or before.voice): + await self._on_voice_state_update(after, before.voice, after.voice) + + @commands.Cog.listener() + async def on_voice_state_update( + self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState + ): + try: + await self._on_voice_state_update(member, before, after) + except Exception as e: + log.error(f"Error in voice state update event.\nBefore: {before}\nAfter: {after}", exc_info=e) + + async def _on_voice_state_update( + self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState + ): + if member.bot and self.db.ignore_bots: + return + conf = self.db.get_conf(member.guild) + if not conf.enabled: + return + voice = self.voice_tracking[member.guild.id] + if not before.channel and not after.channel: + log.error(f"False voice state update for {member.name} in {member.guild}") + voice.pop(member.id, None) + return + + perf = perf_counter() + + if before.channel == after.channel: + log.debug(f"Voice state changed for {member.name} in {member.guild}") + earning_xp = self.can_gain_exp(conf, member, after) + user_data = self.get_init_state(conf, member, perf, after) + if user_data.not_gaining_xp and earning_xp: + log.debug(f"{member.name} now earning xp in {after.channel.name} in {member.guild}") + # User's state change means they can now earn exp again + user_data.not_gaining_xp = False + user_data.not_gaining_xp_time += perf - user_data.stopped_gaining_xp_at + user_data.stopped_gaining_xp_at = None + elif not user_data.not_gaining_xp and not earning_xp: + log.debug(f"{member.name} no longer earning xp in {after.channel.name} in {member.guild}") + # User's state change means they shouldnt earn exp + user_data.not_gaining_xp = True + user_data.stopped_gaining_xp_at = perf + else: + # No meaningful state change, just return + pass + return + + # Case 1: User joins VC + if not before.channel and after.channel: + log.debug(f"{member.name} joined VC {after.channel.name} in {member.guild}") + self.get_init_state(conf, member, perf, after) + # Go ahead and update other users in the channel + for m in after.channel.members: + if m.id == member.id: + # We just initialized them + continue + user_data = self.get_init_state(conf, m, perf) + earning_xp = self.can_gain_exp(conf, m, m.voice) + if user_data.not_gaining_xp and earning_xp: + log.debug(f"{m.name} now earning xp in {after.channel.name} in {member.guild}") + user_data.not_gaining_xp = False + user_data.not_gaining_xp_time += perf - user_data.stopped_gaining_xp_at + user_data.stopped_gaining_xp_at = None + elif not user_data.not_gaining_xp and not earning_xp: + log.debug(f"{m.name} no longer earning xp in {after.channel.name} in {member.guild}") + user_data.not_gaining_xp = True + user_data.stopped_gaining_xp_at = perf + # No exp needs to be added here so just return + return + + # Case 2: User switches VC + if before.channel and after.channel: + # Treat this as a the user leaving the VC and then joining the new one + # We'll fire off two events, one for leaving and one for joining + # This will also reduce the amount of code that needs to be duplicated + + # Simulate user leaving the VC + mock_after_voice_state = copy(after) + mock_after_voice_state.channel = None + await self._on_voice_state_update(member, before, mock_after_voice_state) + + # Simulate user joining the new VC + mock_before_voice_state = before + mock_before_voice_state.channel = None + await self._on_voice_state_update(member, mock_before_voice_state, after) + + # No exp needs to be added here so just return + return + + # Case 3: If we're here, the user left the VC + log.debug(f"{member.name} left VC {before.channel.name} in {member.guild}") + # First lets add the time to the user, and exp if they were earning it + data = voice.pop(member.id, None) + if not data: + # User wasnt in the voice cache, maybe cog was reloaded while user was in VC? + log.error( + f"User {member.name} left VC but wasnt in voice cache in {member.guild}\nBefore: {before}\nAfter: {after}" + ) + return + # Add whatever time is left that the user wasnt gaining exp to their total time not gaining exp + if data.not_gaining_xp and data.stopped_gaining_xp_at: + data.not_gaining_xp_time += perf - data.stopped_gaining_xp_at + # Calculate the total time the user spent in the VC + total_time_in_voice = perf - data.joined + # Effective time is the total time minus the time they weren't earning exp + effective_time = total_time_in_voice - data.not_gaining_xp_time + profile = conf.get_profile(member) + weekly = conf.get_weekly_profile(member) if conf.weeklysettings.on else None + + log.debug(f"{member.name} spent {round(total_time_in_voice, 2)}s in VC {before.channel.name} in {member.guild}") + if effective_time > 0: + log.debug(f"{round(effective_time, 2)}s of that was effective time") + profile.voice += total_time_in_voice + if weekly: + weekly.voice += total_time_in_voice + + # Calculate the exp to add + xp_to_add = conf.voicexp * (effective_time / 60) + cat_id = getattr(before.channel.category, "id", 0) + if before.channel.id in conf.channelbonus.voice: + xp_to_add += random.randint(*conf.channelbonus.voice[before.channel.id]) * (effective_time / 60) + elif cat_id in conf.channelbonus.voice: + xp_to_add += random.randint(*conf.channelbonus.voice[cat_id]) * (effective_time / 60) + + # Stack all role bonuses + role_ids = [role.id for role in member.roles] + for role_id, (bonus_min, bonus_max) in conf.rolebonus.voice.items(): + if role_id in role_ids: + xp_to_add += random.randint(bonus_min, bonus_max) * (effective_time / 60) + + # Add the exp to the user + if xp_to_add: + log.debug(f"Adding {round(xp_to_add, 2)} Voice XP to {member.name} in {member.guild}") + profile.xp += xp_to_add + if weekly: + weekly.xp += xp_to_add + + # Now we need to update everyone else in the channel in case the exp gaining states have changed + # Get the channel now that the user has left + channel: discord.VoiceChannel = member.guild.get_channel(before.channel.id) + if not channel: + # User left channel because it was deleted? + log.warning(f"User {member.name} left VC {before.channel.name} but channel wasnt found in {member.guild}") + else: + # Update everyone else in the channel + for m in channel.members: + if m.id == member.id: + continue + earning_xp = self.can_gain_exp(conf, m, m.voice) + user_data = self.get_init_state(conf, m, perf) + if user_data.not_gaining_xp and earning_xp: + log.debug(f"{m.name} now earning xp in {channel.name} in {member.guild}") + user_data.not_gaining_xp = False + user_data.not_gaining_xp_time += perf - user_data.stopped_gaining_xp_at + user_data.stopped_gaining_xp_at = None + elif not user_data.not_gaining_xp and not earning_xp: + log.debug(f"{m.name} no longer earning xp in {channel.name} in {member.guild}") + user_data.not_gaining_xp = True + user_data.stopped_gaining_xp_at = perf + + # Save the changes + self.save() + # Check for levelups + await self.check_levelups(member.guild, member, profile, conf, channel=channel) + + def can_gain_exp( + self, + conf: GuildSettings, + member: discord.Member, + voice_state: discord.VoiceState, + ) -> bool: + """Determine whether a user can gain exp in the current voice state + + voice_state may be the before or after voice state, depending on the event + + Args: + conf (GuildSettings): The guild settings + member (discord.Member): The member to check + voice_state (discord.VoiceState): The current state of the user in the VC + + Returns: + bool: Whether the user can gain exp + """ + addxp = True + if conf.ignore_deafened and voice_state.self_deaf: + addxp = False + elif conf.ignore_muted and voice_state.self_mute: + addxp = False + elif conf.ignore_invisible and member.status.name == "offline": + addxp = False + elif any(role.id in conf.ignoredroles for role in member.roles): + addxp = False + elif member.id in conf.ignoredusers: + addxp = False + elif voice_state.channel.id in conf.ignoredchannels: + addxp = False + elif voice_state.channel.category_id and voice_state.channel.category_id in conf.ignoredchannels: + addxp = False + elif ( + conf.ignore_solo and len([i for i in voice_state.channel.members if (not i.bot and i.id != member.id)]) < 1 + ): + addxp = False + elif self.db.ignore_bots and member.bot: + addxp = False + elif conf.allowedchannels: + if voice_state.channel.id not in conf.allowedchannels: + if voice_state.channel.category_id: + if voice_state.channel.category_id not in conf.allowedchannels: + addxp = False + else: + addxp = False + + return addxp diff --git a/levelup/locales/de-DE.po b/levelup/locales/de-DE.po new file mode 100644 index 0000000..c0958e9 --- /dev/null +++ b/levelup/locales/de-DE.po @@ -0,0 +1,49 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/locales/messages.pot\n" +"X-Crowdin-File-ID: 172\n" +"Language: de_DE\n" + +#: levelup\abc.py:17 +#, docstring +msgid "Type detection" +msgstr "" + +#: levelup\abc.py:21 +#, docstring +msgid "Type hinting" +msgstr "" + +#: levelup\main.py:71 +#, docstring +msgid "\n" +" Your friendly neighborhood leveling system\n\n" +" Earn experience by chatting in text and voice channels, compare levels with your friends, customize your profile and view various leaderboards!\n" +" " +msgstr "" + +#: levelup\main.py:233 +msgid "LevelUp has successfully migrated to v4!\n" +"Leveling is now disabled by default and must be toggled on in each server via `[p]lset toggle`.\n" +"[View the changelog](https://github.com/vertyco/vrt-cogs/blob/main/levelup/CHANGELOG.md) for more information." +msgstr "" + +#: levelup\main.py:243 +msgid "LevelUp has failed to migrate to v4!\n" +"Send this to vertyco:\n" +"{}" +msgstr "" + diff --git a/levelup/locales/es-ES.po b/levelup/locales/es-ES.po new file mode 100644 index 0000000..7daf637 --- /dev/null +++ b/levelup/locales/es-ES.po @@ -0,0 +1,54 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/locales/messages.pot\n" +"X-Crowdin-File-ID: 172\n" +"Language: es_ES\n" + +#: levelup\abc.py:17 +#, docstring +msgid "Type detection" +msgstr "Detección de tipo" + +#: levelup\abc.py:21 +#, docstring +msgid "Type hinting" +msgstr "Inferencia de tipos" + +#: levelup\main.py:71 +#, docstring +msgid "\n" +" Your friendly neighborhood leveling system\n\n" +" Earn experience by chatting in text and voice channels, compare levels with your friends, customize your profile and view various leaderboards!\n" +" " +msgstr "\n" +" El sistema de nivelación amigable de tu vecindario\n\n" +" Gana experiencia chateando en canales de texto y voz, compara niveles con tus amigos, personaliza tu perfil y visualiza varias tablas de clasificación!\n" +" " + +#: levelup\main.py:233 +msgid "LevelUp has successfully migrated to v4!\n" +"Leveling is now disabled by default and must be toggled on in each server via `[p]lset toggle`.\n" +"[View the changelog](https://github.com/vertyco/vrt-cogs/blob/main/levelup/CHANGELOG.md) for more information." +msgstr "¡LevelUp se ha migrado exitosamente a la v4!\n" +"El nivelado está ahora desactivado por defecto y debe activarse en cada servidor mediante `[p]lset toggle`.\n" +"[Ver el registro de cambios](https://github.com/vertyco/vrt-cogs/blob/main/levelup/CHANGELOG.md) para más información." + +#: levelup\main.py:243 +msgid "LevelUp has failed to migrate to v4!\n" +"Send this to vertyco:\n" +"{}" +msgstr "" + diff --git a/levelup/locales/fr-FR.po b/levelup/locales/fr-FR.po new file mode 100644 index 0000000..abc5b4d --- /dev/null +++ b/levelup/locales/fr-FR.po @@ -0,0 +1,49 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/locales/messages.pot\n" +"X-Crowdin-File-ID: 172\n" +"Language: fr_FR\n" + +#: levelup\abc.py:17 +#, docstring +msgid "Type detection" +msgstr "" + +#: levelup\abc.py:21 +#, docstring +msgid "Type hinting" +msgstr "" + +#: levelup\main.py:71 +#, docstring +msgid "\n" +" Your friendly neighborhood leveling system\n\n" +" Earn experience by chatting in text and voice channels, compare levels with your friends, customize your profile and view various leaderboards!\n" +" " +msgstr "" + +#: levelup\main.py:233 +msgid "LevelUp has successfully migrated to v4!\n" +"Leveling is now disabled by default and must be toggled on in each server via `[p]lset toggle`.\n" +"[View the changelog](https://github.com/vertyco/vrt-cogs/blob/main/levelup/CHANGELOG.md) for more information." +msgstr "" + +#: levelup\main.py:243 +msgid "LevelUp has failed to migrate to v4!\n" +"Send this to vertyco:\n" +"{}" +msgstr "" + diff --git a/levelup/locales/hr-HR.po b/levelup/locales/hr-HR.po new file mode 100644 index 0000000..46d3a0d --- /dev/null +++ b/levelup/locales/hr-HR.po @@ -0,0 +1,49 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: hr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/locales/messages.pot\n" +"X-Crowdin-File-ID: 172\n" +"Language: hr_HR\n" + +#: levelup\abc.py:17 +#, docstring +msgid "Type detection" +msgstr "" + +#: levelup\abc.py:21 +#, docstring +msgid "Type hinting" +msgstr "" + +#: levelup\main.py:71 +#, docstring +msgid "\n" +" Your friendly neighborhood leveling system\n\n" +" Earn experience by chatting in text and voice channels, compare levels with your friends, customize your profile and view various leaderboards!\n" +" " +msgstr "" + +#: levelup\main.py:233 +msgid "LevelUp has successfully migrated to v4!\n" +"Leveling is now disabled by default and must be toggled on in each server via `[p]lset toggle`.\n" +"[View the changelog](https://github.com/vertyco/vrt-cogs/blob/main/levelup/CHANGELOG.md) for more information." +msgstr "" + +#: levelup\main.py:243 +msgid "LevelUp has failed to migrate to v4!\n" +"Send this to vertyco:\n" +"{}" +msgstr "" + diff --git a/levelup/locales/ko-KR.po b/levelup/locales/ko-KR.po new file mode 100644 index 0000000..dcb9781 --- /dev/null +++ b/levelup/locales/ko-KR.po @@ -0,0 +1,49 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Korean\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ko\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/locales/messages.pot\n" +"X-Crowdin-File-ID: 172\n" +"Language: ko_KR\n" + +#: levelup\abc.py:17 +#, docstring +msgid "Type detection" +msgstr "" + +#: levelup\abc.py:21 +#, docstring +msgid "Type hinting" +msgstr "" + +#: levelup\main.py:71 +#, docstring +msgid "\n" +" Your friendly neighborhood leveling system\n\n" +" Earn experience by chatting in text and voice channels, compare levels with your friends, customize your profile and view various leaderboards!\n" +" " +msgstr "" + +#: levelup\main.py:233 +msgid "LevelUp has successfully migrated to v4!\n" +"Leveling is now disabled by default and must be toggled on in each server via `[p]lset toggle`.\n" +"[View the changelog](https://github.com/vertyco/vrt-cogs/blob/main/levelup/CHANGELOG.md) for more information." +msgstr "" + +#: levelup\main.py:243 +msgid "LevelUp has failed to migrate to v4!\n" +"Send this to vertyco:\n" +"{}" +msgstr "" + diff --git a/levelup/locales/messages.pot b/levelup/locales/messages.pot new file mode 100644 index 0000000..aedd533 --- /dev/null +++ b/levelup/locales/messages.pot @@ -0,0 +1,46 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" + +#: levelup\abc.py:17 +#, docstring +msgid "Type detection" +msgstr "" + +#: levelup\abc.py:21 +#, docstring +msgid "Type hinting" +msgstr "" + +#: levelup\main.py:71 +#, docstring +msgid "" +"\n" +" Your friendly neighborhood leveling system\n" +"\n" +" Earn experience by chatting in text and voice channels, compare levels with your friends, customize your profile and view various leaderboards!\n" +" " +msgstr "" + +#: levelup\main.py:233 +msgid "" +"LevelUp has successfully migrated to v4!\n" +"Leveling is now disabled by default and must be toggled on in each server via `[p]lset toggle`.\n" +"[View the changelog](https://github.com/vertyco/vrt-cogs/blob/main/levelup/CHANGELOG.md) for more information." +msgstr "" + +#: levelup\main.py:243 +msgid "" +"LevelUp has failed to migrate to v4!\n" +"Send this to vertyco:\n" +"{}" +msgstr "" diff --git a/levelup/locales/pt-PT.po b/levelup/locales/pt-PT.po new file mode 100644 index 0000000..e8518a3 --- /dev/null +++ b/levelup/locales/pt-PT.po @@ -0,0 +1,49 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: pt-PT\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/locales/messages.pot\n" +"X-Crowdin-File-ID: 172\n" +"Language: pt_PT\n" + +#: levelup\abc.py:17 +#, docstring +msgid "Type detection" +msgstr "" + +#: levelup\abc.py:21 +#, docstring +msgid "Type hinting" +msgstr "" + +#: levelup\main.py:71 +#, docstring +msgid "\n" +" Your friendly neighborhood leveling system\n\n" +" Earn experience by chatting in text and voice channels, compare levels with your friends, customize your profile and view various leaderboards!\n" +" " +msgstr "" + +#: levelup\main.py:233 +msgid "LevelUp has successfully migrated to v4!\n" +"Leveling is now disabled by default and must be toggled on in each server via `[p]lset toggle`.\n" +"[View the changelog](https://github.com/vertyco/vrt-cogs/blob/main/levelup/CHANGELOG.md) for more information." +msgstr "" + +#: levelup\main.py:243 +msgid "LevelUp has failed to migrate to v4!\n" +"Send this to vertyco:\n" +"{}" +msgstr "" + diff --git a/levelup/locales/ru-RU.po b/levelup/locales/ru-RU.po new file mode 100644 index 0000000..a374135 --- /dev/null +++ b/levelup/locales/ru-RU.po @@ -0,0 +1,49 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/locales/messages.pot\n" +"X-Crowdin-File-ID: 172\n" +"Language: ru_RU\n" + +#: levelup\abc.py:17 +#, docstring +msgid "Type detection" +msgstr "" + +#: levelup\abc.py:21 +#, docstring +msgid "Type hinting" +msgstr "" + +#: levelup\main.py:71 +#, docstring +msgid "\n" +" Your friendly neighborhood leveling system\n\n" +" Earn experience by chatting in text and voice channels, compare levels with your friends, customize your profile and view various leaderboards!\n" +" " +msgstr "" + +#: levelup\main.py:233 +msgid "LevelUp has successfully migrated to v4!\n" +"Leveling is now disabled by default and must be toggled on in each server via `[p]lset toggle`.\n" +"[View the changelog](https://github.com/vertyco/vrt-cogs/blob/main/levelup/CHANGELOG.md) for more information." +msgstr "" + +#: levelup\main.py:243 +msgid "LevelUp has failed to migrate to v4!\n" +"Send this to vertyco:\n" +"{}" +msgstr "" + diff --git a/levelup/locales/tr-TR.po b/levelup/locales/tr-TR.po new file mode 100644 index 0000000..768a55f --- /dev/null +++ b/levelup/locales/tr-TR.po @@ -0,0 +1,49 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: tr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/locales/messages.pot\n" +"X-Crowdin-File-ID: 172\n" +"Language: tr_TR\n" + +#: levelup\abc.py:17 +#, docstring +msgid "Type detection" +msgstr "" + +#: levelup\abc.py:21 +#, docstring +msgid "Type hinting" +msgstr "" + +#: levelup\main.py:71 +#, docstring +msgid "\n" +" Your friendly neighborhood leveling system\n\n" +" Earn experience by chatting in text and voice channels, compare levels with your friends, customize your profile and view various leaderboards!\n" +" " +msgstr "" + +#: levelup\main.py:233 +msgid "LevelUp has successfully migrated to v4!\n" +"Leveling is now disabled by default and must be toggled on in each server via `[p]lset toggle`.\n" +"[View the changelog](https://github.com/vertyco/vrt-cogs/blob/main/levelup/CHANGELOG.md) for more information." +msgstr "" + +#: levelup\main.py:243 +msgid "LevelUp has failed to migrate to v4!\n" +"Send this to vertyco:\n" +"{}" +msgstr "" + diff --git a/levelup/main.py b/levelup/main.py new file mode 100644 index 0000000..4504f77 --- /dev/null +++ b/levelup/main.py @@ -0,0 +1,286 @@ +"""MIT License + +Copyright (c) 2021-present vertyco + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.""" + +import asyncio +import logging +import multiprocessing as mp +import sys +import typing as t +from collections import defaultdict +from contextlib import suppress +from datetime import datetime +from time import perf_counter + +import discord +import orjson +import psutil +from pydantic import ValidationError +from redbot.core import commands +from redbot.core.bot import Red +from redbot.core.data_manager import bundled_data_path, cog_data_path +from redbot.core.i18n import Translator, cog_i18n +from redbot.core.utils.chat_formatting import box, humanize_list + +from .abc import CompositeMetaClass +from .commands import Commands +from .commands.user import view_profile_context +from .common.models import DB, VoiceTracking, run_migrations +from .dashboard.integration import DashboardIntegration +from .generator import api +from .generator.tenor.converter import TenorAPI +from .listeners import Listeners +from .shared import SharedFunctions +from .tasks import Tasks + +log = logging.getLogger("red.vrt.levelup") +_ = Translator("LevelUp", __file__) +RequestType = t.Literal["discord_deleted_user", "owner", "user", "user_strict"] +IS_WINDOWS: bool = sys.platform.startswith("win") + +# Generate translations +# redgettext -D -r levelup/ --command-docstring + + +@cog_i18n(_) +class LevelUp( + Commands, + SharedFunctions, + DashboardIntegration, + Listeners, + Tasks, + commands.Cog, + metaclass=CompositeMetaClass, +): + """ + Your friendly neighborhood leveling system + + Earn experience by chatting in text and voice channels, compare levels with your friends, customize your profile and view various leaderboards! + """ + + __author__ = "[vertyco](https://github.com/vertyco/vrt-cogs)" + __version__ = "4.3.26" + __contributors__ = [ + "[aikaterna](https://github.com/aikaterna/aikaterna-cogs)", + "[AAA3A](https://github.com/AAA3A-AAA3A/AAA3A-cogs)", + ] + + def __init__(self, bot: Red, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot: Red = bot + + # Cache + self.db: DB = DB() + self.lastmsg: t.Dict[int, t.Dict[int, float]] = {} # GuildID: {UserID: LastMessageTime} + self.profile_cache: t.Dict[int, t.Dict[int, t.Tuple[str, bytes]]] = {} # GuildID: {UserID: (last_used, bytes)} + self.stars: t.Dict[int, t.Dict[int, datetime]] = {} # Guild_ID: {User_ID: {User_ID: datetime}} + + # {guild_id: {member_id: tracking_data}} + self.voice_tracking: t.Dict[int, t.Dict[int, VoiceTracking]] = defaultdict(dict) + + # Root Paths + self.cog_path = cog_data_path(self) + self.bundled_path = bundled_data_path(self) + # Settings Files + self.settings_file = self.cog_path / "LevelUp.json" + self.old_settings_file = self.cog_path / "settings.json" + # Custom Paths + self.custom_fonts = self.cog_path / "fonts" + self.custom_backgrounds = self.cog_path / "backgrounds" + # Bundled Paths + self.stock = self.bundled_path / "stock" + self.fonts = self.bundled_path / "fonts" + self.backgrounds = self.bundled_path / "backgrounds" + + # Save State + self.io_lock = asyncio.Lock() + self.last_save: float = perf_counter() + self.initialized: bool = False + + # Tenor API + self.tenor: TenorAPI = None + + # Internal Profile Generator API + self.api_proc: t.Union[asyncio.subprocess.Process, mp.Process, None] = None + + async def cog_load(self) -> None: + if hasattr(self.bot, "_levelup_internal_api"): + self.api_proc = self.bot._levelup_internal_api + else: + self.bot._levelup_internal_api = None + self.bot.tree.add_command(view_profile_context) + asyncio.create_task(self.initialize()) + + async def cog_unload(self) -> None: + self.bot.tree.remove_command(view_profile_context) + self.stop_levelup_tasks() + + async def start_api(self) -> bool: + if not self.db.internal_api_port: + return False + if self.api_proc is not None: + return False + try: + log_dir = self.cog_path / "APILogs" + log_dir.mkdir(exist_ok=True, parents=True) + proc = await api.run(port=self.db.internal_api_port, log_dir=log_dir) + self.api_proc = proc + self.bot._levelup_internal_api = proc + log.debug(f"API Process started: {proc.pid}") + return True + except Exception as e: + if "Port already in use" in str(e): + log.error( + "Port already in use, Internal API cannot be started, either change the port or restart the bot instance." + ) + else: + log.error("Failed to start internal API", exc_info=e) + return False + + async def stop_api(self) -> bool: + proc: t.Union[asyncio.subprocess.Process, mp.Process, None] = self.api_proc + self.api_proc = None + self.bot._levelup_internal_api = None + if proc is None: + return False + try: + parent = psutil.Process(proc.pid) + except psutil.NoSuchProcess: + return False + for child in parent.children(recursive=True): + log.info(f"Killing child process: {child.pid}") + child.kill() + proc.terminate() + log.info(f"Terminated process: {proc.pid}, API is now stopped") + return True + + def save(self) -> None: + async def _save(): + if self.io_lock.locked(): + # Already saving, skip this + return + if perf_counter() - self.last_save < 2: + # Do not save more than once every 2 seconds + return + if not self.initialized: + # Do not save if not initialized, we don't want to overwrite the config with default data + return + try: + log.debug("Saving config") + async with self.io_lock: + self.db.to_file(self.settings_file) + await asyncio.to_thread(self.db.to_file, self.settings_file) + log.debug("Config saved") + except Exception as e: + log.error("Failed to save config", exc_info=e) + finally: + self.last_save = perf_counter() + + asyncio.create_task(_save()) + + async def initialize(self) -> None: + await self.bot.wait_until_red_ready() + if not hasattr(self, "__author__"): + return + migrated = False + if self.settings_file.exists(): + log.info("Loading config") + try: + self.db = await asyncio.to_thread(DB.from_file, self.settings_file) + except Exception as e: + log.error("Failed to load config!", exc_info=e) + return + elif self.old_settings_file.exists(): + raw_settings = self.old_settings_file.read_text() + settings = orjson.loads(raw_settings) + if settings: + log.warning("Migrating old settings.json") + try: + self.db = await asyncio.to_thread(run_migrations, settings) + log.warning("Migration complete!") + migrated = True + with suppress(discord.HTTPException): + await self.bot.send_to_owners( + _( + "LevelUp has successfully migrated to v4!\n" + "Leveling is now disabled by default and must be toggled on in each server via `[p]lset toggle`.\n" + "[View the changelog](https://github.com/vertyco/vrt-cogs/blob/main/levelup/CHANGELOG.md) for more information." + ) + ) + except ValidationError as e: + log.error("Failed to migrate old settings.json", exc_info=e) + with suppress(discord.HTTPException): + await self.bot.send_to_owners( + _("LevelUp has failed to migrate to v4!\nSend this to vertyco:\n{}").format(box(str(e))) + ) + return + except Exception as e: + log.error("Failed to migrate old settings.json", exc_info=e) + return + + log.info("Config initialized") + self.initialized = True + + if migrated: + self.save() + + if voice_initialized := await self.initialize_voice_states(): + log.info(f"Initialized {voice_initialized} voice states") + + self.start_levelup_tasks() + self.custom_fonts.mkdir(exist_ok=True) + self.custom_backgrounds.mkdir(exist_ok=True) + logging.getLogger("PIL").setLevel(logging.WARNING) + await self.load_tenor() + if self.db.internal_api_port and not self.db.external_api_url: + await self.start_api() + + async def load_tenor(self) -> None: + tokens = await self.bot.get_shared_api_tokens("tenor") + if "api_key" in tokens: + log.debug("Tenor API key loaded") + self.tenor = TenorAPI(tokens["api_key"], str(self.bot.user)) + + async def on_red_api_tokens_update(self, service_name: str, api_tokens: t.Dict[str, str]) -> None: + if service_name != "tenor": + return + if "api_key" in api_tokens: + if self.tenor is not None: + self.tenor._token = api_tokens["api_key"] + return + log.debug("Tenor API key updated") + self.tenor = TenorAPI(api_tokens["api_key"], str(self.bot.user)) + + def format_help_for_context(self, ctx): + helpcmd = super().format_help_for_context(ctx) + info = ( + f"{helpcmd}\n" + f"Cog Version: {self.__version__}\n" + f"Author: {self.__author__}\n" + f"Contributors: {humanize_list(self.__contributors__)}\n" + ) + return info + + async def red_delete_data_for_user(self, *, requester: RequestType, user_id: int): + return + + async def red_get_data_for_user(self, *, user_id: int): + return diff --git a/levelup/shared/README.md b/levelup/shared/README.md new file mode 100644 index 0000000..3715afd --- /dev/null +++ b/levelup/shared/README.md @@ -0,0 +1,59 @@ +# 3rd Party Integration Help + +This directory contains metaclassed functions that are shared between the LevelUp cog and other cogs that wish to use them. These functions are designed to be as modular as possible, allowing for easy integration into other cogs. + +## Example Usage + +Say we want to add xp to a user and check if they've leveled up. + +```python +cog = self.bot.get_cog("LevelUp") +member: discord.Member = ctx.author +new_xp = await cog.add_xp(member, 1000) +await cog.check_levelups(member) +``` + +## Functions + +The functions themselves are documented in the function doscstrings, but here is a brief overview of what each function does. + +### [profile.py](https://github.com/vertyco/vrt-cogs/blob/main/levelup/shared/profile.py) + +- add_xp - Adds XP to a user's profile +- remove_xp - Removes XP from a user's profile +- set_xp - Sets a user's XP to a specific amount +- get_profile_background - Gets the background image for a user's profile +- get_banner - Gets the banner image for a user's profile +- get_user_profile - Gets a user's profile +- get_user_profile_cached - Gets a user's profile from the cache + +### [weeklyreset.py](https://github.com/vertyco/vrt-cogs/blob/main/levelup/shared/weeklyreset.py) + +- reset_weekly - Resets the weekly XP for all users and sends the reset message to the set channel + +### [levelups.py](https://github.com/vertyco/vrt-cogs/blob/main/levelup/shared/levelups.py) + +- check_levelups - Check if a user has leveled up and award roles if needed +- ensure_roles - Ensure that a user has the correct roles for their level and/or prestige + +## Events + +LevelUp dispatches a few events that other cogs can listen for. + +### member_levelup + +Add this listener to your cog to listen for when a user levels up. + +```python +@commands.Cog.listener() +async def on_member_levelup( + self, + guild: discord.Guild, + member: discord.Member, + message: str | None, + channel: TextChannel | VoiceChannel | Thread | ForumChannel, + nev_level: int, +): + print(f"{member} leveled up to level {new_level}!") + # Do something when a user levels up +``` diff --git a/levelup/shared/__init__.py b/levelup/shared/__init__.py new file mode 100644 index 0000000..d96f601 --- /dev/null +++ b/levelup/shared/__init__.py @@ -0,0 +1,12 @@ +from ..abc import CompositeMetaClass +from .levelups import LevelUps +from .profile import ProfileFormatting +from .weeklyreset import WeeklyReset + + +class SharedFunctions(LevelUps, ProfileFormatting, WeeklyReset, metaclass=CompositeMetaClass): + """ + Subclass all shared metaclassed parts of the cog + + This includes classes with functions available to other cogs + """ diff --git a/levelup/shared/levelups.py b/levelup/shared/levelups.py new file mode 100644 index 0000000..1f21b7f --- /dev/null +++ b/levelup/shared/levelups.py @@ -0,0 +1,341 @@ +import asyncio +import base64 +import logging +import random +import typing as t +from contextlib import suppress +from io import BytesIO + +import aiohttp +import discord +from redbot.core.i18n import Translator + +from ..abc import MixinMeta +from ..common import utils +from ..common.models import GuildSettings, Profile +from ..generator import levelalert + +log = logging.getLogger("red.vrt.levelup.shared.levelups") +_ = Translator("LevelUp", __file__) + + +class LevelUps(MixinMeta): + async def check_levelups( + self, + guild: discord.Guild, + member: discord.Member, + profile: Profile, + conf: GuildSettings, + message: t.Optional[discord.Message] = None, + channel: t.Optional[ + t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel] + ] = None, + ) -> bool: + """Check if a user has leveled up and award roles if needed + + Args: + guild (discord.Guild): The guild where the leveling up occurred. + member (discord.Member): The member who leveled up. + profile (Profile): The profile of the member. + conf (GuildSettings): The guild settings. + message (t.Optional[discord.Message], optional): The message that triggered the leveling up. Defaults to None. + channel (t.Optional[t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel]], optional): The channel where the leveling up occurred. Defaults to None. + + Returns: + bool: True if the user leveled up, False otherwise. + """ + calculated_level = conf.algorithm.get_level(profile.xp) + if calculated_level == profile.level: + # No action needed, user hasn't leveled up + return False + if not calculated_level: + # User hasnt reached level 1 yet + return False + log.debug(f"{member} has reached level {calculated_level} in {guild}") + profile.level = calculated_level + # User has reached a new level, time to log and award roles if needed + await self.ensure_roles(member, conf) + current_channel = channel or (message.channel if message else None) + log_channel = guild.get_channel(conf.notifylog) if conf.notifylog else None + + role = None + if calculated_level in conf.levelroles: + role_id = conf.levelroles[calculated_level] + role = guild.get_role(role_id) + + placeholders = { + "username": member.name, + "displayname": member.display_name, + "mention": member.mention, + "level": profile.level, + "role": role.name if role else None, + "server": guild.name, + } + username = member.display_name if profile.show_displayname else member.name + mention = member.mention if conf.notifymention else username + if role: + if dm_txt_raw := conf.role_awarded_dm: + dm_txt = dm_txt_raw.format(**placeholders) + else: + dm_txt = _("You just reached level {} in {} and obtained the {} role!").format( + profile.level, guild.name, role.mention + ) + if msg_txt_raw := conf.role_awarded_msg: + msg_txt = msg_txt_raw.format(**placeholders) + else: + msg_txt = _("{} just reached level {} and obtained the {} role!").format( + mention, profile.level, role.mention + ) + else: + placeholders.pop("role") + if dm_txt_raw := conf.levelup_dm: + dm_txt = dm_txt_raw.format(**placeholders) + else: + dm_txt = _("You just reached level {} in {}!").format(profile.level, guild.name) + if msg_txt_raw := conf.levelup_msg: + msg_txt = msg_txt_raw.format(**placeholders) + else: + msg_txt = _("{} just reached level {}!").format(mention, profile.level) + + if conf.use_embeds or self.db.force_embeds: + if conf.notifydm: + embed = discord.Embed( + description=dm_txt, + color=member.color, + ).set_thumbnail(url=member.display_avatar) + with suppress(discord.HTTPException): + await member.send(embed=embed) + + embed = discord.Embed( + description=msg_txt, + color=member.color, + ).set_author( + name=member.display_name if profile.show_displayname else member.name, + icon_url=member.display_avatar, + ) + if current_channel and conf.notify: + with suppress(discord.HTTPException): + if conf.notifymention: + await current_channel.send(member.mention, embed=embed) + else: + await current_channel.send(embed=embed) + + current_channel_id = current_channel.id if current_channel else 0 + if log_channel and log_channel.id != current_channel_id: + with suppress(discord.HTTPException): + if conf.notifymention: + await log_channel.send(member.mention, embed=embed) + else: + await log_channel.send(embed=embed) + + else: + fonts = list(self.fonts.glob("*.ttf")) + list(self.custom_fonts.iterdir()) + font = str(random.choice(fonts)) + if profile.font: + if (self.fonts / profile.font).exists(): + font = str(self.fonts / profile.font) + elif (self.custom_fonts / profile.font).exists(): + font = str(self.custom_fonts / profile.font) + + color = utils.string_to_rgb(profile.statcolor) if profile.statcolor else member.color.to_rgb() + if color == (0, 0, 0): + color = utils.string_to_rgb(profile.namecolor) if profile.namecolor else None + + payload = aiohttp.FormData() + if self.db.external_api_url or (self.db.internal_api_port and self.api_proc): + banner = await self.get_profile_background(member.id, profile, try_return_url=True) + avatar = member.display_avatar.url + payload.add_field( + "background_bytes", BytesIO(banner) if isinstance(banner, bytes) else banner, filename="data" + ) + payload.add_field("avatar_bytes", avatar) + payload.add_field("level", str(profile.level)) + payload.add_field("color", str(color)) + payload.add_field("font_path", font) + payload.add_field("render_gif", str(self.db.render_gifs)) + + else: + avatar = await member.display_avatar.read() + banner = await self.get_profile_background(member.id, profile) + + img_bytes, animated = None, None + if external_url := self.db.external_api_url: + try: + url = f"{external_url}/levelup" + async with aiohttp.ClientSession() as session: + async with session.post(url, data=payload) as response: + if response.status == 200: + data = await response.json() + img_b64, animated = data["b64"], data["animated"] + img_bytes = base64.b64decode(img_b64) + except Exception as e: + log.error("Failed to fetch levelup image from external API", exc_info=e) + elif self.db.internal_api_port and self.api_proc: + try: + url = f"http://127.0.0.1:{self.db.internal_api_port}/levelup" + async with aiohttp.ClientSession() as session: + async with session.post(url, data=payload) as response: + if response.status == 200: + data = await response.json() + img_b64, animated = data["b64"], data["animated"] + img_bytes = base64.b64decode(img_b64) + except Exception as e: + log.error("Failed to fetch levelup image from internal API", exc_info=e) + + def _run() -> t.Tuple[bytes, bool]: + img_bytes, animated = levelalert.generate_level_img( + background_bytes=banner, + avatar_bytes=avatar, + level=profile.level, + color=color, + font_path=font, + render_gif=self.db.render_gifs, + ) + return img_bytes, animated + + if not img_bytes: + img_bytes, animated = await asyncio.to_thread(_run) + + ext = "gif" if animated else "webp" + if conf.notifydm: + file = discord.File(BytesIO(img_bytes), filename=f"levelup.{ext}") + with suppress(discord.HTTPException): + await member.send(dm_txt, file=file) + + if current_channel and conf.notify: + file = discord.File(BytesIO(img_bytes), filename=f"levelup.{ext}") + with suppress(discord.HTTPException): + if conf.notifymention and message is not None: + await message.reply(msg_txt, file=file, mention_author=True) + else: + await current_channel.send(msg_txt, file=file) + + current_channel_id = current_channel.id if current_channel else 0 + if log_channel and log_channel.id != current_channel_id: + file = discord.File(BytesIO(img_bytes), filename=f"levelup.{ext}") + with suppress(discord.HTTPException): + await log_channel.send(msg_txt, file=file) + + payload = { + "guild": guild, # discord.Guild + "member": member, # discord.Member + "message": message, # Optional[discord.Message] = None + "channel": channel, # Optional[TextChannel | VoiceChannel | Thread | ForumChannel] = None + "new_level": profile.level, # int + } + self.bot.dispatch("member_levelup", **payload) + return True + + async def ensure_roles( + self, + member: discord.Member, + conf: t.Optional[GuildSettings] = None, + reason: t.Optional[str] = None, + ) -> t.Tuple[t.List[discord.Role], t.List[discord.Role]]: + """Ensure a user has the correct level roles based on their level and the guild's settings""" + if conf is None: + conf = self.db.get_conf(member.guild) + if not conf.levelroles: + return [], [] + if not member.guild.me.guild_permissions.manage_roles: + return [], [] + if member.id not in conf.users: + return [], [] + if reason is None: + reason = _("Level Up") + conf.levelroles = dict(sorted(conf.levelroles.items(), key=lambda x: x[0], reverse=True)) + to_add = set() + to_remove = set() + user_roles = member.roles + user_role_ids = [role.id for role in user_roles] + profile = conf.get_profile(member) + + using_prestige = all([profile.prestige, conf.prestigelevel, conf.prestigedata, conf.keep_level_roles]) + + if using_prestige: + # User has prestiges and thus must meet the requirements for any level role inherently + valid_levels = conf.levelroles + else: + valid_levels = {k: v for k, v in conf.levelroles.items() if k <= profile.level} + + valid_levels = dict(sorted(valid_levels.items(), key=lambda x: x[0])) + + if conf.autoremove: + # Add highest level role and remove the rest + highest_role_id = 0 + if valid_levels: + highest_role_id = valid_levels[max(list(valid_levels.keys()))] + if highest_role_id not in user_role_ids: + to_add.add(highest_role_id) + + for role_id in conf.levelroles.values(): + if role_id != highest_role_id and role_id in user_role_ids: + to_remove.add(role_id) + else: + # Ensure user has all roles up to their level + for level, role_id in conf.levelroles.items(): + if level <= profile.level or using_prestige: + if role_id not in user_role_ids: + to_add.add(role_id) + elif role_id in user_role_ids: + to_remove.add(role_id) + + if profile.prestige and conf.prestigedata: + # Assign prestige roles + if conf.stackprestigeroles: + for prestige_level, pdata in conf.prestigedata.items(): + if profile.prestige < prestige_level: + continue + if pdata.role in user_role_ids: + continue + to_add.add(pdata.role) + else: + # Remove all prestige roles except the highest + for prestige_level, pdata in conf.prestigedata.items(): + if prestige_level == profile.prestige: + if pdata.role not in user_role_ids: + to_add.add(pdata.role) + continue + if pdata.role in user_role_ids: + to_remove.add(pdata.role) + + if weekly_role_id := conf.weeklysettings.role: + role_winners = conf.weeklysettings.last_winners + if not conf.weeklysettings.role_all and role_winners: + role_winners = [role_winners[0]] + + if member.id in role_winners and weekly_role_id not in user_role_ids: + to_add.add(weekly_role_id) + elif member.id not in role_winners and weekly_role_id in user_role_ids: + to_remove.add(weekly_role_id) + + add_roles: t.List[discord.Role] = [] + remove_roles: t.List[discord.Role] = [] + bad_roles = set() # Roles that the bot can't manage or cant find + for role_id in to_add: + role = member.guild.get_role(role_id) + if role and role.position < member.guild.me.top_role.position: + add_roles.append(role) + else: + bad_roles.add(role_id) + for role_id in to_remove: + role = member.guild.get_role(role_id) + if role and role.position < member.guild.me.top_role.position: + remove_roles.append(role) + else: + bad_roles.add(role_id) + + if bad_roles: + conf.levelroles = {k: v for k, v in conf.levelroles.items() if v not in bad_roles} + self.save() + + try: + if add_roles: + await member.add_roles(*add_roles, reason=reason) + if remove_roles: + await member.remove_roles(*remove_roles, reason=reason) + except discord.HTTPException: + log.warning(f"Failed to add/remove roles for {member}") + add_roles = [] + remove_roles = [] + return add_roles, remove_roles diff --git a/levelup/shared/locales/de-DE.po b/levelup/shared/locales/de-DE.po new file mode 100644 index 0000000..3325edf --- /dev/null +++ b/levelup/shared/locales/de-DE.po @@ -0,0 +1,216 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/shared/locales/messages.pot\n" +"X-Crowdin-File-ID: 174\n" +"Language: de_DE\n" + +#: levelup\shared\__init__.py:8 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes classes with functions available to other cogs\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:34 +#, docstring +msgid "Check if a user has leveled up and award roles if needed\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the leveling up occurred.\n" +" member (discord.Member): The member who leveled up.\n" +" profile (Profile): The profile of the member.\n" +" conf (GuildSettings): The guild settings.\n" +" message (t.Optional[discord.Message], optional): The message that triggered the leveling up. Defaults to None.\n" +" channel (t.Optional[t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel]], optional): The channel where the leveling up occurred. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the user leveled up, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:72 +msgid "You just reached level {} in {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:78 +msgid "{} just reached level {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:86 +msgid "You just reached level {} in {}!" +msgstr "" + +#: levelup\shared\levelups.py:90 +msgid "{} just reached level {}!" +msgstr "" + +#: levelup\shared\levelups.py:232 +#, docstring +msgid "Ensure a user has the correct level roles based on their level and the guild's settings" +msgstr "" + +#: levelup\shared\profile.py:26 +#, docstring +msgid "Add XP to a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:36 +#, docstring +msgid "Set a user's XP and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:46 +#, docstring +msgid "Remove XP from a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:58 +#, docstring +msgid "\n" +" Get a background for a user's profile in the following priority:\n" +" - Custom background selected by user\n" +" - Banner of user's Discord profile\n" +" - Random background\n" +" " +msgstr "" + +#: levelup\shared\profile.py:98 +#, docstring +msgid "Fetch a user's banner from Discord's API\n\n" +" Args:\n" +" user_id (int): The ID of the user\n\n" +" Returns:\n" +" t.Optional[str]: The URL of the user's banner image, or None if no banner is found\n" +" " +msgstr "" + +#: levelup\shared\profile.py:113 +#, docstring +msgid "\n" +" Get a user's profile as an embed or file\n" +" If embed profiles are disabled, a file will be returned, otherwise an embed will be returned\n\n" +" Args:\n" +" member (discord.Member): The member to get the profile for\n" +" reraise (bool, optional): Fetching profiles will normally catch almost all exceptions and try to\n" +" handle them silently, this will make them throw an exception. Defaults to False.\n\n" +" Returns:\n" +" t.Union[discord.Embed, discord.File]: An embed or file containing the user's profile\n" +" " +msgstr "" + +#: levelup\shared\profile.py:165 +msgid "Level {}\n" +msgstr "" + +#: levelup\shared\profile.py:167 +msgid "Prestige {}\n" +msgstr "" + +#: levelup\shared\profile.py:170 +msgid " stars\n" +msgstr "" + +#: levelup\shared\profile.py:171 +msgid " messages sent\n" +msgstr "" + +#: levelup\shared\profile.py:172 +msgid " in voice\n" +msgstr "" + +#: levelup\shared\profile.py:174 +msgid " Exp ({} total)\n" +msgstr "" + +#: levelup\shared\profile.py:188 +msgid "{}'s Profile" +msgstr "" + +#: levelup\shared\profile.py:192 +msgid "Rank {}, with {}% of the total server Exp" +msgstr "" + +#: levelup\shared\profile.py:197 +msgid "Progress" +msgstr "" + +#: levelup\shared\profile.py:320 +#, docstring +msgid "Cached version of get_user_profile" +msgstr "" + +#: levelup\shared\weeklyreset.py:22 +#, docstring +msgid "Announce and reset weekly stats\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the weekly stats are being reset.\n" +" ctx (commands.Context, optional): Sends the announcement embed in the current channel. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the weekly stats were reset, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\weeklyreset.py:38 +msgid "There are no users in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:52 +msgid "There are no users with XP in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:76 +msgid "`Total Exp: `{}\n" +"`Total Messages: `{}\n" +"`Total Stars: `{}\n" +"`Total Voicetime: `{}\n" +"`Next Reset: `{}" +msgstr "" + +#: levelup\shared\weeklyreset.py:92 +msgid "Top Weekly Exp Earners" +msgstr "" + +#: levelup\shared\weeklyreset.py:106 +msgid "`Experience: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:107 +msgid "`Messages: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:109 +msgid "`Stars: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:111 +msgid "`Voicetime: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:138 +msgid "Missing permissions to manage roles" +msgstr "" + +#: levelup\shared\weeklyreset.py:150 +msgid "Weekly winner role removal" +msgstr "" + +#: levelup\shared\weeklyreset.py:162 +msgid "Weekly winner role addition" +msgstr "" + +#: levelup\shared\weeklyreset.py:180 +msgid "Weekly stats have been reset." +msgstr "" + diff --git a/levelup/shared/locales/es-ES.po b/levelup/shared/locales/es-ES.po new file mode 100644 index 0000000..53c132d --- /dev/null +++ b/levelup/shared/locales/es-ES.po @@ -0,0 +1,242 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/shared/locales/messages.pot\n" +"X-Crowdin-File-ID: 174\n" +"Language: es_ES\n" + +#: levelup\shared\__init__.py:8 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes classes with functions available to other cogs\n" +" " +msgstr "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes classes with functions available to other cogs\n" +" " + +#: levelup\shared\levelups.py:34 +#, docstring +msgid "Check if a user has leveled up and award roles if needed\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the leveling up occurred.\n" +" member (discord.Member): The member who leveled up.\n" +" profile (Profile): The profile of the member.\n" +" conf (GuildSettings): The guild settings.\n" +" message (t.Optional[discord.Message], optional): The message that triggered the leveling up. Defaults to None.\n" +" channel (t.Optional[t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel]], optional): The channel where the leveling up occurred. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the user leveled up, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:72 +msgid "You just reached level {} in {} and obtained the {} role!" +msgstr "¡Acabas de alcanzar el nivel {} en {} y obtuviste el rol {}!" + +#: levelup\shared\levelups.py:78 +msgid "{} just reached level {} and obtained the {} role!" +msgstr "¡{} acaba de alcanzar el nivel {} y obtuvo el rol {}!" + +#: levelup\shared\levelups.py:86 +msgid "You just reached level {} in {}!" +msgstr "¡Acabas de alcanzar el nivel {} en {}!" + +#: levelup\shared\levelups.py:90 +msgid "{} just reached level {}!" +msgstr "¡{} acaba de alcanzar el nivel {}!" + +#: levelup\shared\levelups.py:232 +#, docstring +msgid "Ensure a user has the correct level roles based on their level and the guild's settings" +msgstr "Asegúrate de que un usuario tenga los roles de nivel correctos según su nivel y la configuración del servidor" + +#: levelup\shared\profile.py:26 +#, docstring +msgid "Add XP to a user and check for level ups" +msgstr "Añade XP a un usuario y verifica si sube de nivel" + +#: levelup\shared\profile.py:36 +#, docstring +msgid "Set a user's XP and check for level ups" +msgstr "Establece el XP de un usuario y verifica si sube de nivel" + +#: levelup\shared\profile.py:46 +#, docstring +msgid "Remove XP from a user and check for level ups" +msgstr "Elimina XP de un usuario y verifica si sube de nivel" + +#: levelup\shared\profile.py:58 +#, docstring +msgid "\n" +" Get a background for a user's profile in the following priority:\n" +" - Custom background selected by user\n" +" - Banner of user's Discord profile\n" +" - Random background\n" +" " +msgstr "\n" +" Obtén un fondo para el perfil de un usuario en la siguiente prioridad:\n" +" - Fondo personalizado seleccionado por el usuario\n" +" - Banner del perfil de Discord del usuario\n" +" - Fondo aleatorio\n" +" " + +#: levelup\shared\profile.py:98 +#, docstring +msgid "Fetch a user's banner from Discord's API\n\n" +" Args:\n" +" user_id (int): The ID of the user\n\n" +" Returns:\n" +" t.Optional[str]: The URL of the user's banner image, or None if no banner is found\n" +" " +msgstr "Obtén el banner de un usuario desde la API de Discord\n\n" +" Argumentos:\n" +" user_id (int): El ID del usuario\n\n" +" Retorna:\n" +" t.Optional[str]: La URL de la imagen del banner del usuario, o None si no se encuentra ningún banner\n" +" " + +#: levelup\shared\profile.py:113 +#, docstring +msgid "\n" +" Get a user's profile as an embed or file\n" +" If embed profiles are disabled, a file will be returned, otherwise an embed will be returned\n\n" +" Args:\n" +" member (discord.Member): The member to get the profile for\n" +" reraise (bool, optional): Fetching profiles will normally catch almost all exceptions and try to\n" +" handle them silently, this will make them throw an exception. Defaults to False.\n\n" +" Returns:\n" +" t.Union[discord.Embed, discord.File]: An embed or file containing the user's profile\n" +" " +msgstr "\n" +" Obtén el perfil de un usuario como un embed o archivo\n" +" Si los perfiles embed están desactivados, se devolverá un archivo, de lo contrario, se devolverá un embed\n\n" +" Argumentos:\n" +" member (discord.Member): El miembro para obtener el perfil\n" +" reraise (bool, opcional): Obtener perfiles normalmente captura casi todas las excepciones e intenta\n" +" manejarlas de manera silenciosa, esto hará que lance una excepción. Por defecto es False.\n\n" +" Retorna:\n" +" t.Union[discord.Embed, discord.File]: Un embed o archivo que contiene el perfil del usuario\n" +" " + +#: levelup\shared\profile.py:165 +msgid "Level {}\n" +msgstr "Nivel {}\n" + +#: levelup\shared\profile.py:167 +msgid "Prestige {}\n" +msgstr "Prestigio {}\n" + +#: levelup\shared\profile.py:170 +msgid " stars\n" +msgstr " estrellas\n" + +#: levelup\shared\profile.py:171 +msgid " messages sent\n" +msgstr " mensajes enviados\n" + +#: levelup\shared\profile.py:172 +msgid " in voice\n" +msgstr " en voz\n" + +#: levelup\shared\profile.py:174 +msgid " Exp ({} total)\n" +msgstr " Exp ({} total)\n" + +#: levelup\shared\profile.py:188 +msgid "{}'s Profile" +msgstr "Perfil de {}" + +#: levelup\shared\profile.py:192 +msgid "Rank {}, with {}% of the total server Exp" +msgstr "Rango {}, con el {}% del XP total del servidor" + +#: levelup\shared\profile.py:197 +msgid "Progress" +msgstr "Progreso" + +#: levelup\shared\profile.py:320 +#, docstring +msgid "Cached version of get_user_profile" +msgstr "Versión en caché de get_user_profile" + +#: levelup\shared\weeklyreset.py:22 +#, docstring +msgid "Announce and reset weekly stats\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the weekly stats are being reset.\n" +" ctx (commands.Context, optional): Sends the announcement embed in the current channel. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the weekly stats were reset, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\weeklyreset.py:38 +msgid "There are no users in the weekly data yet" +msgstr "Aún no hay usuarios en los datos semanales" + +#: levelup\shared\weeklyreset.py:52 +msgid "There are no users with XP in the weekly data yet" +msgstr "Aún no hay usuarios con XP en los datos semanales" + +#: levelup\shared\weeklyreset.py:76 +msgid "`Total Exp: `{}\n" +"`Total Messages: `{}\n" +"`Total Stars: `{}\n" +"`Total Voicetime: `{}\n" +"`Next Reset: `{}" +msgstr "`Total Exp: `{}\n" +"`Total de Mensajes: `{}\n" +"`Total de Estrellas: `{}\n" +"`Total de Tiempo de voz: `{}\n" +"`Próximo Reinicio: `{}" + +#: levelup\shared\weeklyreset.py:92 +msgid "Top Weekly Exp Earners" +msgstr "Principales ganadores de experiencia semanal" + +#: levelup\shared\weeklyreset.py:106 +msgid "`Experience: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:107 +msgid "`Messages: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:109 +msgid "`Stars: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:111 +msgid "`Voicetime: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:138 +msgid "Missing permissions to manage roles" +msgstr "Faltan permisos para gestionar roles" + +#: levelup\shared\weeklyreset.py:150 +msgid "Weekly winner role removal" +msgstr "Eliminación de roles de ganador semanal" + +#: levelup\shared\weeklyreset.py:162 +msgid "Weekly winner role addition" +msgstr "Adición de roles de ganador semanal" + +#: levelup\shared\weeklyreset.py:180 +msgid "Weekly stats have been reset." +msgstr "Las estadísticas semanales han sido reiniciadas." + diff --git a/levelup/shared/locales/fr-FR.po b/levelup/shared/locales/fr-FR.po new file mode 100644 index 0000000..f805660 --- /dev/null +++ b/levelup/shared/locales/fr-FR.po @@ -0,0 +1,216 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/shared/locales/messages.pot\n" +"X-Crowdin-File-ID: 174\n" +"Language: fr_FR\n" + +#: levelup\shared\__init__.py:8 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes classes with functions available to other cogs\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:34 +#, docstring +msgid "Check if a user has leveled up and award roles if needed\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the leveling up occurred.\n" +" member (discord.Member): The member who leveled up.\n" +" profile (Profile): The profile of the member.\n" +" conf (GuildSettings): The guild settings.\n" +" message (t.Optional[discord.Message], optional): The message that triggered the leveling up. Defaults to None.\n" +" channel (t.Optional[t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel]], optional): The channel where the leveling up occurred. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the user leveled up, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:72 +msgid "You just reached level {} in {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:78 +msgid "{} just reached level {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:86 +msgid "You just reached level {} in {}!" +msgstr "" + +#: levelup\shared\levelups.py:90 +msgid "{} just reached level {}!" +msgstr "" + +#: levelup\shared\levelups.py:232 +#, docstring +msgid "Ensure a user has the correct level roles based on their level and the guild's settings" +msgstr "" + +#: levelup\shared\profile.py:26 +#, docstring +msgid "Add XP to a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:36 +#, docstring +msgid "Set a user's XP and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:46 +#, docstring +msgid "Remove XP from a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:58 +#, docstring +msgid "\n" +" Get a background for a user's profile in the following priority:\n" +" - Custom background selected by user\n" +" - Banner of user's Discord profile\n" +" - Random background\n" +" " +msgstr "" + +#: levelup\shared\profile.py:98 +#, docstring +msgid "Fetch a user's banner from Discord's API\n\n" +" Args:\n" +" user_id (int): The ID of the user\n\n" +" Returns:\n" +" t.Optional[str]: The URL of the user's banner image, or None if no banner is found\n" +" " +msgstr "" + +#: levelup\shared\profile.py:113 +#, docstring +msgid "\n" +" Get a user's profile as an embed or file\n" +" If embed profiles are disabled, a file will be returned, otherwise an embed will be returned\n\n" +" Args:\n" +" member (discord.Member): The member to get the profile for\n" +" reraise (bool, optional): Fetching profiles will normally catch almost all exceptions and try to\n" +" handle them silently, this will make them throw an exception. Defaults to False.\n\n" +" Returns:\n" +" t.Union[discord.Embed, discord.File]: An embed or file containing the user's profile\n" +" " +msgstr "" + +#: levelup\shared\profile.py:165 +msgid "Level {}\n" +msgstr "" + +#: levelup\shared\profile.py:167 +msgid "Prestige {}\n" +msgstr "" + +#: levelup\shared\profile.py:170 +msgid " stars\n" +msgstr "" + +#: levelup\shared\profile.py:171 +msgid " messages sent\n" +msgstr "" + +#: levelup\shared\profile.py:172 +msgid " in voice\n" +msgstr "" + +#: levelup\shared\profile.py:174 +msgid " Exp ({} total)\n" +msgstr "" + +#: levelup\shared\profile.py:188 +msgid "{}'s Profile" +msgstr "" + +#: levelup\shared\profile.py:192 +msgid "Rank {}, with {}% of the total server Exp" +msgstr "" + +#: levelup\shared\profile.py:197 +msgid "Progress" +msgstr "" + +#: levelup\shared\profile.py:320 +#, docstring +msgid "Cached version of get_user_profile" +msgstr "" + +#: levelup\shared\weeklyreset.py:22 +#, docstring +msgid "Announce and reset weekly stats\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the weekly stats are being reset.\n" +" ctx (commands.Context, optional): Sends the announcement embed in the current channel. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the weekly stats were reset, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\weeklyreset.py:38 +msgid "There are no users in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:52 +msgid "There are no users with XP in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:76 +msgid "`Total Exp: `{}\n" +"`Total Messages: `{}\n" +"`Total Stars: `{}\n" +"`Total Voicetime: `{}\n" +"`Next Reset: `{}" +msgstr "" + +#: levelup\shared\weeklyreset.py:92 +msgid "Top Weekly Exp Earners" +msgstr "" + +#: levelup\shared\weeklyreset.py:106 +msgid "`Experience: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:107 +msgid "`Messages: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:109 +msgid "`Stars: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:111 +msgid "`Voicetime: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:138 +msgid "Missing permissions to manage roles" +msgstr "" + +#: levelup\shared\weeklyreset.py:150 +msgid "Weekly winner role removal" +msgstr "" + +#: levelup\shared\weeklyreset.py:162 +msgid "Weekly winner role addition" +msgstr "" + +#: levelup\shared\weeklyreset.py:180 +msgid "Weekly stats have been reset." +msgstr "" + diff --git a/levelup/shared/locales/hr-HR.po b/levelup/shared/locales/hr-HR.po new file mode 100644 index 0000000..59482d0 --- /dev/null +++ b/levelup/shared/locales/hr-HR.po @@ -0,0 +1,216 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: hr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/shared/locales/messages.pot\n" +"X-Crowdin-File-ID: 174\n" +"Language: hr_HR\n" + +#: levelup\shared\__init__.py:8 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes classes with functions available to other cogs\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:34 +#, docstring +msgid "Check if a user has leveled up and award roles if needed\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the leveling up occurred.\n" +" member (discord.Member): The member who leveled up.\n" +" profile (Profile): The profile of the member.\n" +" conf (GuildSettings): The guild settings.\n" +" message (t.Optional[discord.Message], optional): The message that triggered the leveling up. Defaults to None.\n" +" channel (t.Optional[t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel]], optional): The channel where the leveling up occurred. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the user leveled up, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:72 +msgid "You just reached level {} in {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:78 +msgid "{} just reached level {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:86 +msgid "You just reached level {} in {}!" +msgstr "" + +#: levelup\shared\levelups.py:90 +msgid "{} just reached level {}!" +msgstr "" + +#: levelup\shared\levelups.py:232 +#, docstring +msgid "Ensure a user has the correct level roles based on their level and the guild's settings" +msgstr "" + +#: levelup\shared\profile.py:26 +#, docstring +msgid "Add XP to a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:36 +#, docstring +msgid "Set a user's XP and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:46 +#, docstring +msgid "Remove XP from a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:58 +#, docstring +msgid "\n" +" Get a background for a user's profile in the following priority:\n" +" - Custom background selected by user\n" +" - Banner of user's Discord profile\n" +" - Random background\n" +" " +msgstr "" + +#: levelup\shared\profile.py:98 +#, docstring +msgid "Fetch a user's banner from Discord's API\n\n" +" Args:\n" +" user_id (int): The ID of the user\n\n" +" Returns:\n" +" t.Optional[str]: The URL of the user's banner image, or None if no banner is found\n" +" " +msgstr "" + +#: levelup\shared\profile.py:113 +#, docstring +msgid "\n" +" Get a user's profile as an embed or file\n" +" If embed profiles are disabled, a file will be returned, otherwise an embed will be returned\n\n" +" Args:\n" +" member (discord.Member): The member to get the profile for\n" +" reraise (bool, optional): Fetching profiles will normally catch almost all exceptions and try to\n" +" handle them silently, this will make them throw an exception. Defaults to False.\n\n" +" Returns:\n" +" t.Union[discord.Embed, discord.File]: An embed or file containing the user's profile\n" +" " +msgstr "" + +#: levelup\shared\profile.py:165 +msgid "Level {}\n" +msgstr "" + +#: levelup\shared\profile.py:167 +msgid "Prestige {}\n" +msgstr "" + +#: levelup\shared\profile.py:170 +msgid " stars\n" +msgstr "" + +#: levelup\shared\profile.py:171 +msgid " messages sent\n" +msgstr "" + +#: levelup\shared\profile.py:172 +msgid " in voice\n" +msgstr "" + +#: levelup\shared\profile.py:174 +msgid " Exp ({} total)\n" +msgstr "" + +#: levelup\shared\profile.py:188 +msgid "{}'s Profile" +msgstr "" + +#: levelup\shared\profile.py:192 +msgid "Rank {}, with {}% of the total server Exp" +msgstr "" + +#: levelup\shared\profile.py:197 +msgid "Progress" +msgstr "" + +#: levelup\shared\profile.py:320 +#, docstring +msgid "Cached version of get_user_profile" +msgstr "" + +#: levelup\shared\weeklyreset.py:22 +#, docstring +msgid "Announce and reset weekly stats\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the weekly stats are being reset.\n" +" ctx (commands.Context, optional): Sends the announcement embed in the current channel. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the weekly stats were reset, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\weeklyreset.py:38 +msgid "There are no users in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:52 +msgid "There are no users with XP in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:76 +msgid "`Total Exp: `{}\n" +"`Total Messages: `{}\n" +"`Total Stars: `{}\n" +"`Total Voicetime: `{}\n" +"`Next Reset: `{}" +msgstr "" + +#: levelup\shared\weeklyreset.py:92 +msgid "Top Weekly Exp Earners" +msgstr "" + +#: levelup\shared\weeklyreset.py:106 +msgid "`Experience: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:107 +msgid "`Messages: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:109 +msgid "`Stars: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:111 +msgid "`Voicetime: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:138 +msgid "Missing permissions to manage roles" +msgstr "" + +#: levelup\shared\weeklyreset.py:150 +msgid "Weekly winner role removal" +msgstr "" + +#: levelup\shared\weeklyreset.py:162 +msgid "Weekly winner role addition" +msgstr "" + +#: levelup\shared\weeklyreset.py:180 +msgid "Weekly stats have been reset." +msgstr "" + diff --git a/levelup/shared/locales/ko-KR.po b/levelup/shared/locales/ko-KR.po new file mode 100644 index 0000000..304b198 --- /dev/null +++ b/levelup/shared/locales/ko-KR.po @@ -0,0 +1,216 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Korean\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ko\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/shared/locales/messages.pot\n" +"X-Crowdin-File-ID: 174\n" +"Language: ko_KR\n" + +#: levelup\shared\__init__.py:8 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes classes with functions available to other cogs\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:34 +#, docstring +msgid "Check if a user has leveled up and award roles if needed\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the leveling up occurred.\n" +" member (discord.Member): The member who leveled up.\n" +" profile (Profile): The profile of the member.\n" +" conf (GuildSettings): The guild settings.\n" +" message (t.Optional[discord.Message], optional): The message that triggered the leveling up. Defaults to None.\n" +" channel (t.Optional[t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel]], optional): The channel where the leveling up occurred. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the user leveled up, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:72 +msgid "You just reached level {} in {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:78 +msgid "{} just reached level {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:86 +msgid "You just reached level {} in {}!" +msgstr "" + +#: levelup\shared\levelups.py:90 +msgid "{} just reached level {}!" +msgstr "" + +#: levelup\shared\levelups.py:232 +#, docstring +msgid "Ensure a user has the correct level roles based on their level and the guild's settings" +msgstr "" + +#: levelup\shared\profile.py:26 +#, docstring +msgid "Add XP to a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:36 +#, docstring +msgid "Set a user's XP and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:46 +#, docstring +msgid "Remove XP from a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:58 +#, docstring +msgid "\n" +" Get a background for a user's profile in the following priority:\n" +" - Custom background selected by user\n" +" - Banner of user's Discord profile\n" +" - Random background\n" +" " +msgstr "" + +#: levelup\shared\profile.py:98 +#, docstring +msgid "Fetch a user's banner from Discord's API\n\n" +" Args:\n" +" user_id (int): The ID of the user\n\n" +" Returns:\n" +" t.Optional[str]: The URL of the user's banner image, or None if no banner is found\n" +" " +msgstr "" + +#: levelup\shared\profile.py:113 +#, docstring +msgid "\n" +" Get a user's profile as an embed or file\n" +" If embed profiles are disabled, a file will be returned, otherwise an embed will be returned\n\n" +" Args:\n" +" member (discord.Member): The member to get the profile for\n" +" reraise (bool, optional): Fetching profiles will normally catch almost all exceptions and try to\n" +" handle them silently, this will make them throw an exception. Defaults to False.\n\n" +" Returns:\n" +" t.Union[discord.Embed, discord.File]: An embed or file containing the user's profile\n" +" " +msgstr "" + +#: levelup\shared\profile.py:165 +msgid "Level {}\n" +msgstr "" + +#: levelup\shared\profile.py:167 +msgid "Prestige {}\n" +msgstr "" + +#: levelup\shared\profile.py:170 +msgid " stars\n" +msgstr "" + +#: levelup\shared\profile.py:171 +msgid " messages sent\n" +msgstr "" + +#: levelup\shared\profile.py:172 +msgid " in voice\n" +msgstr "" + +#: levelup\shared\profile.py:174 +msgid " Exp ({} total)\n" +msgstr "" + +#: levelup\shared\profile.py:188 +msgid "{}'s Profile" +msgstr "" + +#: levelup\shared\profile.py:192 +msgid "Rank {}, with {}% of the total server Exp" +msgstr "" + +#: levelup\shared\profile.py:197 +msgid "Progress" +msgstr "" + +#: levelup\shared\profile.py:320 +#, docstring +msgid "Cached version of get_user_profile" +msgstr "" + +#: levelup\shared\weeklyreset.py:22 +#, docstring +msgid "Announce and reset weekly stats\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the weekly stats are being reset.\n" +" ctx (commands.Context, optional): Sends the announcement embed in the current channel. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the weekly stats were reset, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\weeklyreset.py:38 +msgid "There are no users in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:52 +msgid "There are no users with XP in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:76 +msgid "`Total Exp: `{}\n" +"`Total Messages: `{}\n" +"`Total Stars: `{}\n" +"`Total Voicetime: `{}\n" +"`Next Reset: `{}" +msgstr "" + +#: levelup\shared\weeklyreset.py:92 +msgid "Top Weekly Exp Earners" +msgstr "" + +#: levelup\shared\weeklyreset.py:106 +msgid "`Experience: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:107 +msgid "`Messages: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:109 +msgid "`Stars: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:111 +msgid "`Voicetime: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:138 +msgid "Missing permissions to manage roles" +msgstr "" + +#: levelup\shared\weeklyreset.py:150 +msgid "Weekly winner role removal" +msgstr "" + +#: levelup\shared\weeklyreset.py:162 +msgid "Weekly winner role addition" +msgstr "" + +#: levelup\shared\weeklyreset.py:180 +msgid "Weekly stats have been reset." +msgstr "" + diff --git a/levelup/shared/locales/messages.pot b/levelup/shared/locales/messages.pot new file mode 100644 index 0000000..5e9b58d --- /dev/null +++ b/levelup/shared/locales/messages.pot @@ -0,0 +1,227 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" + +#: levelup\shared\__init__.py:8 +#, docstring +msgid "" +"\n" +" Subclass all shared metaclassed parts of the cog\n" +"\n" +" This includes classes with functions available to other cogs\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:34 +#, docstring +msgid "" +"Check if a user has leveled up and award roles if needed\n" +"\n" +" Args:\n" +" guild (discord.Guild): The guild where the leveling up occurred.\n" +" member (discord.Member): The member who leveled up.\n" +" profile (Profile): The profile of the member.\n" +" conf (GuildSettings): The guild settings.\n" +" message (t.Optional[discord.Message], optional): The message that triggered the leveling up. Defaults to None.\n" +" channel (t.Optional[t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel]], optional): The channel where the leveling up occurred. Defaults to None.\n" +"\n" +" Returns:\n" +" bool: True if the user leveled up, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:72 +msgid "You just reached level {} in {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:78 +msgid "{} just reached level {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:86 +msgid "You just reached level {} in {}!" +msgstr "" + +#: levelup\shared\levelups.py:90 +msgid "{} just reached level {}!" +msgstr "" + +#: levelup\shared\levelups.py:232 +#, docstring +msgid "" +"Ensure a user has the correct level roles based on their level and the " +"guild's settings" +msgstr "" + +#: levelup\shared\profile.py:26 +#, docstring +msgid "Add XP to a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:36 +#, docstring +msgid "Set a user's XP and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:46 +#, docstring +msgid "Remove XP from a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:58 +#, docstring +msgid "" +"\n" +" Get a background for a user's profile in the following priority:\n" +" - Custom background selected by user\n" +" - Banner of user's Discord profile\n" +" - Random background\n" +" " +msgstr "" + +#: levelup\shared\profile.py:98 +#, docstring +msgid "" +"Fetch a user's banner from Discord's API\n" +"\n" +" Args:\n" +" user_id (int): The ID of the user\n" +"\n" +" Returns:\n" +" t.Optional[str]: The URL of the user's banner image, or None if no banner is found\n" +" " +msgstr "" + +#: levelup\shared\profile.py:113 +#, docstring +msgid "" +"\n" +" Get a user's profile as an embed or file\n" +" If embed profiles are disabled, a file will be returned, otherwise an embed will be returned\n" +"\n" +" Args:\n" +" member (discord.Member): The member to get the profile for\n" +" reraise (bool, optional): Fetching profiles will normally catch almost all exceptions and try to\n" +" handle them silently, this will make them throw an exception. Defaults to False.\n" +"\n" +" Returns:\n" +" t.Union[discord.Embed, discord.File]: An embed or file containing the user's profile\n" +" " +msgstr "" + +#: levelup\shared\profile.py:165 +msgid "Level {}\n" +msgstr "" + +#: levelup\shared\profile.py:167 +msgid "Prestige {}\n" +msgstr "" + +#: levelup\shared\profile.py:170 +msgid " stars\n" +msgstr "" + +#: levelup\shared\profile.py:171 +msgid " messages sent\n" +msgstr "" + +#: levelup\shared\profile.py:172 +msgid " in voice\n" +msgstr "" + +#: levelup\shared\profile.py:174 +msgid " Exp ({} total)\n" +msgstr "" + +#: levelup\shared\profile.py:188 +msgid "{}'s Profile" +msgstr "" + +#: levelup\shared\profile.py:192 +msgid "Rank {}, with {}% of the total server Exp" +msgstr "" + +#: levelup\shared\profile.py:197 +msgid "Progress" +msgstr "" + +#: levelup\shared\profile.py:320 +#, docstring +msgid "Cached version of get_user_profile" +msgstr "" + +#: levelup\shared\weeklyreset.py:22 +#, docstring +msgid "" +"Announce and reset weekly stats\n" +"\n" +" Args:\n" +" guild (discord.Guild): The guild where the weekly stats are being reset.\n" +" ctx (commands.Context, optional): Sends the announcement embed in the current channel. Defaults to None.\n" +"\n" +" Returns:\n" +" bool: True if the weekly stats were reset, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\weeklyreset.py:38 +msgid "There are no users in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:52 +msgid "There are no users with XP in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:76 +msgid "" +"`Total Exp: `{}\n" +"`Total Messages: `{}\n" +"`Total Stars: `{}\n" +"`Total Voicetime: `{}\n" +"`Next Reset: `{}" +msgstr "" + +#: levelup\shared\weeklyreset.py:92 +msgid "Top Weekly Exp Earners" +msgstr "" + +#: levelup\shared\weeklyreset.py:106 +msgid "`Experience: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:107 +msgid "`Messages: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:109 +msgid "`Stars: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:111 +msgid "`Voicetime: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:138 +msgid "Missing permissions to manage roles" +msgstr "" + +#: levelup\shared\weeklyreset.py:150 +msgid "Weekly winner role removal" +msgstr "" + +#: levelup\shared\weeklyreset.py:162 +msgid "Weekly winner role addition" +msgstr "" + +#: levelup\shared\weeklyreset.py:180 +msgid "Weekly stats have been reset." +msgstr "" diff --git a/levelup/shared/locales/pt-PT.po b/levelup/shared/locales/pt-PT.po new file mode 100644 index 0000000..03d9175 --- /dev/null +++ b/levelup/shared/locales/pt-PT.po @@ -0,0 +1,216 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:58\n" +"Last-Translator: \n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: pt-PT\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/shared/locales/messages.pot\n" +"X-Crowdin-File-ID: 174\n" +"Language: pt_PT\n" + +#: levelup\shared\__init__.py:8 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes classes with functions available to other cogs\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:34 +#, docstring +msgid "Check if a user has leveled up and award roles if needed\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the leveling up occurred.\n" +" member (discord.Member): The member who leveled up.\n" +" profile (Profile): The profile of the member.\n" +" conf (GuildSettings): The guild settings.\n" +" message (t.Optional[discord.Message], optional): The message that triggered the leveling up. Defaults to None.\n" +" channel (t.Optional[t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel]], optional): The channel where the leveling up occurred. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the user leveled up, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:72 +msgid "You just reached level {} in {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:78 +msgid "{} just reached level {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:86 +msgid "You just reached level {} in {}!" +msgstr "" + +#: levelup\shared\levelups.py:90 +msgid "{} just reached level {}!" +msgstr "" + +#: levelup\shared\levelups.py:232 +#, docstring +msgid "Ensure a user has the correct level roles based on their level and the guild's settings" +msgstr "" + +#: levelup\shared\profile.py:26 +#, docstring +msgid "Add XP to a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:36 +#, docstring +msgid "Set a user's XP and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:46 +#, docstring +msgid "Remove XP from a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:58 +#, docstring +msgid "\n" +" Get a background for a user's profile in the following priority:\n" +" - Custom background selected by user\n" +" - Banner of user's Discord profile\n" +" - Random background\n" +" " +msgstr "" + +#: levelup\shared\profile.py:98 +#, docstring +msgid "Fetch a user's banner from Discord's API\n\n" +" Args:\n" +" user_id (int): The ID of the user\n\n" +" Returns:\n" +" t.Optional[str]: The URL of the user's banner image, or None if no banner is found\n" +" " +msgstr "" + +#: levelup\shared\profile.py:113 +#, docstring +msgid "\n" +" Get a user's profile as an embed or file\n" +" If embed profiles are disabled, a file will be returned, otherwise an embed will be returned\n\n" +" Args:\n" +" member (discord.Member): The member to get the profile for\n" +" reraise (bool, optional): Fetching profiles will normally catch almost all exceptions and try to\n" +" handle them silently, this will make them throw an exception. Defaults to False.\n\n" +" Returns:\n" +" t.Union[discord.Embed, discord.File]: An embed or file containing the user's profile\n" +" " +msgstr "" + +#: levelup\shared\profile.py:165 +msgid "Level {}\n" +msgstr "" + +#: levelup\shared\profile.py:167 +msgid "Prestige {}\n" +msgstr "" + +#: levelup\shared\profile.py:170 +msgid " stars\n" +msgstr "" + +#: levelup\shared\profile.py:171 +msgid " messages sent\n" +msgstr "" + +#: levelup\shared\profile.py:172 +msgid " in voice\n" +msgstr "" + +#: levelup\shared\profile.py:174 +msgid " Exp ({} total)\n" +msgstr "" + +#: levelup\shared\profile.py:188 +msgid "{}'s Profile" +msgstr "" + +#: levelup\shared\profile.py:192 +msgid "Rank {}, with {}% of the total server Exp" +msgstr "" + +#: levelup\shared\profile.py:197 +msgid "Progress" +msgstr "" + +#: levelup\shared\profile.py:320 +#, docstring +msgid "Cached version of get_user_profile" +msgstr "" + +#: levelup\shared\weeklyreset.py:22 +#, docstring +msgid "Announce and reset weekly stats\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the weekly stats are being reset.\n" +" ctx (commands.Context, optional): Sends the announcement embed in the current channel. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the weekly stats were reset, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\weeklyreset.py:38 +msgid "There are no users in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:52 +msgid "There are no users with XP in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:76 +msgid "`Total Exp: `{}\n" +"`Total Messages: `{}\n" +"`Total Stars: `{}\n" +"`Total Voicetime: `{}\n" +"`Next Reset: `{}" +msgstr "" + +#: levelup\shared\weeklyreset.py:92 +msgid "Top Weekly Exp Earners" +msgstr "" + +#: levelup\shared\weeklyreset.py:106 +msgid "`Experience: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:107 +msgid "`Messages: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:109 +msgid "`Stars: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:111 +msgid "`Voicetime: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:138 +msgid "Missing permissions to manage roles" +msgstr "" + +#: levelup\shared\weeklyreset.py:150 +msgid "Weekly winner role removal" +msgstr "" + +#: levelup\shared\weeklyreset.py:162 +msgid "Weekly winner role addition" +msgstr "" + +#: levelup\shared\weeklyreset.py:180 +msgid "Weekly stats have been reset." +msgstr "" + diff --git a/levelup/shared/locales/ru-RU.po b/levelup/shared/locales/ru-RU.po new file mode 100644 index 0000000..9327c0c --- /dev/null +++ b/levelup/shared/locales/ru-RU.po @@ -0,0 +1,216 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/shared/locales/messages.pot\n" +"X-Crowdin-File-ID: 174\n" +"Language: ru_RU\n" + +#: levelup\shared\__init__.py:8 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes classes with functions available to other cogs\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:34 +#, docstring +msgid "Check if a user has leveled up and award roles if needed\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the leveling up occurred.\n" +" member (discord.Member): The member who leveled up.\n" +" profile (Profile): The profile of the member.\n" +" conf (GuildSettings): The guild settings.\n" +" message (t.Optional[discord.Message], optional): The message that triggered the leveling up. Defaults to None.\n" +" channel (t.Optional[t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel]], optional): The channel where the leveling up occurred. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the user leveled up, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:72 +msgid "You just reached level {} in {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:78 +msgid "{} just reached level {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:86 +msgid "You just reached level {} in {}!" +msgstr "" + +#: levelup\shared\levelups.py:90 +msgid "{} just reached level {}!" +msgstr "" + +#: levelup\shared\levelups.py:232 +#, docstring +msgid "Ensure a user has the correct level roles based on their level and the guild's settings" +msgstr "" + +#: levelup\shared\profile.py:26 +#, docstring +msgid "Add XP to a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:36 +#, docstring +msgid "Set a user's XP and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:46 +#, docstring +msgid "Remove XP from a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:58 +#, docstring +msgid "\n" +" Get a background for a user's profile in the following priority:\n" +" - Custom background selected by user\n" +" - Banner of user's Discord profile\n" +" - Random background\n" +" " +msgstr "" + +#: levelup\shared\profile.py:98 +#, docstring +msgid "Fetch a user's banner from Discord's API\n\n" +" Args:\n" +" user_id (int): The ID of the user\n\n" +" Returns:\n" +" t.Optional[str]: The URL of the user's banner image, or None if no banner is found\n" +" " +msgstr "" + +#: levelup\shared\profile.py:113 +#, docstring +msgid "\n" +" Get a user's profile as an embed or file\n" +" If embed profiles are disabled, a file will be returned, otherwise an embed will be returned\n\n" +" Args:\n" +" member (discord.Member): The member to get the profile for\n" +" reraise (bool, optional): Fetching profiles will normally catch almost all exceptions and try to\n" +" handle them silently, this will make them throw an exception. Defaults to False.\n\n" +" Returns:\n" +" t.Union[discord.Embed, discord.File]: An embed or file containing the user's profile\n" +" " +msgstr "" + +#: levelup\shared\profile.py:165 +msgid "Level {}\n" +msgstr "" + +#: levelup\shared\profile.py:167 +msgid "Prestige {}\n" +msgstr "" + +#: levelup\shared\profile.py:170 +msgid " stars\n" +msgstr "" + +#: levelup\shared\profile.py:171 +msgid " messages sent\n" +msgstr "" + +#: levelup\shared\profile.py:172 +msgid " in voice\n" +msgstr "" + +#: levelup\shared\profile.py:174 +msgid " Exp ({} total)\n" +msgstr "" + +#: levelup\shared\profile.py:188 +msgid "{}'s Profile" +msgstr "" + +#: levelup\shared\profile.py:192 +msgid "Rank {}, with {}% of the total server Exp" +msgstr "" + +#: levelup\shared\profile.py:197 +msgid "Progress" +msgstr "" + +#: levelup\shared\profile.py:320 +#, docstring +msgid "Cached version of get_user_profile" +msgstr "" + +#: levelup\shared\weeklyreset.py:22 +#, docstring +msgid "Announce and reset weekly stats\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the weekly stats are being reset.\n" +" ctx (commands.Context, optional): Sends the announcement embed in the current channel. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the weekly stats were reset, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\weeklyreset.py:38 +msgid "There are no users in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:52 +msgid "There are no users with XP in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:76 +msgid "`Total Exp: `{}\n" +"`Total Messages: `{}\n" +"`Total Stars: `{}\n" +"`Total Voicetime: `{}\n" +"`Next Reset: `{}" +msgstr "" + +#: levelup\shared\weeklyreset.py:92 +msgid "Top Weekly Exp Earners" +msgstr "" + +#: levelup\shared\weeklyreset.py:106 +msgid "`Experience: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:107 +msgid "`Messages: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:109 +msgid "`Stars: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:111 +msgid "`Voicetime: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:138 +msgid "Missing permissions to manage roles" +msgstr "" + +#: levelup\shared\weeklyreset.py:150 +msgid "Weekly winner role removal" +msgstr "" + +#: levelup\shared\weeklyreset.py:162 +msgid "Weekly winner role addition" +msgstr "" + +#: levelup\shared\weeklyreset.py:180 +msgid "Weekly stats have been reset." +msgstr "" + diff --git a/levelup/shared/locales/tr-TR.po b/levelup/shared/locales/tr-TR.po new file mode 100644 index 0000000..1d2094d --- /dev/null +++ b/levelup/shared/locales/tr-TR.po @@ -0,0 +1,216 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: tr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/shared/locales/messages.pot\n" +"X-Crowdin-File-ID: 174\n" +"Language: tr_TR\n" + +#: levelup\shared\__init__.py:8 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes classes with functions available to other cogs\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:34 +#, docstring +msgid "Check if a user has leveled up and award roles if needed\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the leveling up occurred.\n" +" member (discord.Member): The member who leveled up.\n" +" profile (Profile): The profile of the member.\n" +" conf (GuildSettings): The guild settings.\n" +" message (t.Optional[discord.Message], optional): The message that triggered the leveling up. Defaults to None.\n" +" channel (t.Optional[t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel]], optional): The channel where the leveling up occurred. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the user leveled up, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\levelups.py:72 +msgid "You just reached level {} in {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:78 +msgid "{} just reached level {} and obtained the {} role!" +msgstr "" + +#: levelup\shared\levelups.py:86 +msgid "You just reached level {} in {}!" +msgstr "" + +#: levelup\shared\levelups.py:90 +msgid "{} just reached level {}!" +msgstr "" + +#: levelup\shared\levelups.py:232 +#, docstring +msgid "Ensure a user has the correct level roles based on their level and the guild's settings" +msgstr "" + +#: levelup\shared\profile.py:26 +#, docstring +msgid "Add XP to a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:36 +#, docstring +msgid "Set a user's XP and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:46 +#, docstring +msgid "Remove XP from a user and check for level ups" +msgstr "" + +#: levelup\shared\profile.py:58 +#, docstring +msgid "\n" +" Get a background for a user's profile in the following priority:\n" +" - Custom background selected by user\n" +" - Banner of user's Discord profile\n" +" - Random background\n" +" " +msgstr "" + +#: levelup\shared\profile.py:98 +#, docstring +msgid "Fetch a user's banner from Discord's API\n\n" +" Args:\n" +" user_id (int): The ID of the user\n\n" +" Returns:\n" +" t.Optional[str]: The URL of the user's banner image, or None if no banner is found\n" +" " +msgstr "" + +#: levelup\shared\profile.py:113 +#, docstring +msgid "\n" +" Get a user's profile as an embed or file\n" +" If embed profiles are disabled, a file will be returned, otherwise an embed will be returned\n\n" +" Args:\n" +" member (discord.Member): The member to get the profile for\n" +" reraise (bool, optional): Fetching profiles will normally catch almost all exceptions and try to\n" +" handle them silently, this will make them throw an exception. Defaults to False.\n\n" +" Returns:\n" +" t.Union[discord.Embed, discord.File]: An embed or file containing the user's profile\n" +" " +msgstr "" + +#: levelup\shared\profile.py:165 +msgid "Level {}\n" +msgstr "" + +#: levelup\shared\profile.py:167 +msgid "Prestige {}\n" +msgstr "" + +#: levelup\shared\profile.py:170 +msgid " stars\n" +msgstr "" + +#: levelup\shared\profile.py:171 +msgid " messages sent\n" +msgstr "" + +#: levelup\shared\profile.py:172 +msgid " in voice\n" +msgstr "" + +#: levelup\shared\profile.py:174 +msgid " Exp ({} total)\n" +msgstr "" + +#: levelup\shared\profile.py:188 +msgid "{}'s Profile" +msgstr "" + +#: levelup\shared\profile.py:192 +msgid "Rank {}, with {}% of the total server Exp" +msgstr "" + +#: levelup\shared\profile.py:197 +msgid "Progress" +msgstr "" + +#: levelup\shared\profile.py:320 +#, docstring +msgid "Cached version of get_user_profile" +msgstr "" + +#: levelup\shared\weeklyreset.py:22 +#, docstring +msgid "Announce and reset weekly stats\n\n" +" Args:\n" +" guild (discord.Guild): The guild where the weekly stats are being reset.\n" +" ctx (commands.Context, optional): Sends the announcement embed in the current channel. Defaults to None.\n\n" +" Returns:\n" +" bool: True if the weekly stats were reset, False otherwise.\n" +" " +msgstr "" + +#: levelup\shared\weeklyreset.py:38 +msgid "There are no users in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:52 +msgid "There are no users with XP in the weekly data yet" +msgstr "" + +#: levelup\shared\weeklyreset.py:76 +msgid "`Total Exp: `{}\n" +"`Total Messages: `{}\n" +"`Total Stars: `{}\n" +"`Total Voicetime: `{}\n" +"`Next Reset: `{}" +msgstr "" + +#: levelup\shared\weeklyreset.py:92 +msgid "Top Weekly Exp Earners" +msgstr "" + +#: levelup\shared\weeklyreset.py:106 +msgid "`Experience: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:107 +msgid "`Messages: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:109 +msgid "`Stars: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:111 +msgid "`Voicetime: `{}\n" +msgstr "" + +#: levelup\shared\weeklyreset.py:138 +msgid "Missing permissions to manage roles" +msgstr "" + +#: levelup\shared\weeklyreset.py:150 +msgid "Weekly winner role removal" +msgstr "" + +#: levelup\shared\weeklyreset.py:162 +msgid "Weekly winner role addition" +msgstr "" + +#: levelup\shared\weeklyreset.py:180 +msgid "Weekly stats have been reset." +msgstr "" + diff --git a/levelup/shared/profile.py b/levelup/shared/profile.py new file mode 100644 index 0000000..b262996 --- /dev/null +++ b/levelup/shared/profile.py @@ -0,0 +1,345 @@ +import asyncio +import base64 +import logging +import random +import typing as t +from io import BytesIO +from time import perf_counter + +import aiohttp +import discord +from redbot.core import bank +from redbot.core.i18n import Translator +from redbot.core.utils.chat_formatting import box, humanize_number + +from ..abc import MixinMeta +from ..common import formatter, utils +from ..common.models import Profile +from ..generator.styles import default, runescape + +log = logging.getLogger("red.vrt.levelup.shared.profile") +_ = Translator("LevelUp", __file__) + + +class ProfileFormatting(MixinMeta): + async def add_xp(self, member: discord.Member, xp: int) -> int: + """Add XP to a user and check for level ups""" + if not isinstance(member, discord.Member): + raise TypeError("member must be a discord.Member") + conf = self.db.get_conf(member.guild) + profile = conf.get_profile(member) + profile.xp += xp + self.save() + return int(profile.xp) + + async def set_xp(self, member: discord.Member, xp: int) -> int: + """Set a user's XP and check for level ups""" + if not isinstance(member, discord.Member): + raise TypeError("member must be a discord.Member") + conf = self.db.get_conf(member.guild) + profile = conf.get_profile(member) + profile.xp = xp + self.save() + return int(profile.xp) + + async def remove_xp(self, member: discord.Member, xp: int) -> int: + """Remove XP from a user and check for level ups""" + if not isinstance(member, discord.Member): + raise TypeError("member must be a discord.Member") + conf = self.db.get_conf(member.guild) + profile = conf.get_profile(member) + profile.xp -= xp + self.save() + return int(profile.xp) + + async def get_profile_background( + self, user_id: int, profile: Profile, try_return_url: bool = False + ) -> t.Union[bytes, str]: + """ + Get a background for a user's profile in the following priority: + - Custom background selected by user + - Banner of user's Discord profile + - Random background + """ + if profile.background == "default": + if banner_url := await self.get_banner(user_id): + if try_return_url: + return banner_url + if banner_bytes := await utils.get_content_from_url(banner_url): + return banner_bytes + + if profile.background.lower().startswith("http"): + if try_return_url: + return profile.background + if content := await utils.get_content_from_url(profile.background): + return content + + valid = list(self.backgrounds.glob("*.webp")) + list(self.custom_backgrounds.iterdir()) + if profile.background == "random": + return random.choice(valid).read_bytes() + + # See if filename is specified + for path in valid: + if profile.background == path.stem or profile.background == path.name: + return path.read_bytes() + + # If we're here then the profile's preference failed + # Try banner first if not default + if profile.background != "default": + if banner_url := await self.get_banner(user_id): + if try_return_url: + return banner_url + if banner_bytes := await utils.get_content_from_url(banner_url): + return banner_bytes + + return random.choice(valid).read_bytes() + + async def get_banner(self, user_id: int) -> t.Optional[str]: + """Fetch a user's banner from Discord's API + + Args: + user_id (int): The ID of the user + + Returns: + t.Optional[str]: The URL of the user's banner image, or None if no banner is found + """ + req = await self.bot.http.request(discord.http.Route("GET", "/users/{uid}", uid=user_id)) + if banner_id := req.get("banner"): + return f"https://cdn.discordapp.com/banners/{user_id}/{banner_id}?size=1024" + + async def get_user_profile( + self, member: discord.Member, reraise: bool = False + ) -> t.Union[discord.Embed, discord.File]: + """ + Get a user's profile as an embed or file + If embed profiles are disabled, a file will be returned, otherwise an embed will be returned + + Args: + member (discord.Member): The member to get the profile for + reraise (bool, optional): Fetching profiles will normally catch almost all exceptions and try to + handle them silently, this will make them throw an exception. Defaults to False. + + Returns: + t.Union[discord.Embed, discord.File]: An embed or file containing the user's profile + """ + if not isinstance(member, discord.Member): + raise TypeError("member must be a discord.Member") + guild = member.guild + conf = self.db.get_conf(guild) + profile = conf.get_profile(member) + + last_level_xp = conf.algorithm.get_xp(profile.level) + current_xp = int(profile.xp) + next_level_xp = conf.algorithm.get_xp(profile.level + 1) + log.debug(f"last_level_xp: {last_level_xp}, current_xp: {current_xp}, next_level_xp: {next_level_xp}") + if current_xp >= next_level_xp: + # Rare but possible + log.warning(f"User {member} has more XP than needed for next level") + await self.check_levelups(guild, member, profile, conf) + return await self.get_user_profile(member) + + current_diff = next_level_xp - last_level_xp + progress = current_diff - (next_level_xp - current_xp) + + stat = await asyncio.to_thread( + formatter.get_user_position, + guild=guild, + conf=conf, + lbtype="xp", + target_user=member.id, + key="xp", + ) + bar = utils.get_bar(progress, current_diff) + + level = conf.emojis.get("level", self.bot) + trophy = conf.emojis.get("trophy", self.bot) + star = conf.emojis.get("star", self.bot) + chat = conf.emojis.get("chat", self.bot) + mic = conf.emojis.get("mic", self.bot) + bulb = conf.emojis.get("bulb", self.bot) + money = conf.emojis.get("money", self.bot) + + # Prestige data + pdata = None + if profile.prestige and profile.prestige in conf.prestigedata: + pdata = conf.prestigedata[profile.prestige] + + if conf.use_embeds or self.db.force_embeds: + txt = f"{level}|" + _("Level {}\n").format(humanize_number(profile.level)) + if pdata: + txt += f"{trophy}|" + _("Prestige {}\n").format( + f"{humanize_number(profile.prestige)} {pdata.emoji_string}" + ) + txt += f"{star}|{humanize_number(profile.stars)}" + _(" stars\n") + txt += f"{chat}|{humanize_number(profile.messages)}" + _(" messages sent\n") + txt += f"{mic}|{utils.humanize_delta(profile.voice)}" + _(" in voice\n") + progress_txt = f"{bulb}|{humanize_number(progress)}/{humanize_number(current_diff)}" + txt += progress_txt + _(" Exp ({} total)\n").format(humanize_number(current_xp)) + if conf.showbal: + balance = await bank.get_balance(member) + creditname = await bank.get_currency_name(guild) + txt += f"{money}|{humanize_number(balance)} {creditname}\n" + color = member.color + if profile.statcolor: + color = discord.Color.from_rgb(*utils.string_to_rgb(profile.statcolor)) + elif profile.barcolor: + color = discord.Color.from_rgb(*utils.string_to_rgb(profile.barcolor)) + elif profile.namecolor: + color = discord.Color.from_rgb(*utils.string_to_rgb(profile.namecolor)) + embed = discord.Embed(description=txt, color=color) + embed.set_author( + name=_("{}'s Profile").format(member.display_name if profile.show_displayname else member.name), + icon_url=member.display_avatar, + ) + embed.set_footer( + text=_("Rank {}, with {}% of the total server Exp").format( + humanize_number(stat["position"]), round(stat["percent"], 1) + ), + icon_url=guild.icon, + ) + embed.add_field(name=_("Progress"), value=box(bar, lang="python"), inline=False) + return embed + + kwargs = { + "username": member.display_name if profile.show_displayname else member.name, + "status": str(member.status).strip(), + "level": profile.level, + "messages": profile.messages, + "voicetime": profile.voice, + "stars": profile.stars, + "prestige": profile.prestige, + "previous_xp": last_level_xp, + "current_xp": current_xp, + "next_xp": next_level_xp, + "position": stat["position"], + "blur": profile.blur, + "base_color": member.color.to_rgb() if member.color.to_rgb() != (0, 0, 0) else None, + "user_color": utils.string_to_rgb(profile.namecolor) if profile.namecolor else None, + "stat_color": utils.string_to_rgb(profile.statcolor) if profile.statcolor else None, + "level_bar_color": utils.string_to_rgb(profile.barcolor) if profile.barcolor else None, + "render_gif": self.db.render_gifs, + "reraise": reraise, + } + + profile_style = conf.style_override or profile.style + if self.db.external_api_url or (self.db.internal_api_port and self.api_proc): + # We'll use the external/internal API, try to get URLs instead for faster http requests + kwargs["avatar_bytes"] = member.display_avatar.url + if profile_style != "runescape": + kwargs["background_bytes"] = await self.get_profile_background(member.id, profile, try_return_url=True) + if pdata and pdata.emoji_url: + kwargs["prestige_emoji"] = pdata.emoji_url + if member.top_role.icon: + kwargs["role_icon"] = member.top_role.icon.url + else: + kwargs["avatar_bytes"] = await member.display_avatar.read() + if profile_style != "runescape": + kwargs["background_bytes"] = await self.get_profile_background(member.id, profile) + if pdata and pdata.emoji_url: + emoji_bytes = await utils.get_content_from_url(pdata.emoji_url) + kwargs["prestige_emoji"] = emoji_bytes + if member.top_role.icon: + kwargs["role_icon"] = await member.top_role.icon.read() + + if profile.font: + if (self.fonts / profile.font).exists(): + kwargs["font_path"] = str(self.fonts / profile.font) + elif (self.custom_fonts / profile.font).exists(): + kwargs["font_path"] = str(self.custom_fonts / profile.font) + + if conf.showbal: + kwargs["balance"] = await bank.get_balance(member) + kwargs["currency_name"] = await bank.get_currency_name(guild) + + if background_bytes := kwargs.get("background_bytes"): + # Sometimes discord's CDN returns b'This content is no longer available.' + # If this occurs we'll reset the background to default + if "This content is no longer available." in str(background_bytes): + profile.background = "default" + self.save() + log.warning( + f"User {member.name} ({member.id}) has a background that no longer exists! Resetting to default" + ) + kwargs["background_bytes"] = await self.get_profile_background(member.id, profile) + + endpoints = { + "default": "fullprofile", + "runescape": "runescape", + } + payload = aiohttp.FormData() + if self.db.external_api_url or (self.db.internal_api_port and self.api_proc): + for key, value in kwargs.items(): + if value is None: + continue + if isinstance(value, bytes): + payload.add_field(key, value, filename="data") + else: + payload.add_field(key, str(value)) + + if external_url := self.db.external_api_url: + try: + url = f"{external_url}/{endpoints[profile_style]}" + async with aiohttp.ClientSession() as session: + async with session.post(url, data=payload) as response: + if response.status == 200: + data = await response.json() + img_b64, animated = data["b64"], data["animated"] + img_bytes = base64.b64decode(img_b64) + ext = "gif" if animated else "webp" + return discord.File(BytesIO(img_bytes), filename=f"profile.{ext}") + log.error(f"Failed to fetch profile from external API: {response.status}") + except Exception as e: + log.error("Failed to fetch profile from external API", exc_info=e) + elif self.db.internal_api_port and self.api_proc: + try: + url = f"http://127.0.0.1:{self.db.internal_api_port}/{endpoints[profile_style]}" + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.post(url, data=payload, ssl=False) as response: + if response.status == 200: + data = await response.json() + img_b64, animated = data["b64"], data["animated"] + img_bytes = base64.b64decode(img_b64) + ext = "gif" if animated else "webp" + return discord.File(BytesIO(img_bytes), filename=f"profile.{ext}") + log.error(f"Failed to fetch profile from internal API: {response.status}") + except Exception as e: + log.error("Failed to fetch profile from internal API", exc_info=e) + + # By default we'll use the bundled generator + funcs = { + "default": default.generate_default_profile, + "runescape": runescape.generate_runescape_profile, + } + + def _run() -> discord.File: + img_bytes, animated = funcs[profile_style](**kwargs) + ext = "gif" if animated else "webp" + return discord.File(BytesIO(img_bytes), filename=f"profile.{ext}") + + file = await asyncio.to_thread(_run) + return file + + async def get_user_profile_cached(self, member: discord.Member) -> t.Union[discord.File, discord.Embed]: + """Cached version of get_user_profile""" + if not self.db.cache_seconds: + return await self.get_user_profile(member) + now = perf_counter() + cachedata = self.profile_cache.setdefault(member.guild.id, {}).get(member.id) + if cachedata is None: + file = await self.get_user_profile(member) + if not isinstance(file, discord.File): + return file + filebytes = file.fp.read() + self.profile_cache[member.guild.id][member.id] = (now, filebytes) + return discord.File(BytesIO(filebytes), filename="profile.webp") + + last_used, imgbytes = cachedata + if last_used and now - last_used < self.db.cache_seconds: + return discord.File(BytesIO(imgbytes), filename="profile.webp") + + file = await self.get_user_profile(member) + if not isinstance(file, discord.File): + return file + filebytes = file.fp.read() + self.profile_cache[member.guild.id][member.id] = (now, filebytes) + return discord.File(BytesIO(filebytes), filename="profile.webp") diff --git a/levelup/shared/weeklyreset.py b/levelup/shared/weeklyreset.py new file mode 100644 index 0000000..6fa2bd4 --- /dev/null +++ b/levelup/shared/weeklyreset.py @@ -0,0 +1,182 @@ +import logging +import typing as t +from contextlib import suppress +from datetime import timedelta +from io import StringIO + +import discord +from redbot.core import commands +from redbot.core.i18n import Translator +from redbot.core.utils.chat_formatting import humanize_number + +from ..abc import MixinMeta +from ..common import utils +from ..common.models import ProfileWeekly + +log = logging.getLogger("red.vrt.levelup.shared.weeklyreset") +_ = Translator("LevelUp", __file__) + + +class WeeklyReset(MixinMeta): + async def reset_weekly(self, guild: discord.Guild, ctx: commands.Context = None) -> bool: + """Announce and reset weekly stats + + Args: + guild (discord.Guild): The guild where the weekly stats are being reset. + ctx (commands.Context, optional): Sends the announcement embed in the current channel. Defaults to None. + + Returns: + bool: True if the weekly stats were reset, False otherwise. + """ + log.warning(f"Resetting weekly stats for {guild.name}") + conf = self.db.get_conf(guild) + if not conf.users_weekly: + log.info("No users in the weekly data") + if ctx: + await ctx.send(_("There are no users in the weekly data yet")) + conf.weeklysettings.refresh() + self.save() + return False + + valid_users: t.Dict[discord.Member, ProfileWeekly] = {} + for user_id, stats in conf.users_weekly.items(): + user = guild.get_member(user_id) + if user and stats.xp > 0: + valid_users[user] = stats + + if not valid_users: + log.info("No users with XP in the weekly data") + if ctx: + await ctx.send(_("There are no users with XP in the weekly data yet")) + conf.weeklysettings.refresh() + self.save() + return False + + channel = guild.get_channel(conf.weeklysettings.channel) if conf.weeklysettings.channel else None + + total_xp = 0 + total_messages = 0 + total_voicetime = 0 + total_stars = 0 + for stats in valid_users.values(): + total_xp += stats.xp + total_messages += stats.messages + total_voicetime += stats.voice + total_stars += stats.stars + total_xp = humanize_number(int(total_xp)) + total_messages = humanize_number(total_messages) + total_voicetime = utils.humanize_delta(total_voicetime) + total_stars = humanize_number(total_stars) + + next_reset = int(conf.weeklysettings.next_reset + timedelta(days=7).total_seconds()) + + embed = discord.Embed( + description=_( + "`Total Exp: `{}\n" + "`Total Messages: `{}\n" + "`Total Stars: `{}\n" + "`Total Voicetime: `{}\n" + "`Next Reset: `{}" + ).format( + total_xp, + total_messages, + total_stars, + total_voicetime, + f"", + ), + color=await self.bot.get_embed_color(channel or ctx), + ) + embed.set_author( + name=_("Top Weekly Exp Earners"), + icon_url=guild.icon, + ) + embed.set_thumbnail(url=guild.icon) + place_emojis = ["🥇", "🥈", "🥉"] + sorted_users = sorted(valid_users.items(), key=lambda x: x[1].xp, reverse=True) + top_user_ids = [] + for idx, (user, stats) in enumerate(sorted_users): + place = idx + 1 + if place > conf.weeklysettings.count: + break + top_user_ids.append(user.id) + position = place_emojis[idx] if idx < 3 else f"#{place}." + tmp = StringIO() + tmp.write(_("`Experience: `{}\n").format(humanize_number(round(stats.xp)))) + tmp.write(_("`Messages: `{}\n").format(humanize_number(stats.messages))) + if stats.stars: + tmp.write(_("`Stars: `{}\n").format(humanize_number(stats.stars))) + if round(stats.voice): + tmp.write(_("`Voicetime: `{}\n").format(utils.humanize_delta(round(stats.voice)))) + embed.add_field(name=f"{position} {user.display_name}", value=tmp.getvalue(), inline=False) + + if ctx: + with suppress(discord.HTTPException): + await ctx.send(embed=embed) + + command_channel_id = ctx.channel.id if ctx else 0 + if channel and command_channel_id != channel.id: + mentions = ", ".join(f"<@{uid}>" for uid in top_user_ids) + with suppress(discord.HTTPException): + if conf.weeklysettings.ping_winners: + await channel.send(mentions, embed=embed) + else: + await channel.send(embed=embed) + + top: t.List[t.Tuple[discord.Member, ProfileWeekly]] = sorted_users[: conf.weeklysettings.count] + + if conf.weeklysettings.role_all: + winners: t.List[discord.Member] = [user[0] for user in top] + else: + winners: t.List[discord.Member] = [top[0][0]] + + winner_ids = [user.id for user in winners] + + perms = guild.me.guild_permissions.manage_roles + if not perms: + log.warning("Missing permissions to manage roles") + if ctx: + await ctx.send(_("Missing permissions to manage roles")) + + role = guild.get_role(conf.weeklysettings.role) if conf.weeklysettings.role else None + if role and perms: + if conf.weeklysettings.remove: + for user_id in conf.weeklysettings.last_winners: + user = guild.get_member(user_id) + if not user: + continue + user_roles = [role.id for role in user.roles] + if role.id in user_roles and user.id not in winner_ids: + try: + await user.remove_roles(role, reason=_("Weekly winner role removal")) + except discord.Forbidden: + log.warning(f"Missing permissions to apply role {role} to {user.name}") + except discord.HTTPException: + pass + + for winner in winners: + role_ids = [role.id for role in winner.roles] + if role.id in role_ids: + # User already has the role + continue + try: + await winner.add_roles(role, reason=_("Weekly winner role addition")) + except discord.Forbidden: + log.warning(f"Missing permissions to apply role {role} to {winner}") + except discord.HTTPException: + pass + + conf.weeklysettings.last_winners = [user[0].id for user in top] + + if bonus := conf.weeklysettings.bonus: + for i in top: + profile = conf.get_profile(i[0]) + profile.xp += bonus + + conf.weeklysettings.refresh() + conf.users_weekly.clear() + conf.weeklysettings.last_embed = embed.to_dict() + self.save() + if ctx: + await ctx.send(_("Weekly stats have been reset.")) + log.info(f"Reset weekly stats for {guild.name}") + return True diff --git a/levelup/tasks/__init__.py b/levelup/tasks/__init__.py new file mode 100644 index 0000000..881badf --- /dev/null +++ b/levelup/tasks/__init__.py @@ -0,0 +1,16 @@ +from ..abc import CompositeMetaClass +from .weekly import WeeklyTask + + +class Tasks(WeeklyTask, metaclass=CompositeMetaClass): + """ + Subclass all shared metaclassed parts of the cog + + This includes all task loops for LevelUp + """ + + def start_levelup_tasks(self): + self.weekly_reset_check.start() + + def stop_levelup_tasks(self): + self.weekly_reset_check.cancel() diff --git a/levelup/tasks/locales/de-DE.po b/levelup/tasks/locales/de-DE.po new file mode 100644 index 0000000..e3fe276 --- /dev/null +++ b/levelup/tasks/locales/de-DE.po @@ -0,0 +1,27 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/tasks/locales/messages.pot\n" +"X-Crowdin-File-ID: 188\n" +"Language: de_DE\n" + +#: levelup\tasks\__init__.py:6 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes all task loops for LevelUp\n" +" " +msgstr "" + diff --git a/levelup/tasks/locales/es-ES.po b/levelup/tasks/locales/es-ES.po new file mode 100644 index 0000000..63e6c61 --- /dev/null +++ b/levelup/tasks/locales/es-ES.po @@ -0,0 +1,30 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/tasks/locales/messages.pot\n" +"X-Crowdin-File-ID: 188\n" +"Language: es_ES\n" + +#: levelup\tasks\__init__.py:6 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes all task loops for LevelUp\n" +" " +msgstr "\n" +" Subclase todas las partes metaclasificadas compartidas del engranaje\n\n" +" Esto incluye todos los bucles de tareas para LevelUp\n" +" " + diff --git a/levelup/tasks/locales/fr-FR.po b/levelup/tasks/locales/fr-FR.po new file mode 100644 index 0000000..aa3ca46 --- /dev/null +++ b/levelup/tasks/locales/fr-FR.po @@ -0,0 +1,27 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/tasks/locales/messages.pot\n" +"X-Crowdin-File-ID: 188\n" +"Language: fr_FR\n" + +#: levelup\tasks\__init__.py:6 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes all task loops for LevelUp\n" +" " +msgstr "" + diff --git a/levelup/tasks/locales/hr-HR.po b/levelup/tasks/locales/hr-HR.po new file mode 100644 index 0000000..19dc9dc --- /dev/null +++ b/levelup/tasks/locales/hr-HR.po @@ -0,0 +1,27 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: hr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/tasks/locales/messages.pot\n" +"X-Crowdin-File-ID: 188\n" +"Language: hr_HR\n" + +#: levelup\tasks\__init__.py:6 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes all task loops for LevelUp\n" +" " +msgstr "" + diff --git a/levelup/tasks/locales/ko-KR.po b/levelup/tasks/locales/ko-KR.po new file mode 100644 index 0000000..538e715 --- /dev/null +++ b/levelup/tasks/locales/ko-KR.po @@ -0,0 +1,27 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Korean\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ko\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/tasks/locales/messages.pot\n" +"X-Crowdin-File-ID: 188\n" +"Language: ko_KR\n" + +#: levelup\tasks\__init__.py:6 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes all task loops for LevelUp\n" +" " +msgstr "" + diff --git a/levelup/tasks/locales/messages.pot b/levelup/tasks/locales/messages.pot new file mode 100644 index 0000000..fa84b33 --- /dev/null +++ b/levelup/tasks/locales/messages.pot @@ -0,0 +1,22 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" + +#: levelup\tasks\__init__.py:6 +#, docstring +msgid "" +"\n" +" Subclass all shared metaclassed parts of the cog\n" +"\n" +" This includes all task loops for LevelUp\n" +" " +msgstr "" diff --git a/levelup/tasks/locales/pt-PT.po b/levelup/tasks/locales/pt-PT.po new file mode 100644 index 0000000..714b9ce --- /dev/null +++ b/levelup/tasks/locales/pt-PT.po @@ -0,0 +1,27 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: pt-PT\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/tasks/locales/messages.pot\n" +"X-Crowdin-File-ID: 188\n" +"Language: pt_PT\n" + +#: levelup\tasks\__init__.py:6 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes all task loops for LevelUp\n" +" " +msgstr "" + diff --git a/levelup/tasks/locales/ru-RU.po b/levelup/tasks/locales/ru-RU.po new file mode 100644 index 0000000..e65c661 --- /dev/null +++ b/levelup/tasks/locales/ru-RU.po @@ -0,0 +1,27 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/tasks/locales/messages.pot\n" +"X-Crowdin-File-ID: 188\n" +"Language: ru_RU\n" + +#: levelup\tasks\__init__.py:6 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes all task loops for LevelUp\n" +" " +msgstr "" + diff --git a/levelup/tasks/locales/tr-TR.po b/levelup/tasks/locales/tr-TR.po new file mode 100644 index 0000000..c9c6282 --- /dev/null +++ b/levelup/tasks/locales/tr-TR.po @@ -0,0 +1,27 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: tr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/tasks/locales/messages.pot\n" +"X-Crowdin-File-ID: 188\n" +"Language: tr_TR\n" + +#: levelup\tasks\__init__.py:6 +#, docstring +msgid "\n" +" Subclass all shared metaclassed parts of the cog\n\n" +" This includes all task loops for LevelUp\n" +" " +msgstr "" + diff --git a/levelup/tasks/weekly.py b/levelup/tasks/weekly.py new file mode 100644 index 0000000..d8d536d --- /dev/null +++ b/levelup/tasks/weekly.py @@ -0,0 +1,54 @@ +import asyncio +import logging +import typing as t +from datetime import datetime + +import discord +from discord.ext import tasks +from redbot.core.i18n import Translator + +from ..abc import MixinMeta + +log = logging.getLogger("red.vrt.levelup.tasks.weekly") +_ = Translator("LevelUp", __file__) + +loop_kwargs = {"minutes": 5} +if discord.version_info >= (2, 4, 0): + loop_kwargs["name"] = "LevelUp.weekly_reset_check" + + +class WeeklyTask(MixinMeta): + @tasks.loop(**loop_kwargs) + async def weekly_reset_check(self): + now = datetime.now().timestamp() + guild_ids = list(self.db.configs.keys()) + jobs: t.List[asyncio.Task] = [] + for guild_id in guild_ids: + guild = self.bot.get_guild(guild_id) + if not guild: + continue + conf = self.db.configs[guild_id] + if not conf.weeklysettings.on: + continue + if not conf.users_weekly: + continue + if not conf.weeklysettings.autoreset: + continue + last_reset = conf.weeklysettings.last_reset + next_reset = conf.weeklysettings.next_reset + # Skip if stats were wiped less than an hour ago + if now - last_reset < 3600: + continue + # If we're within 6 minutes of the reset time, reset now + if next_reset - now > 360: + continue + jobs.append(self.reset_weekly(guild)) + + if jobs: + await asyncio.gather(*jobs) + + @weekly_reset_check.before_loop + async def before_weekly_reset_check(self): + await self.bot.wait_until_red_ready() + await asyncio.sleep(10) + log.info("Starting weekly reset check loop") diff --git a/levelup/views/dynamic_menu.py b/levelup/views/dynamic_menu.py new file mode 100644 index 0000000..8d04904 --- /dev/null +++ b/levelup/views/dynamic_menu.py @@ -0,0 +1,289 @@ +import asyncio +import logging +import typing as t +from contextlib import suppress +from io import BytesIO + +import discord +from rapidfuzz import fuzz +from redbot.core import commands +from redbot.core.i18n import Translator + +_ = Translator("LevelUp", __file__) +log = logging.getLogger("red.vrt.levelup.dynamic_menu") + + +class SearchModal(discord.ui.Modal): + def __init__(self, current: t.Optional[str] = None): + super().__init__(title="Search", timeout=240) + self.query = current + self.input = discord.ui.TextInput(label=_("Enter Search Query or Page"), default=current) + self.add_item(self.input) + + async def on_submit(self, interaction: discord.Interaction): + self.query = self.input.value + await interaction.response.defer() + self.stop() + + +class DynamicMenu(discord.ui.View): + def __init__( + self, + ctx: commands.Context, + pages: t.Union[t.List[discord.Embed], t.List[str]], + message: t.Optional[t.Union[discord.Message, discord.InteractionMessage, None]] = None, + page: int = 0, + timeout: t.Union[int, float, None] = 300, + image_bytes: t.Optional[bytes] = None, + ): + super().__init__(timeout=timeout) + self.check_pages(pages) # Modifies pages in place + + self.ctx = ctx + self.author = ctx.author + self.channel = ctx.channel + self.guild = ctx.guild + self.pages = pages + self.message = message + self.page = page + self.image_bytes = image_bytes + self.page_count = len(pages) + + def check_pages(self, pages: t.List[t.Union[discord.Embed, str]]): + # Ensure pages are either all embeds or all strings + if isinstance(pages[0], discord.Embed): + if not all(isinstance(page, discord.Embed) for page in pages): + raise TypeError("All pages must be Embeds or strings.") + # If the first page has no footer, add one to all pages for page number + if pages[0].footer: + return + page_count = len(pages) + for idx in range(len(pages)): + pages[idx].set_footer(text=f"Page {idx + 1}/{page_count}") + else: + if not all(isinstance(page, str) for page in pages): + raise TypeError("All pages must be Embeds or strings.") + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author.id: + await interaction.response.send_message("This isn't your menu!", ephemeral=True) + return False + return True + + async def on_timeout(self) -> None: + if self.message: + with suppress(discord.NotFound, discord.Forbidden, discord.HTTPException): + await self.message.edit(view=None) + await self.ctx.tick() + + async def refresh(self, interaction: discord.Interaction = None): + """Call this to start and refresh the menu.""" + try: + await self.__refresh(interaction) + except Exception as e: + current_page = self.pages[self.page] + if isinstance(current_page, discord.Embed): + content = current_page.description or current_page.title + if not content: + content = "" + for field in current_page.fields: + content += f"{field.name}\n{field.value}\n" + else: + content = current_page + log.error(f"Error refreshing menu, current page: {content}", exc_info=e) + + async def __refresh(self, interaction: discord.Interaction = None): + self.clear_items() + single = [self.close] + small = [self.left] + single + [self.right] + large = small + [self.left10, self.search, self.right10] + + buttons = large if self.page_count > 10 else small if self.page_count > 1 else single + for button in buttons: + self.add_item(button) + + if len(buttons) == 1 and isinstance(self.pages[self.page], discord.Embed): + for embed in self.pages: + embed.set_footer(text=None) + + attachments = [] + file = None + if self.image_bytes: + file = discord.File(BytesIO(self.image_bytes), filename="image.webp") + attachments.append(file) + + kwargs = {"view": self} + if isinstance(self.pages[self.page], discord.Embed): + kwargs["embed"] = self.pages[self.page] + kwargs["content"] = None + else: + kwargs["content"] = self.pages[self.page] + + if (self.message or interaction) and attachments: + kwargs["attachments"] = attachments + elif (not self.message and not interaction) and file: + kwargs["file"] = file # Need to send new message + + if interaction and self.message is not None: + # We are refreshing due to a button press + if not interaction.response.is_done(): + try: + await interaction.response.edit_message(**kwargs) + return self + except discord.HTTPException: + try: + await self.message.edit(**kwargs) + except discord.HTTPException: + kwargs.pop("attachments", None) + kwargs["file"] = file + self.message = await self.ctx.send(**kwargs) + else: + try: + await interaction.edit_original_response(**kwargs) + return self + except discord.HTTPException: + try: + await self.message.edit(**kwargs) + except discord.HTTPException: + kwargs.pop("attachments", None) + kwargs["file"] = file + self.message = await self.ctx.send(**kwargs) + return self + + if self.message: + try: + await self.message.edit(**kwargs) + except discord.HTTPException: + kwargs.pop("attachments", None) + kwargs["file"] = file + self.message = await self.ctx.send(**kwargs) + else: + self.message = await self.ctx.send(**kwargs) + return self + + @discord.ui.button( + emoji="\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}", + style=discord.ButtonStyle.primary, + row=1, + ) + async def left10(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page -= 10 + self.page %= self.page_count + await self.refresh(interaction) + + @discord.ui.button( + emoji="\N{LEFTWARDS BLACK ARROW}\N{VARIATION SELECTOR-16}", + style=discord.ButtonStyle.primary, + ) + async def left(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page -= 1 + self.page %= self.page_count + await self.refresh(interaction) + + @discord.ui.button( + emoji="\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}", + style=discord.ButtonStyle.danger, + ) + async def close(self, interaction: discord.Interaction, button: discord.ui.Button): + with suppress(discord.HTTPException): + await interaction.response.defer() + try: + await interaction.delete_original_response() + except discord.HTTPException: + if self.message: + with suppress(discord.HTTPException): + await self.message.delete() + self.stop() + + @discord.ui.button( + emoji="\N{BLACK RIGHTWARDS ARROW}\N{VARIATION SELECTOR-16}", + style=discord.ButtonStyle.primary, + ) + async def right(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page += 1 + self.page %= self.page_count + await self.refresh(interaction) + + @discord.ui.button( + emoji="\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}", + style=discord.ButtonStyle.primary, + row=1, + ) + async def right10(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page += 10 + self.page %= self.page_count + await self.refresh(interaction) + + @discord.ui.button( + emoji="\N{LEFT-POINTING MAGNIFYING GLASS}", + style=discord.ButtonStyle.secondary, + row=1, + ) + async def search(self, interaction: discord.Interaction, button: discord.ui.Button): + modal = SearchModal(str(self.page + 1)) + await interaction.response.send_modal(modal) + await modal.wait() + + if modal.query is None: + return + + if modal.query.isnumeric(): + self.page = int(modal.query) - 1 + self.page %= self.page_count + return await self.refresh(interaction) + + if isinstance(self.pages[self.page], str): + for i, page in enumerate(self.pages): + if modal.query.casefold() in page.casefold(): + self.page = i + return await self.refresh(interaction) + with suppress(discord.HTTPException): + await interaction.followup.send(_("No page found matching that query."), ephemeral=True) + return + + # Pages are embeds + for i, embed in enumerate(self.pages): + if embed.title and modal.query.casefold() in embed.title.casefold(): + self.page = i + return await self.refresh(interaction) + if modal.query.casefold() in embed.description.casefold(): + self.page = i + return await self.refresh(interaction) + if embed.footer and modal.query.casefold() in embed.footer.text.casefold(): + self.page = i + return await self.refresh(interaction) + for field in embed.fields: + if modal.query.casefold() in field.name.casefold(): + self.page = i + return await self.refresh(interaction) + if modal.query.casefold() in field.value.casefold(): + self.page = i + return await self.refresh(interaction) + + # No results found, resort to fuzzy matching + def _fuzzymatch() -> t.List[t.Tuple[int, int]]: + # [(match, index)] + matches: t.List[t.Tuple[int, int]] = [] + for i, embed in enumerate(self.pages): + matches.append((fuzz.ratio(modal.query.lower(), embed.title.lower()), i)) + matches.append((fuzz.ratio(modal.query.lower(), embed.description.lower()), i)) + if embed.footer: + matches.append((fuzz.ratio(modal.query.lower(), embed.footer.text.lower()), i)) + for field in embed.fields: + matches.append((fuzz.ratio(modal.query.lower(), field.name.lower()), i)) + matches.append((fuzz.ratio(modal.query.lower(), field.value.lower()), i)) + if matches: + matches.sort(key=lambda x: x[0], reverse=True) + return matches + + matches = await asyncio.to_thread(_fuzzymatch) + + # Sort by best match + best_score, best_index = matches[0] + if best_score < 50: + with suppress(discord.HTTPException): + await interaction.followup.send(_("No page found matching that query."), ephemeral=True) + return + self.page = best_index + await self.refresh(interaction) + await interaction.followup.send(_("Found closest match of {}%").format(int(best_score)), ephemeral=True) diff --git a/levelup/views/locales/de-DE.po b/levelup/views/locales/de-DE.po new file mode 100644 index 0000000..9fadcbd --- /dev/null +++ b/levelup/views/locales/de-DE.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/views/locales/messages.pot\n" +"X-Crowdin-File-ID: 176\n" +"Language: de_DE\n" + diff --git a/levelup/views/locales/es-ES.po b/levelup/views/locales/es-ES.po new file mode 100644 index 0000000..7eba975 --- /dev/null +++ b/levelup/views/locales/es-ES.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/views/locales/messages.pot\n" +"X-Crowdin-File-ID: 176\n" +"Language: es_ES\n" + diff --git a/levelup/views/locales/fr-FR.po b/levelup/views/locales/fr-FR.po new file mode 100644 index 0000000..40aeda5 --- /dev/null +++ b/levelup/views/locales/fr-FR.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/views/locales/messages.pot\n" +"X-Crowdin-File-ID: 176\n" +"Language: fr_FR\n" + diff --git a/levelup/views/locales/hr-HR.po b/levelup/views/locales/hr-HR.po new file mode 100644 index 0000000..8874bc3 --- /dev/null +++ b/levelup/views/locales/hr-HR.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Croatian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: hr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/views/locales/messages.pot\n" +"X-Crowdin-File-ID: 176\n" +"Language: hr_HR\n" + diff --git a/levelup/views/locales/ko-KR.po b/levelup/views/locales/ko-KR.po new file mode 100644 index 0000000..5a37331 --- /dev/null +++ b/levelup/views/locales/ko-KR.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Korean\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ko\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/views/locales/messages.pot\n" +"X-Crowdin-File-ID: 176\n" +"Language: ko_KR\n" + diff --git a/levelup/views/locales/messages.pot b/levelup/views/locales/messages.pot new file mode 100644 index 0000000..4651114 --- /dev/null +++ b/levelup/views/locales/messages.pot @@ -0,0 +1,12 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2024-07-24 17:08-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" diff --git a/levelup/views/locales/pt-PT.po b/levelup/views/locales/pt-PT.po new file mode 100644 index 0000000..9dcfec8 --- /dev/null +++ b/levelup/views/locales/pt-PT.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Portuguese\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: pt-PT\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/views/locales/messages.pot\n" +"X-Crowdin-File-ID: 176\n" +"Language: pt_PT\n" + diff --git a/levelup/views/locales/ru-RU.po b/levelup/views/locales/ru-RU.po new file mode 100644 index 0000000..a131c0f --- /dev/null +++ b/levelup/views/locales/ru-RU.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/views/locales/messages.pot\n" +"X-Crowdin-File-ID: 176\n" +"Language: ru_RU\n" + diff --git a/levelup/views/locales/tr-TR.po b/levelup/views/locales/tr-TR.po new file mode 100644 index 0000000..0bcc6d2 --- /dev/null +++ b/levelup/views/locales/tr-TR.po @@ -0,0 +1,19 @@ +msgid "" +msgstr "" +"Project-Id-Version: vrt-cogs\n" +"POT-Creation-Date: 2024-06-18 16:29-0400\n" +"PO-Revision-Date: 2024-12-03 14:59\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: redgettext 3.4.2\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: vrt-cogs\n" +"X-Crowdin-Project-ID: 550681\n" +"X-Crowdin-Language: tr\n" +"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/views/locales/messages.pot\n" +"X-Crowdin-File-ID: 176\n" +"Language: tr_TR\n" + diff --git a/repolist/README.md b/repolist/README.md new file mode 100644 index 0000000..f7a1c60 --- /dev/null +++ b/repolist/README.md @@ -0,0 +1,2 @@ +# repolist +List all installed repos and their available cogs in one command. It only has one command: `[p]repolist`. diff --git a/repolist/__init__.py b/repolist/__init__.py new file mode 100644 index 0000000..634d627 --- /dev/null +++ b/repolist/__init__.py @@ -0,0 +1,12 @@ +import json +from pathlib import Path + +from redbot.core.bot import Red + +from .repolist import RepoList + +with open(Path(__file__).parent / "info.json") as fp: + __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] + +async def setup(bot: Red) -> None: + await bot.add_cog(RepoList(bot)) diff --git a/repolist/info.json b/repolist/info.json new file mode 100644 index 0000000..28134da --- /dev/null +++ b/repolist/info.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/Cog-Creators/Red-DiscordBot/V3/develop/schema/red_cog.schema.json", + "name": "RepoList", + "short": "List all installed repos and their available cogs in one command", + "description": "List all installed repos and their available cogs in one command", + "end_user_data_statement": "This cog does not persistently store any data or metadata about users.", + "author": [ + "Mr. 42" + ], + "required_cogs": {}, + "requirements": [], + "tags": [ + "tools" + ], + "min_bot_version": "3.5.0", + "hidden": false, + "disabled": false, + "type": "COG" +} diff --git a/repolist/repolist.py b/repolist/repolist.py new file mode 100644 index 0000000..446fb96 --- /dev/null +++ b/repolist/repolist.py @@ -0,0 +1,57 @@ +from inspect import getfile +from redbot.core import checks, commands +from redbot.core.bot import Red +from redbot.core.i18n import Translator +from redbot.core.utils.chat_formatting import box, pagify + +class RepoList(commands.Cog): + """List all installed repos and their available cogs in one command.""" + def __init__(self, bot: Red) -> None: + self.bot = bot + + @checks.is_owner() + @commands.command() + async def repolist(self, ctx: commands.Context) -> None: + """List all installed repos and their available cogs.""" + cog = self.bot.get_cog("Downloader") + _ = Translator("Downloader", getfile(cog.__class__)) + repos = cog._repo_manager.repos + sorted_repos = sorted(repos, key=lambda r: str.lower(r.name)) + if len(repos) == 0: + await ctx.send(box(_("There are no repos installed."))) + else: + for repo in sorted_repos: + sort_function = lambda x: x.name.lower() + all_installed_cogs = await cog.installed_cogs() + installed_cogs_in_repo = [cog for cog in all_installed_cogs if cog.repo_name == repo.name] + installed_str = "\n".join( + "- {}{}".format(i.name, ": {}".format(i.short) if i.short else "") + for i in sorted(installed_cogs_in_repo, key=sort_function) + ) + + if len(installed_cogs_in_repo) > 1: + installed_str = _("# Installed Cogs\n{text}").format(text=installed_str) + elif installed_cogs_in_repo: + installed_str = _("# Installed Cog\n{text}").format(text=installed_str) + + available_cogs = [ + cog for cog in repo.available_cogs if not (cog.hidden or cog in installed_cogs_in_repo) + ] + available_str = "\n".join( + "+ {}{}".format(cog.name, ": {}".format(cog.short) if cog.short else "") + for cog in sorted(available_cogs, key=sort_function) + ) + + if not available_str: + cogs = _("> Available Cogs\nNo cogs are available.") + elif len(available_cogs) > 1: + cogs = _("> Available Cogs\n{text}").format(text=available_str) + else: + cogs = _("> Available Cog\n{text}").format(text=available_str) + header = "{}: {}\n{}".format(repo.name, repo.short or "", repo.url) + cogs = header + "\n\n" + cogs + "\n\n" + installed_str + for page in pagify(cogs, ["\n"], shorten_by=16): + await ctx.send(box(page.lstrip(" "), lang="markdown")) + + async def red_delete_data_for_user(self, **kwargs) -> None: + pass