Implement basic i18n

Not fully functional yet, so this won't/shouldn't be pushed onto the site yet (no language dropdown, there are still quite a few hardcoded strings that need to become string keys ...) but it works more than well enough to at least test and add more things as string keys that are currently hardcoded.

There are two languages provided, "en-us" and "pirate". Pirate is a test language to make sure all string keys are applying properly and all that.

As a bonus the new I18n system also offers a slightly less boneheaded way to do replacements, which is much more flexible than blind replace calls, and can take any type which is able to be stringified.

The parsing code for this is far from my best work, but it'll do for now, and seems to work okay (and has basic idiot proofing), so eh.

Supersedes #13.
This commit is contained in:
modeco80 2024-03-12 06:45:03 -04:00
parent 7691b84073
commit 6327036283
10 changed files with 315 additions and 40 deletions

View file

@ -1,5 +1,4 @@
dist
*.md
*.json
*.html
*.css

View file

@ -15,9 +15,7 @@
<meta property="og:url" content="https://computernewb.com/collab-vm/"/>
<meta property="og:description" content="A website that lets you take turns controlling online virtual machines with complete strangers!"/>
<meta property="og:site_name" content="Computernewb"/>
<!-- Using github for now until we have a canonical url -->
<meta property="og:image" content="https://raw.githubusercontent.com/computernewb/collab-vm-1.2-webapp/master/dist/desktop.png"/>
<!---->
<meta property="og:image" content="https://computernewb.com/collab-vm/desktop.png"/>
</head>
<body class="bg-dark">
<div class="modal fade" id="qemuMonitorModal" tabindex="-1" aria-hidden="true">
@ -110,20 +108,20 @@
</div>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="#">CollabVM</a>
<a class="navbar-brand" href="#"><span id="siteNameText"></span></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a id="homeBtn" href="#" class="nav-link active" aria-current="page"><i class="fa-solid fa-house"></i> Home</a>
<a id="homeBtn" href="#" class="nav-link active" aria-current="page"><i class="fa-solid fa-house"></i> <span id="homeBtnText"></span></a>
</li>
<li class="nav-item">
<a href="https://computernewb.com/collab-vm/faq/" class="nav-link"><i class="fa-solid fa-circle-question"></i> FAQ</a>
<a href="https://computernewb.com/collab-vm/faq/" class="nav-link"><i class="fa-solid fa-circle-question"></i> <span id="faqBtnText"></span></a>
</li>
<li class="nav-item">
<a href="https://computernewb.com/collab-vm/rules" class="nav-link"><i class="fa-solid fa-clipboard-check"></i> Rules</a>
<a href="https://computernewb.com/collab-vm/rules" class="nav-link"><i class="fa-solid fa-clipboard-check"></i> <span id="rulesBtnText"></span></a>
</li>
<li class="nav-item">
<a href="https://discord.gg/a4kqb4mGyX" class="nav-link"><i class="fa-brands fa-discord"></i> Discord</a>
@ -148,20 +146,20 @@
<div id="vmDisplay"></div>
<p id="turnstatus" class="text-light"></p>
<div id="voteResetPanel" class="bg-dark text-light" style="display:none;">
Do you want to reset the vm?<br/>
<button class="btn btn-success" id="voteYesBtn"><i class="fa-solid fa-check"></i> Yes<span class="badge bg-secondary" id="voteYesLabel"></span></button> <button class="btn btn-danger" id="voteNoBtn"><i class="fa-solid fa-ban"></i> No<span class="badge bg-secondary" id="voteNoLabel"></span></button><br/>
Vote ends in <span id="votetime"></span> seconds<br/>
<span id="voteResetHeaderText"></span><br/>
<button class="btn btn-success" id="voteYesBtn"><i class="fa-solid fa-check"></i> <span id="voteYesBtnText"></span><span class="badge bg-secondary" id="voteYesLabel"></span></button> <button class="btn btn-danger" id="voteNoBtn"><i class="fa-solid fa-ban"></i> <span id="voteNoBtnText"></span><span class="badge bg-secondary" id="voteNoLabel"></span></button><br/>
<span id="voteTimeText"></span>
<div id="forceVotePanel">
<button class="btn btn-info" id="forceVoteYesBtn"><i class="fa-solid fa-check"></i> Pass Vote</button>
<button class="btn btn-info" id="forceVoteNoBtn"><i class="fa-solid fa-ban"></i> Cancel Vote</button>
<button class="btn btn-info" id="forceVoteYesBtn"><i class="fa-solid fa-check"></i> <span id="passVoteBtnText"></span></button>
<button class="btn btn-info" id="forceVoteNoBtn"><i class="fa-solid fa-ban"></i> <span id="cancelVoteBtnText"></span></button>
</div>
</div>
<div id="btns">
<button class="btn btn-secondary" id="takeTurnBtn"><i class="fa-solid fa-computer-mouse"></i> <span id="turnBtnText"></span></button>
<button class="btn btn-secondary" id="oskBtn"><i class="fa-solid fa-keyboard"></i> Keyboard</button>
<button class="btn btn-secondary" id="changeUsernameBtn"><i class="fa-solid fa-signature"></i> Change Username</button>
<button class="btn btn-secondary" id="voteResetButton"><i class="fa-solid fa-rotate-left"></i> Vote for Reset</button>
<button class="btn btn-secondary" id="screenshotButton"><i class="fa-solid fa-camera"></i> Screenshot</button>
<button class="btn btn-secondary" id="changeUsernameBtn"><i class="fa-solid fa-signature"></i> <span id="changeUsernameBtnText"></span></button>
<button class="btn btn-secondary" id="voteResetButton"><i class="fa-solid fa-rotate-left"></i> <span id="voteForResetBtnText"></span></button>
<button class="btn btn-secondary" id="screenshotButton"><i class="fa-solid fa-camera"></i> <span id="screenshotBtnText"></span></button>
<button class="btn btn-secondary" id="ctrlAltDelBtn"><i class="fa-solid fa-gear"></i> Ctrl+Alt+Del</button>
<div id="staffbtns">
<button class="btn btn-secondary" id="restoreBtn"><i class="fa-solid fa-rotate-left"></i> Restore</button>
@ -191,7 +189,7 @@
<div class="table-responsive username-table">
<table class="table table-hover table-dark table-borderless">
<thead>
<th><i class="fa-solid fa-user"></i> Users Online (<span id="onlineusercount"></span>)</th>
<th><i class="fa-solid fa-user"></i> <span id="usersOnlineText"></span> (<span id="onlineusercount"></span>)</th>
</thead>
<tbody id="userlist"></tbody>
</table>

