<|start_filename|>src/background.js<|end_filename|>
(async function BackgroundScript()
{
	let browserLanguageTag = browser.i18n.getUILanguage();
	let [browserLanguage, secondaryTag] = browserLanguageTag.split("-");

	let appLanguage = "en";
	if (WordologyStrings.SUPPORTED_LANGUAGES.includes(browserLanguage))
	{
		appLanguage = browserLanguage;
	}

	var OM = new OptionsManager();
	await OM.initializeStorage(appLanguage);

	var TM = new ToggleManagerBackground("ToggleManager",
		{
			notReadyIcon: "./icons/icon_inactive_32.png",
			offIcon: "./icons/icon_off_32.png",
			runningIcon: "./icons/icon_inactive_32.png",
			onIcon: "./icons/icon_on_32.png"
		}
	);

	var profileId = await OM.getCurrentProfileId();
	var DF = new DictionaryFetcherBackground("DictionaryFetcher");
	OM.addOnChangeProfileListener(onChangeProfile);

	if (profileId !== null)
	{
		DF.setDictionary(new Dictionary(profileId));
	}
	else
	{
		TM.loggedOut();
	}

	function onChangeProfile(profileId) {
		if (profileId !== null)
		{
			DF.setDictionary(new Dictionary(profileId));
			TM.loggedIn();
		}
		else
		{
			TM.loggedOut();
		}
	}
})();


<|start_filename|>src/settings/options-tab/options-tab.component.js<|end_filename|>
class OptionsTabController
{
    constructor($scope)
    {
        this.OM = new OptionsManager();
        this.options = null;
        this.oldOptions = null;
        this.optionsLoaded = false;
        this.prefixInput = "";
        this.suffixInput = "";
        this.$scope = $scope;
        this.reloadOptions();

        this.prefixColumns = [
            { name: "prefix", label: "Prefix", searchable: false }
        ];
        this.suffixColumns = [
            { name: "suffix", label: "Suffix", searchable: false }
        ];

        this.onDeletePrefix = this.onDeletePrefix.bind(this);
        this.onDeleteSuffix = this.onDeleteSuffix.bind(this);

        this.$scope.$on("reload", () => this.reloadOptions());
    }

    getPrefixColumns()
    {
        this.prefixColumns[0].label = this.strings.PREFIX;
        return this.prefixColumns;
    }

    getSuffixColumns()
    {
        this.suffixColumns[0].label = this.strings.SUFFIX;
        return this.suffixColumns;
    }

    addPrefix()
    {
        var backup = this.blacklistedPrefixes;
        this.blacklistedPrefixes = null;
        setTimeout(
            () =>
            {
                this.blacklistedPrefixes = backup;
                this.blacklistedPrefixes.push({ prefix: this.prefixInput });
                this.prefixInput = "";
                this.OM.setOption("blacklistedPrefixes", this.blacklistedPrefixes.map(row => row.prefix));
                this.$scope.$digest();
            }
        );
    }

    async reloadOptions()
    {
        try
        {
            var options = await this.OM.getOptions();
            this.options = options;
            this.oldOptions = Object.assign({}, options);
            this.optionsLoaded = true;
            this.blacklistedPrefixes = null;
            this.whitelistedSuffixes = null;
            setTimeout(
                () =>
                {
                    this.blacklistedPrefixes = this.options.blacklistedPrefixes.map(prefix => ({ prefix: prefix}));
                    this.whitelistedSuffixes = this.options.whitelistedSuffixes.map(suffix => ({ suffix: suffix}));
                    this.$scope.$digest();
                }
            );
            this.$scope.$digest();
        }
        catch
        {
            this.options = null;
            this.blacklistedPrefixes = [];
            this.whitelistedSuffixes = [];
            this.$scope.$digest();
        }
    }

    onDeletePrefix(deletedPrefix)
    {
        var newList = this.blacklistedPrefixes.map(row => row.prefix).filter(prefix => prefix !== deletedPrefix);
        this.OM.setOption("blacklistedPrefixes", newList);
    }

    onDeleteSuffix(deletedSuffix)
    {
        var newList = this.whitelistedSuffixes.map(row => row.suffix).filter(suffix => suffix !== deletedSuffix);
        this.OM.setOption("whitelistedSuffixes", newList);
    }

    addSuffix()
    {
        var backup = this.whitelistedSuffixes;
        this.whitelistedSuffixes = null;
        setTimeout(
            () =>
            {
                this.whitelistedSuffixes = backup;
                this.whitelistedSuffixes.push({ suffix: this.suffixInput });
                this.suffixInput = "";
                this.OM.setOption("whitelistedSuffixes", this.whitelistedSuffixes.map(row => row.suffix));
                this.$scope.$digest();
            }
        );
    }

    resetColorsToDefault()
    {
        var defaultColors = {
            notDefinedColor     : OptionsManager.DefaultProfileOptions.notDefinedColor,
            definedColor        : OptionsManager.DefaultProfileOptions.definedColor,
            similarColor        : OptionsManager.DefaultProfileOptions.similarColor,
            notDefinedOpacity   : OptionsManager.DefaultProfileOptions.notDefinedOpacity,
            definedOpacity      : OptionsManager.DefaultProfileOptions.definedOpacity,
            similarOpacity      : OptionsManager.DefaultProfileOptions.similarOpacity
        };
        this.OM.setOptions(defaultColors);
        Object.assign(this.options, defaultColors);
        Object.assign(this.oldOptions, defaultColors);
        this.$scope.$digest();
    }

    onUserChangeOption(key, newValue)
    {
        this.OM.setOption(key, newValue);
    }

    $doCheck()
    {
        if (this.optionsLoaded)
        {
            for (let key in this.options)
            {
                if (this.options[key] !== this.oldOptions[key])
                {
                    this.oldOptions[key] = this.options[key];
                    this.onUserChangeOption(key, this.options[key]);
                }
            }
        }
    }
}

app.component("optionsTab",
    {
        controller: OptionsTabController,
        templateUrl: "options-tab/options-tab.html",
        controllerAs: "ctrl",
        bindings: {
            strings: "<"
        }
    }
);

<|start_filename|>src/OptionsManager.js<|end_filename|>
class OptionsManager
{
	constructor()
	{
		this.dataSetPending = false;
	}

	async initializeStorage(language)
	{
		var localData = await browser.storage.local.get();
		var modificationsNecessary = false

		const defaultOptions = {
			version  : "1.2",
			profile  : 0,
			language : language,
			profiles : [{id: 0, name: "Default Profile"}]
		};

		for (let optionName in defaultOptions)
		{
			if (localData[optionName] === undefined)
			{
				localData[optionName] = defaultOptions[optionName];
				modificationsNecessary = true;
			}
		}

        for (let profile of localData["profiles"])
        {
            var optionsKey = profile.id + "/options";
            if (localData[optionsKey] === undefined)
            {
                localData[optionsKey] = OptionsManager.DefaultProfileOptions;
                modificationsNecessary = true;
            }
            else
            {
                for (var optionName in OptionsManager.DefaultProfileOptions)
                {
                    if (localData[optionsKey][optionName] === undefined)
                    {
                        localData[optionsKey][optionName] = OptionsManager.DefaultProfileOptions[optionName];
                        modificationsNecessary = true;
                    }
                }
            }
        }

		if (modificationsNecessary)
		{
			return await this.safeLocalStorageSet(localData);
		}
    }

    addOnChangeProfileListener(listener)
    {
        browser.storage.onChanged.addListener(
            (changes, storageAreaName) => {
                if (storageAreaName === "local")
                {
					if (changes.profile !== undefined)
					{
						if (changes.profile.newValue !== changes.profile.oldValue)
						{
							listener(changes.profile.newValue);
						}
					}
                }
            }
        );
    }

	async getOptions()
	{
		var localData = await browser.storage.local.get();
		var id = await this.getCurrentProfileId();
		var options = localData[id + "/options"];
		return options;
	}

	async getGlobalOptions()
	{
		var localData = await browser.storage.local.get();
		var publicOptions = {};
		for (let optionName in localData)
		{
			if (OptionsManager.PublicGlobalOptions.includes(optionName))
			{
				publicOptions[optionName] = localData[optionName];
			}
		}
		return publicOptions;
	}

	async getCurrentProfileId()
	{
        var localData = await browser.storage.local.get();
		return localData.profile;
	}

	async getProfileName(id)
	{
        var localData = await browser.storage.local.get();
		var profiles = localData.profiles;
		var filteredProfiles = profiles.filter(profile => profile.id === id)
		if (filteredProfiles.length === 0)
		{
			return null;
		}
		else
		{
			return filteredProfiles[0].name;
		}
	}

	async getProfiles()
	{
        var localData = await browser.storage.local.get();
		return localData.profiles;
	}

	async setOptions(valuesToSet)
	{
		if (this.dataSetPending)
		{
			throw new Error(
				`Attempted to call OptionsManager.setOption() while data set was still pending.
				Due to the way localStorage works, you must wait for the previous set to complete
				before setting again.`
			);
		}

		var localData = await browser.storage.local.get();
		var profileId = localData["profile"];

		if (profileId === null)
		{
			throw new Error("Attempted to set options while not logged into a profile.");
		}

		var currentOptions = localData[profileId + "/options"];
		var modifiedOptions = Object.assign({}, currentOptions, valuesToSet);
		var dataToSet = {};
		dataToSet[profileId + "/options"] = modifiedOptions;
		return await this.safeLocalStorageSet(dataToSet);
	}

	async setOption(optionName, value)
	{
		var valuesToSet = {};
		valuesToSet[optionName] = value;
		return await this.setOptions(valuesToSet);
	}

	async setGlobalOption(optionName, value)
	{
		if (this.dataSetPending)
		{
			throw new Error(
				`Attempted to call OptionsManager.setGlobalOption() while data set was still pending.
				Due to the way localStorage works, you must wait for the previous set to complete
				before setting again.`
			);
		}

		var localData = await browser.storage.local.get();
		localData[optionName] = value;
		return await this.safeLocalStorageSet(localData);
	}

	async setCurrentProfile(id)
	{
		return await this.safeLocalStorageSet({ profile: id });
	}

	async deleteProfile(id)
	{
		var profiles = (await browser.storage.local.get("profiles")).profiles;
		var newProfiles = profiles.filter(profile => profile.id !== id);
		var currentProfile = await this.getCurrentProfileId();
		var newProfile;
		if (currentProfile === id)
		{
			newProfile = null;
		}
		else
		{
			newProfile = currentProfile;
		}

		var dataToSet = {
			profile: newProfile,
			profiles: newProfiles
		}

		await this.safeLocalStorageSet(dataToSet);

		return await browser.storage.local.remove(id + "/options");
	}

