Sfoglia il codice sorgente

make the dashboard translatable

Markus-Rost 4 anni fa
parent
commit
fbc3dad957

+ 54 - 32
dashboard/guilds.js

@@ -1,6 +1,7 @@
 const cheerio = require('cheerio');
 const {defaultPermissions} = require('../util/default.json');
-const {settingsData, createNotice, escapeText} = require('./util.js');
+const Lang = require('./i18n.js');
+const {settingsData, createNotice} = require('./util.js');
 
 const forms = {
 	settings: require('./settings.js').get,
@@ -30,9 +31,22 @@ function dashboard_guilds(res, state, reqURL, action, actionArgs) {
 	var args = reqURL.pathname.split('/');
 	args = reqURL.pathname.split('/');
 	var settings = settingsData.get(state);
+	if ( reqURL.searchParams.get('owner') && process.env.owner.split('|').includes(settings.user.id) ) {
+		args[0] = 'owner';
+	}
+	var dashboardLang = new Lang(settings.user.locale);
 	var $ = cheerio.load(file);
-	if ( process.env.READONLY ) createNotice($, 'readonly');
-	if ( action ) createNotice($, action, actionArgs);
+	$('head title').text(dashboardLang.get('general.title'));
+	$('.channel#settings div').text(dashboardLang.get('general.settings'));
+	$('.channel#verification div').text(dashboardLang.get('general.verification'));
+	$('.channel#rcscript div').text(dashboardLang.get('general.rcscript'));
+	$('#selector span').text(dashboardLang.get('general.selector'));
+	$('#support span').text(dashboardLang.get('general.support'));
+	$('#logout').attr('alt', dashboardLang.get('general.logout'));
+	$('.guild#invite a').attr('alt', dashboardLang.get('general.invite'));
+	$('.guild#refresh a').attr('alt', dashboardLang.get('general.refresh'));
+	if ( process.env.READONLY ) createNotice($, 'readonly', dashboardLang);
+	if ( action ) createNotice($, action, dashboardLang, actionArgs);
 	$('head').append(
 		$('<script>').text(`history.replaceState(null, null, '${reqURL.pathname}');`)
 	);
@@ -79,14 +93,17 @@ function dashboard_guilds(res, state, reqURL, action, actionArgs) {
 	if ( settings.guilds.isMember.has(id) ) {
 		let guild = settings.guilds.isMember.get(id);
 		$('head title').text(`${guild.name} – ` + $('head title').text());
-		$('<script>').text(`const isPatreon = ${guild.patreon};`).insertBefore('script#indexjs');
+		$('<script>').text(`
+			const isPatreon = ${guild.patreon};
+			const i18n = ${JSON.stringify(dashboardLang.get('indexjs'))};
+		`).insertBefore('script#indexjs');
 		$('.channel#settings').attr('href', `/guild/${guild.id}/settings`);
 		$('.channel#verification').attr('href', `/guild/${guild.id}/verification`);
 		$('.channel#rcscript').attr('href', `/guild/${guild.id}/rcscript`);
-		if ( args[3] === 'settings' ) return forms.settings(res, $, guild, args);
-		if ( args[3] === 'verification' ) return forms.verification(res, $, guild, args);
-		if ( args[3] === 'rcscript' ) return forms.rcscript(res, $, guild, args);
-		return forms.settings(res, $, guild, args);
+		if ( args[3] === 'settings' ) return forms.settings(res, $, guild, args, dashboardLang);
+		if ( args[3] === 'verification' ) return forms.verification(res, $, guild, args, dashboardLang);
+		if ( args[3] === 'rcscript' ) return forms.rcscript(res, $, guild, args, dashboardLang);
+		return forms.settings(res, $, guild, args, dashboardLang);
 	}
 	else if ( settings.guilds.notMember.has(id) ) {
 		let guild = settings.guilds.notMember.get(id);
@@ -100,32 +117,41 @@ function dashboard_guilds(res, state, reqURL, action, actionArgs) {
 		$('#channellist').empty();
 		$('<a class="channel channel-header">').attr('href', url).append(
 			$('<img>').attr('src', '/src/settings.svg'),
-			$('<div>').text('Invite Wiki-Bot')
+			$('<div>').text(dashboardLang.get('general.invite'))
 		).appendTo('#channellist');
 		$('#text .description').append(
-			$('<p>').append(
-				escapeText(`Wiki-Bot is not a member of "${guild.name}" yet, but you can `),
-				$('<a>').attr('href', url).text('invite Wiki-Bot'),
-				escapeText('.')
-			),
-			$('<a id="login-button">').attr('href', url).text('Invite Wiki-Bot').prepend(
+			$('<p>').html(dashboardLang.get('selector.invite', true, $('<code>').text(guild.name), $('<a>').attr('href', url))),
+			$('<a id="login-button">').attr('href', url).text(dashboardLang.get('general.invite')).prepend(
 				$('<img alt="Discord">').attr('src', 'https://discord.com/assets/f8389ca1a741a115313bede9ac02e2c0.svg')
 			)
 		);
 	}
+	else if ( args[0] === 'owner' ) {
+		let guild = {
+			id, name: 'OWNER ACCESS',
+			acronym: '', userPermissions: 0,
+			patreon: true, botPermissions: 0,
+			channels: [], roles: []
+		};
+		$('head title').text(`${guild.name} – ` + $('head title').text());
+		$('<script>').text(`const isPatreon = ${guild.patreon};`).insertBefore('script#indexjs');
+		$('.channel#settings').attr('href', `/guild/${guild.id}/settings?owner=true`);
+		$('.channel#verification').attr('href', `/guild/${guild.id}/verification?owner=true`);
+		$('.channel#rcscript').attr('href', `/guild/${guild.id}/rcscript?owner=true`);
+		if ( args[3] === 'settings' ) return forms.settings(res, $, guild, args, dashboardLang);
+		if ( args[3] === 'verification' ) return forms.verification(res, $, guild, args, dashboardLang);
+		if ( args[3] === 'rcscript' ) return forms.rcscript(res, $, guild, args, dashboardLang);
+		return forms.settings(res, $, guild, args, dashboardLang);
+	}
 	else {
-		$('head title').text('Server Selector – ' + $('head title').text());
+		$('head title').text(dashboardLang.get('selector.title') + ' – ' + $('head title').text());
 		$('#channellist').empty();
-		$('<p>').append(
-			escapeText('This is a list of all servers you can change settings on because you have the '),
-			$('<code>').text('Manage Server'),
-			escapeText(' permission. Please select a server:')
-		).appendTo('#text .description');
+		$('<p>').html(dashboardLang.get('selector.desc', true, $('<code>'))).appendTo('#text .description');
 		if ( settings.guilds.isMember.size ) {
-			$('<h2 id="with-wikibot">').text('Server with Wiki-Bot').appendTo('#text');
+			$('<h2 id="with-wikibot">').text(dashboardLang.get('selector.with')).appendTo('#text');
 			$('<a class="channel">').attr('href', '#with-wikibot').append(
 				$('<img>').attr('src', '/src/channel.svg'),
-				$('<div>').text('Server with Wiki-Bot')
+				$('<div>').text(dashboardLang.get('selector.with'))
 			).appendTo('#channellist');
 			$('<div class="server-selector" id="isMember">').appendTo('#text');
 			settings.guilds.isMember.forEach( guild => {
@@ -138,10 +164,10 @@ function dashboard_guilds(res, state, reqURL, action, actionArgs) {
 			} );
 		}
 		if ( settings.guilds.notMember.size ) {
-			$('<h2 id="without-wikibot">').text('Server without Wiki-Bot').appendTo('#text');
+			$('<h2 id="without-wikibot">').text(dashboardLang.get('selector.without')).appendTo('#text');
 			$('<a class="channel">').attr('href', '#without-wikibot').append(
 				$('<img>').attr('src', '/src/channel.svg'),
-				$('<div>').text('Server without Wiki-Bot')
+				$('<div>').text(dashboardLang.get('selector.without'))
 			).appendTo('#channellist');
 			$('<div class="server-selector" id="notMember">').appendTo('#text');
 			settings.guilds.notMember.forEach( guild => {
@@ -160,15 +186,11 @@ function dashboard_guilds(res, state, reqURL, action, actionArgs) {
 			} );
 			$('<a class="channel channel-header">').attr('href', url).append(
 				$('<img>').attr('src', '/src/settings.svg'),
-				$('<div>').text('Switch Accounts')
+				$('<div>').text(dashboardLang.get('selector.switch'))
 			).appendTo('#channellist');
 			$('#text .description').append(
-				$('<p>').append(
-					escapeText('You currently don\'t have the '),
-					$('<code>').text('Manage Server'),
-					escapeText(' permission on any servers, are you logged into the correct account?')
-				),
-				$('<a id="login-button">').attr('href', url).text('Switch Accounts').prepend(
+				$('<p>').html(dashboardLang.get('selector.none', true, $('<code>'))),
+				$('<a id="login-button">').attr('href', url).text(dashboardLang.get('selector.switch')).prepend(
 					$('<img alt="Discord">').attr('src', 'https://discord.com/assets/f8389ca1a741a115313bede9ac02e2c0.svg')
 				)
 			);

+ 140 - 0
dashboard/i18n.js

@@ -0,0 +1,140 @@
+const {defaultSettings} = require('../util/default.json');
+const {escapeText} = require('./util.js');
+const i18n = {
+	en: require('./i18n/en.json'),
+	de: require('./i18n/de.json'),
+	pl: require('./i18n/pl.json')
+};
+
+/**
+ * A language.
+ * @class
+ */
+class Lang {
+	/**
+	 * Creates a new language.
+	 * @param {String} [lang] - The language code.
+	 * @param {String} [namespace] - The namespace for the language.
+	 * @constructs Lang
+	 */
+	constructor(lang = defaultSettings.lang, namespace = '') {
+		if ( !( typeof lang === 'string' && lang in i18n ) ) lang = defaultSettings.lang;
+		this.lang = lang;
+		this.namespace = namespace;
+		this.fallback = ( i18n?.[lang]?.fallback.slice() || [defaultSettings.lang] ).filter( fb => fb.trim() );
+	}
+
+	/**
+	 * Get a localized message.
+	 * @param {String} message - Name of the message.
+	 * @param {Boolean} escaped - If the message should be HTML escaped.
+	 * @param {(String|import('cheerio'))[]} args - Arguments for the message.
+	 * @returns {String}
+	 */
+	get(message = '', escaped = false, ...args) {
+		if ( this.namespace.length ) message = this.namespace + '.' + message;
+		let keys = ( message.length ? message.split('.') : [] );
+		let lang = this.lang;
+		let text = i18n?.[lang];
+		let fallback = 0;
+		for (let n = 0; n < keys.length; n++) {
+			if ( text ) {
+				text = text?.[keys[n]];
+				if ( typeof text === 'string' ) text = text.trim()
+			}
+			if ( !text ) {
+				if ( fallback < this.fallback.length ) {
+					lang = this.fallback[fallback];
+					fallback++;
+					text = i18n?.[lang];
+					n = -1;
+				}
+				else {
+					n = keys.length;
+				}
+			}
+		}
+		if ( typeof text === 'string' ) {
+			if ( escaped ) text = escapeText(text);
+			args.forEach( (arg, i) => {
+				if ( escaped && typeof arg !== 'string' ) {
+					text = text.replaceSave( new RegExp( `\\[([^\\]]+)\\]\\(\\$${i + 1}\\)`, 'g' ), (m, linkText) => {
+						return arg.html(linkText);
+					} );
+				}
+				text = text.replaceSave( new RegExp( `\\$${i + 1}`, 'g' ), arg );
+			} );
+			if ( text.includes( 'PLURAL:' ) ) text = text.replace( /{{\s*PLURAL:\s*[+-]?(\d+)\s*\|\s*([^\{\}]*?)\s*}}/g, (m, number, cases) => {
+				return plural(lang, parseInt(number, 10), cases.split(/\s*\|\s*/));
+			} );
+		}
+		return ( text || '⧼' + message + ( isDebug && args.length ? ': ' + args.join(', ') : '' ) + '⧽' );
+	}
+}
+
+/**
+ * Parse plural text.
+ * @param {String} lang - The language code.
+ * @param {Number} number - The amount.
+ * @param {String[]} args - The possible text.
+ * @returns {String}
+ */
+function plural(lang, number, args) {
+	// https://translatewiki.net/wiki/Plural/Mediawiki_plural_rules
+	var text = args[args.length - 1];
+	switch ( lang ) {
+		case 'fr':
+		case 'hi':
+			if ( number <= 1 ) text = getArg(args, 0);
+			else text = getArg(args, 1);
+			break;
+		case 'pl':
+			if ( number === 1 ) text = getArg(args, 0);
+			else if ( [2,3,4].includes( number % 10 ) && ![12,13,14].includes( number % 100 ) ) {
+				text = getArg(args, 1);
+			}
+			else text = getArg(args, 2);
+			break;
+		case 'ru':
+			if ( args.length > 2 ) {
+				if ( number % 10 === 1 && number % 100 !== 11 ) text = getArg(args, 0);
+				else if ( [2,3,4].includes( number % 10 ) && ![12,13,14].includes( number % 100 ) ) {
+					text = getArg(args, 1);
+				}
+				else text = getArg(args, 2);
+			}
+			else {
+				if ( number === 1 ) text = getArg(args, 0);
+				else text = getArg(args, 1);
+			}
+			break;
+		case 'bn':
+		case 'de':
+		case 'en':
+		case 'es':
+		case 'ja':
+		case 'nl':
+		case 'pt-br':
+		case 'th':
+		case 'tr':
+		case 'ja':
+		case 'zh-hans':
+		case 'zh-hant':
+		default:
+			if ( number === 1 ) text = getArg(args, 0);
+			else text = getArg(args, 1);
+	}
+	return text;
+}
+
+/**
+ * Get text option.
+ * @param {String[]} args - The list of options.
+ * @param {Number} index - The preferred option.
+ * @returns {String}
+ */
+function getArg(args, index) {
+	return ( args.length > index ? args[index] : args[args.length - 1] );
+}
+
+module.exports = Lang;

+ 9 - 0
dashboard/i18n/de.json

@@ -0,0 +1,9 @@
+{
+    "fallback": [
+        "en",
+        " ",
+        " ",
+        " ",
+        " "
+	]
+}

+ 190 - 0
dashboard/i18n/en.json

@@ -0,0 +1,190 @@
+{
+    "fallback": [
+        " ",
+        " ",
+        " ",
+        " ",
+        " "
+    ],
+    "general": {
+        "title": "Wiki-Bot Settings",
+        "settings": "Settings",
+        "verification": "Verifications",
+        "rcscript": "Recent Changes",
+        "selector": "Server Selector",
+        "support": "Support Server",
+        "logout": "Logout",
+        "invite": "Invite Wiki-Bot",
+        "refresh": "Refresh server list",
+        "save": "Save",
+        "delete": "Delete"
+    },
+    "selector": {
+        "title": "Server Selector",
+        "desc": "This is a list of all servers you can change settings on because you have the [Manage Server]($1) permission. Please select a server:",
+        "with": "Server with Wiki-Bot",
+        "without": "Server without Wiki-Bot",
+        "switch": "Switch Accounts",
+        "none": "You currently don't have the [Manage Server]($1) permission on any servers, are you logged into the correct account?",
+        "invite": "Wiki-Bot is not a member of $1 yet, but you can [invite Wiki-Bot]($2)."
+    },
+    "indexjs": {
+        "valid": {
+            "title": "The wiki is valid and can be used!",
+            "MediaWiki": "Warning: Requires at least $1 for full functionality.",
+            "TextExtracts": "Warning: Requires the extension $1 for page descriptions.",
+            "PageImages": "Warning: Requires the extension $1 for page thumbnails."
+        },
+        "invalid": {
+            "title": "Invalid wiki!",
+            "text": "The URL couldn't be resolved to a valid MediaWiki site!"
+        },
+        "outdated": {
+            "title": "Outdated MediaWiki version!",
+            "text": "The recent changes webhook requires at least MediaWiki 1.30!"
+        },
+        "sysmessage": {
+            "title": "System message doesn't match!",
+            "text": "The page $1 needs to match the server id $2."
+        },
+        "prefix": {
+            "space": "The prefix may not include spaces!",
+            "code": "The prefix may not include code markdown!",
+            "backslash": "The prefix may not include backslashes!"
+        }
+    },
+    "notice": {
+        "unauthorized": {
+            "title": "Not logged in!",
+            "text": "Please login before you can change any settings."
+        },
+        "save": {
+            "title": "Settings saved!",
+            "text": "The settings have been updated successfully."
+        },
+        "nosettings": {
+            "title": "Server not set up yet!",
+            "text": "Please define settings for the server first.",
+            "note": "Change settings."
+        },
+        "logout": {
+            "title": "Successfully logged out!",
+            "text": "You have been successfully logged out. To change any settings you need to login again."
+        },
+        "refresh": {
+            "title": "Refresh successful!",
+            "text": "Your server list has been successfully refeshed."
+        },
+        "missingperm": {
+            "title": "Missing permission!",
+            "text": "Either you or Wiki-Bot are missing the $1 permission for this function."
+        },
+        "loginfail": {
+            "title": "Login failed!",
+            "text": "An error occurred while logging you in, please try again."
+        },
+        "sysmessage": {
+            "title": "System message doesn't match!",
+            "text": "The page $1 needs to match the server id $2."
+        },
+        "mwversion": {
+            "title": "Outdated MediaWiki version!",
+            "text": "Requires at least MediaWiki 1.30, found $1 on $2."
+        },
+        "nochange": {
+            "title": "Save failed!",
+            "text": "The settings matched the current default settings."
+        },
+        "invalidusergroup": {
+            "title": "Invalid user group!",
+            "text": "The user group name was too long or you provided too many."
+        },
+        "wikiblocked": {
+            "title": "Wiki is blocked!",
+            "text": "$1 has been blocked from being added as a recent changes webhook.",
+            "note": "Reason:"
+        },
+        "savefail": {
+            "title": "Save failed!",
+            "text": "The settings could not be saved, please try again."
+        },
+        "movefail": {
+            "title": "Settings partially saved!",
+            "text": "The settings have only been partially updated.",
+            "note": "The webhook channel could not be changed!"
+        },
+        "refreshfail": {
+            "title": "Refresh failed!",
+            "text": "You server list could not be refreshed, please try again."
+        },
+        "error": {
+            "title": "Unknown error!",
+            "text": "An unknown error occured, please try again."
+        },
+        "readonly": {
+            "title": "Read-only database!",
+            "text": "You can currently only view your settings, but not change them."
+        }
+    },
+    "settings": {
+        "failed": "Failed to load the settings!",
+        "desc": "These are the settings for $1:",
+        "new": "New channel overwrite",
+        "form": {
+            "new": "New Channel Overwrite",
+            "overwrite": "$1 Settings",
+            "default": "Server-wide Settings",
+            "channel": "Channel:",
+            "wiki": "Default Wiki:",
+            "wiki_check": "Check wiki",
+            "lang": "Language:",
+            "role": "Minimal Role:",
+            "prefix": "Prefix:",
+            "prefix_space": "Prefix ends with space:",
+            "inline": "Inline commands:",
+            "select_channel": "-- Select a Channel --",
+            "confirm": "Do you really want to delete the channel overwrite?"
+        }
+    },
+    "verification": {
+        "desc": "These are the verifications for $1:",
+        "new": "New verification",
+        "form": {
+            "new": "New Verification",
+            "entry": "Verification #$1",
+            "channel": "Channel:",
+            "role": "Role:",
+            "usergroup": "Wiki user group:",
+            "usergroup_and": "Require all user groups:",
+            "editcount": "Minimal edit count:",
+            "accountage": "Account age (in days):",
+            "rename": "Rename users:",
+            "more": "Add more",
+            "select_role": "-- Select a Role --",
+            "confirm": "Do you really want to delete the verification?"
+        },
+        "explanation": "<h2>User Verification</h2>\n<p>Using the <code>!wiki verify &lt;wiki username&gt;</code> command, users are able to verify themselves as a specific wiki user by using the Discord field on their wiki profile. If the user matches and user verifications are set up on the server, Wiki-Bot will give them the roles for all verification entries they matched.</p>\n<p>Every verification entry allows for multiple restrictions on when a user should match the verification:</p>\n<ul>\n<li>Channel to use the <code>!wiki verify</code> command in.</li>\n<li>Role to get when matching the verification entry.</li>\n<li>Required edit count on the wiki to match the verification entry.</li>\n<li>Required user group to be a member of on the wiki to match the verification entry.</li>\n<li>Required account age in days to match the verification entry.</li>\n<li>Whether the Discord users nickname should be set to their wiki username when they match the verification entry.</li>\n</ul>"
+    },
+    "rcscript": {
+        "desc": "These are the recent changes webhooks for $1:",
+        "new": "New Webhook",
+        "form": {
+            "new": "New Recent Changes Webhook",
+            "entry": "Recent Changes Webhook #$1",
+            "channel": "Channel:",
+            "wiki": "Wiki:",
+            "wiki_check": "Check wiki",
+            "lang": "Language:",
+            "display": "Display mode:",
+            "display_compact": "Compact text messages with inline links.",
+            "display_embed": "Embed messages with edit tags and category changes.",
+            "display_image": "Embed messages with image previews.",
+            "display_diff": "Embed messages with image previews and edit differences.",
+            "feeds": "Feeds based changes:",
+            "feeds_only": "Only feeds based changes:",
+            "select_channel": "-- Select a Channel --",
+            "confirm": "Do you really want to delete the recent changes webhook?"
+        },
+        "explanation": "<h2>Recent Changes Webhook</h2>\n<p>Wiki-Bot is able to run a recent changes webhook based on <a href=\"https://gitlab.com/piotrex43/RcGcDw\" target=\"_blank\">RcGcDw</a>. The recent changes can be displayed in compact text messages with inline links or embed messages with edit tags and category changes.</p>\n<p>Requirements to add a recent changes webhook:</p>\n<ul>\n<li>The wiki needs to run on <a href=\"https://www.mediawiki.org/wiki/MediaWiki_1.30\" target=\"_blank\">MediaWiki 1.30</a> or higher.</li>\n<li>The system message <code>MediaWiki:Custom-RcGcDw</code> needs to be set to the Discord server id <code id=\"server-id\" class=\"user-select\"></code>.</li>\n</ul>"
+    }
+}

+ 9 - 0
dashboard/i18n/pl.json

@@ -0,0 +1,9 @@
+{
+    "fallback": [
+        "en",
+        " ",
+        " ",
+        " ",
+        " "
+	]
+}

+ 7 - 5
dashboard/oauth.js

@@ -1,7 +1,9 @@
 const crypto = require('crypto');
 const cheerio = require('cheerio');
-const {defaultPermissions} = require('../util/default.json');
+const {defaultPermissions, defaultSettings} = require('../util/default.json');
 const Wiki = require('../util/wiki.js');
+const Lang = require('./i18n.js');
+const dashboardLang = new Lang(defaultSettings.lang);
 const {got, settingsData, sendMsg, createNotice, hasPerm} = require('./util.js');
 
 const DiscordOauth2 = require('discord-oauth2');
@@ -30,8 +32,8 @@ function dashboard_login(res, state, action) {
 	var $ = cheerio.load(file);
 	let responseCode = 200;
 	let prompt = 'none';
-	if ( process.env.READONLY ) createNotice($, 'readonly');
-	if ( action ) createNotice($, action);
+	if ( process.env.READONLY ) createNotice($, 'readonly', dashboardLang);
+	if ( action ) createNotice($, action, dashboardLang);
 	if ( action === 'unauthorized' ) $('head').append(
 		$('<script>').text('history.replaceState(null, null, "/login");')
 	);
@@ -222,12 +224,12 @@ function dashboard_api(res, input) {
 		RcGcDw: '',
 		customRcGcDw: wiki.toLink('MediaWiki:Custom-RcGcDw', 'action=edit')
 	};
-	return got.get( wiki + 'api.php?&action=query&meta=allmessages|siteinfo&ammessages=custom-RcGcDw&amenableparser=true&siprop=general|extensions' + ( wiki.isFandom() ? '|variables' : '' ) + '&format=json' ).then( response => {
+	return got.get( wiki + 'api.php?&action=query&meta=allmessages|siteinfo&ammessages=custom-RcGcDw&amenableparser=true&siprop=general|extensions&format=json' ).then( response => {
 		if ( response.statusCode === 404 && typeof response.body === 'string' ) {
 			let api = cheerio.load(response.body)('head link[rel="EditURI"]').prop('href');
 			if ( api ) {
 				wiki = new Wiki(api.split('api.php?')[0], wiki);
-				return got.get( wiki + 'api.php?action=query&meta=allmessages|siteinfo&ammessages=custom-RcGcDw&amenableparser=true&siprop=general|extensions' + ( wiki.isFandom() ? '|variables' : '' ) + '&format=json' );
+				return got.get( wiki + 'api.php?action=query&meta=allmessages|siteinfo&ammessages=custom-RcGcDw&amenableparser=true&siprop=general|extensions&format=json' );
 			}
 		}
 		return response;

+ 48 - 38
dashboard/rcscript.js

@@ -59,6 +59,7 @@ const fieldset = {
  * Create a settings form
  * @param {import('cheerio')} $ - The response body
  * @param {String} header - The form header
+ * @param {import('./i18n.js')} dashboardLang - The user language
  * @param {Object} settings - The current settings
  * @param {Boolean} settings.patreon
  * @param {String} [settings.channel]
@@ -67,36 +68,43 @@ const fieldset = {
  * @param {Number} settings.display
  * @param {Number} [settings.wikiid]
  * @param {Number} [settings.rcid]
- * @param {Object[]} guildChannels - The guild channels
- * @param {String} guildChannels.id
- * @param {String} guildChannels.name
- * @param {Number} guildChannels.userPermissions
- * @param {Number} guildChannels.botPermissions
+ * @param {import('./util.js').Channel[]} guildChannels - The guild channels
  * @param {String[]} allWikis - The guild wikis
  */
-function createForm($, header, settings, guildChannels, allWikis) {
+function createForm($, header, dashboardLang, settings, guildChannels, allWikis) {
 	var readonly = ( process.env.READONLY ? true : false );
 	var curChannel = guildChannels.find( guildChannel => settings.channel === guildChannel.id );
 	var fields = [];
 	let channel = $('<div>').append(fieldset.channel);
+	channel.find('label').text(dashboardLang.get('rcscript.form.channel'));
+	let curCat = null;
 	if ( !settings.channel || ( curChannel && hasPerm(curChannel.botPermissions, 'MANAGE_WEBHOOKS') && hasPerm(curChannel.userPermissions, 'VIEW_CHANNEL', 'MANAGE_WEBHOOKS') ) ) {
 		channel.find('#wb-settings-channel').append(
 			...guildChannels.filter( guildChannel => {
-				return ( hasPerm(guildChannel.userPermissions, 'VIEW_CHANNEL', 'MANAGE_WEBHOOKS') && hasPerm(guildChannel.botPermissions, 'MANAGE_WEBHOOKS') );
+				return ( ( hasPerm(guildChannel.userPermissions, 'VIEW_CHANNEL', 'MANAGE_WEBHOOKS') && hasPerm(guildChannel.botPermissions, 'MANAGE_WEBHOOKS') ) || guildChannel.isCategory );
 			} ).map( guildChannel => {
-				var optionChannel = $(`<option id="wb-settings-channel-${guildChannel.id}">`).val(guildChannel.id);
+				if ( guildChannel.isCategory ) {
+					curCat = $('<optgroup>').attr('label', guildChannel.name);
+					return curCat;
+				}
+				var optionChannel = $(`<option id="wb-settings-channel-${guildChannel.id}">`).val(guildChannel.id).text(`${guildChannel.id} – #${guildChannel.name}`);
 				if ( settings.channel === guildChannel.id ) {
 					optionChannel.attr('selected', '');
 				}
-				return optionChannel.text(`${guildChannel.id} – #${guildChannel.name}`);
+				if ( !curCat ) return optionChannel;
+				optionChannel.appendTo(curCat);
+			} ).filter( catChannel => {
+				if ( !catChannel ) return false;
+				if ( catChannel.is('optgroup') && !catChannel.children('option').length ) return false;
+				return true;
 			} )
 		);
 		if ( !settings.channel ) {
 			if ( !channel.find('#wb-settings-channel').children('option').length ) {
-				createNotice($, 'missingperm', ['Manage Webhooks']);
+				createNotice($, 'missingperm', dashboardLang, ['Manage Webhooks']);
 			}
 			channel.find('#wb-settings-channel').prepend(
-				$(`<option id="wb-settings-channel-default" selected hidden>`).val('').text('-- Select a Channel --')
+				$(`<option id="wb-settings-channel-default" selected hidden>`).val('').text(dashboardLang.get('rcscript.form.select_channel'))
 			);
 		}
 	}
@@ -108,21 +116,31 @@ function createForm($, header, settings, guildChannels, allWikis) {
 	);
 	fields.push(channel);
 	let wiki = $('<div>').append(fieldset.wiki);
+	wiki.find('label').text(dashboardLang.get('rcscript.form.wiki'));
+	wiki.find('#wb-settings-wiki-check').text(dashboardLang.get('rcscript.form.wiki_check'));
 	wiki.find('#wb-settings-wiki').val(settings.wiki);
 	wiki.find('#wb-settings-wiki-list').append(
 		...allWikis.map( listWiki => $(`<option>`).val(listWiki) )
 	);
 	fields.push(wiki);
 	let lang = $('<div>').append(fieldset.lang);
+	lang.find('label').text(dashboardLang.get('rcscript.form.lang'));
 	lang.find(`#wb-settings-lang-${settings.lang}`).attr('selected', '');
 	fields.push(lang);
 	let display = $('<div>').append(fieldset.display);
+	display.find('span').text(dashboardLang.get('rcscript.form.display'));
+	display.find('label').eq(0).text(dashboardLang.get('rcscript.form.display_compact'));
+	display.find('label').eq(1).text(dashboardLang.get('rcscript.form.display_embed'));
+	display.find('label').eq(2).text(dashboardLang.get('rcscript.form.display_image'));
+	display.find('label').eq(3).text(dashboardLang.get('rcscript.form.display_diff'));
 	display.find(`#wb-settings-display-${settings.display}`).attr('checked', '');
 	if ( !settings.patreon ) display.find('.wb-settings-display').filter( (i, radioDisplay) => {
 		return ( i > rcgcdwLimit.display && !$(radioDisplay).has('input:checked').length );
 	} ).remove();
 	fields.push(display);
 	let feeds = $('<div id="wb-settings-feeds-hide">').append(fieldset.feeds);
+	feeds.find('label').eq(0).text(dashboardLang.get('rcscript.form.feeds'));
+	feeds.find('label').eq(1).text(dashboardLang.get('rcscript.form.feeds_only'));
 	if ( /\.(?:fandom\.com|wikia\.org)$/.test(new URL(settings.wiki).hostname) ) {
 		if ( settings.wikiid ) {
 			feeds.find('#wb-settings-feeds').attr('checked', '');
@@ -135,14 +153,14 @@ function createForm($, header, settings, guildChannels, allWikis) {
 		feeds.find('#wb-settings-feeds-only-hide').attr('style', 'visibility: hidden;');
 	}
 	fields.push(feeds);
-	fields.push($(fieldset.save).val('Save'));
+	fields.push($(fieldset.save).val(dashboardLang.get('general.save')));
 	if ( settings.channel && curChannel && hasPerm(curChannel.userPermissions, 'MANAGE_WEBHOOKS') ) {
-		fields.push($(fieldset.delete).val('Delete').attr('onclick', `return confirm('Are you sure?');`));
+		fields.push($(fieldset.delete).val(dashboardLang.get('general.delete')).attr('onclick', `return confirm('${dashboardLang.get('rcscript.form.confirm').replace( /'/g, '\\$&' )}');`));
 	}
 	var form = $('<fieldset>').append(...fields);
 	if ( readonly ) {
 		form.find('input').attr('readonly', '');
-		form.find('input[type="checkbox"], input[type="radio"]:not(:checked), option').attr('disabled', '');
+		form.find('input[type="checkbox"], input[type="radio"]:not(:checked), option, optgroup').attr('disabled', '');
 		form.find('input[type="submit"], button.addmore').remove();
 	}
 	return $('<form id="wb-settings" method="post" enctype="application/x-www-form-urlencoded">').append(
@@ -151,29 +169,20 @@ function createForm($, header, settings, guildChannels, allWikis) {
 	);
 }
 
-const explanation = `
-<h2>Recent Changes Webhook</h2>
-<p>Wiki-Bot is able to run a recent changes webhook based on <a href="https://gitlab.com/piotrex43/RcGcDw" target="_blank">RcGcDw</a>. The recent changes can be displayed in compact text messages with inline links or embed messages with edit tags and category changes.</p>
-<p>Requirements to add a recent changes webhook:</p>
-<ul>
-	<li>The wiki needs to run on <a href="https://www.mediawiki.org/wiki/MediaWiki_1.30" target="_blank">MediaWiki 1.30</a> or higher.</li>
-	<li>The system message <code>MediaWiki:Custom-RcGcDw</code> needs to be set to the Discord server id <code id="server-id" class="user-select"></code>.</li>
-</ul>
-`;
-
 /**
  * Let a user change recent changes scripts
  * @param {import('http').ServerResponse} res - The server response
  * @param {import('cheerio')} $ - The response body
  * @param {import('./util.js').Guild} guild - The current guild
  * @param {String[]} args - The url parts
+ * @param {import('./i18n.js')} dashboardLang - The user language
  */
-function dashboard_rcscript(res, $, guild, args) {
 	db.all( 'SELECT discord.wiki mainwiki, discord.lang mainlang, (SELECT GROUP_CONCAT(DISTINCT wiki) FROM discord WHERE guild = ?) allwikis, webhook, configid, rcgcdw.wiki, rcgcdw.lang, display, wikiid, rcid FROM discord LEFT JOIN rcgcdw ON discord.guild = rcgcdw.guild WHERE discord.guild = ? AND discord.channel IS NULL ORDER BY configid ASC', [guild.id, guild.id], function(dberror, rows) {
+function dashboard_rcscript(res, $, guild, args, dashboardLang) {
 		if ( dberror ) {
 			console.log( '- Dashboard: Error while getting the RcGcDw: ' + dberror );
-			createNotice($, 'error');
-			$('#text .description').html(explanation);
+			createNotice($, 'error', dashboardLang);
+			$('#text .description').html(dashboardLang.get('rcscript.explanation'));
 			$('#text code#server-id').text(guild.id);
 			$('.channel#rcscript').addClass('selected');
 			let body = $.html();
@@ -182,8 +191,8 @@ function dashboard_rcscript(res, $, guild, args) {
 			return res.end();
 		}
 		if ( rows.length === 0 ) {
-			createNotice($, 'nosettings', [guild.id]);
-			$('#text .description').html(explanation);
+			createNotice($, 'nosettings', dashboardLang, [guild.id]);
+			$('#text .description').html(dashboardLang.get('rcscript.explanation'));
 			$('#text code#server-id').text(guild.id);
 			$('.channel#rcscript').addClass('selected');
 			let body = $.html();
@@ -195,7 +204,7 @@ function dashboard_rcscript(res, $, guild, args) {
 		var lang = rows[0].mainlang;
 		var allwikis = rows[0].allwikis.split(',').sort();
 		if ( rows.length === 1 && rows[0].configid === null ) rows.pop();
-		$('<p>').text(`These are the recent changes webhooks for "${guild.name}":`).appendTo('#text .description');
+		$('<p>').html(dashboardLang.get('rcscript.desc', true, $('<code>').text(guild.name))).appendTo('#text .description');
 		Promise.all(rows.map( row => {
 			return got.get( 'https://discord.com/api/webhooks/' + row.webhook ).then( response => {
 				if ( !response.body?.channel_id ) {
@@ -208,6 +217,7 @@ function dashboard_rcscript(res, $, guild, args) {
 				row.channel = 'UNKNOWN';
 			} );
 		} )).finally( () => {
+			let suffix = ( args[0] === 'owner' ? '?owner=true' : '' );
 			$('#channellist #rcscript').after(
 				...rows.map( row => {
 					return $('<a class="channel">').attr('id', `channel-${row.configid}`).append(
@@ -215,17 +225,17 @@ function dashboard_rcscript(res, $, guild, args) {
 						$('<div>').text(`${row.configid} - ${( guild.channels.find( channel => {
 							return channel.id === row.channel;
 						} )?.name || row.channel )}`)
-					).attr('href', `/guild/${guild.id}/rcscript/${row.configid}`);
+					).attr('href', `/guild/${guild.id}/rcscript/${row.configid}${suffix}`);
 				} ),
 				( process.env.READONLY || rows.length >= rcgcdwLimit[( guild.patreon ? 'patreon' : 'default' )] ? '' :
 				$('<a class="channel" id="channel-new">').append(
 					$('<img>').attr('src', '/src/channel.svg'),
-					$('<div>').text('New webhook')
-				).attr('href', `/guild/${guild.id}/rcscript/new`) )
+					$('<div>').text(dashboardLang.get('rcscript.new'))
+				).attr('href', `/guild/${guild.id}/rcscript/new${suffix}`) )
 			);
 			if ( args[4] === 'new' && !( process.env.READONLY || rows.length >= rcgcdwLimit[( guild.patreon ? 'patreon' : 'default' )] ) ) {
 				$('.channel#channel-new').addClass('selected');
-				createForm($, 'New Recent Changes Webhook', {
+				createForm($, dashboardLang.get('rcscript.form.new'), dashboardLang, {
 					wiki, lang: ( lang in allLangs.names ? lang : defaultSettings.lang ),
 					display: 1, patreon: guild.patreon
 				}, guild.channels, allwikis).attr('action', `/guild/${guild.id}/rcscript/new`).appendTo('#text');
@@ -233,13 +243,13 @@ function dashboard_rcscript(res, $, guild, args) {
 			else if ( rows.some( row => row.configid.toString() === args[4] ) ) {
 				let row = rows.find( row => row.configid.toString() === args[4] );
 				$(`.channel#channel-${row.configid}`).addClass('selected');
-				createForm($, `Recent Changes Webhook #${row.configid}`, Object.assign({
+				createForm($, dashboardLang.get('rcscript.form.entry', false, row.configid), dashboardLang, Object.assign({
 					patreon: guild.patreon
 				}, row), guild.channels, allwikis).attr('action', `/guild/${guild.id}/rcscript/${row.configid}`).appendTo('#text');
 			}
 			else {
 				$('.channel#rcscript').addClass('selected');
-				$('#text .description').html(explanation);
+				$('#text .description').html(dashboardLang.get('rcscript.explanation'));
 				$('#text code#server-id').text(guild.id);
 			}
 			let body = $.html();
@@ -282,7 +292,7 @@ function update_rcscript(res, userSettings, guild, type, settings) {
 		}
 		settings.display = parseInt(settings.display, 10);
 		if ( type === 'new' && !userSettings.guilds.isMember.get(guild).channels.some( channel => {
-			return ( channel.id === settings.channel );
+			return ( channel.id === settings.channel && !channel.isCategory );
 		} ) ) return res(`/guild/${guild}/rcscript/new`, 'savefail');
 	}
 	if ( settings.delete_settings && type === 'new' ) {
@@ -446,7 +456,7 @@ function update_rcscript(res, userSettings, guild, type, settings) {
 			var newChannel = false;
 			if ( settings.save_settings && row.channel !== settings.channel ) {
 				if ( !userSettings.guilds.isMember.get(guild).channels.some( channel => {
-					return ( channel.id === settings.channel );
+					return ( channel.id === settings.channel && !channel.isCategory );
 				} ) ) return res(`/guild/${guild}/rcscript/${type}`, 'savefail');
 				newChannel = true;
 			}

+ 32 - 27
dashboard/settings.js

@@ -40,6 +40,7 @@ const fieldset = {
  * Create a settings form
  * @param {import('cheerio')} $ - The response body
  * @param {String} header - The form header
+ * @param {import('./i18n.js')} dashboardLang - The user language
  * @param {Object} settings - The current settings
  * @param {Boolean} settings.patreon
  * @param {String} settings.channel
@@ -48,22 +49,18 @@ const fieldset = {
  * @param {String} settings.role
  * @param {Boolean} settings.inline
  * @param {String} settings.prefix
- * @param {Object[]} guildRoles - The guild roles
- * @param {String} guildRoles.id
- * @param {String} guildRoles.name
- * @param {Object[]} guildChannels - The guild channels
- * @param {String} guildChannels.id
- * @param {String} guildChannels.name
- * @param {Number} guildChannels.userPermissions
+ * @param {import('./util.js').Role[]} guildRoles - The guild roles
+ * @param {import('./util.js').Channel[]} guildChannels - The guild channels
  */
-function createForm($, header, settings, guildRoles, guildChannels = []) {
+function createForm($, header, dashboardLang, settings, guildRoles, guildChannels = []) {
 	var readonly = ( process.env.READONLY ? true : false );
-	if ( settings.channel && guildChannels.userPermissions === 0 && guildChannels.name === 'UNKNOWN' ) {
+	if ( settings.channel && guildChannels.length === 1 && guildChannels[0].userPermissions === 0 && guildChannels[0].name === 'UNKNOWN' ) {
 		readonly = true;
 	}
 	var fields = [];
 	if ( settings.channel ) {
 		let channel = $('<div>').append(fieldset.channel);
+		channel.find('label').text(dashboardLang.get('settings.form.channel'));
 		channel.find('#wb-settings-channel').append(
 			...guildChannels.map( guildChannel => {
 				return $(`<option id="wb-settings-channel-${guildChannel.id}">`).val(guildChannel.id).text(`${guildChannel.id} – #${guildChannel.name}`)
@@ -76,20 +73,23 @@ function createForm($, header, settings, guildRoles, guildChannels = []) {
 			}
 		}
 		else channel.find('#wb-settings-channel').prepend(
-			$(`<option id="wb-settings-channel-default" selected hidden>`).val('').text('-- Select a Channel --')
+			$(`<option id="wb-settings-channel-default" selected hidden>`).val('').text(dashboardLang.get('settings.form.select_channel'))
 		);
 		fields.push(channel);
 	}
 	let wiki = $('<div>').append(fieldset.wiki);
+	wiki.find('label').text(dashboardLang.get('settings.form.wiki'));
+	wiki.find('#wb-settings-wiki-check').text(dashboardLang.get('settings.form.wiki_check'));
 	wiki.find('#wb-settings-wiki').val(settings.wiki);
 	fields.push(wiki);
 	if ( !settings.channel || settings.patreon ) {
 		let lang = $('<div>').append(fieldset.lang);
+		lang.find('label').text(dashboardLang.get('settings.form.lang'));
 		lang.find(`#wb-settings-lang-${settings.lang}`).attr('selected', '');
 		fields.push(lang);
 		let role = $('<div>').append(fieldset.role);
+		role.find('label').text(dashboardLang.get('settings.form.role'));
 		role.find('#wb-settings-role').append(
-			$(`<option id="wb-settings-role-default">`).val('').text(`@everyone`),
 			...guildRoles.map( guildRole => {
 				return $(`<option id="wb-settings-role-${guildRole.id}">`).val(guildRole.id).text(`${guildRole.id} – @${guildRole.name}`)
 			} ),
@@ -99,20 +99,23 @@ function createForm($, header, settings, guildRoles, guildChannels = []) {
 		else role.find(`#wb-settings-role-everyone`).attr('selected', '');
 		fields.push(role);
 		let inline = $('<div>').append(fieldset.inline);
+		inline.find('label').text(dashboardLang.get('settings.form.inline'));
 		if ( !settings.inline ) inline.find('#wb-settings-inline').attr('checked', '');
 		fields.push(inline);
 	}
 	if ( settings.patreon && !settings.channel ) {
 		let prefix = $('<div>').append(fieldset.prefix);
+		prefix.find('label').eq(0).text(dashboardLang.get('settings.form.prefix'));
+		prefix.find('label').eq(1).text(dashboardLang.get('settings.form.prefix_space'));
 		prefix.find('#wb-settings-prefix').val(settings.prefix.trim());
 		if ( settings.prefix.endsWith( ' ' ) ) {
 			prefix.find('#wb-settings-prefix-space').attr('checked', '');
 		}
 		fields.push(prefix);
 	}
-	fields.push($(fieldset.save).val('Save'));
+	fields.push($(fieldset.save).val(dashboardLang.get('general.save')));
 	if ( settings.channel && settings.channel !== 'new' ) {
-		fields.push($(fieldset.delete).val('Delete').attr('onclick', `return confirm('Are you sure?');`));
+		fields.push($(fieldset.delete).val(dashboardLang.get('general.delete')).attr('onclick', `return confirm('${dashboardLang.get('settings.form.confirm').replace( /'/g, '\\$&' )}');`));
 	}
 	var form = $('<fieldset>').append(...fields);
 	if ( readonly ) {
@@ -132,23 +135,24 @@ function createForm($, header, settings, guildRoles, guildChannels = []) {
  * @param {import('cheerio')} $ - The response body
  * @param {import('./util.js').Guild} guild - The current guild
  * @param {String[]} args - The url parts
+ * @param {import('./i18n.js')} dashboardLang - The user language
  */
-function dashboard_settings(res, $, guild, args) {
+function dashboard_settings(res, $, guild, args, dashboardLang) {
 	db.all( 'SELECT channel, wiki, lang, role, inline, prefix, patreon FROM discord WHERE guild = ? ORDER BY channel ASC', [guild.id], function(dberror, rows) {
 		if ( dberror ) {
 			console.log( '- Dashboard: Error while getting the settings: ' + dberror );
-			createNotice($, 'error');
-			$('<p>').text('Failed to load the settings!').appendTo('#text .description');
+			createNotice($, 'error', dashboardLang);
+			$('<p>').text(dashboardLang.get('settings.failed')).appendTo('#text .description');
 			$('.channel#settings').addClass('selected');
 			let body = $.html();
 			res.writeHead(200, {'Content-Length': body.length});
 			res.write( body );
 			return res.end();
 		}
-		$('<p>').text(`These are the settings for "${guild.name}":`).appendTo('#text .description');
+		$('<p>').html(dashboardLang.get('settings.desc', true, $('<code>').text(guild.name))).appendTo('#text .description');
 		if ( !rows.length ) {
 			$('.channel#settings').addClass('selected');
-			createForm($, 'Server-wide Settings', Object.assign({
+			createForm($, dashboardLang.get('settings.form.default'), dashboardLang, Object.assign({
 				prefix: process.env.prefix
 			}, defaultSettings), guild.roles).attr('action', `/guild/${guild.id}/settings/default`).appendTo('#text');
 			let body = $.html();
@@ -163,34 +167,35 @@ function dashboard_settings(res, $, guild, args) {
 		} ).sort( (a, b) => {
 			return guild.channels.indexOf(a) - guild.channels.indexOf(b);
 		} );
+		let suffix = ( args[0] === 'owner' ? '?owner=true' : '' );
 		$('#channellist #settings').after(
 			...channellist.map( channel => {
 				return $('<a class="channel">').attr('id', `channel-${channel.id}`).append(
 					$('<img>').attr('src', '/src/channel.svg'),
 					$('<div>').text(channel.name)
-				).attr('href', `/guild/${guild.id}/settings/${channel.id}`).attr('title', channel.id);
+				).attr('href', `/guild/${guild.id}/settings/${channel.id}${suffix}`).attr('title', channel.id);
 			} ),
 			( process.env.READONLY || !guild.channels.filter( channel => {
-				return ( hasPerm(channel.userPermissions, 'VIEW_CHANNEL', 'SEND_MESSAGES') && !rows.some( row => row.channel === channel.id ) );
+				return ( !channel.isCategory && hasPerm(channel.userPermissions, 'VIEW_CHANNEL', 'SEND_MESSAGES') && !rows.some( row => row.channel === channel.id ) );
 			} ).length ? '' :
 			$('<a class="channel" id="channel-new">').append(
 				$('<img>').attr('src', '/src/channel.svg'),
-				$('<div>').text('New channel overwrite')
-			).attr('href', `/guild/${guild.id}/settings/new`) )
+				$('<div>').text(dashboardLang.get('settings.new'))
+			).attr('href', `/guild/${guild.id}/settings/new${suffix}`) )
 		);
 		if ( args[4] === 'new' && !process.env.READONLY ) {
 			$('.channel#channel-new').addClass('selected');
-			createForm($, 'New Channel Overwrite', Object.assign({}, rows.find( row => !row.channel ), {
+			createForm($, dashboardLang.get('settings.form.new'), dashboardLang, Object.assign({}, rows.find( row => !row.channel ), {
 				patreon: isPatreon,
 				channel: 'new'
 			}), guild.roles, guild.channels.filter( channel => {
-				return ( hasPerm(channel.userPermissions, 'VIEW_CHANNEL', 'SEND_MESSAGES') && !rows.some( row => row.channel === channel.id ) );
+				return ( !channel.isCategory && hasPerm(channel.userPermissions, 'VIEW_CHANNEL', 'SEND_MESSAGES') && !rows.some( row => row.channel === channel.id ) );
 			} )).attr('action', `/guild/${guild.id}/settings/new`).appendTo('#text');
 		}
 		else if ( channellist.some( channel => channel.id === args[4] ) ) {
 			let channel = channellist.find( channel => channel.id === args[4] );
 			$(`.channel#channel-${channel.id}`).addClass('selected');
-			createForm($, `#${channel.name} Settings`, Object.assign({}, rows.find( row => {
+			createForm($, dashboardLang.get('settings.form.overwrite', false, `#${channel.name}`), dashboardLang, Object.assign({}, rows.find( row => {
 				return row.channel === channel.id;
 			} ), {
 				patreon: isPatreon
@@ -198,7 +203,7 @@ function dashboard_settings(res, $, guild, args) {
 		}
 		else {
 			$('.channel#settings').addClass('selected');
-			createForm($, 'Server-wide Settings', rows.find( row => !row.channel ), guild.roles).attr('action', `/guild/${guild.id}/settings/default`).appendTo('#text');
+			createForm($, dashboardLang.get('settings.form.default'), dashboardLang, rows.find( row => !row.channel ), guild.roles).attr('action', `/guild/${guild.id}/settings/default`).appendTo('#text');
 		}
 		let body = $.html();
 		res.writeHead(200, {'Content-Length': body.length});
@@ -236,7 +241,7 @@ function update_settings(res, userSettings, guild, type, settings) {
 			return res(`/guild/${guild}/settings/${type}`, 'savefail');
 		}
 		if ( settings.channel && !userSettings.guilds.isMember.get(guild).channels.some( channel => {
-			return ( channel.id === settings.channel );
+			return ( channel.id === settings.channel && !channel.isCategory );
 		} ) ) return res(`/guild/${guild}/settings/${type}`, 'savefail');
 		if ( settings.role && !userSettings.guilds.isMember.get(guild).roles.some( role => {
 			return ( role.id === settings.role );

+ 25 - 21
dashboard/src/index.js

@@ -113,24 +113,24 @@ if ( wiki ) {
 				wikichecknotice.className = 'notice';
 				wikichecknotice.innerHTML = '';
 				if ( response.error ) {
-					wiki.setCustomValidity('Invalid wiki!');
+					wiki.setCustomValidity(i18n.invalid.title);
 					wikichecknotice.classList.add('notice-error');
 					var noticeTitle = document.createElement('b');
-					noticeTitle.textContent = 'Invalid wiki!';
+					noticeTitle.textContent = i18n.invalid.title;
 					var noticeText = document.createElement('div');
-					noticeText.textContent = 'The URL could not be resolved to a valid MediaWiki site!';
+					noticeText.textContent = i18n.invalid.text;
 					wikichecknotice.append(noticeTitle, noticeText);
 					return;
 				}
 				wiki.value = response.wiki;
 				if ( document.location.pathname.split('/')[3] === 'rcscript' ) {
 					if ( !response.MediaWiki ) {
-						wiki.setCustomValidity('Outdated MediaWiki version!');
+						wiki.setCustomValidity(i18n.outdated.title);
 						wikichecknotice.classList.add('notice-error');
 						var noticeTitle = document.createElement('b');
-						noticeTitle.textContent = 'Outdated MediaWiki version!';
+						noticeTitle.textContent = i18n.outdated.title;
 						var noticeText = document.createElement('div');
-						noticeText.textContent = 'The recent changes webhook requires at least MediaWiki 1.30!';
+						noticeText.textContent = i18n.outdated.text;
 						var noticeLink = document.createElement('a');
 						noticeLink.setAttribute('target', '_blank');
 						noticeLink.setAttribute('href', 'https://www.mediawiki.org/wiki/MediaWiki_1.30');
@@ -141,7 +141,7 @@ if ( wiki ) {
 					if ( response.RcGcDw !== document.location.pathname.split('/')[2] ) {
 						wikichecknotice.classList.add('notice-info');
 						var noticeTitle = document.createElement('b');
-						noticeTitle.textContent = 'System message does not match!';
+						noticeTitle.textContent = i18n.sysmessage.title;
 						var sysmessageLink = document.createElement('a');
 						sysmessageLink.setAttribute('target', '_blank');
 						sysmessageLink.setAttribute('href', response.customRcGcDw);
@@ -152,12 +152,13 @@ if ( wiki ) {
 						guildCode.className = 'user-select';
 						guildCode.textContent = document.location.pathname.split('/')[2];
 						var noticeText = document.createElement('div');
+						var textSnippets = i18n.sysmessage.text.split(/\$\d/);
 						noticeText.append(
-							document.createTextNode('The page '),
+							document.createTextNode(textSnippets[0]),
 							sysmessageLink,
-							document.createTextNode(' needs to be the server id '),
+							document.createTextNode(textSnippets[1]),
 							guildCode,
-							document.createTextNode('.')
+							document.createTextNode(textSnippets[2])
 						);
 						var noticeLink = sysmessageLink.cloneNode();
 						noticeLink.textContent = response.customRcGcDw;
@@ -166,13 +167,13 @@ if ( wiki ) {
 					}
 					wikichecknotice.classList.add('notice-success');
 					var noticeTitle = document.createElement('b');
-					noticeTitle.textContent = 'The wiki is valid and can be used!';
+					noticeTitle.textContent = i18n.valid.title;
 					wikichecknotice.append(noticeTitle);
 					return;
 				}
 				wikichecknotice.classList.add('notice-success');
 				var noticeTitle = document.createElement('b');
-				noticeTitle.textContent = 'The wiki is valid and can be used!';
+				noticeTitle.textContent = i18n.valid.title;
 				wikichecknotice.append(noticeTitle);
 				if ( !/\.(?:gamepedia\.com|fandom\.com|wikia\.org)$/.test(wiki.value.split('/')[2]) ) {
 					if ( !response.MediaWiki ) {
@@ -181,10 +182,11 @@ if ( wiki ) {
 						noticeLink.setAttribute('href', 'https://www.mediawiki.org/wiki/MediaWiki_1.30');
 						noticeLink.textContent = 'MediaWiki 1.30';
 						var noticeText = document.createElement('div');
+						var textSnippets = i18n.valid.MediaWiki.split(/\$\d/);
 						noticeText.append(
-							document.createTextNode('Warning: Requires at least '),
+							document.createTextNode(textSnippets[0]),
 							noticeLink,
-							document.createTextNode(' for full functionality.')
+							document.createTextNode(textSnippets[1])
 						);
 						wikichecknotice.append(noticeText);
 					}
@@ -194,10 +196,11 @@ if ( wiki ) {
 						noticeLink.setAttribute('href', 'https://www.mediawiki.org/wiki/Extension:TextExtracts');
 						noticeLink.textContent = 'TextExtracts';
 						var noticeText = document.createElement('div');
+						var textSnippets = i18n.valid.TextExtracts.split(/\$\d/);
 						noticeText.append(
-							document.createTextNode('Warning: Requires the extension '),
+							document.createTextNode(textSnippets[0]),
 							noticeLink,
-							document.createTextNode(' for page descriptions.')
+							document.createTextNode(textSnippets[1])
 						);
 						wikichecknotice.append(noticeText);
 					}
@@ -207,10 +210,11 @@ if ( wiki ) {
 						noticeLink.setAttribute('href', 'https://www.mediawiki.org/wiki/Extension:PageImages');
 						noticeLink.textContent = 'PageImages';
 						var noticeText = document.createElement('div');
+						var textSnippets = i18n.valid.PageImages.split(/\$\d/);
 						noticeText.append(
-							document.createTextNode('Warning: Requires the extension '),
+							document.createTextNode(textSnippets[0]),
 							noticeLink,
-							document.createTextNode(' for page thumbnails.')
+							document.createTextNode(textSnippets[1])
 						);
 						wikichecknotice.append(noticeText);
 					}
@@ -288,13 +292,13 @@ const prefix = document.getElementById('wb-settings-prefix');
 if ( prefix ) prefix.addEventListener( 'input', function() {
 	if ( prefix.validity.patternMismatch ) {
 		if ( prefix.value.trim().includes( ' ' ) ) {
-			prefix.setCustomValidity('The prefix may not include spaces!');
+			prefix.setCustomValidity(i18n.prefix.space);
 		}
 		else if ( prefix.value.includes( '`' ) ) {
-			prefix.setCustomValidity('The prefix may not include code markdown!');
+			prefix.setCustomValidity(i18n.prefix.code);
 		}
 		else if ( prefix.value.includes( '\\' ) ) {
-			prefix.setCustomValidity('The prefix may not include backslashes!');
+			prefix.setCustomValidity(i18n.prefix.backslash);
 		}
 		else prefix.setCustomValidity('');
 	}

+ 62 - 52
dashboard/util.js

@@ -50,8 +50,24 @@ const db = new sqlite3.Database( './wikibot.db', mode, dberror => {
  * @property {String} userPermissions
  * @property {Boolean} [patreon]
  * @property {String} [botPermissions]
- * @property {{id: String, name: String, userPermissions: Number, botPermissions: Number}[]} [channels]
- * @property {{id: String, name: String, lower: Boolean}[]} [roles]
+ * @property {Channel[]} [channels]
+ * @property {Role[]} [roles]
+ */
+
+/**
+ * @typedef Channel
+ * @property {String} id
+ * @property {String} name
+ * @property {Boolean} isCategory
+ * @property {Number} userPermissions
+ * @property {Number} botPermissions
+ */
+
+/**
+ * @typedef Role
+ * @property {String} id
+ * @property {String} name
+ * @property {Boolean} lower
  */
 
 /**
@@ -92,10 +108,11 @@ function sendMsg(message) {
  * Create a red notice
  * @param {import('cheerio')} $ - The cheerio static
  * @param {String} notice - The notice to create
+ * @param {import('./i18n.js')} dashboardLang - The user language
  * @param {String[]} [args] - The arguments for the notice
  * @returns {import('cheerio')}
  */
-function createNotice($, notice, args = []) {
+function createNotice($, notice, dashboardLang, args = []) {
 	if ( !notice ) return;
 	var type = 'info';
 	var title = $('<b>');
@@ -104,105 +121,98 @@ function createNotice($, notice, args = []) {
 	switch (notice) {
 		case 'unauthorized':
 			type = 'info';
-			title.text('Not logged in!');
-			text.text('Please login before you can change any settings.');
+			title.text(dashboardLang.get('notice.unauthorized.title'));
+			text.text(dashboardLang.get('notice.unauthorized.text'));
 			break;
 		case 'save':
 			type = 'success';
-			title.text('Settings saved!');
-			text.text('The settings have been updated successfully.');
+			title.text(dashboardLang.get('notice.save.title'));
+			text.text(dashboardLang.get('notice.save.text'));
 			break;
 		case 'nosettings':
 			type = 'info';
-			title.text('Server not set up yet!');
-			text.text('Please define settings for the server first.');
-			note = $('<a>').text('Change settings.').attr('href', `/guild/${args[0]}/settings`);
+			title.text(dashboardLang.get('notice.nosettings.title'));
+			text.text(dashboardLang.get('notice.nosettings.text'));
+			note = $('<a>').text(dashboardLang.get('notice.nosettings.note')).attr('href', `/guild/${args[0]}/settings`);
 			break;
 		case 'logout':
 			type = 'success';
-			title.text('Successfully logged out!');
-			text.text('You have been successfully logged out. To change any settings you need to login again.');
+			title.text(dashboardLang.get('notice.logout.title'));
+			text.text(dashboardLang.get('notice.logout.text'));
 			break;
 		case 'refresh':
 			type = 'success';
-			title.text('Refresh successful!');
-			text.text('Your server list has been successfully refeshed.');
+			title.text(dashboardLang.get('notice.refresh.title'));
+			text.text(dashboardLang.get('notice.refresh.text'));
 			break;
 		case 'missingperm':
 			type = 'error';
-			title.text('Missing permission!');
-			text.append(
-				escapeText('Either you or Wiki-Bot are missing the '),
-				$('<code>').text(args[0]),
-				escapeText(' permission for this function.')
-			);
+			title.text(dashboardLang.get('notice.missingperm.title'));
+			text.html(dashboardLang.get('notice.missingperm.text', true, $('<code>').text(args[0])));
 			break;
 		case 'loginfail':
 			type = 'error';
-			title.text('Login failed!');
-			text.text('An error occurred while logging you in, please try again.');
+			title.text(dashboardLang.get('notice.loginfail.title'));
+			text.text(dashboardLang.get('notice.loginfail.text'));
 			break;
 		case 'sysmessage':
 			type = 'info';
-			title.text('System message does not match!');
-			text.append(
-				escapeText('The page '),
-				$('<a target="_blank">').append(
-					$('<code>').text('MediaWiki:Custom-RcGcDw')
-				).attr('href', args[1]),
-				escapeText(' needs to be the server id '),
-				$('<code class="user-select">').text(args[0]),
-				escapeText('.')
-			);
+			title.text(dashboardLang.get('notice.sysmessage.title'));
+			text.text(dashboardLang.get('notice.sysmessage.text', true, $('<a target="_blank">').append(
+				$('<code>').text('MediaWiki:Custom-RcGcDw')
+			).attr('href', args[1]), $('<code class="user-select">').text(args[0])));
 			note = $('<a target="_blank">').text(args[1]).attr('href', args[1]);
 			break;
 		case 'mwversion':
 			type = 'error';
-			title.text('Outdated MediaWiki version!');
-			text.text(`Requires at least MediaWiki 1.30, found ${args[0]} on ${args[1]}.`);
+			title.text(dashboardLang.get('notice.mwversion.title'));
+			text.text(dashboardLang.get('notice.mwversion.text', false, args[0], args[1]));
 			note = $('<a target="_blank">').text('https://www.mediawiki.org/wiki/MediaWiki_1.30').attr('href', 'https://www.mediawiki.org/wiki/MediaWiki_1.30');
 			break;
 		case 'nochange':
 			type = 'info';
-			title.text('Save failed!');
-			text.text('The settings matched the current default settings.');
+			title.text(dashboardLang.get('notice.nochange.title'));
+			text.text(dashboardLang.get('notice.nochange.text'));
 			break;
 		case 'invalidusergroup':
 			type = 'error';
-			title.text('Invalid user group!');
-			text.text('The user group name was too long or you provided too many.');
+			title.text(dashboardLang.get('notice.invalidusergroup.title'));
+			text.text(dashboardLang.get('notice.invalidusergroup.text'));
 			break;
 		case 'wikiblocked':
 			type = 'error';
-			title.text('Wiki is blocked!');
-			text.text(`${args[0]} has been blocked from being added as a recent changes webhook.`);
-			if ( args[1] ) note = $('<div>').text(`Reason: ${args[1]}`);
+			title.text(dashboardLang.get('notice.wikiblocked.title'));
+			text.text(dashboardLang.get('notice.wikiblocked.text', false, args[0]));
+			if ( args[1] ) note = $('<div>').append(
+				dashboardLang.get('notice.wikiblocked.note', true) + ' ',
+				$('<code>').text(args[1])
+			);
 			break;
 		case 'savefail':
 			type = 'error';
-			title.text('Save failed!');
-			text.text('The settings could not be saved, please try again.');
+			title.text(dashboardLang.get('notice.savefail.title'));
+			text.text(dashboardLang.get('notice.savefail.text'));
 			break;
 		case 'movefail':
 			type = 'info';
-			title.text('Settings partially saved!');
-			text.text('The settings have only been partially updated.');
-			note = $('<div>').text('The webhook channel could not be changed!');
+			title.text(dashboardLang.get('notice.movefail.title'));
+			text.text(dashboardLang.get('notice.movefail.text'));
+			note = $('<div>').text(dashboardLang.get('notice.movefail.note'));
 			break;
 		case 'refreshfail':
 			type = 'error';
-			title.text('Refresh failed!');
-			text.text('You server list could not be refreshed, please try again.');
+			title.text(dashboardLang.get('notice.refreshfail.title'));
+			text.text(dashboardLang.get('notice.refreshfail.text'));
 			break;
 		case 'error':
 			type = 'error';
-			title.text('Unknown error!');
-			text.text('An unknown error occured, please try again.');
+			title.text(dashboardLang.get('notice.error.title'));
+			text.text(dashboardLang.get('notice.error.text'));
 			break;
 		case 'readonly':
 			type = 'info';
-			title.text('Read-only database!');
-			text.text('You can currently only view your settings, but not change them.');
+			title.text(dashboardLang.get('notice.readonly.title'));
+			text.text(dashboardLang.get('notice.readonly.text'));
 			break;
 		default:
 			return;

+ 58 - 50
dashboard/verification.js

@@ -37,6 +37,7 @@ const fieldset = {
  * Create a settings form
  * @param {import('cheerio')} $ - The response body
  * @param {String} header - The form header
+ * @param {import('./i18n.js')} dashboardLang - The user language
  * @param {Object} settings - The current settings
  * @param {String} settings.channel
  * @param {String} settings.role
@@ -45,30 +46,34 @@ const fieldset = {
  * @param {Number} settings.accountage
  * @param {Boolean} settings.rename
  * @param {String} [settings.defaultrole]
- * @param {Object[]} guildChannels - The guild channels
- * @param {String} guildChannels.id
- * @param {String} guildChannels.name
- * @param {Number} guildChannels.userPermissions
- * @param {Number} guildChannels.botPermissions
- * @param {Object[]} guildRoles - The guild roles
- * @param {String} guildRoles.id
- * @param {String} guildRoles.name
- * @param {Boolean} guildRoles.lower
+ * @param {import('./util.js').Channel[]} guildChannels - The guild channels
+ * @param {import('./util.js').Role[]} guildRoles - The guild roles
  */
-function createForm($, header, settings, guildChannels, guildRoles) {
+function createForm($, header, dashboardLang, settings, guildChannels, guildRoles) {
 	var readonly = ( process.env.READONLY ? true : false );
 	var fields = [];
 	let channel = $('<div>').append(fieldset.channel);
+	channel.find('label').text(dashboardLang.get('verification.form.channel'));
+	let curCat = null;
 	channel.find('#wb-settings-channel').append(
 		$('<option class="wb-settings-channel-default defaultSelect" hidden>').val('').text('-- Select a Channel --'),
 		...guildChannels.filter( guildChannel => {
-			return ( hasPerm(guildChannel.userPermissions, 'VIEW_CHANNEL') || settings.channel.includes( '|' + guildChannel.id + '|' ) );
+			return ( hasPerm(guildChannel.userPermissions, 'VIEW_CHANNEL') || guildChannel.isCategory || settings.channel.includes( '|' + guildChannel.id + '|' ) );
 		} ).map( guildChannel => {
-			var optionChannel = $(`<option class="wb-settings-channel-${guildChannel.id}">`).val(guildChannel.id);
+			if ( guildChannel.isCategory ) {
+				curCat = $('<optgroup>').attr('label', guildChannel.name);
+				return curCat;
+			}
+			var optionChannel = $(`<option class="wb-settings-channel-${guildChannel.id}">`).val(guildChannel.id).text(`${guildChannel.id} – #${guildChannel.name}`);
 			if ( !hasPerm(guildChannel.userPermissions, 'VIEW_CHANNEL') ) {
 				optionChannel.addClass('wb-settings-error');
 			}
-			return optionChannel.text(`${guildChannel.id} – #${guildChannel.name}`);
+			if ( !curCat ) return optionChannel;
+			optionChannel.appendTo(curCat);
+		} ).filter( catChannel => {
+			if ( !catChannel ) return false;
+			if ( catChannel.is('optgroup') && !catChannel.children('option').length ) return false;
+			return true;
 		} )
 	);
 	if ( settings.channel ) {
@@ -97,8 +102,9 @@ function createForm($, header, settings, guildChannels, guildRoles) {
 	}
 	fields.push(channel);
 	let role = $('<div>').append(fieldset.role);
+	role.find('label').text(dashboardLang.get('verification.form.role'));
 	role.find('#wb-settings-role').append(
-		$('<option class="wb-settings-role-default defaultSelect" hidden>').val('').text('-- Select a Role --'),
+		$('<option class="wb-settings-role-default defaultSelect" hidden>').val('').text(dashboardLang.get('verification.form.select_role')),
 		...guildRoles.filter( guildRole => {
 			return guildRole.lower || settings.role.split('|').includes( guildRole.id );
 		} ).map( guildRole => {
@@ -136,6 +142,8 @@ function createForm($, header, settings, guildChannels, guildRoles) {
 	}
 	fields.push(role);
 	let usergroup = $('<div>').append(fieldset.usergroup);
+	usergroup.find('label').eq(0).text(dashboardLang.get('verification.form.usergroup'));
+	usergroup.find('label').eq(1).text(dashboardLang.get('verification.form.usergroup_and'));
 	if ( settings.usergroup.startsWith( 'AND|' ) ) {
 		settings.usergroup = settings.usergroup.substring(4);
 		usergroup.find('#wb-settings-usergroup-and').attr('checked', '');
@@ -146,59 +154,50 @@ function createForm($, header, settings, guildChannels, guildRoles) {
 	}
 	fields.push(usergroup);
 	let editcount = $('<div>').append(fieldset.editcount);
+	editcount.find('label').text(dashboardLang.get('verification.form.editcount'));
 	editcount.find('#wb-settings-editcount').val(settings.editcount);
 	fields.push(editcount);
 	let accountage = $('<div>').append(fieldset.accountage);
+	accountage.find('label').text(dashboardLang.get('verification.form.accountage'));
 	accountage.find('#wb-settings-accountage').val(settings.accountage);
 	fields.push(accountage);
 	if ( settings.rename || guildChannels.some( guildChannel => {
 		return hasPerm(guildChannel.botPermissions, 'MANAGE_NICKNAMES');
 	} ) ) {
 		let rename = $('<div>').append(fieldset.rename);
+		rename.find('label').text(dashboardLang.get('verification.form.rename'));
 		if ( settings.rename ) rename.find('#wb-settings-rename').attr('checked', '');
 		fields.push(rename);
 	}
-	fields.push($(fieldset.save).val('Save'));
+	fields.push($(fieldset.save).val(dashboardLang.get('general.save')));
 	if ( settings.channel ) {
-		fields.push($(fieldset.delete).val('Delete').attr('onclick', `return confirm('Are you sure?');`));
+		fields.push($(fieldset.delete).val(dashboardLang.get('general.delete')).attr('onclick', `return confirm('${dashboardLang.get('verification.form.confirm').replace( /'/g, '\\$&' )}');`));
 	}
 	var form = $('<fieldset>').append(...fields);
 	if ( readonly ) {
 		form.find('input').attr('readonly', '');
-		form.find('input[type="checkbox"], option').attr('disabled', '');
+		form.find('input[type="checkbox"], option, optgroup').attr('disabled', '');
 		form.find('input[type="submit"], button.addmore').remove();
 	}
+	form.find('button.addmore').text(dashboardLang.get('verification.form.more'));
 	return $('<form id="wb-settings" method="post" enctype="application/x-www-form-urlencoded">').append(
 		$('<h2>').text(header),
 		form
 	);
 }
 
-const explanation = `
-<h2>User Verification</h2>
-<p>Using the <code>!wiki verify &lt;wiki username&gt;</code> command, users are able to verify themselves as a specific wiki user by using the Discord field on their wiki profile. If the user matches and user verifications are set up on the server, Wiki-Bot will give them the roles for all verification entries they matched.</p>
-<p>Every verification entry allows for multiple restrictions on when a user should match the verification:</p>
-<ul>
-	<li>Channel to use the <code>!wiki verify</code> command in.</li>
-	<li>Role to get when matching the verification entry.</li>
-	<li>Required edit count on the wiki to match the verification entry.</li>
-	<li>Required user group to be a member of on the wiki to match the verification entry.</li>
-	<li>Required account age in days to match the verification entry.</li>
-	<li>Whether the Discord users nickname should be set to their wiki username when they match the verification entry.</li>
-</ul>
-`;
-
 /**
  * Let a user change verifications
  * @param {import('http').ServerResponse} res - The server response
  * @param {import('cheerio')} $ - The response body
  * @param {import('./util.js').Guild} guild - The current guild
  * @param {String[]} args - The url parts
+ * @param {import('./i18n.js')} dashboardLang - The user language
  */
-function dashboard_verification(res, $, guild, args) {
+function dashboard_verification(res, $, guild, args, dashboardLang) {
 	if ( !hasPerm(guild.botPermissions, 'MANAGE_ROLES') ) {
-		createNotice($, 'missingperm', ['Manage Roles']);
-		$('#text .description').html(explanation);
+		createNotice($, 'missingperm', dashboardLang, ['Manage Roles']);
+		$('#text .description').html(dashboardLang.get('verification.explanation'));
 		$('.channel#verification').addClass('selected');
 		let body = $.html();
 		res.writeHead(200, {'Content-Length': body.length});
@@ -208,8 +207,8 @@ function dashboard_verification(res, $, guild, args) {
 	db.all( 'SELECT wiki, discord.role defaultrole, configid, verification.channel, verification.role, editcount, usergroup, accountage, rename FROM discord LEFT JOIN verification ON discord.guild = verification.guild WHERE discord.guild = ? AND discord.channel IS NULL ORDER BY configid ASC', [guild.id], function(dberror, rows) {
 		if ( dberror ) {
 			console.log( '- Dashboard: Error while getting the verifications: ' + dberror );
-			createNotice($, 'error');
-			$('#text .description').html(explanation);
+			createNotice($, 'error', dashboardLang);
+			$('#text .description').html(dashboardLang.get('verification.explanation'));
 			$('.channel#verification').addClass('selected');
 			let body = $.html();
 			res.writeHead(200, {'Content-Length': body.length});
@@ -217,8 +216,8 @@ function dashboard_verification(res, $, guild, args) {
 			return res.end();
 		}
 		if ( rows.length === 0 ) {
-			createNotice($, 'nosettings', [guild.id]);
-			$('#text .description').html(explanation);
+			createNotice($, 'nosettings', dashboardLang, [guild.id]);
+			$('#text .description').html(dashboardLang.get('verification.explanation'));
 			$('.channel#verification').addClass('selected');
 			let body = $.html();
 			res.writeHead(200, {'Content-Length': body.length});
@@ -228,7 +227,8 @@ function dashboard_verification(res, $, guild, args) {
 		var wiki = rows[0].wiki;
 		var defaultrole = rows[0].defaultrole;
 		if ( rows.length === 1 && rows[0].configid === null ) rows.pop();
-		$('<p>').text(`These are the verifications for "${guild.name}":`).appendTo('#text .description');
+		$('<p>').html(dashboardLang.get('verification.desc', true, $('<code>').text(guild.name))).appendTo('#text .description');
+		let suffix = ( args[0] === 'owner' ? '?owner=true' : '' );
 		$('#channellist #verification').after(
 			...rows.map( row => {
 				return $('<a class="channel">').attr('id', `channel-${row.configid}`).append(
@@ -237,18 +237,18 @@ function dashboard_verification(res, $, guild, args) {
 						return role.id === row.role.split('|')[0];
 					} )?.name || guild.channels.find( channel => {
 						return channel.id === row.channel.split('|')[1];
-					} )?.name || row.usergroup.split('|')[0] )}`)
-				).attr('href', `/guild/${guild.id}/verification/${row.configid}`);
+					} )?.name || row.usergroup.split('|')[( row.usergroup.startsWith('AND|') ? 1 : 0 )] )}`)
+				).attr('href', `/guild/${guild.id}/verification/${row.configid}${suffix}`);
 			} ),
 			( process.env.READONLY || rows.length >= verificationLimit[( guild.patreon ? 'patreon' : 'default' )] ? '' :
 			$('<a class="channel" id="channel-new">').append(
 				$('<img>').attr('src', '/src/channel.svg'),
-				$('<div>').text('New verification')
-			).attr('href', `/guild/${guild.id}/verification/new`) )
+				$('<div>').text(dashboardLang.get('verification.new'))
+			).attr('href', `/guild/${guild.id}/verification/new${suffix}`) )
 		);
 		if ( args[4] === 'new' && !( process.env.READONLY || rows.length >= verificationLimit[( guild.patreon ? 'patreon' : 'default' )] ) ) {
 			$('.channel#channel-new').addClass('selected');
-			createForm($, 'New Verification', {
+			createForm($, dashboardLang.get('verification.form.new'), dashboardLang, {
 				channel: '', role: '', usergroup: 'user',
 				editcount: 0, accountage: 0, rename: false, defaultrole
 			}, guild.channels, guild.roles).attr('action', `/guild/${guild.id}/verification/new`).appendTo('#text');
@@ -256,11 +256,11 @@ function dashboard_verification(res, $, guild, args) {
 		else if ( rows.some( row => row.configid.toString() === args[4] ) ) {
 			let row = rows.find( row => row.configid.toString() === args[4] );
 			$(`.channel#channel-${row.configid}`).addClass('selected');
-			createForm($, `Verification #${row.configid}`, row, guild.channels, guild.roles).attr('action', `/guild/${guild.id}/verification/${row.configid}`).appendTo('#text');
+			createForm($, dashboardLang.get('verification.form.entry', false, row.configid), dashboardLang, row, guild.channels, guild.roles).attr('action', `/guild/${guild.id}/verification/${row.configid}`).appendTo('#text');
 		}
 		else {
 			$('.channel#verification').addClass('selected');
-			$('#text .description').html(explanation);
+			$('#text .description').html(dashboardLang.get('verification.explanation'));
 		}
 		let body = $.html();
 		res.writeHead(200, {'Content-Length': body.length});
@@ -329,9 +329,13 @@ function update_verification(res, userSettings, guild, type, settings) {
 		if ( type === 'new' ) {
 			let curGuild = userSettings.guilds.isMember.get(guild);
 			if ( settings.channel.some( channel => {
-				return !curGuild.channels.some( guildChannel => guildChannel.id === channel );
+				return !curGuild.channels.some( guildChannel => {
+					return ( guildChannel.id === channel && !guildChannel.isCategory );
+				} );
 			} ) || settings.role.some( role => {
-				return !curGuild.roles.some( guildRole => guildRole.id === role && guildRole.lower );
+				return !curGuild.roles.some( guildRole => {
+					return ( guildRole.id === role && guildRole.lower );
+				} );
 			} ) ) return res(`/guild/${guild}/verification/new`, 'savefail');
 		}
 	}
@@ -494,9 +498,13 @@ function update_verification(res, userSettings, guild, type, settings) {
 			if ( newChannel.length || newRole.length ) {
 				let curGuild = userSettings.guilds.isMember.get(guild);
 				if ( newChannel.some( channel => {
-					return !curGuild.channels.some( guildChannel => guildChannel.id === channel );
+					return !curGuild.channels.some( guildChannel => {
+						return ( guildChannel.id === channel && !guildChannel.isCategory );
+					} );
 				} ) || newRole.some( role => {
-					return !curGuild.roles.some( guildRole => guildRole.id === role && guildRole.lower );
+					return !curGuild.roles.some( guildRole => {
+						return ( guildRole.id === role && guildRole.lower );
+					} );
 				} ) ) return res(`/guild/${guild}/verification/${type}`, 'savefail');
 			}
 			( newUsergroup.length ? got.get( row.wiki + 'api.php?action=query&meta=allmessages&amprefix=group-&amincludelocal=true&amenableparser=true&format=json' ).then( gresponse => {