9
src/ts/StringLike.ts Normal file
View file

@ -0,0 +1,9 @@
// TODO: `Object` has a toString(), but we should probably gate that off
/// Interface for things that can be turned into strings
export interface ToStringable {
toString(): string;
}
/// A type for strings, or things that can (in a valid manner) be turned into strings
export type StringLike = string | ToStringable;

209
src/ts/i18n.ts Normal file
View file

@ -0,0 +1,209 @@
import { StringLike } from './StringLike';
// Nice little string key helper
export enum I18nStringKey {
kSiteName = 'kSiteName',
kHomeButton = 'kHomeButton',
kFAQButton = 'kFAQButton',
kRulesButton = 'kRulesButton',
kVMResetTitle = 'kVMResetTitle',
kGenericYes = 'kGenericYes',
kGenericNo = 'kGenericNo',
kVMVoteTime = 'kVMVoteTime',
kPassVoteButton = 'kPassVoteButton',
kCancelVoteButton = 'kCancelVoteButton',
kTakeTurnButton = 'kTakeTurnButton',
kEndTurnButton = 'kEndTurnButton',
kChangeUsernameButton = 'kChangeUsernameButton',
kVoteButton = 'kVoteButton',
kScreenshotButton = 'kScreenshotButton',
kUsersOnlineHeading = 'kUsersOnlineHeading',
kTurnTime = 'kTurnTime',
kWaitingTurnTime = 'kWaitingTurnTime',
kVoteCooldown = 'kVoteCooldown',
kEnterNewUsername = 'kEnterNewUsername'
}
// This models the JSON structure.
export type Language = {
languageName: string;
translatedLanguageName: string;
author: string;
stringKeys: {
// This is fancy typescript speak for
// "any string index returns a string",
// which is our expectation.
// See https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures if this is confusing.
[key: string]: string;
};
};
// ID for fallback language
const fallbackId = 'fallback';
// This language is provided in the webapp itself just in case language stuff fails
const fallbackLanguage: Language = {
languageName: 'Fallback',
translatedLanguageName: 'Fallback',
author: 'Computernewb',
stringKeys: {
kTitle: 'CollabVM',
kHomeButton: 'Home',
kFAQButton: 'FAQ',
kRulesButton: 'Rules',
kVMResetTitle: 'Do you want to reset the VM?',
kGenericYes: 'Yes',
kGenericNo: 'No',
kVMVoteTime: 'Vote ends in {0} seconds',
kPassVoteButton: 'Pass Vote',
kCancelVoteButton: 'Cancel Vote',
kTakeTurnButton: 'Take Turn',
kEndTurnButton: 'End Turn',
kChangeUsernameButton: 'Change Username',
kVoteButton: 'Vote For Reset',
kScreenshotButton: 'Screenshot',
kUsersOnlineHeading: 'Users Online:',
kTurnTime: 'Turn expires in {0} seconds.',
kWaitingTurnTime: 'Waiting for turn in {0} seconds.',
kVoteCooldown: 'Please wait {0} seconds before starting another vote.',
kEnterNewUsername: 'Enter a new username, or leave the field blank to be assigned a guest username'
}
};
interface StringMap {
[k: string]: string;
}
/// our fancy internationalization helper.
export class I18n {
// The language data itself
private lang: Language = fallbackLanguage;
// the ID of the language
private langId: string = fallbackId;
async LoadLanguageFile(id: string) {
let languageData = await I18n.LoadLanguageFileImpl(id);
this.SetLanguage(languageData, id);
}
async initWithLanguage(id: string) {
try {
await this.LoadLanguageFile(id);
console.log("i18n initalized for", id, "sucessfully!");
} catch (e) {
alert(`There was an error loading the language file for \"${id}\". Please tell a site admin this happened, and give them the following information: \"${(e as Error).message}\"`);
// force set the language to fallback
this.SetLanguage(fallbackLanguage, fallbackId);
}
}
private static async LoadLanguageFileImpl(id: string): Promise<Language> {
let path = `./lang/${id}.json`;
let res = await fetch(path);
if (!res.ok) throw new Error(res.statusText);
return (await res.json()) as Language;
}
private SetLanguage(lang: Language, id: string) {
let lastId = this.langId;
this.langId = id;
this.lang = lang;
// Only replace static strings
if (this.langId != lastId) this.ReplaceStaticStrings();
}
// Replaces static strings that we don't recompute
private ReplaceStaticStrings() {
const kDomIdtoStringMap: StringMap = {
siteNameText: I18nStringKey.kSiteName,
homeBtnText: I18nStringKey.kHomeButton,
faqBtnText: I18nStringKey.kFAQButton,
rulesBtnText: I18nStringKey.kRulesButton,
usersOnlineText: I18nStringKey.kUsersOnlineHeading,
voteResetHeaderText: I18nStringKey.kVMResetTitle,
voteYesBtnText: I18nStringKey.kGenericYes,
voteNoBtnText: I18nStringKey.kGenericNo,
changeUsernameBtnText: I18nStringKey.kChangeUsernameButton,
voteForResetBtnText: I18nStringKey.kVoteButton,
screenshotBtnText: I18nStringKey.kScreenshotButton,
// admin stuff
passVoteBtnText: I18nStringKey.kPassVoteButton,
cancelVoteBtnText: I18nStringKey.kCancelVoteButton
};
for (let domId of Object.keys(kDomIdtoStringMap)) {
let element = document.getElementById(domId);
if (element == null) {
alert('Uhh!! THIS SHOULD NOT BE SEEN!! IF YOU DO YELL LOUDLY');
return;
}
// Do the magic.
element.innerText = this.GetString(kDomIdtoStringMap[domId]);
}
}
// Gets a string, which also allows replacing by index with the given replacements.
GetString(key: string, ...replacements: StringLike[]): string {
let replacementStringArray: Array<string> = [...replacements].map((el) => {
// This catches cases where the thing already is a string
if (typeof el == 'string') return el as string;
return el.toString();
});
let val = this.lang.stringKeys[key];
if (val == null) {
let fallback = fallbackLanguage.stringKeys[key];
if (fallback == null) return 'UH OH WORM';
else return fallback;
}
for (let i = 0; i < val.length; ++i) {
if (val[i] == '{') {
let replacementStart = i;
let foundReplacementEnd = false;
if (i + 1 > val.length) {
throw new Error('Cutoff/invalid replacement');
}
// Try and find the replacement end
for (let j = i + 1; j < val.length; ++j) {
if (val[j] == '}') {
foundReplacementEnd = true;
i = j;
break;
}
}
if (!foundReplacementEnd) throw new Error('Invalid replacement, has no "}" to terminate it');
// Get the beginning and trailer
let beginning = val.substring(0, replacementStart);
let trailer = val.substring(replacementStart + 3);
let replacementIndex = parseInt(val.substring(replacementStart + 1, i));
if (Number.isNaN(replacementIndex) || replacementIndex > replacementStringArray.length) throw new Error('Invalid replacement');
// This is seriously the only decent way to do this in javascript
// thanks brendan eich (replace this thanking with more choice words in your head)
val = beginning + replacementStringArray[replacementIndex] + trailer;
}
}
return val;
}
}
export let TheI18n = new I18n();