	async createProfile(name)
	{
		var profiles = await this.getProfiles();
		var maximum = 0;
		for (let profile of profiles)
		{
			if (profile.id > maximum)
			{
				maximum = profile.id
			}
		}
		var newId = maximum + 1;
		var newProfile = { id: newId, name: name };
		profiles.push(newProfile);

		var dataToSet = {};
		dataToSet["profiles"] = profiles;
		dataToSet[newId + "/options"] = OptionsManager.DefaultProfileOptions;

		await this.safeLocalStorageSet(dataToSet);
		return newId;
	}

	async renameProfile(id, name)
	{
		var profiles = await this.getProfiles();
		for (let profile of profiles)
		{
			if (profile.id === id)
			{
				profile.name = name;
			}
		}
		return await this.safeLocalStorageSet({ profiles: profiles });
	}

	async safeLocalStorageSet(dataObject)
	{
		if (this.dataSetPending)
		{
			throw new Error(
				`Attempted to call OptionsManager.setOption() while data set was still pending.
				Due to the way localStorage works, you must wait for the previous set to complete
				before setting again.`
			);
		}
		else
		{
			this.dataSetPending = true;
			return browser.storage.local.set(dataObject).then(() => {this.dataSetPending = false});
		}
	}

	async safeLocalStorageSetKey(key, value)
	{
		if (this.dataSetPending)
		{
			throw new Error(
				`Attempted to call OptionsManager.setOption() while data set was still pending.
				Due to the way localStorage works, you must wait for the previous set to complete
				before setting again.`
			);
		}
		else
		{
			this.dataSetPending = true;
			return browser.storage.local.set(key, value).then(() => {this.dataSetPending = false});
		}
	}
}

OptionsManager.PublicGlobalOptions = [
	"language"
]

OptionsManager.DefaultProfileOptions =
{
	blacklistedPrefixes : [],
	whitelistedSuffixes : [],
	minPrefixLength     : 4,
	maxSuffixLength     : 4,
	useInflection       : true,
	lookupURL           : "https://translate.google.com/#auto/en/$",
	notDefinedColor     : "#FF6464",
	definedColor        : "#64FF32",
	similarColor        : "#6496FF",
	notDefinedOpacity   : 35,
	definedOpacity      : 35,
	similarOpacity      : 35,
	separatorCharacters : ""
};

<|start_filename|>src/WordEditDialog.css<|end_filename|>
.vex
{
	font-size: 12pt;
	font-variant: normal;
}
.vex .word
{
	display: block;
	text-align: center;
	font-weight: bold;
}

.vex .suggestionNote
{
	font-size: 0.75em;
	color: #777;
}

.vex a
{
	color: #3288e6;
	text-decoration: underline;
}

.vex-overlay-reset
{
	width: 100%;
	height: 100%;
	margin: 0px;
	padding: 0px;
	border: none;
	border-radius: 0px;
	background-color: none;
}

.vex-content-reset
{
	margin: 0px;
	padding: 0px;
	border: none;
	border-radius: 0px;
	background-color: none;
}


<|start_filename|>src/settings/app.js<|end_filename|>
const app = angular.module("app", ["ngTable"]);

app.filter("trust",
    function ($sce) {
        return (html => $sce.trustAsHtml(html));
    }
);

<|start_filename|>src/content_script.js<|end_filename|>
(function ContentScript()
{
	const LATIN_EXTENDED = "A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF\\u0100-\\u017F\\u0180-\\u024F";
	const CYRILLIC = "\\u0400-\\u04FF";
	const GREEK = "\\u0370-\\u03FF";
	const KOREAN = "\\u3131-\\uD79D";
	const ALPHABET = LATIN_EXTENDED + CYRILLIC + GREEK + KOREAN;

	var loggedIn = true;

	var DF = new DictionaryFetcherPage("DictionaryFetcher");
	var WM = null;
	var TM = new ToggleManagerPage("ToggleManager",
		{
			onFirstOn             : init,
			onToggleOn            : () => WM.show(),
			onToggleOff           : () => WM.hide(),
			onLoggedOut           : () => { loggedIn = false; WM.hide(); },
			onLoggedOutPress      : showLoggedOutPopup,
			onChangedProfile      : () => { loggedIn = false; WM.hide(); },
			onChangedProfilePress : showChangedProfilePopup
		}
	);
	var options;

	async function init()
	{
		options = await (new OptionsManager()).getOptions();

		WM = new WordManager(ALPHABET + options.separatorCharacters);

		document.querySelector(":root").style.setProperty(
			"--wordology-not-defined-color",
			cssStringFromHex(options.notDefinedColor, options.notDefinedOpacity/100)
		);
		document.querySelector(":root").style.setProperty(
			"--wordology-defined-color",
			cssStringFromHex(options.definedColor, options.definedOpacity/100)
		);
		document.querySelector(":root").style.setProperty(
			"--wordology-similar-color",
			cssStringFromHex(options.similarColor, options.similarOpacity/100)
		);

		if (!loggedIn)
		{
			return;
		}

		WM.processNode(document);
		var listOfWordsOnPage = WM.getWords();

		var dictionaryFetcherRequest =
			{
				words   : listOfWordsOnPage,
				options : options
			};
		var dictOfMatches = await DF.getMatches(dictionaryFetcherRequest);

		if (!loggedIn)
		{
			WM.hide();
			return;
		}

		WM.setData(dictOfMatches);

		for (var wordElement of WM.getWordElements())
		{
			wordElement.click.addListener(onClickWord);
			wordElement.mouseOver.addListener(onMouseOverWord);
			wordElement.mouseOut.addListener(onMouseOutWord);
		}
	}

	function onMouseOverWord(e)
	{
		if (e.target.match)
		{
			e.target.showPopup();
		}
	}

	function onMouseOutWord(e)
	{
		e.target.hidePopup();
	}

	async function onClickWord(e)
	{
		var wordElement = e.target;

		if (window.getSelection().toString().length === 0)
		{
			e.domEvent.preventDefault();
			e.domEvent.stopPropagation();

			var userResponse = await WordEditDialog.open(
				{
					word: wordElement.getWord(),
					match: wordElement.getMatch()
				}
			);

			if (userResponse.definition)
			{
				WM.updateDefinition(wordElement.getWord(), userResponse.definition, options);
				var entry = {word: wordElement.getWord(), definition: userResponse.definition};
				DF.setData([entry]);
			}
		}
	}

	async function showLoggedOutPopup()
	{
		let options = await (new OptionsManager()).getGlobalOptions();
		const strings = WordologyStrings.getStrings(options.language);
		message = strings.LOGGED_OUT;
		vex.dialog.buttons.YES.text = strings.OK;
        vex.dialog.buttons.NO.text = strings.CANCEL;
		vex.dialog.open({
			input: message
		});
	}

	async function showChangedProfilePopup()
	{
		let options = await (new OptionsManager()).getGlobalOptions();
		const strings = WordologyStrings.getStrings(options.language);
		message = strings.CHANGED_PROFILE;
		vex.dialog.buttons.YES.text = strings.OK;
        vex.dialog.buttons.NO.text = strings.CANCEL;
		vex.dialog.open({
			input: message
		});
	}

	function cssStringFromHex(hexstring, opacity)
	{
		var red   = parseInt(hexstring.substring(1, 3), 16);
		var green = parseInt(hexstring.substring(3, 5), 16);
		var blue  = parseInt(hexstring.substring(5, 7), 16);
		return `rgba(${red}, ${green}, ${blue}, ${opacity})`;
	}
})();


<|start_filename|>src/manifest-chrome.json<|end_filename|>
{
	"manifest_version": 2,

	"name": "Wordology",
	"description": "Adds a calque to foreign-language web-pages to aid language learning.",
	"version": "2",

	"icons":
	{
		"48": "./icons/icon48.png",
		"96": "./icons/icon96.png"
	},

	"background": {
		"scripts": [
			"browser-polyfill.min.js",
			"strings.js",
			"OptionsManager.js",
			"messages.js",
			"Dictionary.js",
			"DictionaryFetcherBackground.js",
			"TabState.js",
			"ToggleManagerBackground.js",
			"background.js"
		]
	},

	"content_scripts": [
		{
			"matches": [
				"*://*/*",
				"file:///*/*"
			],
			"css": ["vex/vex-theme-custom.css", "vex/vex_custom.css", "WordEditDialog.css", "WordElement.css"],
			"js": [
				"browser-polyfill.min.js",
				"vex/vex.combined.min.js",
				"strings.js",
				"OptionsManager.js",
				"messages.js",
				"wordmatching.js",
				"WordEditDialog.js",
				"DictionaryFetcherPage.js",
				"WordElement.js",
				"WordManager.js",
				"ToggleManagerPage.js",
				"content_script.js"
			],
			"run_at": "document_idle"
		}
	],

	"browser_action": {
		"default_icon": "./icons/icon_inactive_32.png"
	},

	"options_ui": {
		"open_in_tab": true,
		"page": "./settings/index.html"
	},

	"permissions": [
		"*://*/*",
		"webNavigation",
		"storage"
	]
}


<|start_filename|>src/ToggleManagerPage.js<|end_filename|>
class ToggleManagerPage
{
	constructor(name, config)
	{
		if (config.onFirstOn)
			this.onFirstOn = config.onFirstOn;
		if (config.onToggleOn)
			this.onToggleOn = config.onToggleOn;
		if (config.onToggleOff)
			this.onToggleOff = config.onToggleOff;
		if (config.onLoggedOut)
			this.onLoggedOut = config.onLoggedOut;
		if (config.onLoggedOutPress)
			this.onLoggedOutPress = config.onLoggedOutPress;
		if (config.onChangedProfile)
			this.onChangedProfile = config.onChangedProfile;
		if (config.onChangedProfilePress)
			this.onChangedProfilePress = config.onChangedProfilePress;

		this.readyMessageSender = new MessageSender(name + "Ready");
		this.doneRunningMessageSender = new MessageSender(name + "DoneRunning");

		new MessageSlot(name + "ToggleFirstOn",
			async (message, sender) =>
			{
				await this.onFirstOn();
				this.doneRunningMessageSender.sendToRuntime({});
			}
		);

		new MessageSlot(name + "ToggleOn",
			async (message, sender) =>
			{
				await this.onToggleOn();
				this.doneRunningMessageSender.sendToRuntime({});
			}
		);

		new MessageSlot(name + "ToggleOff",
			(message, sender) => this.onToggleOff()
		);

		new MessageSlot(name + "LoggedOut",
			(message, sender) => this.onLoggedOut()
		);

		new MessageSlot(name + "LoggedOutPress",
			(message, sender) => this.onLoggedOutPress()
		);

		new MessageSlot(name + "ChangedProfile",
			(message, sender) => this.onChangedProfile()
		);

		new MessageSlot(name + "ChangedProfilePress",
			(message, sender) => this.onChangedProfilePress()
		);

		this.readyMessageSender.sendToRuntime();
	}

