handlers/LanguageHandler.js

'use strict';

const { Collection } = require("discord.js");
const { isAbsolute, normalize, join, basename, extname } = require("path");
const { existsSync, statSync, readdirSync, readFileSync } = require("fs");

/**
 * @typedef {Object} Language A set of language data.
 * @property {ISOLangCode} id The language's ISO 639-1 code.
 * @property {Object} data All translations.
 */

/**
 * @typedef {'aa'|'ab'|'ae'|'af'|'ak'|'am'|'an'|'ar'|'as'|'av'|'ay'|'az'|'ba'|'be'|'bg'|'bh'|'bm'|'bi'|'bn'|'bo'|'br'|'bs'|'ca'|'ce'|'ch'|'co'|'cr'|'cs'|'cu'|'cv'|'cy'|'da'|'de'|'dv'|'dz'|'ee'|'el'|'en'|'eo'|'es'|'et'|'eu'|'fa'|'ff'|'fi'|'fj'|'fo'|'fr'|'fy'|'ga'|'gd'|'gl'|'gn'|'gu'|'gv'|'ha'|'he'|'hi'|'ho'|'hr'|'ht'|'hu'|'hy'|'hz'|'ia'|'id'|'ie'|'ig'|'ii'|'ik'|'io'|'is'|'it'|'iu'|'ja'|'jv'|'ka'|'kg'|'ki'|'kj'|'kk'|'kl'|'km'|'kn'|'ko'|'kr'|'ks'|'ku'|'kv'|'kw'|'ky'|'la'|'lb'|'lg'|'li'|'ln'|'lo'|'lt'|'lu'|'lv'|'mg'|'mh'|'mi'|'mk'|'ml'|'mn'|'mr'|'ms'|'mt'|'my'|'na'|'nb'|'nd'|'ne'|'ng'|'nl'|'nn'|'no'|'nr'|'nv'|'ny'|'oc'|'oj'|'om'|'or'|'os'|'pa'|'pi'|'pl'|'ps'|'pt'|'qu'|'rm'|'rn'|'ro'|'ru'|'rw'|'sa'|'sc'|'sd'|'se'|'sg'|'si'|'sk'|'sl'|'sm'|'sn'|'so'|'sq'|'sr'|'ss'|'st'|'su'|'sv'|'sw'|'ta'|'te'|'tg'|'th'|'ti'|'tk'|'tl'|'tn'|'to'|'tr'|'ts'|'tt'|'tw'|'ty'|'ug'|'uk'|'ur'|'uz'|'ve'|'vi'|'vo'|'wa'|'wo'|'xh'|'yi'|'yo'|'za'|'zh'|'zu'} ISOLangCode An ISO 639-1 language code.
 */

/**
 * Class used to handle interpretation and use of language files. 
 * Please load the LanguageHandler before any other handlers to 
 * ensure other handlers have access to the loaclised versions of 
 * any text they require. Any errors thrown from the LanguageHandler 
 * will be in English, as they cannot be localised.
 */
class LanguageHandler {
    /**
     * Array containing absolute paths to language files.
     * @type {String[]}
     * @private
     * @static
     */
    static #languageFiles = [];
    /**
     * Collection containing all language data.
     * @type {Collection<String, Language>}
     * @private
     * @static
     */
    static #languages = new Collection();

    /**
     * ISO Language Code of the language to default to in cases in 
     * which the correct language to use cannot be determined.
     * @type {ISOLangCode}
     * @static
     */
    static defaultLanguage = "";
    
    /**
     * Add a folder to scan for language files when language files are loaded.
     * @param {String} languageFolder The absolute path to the folder containing language files.
     * @static
     * @example
     * // Add "src/lang" to the list of folders to scan
     * 
     * const { join } = require("path");
     * 
     * LanguageHandler.addLanguageFolder(join(__dirname, "src", "lang"));
     */
    static addLanguageFolder(languageFolder) {
        // check if then normalised path is absolute
        if (!isAbsolute(normalize(languageFolder))) throw new Error("Only absolute paths can be set as language folders.");
        // check the folder exists
        if (!existsSync(normalize(languageFolder))) throw new Error("That path does not exist.");
        // check that the folder is indeed a folder
        if (!statSync(normalize(languageFolder)).isDirectory()) throw new Error("This function expects a folder path, not a file path.")
        // scan for language files
        readdirSync(normalize(languageFolder)).forEach(name => {
            // ignore folders
            if (statSync(join(normalize(languageFolder), name)).isDirectory()) return;
            // ignore other files
            if (!/.lang$/g.test(name)) return;
            // add the file to the list of language files
            LanguageHandler.#languageFiles.push(join(normalize(languageFolder), name));
        });
    }

