// Copyright 2019-2022 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const fs = require('fs');
const SubModule = require('./subModule.js');
delete require.cache[require.resolve('./locale/Strings.js')];
const Strings = require('./locale/Strings.js');
/**
* @description Manages pet related commands.
* @listens Command#language
* @listens Command#lang
* @listens Command#locale
* @listens Command#setlanguage
* @listens Command#setlang
* @listens Command#setlocale
* @listens Command#changelanguage
* @listens Command#changelang
* @listens Command#changelocale
* @augments SubModule
*/
class LocaleManager extends SubModule {
/**
* @description SubModule managing language choices.
*/
constructor() {
super();
/** @inheritdoc */
this.myName = 'LocaleManager';
/**
* @description Instance of locale strings helper.
* @private
* @type {Strings}
* @default
* @constant
*/
this._strings = new Strings('localeManager');
this._strings.purge();
/**
* @description Language mappings read from file. This maps the common ways
* to type a language, to the locale string we understand. All keys are
* lower-case UTF-8 strings.
* @private
* @type {object.<string>}
* @default
*/
this._mappings = {english: 'en_US'};
/**
* @description Locale settings set for specific guilds. If a guild is not
* included in here, they are expected to use the default locale settings.
* Mapped by guild ID.
* @private
* @type {object.<string>}
* @default
* @constant
*/
this._guilds = {};
fs.watchFile(
LocaleManager._localeMapFilename, {persistent: false},
this._readLocaleMap);
this._readLocaleMap();
this._commandLanguage = this._commandLanguage.bind(this);
}
/** @inheritdoc */
initialize() {
this.command.on(
new this.command.SingleCommand(
[
'language', 'lang', 'locale', 'setlanguage', 'setlang',
'setlocale', 'changelanguage', 'changelang', 'changelocale',
],
this._commandLanguage, {
validOnlyInGuild: true,
defaultDisabled: true,
permissions: this.Discord.PermissionsBitField.Flags.ManageGuild,
}));
this.client.guilds.cache.forEach((g) => {
const fn =
`${this.common.guildSaveDir}${g.id}${LocaleManager._localeSaveFile}`;
this.common.readFile(fn, (err, data) => {
if (err) {
if (err.code !== 'ENOENT') {
this.error(`Failed to read guild locale settings: ${fn}`);
console.error(err);
}
return;
}
const str = data.toString();
if (!Strings.parseLocale(str)) {
this.error(`${str} is not a valid locale: ${fn}`);
return;
}
this._guilds[g.id] = str;
});
});
/**
* @description Inject the {@link LocaleManager.getLocale} function into
* {@link SpikeyBot} instance. Will be undefined if this SubModule is not
* loaded. Gets re-set to `undefined` on shutdown.
* @public
* @see {@link LocaleManager.getLocale}
* @type {?Function}
* @param {*} args Args passed directly to {@link LocaleManager.getLocale}.
* @returns {?string} Locale string, or null.
*/
this.bot.getLocale = (...args) => this.getLocale(...args);
}
/** @inheritdoc */
shutdown() {
this.command.removeListener('language');
fs.unwatchFile(LocaleManager._localeMapFilename, this._readLocaleMap);
this.bot.getLocale = undefined;
}
/**
* @description Read the current locale mapping information from file.
* @private
*/
_readLocaleMap() {
fs.readFile(LocaleManager._localeMapFilename, (err, data) => {
if (err) {
this.error('Failed to read language to locale mapping information.');
console.error(err);
return;
}
try {
const parsed = JSON.parse(data);
const entries = Object.entries(parsed);
let fail = false;
entries.forEach((obj) => {
if (obj[0].match(/(\n|\r)+/)) {
fail = true;
this.error(
obj[0].replace(/\n/g, '\\n').replace(/\r/g, '\\r') +
': May not contain a new-line character.');
} else if (obj[0].match(/\s+/)) {
fail = true;
this.error(obj[0] + ': May not contain white-space.');
} else if (obj[0] !== obj[0].toLocaleLowerCase()) {
fail = true;
this.error(obj[0] + ': Must only be lower-case characters.');
} else if (!Strings.parseLocale(obj[1])) {
fail = true;
this.error(obj[0] + ': Does not map to valid locale: ' + obj[1]);
}
});
if (fail) {
throw new Error('Found invalid keys in language to locale mapping.');
}
this._mappings = parsed;
this.debug('Language-Locale map updated.');
} catch (err) {
this.error('Failed to parse language to locale mapping information.');
console.error(err);
return;
}
});
}
/**
* @description Get the locale of the given language strings.
* @public
* @param {string} lang The language string to lookup in the map.
* @returns {?string} Locale string, or null if unable to find.
*/
langToLocale(lang) {
return this._mappings[lang.toLocaleLowerCase()];
}
/**
* @description Filename relative to cwd to find language to locale mapping
* information.
* @private
* @static
* @constant
* @type {string}
*/
static get _localeMapFilename() {
return './strings/map.json';
}
/**
* @description Filename relative to guild directory storing the guild's
* locale settings.
* @private
* @static
* @constant
* @type {string}
*/
static get _localeSaveFile() {
return '/locale.txt';
}
/**
* @description Get the locale string for a specified guild.
* @public
* @param {string} gId The ID of the guild to fetch.
* @returns {?string} Locale string, or null if default.
*/
getLocale(gId) {
return gId && this._guilds[gId];
}
/**
* @description Change the chosen locale value in a specific guild.
* @public
* @param {string} gId The guild ID.
* @param {string} locale The valid locale string to set the value to.
*/
changeLocale(gId, locale) {
if (!Strings.parseLocale(locale)) {
throw new TypeError('Locale is not valid.');
}
this._guilds[gId] = locale;
const fn =
`${this.common.guildSaveDir}${gId}${LocaleManager._localeSaveFile}`;
this.common.mkAndWrite(fn, null, locale, (err) => {
if (err) {
this.error(`Failed to save locale settings: ${fn}`);
console.error(err);
return;
}
this.debug(`Updated Locale: ${gId} ${locale}`);
});
}
/**
* @description User typed the language command.
* @private
* @type {commandHandler}
* @param {Discord~Message} msg Message that triggered command.
* @listens Command#language
* @listens Command#lang
* @listens Command#locale
* @listens Command#setlanguage
* @listens Command#setlang
* @listens Command#setlocale
* @listens Command#changelanguage
* @listens Command#changelang
* @listens Command#changelocale
*/
_commandLanguage(msg) {
const text = msg.text.trim();
if (!text || text.length < 2) {
this._strings.reply(
this.common, msg, 'title', 'currentLocale',
msg.locale || this._strings.defaultLocale);
return;
}
const locale = this.langToLocale(text);
if (!locale) {
this._strings.reply(this.common, msg, 'title', 'invalidLocale');
return;
}
const desc = msg.channel.permissionsFor(msg.guild.members.me)
.has(this.Discord.PermissionsBitField.Flags.AddReactions) ?
'fillOne' :
'confirmLocaleReact';
const emoji = '✅';
const p = this._strings.reply(
this.common, msg, 'confirmLocale', desc, locale, emoji);
p.then((msg_) => {
msg_.react(emoji);
const filter = (reaction, user) =>
user.id === msg.author.id && reaction.emoji.name === emoji;
msg_.awaitReactions({filter, max: 1, time: 30 * 1000})
.then((reactions) => {
if (reactions.size === 0) {
msg_.edit({content: 'Timed out'});
msg_.reactions.removeAll().catch(() => {});
return;
}
msg_.edit({content: 'Confirmed'});
msg_.reactions.removeAll().catch(() => {});
this.changeLocale(msg.guild.id, locale);
});
});
}
}
module.exports = new LocaleManager();