	onFirstOn()	{}
	onToggleOn() {}
	onToggleOff() {}
	onLoggedOut() {}
	onLoggedOutPress() {}
	onChangedProfile() {}
	onChangedProfilePress() {}
}


<|start_filename|>src/WordEditDialog.js<|end_filename|>
vex.defaultOptions =  {
	content: '',
	unsafeContent: '',
	showCloseButton: true,
	escapeButtonCloses: true,
	overlayClosesOnClick: true,
	appendLocation: 'body',
	className: 'vex-theme-custom',
	overlayClassName: 'vex-overlay-reset',
	contentClassName: 'vex-content-reset',
	closeClassName: '',
	closeAllOnPopState: true
}

WordEditDialog =
{
	// config: { word: word, match: match }
	open: async function(config)
	{
		var options = await (new OptionsManager()).getOptions();
		var globalOptions = await (new OptionsManager()).getGlobalOptions();

		return new Promise(
			resolve =>
			{
				vex.dialog.buttons.YES.text = WordologyStrings.getStrings(globalOptions.language).OK;
				vex.dialog.buttons.NO.text = WordologyStrings.getStrings(globalOptions.language).CANCEL;
				vex.dialog.open({
					input: WordEditDialog.generateDialogHTML(
						{
							word: config.word,
							match: config.match,
							lookupURL: options.lookupURL,
							language: globalOptions.language
						}
					),
					callback: resolve
				});
				if (config.match)
				{
					var textBox = document.querySelector(".vex-content input[name=\"definition\"");
					textBox.select();
				}
				document.querySelector(".vex-content a.lookup").addEventListener("click",
					function(e)
					{
						document.querySelector(".vex-content input[name=\"definition\"").focus();
					}
				);
			}
		);
	},

	generateDialogHTML: function(config)
	{
		var word      = config.word;
		var match     = config.match;
		var lookupURL = config.lookupURL;
		var language  = config.language;

		const strings = WordologyStrings.getStrings(language);

		if (match)
		{
			var suggestion = match.entry.definition;

			if (!match.exact)
			{
				let matchingWord = match.entry.word;
				var suggestionNote = `
					<span class=\"suggestionNote\">${strings.TRANSLATION_SUGGESTED(matchingWord)}</span>
				`;
			}
			else
			{
				suggestionNote = "";
			}
		}
		else
		{
			suggestion = "";
			suggestionNote = "";
		}

		if (lookupURL)
		{
			let url = lookupURL.replace("$", word);
			var lookupLink = `<a class="lookup" target="_blank" href="${url}">${strings.LOOK_UP}</a>`;
		}
		else
		{
			lookupLink = "";
		}

		return `
			<span class="word">${word}</span>
			<label for="definition">${strings.TRANSLATION}:</label>
			<input type="text" name="definition" autocomplete="off" value="${suggestion}" />
			${suggestionNote}
			${suggestionNote && lookupLink ? "<br />" : ""}
			${lookupLink}
			<input type="hidden" name="word" value="${word}" />
		`;
	}
};


<|start_filename|>src/wordmatching.js<|end_filename|>
const WordMatching =
{
	UpdateUndefinedWords: function UpdateUndefinedWords(undefinedWords, newEntry, options)
	{
		options.useInflection = true;

		var dictOfMatches = {};
		for (var word of undefinedWords)
		{
			var dictionaryTree = WordMatching.BuildTree([newEntry]);
			var maybeMatch = WordMatching.FindBestMatch(word, dictionaryTree, options);
			if (maybeMatch !== null)
			{
				dictOfMatches[word] = maybeMatch;
			}
		}
		return dictOfMatches;
	},

	UpdateMatchedWords: function UpdateMatchedWords(taggedMatches, newEntry, options)
	{
		options.useInflection = true;

		var dictOfMatches = {};
		for (var match of taggedMatches)
		{
			var oldMatchedWord = match.word;
			var dictionaryTree = WordMatching.BuildTree([match.entry, newEntry]);
			var bestMatch = WordMatching.FindBestMatch(oldMatchedWord, dictionaryTree, options);
			if (bestMatch !== null)
			{
				dictOfMatches[oldMatchedWord] = bestMatch;
			}
		}
		return dictOfMatches;
	},

	MatchWords: function MatchWords(listOfWords, listOfEntries, options)
	{
		var dictionaryTree = WordMatching.BuildTree(listOfEntries);
		var dictOfMatches = {};
		var alreadyEncounteredWords = [];

		for (var word of listOfWords)
		{
			if (! alreadyEncounteredWords.includes(word))
			{
				var bestMatch = WordMatching.FindBestMatch(
					word,
					dictionaryTree,
					options
				);
				if (bestMatch)
				{
					dictOfMatches[word] = bestMatch;
				}

				alreadyEncounteredWords.push(word);
			}
		}
		return dictOfMatches;
	},

	FindBestMatch: function FindBestMatch(wordToMatch, tree, options)
	{
		var minPrefixLength     = options.minPrefixLength;
		var blacklistedPrefixes = options.blacklistedPrefixes;
		var whitelistedSuffixes = options.whitelistedSuffixes;
		var maxSuffixLength     = options.maxSuffixLength;
		var useInflection       = options.useInflection;

		var currentBranch = tree;
		var truePrefixLength = 0;
		var currentWord = "";

		for (var i = 0; i < wordToMatch.length; i++)
		{
			let c = wordToMatch[i];
			currentWord += c;

			if (blacklistedPrefixes.includes(currentWord))
			{
				truePrefixLength = 0;
			}

			if (currentBranch[c] !== undefined)
			{
				currentBranch = currentBranch[c];
				truePrefixLength += 1;
			}
			else
			{
				break;
			}
		}

		var nCharactersTraversed = i;

		if (nCharactersTraversed == wordToMatch.length && currentBranch.entry)
		{
			return { entry: currentBranch.entry, exact: true };
		}
		else if (truePrefixLength >= minPrefixLength)
		{
			if (useInflection)
			{
				let remainingSuffix = wordToMatch.substring(nCharactersTraversed);
				let trueSuffixLength = WordMatching.LongestTrueSuffixLength(remainingSuffix, whitelistedSuffixes);
				if (trueSuffixLength <= maxSuffixLength)
				{
					return WordMatching.FindBestMatchFromSuffixTree(currentBranch, nCharactersTraversed, options);
				}
				else
				{
					return null;
				}
			}
			else
			{
				return null;
			}
		}
		else
		{
			return null;
		}
	},

	FindBestMatchFromSuffixTree: function FindBestMatchFromSuffixTree(tree, prefixLength, options)
	{
		var whitelistedSuffixes = options.whitelistedSuffixes;
		var maxSuffixLength     = options.maxSuffixLength;

		var frontier = [tree];
		var nextFrontier = [];
		var frontierExpanded = true;
		var minLength = null;
		var bestMatchEntry = null;
		while (frontierExpanded)
		{
			frontierExpanded = false;
			for (let node of frontier)
			{
				for (let key in node)
				{
					if (key == "entry")
					{
						let suffix = node.entry.word.substring(prefixLength);
						let length = WordMatching.LongestTrueSuffixLength(suffix, whitelistedSuffixes);
						if (minLength === null || length < minLength)
						{
							minLength = length;
							bestMatchEntry = node.entry;
						}
					}
					else
					{
						nextFrontier.push(node[key]);
						frontierExpanded = true;
					}
				}
			}
			frontier = nextFrontier;
			nextFrontier = [];
		}
		if (minLength <= maxSuffixLength)
			return {entry: bestMatchEntry, exact: false};
		else
			return null;
	},

	LongestTrueSuffixLength: function LongestTrueSuffixLength(word, whitelist)
	{
		var suffix = "";
		var length = 0;
		for (var i = word.length - 1; i >= 0; i--)
		{
			let suffix = word.substring(i);
			length += 1;
			if (whitelist.includes(suffix))
			{
				length = 0;
			}
		}
		return length;
	},

	BuildTree: function BuildTree(listOfEntries)
	{
		var tree = {};

		for (let entry of listOfEntries)
		{
			let currentBranch = tree;
			let word = entry.word;

			for (let i = 0; i < word.length; i++)
			{
				let c = word[i];
				if (currentBranch[c] === undefined)
				{
					currentBranch[c] = {};
				}
				currentBranch = currentBranch[c];
			}
			currentBranch.entry = entry;
		}
		return tree;
	}
};


<|start_filename|>src/TabState.js<|end_filename|>
const TabState =
	{
		NeverOn        : 1,
		On             : 2,
		Running        : 3,
		Off            : 4,
		LoggedOut      : 5,
		ChangedProfile : 6
	};


<|start_filename|>src/Dictionary.js<|end_filename|>
class Dictionary
{
	constructor(profileId)
	{
		var request = window.indexedDB.open(profileId + "/database", 1);
		var upgraded = false;
		this.willOpen = new Promise(
			function(resolve, reject)
			{
				request.onupgradeneeded = function(event)
				{
					var db = event.target.result;
					var objectStore = db.createObjectStore("words", { keyPath: "word" });
					objectStore.transaction.oncomplete = function(event)
					{
						resolve(db);
					};
					upgraded = true;
				};

				request.onsuccess = function(event)
				{
					var db = event.target.result;
					if (!upgraded)
					{
						resolve(db);
					}
				};
			}
		);
	}

	async setData(listOfEntries)
	{
		var db = await this.willOpen;
		var objectStore = db.transaction(["words"], "readwrite").objectStore("words");
		for (var entry of listOfEntries)
		{
			objectStore.put(entry);
		}
	}

	async getMatches(requestObject)
	{
		var requestedWords = requestObject.words;
		var options        = requestObject.options;

		var db = await this.willOpen;

		return new Promise(
			function(resolve, reject)
			{
				var request = db.transaction(["words"]).objectStore("words").getAll();
				request.onsuccess =
				function(event)
				{
					var dictionaryEntries = request.result;
					var variables = {
						dictionaryEntries : dictionaryEntries,
						requestedWords    : requestedWords,
						options           : options
					};

					var worker = new Worker("wordmatcher.worker.js");
					worker.postMessage(variables);
					worker.onmessage =
					function(e)
					{
						var dictOfMatches = e.data;
						resolve(dictOfMatches);
					};
				};
			}
		);
	}