    /**
     * Clear any languages currently loaded and load all languages currently set to be used.
     * @param {import("winston").Logger=} logger The logger being used (to report errors with language files).
     * @static
     * @example
     * // Load all languages in src/lang
     * 
     * const { join } = require("path");
     * 
     * LanguageHandler.addLanguageFolder(join(__dirname, "src", "lang"));
     * LanguageHandler.loadAllLanguages();
     */
    static loadAllLanguages(logger) {
        // unload all language data
        LanguageHandler.#languages = new Collection();
        // load all languages
        LanguageHandler.#languageFiles.forEach(path => {
            try {
                LanguageHandler.loadLanguageFile(path);
            } catch (e) {
                if (logger) logger.error(e);
                else console.error(e);
            }
        });
    }

    /**
     * Load an individual language file.
     * @param {String} filepath The path to the file to load.
     * @static
     * @example
     * // Load the default "en.lang" language file
     * 
     * const { join } = require("path");
     * 
     * LanguageHandler.loadLanguageFile(join(__dirname, "lang", "en.lang"));
     */
    static loadLanguageFile(filepath) {
        // check if the path is absolute
        if (!isAbsolute(normalize(filepath))) throw new Error("Please provide an absolute path.");
        // check the file exists
        if (!existsSync(normalize(filepath))) throw new Error("That path does not exist.");
        // check that the path isn't a directory
        if (statSync(normalize(filepath)).isDirectory()) throw new Error("That path leads to a directory.");
        // get file contents
        let content = readFileSync(filepath, "utf-8");
        let lines = content.split("\n");
        // create the empty language object
        let language = {};
        lines.forEach(line => {
            // ignore comments
            if (line.indexOf("#") == 0 || line.indexOf("-") == 0) return;
            // ensure the line has an entry
            if (line.indexOf("=") == -1) return;
            // get the identifier
            let separated = line.split("=");
            let identifier = separated.shift().trim();
            // get the value
            let value = separated.join("=");
            // add characters such as \n, \t, \# and \\
            value = value.replace(/(?<!\\)\\n/g, "\n").replace(/(?<!\\)\\t/g, "\t").replace(/(?<!\\)\\#/g, "#").replace(/\\\\/g, "\\");
            // remove \r cause it breaks stuff
            value = value.replace(/\r/g, "");
            // create entry in language object
            let lastLevel = language;
            // for each element of the identifier
            identifier.split(".").forEach((x, i, a) => {
                // if an element is undefined, create it
                if (!lastLevel[x]) lastLevel[x] = {};
                // if this is the last element, set it to the text
                if (i == a.length - 1) lastLevel[x] = value;
                // go a level deeper for the next element
                lastLevel = lastLevel[x];
            });
        });
        // add the language to the languages collection
        LanguageHandler.#languages.set(basename(normalize(filepath), extname(normalize(filepath))), language);
    }

    /**
     * Get localised text.
     * @param {ISOLangCode} locale The ISO language code of the locale to use.
     * @param {String[]} path An array containing the path to the localisation.
     * @param  {...String=} replace Items to place within the localised text.
     * @returns {String} The localised text.
     * @static
     * @example
     * // Get a localised logging level name
     * 
     * let name = LanguageHandler.getLocalisation("en", ["console", "logging", "debug"]);
     */
    static getLocalisation(locale, path, ...replace) {
        // if no locale provided use the default
        if (!locale) locale = LanguageHandler.defaultLanguage;
        // ensure locale is loaded
        if (!LanguageHandler.#languages.get(locale)) return path.join(".");
        // get the translation
        let nextElement = LanguageHandler.#languages.get(locale);
        path.forEach(x => nextElement = nextElement?.[x]);
        // return a fallback if no translation is found
        if (!nextElement) nextElement = path.join(".");
        // check the found translation is actually a string
        if (!typeof(nextElement) == "string") throw new Error(`The translation of '${path.join(".")}' in locale '${locale} is not a string.'`);
        // replace elements in the translation
        replace.forEach((x, i) => nextElement = nextElement.replace(new RegExp(`\\\$\\\{${i}\\\}`, "g"), x));
        // return the final result
        return nextElement;
    }

    /**
     * Get localised text with a locale defined by a Discord API locale..
     * @param {String|import("discord.js").Interaction} locale The Discord API locale or Discord Interaction.
     * @param {String[]} path An array containing the path to the localisation.
     * @param  {...String=} replace Items to place within the localised text.
     * @returns {String} The localised text.
     * @static
     * @example
     * // Get a localised logging level name
     * 
     * let name = LanguageHandler.getLocalisationFromAPILocale(interaction, ["console", "logging", "debug"]);
     * let otherName = LanguageHandler.getLocalisationFromAPILocale("en-GB", ["console", "logging", "debug"]);
     */
    static getLocalisationFromAPILocale(locale, path, ...replace) {
        let localeToUse = typeof(locale) == "string" ? locale : locale.locale;
        let languageID = Array.from(LanguageHandler.#languages).find(x => x[1].meta?.apiLocale == localeToUse)[0];
        return LanguageHandler.getLocalisation(languageID, path, ...replace);
    }

    /**
     * Get the localisations of a command to provide to the Discord API.
     * @param {import("../types").Command} command The command to retrieve localisations for.
     * @returns {import("../types").CommandLocalisationObject} The command's localisations.
     * @static
     */
    static getCommandLocalisations(command) {
        /** @type {import("../types").CommandLocalisationObject} */
        let localisations = {
            name: {},
            description: {}
        }
        // for each language
        for (const locale of [...LanguageHandler.#languages.keys()]) {
            // test for an api locale
            if (!LanguageHandler.#languages.get(locale).meta?.apiLocale) continue;
            // attempt to find the localised name
            if (LanguageHandler.#languages.get(locale).commands?.[command.guild ? "guild" : "global"]?.[command.name]?.name) {
                // set the localised name
                localisations.name[LanguageHandler.#languages.get(locale).meta.apiLocale] = LanguageHandler.#languages.get(locale).commands[command.guild ? "guild" : "global"][command.name].name;
            }
            // attempt to find the localised description
            if (LanguageHandler.#languages.get(locale).commands?.[command.guild ? "guild" : "global"]?.[command.name]?.description) {
                // set the localised description
                localisations.description[LanguageHandler.#languages.get(locale).meta.apiLocale] = LanguageHandler.#languages.get(locale).commands[command.guild ? "guild" : "global"][command.name].description;
            }
        }

        return localisations;
    }

    /**
     * Get the localistions of options of a command to provide to the Discord API.
     * @param {import("../types").Command} command The command to retrieve localisations for.
     * @returns {import("../types").CommandOptionLocalisationObject} The command's localisations.
     * @static
     */
    static getCommandOptionLocalisations(command) {
        /** @type {import("../types").CommandOptionLocalisationObject} */
        let localisations = {};
        // for each language
        for (const locale of [...LanguageHandler.#languages.keys()]) {
            // test for an api locale
            if (!LanguageHandler.#languages.get(locale).meta?.apiLocale) continue;
            // for each option
            for (const option of command.options) {
                /** @type {import("../types").CommandLocalisationObject} */
                let localisation = {
                    name: {},
                    description: {}
                }
                // try to find the localised name
                if (LanguageHandler.#languages.get(locale).commands?.options?.[command.guild ? "guild" : "global"]?.[command.name]?.[option.name]?.name) {
                    // set the localised name
                    localisation.name[LanguageHandler.#languages.get(locale).meta.apiLocale] = LanguageHandler.#languages.get(locale).commands.options[command.guild ? "guild" : "global"][command.name][option.name].name
                }
                // try to find the localised description
                if (LanguageHandler.#languages.get(locale).commands?.options?.[command.guild ? "guild" : "global"]?.[command.name]?.[option.name]?.description) {
                    // set the localised description
                    localisation.description[LanguageHandler.#languages.get(locale).meta.apiLocale] = LanguageHandler.#languages.get(locale).commands.options[command.guild ? "guild" : "global"][command.name][option.name].description
                }
                // add to the full object
                localisations[option.name] = localisation;
            }
        }
        // return the full object
        return localisations;
    }
}

module.exports = LanguageHandler;