Skip to content

Commit

Permalink
fix: tr system overhaul, fixing the transformation issues
Browse files Browse the repository at this point in the history
  • Loading branch information
Sv443 committed Jan 4, 2025
1 parent 6c13e81 commit 4c3d931
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 72 deletions.
16 changes: 8 additions & 8 deletions src/assets/translations/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,17 @@
},
"info": {
"name": "info",
"version": "Version: ${version}",
"createdBy": "Created by [<authorName>](${authorUrl})",
"globalOptOut": "Opt out of automatic replies across every server by using the command `/settings set auto_reply new_value:false`",
"bugsLink": "- Submit bugs or feature requests on [GitHub](${bugsUrl})",
"supportServerLink": "- Join the [support server](${supportServerInviteUrl}) if you have any questions or need help",
"donationLink": "- This bot is completely free so if you are able to, please consider [supporting the development ❤️](${fundingUrl})",
"installExtensions": "Install the browser extensions to improve your experience with YouTube:",
"headline": "Version ${version} - created by [${name}.](${url})",
"donationLink": "This bot is completely free so if you are able to, please consider [supporting the development ❤️](${url})",
"bugsLink": "- Submit bugs or feature requests [on GitHub](${url})",
"supportServerLink": "- [Join the support server](${supportServerInviteUrl}) if you have any questions or need help",
"globalOptOut": "- Opt out of automatic replies across every server by using the command `/settings set auto_reply new_value:false`",
"installExtensions": "Install these browser extensions to improve your experience with YouTube:",
"installExtReturnYtDislike": "- [ReturnYoutubeDislike](https://returnyoutubedislike.com/) - shows approximate likes and dislikes on videos",
"installExtSponsorBlock": "- [SponsorBlock](https://sponsor.ajay.app/) - shows segments and chapters to automatically or manually skip",
"installExtDeArrow": "- [DeArrow](https://dearrow.ajay.app/) - shows crowdsourced thumbnails and titles to reduce clickbait",
"poweredBy": "This bot wouldn't have been possible without the APIs from [ReturnYoutubeDislike](https://returnyoutubedislike.com/), [SponsorBlock](https://sponsor.ajay.app/), and [DeArrow.](https://dearrow.ajay.app/)"
"installExtMobile": "On Firefox Beta for Android, you can install these extensions by [creating your own extension collection.](https://www.androidpolice.com/install-add-on-extension-mozilla-firefox-android/)",
"poweredBy": "This bot wouldn't have been possible without the APIs of [ReturnYoutubeDislike](https://returnyoutubedislike.com/), [SponsorBlock](https://sponsor.ajay.app/), and [DeArrow.](https://dearrow.ajay.app/)"
},
"embedTitles": {
"commands": "Commands:",
Expand Down
43 changes: 24 additions & 19 deletions src/commands/Help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class HelpCmd extends SlashCommand {
.setDescriptionLocalizations(getLocMap("commands.help.descriptions.command"))
.addSubcommand(subcommand =>
subcommand
.setName(CmdBase.getCmdName(tr.for("en-US", "commands.help.names.subcmd.commands")))
.setName(tr.for("en-US", "commands.help.names.subcmd.commands"))
.setNameLocalizations(getLocMap("commands.help.names.subcmd.commands"))
.setDescription(tr.for("en-US", "commands.help.descriptions.subcmd.commands"))
.setDescriptionLocalizations(getLocMap("commands.help.descriptions.subcmd.commands"))
Expand All @@ -32,7 +32,7 @@ export class HelpCmd extends SlashCommand {
)
.addSubcommand(subcommand =>
subcommand
.setName(CmdBase.getCmdName(tr.for("en-US", "commands.help.names.subcmd.info")))
.setName(tr.for("en-US", "commands.help.names.subcmd.info"))
.setNameLocalizations(getLocMap("commands.help.names.subcmd.info"))
.setDescription(tr.for("en-US", "commands.help.descriptions.subcmd.info"))
.setDescriptionLocalizations(getLocMap("commands.help.descriptions.subcmd.info"))
Expand All @@ -45,7 +45,7 @@ export class HelpCmd extends SlashCommand {
public async run(int: CommandInteraction, opt: CommandInteractionOption) {
if(!HelpCmd.checkInGuild(int))
return;
const locale = await HelpCmd.getGuildLocale(int);

switch(opt.name) {
case "commands": {
let cmdList = "";
Expand All @@ -55,7 +55,6 @@ export class HelpCmd extends SlashCommand {
let ephemeral = false;

const allowedCmds = [...cmdInstances.values()]
.sort((a, b) => a.builderJson.name.localeCompare(b.builderJson.name))
.filter(cmd => {
if(typeof cmd.builderJson.default_member_permissions === "undefined" || cmd.builderJson.default_member_permissions === "0")
return true;
Expand All @@ -65,50 +64,56 @@ export class HelpCmd extends SlashCommand {
const hasPerms = bitSetHas(BigInt(int.member.permissions as string), BigInt(cmd.builderJson.default_member_permissions as string));
if(hasPerms) {
ephemeral = true;
if(showHidden)
hiddenCmds.add(cmd.builderJson.name);
showHidden && hiddenCmds.add(cmd.builderJson.name);
}
return showHidden ? hasPerms : false;
});
})
.sort((a, b) => a.builderJson.name.localeCompare(b.builderJson.name));

if(!showHidden)
ephemeral = false;

for(const { builderJson: cmdData } of allowedCmds)
cmdList += `- ${hiddenCmds.has(cmdData.name) ? "🔒 " : ""}\`/${cmdData.name}\`${"description" in cmdData ? `\n ${cmdData.description}` : ""}\n`;
for(const { builderJson: data } of allowedCmds)
cmdList += `- ${hiddenCmds.has(data.name) ? "🔒 " : ""}\`/${data.name}\`${"description" in data ? `\n ${data.description}` : ""}\n`;

await int.deferReply({ ephemeral });
const locale = await HelpCmd.getGuildLocale(int);

return int.reply({
return int.editReply({
embeds: [
embedify(cmdList)
.setTitle(tr.for(locale, "commands.help.embedTitles.commands"))
.setFooter({ text: tr.for(locale, "commands.help.embedFooters.commands") }),
],
ephemeral,
});
}
case "info":
return int.reply({
case "info": {
await int.deferReply();
const locale = await HelpCmd.getGuildLocale(int);

const { version, author: { name, url }} = pkg;
return int.editReply({
embeds: [
embedify([
tr.for(locale, "commands.help.info.version", pkg.version),
tr.for(locale, "commands.help.info.createdBy", pkg.author.name, pkg.author.url),
"",
tr.for(locale, "commands.help.info.globalOptOut"),
tr.for(locale, "commands.help.info.headline", { version, name, url }),
tr.for(locale, "commands.help.info.donationLink", pkg.funding.url),
"",
tr.for(locale, "commands.help.info.bugsLink", pkg.bugs.url),
tr.for(locale, "commands.help.info.supportServerLink", getEnvVar("SUPPORT_SERVER_INVITE_URL")),
tr.for(locale, "commands.help.info.donationLink", pkg.funding.url),
tr.for(locale, "commands.help.info.globalOptOut"),
"",
tr.for(locale, "commands.help.info.installExtensions"),
tr.for(locale, "commands.help.info.installExtReturnYtDislike"),
tr.for(locale, "commands.help.info.installExtSponsorBlock"),
tr.for(locale, "commands.help.info.installExtDeArrow"),
tr.for(locale, "commands.help.info.installExtMobile"),
"",
tr.for(locale, "commands.help.info.poweredBy"),
])
.setTitle("commands.help.embedTitles.info"),
.setTitle(tr.for(locale, "commands.help.embedTitles.info")),
],
});
}
}
}
}
13 changes: 13 additions & 0 deletions src/lib/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Col, useEmbedify } from "@lib/embedify.ts";
import { defaultLocale, tr } from "@lib/translate.ts";
import { em } from "@lib/db.ts";
import { GuildConfig } from "@models/GuildConfig.model.ts";
import k from "kleur";

const cmdPrefix = getEnvVar("CMD_PREFIX", "stringOrUndefined");

Expand All @@ -26,6 +27,7 @@ export abstract class CmdBase {

/**
* Returns the command name, optionally prefixed by the env var `CMD_PREFIX`
* ⚠️ Only use at the top level, not in subcommands!
* If no name is passed, returns only the prefix, or an empty string if none is set
*/
public static getCmdName(name?: string) {
Expand Down Expand Up @@ -65,6 +67,17 @@ export abstract class SlashCommand extends CmdBase {

this.builder = builder;
this.builderJson = builder.toJSON();

// check if any subcommand has the command prefix and throw error
if(cmdPrefix) {
const errors = [] as string[];
for(const sub of this.builderJson.options ?? []) {
if(sub.name.startsWith(cmdPrefix))
errors.push(`Subcommand name "${sub.name}" cannot start with the command prefix "${cmdPrefix}"`);
}
if(errors.length > 0)
throw new Error(`${k.red(`Encountered ${errors.length === 1 ? "error" : "errors"} while creating slash command instance "${this.constructor.name}":`)}\n${errors.join("\n")}`);
}
}

/** Gets executed when the command is run by a user */
Expand Down
134 changes: 96 additions & 38 deletions src/lib/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,24 @@ export interface TrObject {
[key: string]: string | TrObject;
}

/** Properties for the transform function that transforms a matched translation string into something else */
export type TransformFnProps<TTrKey extends string = string> = {
/** The currently set language - empty string if not set yet */
language: string;
/** The matches as returned by `RegExp.exec()` */
matches: RegExpExecArray[];
/** The translation key */
trKey: TTrKey;
/** Translation value before any transformations */
trValue: string;
/** Current value, possibly in-between transformations */
currentValue: string;
/** Arguments passed to the translation function */
trArgs: (Stringifiable | Record<string, Stringifiable>)[];
};

/** Function that transforms a matched translation string into something else */
export type TransformFn = (language: string, trValue: string, matchesArr: RegExpMatchArray, ...trArgs: (Stringifiable | Record<string, Stringifiable>)[]) => Stringifiable;
export type TransformFn<TTrKey extends string = string> = (props: TransformFnProps<TTrKey>) => Stringifiable;

/** Pass a translation object to this function to get all keys in the object */
export type TrKeys<TTrObj, P extends string = ""> = {
Expand All @@ -56,7 +72,7 @@ const valTransforms: Array<{
let curLang = "";

/** Common function to resolve the translation text in a specific language. */
function translate(language: string, key: string, ...args: (Stringifiable | Record<string, Stringifiable>)[]): string {
function translate<TTrKey extends string = string>(language: string, key: TTrKey, ...trArgs: (Stringifiable | Record<string, Stringifiable>)[]): string {
if(typeof language !== "string")
language = curLang ?? "";

Expand All @@ -65,12 +81,33 @@ function translate(language: string, key: string, ...args: (Stringifiable | Reco
if(typeof language !== "string" || language.length === 0 || typeof trObj !== "object" || trObj === null)
return key;

const transform = (value: string): string => {
const tf = valTransforms.find((t) => t.regex.test(value));
const transformTrVal = (trKey: TTrKey, trValue: string): string => {
const tfs = valTransforms.filter(({ regex }) => new RegExp(regex).test(trValue));

if(tfs.length === 0)
return trValue;

let retStr = String(trValue);

for(const tf of tfs) {
const re = new RegExp(tf.regex);

return tf
? value.replace(tf.regex, (...matches) => String(tf.fn(language, value, matches, ...args)))
: value;
const matches: RegExpExecArray[] = [];
let execRes: RegExpExecArray | null;
while((execRes = re.exec(trValue)) !== null)
matches.push(execRes);

retStr = String(tf.fn({
language,
trValue,
currentValue: retStr,
matches,
trKey,
trArgs,
}));
}

return retStr;
};

// try to resolve via traversal (e.g. `trObj["key"]["parts"]`)
Expand All @@ -82,12 +119,12 @@ function translate(language: string, key: string, ...args: (Stringifiable | Reco
value = value?.[part];
}
if(typeof value === "string")
return transform(value);
return transformTrVal(key, value);

// try falling back to `trObj["key.parts"]`
value = trObj?.[key];
if(typeof value === "string")
return transform(value);
return transformTrVal(key, value);

// default to translation key
return key;
Expand Down Expand Up @@ -208,15 +245,15 @@ const hasKey = (key: TrKeyEn, language = curLang): boolean => {
* ```
* @param pattern Regular expression or string (passed to `new RegExp(pattern, "gm")`) that should match the entire pattern that calls the transform function
*/
const addTransform = (pattern: RegExp | string, fn: TransformFn): void => {
valTransforms.push({ fn, regex: typeof pattern === "string" ? new RegExp(pattern, "gm") : pattern });
const addTransform = (pattern: RegExp | string, fn: TransformFn<TrKeyEn>): void => {
valTransforms.push({ fn: fn as TransformFn, regex: typeof pattern === "string" ? new RegExp(pattern, "gm") : pattern });
};

/**
* Deletes the first transform function from the list of registered transform functions.
* @param patternOrFn A reference to the regular expression of the transform function, a string matching the original pattern, or a reference to the transform function to delete
*/
const deleteTransform = (patternOrFn: RegExp | string | TransformFn): void => {
const deleteTransform = (patternOrFn: RegExp | string | TransformFn<TrKeyEn>): void => {
const idx = valTransforms.findIndex((t) =>
(t.fn === patternOrFn as unknown as () => void)
|| (t.regex === (typeof patternOrFn === "string" ? new RegExp(patternOrFn, "gm") : patternOrFn))
Expand Down Expand Up @@ -278,37 +315,39 @@ export const defaultLocale = "en-US";
/** Array of tuples containing the regular expression and the transformation function */
const transforms = [
[
/<\$([a-zA-Z0-9$_-]+)>/gm,
(_lang, fullMatch, matchesArr, ...args) => {
let str = matchesArr[0];
const eachKeyInTrString = (keys: string[]) => keys.every((key) => fullMatch.includes("${" + key + "}"));
/\$\{([a-zA-Z0-9$_-]+)\}/gm,
({ matches, trArgs, trValue }) => {
let str = String(trValue);

const eachKeyInTrString = (keys: string[]) => keys.every((key) => trValue.includes("${" + key + "}"));

const namedMapping = () => {
if(!str.includes("${") || !args[0] || typeof args[0] !== "object" || !eachKeyInTrString(Object.keys(args[0])))
if(!str.includes("${") || typeof trArgs[0] === "undefined" || typeof trArgs[0] !== "object" || !eachKeyInTrString(Object.keys(trArgs[0] ?? {})))
return;
for(const key in args[0]) {
const regex = new RegExp("\\$" + `{${key}\\}`, "gm");
str = str.replace(regex, String((args[0] as Record<string, string>)[key]));
for(const match of matches) {
str = str.replace(match[0], String((trArgs[0] as Record<string, string>)[match[1]]));
}
};

const positionalMapping = () => {
if(!(/\${.+}/m.test(str)))
if(!(/\$\{.+\}/m.test(str)) || !trArgs[0])
return;
for(const arg of args)
str = str.replace(/\${[a-zA-Z0-9$_-]+}/, String(arg));
let matchNum = -1;
for(const match of matches) {
matchNum++;
str = str.replace(match[0], String(trArgs[matchNum]));
}
};
if(args[0] && typeof args[0] === "object" && eachKeyInTrString(Object.keys(args[0])) && String(args[0]).startsWith("[object"))

if(trArgs[0] && typeof trArgs[0] === "object" && trArgs[0] !== null && eachKeyInTrString(Object.keys(trArgs[0] ?? {})) && String(trArgs[0]).startsWith("[object"))
namedMapping();
else
positionalMapping();

return str;
},
],
] as const satisfies [RegExp, TransformFn][];
] as const satisfies [RegExp, TransformFn<TrKeyEn>][];

/** Loads all translations from files in the folder at `src/assets/translations` and applies transformation functions */
export async function initTranslations(): Promise<void> {
Expand Down Expand Up @@ -345,12 +384,31 @@ export async function initTranslations(): Promise<void> {
export function getLocMap(trKey: TrKeyEn, prefix = ""): LocalizationMap {
const locMap = {} as LocalizationMap;

for(const [locale, trObj] of Object.entries(trans)) {
const transform = (value: string): string => {
const tf = valTransforms.find((t) => t.regex.test(value));
return tf
? value.replace(tf.regex, (...matches) => String(tf.fn(locale, value, matches)))
: value;
for(const [language, trObj] of Object.entries(trans)) {
const transform = (trValue: string): string => {
const tf = valTransforms.find((t) => t.regex.test(trValue));

if(!tf)
return trValue;

let retStr = String(trValue);
const re = new RegExp(tf.regex);

const matches: RegExpExecArray[] = [];
let execRes: RegExpExecArray | null;
while((execRes = re.exec(trValue)) !== null)
matches.push(execRes);

retStr = String(tf.fn({
language,
trValue,
currentValue: retStr,
matches,
trKey,
trArgs: [],
}));

return retStr;
};

// try to resolve via traversal (e.g. `trObj["key"]["parts"]`)
Expand All @@ -362,12 +420,12 @@ export function getLocMap(trKey: TrKeyEn, prefix = ""): LocalizationMap {
value = value?.[part];
}
if(typeof value === "string")
locMap[locale as keyof LocalizationMap] = prefix + transform(value);
locMap[language as keyof LocalizationMap] = prefix + transform(value);

// try falling back to `trObj["key.parts"]`
value = trObj?.[trKey];
if(typeof value === "string")
locMap[locale as keyof LocalizationMap] = prefix + transform(value);
locMap[language as keyof LocalizationMap] = prefix + transform(value);
}

return Object.keys(locMap).length === 0
Expand Down
Loading

0 comments on commit 4c3d931

Please # to comment.