	async removeEntries(listOfWords)
	{
		var _this = this;
		var db = await this.willOpen;
		var objectStore = db.transaction(["words"], "readwrite").objectStore("words");
		for (var word of listOfWords)
		{
			objectStore.delete(word);
		}
	}

	async getEverything()
	{
		var db = await this.willOpen;
		return new Promise(
			function(resolve, reject)
			{
				var request = db.transaction(["words"]).objectStore("words").getAll();
				//request.result; uncommenting this makes onsuccess not run
				request.onsuccess = function(event)
				{
					var dictOfEntries = {};
					var dictionaryEntries = request.result;
					for (var entry of dictionaryEntries)
					{
						dictOfEntries[entry.word] = entry;
					}
					resolve(dictOfEntries);
				};
				request.onerror = function(event)
				{
					console.log("Database error");
				}
			}
		);
	}

	async clear()
	{
		var db = await this.willOpen;
		db.transaction(["words"], "readwrite").objectStore("words").clear();
	}

}

Dictionary.deleteDatabase = function deleteDatabase(profileId)
{
	var deleteRequest = window.indexedDB.deleteDatabase(profileId + "/database");

	return new Promise(
		resolve => {
			deleteRequest.onsuccess = resolve;
		}
	);
}

<|start_filename|>src/WordElement.css<|end_filename|>
span[wordology_word]
{
	display: inline-block;
	padding: 0px;
	border-radius: 0px;
	position: relative;
	text-indent: initial;
}
span[wordology_word="hidden"]
{
	background-color: transparent;
}
span[wordology_word="not_defined"]
{
	--default-color: rgba(255, 100, 100, 0.35);
	background-color: var(--wordology-not-defined-color, var(--default-color));
}
span[wordology_word="defined"]
{
	--default-color: rgba(100, 255, 50, 0.35);
	background-color: var(--wordology-defined-color, var(--default-color));
}
span[wordology_word="similar"]
{
	--default-color: rgba(100, 150, 255, 0.35);
	background-color: var(--wordology-similar-color, var(--default-color));
}

span.wordology-popup
{
	position: absolute;
	z-index: 999999999;
}

span.wordology-popup span.wordology-popup-bubble
{
	background-color: hsla(0, 0%, 20%, 1.0);
	color: white;
	padding: 7px;
	font-size: 12pt;
	min-height: 0px;
	font-family: sans-serif;
	font-weight: normal;
	font-style: normal;
	text-transform: none;
	white-space : normal;
	top : 0px;
	left : 0px;
	line-height: 1.0em;
	height: auto;
	display: block;
	max-width: 400px;
	width: max-content;
	text-align: center;
}

span.wordology-popup span.wordology-popup-tail
{
	width: 0px;
	display: block;
}

.wordology-popup-tail.bottom {
	width: 0px;
	border-top: 5px solid hsla(0, 0%, 20%, 1.0);
	border-right: 5px solid transparent;
	border-left: 5px solid transparent;
	margin-left: auto;
	margin-right: auto;
}

.wordology-popup-tail.top {
	width: 0px;
	border-bottom: 5px solid hsla(0, 0%, 20%, 1.0);
	border-right: 5px solid transparent;
	border-left: 5px solid transparent;
	margin-left: auto;
	margin-right: auto;
}

.wordology-popup-tail.top-left {
	width: 0px;
	border-bottom: 5px solid hsla(0, 0%, 20%, 1.0);
	border-right: 5px solid transparent;
	margin-left: 0px;
	margin-right: auto;
}

.wordology-popup-tail.top-right {
	width: 0px;
	border-bottom: 5px solid hsla(0, 0%, 20%, 1.0);
	border-left: 5px solid transparent;
	margin-left: auto;
	margin-right: 0px;
}

.wordology-popup-tail.bottom-left {
	width: 0px;
	border-top: 5px solid hsla(0, 0%, 20%, 1.0);
	border-right: 5px solid transparent;
	margin-left: 0px;
	margin-right: auto;
}

.wordology-popup-tail.bottom-right {
	width: 0px;
	border-top: 5px solid hsla(0, 0%, 20%, 1.0);
	border-left: 5px solid transparent;
	margin-left: auto;
	margin-right: 0px;
}

<|start_filename|>src/settings/style.css<|end_filename|>
/***************************************************************
 *
 *     GENERAL
 *
 * *************************************************************/

:root
{
	--border-color: #555;
}

body
{
	font-size: 12pt;
	font-family: 'Source Sans Pro', sans-serif;
	background: #F5F5F5;
	padding: 25px;
	margin: 0px;
}

h1:first-child
{
	margin-top: 0px;
}

#contents
{
	max-width: 1000px;
	margin: auto;
	margin-top: 15px;
}

#navBar
{
	display: block;
	margin: 0px;
	padding: 0px;
	height: 40px;
	box-sizing: border-box;
	position: relative;
	top: 1px;
	z-index: 2;
}
#navBar li
{
	display: inline-block;
	background-color: white;
	border: 1px solid var(--border-color);
	padding: 10px;
	height: 40px;
	box-sizing: border-box;
	border-bottom: 1px solid var(--border-color);
	margin-right: 1px;
	box-shadow: rgba(0,0,0,0.5) 2px 0px 0px;
	border-top-left-radius: 7px;
	border-top-right-radius: 7px;
	cursor: pointer;
}
#navBar li:hover
{
	text-decoration: underline;
}
#navBar li.activeTab
{
	border-bottom: none;
	font-weight: bold;
}
#navBar li.activeTab:hover
{
	text-decoration: none;
}

.page
{
	background: white;
	border: 1px solid var(--border-color);
	padding: 25px;
	width: 100%;
	display: none;
	box-sizing: border-box;
	position: relative;
}
.activePage
{
	display: block;
}

#profileSelector
{
	position: absolute;
	top: 15px;
	right: 25px;
}
#profileSelector div
{
	display: block;
	width: 100%;
	text-align: right;
	margin-bottom: 5px;
	padding-right: 4px;
	box-sizing: border-box;
}
#profileSelector button
{
	all: unset;
	cursor: pointer;
	height: 18px;
	width: 18px;
	font-size: 12px;
	font-family: monospace;
	font-variant: small-caps;
	text-align: center;
	background-color: white;
	box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 2px;
	position: relative;
}
#profileSelector button:active
{
	left: 2px;
	top: 2px;
	box-shadow: none;
}
#profileSelector select
{
	width: 150px;
	font-size: 16px;
}

.page > *:first-child
{
	margin-top: 0px;
}

.page.disabled main
{
	opacity: 0.15;
}

.cover
{
	display: none;
}

.disabled .cover
{
	font-size: 25pt;
	text-shadow: rgba(0,0,0,0.3) 3px 3px 3px;
	position: absolute;
	top: 0px;
	left: 0px;
	height: 100%;
	width: 100%;
	text-align: center;
	font-weight: bold;
	box-sizing: border-box;
	display: flex;
    align-items: center;
	justify-content: center;
	z-index: 2;
}

.cover span
{
	margin: auto;
}

footer
{
	font-size: 20px;
	display: block;
	margin-top: 15px;
	text-align: center;
}

/***************************************************************
 *
 *     OPTIONS TAB
 *
 * *************************************************************/

.optionsBlock
{
	margin-bottom: 10px;
	padding: 0px;
}

#blacklistedPrefixes
{
	margin-top: 10px;
}

input[type="number"]
{
	width: 40px;
}

#addPrefixInput
{
	width: 100px;
}

#urlInput
{
	width: 400px;
}

#pageOptions > *:first-child
{
	margin-top: 0px;
}

input[type="range"]
{
	vertical-align: middle;
}

.colors div
{
	width: 300px;
	position: relative;
}
.colors input
{
	position: absolute;
	right: 0px;
}

.noAffixesNotice
{
	font-style: italic;
	margin-left: 20px;
}

/***************************************************************
 *
 *     DICTIONARY TAB
 *
 * *************************************************************/
#pageDictionary > main
{
	display: flex;
	flex-flow: row;
}
#pageDictionary #wordListSection
{
	flex-grow: 1;
}
#pageDictionary #controls
{
	margin-left: 20px;
	flex-grow: 0;
	flex-shrink: 0;
	flex-basis: 200px;
}

#pageDictionary #noWordsNotice
{
	display: flex;
	align-items: center;
	justify-content: center;
	font-size: 30px;
	height: 100%;
	text-shadow: rgba(0,0,0,0.3) 2px 2px 2px;
}

#pageDictionary #controls > *
{
	width: 100%;
	height: 80px;
	box-sizing: border-box;
	border: none;
	border-radius: 10px;
	margin-bottom: 10px;
	color: black;
	text-decoration: none;
	font-size: 12pt;
	cursor: pointer;
	box-shadow: rgba(0, 0, 0, 0.6) 2px 4px 5px;

	display: flex;
	align-items: center;
	justify-content: center;
}
#pageDictionary #controls > *:active
{
	position: relative;
	left: 2px;
	top: 4px;
	box-shadow: none;
}
#downloadLink, #importButton
{
	background-color: #77AAEE;
}
#clearButton
{
	background-color: #FF6666;
}