View file

@ -11,6 +11,7 @@ import VoteStatus from './protocol/VoteStatus.js';
import * as bootstrap from 'bootstrap';
import MuteState from './protocol/MuteState.js';
import { Unsubscribe } from 'nanoevents';
import { I18nStringKey, TheI18n } from './i18n.js';
// Elements
const w = window as any;
@ -39,7 +40,7 @@ const elements = {
voteNoBtn: document.getElementById('voteNoBtn') as HTMLButtonElement,
voteYesLabel: document.getElementById('voteYesLabel') as HTMLSpanElement,
voteNoLabel: document.getElementById('voteNoLabel') as HTMLSpanElement,
votetime: document.getElementById('votetime') as HTMLSpanElement,
voteTimeText: document.getElementById('voteTimeText') as HTMLSpanElement,
loginModal: document.getElementById('loginModal') as HTMLDivElement,
adminPassword: document.getElementById('adminPassword') as HTMLInputElement,
loginButton: document.getElementById('loginButton') as HTMLButtonElement,
@ -323,6 +324,7 @@ async function openVM(vm: VM): Promise<void> {
unsubscribeCallbacks.push(VM!.on('rename', (oldname, newname, selfrename) => userRenamed(oldname, newname, selfrename)));
unsubscribeCallbacks.push(
VM!.on('renamestatus', (status) => {
// TODO: i18n these
switch (status) {
case 'taken':
alert('That username is already taken');
@ -339,7 +341,7 @@ async function openVM(vm: VM): Promise<void> {
unsubscribeCallbacks.push(VM!.on('turn', (status) => turnUpdate(status)));
unsubscribeCallbacks.push(VM!.on('vote', (status: VoteStatus) => voteUpdate(status)));
unsubscribeCallbacks.push(VM!.on('voteend', () => voteEnd()));
unsubscribeCallbacks.push(VM!.on('votecd', (cd) => window.alert(`Please wait ${cd} seconds before starting another vote.`)));
unsubscribeCallbacks.push(VM!.on('votecd', (voteCooldown) => window.alert(TheI18n.GetString(I18nStringKey.kVoteCooldown, voteCooldown))));
unsubscribeCallbacks.push(VM!.on('login', (rank: Rank, perms: Permissions) => onLogin(rank, perms)));
unsubscribeCallbacks.push(
VM!.on('close', () => {
@ -554,7 +556,7 @@ function turnUpdate(status: TurnStatus) {
user.element.classList.remove('user-turn', 'user-waiting');
user.element.setAttribute('data-cvm-turn', '-1');
}
elements.turnBtnText.innerHTML = 'Take Turn';
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kTakeTurnButton);
enableOSK(false);
if (status.user !== null) {
@ -570,14 +572,14 @@ function turnUpdate(status: TurnStatus) {
if (status.user?.username === w.username) {
turn = 0;
turnTimer = status.turnTime! / 1000;
elements.turnBtnText.innerHTML = 'End Turn';
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kEndTurnButton);
VM!.canvas.classList.add('focused');
enableOSK(true);
}
if (status.queue.some((u) => u.username === w.username)) {
turn = status.queue.findIndex((u) => u.username === w.username) + 1;
turnTimer = status.queueTime! / 1000;
elements.turnBtnText.innerHTML = 'End Turn';
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kEndTurnButton);
VM!.canvas.classList.add('waiting');
}
if (turn === -1) elements.turnstatus.innerText = '';
@ -600,7 +602,7 @@ function voteUpdate(status: VoteStatus) {
function updateVoteEndTime() {
voteTimer--;
elements.votetime.innerText = voteTimer.toString();
elements.voteTimeText.innerText = TheI18n.GetString(I18nStringKey.kVMVoteTime, voteTimer);
if (voteTimer === 0) clearInterval(voteInterval);
}
@ -615,8 +617,8 @@ function turnIntervalCb() {
}
function setTurnStatus() {
if (turn === 0) elements.turnstatus.innerText = `Turn expires in ${turnTimer} seconds`;
else elements.turnstatus.innerText = `Waiting for turn in ${turnTimer} seconds`;
if (turn === 0) elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kTurnTime, turnTimer);
else elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kWaitingTurnTime, turnTimer);
}
function sendChat() {
@ -635,7 +637,7 @@ elements.chatinput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendChat();
});
elements.changeUsernameBtn.addEventListener('click', () => {
let newname = prompt('Enter new username, or leave blank to be assigned a guest username', w.username);
let newname = prompt(TheI18n.GetString(I18nStringKey.kEnterNewUsername), w.username);
if (newname === w.username) return;
VM?.rename(newname);
});
@ -730,7 +732,7 @@ function userModOptions(user: { user: User; element: HTMLTableRowElement }) {
td.setAttribute('aria-expanded', 'false');
let ul = document.createElement('ul');
ul.classList.add('dropdown-menu', 'dropdown-menu-dark', 'table-dark', 'text-light');
if (perms.bypassturn) addUserDropdownItem(ul, 'End Turn', () => VM!.endTurn(user.user.username));
if (perms.bypassturn) addUserDropdownItem(ul, TheI18n.GetString(I18nStringKey.kEndTurnButton), () => VM!.endTurn(user.user.username));
if (perms.ban) addUserDropdownItem(ul, 'Ban', () => VM!.ban(user.user.username));
if (perms.kick) addUserDropdownItem(ul, 'Kick', () => VM!.kick(user.user.username));
if (perms.rename)
@ -838,6 +840,16 @@ w.VMName = null;
// Load all VMs
loadList();
// Set a default internationalization language if not specified
let lang = window.localStorage.getItem('i18n-lang');
if (lang == null) {
lang = 'en-us';
window.localStorage.setItem('i18n-lang', lang);
}
// Initalize the internationalization system
TheI18n.initWithLanguage(lang);
// Welcome modal
let noWelcomeModal = window.localStorage.getItem('no-welcome-modal');
if (noWelcomeModal !== '1') {

View file

@ -1,4 +1,4 @@
import { createNanoEvents, Emitter, DefaultEvents } from 'nanoevents';
import { createNanoEvents, Emitter, DefaultEvents, Unsubscribe } from 'nanoevents';
import * as Guacutils from './Guacutils.js';
import VM from './VM.js';
import { User } from './User.js';
@ -8,15 +8,7 @@ import Mouse from './mouse.js';
import GetKeysym from '../keyboard.js';
import VoteStatus from './VoteStatus.js';
import MuteState from './MuteState.js';
// TODO: `Object` has a toString(), but we should probably gate that off
/// Interface for things that can be turned into strings
interface ToStringable {
toString(): string;
}
/// A type for strings, or things that can (in a valid manner) be turned into strings
type StringLike = string | ToStringable;
import { StringLike } from '../StringLike.js';
export interface CollabVMClientEvents {
open: () => void;
@ -580,11 +572,11 @@ export default class CollabVMClient {
this.send('admin', AdminOpcode.HideScreen, hidden ? '1' : '0');
}
private onInternal<E extends keyof CollabVMClientPrivateEvents>(event: E, callback: CollabVMClientPrivateEvents[E]) {
private onInternal<E extends keyof CollabVMClientPrivateEvents>(event: E, callback: CollabVMClientPrivateEvents[E]): Unsubscribe {
return this.internalEmitter.on(event, callback);
}
on<E extends keyof CollabVMClientEvents>(event: E, callback: CollabVMClientEvents[E]) {
on<E extends keyof CollabVMClientEvents>(event: E, callback: CollabVMClientEvents[E]): Unsubscribe {
return this.publicEmitter.on(event, callback);
}
}

View file

@ -28,7 +28,7 @@ export function decode(string: string): string[] {
export function encode(...string: string[]): string {
let command = '';
for (var i = 0; i < string.length; i++) {
for (let i = 0; i < string.length; i++) {
let current = string[i];
command += current.toString().length + '.' + current;
command += i < string.length - 1 ? ',' : ';';

View file

28
static/lang/en-us.json Normal file
View file

@ -0,0 +1,28 @@
{
"languageName": "English (US)",
"translatedLanguageName": "English (US)",
"author": "Computernewb",
"stringKeys": {
"kSiteName": "CollabVM",
"kHomeButton": "Home",
"kFAQButton": "FAQ",
"kRulesButton": "Rules",
"kVMResetTitle": "Do you want to reset the VM?",
"kGenericYes": "Yes",
"kGenericNo": "No",
"kVMVoteTime": "Vote ends in {0} seconds",
"kPassVoteButton": "Pass Vote",
"kCancelVoteButton": "Cancel Vote",
"kTakeTurnButton": "Take Turn",
"kEndTurnButton": "End Turn",
"kChangeUsernameButton": "Change Username",
"kVoteButton": "Vote For Reset",
"kScreenshotButton": "Screenshot",
"kUsersOnlineHeading": "Users Online:",
"kTurnTime": "Turn expires in {0} seconds.",
"kWaitingTurnTime": "Waiting for turn in {0} seconds.",
"kVoteCooldown": "Please wait {0} seconds before starting another vote.",
"kEnterNewUsername": "Enter a new username, or leave the field blank to be assigned a guest username"
}
}

28
static/lang/pirate.json Normal file
View file

@ -0,0 +1,28 @@
{
"languageName": "Pirate",
"translatedLanguageName": "Arrrghh, matey!",
"author": "Computernewb",
"stringKeys": {
"kSiteName": "Argh, matey, it's CollabVM!",
"kHomeButton": "Arrrghh, set sail",
"kFAQButton": "Arrrgh, get your Map",
"kRulesButton": "Arrrrghh, get the Etiquette Book",
"kVMResetTitle": "Argh, matey, this VM is broken. Would you like to particpate in the VM War?",
"kGenericYes": "Arrrghh, yay!",
"kGenericNo": "Arrrrgh, nay!",
"kVMVoteTime": "Argghh, the cannon fight ends in {0} seconds",
"kPassVoteButton": "Rig the vote",
"kCancelVoteButton": "Ignore the vote",
"kTakeTurnButton": "Take the Wheel",
"kEndTurnButton": "Give Up the Wheel",
"kChangeUsernameButton": "Argh, select a new pirate name",
"kVoteButton": "Argh, my matey, it's broken...",
"kScreenshotButton": "Take a Polaroid",
"kUsersOnlineHeading": "Pirate Friends Online:",
"kTurnTime": "Arrrrgh, your wheel rights expire in {0} seconds.",
"kWaitingTurnTime": "Waiting for the wheel in {0} seconds.",
"kVoteCooldown": "Arrgh matey, you need to wait {0} seconds to vote again.",
"kEnterNewUsername": "Arggghh, matey, what would you like to be known as?"
}
}