<|start_filename|>src/strings.js<|end_filename|>
const WordologyStrings = {
    strings: {
        TRANSLATION: {
            en: "Translation",
            fr: "Traduction",
            pl: "Przetłumaczenie",
            ru: "Перевод",
            es: "Traducción",
            pt: "Tradução"
        },
        LOOK_UP: {
            en: "Look up",
            fr: "Rechercher",
            pl: "Wyszukiwać",
            ru: "Искать",
            es: "Buscar",
            pt: "Procurar"
        },
        LOOKUP_HEADER: {
            en: "Lookup",
            fr: "Recherche",
            pl: "Wyszukiwanie",
            ru: "Искание",
            es: "Búsqueda",
            pt: "Pesquisa"
        },
        LOOKUP_DESCRIPTION: {
            en: `Enter the URL of your preferred online dictionary, typing a dollar sign ($) where the word should be, e.g.: <em>https://en.wiktionary.org/wiki/$</em>.`,
            fr: `Entrez l'URL de votre dictionnaire en-ligne préferé, tapant un signe dollar ($) là où devrait figurer le mot, e.g.: <em>https://fr.wiktionary.org/wiki/$</em>.`,
            pl: `Wpisz tutaj URL twojego preferowanego internetowego słownika, dodając znak dolara ($) tam gdzie powinno być słowo, n.p.: <em>https://pl.wiktionary.org/wiki/$</em>.`,
            ru: `Введите URL предпочитаемого вами онлайн-словаря и введите знак доллара ($) там, где должно быть слово, например: <em>https://ru.wiktionary.org/wiki/$</em>.`,
            es: `Ingrese la URL de su diccionario en línea preferido, escribiendo un signo de dólar ($) donde debería estar la palabra, por ejemplo: <em>https://es.wiktionary.org/wiki/$</em>.`,
            pt: `Digite o URL do seu dicionário online preferido, digitando um símbolo de dólar ($) onde a palavra deve estar, por exemplo: <em>https://pt.wiktionary.org/wiki/$</em>.`
        },
        LOOKUP_URL: {
            en: "Lookup URL:",
            fr: "URL de recherche:",
            pl: "URL wyszukiwania:",
            ru: "URL исканий:",
            es: "URL de búsqueda:",
            pt: "URL de pesquisa:"
        },
        TRANSLATION_SUGGESTED: {
            en: word => `Translation suggested based on similarity to <em>${word}</em>.`,
            fr: word => `Traduction suggérée dû à la similarité avec <em>${word}</em>.`,
            pl: word => `Przetłumaczenie zaproponowany na postawie podobieństwa z <em>${word}</em>.`,
            ru: word => `Предлагаемый перевод основан на сходстве с <em>${word}</em>.`,
            es: word => `Traducción sugerida basada en similitud con <em>${word}</em>.`,
            pt: word => `Tradução sugerida com base na semelhança com <em>${word}</em>.`
        },
        LOGGED_OUT: {
            en: `You don't seem to be logged into a Wordology profile right now. Go to your settings page to log in, and then <a href="javascript:window.location.reload();">reload</a> this page.`,
            fr: `Vous ne semblez pas être connecté à un profil Wordology. Allez à la page de paramètres pour vous connecter, puis <a href="javascript:window.location.reload();">rechargez</a> cette page.`,
            pl: `Wydaje się, że nie jesteś obecnie zalogowany na profil Wordology. Przejdź do strony ustawienia aby się zalogować, a potem <a href="javascript:window.location.reload();">załaduj</a> tę stronę.`,
            ru: `Похоже, вы не вошли сейчас в профиль Wordology. Перейдите на страницу настроек, чтобы войти в систему, а затем <a href="javascript:window.location.reload();">перезагрузите</a> эту страницу.`,
            es: `No parece haber iniciado sesión en un perfil de Wordology en este momento. Vaya a su página de configuración para iniciar sesión y luego <a href="javascript:window.location.reload();">recargar</a> esta página.`,
            pt: `Você não parece estar conectado a um perfil do Wordology no momento. Vá para a página de configurações para efetuar login e, em seguida, <a href="javascript:window.location.reload();">recarregue</a> esta página.`
        },
        CHANGED_PROFILE: {
            en: `You seem to have changed Wordology profiles since the last time this page was loaded. <a href="javascript:window.location.reload();">Reload</a> this page to keep using Wordology.`,
            fr: `Vous semblez avoir changé de profil Wordology depuis que cette page ait été chargée. <a href="javascript:window.location.reload();">Rechargez</a> cette page pour continuer à utiliser Wordology.`,
            pl: `Wydaje się, że zmieniłeś profil Wordology od kiedy była ładowana ta strona. <a href="javascript:window.location.reload();">Załaduj</a> tę stronę aby nadal używać Wordology.`,
            ru: `Похоже, вы изменили профили Wordology с момента последней загрузки этой страницы. <a href="javascript:window.location.reload();">Перезагрузите</a> эту страницу, чтобы продолжать использовать Wordology.`,
            es: `Parece que ha cambiado de perfil de Wordology desde la última vez que se cargó esta página. <a href="javascript:window.location.reload();">Recarga</a> esta página para seguir usando Wordology.`,
            pt: `Você parece ter alterado os perfis do Wordology desde a última vez que esta página foi carregada. <a href="javascript:window.location.reload();">Recarregue</a> esta página para continuar usando o Wordology.`
        },
        WORDS: {
            en: "Words",
            fr: "Mots",
            ru: "Слова",
            pl: "Słowa",
            es: "Palabras",
            pt: "Palavras"
        },
        OPTIONS: {
            en: "Options",
            fr: "Options",
            ru: "Параметры",
            pl: "Ustawienie",
            es: "Configuraciones",
            pt: "Configurações"
        },
        PROFILE: {
            en: "Profile",
            fr: "Profil",
            ru: "Профиль",
            pl: "Profil",
            es: "Perfil",
            pt: "Perfil"
        },
        NO_PROFILE: {
            en: "None",
            fr: "Aucun",
            ru: "Никако́й",
            pl: "Żaden",
            es: "Ninguno",
            pt: "Nenhum"
        },
        HELP: {
            en: "Help",
            fr: "Aide (Anglais)",
            ru: "Помощь (Английский)",
            pl: "Pomoc (Angielski)",
            es: "Ayuda (Inglés)",
            pt: "Socorro (Inglês)"
        },
        FEEDBACK: {
            en: "Feedback",
            fr: "Contact",
            ru: "Отзывы",
            pl: "Opinie",
            es: "Comentarios",
            pt: "Comentários"
        },
        DONATE: {
            en: "Donate",
            fr: "Soutenir",
            ru: "Дарить",
            pl: "Daj",
            es: "Donar",
            pt: "Doar"
        },
        SMART_MATCHING: {
            en: "Smart Matching",
            fr: "Detéction de flexion",
            pl: "Inteligentne dopasowanie",
            ru: "Обнаружение словоизменение",
            es: "Detección de flexión",
            pt: "Detecção de flexão"
        },
        USE_SMART_MATCHING: {
            en: "Use Smart Matching",
            fr: "Utiliser la détéction d'infléction",
            pl: "Używać inteligentne dopasowanie",
            ru: "Использовать обнаружение словоизменения",
            es: "Usar detección de flexión",
            pt: "Usar detecção de flexão"
        },
        SMART_MATCHED_WORDS: {
            en: "Smart matched words:",
            fr: "Mots définis par la detéction de flexion:",
            pl: "Słowa inteligentnie dopasowane:",
            ru: "Слова определенные обнаружением словоизменения:",
            es: "Palabras definidas por la detección de flexión:",
            pt: "Palavras definidas pela detecção de flexão"
        },
        NO_WORDS: {
            en: "You have no words added!",
            fr: "Vous n'avez pas de mots!",
            pl: "Nie masz słów dodanych!",
            ru: "У вас нет добавленных слов!",
            es: "No tiene palabras!",
            pt: "Você não tem palavras!"
        },
        DELETE_WORD: {
            en: word => `Delete "${word}"?`,
            fr: word => `Supprimer "${word}"?`,
            ru: word => `Удалить "${word}"?`,
            pl: word => `Usunąć "${word}"?`,
            es: word => `Eliminar "${word}"?`,
            pt: word => `Apagar ${word}?`
        },
        PREFIX: {
            en: "Prefix",
            fr: "Préfixe",
            pl: "Przedrostek",
            ru: "Приставка",
            es: "Prefijo",
            pt: "Prefixo"
        },
        SUFFIX: {
            en: "Suffix",
            fr: "Suffixe",
            pl: "Przyrostek",
            ru: "Суффикс",
            es: "Sufijo",
            pt: "Sufixo"
        },
        MINIMUM_LENGTH_OF_ROOT: {
            en: "Minimum length of a root:",
            fr: "Longueur minimale d'une racine:",
            pl: "Minimalna długość tematu:",
            ru: "Минимальная длина корня:",
            es: "Longitud mínima de una raíz:",
            pt: "Comprimento mínimo de uma raiz:"
        },
        BLACKLISTED_PREFIXES: {
            en: "Blacklisted prefixes:",
            fr: "Liste noire de préfixes:",
            pl: "Czarna lista przedrostków:",
            ru: "Черный список приставок:",
            es: "Lista negra de prefijos:",
            pt: "Lista negra de prefixos:"
        },
        ADD_PREFIXES: {
            en: "Add prefixes to your blacklist below.",
            fr: "Ajoutez des préfixes à votre liste noire ci-dessous.",
            pl: "Dodaj poniżej przedrostki do czarnej listy.",
            ru: "Внесите приставки в черный список ниже.",
            es: "Agregar prefijos a su lista negra abajo.",
            pt: "Adicione um prefixo à sua lista negra abaixo."
        },
        BLACKLIST_PROMPT: {
            en: "Blacklist prefix:",
            fr: "Ajouter préfixe:",
            pl: "Dodaj przedrostek:",
            ru: "Внести приставку:",
            es: "Agregar prefijo:",
            pt: "Adicionar prefixo:"
        },
        MAXIMUM_LENGTH_OF_SUFFIX: {
            en: "Maximum length of a suffix:",
            fr: "Longueur maximale d'un suffixe:",
            pl: "Maksymalna długość przyrostka:",
            ru: "Максимальная длина суффикса:",
            es: "Longitud máxima de un sufijo:",
            pt: "Comprimento máximo de um sufixo:"
        },
        WHITELISTED_SUFFIXES: {
            en: "Whitelisted suffixes:",
            fr: "Liste blanche de suffixes:",
            pl: "Biała lista przyrostków:",
            ru: "Белый список суффиксов:",
            es: "Lista blanca de sufijos:",
            pt: "Lista branca de sufixos:"
        },
        ADD_SUFFIXES: {
            en: "Add suffixes to your whitelist below.",
            fr: "Ajoutez des suffixes à votre liste blanche ci-dessous.",
            pl: "Dodaj poniżej przyrostki do białej listy.",
            ru: "Внесите суффиксы в белый список ниже.",
            es: "Agregar sufijos a su lista blanca abajo:",
            pt: "Adicione um sufixo à sua lista branca abaixo."
        },
        WHITELIST_PROMPT: {
            en: "Whitelist suffix:",
            fr: "Ajouter suffixe:",
            pl: "Dodaj przyrostek:",
            ru: "Внести суффикс:",
            es: "Agregar sufijo:",
            pt: "Adicionar sufixo:"
        },
        ADD_BUTTON: {
            en: "Add",
            fr: "Ajouter",
            pl: "Dodaj",
            ru: "Добавить",
            es: "Agregar",
            pt: "Adicionar"
        },
        COLORS: {
            en: "Colors",
            fr: "Couleurs",
            ru: "Цвета",
            pl: "Kolory",
            es: "Colores",
            pt: "Cores"
        },
        UNDEFINED_WORDS: {
            en: "Undefined words:",
            fr: "Mots non-définis:",
            pl: "Słowa niezdefiniowane:",
            ru: "Неопределенные слова:",
            es: "Palabras indefinidas:",
            pt: "Palavras indefinidas:"
        },
        DEFINED_WORDS: {
            en: "Defined words:",
            fr: "Mots définis:",
            pl: "Słowa zdefiniowane:",
            ru: "Определенные слова:",
            es: "Palabras definidas:",
            pt: "Palavras definidas:"
        },
        OPACITY: {
            en: "Opacity:",
            fr: "Opacité:",
            pl: "Nieprzezroczystość:",
            ru: "Неясность:",
            es: "Opacidad:",
            pt: "Opacidade:"
        },
        RESET_COLORS: {
            en: "Reset to default colors",
            fr: "Revenir aux couleurs par défaut",
            pl: "Przywróć domyślne kolory",
            ru: "Сбросить цвета по умолчанию",
            es: "Restablecer a los colores predeterminados",
            pt: "Redefinir para cores padrãoes"
        },
        EXTENDED_ALPHABET_HEADER: {
            en: "Extended Alphabet",
            fr: "Alphabet étendu",
            pl: "Alfabet rozszerzony",
            ru: "Расширенный алфавит",
            es: "Alfabeto extendido",
            pt: "Alfabeto estendido"
        },
        EXTENDED_ALPHABET_DESCRIPTION: {
            en: `Add extra characters to the alphabet recognized by Wordology. For example, if you add the hyphen ("-"), then Wordology will consider "flower-pot"
            as one word rather than "flower" and "pot". Just type all extra characters together, with no spaces, e.g. <em>'-</em> to add ' and -.`,
            fr: `Ajoutez des caractères supplémentaires à l'alphabet reconnu par Wordology. Par exemple, en ajoutant le tiret ("-"), Wordology considérera "tire-bouchon" comme un seul mot plutôt que "tire" et "bouchon". Ecrivez simplement tous les caractères à la suite, sans éspaces, e.g. <em>'-</em> pour ajouter ' et -.`,
            pl: `Dodaj więcej znaków do alfabetu rozpoznanego przez Wordology. Na przykład, jeżeli dodajesz myślnik ("-"), to Wordology uważa "czarno-biały"
            za jedno słowo a nie za "czarno" a "biały". Wpisz wszystkie znaki razem, bez spacji, n.p. <em>'-</em> aby dodać ' i -.`,
            ru: `Добавьте дополнительные символы в алфавит, распознаваемый Wordology. Например, если вы добавите дефис ("-"),
            тогда Wordology будет рассматривать "ярко-красный"»" как одно слово, а не "ярко" и "красный". Просто введите все
            лишние символы вместе без пробелов, например, <em>'-</ em> добавить' и -.`,
            es: `Agregue caracteres adicionales al alfabeto reconocido por Wordology. Por ejemplo, si agrega el guión ("-"), Wordology considerará "norte-sur" como una palabra en lugar de "norte" y "sur". Simplemente escriba todos los caracteres adicionales juntos, sin espacios, p.ej. <em>'-</em> para agregar ' y -.`,
            pt: `Adicione caracteres extras ao alfabeto reconhecido por Wordology. Por exemplo, se você adicionar o hífen ("-"), Wordology considerará "norte-americano" como uma palavra em vez de "norte" e "americano". Basta digitar todos os caracteres extras juntos, sem espaços, por exemplo <em>'-</em> para adicionar ' e -.`
        },
        EXTENDED_ALPHABET_PROMPT: {
            en: "Extended alphabet:",
            fr: "Alphabet étendu:",
            pl: "Alfabet rozszerzony:",
            ru: "Расширенный алфавит:",
            es: "Alfabeto extendido:",
            pt: "Alfabeto estendido"
        },
        LOGIN_FOR_OPTIONS: {
            en: "Log in to a profile to access options.",
            fr: "Connectez-vous à un profil pour accéder aux options.",
            pl: "Aby mieć dostęp do ustawień, zaloguj profil.",
            ru: "Войдите в профиль, чтобы получить доступ к параметрам.",
            es: "Inicie sesión en un perfil para acceder a las opciones.",
            pt: "Faça login em um perfil para acessar as opções."
        },
        EXPORT: {
            en: "Export as file",
            fr: "Exporter comme fichier",
            pl: "Wyeksportować jako pliku",
            ru: "Экспортировать в файл",
            es: "Exportar como archivo",
            pt: "Exportar como arquivo"
        },
        IMPORT: {
            en: "Import from file",
            fr: "Importer un fichier",
            pl: "Importować plik",
            ru: "Импортировать из файла",
            es: "Importar desde archivo",
            pt: "Importar do arquivo"
        },
        CLEAR: {
            en: "Clear data",
            fr: "Effacer données",
            pl: "Usunąć dane",
            ru: "Удалить данные",
            es: "Borrar datos",
            pt: "Apagar os dados"
        },
        IMPORT_ERROR: {
            en: "There was an error reading your file. Was that the right file?",
            fr: "Une erreur est survenue en lisant votre fichier. Etait-ce le bon fichier?",
            pl: "Wystąpił problem podczas czytanie pliku. Czy to był poprawny plik?",
            ru: "Произошла ошибка при чтении вашего файла. Это был правильный файл?",
            es: "Se produjo un error al leer su archivo. ¿Era ese el archivo correcto?",
            pt: "Ocorreu um erro ao ler seu arquivo. Esse foi o arquivo certo?"
        },
        DELETE_WORDLIST_WARNING: {
            en: "To delete your entire wordlist for this profile permanently, type DELETE below in all-capital letters, and click OK.",
            fr: "Pour supprimer tous les mots de ce profil de façon permanante, tapez SUPPRIMER ci-dessous en lettre majuscules, et appuyez sur OK.",
            pl: "Aby usunąć trwale wszystkie słowa z tego profilu, wpisz poniżej USUNAC dużymi literami i kliknij OK.",
            ru: "Чтобы окончательно удалить весь список слов для этого профиля, введите УДАЛИТЬ ниже заглавными буквами и нажмите ОК.",
            es: "Para eliminar toda su lista de palabras para este perfil de forma permanente, escriba ELIMINAR en mayúsculas y clique en Aceptar.",
            pt: "Para apagar permanentemente sua lista de palavras inteira desse perfil, digite APAGAR abaixo em letras maiúsculas e clique em OK."
        },
        DELETE_PASSWORD: {
            en: "DELETE",
            fr: "<PASSWORD>",
            pl: "<PASSWORD>",
            ru: "УДАЛИТЬ",
            es: "ELIMINAR",
            pt: "APAGAR"
        },
        OK: {
            en: "OK",
            fr: "OK",
            ru: "OK",
            pl: "OK",
            es: "OK",
            pt: "OK"
        },
        CANCEL: {
            en: "Cancel",
            fr: "Annuler",
            ru: "Отменить",
            pl: "Anuluj",
            es: "Cancelar",
            pt: "Cancelar"
        },
        WORD: {
            en: "Word",
            fr: "Mot",
            ru: "Слово",
            pl: "Słowo",
            es: "Palabra",
            pt: "Palavra"
        },
        DEFINITION: {
            en: "Definition",
            fr: "Définition",
            ru: "Значение",
            pl: "Znaczenia",
            es: "Definición",
            pt: "Definição"
        },
        RENAME_PROFILE: {
            en: name => `Enter new profile name for "${name}":`,
            fr: name => `Entrez un nouveau nom pour le profil "${name}":`,
            pl: name => `Wpisz nową nazwę dla profilu "${name}":`,
            ru: name => `Введите новое имя профиля для "${name}":`,
            es: name => `Escriba un nuevo nombre para el perfil "${name}":`,
            pt: name => `Digite um novo nome do perfil para "${name}":`
        },
        DELETE_PROFILE_WARNING: {
            en: name =>
                `Definitely delete profile "${name}"? Your wordlist and settings will be gone forever.
                Type DELETE (all-caps) to delete.`,
            fr: name =>
                `Vraiment supprimer le profil "${name}"? Vos mots et paramètres seront supprimés à jamais.
                Ecrivez SUPPRIMER (en majuscules) pour supprimer.`,
            pl: name =>
                `Naprawdę usunąć profil "${name}"? Twoje słowa i ustawienia zostają usunięte na zawsze.
                Wpisz USUNAC (dużymi literami) aby usunąć.`,
            ru: name =>
                `Определенно удалить профиль "${name}"? Ваш список слов и настройки исчезнут навсегда.
                Введите УДАЛИТЬ заглавными буквами чтобы удалить.`,
            es: name =>
                `¿Definitivamente eliminar el perfil "${name}"? Su lista de palabras y configuraciones desaparecerán para siempre. Escriba ELIMINAR (en mayúsculas) para eliminar.`,
            pt: name =>
                `Definitivamente apagar o perfil "${name}"? Sua lista de palavras e configurações desaparecerão para sempre.
                Digite APAGAR (maiúsculas) para apagar.`
        },
        ADD_PROFILE: {
            en: "Enter name for new profile:",
            fr: "Entrez un nom pour le nouveau profil:",
            pl: "Wpisz nazwę nowego profilu:",
            ru: "Введите имя для нового профиля:",
            es: "Escriba nombre para el nuevo perfil:",
            pt: "Digite um nome para o novo perfil:"
        },
        ADD_PROFILE_TOOLTIP: {
            en: "Add profile",
            fr: "Ajouter profil",
            pl: "Dodać profil",
            ru: "Добавить профиль",
            es: "Agregar perfil",
            pt: "Adicionar perfil"
        },
        RENAME_PROFILE_TOOLTIP: {
            en: "Rename profile",
            fr: "Renommer profil",
            ru: "Переименовать профиль",
            pl: "Zmienić nazwę profilu",
            es: "Renombrar perfil",
            pt: "Renomear perfil"
        },
        DELETE_PROFILE_TOOLTIP: {
            en: "Delete profile",
            fr: "Supprimer profil",
            ru: "Удалить профиль",
            pl: "Usunąć profil",
            es: "Eliminar perfil",
            pt: "Apagar perfil"
        }
    },

    SUPPORTED_LANGUAGES: ["en", "fr", "es", "pl", "pt", "ru"],

    getStrings: function(languageCode)
    {
        let result = {};
        for (let stringName in WordologyStrings.strings)
        {
            if (WordologyStrings.strings[stringName][languageCode])
            {
                result[stringName] = WordologyStrings.strings[stringName][languageCode];
            }
            else
            {
                result[stringName] = WordologyStrings.strings[stringName]["en"];
            }
        }
        return result;
    }
};

<|start_filename|>src/DictionaryFetcherPage.js<|end_filename|>
class DictionaryFetcherPage
{
	constructor(name)
	{
		this.name = name;
		this.dataRequestCallback = function() {};

		this.responseDataMessageSlot = new MessageSlot(
			name + "ResponseData",
			this.onResponseDataMessage.bind(this)
		);
	}

	onResponseDataMessage(message, sender)
	{
		this.dataRequestCallback(message.entries);
	}

	getMatches(requestObject)
	{
		var messageSender = new MessageSender(this.name + "RequestData");
		messageSender.sendToRuntime(requestObject);
		return new Promise(
			resolve =>
			{
				this.dataRequestCallback = resolve;
			}
		);
	}

	getEverything()
	{
		var messageSender = new MessageSender(this.name + "RequestData");
		messageSender.sendToRuntime(null);
		return new Promise(
			resolve =>
			{
				this.dataRequestCallback = resolve;
			}
		);
	}

	setData(listOfEntries)
	{
		var message = { entries: listOfEntries };
		var messageSender = new MessageSender(this.name + "SetData");
		messageSender.sendToRuntime(message);
	}
}


<|start_filename|>src/settings/dictionary-tab/dictionary-tab.component.js<|end_filename|>
class DictionaryTabController
{
    constructor($scope)
    {
        this.$scope = $scope;
        this.wordList = null;
        this.downloadUri = "";
        this.columns = [
            { name: "word", label: "", searchable: true },
            { name: "definition", label: "", searchable: true }
        ];
        this.reloadWords();
        this.onClickWord = this.onClickWord.bind(this);
        this.onDeleteWord = this.onDeleteWord.bind(this);
        this.$scope.$on("reload", () => this.reloadWords());
    }

    getTableColumns()
    {
        this.columns[0].label = this.strings.WORD;
        this.columns[1].label = this.strings.DEFINITION;
        return this.columns;
    }

    async reloadWords()
    {
        var OM = new OptionsManager();
        var profileId = await OM.getCurrentProfileId();
        var profileName = await OM.getProfileName(profileId);
        var date = (new Date()).toLocaleDateString().replace(/\//g, "-");
        this.fileName = `${profileName} (${date}).json`;
        this.D = new Dictionary(profileId);
        var result = await this.D.getEverything();
        this.loadWordList(result);
    }

    async onClickWord(word)
    {
        var definition = this.dictionaryData[word].definition;
        var match = { entry: { word: word, definition: definition }, exact: true };
        var result = await WordEditDialog.open({ word: word, match: match });
        if (result.definition)
        {
            this.dictionaryData[word].definition = result.definition;
            this.D.setData([{ word: word, definition: result.definition }]);
            this.loadWordList(this.dictionaryData);
        }
    }

    onDeleteWord(word)
    {
        this.D.removeEntries([word]);
        var dictOfEntries = {}
        for (let entry of this.wordList)
        {
            dictOfEntries[entry.word] = entry;
        }
        var json = JSON.stringify(dictOfEntries);
        var downloadUri = `data:application/json,${json}`;
        document.getElementById("downloadLink").setAttribute("href", downloadUri); // Firefox's security features prevent AngularJS from setting href
    }

    loadWordList(dictOfEntries)
    {
        this.wordList = null;
        try
        {
            this.$scope.$apply(); // Make AngularJS notice the change
        }
        catch {}

        this.wordList = Object.values(dictOfEntries);
        this.dictionaryData = dictOfEntries;

        var json = JSON.stringify(dictOfEntries);
        var blob = new Blob([json], { "type": "application/json" });
        var downloadUri = URL.createObjectURL(blob);
        document.getElementById("downloadLink").setAttribute("href", downloadUri); // Firefox's security features prevent AngularJS from setting href

        try
        {
            this.$scope.$apply();
        }
        catch {}
    }

    clearData()
    {
        vex.dialog.buttons.YES.text = this.strings.OK;
        vex.dialog.buttons.NO.text = this.strings.CANCEL;
        vex.dialog.prompt({
            message: this.strings.DELETE_WORDLIST_WARNING,
            callback: response =>
            {
                if (response === this.strings.DELETE_PASSWORD)
                {
                    this.loadWordList({});
                    this.D.clear();
                }
            }
        });
    }

    async importFile(data)
    {
        try
        {
            var dictOfEntries = JSON.parse(data);
            await this.D.setData(Object.values(dictOfEntries));
            var dictOfEntries = await this.D.getEverything();
            this.loadWordList(dictOfEntries);
        }
        catch
        {
            alert(this.strings.IMPORT_ERROR);
        }
    }

    requestImportFile()
    {
        var inputElement = document.createElement("input");
        inputElement.setAttribute("type", "file");
        inputElement.setAttribute("accept", ".json");
        inputElement.addEventListener("change", fileChangeCallback);
        inputElement.click();

        const controller = this;

        function fileChangeCallback(event)
        {
            var fileReader = new FileReader()
            fileReader.onload = fileReadCallback;
            fileReader.readAsText(event.target.files[0]);
        }

        function fileReadCallback(event)
        {
            controller.importFile(event.target.result);
        }
    }
}

app.component("dictionaryTab",
    {
        controller: DictionaryTabController,
        templateUrl: "dictionary-tab/dictionary-tab.html",
        controllerAs: "ctrl",
        bindings: {
            strings: "<"
        }
    }
);

<|start_filename|>src/WordElement.js<|end_filename|>
"use strict";

class WordElement
{
	constructor(dom, text)
	{
		this.span = dom.createElement("span");
		this.span.textContent = text;
		this.span.setAttribute(WordElement.ATTRIBUTE_NAME, WordElement.ATTRIBUTE_UNDEFINED);

		this.word = text.toLowerCase();
		this.match = null;
		this.hidden = false;

		this.mouseOver  = new WordElementEvent();
		this.mouseOut   = new WordElementEvent();
		this.click      = new WordElementEvent();

		this.span.addEventListener("mouseover",
			(function onMouseOver(e)
			{
				if (!this.hidden)
				{
					this.mouseOver.dispatchEvent(
						{ target: this, domEvent: e }
					);
				}
			}).bind(this)
		);
		this.span.addEventListener("mouseout",
			(function onMouseOut(e)
			{
				if (!this.hidden)
				{
					this.mouseOut.dispatchEvent(
						{ target: this, domEvent: e }
					);
				}
			}).bind(this)
		);
		this.span.addEventListener("click",
			(function onClick(e)
			{
				if (!this.hidden)
				{
					this.click.dispatchEvent(
						{ target: this, domEvent: e }
					);
				}
			}).bind(this)
		);
	}

	hide()
	{
		this.span.setAttribute(WordElement.ATTRIBUTE_NAME, WordElement.ATTRIBUTE_HIDDEN);
		this.hidden = true;
	}

	show()
	{
		if (this.match)
		{
			if (this.hasExactMatch())
			{
				this.span.setAttribute(WordElement.ATTRIBUTE_NAME, WordElement.ATTRIBUTE_DEFINED);
			}
			else
			{
				this.span.setAttribute(WordElement.ATTRIBUTE_NAME, WordElement.ATTRIBUTE_SIMILAR);
			}
		}
		else
		{
			this.span.setAttribute(WordElement.ATTRIBUTE_NAME, WordElement.ATTRIBUTE_UNDEFINED);
		}
		this.hidden = false;
	}

	setData(match)
	{
		this.match = match;

		if (match.exact)
		{
			this.span.setAttribute(WordElement.ATTRIBUTE_NAME, WordElement.ATTRIBUTE_DEFINED);
		}
		else
		{
			this.span.setAttribute(WordElement.ATTRIBUTE_NAME, WordElement.ATTRIBUTE_SIMILAR);
		}
	}

	getWord()
	{
		return this.word;
	}

	getMatch()
	{
		return this.match;
	}

	hasExactMatch()
	{
		if (this.match)
		{
			return this.match.exact;
		}
		else
		{
			return false;
		}
	}

	hasMatch()
	{
		return this.match !== null;
	}

	showPopup()
	{
		if (!this.popup)
		{
			this.popup = document.createElement("span");
			this.popup.className = "wordology-popup";
			this.popup.style.display = "none";
			document.body.prepend(this.popup);
			this.popupBubble = document.createElement("span");
			this.popupTail = document.createElement("span");
			this.popupBubble.className = "wordology-popup-bubble"
			this.popupTail.className = "wordology-popup-tail";
		}

		this.popup.appendChild(this.popupBubble);
		this.popup.appendChild(this.popupTail);

		this.popupBubble.textContent = this.match.entry.definition;

		this.popup.style.display = "block";

		var wordLeft = this.span.getBoundingClientRect().left + pageXOffset;
		var wordTop  = this.span.getBoundingClientRect().top + pageYOffset;
		var wordWidth = this.span.getBoundingClientRect().width;
		var wordHeight = this.span.getBoundingClientRect().height;
		var popupHeight = this.popupBubble.scrollHeight + 5;
		var popupWidth = this.popupBubble.scrollWidth;

		const left = wordLeft + (wordWidth / 2) - (popupWidth/2) < 0;
		const right = wordLeft + (wordWidth/2) + (popupWidth/2) > window.innerWidth;
		const top = wordTop - this.popupBubble.scrollHeight - 5 < 0

		if (left)
		{
			this.popup.style.left = (wordLeft + wordWidth) + "px";
		}
		else if (right)
		{
			this.popup.style.left = (wordLeft - popupWidth) + "px";
		}
		else
		{
			this.popup.style.left = (wordLeft + (wordWidth - popupWidth)/2) + "px";
		}

		if (top)
		{
			this.popup.style.top = (wordTop + wordHeight + 5) + "px";
			this.popup.removeChild(this.popupBubble);
			this.popup.appendChild(this.popupBubble);
		}
		else
		{
			this.popup.style.top = (wordTop - this.popupBubble.scrollHeight - 5) + "px";
		}

		let tailClass;
		if (top)
		{
			if (left)
			{
				tailClass = WordElement.CSSClasses.TAIL_TOP_LEFT;
			}
			else if (right)
			{
				tailClass = WordElement.CSSClasses.TAIL_TOP_RIGHT;
			}
			else
			{
				tailClass = WordElement.CSSClasses.TAIL_TOP;
			}
		}
		else
		{
			if (left)
			{
				tailClass = WordElement.CSSClasses.TAIL_BOTTOM_LEFT;
			}
			else if (right)
			{
				tailClass = WordElement.CSSClasses.TAIL_BOTTOM_RIGHT;
			}
			else
			{
				tailClass =  WordElement.CSSClasses.TAIL_BOTTOM;
			}
		}

		for (let [key, cssClass] of Object.entries(WordElement.CSSClasses))
		{
			this.popupTail.classList.remove(cssClass);
		}
		this.popupTail.classList.add(tailClass);
	}

	hidePopup()
	{
		if (this.popup)
		{
			this.popup.style.display = "none";
		}
	}
}
Object.assign(WordElement,
	{
		ATTRIBUTE_NAME      : "wordology_word",
		ATTRIBUTE_UNDEFINED : "not_defined",
		ATTRIBUTE_DEFINED   : "defined",
		ATTRIBUTE_SIMILAR   : "similar",
		ATTRIBUTE_HIDDEN    : "hidden"
	}
);

WordElement.CSSClasses =
{
	TAIL_BOTTOM: "bottom",
	TAIL_TOP: "top",
	TAIL_TOP_LEFT: "top-left",
	TAIL_TOP_RIGHT: "top-right",
	TAIL_BOTTOM_LEFT: "bottom-left",
	TAIL_BOTTOM_RIGHT: "bottom-right"
};

class WordElementEvent
{
	constructor()
	{
		this.listeners = [];
	}

	addListener(f)
	{
		this.listeners.push(f);
	}

	dispatchEvent(details)
	{
		for (var f of this.listeners)
		{
			f(details);
		}
	}
}


<|start_filename|>src/ToggleManagerBackground.js<|end_filename|>
class ToggleManagerBackground
{
	/*
	 * config
	 *     onIcon       : path or animation
	 *     offIcon      : path or animation
	 *     runningIcon  : path or animation
	 *     notReadyIcon : path or animation
	 * */
	constructor(name, config)
	{
		this.onIcon       = config.onIcon;
		this.offIcon      = config.offIcon;
		this.runningIcon  = config.runningIcon;
		this.notReadyIcon = config.notReadyIcon;

		this.tabStateMap = new Map();
		this.amLoggedIn = true;

		this.firstOnMessageSender             = new MessageSender(name + "ToggleFirstOn");
		this.toggleOnMessageSender            = new MessageSender(name + "ToggleOn");
		this.toggleOffMessageSender           = new MessageSender(name + "ToggleOff");
		this.loggedOutMessageSender           = new MessageSender(name + "LoggedOut");
		this.loggedOutPressMessageSender      = new MessageSender(name + "LoggedOutPress");
		this.changedProfileMessageSender      = new MessageSender(name + "ChangedProfile");
		this.changedProfilePressMessageSender = new MessageSender(name + "ChangedProfilePress");

		new MessageSlot(name + "Ready",
			(message, sender) => this.onReadyMessage(sender.tab.id)
		);

		new MessageSlot(name + "DoneRunning",
			(message, sender) => this.onDoneRunningMessage(sender.tab.id)
		);

		browser.browserAction.onClicked.addListener(
			tab => this.toggle(tab.id)
		);

		browser.webNavigation.onBeforeNavigate.addListener(
			details =>
			{
				if (details.frameId === 0)
				{
					this.onTabUnload(details.tabId);
				}
			}
		);
	}

	toggle(tabId)
	{
		var state = this.getTabState(tabId);

		if (this.amLoggedIn)
		{
			switch (state)
			{
				case TabState.NeverOn:
					this.firstOnMessageSender.sendToTab(tabId, {});
					this.setTabState(tabId, TabState.Running);
					browser.browserAction.setIcon({path: this.runningIcon, tabId: tabId});
				break;

				case TabState.Off:
					this.toggleOnMessageSender.sendToTab(tabId);
					this.setTabState(tabId, TabState.Running);
					browser.browserAction.setIcon({path: this.runningIcon, tabId: tabId});
				break;

				case TabState.On:
					this.toggleOffMessageSender.sendToTab(tabId);
					this.setTabState(tabId, TabState.Off);
					browser.browserAction.setIcon({path: this.offIcon, tabId: tabId});
				break;

				case TabState.LoggedOut:
					this.loggedOutPressMessageSender.sendToTab(tabId);
				break;

				case TabState.ChangedProfile:
					this.changedProfilePressMessageSender.sendToTab(tabId);
				break;

				case undefined:
				case TabState.Running:
					// Do nothing.
				break;
			}
		}
		else
		{
			switch (state)
			{
				case TabState.NeverOn:
				case TabState.Off:
				case TabState.On:
				case TabState.LoggedOut:
				case TabState.ChangedProfile:
					this.loggedOutPressMessageSender.sendToTab(tabId);
				break;
			}
		}
	}

	loggedOut()
	{
		this.amLoggedIn = false;
		for (let tabId of this.tabStateMap.keys())
		{
			if (this.tabStateMap[tabId] != TabState.NeverOn)
			{
				this.setTabState(tabId, TabState.LoggedOut);
				browser.browserAction.setIcon({path: this.offIcon, tabId: tabId});
				this.loggedOutMessageSender.sendToTab(tabId);
			}
		}
	}

	loggedIn()
	{
		this.amLoggedIn = true;
		for (let tabId of this.tabStateMap.keys())
		{
			if (this.tabStateMap[tabId] != TabState.NeverOn)
			{
				this.setTabState(tabId, TabState.ChangedProfile);
				browser.browserAction.setIcon({path: this.offIcon, tabId: tabId});
				this.changedProfileMessageSender.sendToTab(tabId);
			}
		}
	}

	getTabState(tabId)
	{
		return this.tabStateMap.get(tabId);
	}

	setTabState(tabId, state)
	{
		this.tabStateMap.set(tabId, state);
	}

	onBrowserAction(tabId)
	{
		this.toggle(tabId);
	}

	onTabUnload(tabId)
	{
		this.tabStateMap.delete(tabId);
		browser.browserAction.setIcon({path: this.notReadyIcon, tabId: tabId});
	}

	onTabClose(tabId)
	{
		this.tabStateMap.delete(tabId);
	}

	onReadyMessage(tabId)
	{
		if (this.amLoggedIn)
		{
			this.setTabState(tabId, TabState.NeverOn);
		}
		else
		{
			this.setTabState(tabId, TabState.LoggedOut);
		}
		browser.browserAction.setIcon({path: this.offIcon, tabId: tabId});
	}

	onDoneRunningMessage(tabId)
	{
		if (this.amLoggedIn)
		{
			if (this.getTabState(tabId) === TabState.ChangedProfile)
			{
				browser.browserAction.setIcon({path: this.offIcon, tabId: tabId});
			}
			else
			{
				this.setTabState(tabId, TabState.On);
				browser.browserAction.setIcon({path: this.onIcon, tabId: tabId});
			}
		}
		else
		{
			this.setTabState(tabId, TabState.LoggedOut);
			browser.browserAction.setIcon({path: this.offIcon, tabId: tabId});
		}
	}

}


<|start_filename|>src/messages.js<|end_filename|>
class MessageSlot
{
	constructor(name, handler)
	{
		this.name = name;
		this.handler = handler;

		browser.runtime.onMessage.addListener(
			(message, sender, response) =>
			{
				if (message.slotName == this.name)
				{
					this.handler(message.content, sender);
				}
			}
		);
	}

	setName(name)
	{
		this.name = name;
	}

	setHandler(handler)
	{
		this.handler = handler;
	}
}

class MessageSender
{
	constructor(name)
	{
		this.slotName = name;
	}

	setTargetSlot(name)
	{
		this.slotName = name;
	}

	sendToRuntime(message)
	{
		browser.runtime.sendMessage(
			{
				slotName: this.slotName,
				content : message
			}
		);
	}

	sendToTab(tabId, message)
	{
		browser.tabs.sendMessage(
			tabId,
			{
				slotName: this.slotName,
				content : message
			}
		);
	}
}


<|start_filename|>src/wordmatcher.worker.js<|end_filename|>
importScripts("wordmatching.js");

if (self)
{
	self.onmessage = function(e)
	{
		var arguments = e.data;
		RunWorker(arguments);
	};
}

function RunWorker(arguments)
{
	var requestedWords    = arguments.requestedWords;
	var options           = arguments.options;
	var dictionaryEntries = arguments.dictionaryEntries;

	var dictOfMatches = WordMatching.MatchWords(requestedWords, dictionaryEntries, options);

	postMessage(dictOfMatches);
}


<|start_filename|>src/settings/root-controller.controller.js<|end_filename|>
class RootController
{
    constructor($scope)
    {
        this.$scope = $scope;
        this.OM = new OptionsManager();
        this.tab = "options";
        this.selectedProfile = null;
        this.OM.getGlobalOptions().then(
            options => {
                this.selectedLanguage = options.language;
                this.strings = WordologyStrings.getStrings(options.language);
            }
        );
        this.reloadProfiles();
    }

    async reloadProfiles()
    {
        var listOfProfiles = await this.OM.getProfiles();
        this.profiles = listOfProfiles;
        var profileId = await this.OM.getCurrentProfileId();
        if (profileId !== null)
        {
            this.selectedProfile = this.profiles.filter(profile => profile.id === profileId)[0];
        }
        else
        {
            this.selectedProfile = null;
        }
        this.$scope.$digest();
        this.$scope.$broadcast("reload");
    }

    async languageChanged()
    {
        await this.OM.setGlobalOption("language", this.selectedLanguage);
        this.strings = WordologyStrings.getStrings(this.selectedLanguage);
        this.$scope.$digest();
    }

    async profileChanged()
    {
        if (this.selectedProfile)
        {
            this.OM.setCurrentProfile(this.selectedProfile.id);
        }
        this.$scope.$broadcast("reload");
    }

    async onClickAddProfile()
    {
        var name = await this.showPrompt(this.strings.ADD_PROFILE);
        if (name)
        {
            var newId = await this.OM.createProfile(name);
            await this.OM.setCurrentProfile(newId);
            this.reloadProfiles();
        }
    }

    async onClickRenameProfile()
    {
        var name = await this.showPrompt(this.strings.RENAME_PROFILE(this.selectedProfile.name));
        if (name)
        {
            await this.OM.renameProfile(this.selectedProfile.id, name);
            this.reloadProfiles();
        }
    }

    async onClickDeleteProfile()
    {
        var response = await this.showPrompt(this.strings.DELETE_PROFILE_WARNING(this.selectedProfile.name));
        if (response === this.strings.DELETE_PASSWORD)
        {
            await this.OM.deleteProfile(this.selectedProfile.id);
            Dictionary.deleteDatabase(this.selectedProfile.id);
            this.reloadProfiles();
        }
    }

    async showPrompt(message)
    {
        vex.dialog.buttons.YES.text = this.strings.OK;
        vex.dialog.buttons.NO.text = this.strings.CANCEL;
        return new Promise(
            resolve => {
                vex.dialog.prompt({message: message, callback: resolve});
            }
        );
    }
}

app.controller("RootController", RootController);