From 43d1457ebfe23196348649c245dfb942a0f00a1a Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Sat, 13 Feb 2021 23:20:22 -0500
Subject: Move bg/js/settings(2) (#1385)

* Move bg/js/settings/ to js/settings/

* Move bg/js/settings2/ to js/settings/
---
 ext/js/settings/anki-controller.js                 | 729 +++++++++++++++++++++
 ext/js/settings/anki-templates-controller.js       | 228 +++++++
 ext/js/settings/audio-controller.js                | 234 +++++++
 ext/js/settings/backup-controller.js               | 419 ++++++++++++
 ext/js/settings/dictionary-controller.js           | 558 ++++++++++++++++
 ext/js/settings/dictionary-import-controller.js    | 346 ++++++++++
 .../extension-keyboard-shortcuts-controller.js     | 292 +++++++++
 ext/js/settings/generic-setting-controller.js      | 232 +++++++
 ext/js/settings/keyboard-mouse-input-field.js      | 243 +++++++
 ext/js/settings/keyboard-shortcuts-controller.js   | 368 +++++++++++
 ext/js/settings/main.js                            | 111 ++++
 ext/js/settings/mecab-controller.js                |  67 ++
 ext/js/settings/modal-controller.js                |  60 ++
 ext/js/settings/modal-jquery.js                    |  73 +++
 ext/js/settings/modal.js                           |  80 +++
 ext/js/settings/nested-popups-controller.js        |  71 ++
 ext/js/settings/permissions-toggle-controller.js   | 131 ++++
 ext/js/settings/pitch-accents-preview-main.js      |  33 +
 ext/js/settings/popup-preview-controller.js        | 129 ++++
 ext/js/settings/popup-preview-frame-main.js        |  44 ++
 ext/js/settings/popup-preview-frame.js             | 233 +++++++
 ext/js/settings/popup-window-controller.js         |  38 ++
 ext/js/settings/profile-conditions-ui.js           | 712 ++++++++++++++++++++
 ext/js/settings/profile-controller.js              | 698 ++++++++++++++++++++
 ext/js/settings/scan-inputs-controller.js          | 310 +++++++++
 ext/js/settings/scan-inputs-simple-controller.js   | 247 +++++++
 .../secondary-search-dictionary-controller.js      |  73 +++
 .../sentence-termination-characters-controller.js  | 243 +++++++
 ext/js/settings/settings-controller.js             | 210 ++++++
 ext/js/settings/settings-display-controller.js     | 400 +++++++++++
 ext/js/settings/settings-main.js                   | 156 +++++
 ext/js/settings/status-footer.js                   |  84 +++
 ext/js/settings/storage-controller.js              | 183 ++++++
 .../translation-text-replacements-controller.js    | 242 +++++++
 34 files changed, 8277 insertions(+)
 create mode 100644 ext/js/settings/anki-controller.js
 create mode 100644 ext/js/settings/anki-templates-controller.js
 create mode 100644 ext/js/settings/audio-controller.js
 create mode 100644 ext/js/settings/backup-controller.js
 create mode 100644 ext/js/settings/dictionary-controller.js
 create mode 100644 ext/js/settings/dictionary-import-controller.js
 create mode 100644 ext/js/settings/extension-keyboard-shortcuts-controller.js
 create mode 100644 ext/js/settings/generic-setting-controller.js
 create mode 100644 ext/js/settings/keyboard-mouse-input-field.js
 create mode 100644 ext/js/settings/keyboard-shortcuts-controller.js
 create mode 100644 ext/js/settings/main.js
 create mode 100644 ext/js/settings/mecab-controller.js
 create mode 100644 ext/js/settings/modal-controller.js
 create mode 100644 ext/js/settings/modal-jquery.js
 create mode 100644 ext/js/settings/modal.js
 create mode 100644 ext/js/settings/nested-popups-controller.js
 create mode 100644 ext/js/settings/permissions-toggle-controller.js
 create mode 100644 ext/js/settings/pitch-accents-preview-main.js
 create mode 100644 ext/js/settings/popup-preview-controller.js
 create mode 100644 ext/js/settings/popup-preview-frame-main.js
 create mode 100644 ext/js/settings/popup-preview-frame.js
 create mode 100644 ext/js/settings/popup-window-controller.js
 create mode 100644 ext/js/settings/profile-conditions-ui.js
 create mode 100644 ext/js/settings/profile-controller.js
 create mode 100644 ext/js/settings/scan-inputs-controller.js
 create mode 100644 ext/js/settings/scan-inputs-simple-controller.js
 create mode 100644 ext/js/settings/secondary-search-dictionary-controller.js
 create mode 100644 ext/js/settings/sentence-termination-characters-controller.js
 create mode 100644 ext/js/settings/settings-controller.js
 create mode 100644 ext/js/settings/settings-display-controller.js
 create mode 100644 ext/js/settings/settings-main.js
 create mode 100644 ext/js/settings/status-footer.js
 create mode 100644 ext/js/settings/storage-controller.js
 create mode 100644 ext/js/settings/translation-text-replacements-controller.js

(limited to 'ext/js')

diff --git a/ext/js/settings/anki-controller.js b/ext/js/settings/anki-controller.js
new file mode 100644
index 00000000..db3e3c14
--- /dev/null
+++ b/ext/js/settings/anki-controller.js
@@ -0,0 +1,729 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * AnkiConnect
+ * AnkiNoteBuilder
+ * ObjectPropertyAccessor
+ * SelectorObserver
+ */
+
+class AnkiController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._ankiConnect = new AnkiConnect();
+        this._ankiNoteBuilder = new AnkiNoteBuilder(false);
+        this._selectorObserver = new SelectorObserver({
+            selector: '.anki-card',
+            ignoreSelector: null,
+            onAdded: this._createCardController.bind(this),
+            onRemoved: this._removeCardController.bind(this),
+            isStale: this._isCardControllerStale.bind(this)
+        });
+        this._stringComparer = new Intl.Collator(); // Locale does not matter
+        this._getAnkiDataPromise = null;
+        this._ankiErrorContainer = null;
+        this._ankiErrorMessageNode = null;
+        this._ankiErrorMessageNodeDefaultContent = '';
+        this._ankiErrorMessageDetailsNode = null;
+        this._ankiErrorMessageDetailsContainer = null;
+        this._ankiErrorMessageDetailsToggle = null;
+        this._ankiErrorInvalidResponseInfo = null;
+        this._ankiCardPrimary = null;
+        this._ankiCardPrimaryType = null;
+        this._validateFieldsToken = null;
+    }
+
+    get settingsController() {
+        return this._settingsController;
+    }
+
+    async prepare() {
+        this._ankiErrorContainer = document.querySelector('#anki-error');
+        this._ankiErrorMessageNode = document.querySelector('#anki-error-message');
+        this._ankiErrorMessageNodeDefaultContent = this._ankiErrorMessageNode.textContent;
+        this._ankiErrorMessageDetailsNode = document.querySelector('#anki-error-message-details');
+        this._ankiErrorMessageDetailsContainer = document.querySelector('#anki-error-message-details-container');
+        this._ankiErrorMessageDetailsToggle = document.querySelector('#anki-error-message-details-toggle');
+        this._ankiErrorInvalidResponseInfo = document.querySelector('#anki-error-invalid-response-info');
+        this._ankiEnableCheckbox = document.querySelector('[data-setting="anki.enable"]');
+        this._ankiCardPrimary = document.querySelector('#anki-card-primary');
+        this._ankiCardPrimaryType = document.querySelector('#anki-card-primary-type');
+
+        this._setupFieldMenus();
+
+        this._ankiErrorMessageDetailsToggle.addEventListener('click', this._onAnkiErrorMessageDetailsToggleClick.bind(this), false);
+        if (this._ankiEnableCheckbox !== null) { this._ankiEnableCheckbox.addEventListener('settingChanged', this._onAnkiEnableChanged.bind(this), false); }
+        if (this._ankiCardPrimaryType !== null) { this._ankiCardPrimaryType.addEventListener('change', this._onAnkiCardPrimaryTypeChange.bind(this), false); }
+
+        const options = await this._settingsController.getOptions();
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+        this._onOptionsChanged({options});
+    }
+
+    getFieldMarkers(type) {
+        switch (type) {
+            case 'terms':
+                return [
+                    'audio',
+                    'clipboard-image',
+                    'clipboard-text',
+                    'cloze-body',
+                    'cloze-prefix',
+                    'cloze-suffix',
+                    'conjugation',
+                    'dictionary',
+                    'document-title',
+                    'expression',
+                    'frequencies',
+                    'furigana',
+                    'furigana-plain',
+                    'glossary',
+                    'glossary-brief',
+                    'glossary-no-dictionary',
+                    'pitch-accents',
+                    'pitch-accent-graphs',
+                    'pitch-accent-positions',
+                    'reading',
+                    'screenshot',
+                    'sentence',
+                    'tags',
+                    'url'
+                ];
+            case 'kanji':
+                return [
+                    'character',
+                    'clipboard-image',
+                    'clipboard-text',
+                    'cloze-body',
+                    'cloze-prefix',
+                    'cloze-suffix',
+                    'dictionary',
+                    'document-title',
+                    'glossary',
+                    'kunyomi',
+                    'onyomi',
+                    'screenshot',
+                    'sentence',
+                    'stroke-count',
+                    'tags',
+                    'url'
+                ];
+            default:
+                return [];
+        }
+    }
+
+    getFieldMarkersHtml(markers) {
+        const fragment = document.createDocumentFragment();
+        for (const marker of markers) {
+            const markerNode = this._settingsController.instantiateTemplate('anki-card-field-marker');
+            markerNode.querySelector('.marker-link').textContent = marker;
+            fragment.appendChild(markerNode);
+        }
+        return fragment;
+    }
+
+    async getAnkiData() {
+        let promise = this._getAnkiDataPromise;
+        if (promise === null) {
+            promise = this._getAnkiData();
+            this._getAnkiDataPromise = promise;
+            promise.finally(() => { this._getAnkiDataPromise = null; });
+        }
+        return promise;
+    }
+
+    async getModelFieldNames(model) {
+        return await this._ankiConnect.getModelFieldNames(model);
+    }
+
+    getRequiredPermissions(fieldValue) {
+        return this._settingsController.permissionsUtil.getRequiredPermissionsForAnkiFieldValue(fieldValue);
+    }
+
+    containsAnyMarker(field) {
+        return this._ankiNoteBuilder.containsAnyMarker(field);
+    }
+
+    // Private
+
+    async _onOptionsChanged({options: {anki}}) {
+        this._ankiConnect.server = anki.server;
+        this._ankiConnect.enabled = anki.enable;
+
+        this._selectorObserver.disconnect();
+        this._selectorObserver.observe(document.documentElement, true);
+    }
+
+    _onAnkiErrorMessageDetailsToggleClick() {
+        const node = this._ankiErrorMessageDetailsContainer;
+        node.hidden = !node.hidden;
+    }
+
+    _onAnkiEnableChanged({detail: {value}}) {
+        if (this._ankiConnect.server === null) { return; }
+        this._ankiConnect.enabled = value;
+
+        for (const cardController of this._selectorObserver.datas()) {
+            cardController.updateAnkiState();
+        }
+    }
+
+    _onAnkiCardPrimaryTypeChange(e) {
+        if (this._ankiCardPrimary === null) { return; }
+        const node = e.currentTarget;
+        let ankiCardMenu;
+        if (node.selectedIndex >= 0) {
+            const option = node.options[node.selectedIndex];
+            ankiCardMenu = option.dataset.ankiCardMenu;
+        }
+
+        this._ankiCardPrimary.dataset.ankiCardType = node.value;
+        if (typeof ankiCardMenu !== 'undefined') {
+            this._ankiCardPrimary.dataset.ankiCardMenu = ankiCardMenu;
+        } else {
+            delete this._ankiCardPrimary.dataset.ankiCardMenu;
+        }
+    }
+
+    _createCardController(node) {
+        const cardController = new AnkiCardController(this._settingsController, this, node);
+        cardController.prepare();
+        return cardController;
+    }
+
+    _removeCardController(node, cardController) {
+        cardController.cleanup();
+    }
+
+    _isCardControllerStale(node, cardController) {
+        return cardController.isStale();
+    }
+
+    _setupFieldMenus() {
+        const fieldMenuTargets = [
+            [['terms'], '#anki-card-terms-field-menu-template'],
+            [['kanji'], '#anki-card-kanji-field-menu-template'],
+            [['terms', 'kanji'], '#anki-card-all-field-menu-template']
+        ];
+        for (const [types, selector] of fieldMenuTargets) {
+            const element = document.querySelector(selector);
+            if (element === null) { continue; }
+
+            let markers = [];
+            for (const type of types) {
+                markers.push(...this.getFieldMarkers(type));
+            }
+            markers = [...new Set(markers)];
+
+            const container = element.content.querySelector('.popup-menu-body');
+            if (container === null) { return; }
+
+            const fragment = document.createDocumentFragment();
+            for (const marker of markers) {
+                const option = document.createElement('button');
+                option.textContent = marker;
+                option.className = 'popup-menu-item';
+                option.dataset.menuAction = 'setFieldMarker';
+                option.dataset.marker = marker;
+                fragment.appendChild(option);
+            }
+            container.appendChild(fragment);
+        }
+    }
+
+    async _getAnkiData() {
+        this._setAnkiStatusChanging();
+        const [
+            [deckNames, error1],
+            [modelNames, error2]
+        ] = await Promise.all([
+            this._getDeckNames(),
+            this._getModelNames()
+        ]);
+
+        if (error1 !== null) {
+            this._showAnkiError(error1);
+        } else if (error2 !== null) {
+            this._showAnkiError(error2);
+        } else {
+            this._hideAnkiError();
+        }
+
+        return {deckNames, modelNames};
+    }
+
+    async _getDeckNames() {
+        try {
+            const result = await this._ankiConnect.getDeckNames();
+            this._sortStringArray(result);
+            return [result, null];
+        } catch (e) {
+            return [[], e];
+        }
+    }
+
+    async _getModelNames() {
+        try {
+            const result = await this._ankiConnect.getModelNames();
+            this._sortStringArray(result);
+            return [result, null];
+        } catch (e) {
+            return [[], e];
+        }
+    }
+
+    _setAnkiStatusChanging() {
+        this._ankiErrorMessageNode.textContent = this._ankiErrorMessageNodeDefaultContent;
+        this._ankiErrorMessageNode.classList.remove('danger-text');
+    }
+
+    _hideAnkiError() {
+        if (this._ankiErrorContainer !== null) {
+            this._ankiErrorContainer.hidden = true;
+        }
+        this._ankiErrorMessageDetailsContainer.hidden = true;
+        this._ankiErrorMessageDetailsToggle.hidden = true;
+        this._ankiErrorInvalidResponseInfo.hidden = true;
+        this._ankiErrorMessageNode.textContent = (this._ankiConnect.enabled ? 'Connected' : 'Not enabled');
+        this._ankiErrorMessageNode.classList.remove('danger-text');
+        this._ankiErrorMessageDetailsNode.textContent = '';
+    }
+
+    _showAnkiError(error) {
+        let errorString = typeof error === 'object' && error !== null ? error.message : null;
+        if (!errorString) { errorString = `${error}`; }
+        if (!/[.!?]$/.test(errorString)) { errorString += '.'; }
+        this._ankiErrorMessageNode.textContent = errorString;
+        this._ankiErrorMessageNode.classList.add('danger-text');
+
+        const data = error.data;
+        let details = '';
+        if (typeof data !== 'undefined') {
+            details += `${JSON.stringify(data, null, 4)}\n\n`;
+        }
+        details += `${error.stack}`.trimRight();
+        this._ankiErrorMessageDetailsNode.textContent = details;
+
+        if (this._ankiErrorContainer !== null) {
+            this._ankiErrorContainer.hidden = false;
+        }
+        this._ankiErrorMessageDetailsContainer.hidden = true;
+        this._ankiErrorInvalidResponseInfo.hidden = (errorString.indexOf('Invalid response') < 0);
+        this._ankiErrorMessageDetailsToggle.hidden = false;
+    }
+
+    _sortStringArray(array) {
+        const stringComparer = this._stringComparer;
+        array.sort((a, b) => stringComparer.compare(a, b));
+    }
+}
+
+class AnkiCardController {
+    constructor(settingsController, ankiController, node) {
+        this._settingsController = settingsController;
+        this._ankiController = ankiController;
+        this._node = node;
+        this._cardType = node.dataset.ankiCardType;
+        this._cardMenu = node.dataset.ankiCardMenu;
+        this._eventListeners = new EventListenerCollection();
+        this._fieldEventListeners = new EventListenerCollection();
+        this._deck = null;
+        this._model = null;
+        this._fields = null;
+        this._modelChangingTo = null;
+        this._ankiCardDeckSelect = null;
+        this._ankiCardModelSelect = null;
+        this._ankiCardFieldsContainer = null;
+        this._cleaned = false;
+        this._fieldEntries = [];
+    }
+
+    async prepare() {
+        const options = await this._settingsController.getOptions();
+        const ankiOptions = options.anki;
+        if (this._cleaned) { return; }
+
+        const cardOptions = this._getCardOptions(ankiOptions, this._cardType);
+        if (cardOptions === null) { return; }
+        const {deck, model, fields} = cardOptions;
+        this._deck = deck;
+        this._model = model;
+        this._fields = fields;
+
+        this._ankiCardDeckSelect = this._node.querySelector('.anki-card-deck');
+        this._ankiCardModelSelect = this._node.querySelector('.anki-card-model');
+        this._ankiCardFieldsContainer = this._node.querySelector('.anki-card-fields');
+
+        this._setupSelects([], []);
+        this._setupFields();
+
+        this._eventListeners.addEventListener(this._ankiCardDeckSelect, 'change', this._onCardDeckChange.bind(this), false);
+        this._eventListeners.addEventListener(this._ankiCardModelSelect, 'change', this._onCardModelChange.bind(this), false);
+        this._eventListeners.on(this._settingsController, 'permissionsChanged', this._onPermissionsChanged.bind(this));
+
+        await this.updateAnkiState();
+    }
+
+    cleanup() {
+        this._cleaned = true;
+        this._fieldEntries = [];
+        this._eventListeners.removeAllEventListeners();
+    }
+
+    async updateAnkiState() {
+        if (this._fields === null) { return; }
+        const {deckNames, modelNames} = await this._ankiController.getAnkiData();
+        if (this._cleaned) { return; }
+        this._setupSelects(deckNames, modelNames);
+    }
+
+    isStale() {
+        return (this._cardType !== this._node.dataset.ankiCardType);
+    }
+
+    // Private
+
+    _onCardDeckChange(e) {
+        this._setDeck(e.currentTarget.value);
+    }
+
+    _onCardModelChange(e) {
+        this._setModel(e.currentTarget.value);
+    }
+
+    _onFieldChange(index, e) {
+        const node = e.currentTarget;
+        this._validateFieldPermissions(node, index, true);
+        this._validateField(node, index);
+    }
+
+    _onFieldInput(index, e) {
+        const node = e.currentTarget;
+        this._validateField(node, index);
+    }
+
+    _onFieldSettingChanged(index, e) {
+        const node = e.currentTarget;
+        this._validateFieldPermissions(node, index, false);
+    }
+
+    _onFieldMenuClose({currentTarget: button, detail: {action, item}}) {
+        switch (action) {
+            case 'setFieldMarker':
+                this._setFieldMarker(button, item.dataset.marker);
+                break;
+        }
+    }
+
+    _onFieldMarkerLinkClick(e) {
+        e.preventDefault();
+        const link = e.currentTarget;
+        this._setFieldMarker(link, link.textContent);
+    }
+
+    _validateField(node, index) {
+        let valid = (node.dataset.hasPermissions !== 'false');
+        if (valid && index === 0 && !this._ankiController.containsAnyMarker(node.value)) {
+            valid = false;
+        }
+        node.dataset.invalid = `${!valid}`;
+    }
+
+    _setFieldMarker(element, marker) {
+        const input = element.closest('.anki-card-field-value-container').querySelector('.anki-card-field-value');
+        input.value = `{${marker}}`;
+        input.dispatchEvent(new Event('change'));
+    }
+
+    _getCardOptions(ankiOptions, cardType) {
+        switch (cardType) {
+            case 'terms': return ankiOptions.terms;
+            case 'kanji': return ankiOptions.kanji;
+            default: return null;
+        }
+    }
+
+    _setupSelects(deckNames, modelNames) {
+        const deck = this._deck;
+        const model = this._model;
+        if (!deckNames.includes(deck)) { deckNames = [...deckNames, deck]; }
+        if (!modelNames.includes(model)) { modelNames = [...modelNames, model]; }
+
+        this._setSelectOptions(this._ankiCardDeckSelect, deckNames);
+        this._ankiCardDeckSelect.value = deck;
+
+        this._setSelectOptions(this._ankiCardModelSelect, modelNames);
+        this._ankiCardModelSelect.value = model;
+    }
+
+    _setSelectOptions(select, optionValues) {
+        const fragment = document.createDocumentFragment();
+        for (const optionValue of optionValues) {
+            const option = document.createElement('option');
+            option.value = optionValue;
+            option.textContent = optionValue;
+            fragment.appendChild(option);
+        }
+        select.textContent = '';
+        select.appendChild(fragment);
+    }
+
+    _setupFields() {
+        this._fieldEventListeners.removeAllEventListeners();
+
+        const markers = this._ankiController.getFieldMarkers(this._cardType);
+        const totalFragment = document.createDocumentFragment();
+        this._fieldEntries = [];
+        let index = 0;
+        for (const [fieldName, fieldValue] of Object.entries(this._fields)) {
+            const content = this._settingsController.instantiateTemplateFragment('anki-card-field');
+
+            const fieldNameContainerNode = content.querySelector('.anki-card-field-name-container');
+            fieldNameContainerNode.dataset.index = `${index}`;
+            const fieldNameNode = content.querySelector('.anki-card-field-name');
+            fieldNameNode.textContent = fieldName;
+
+            const valueContainer = content.querySelector('.anki-card-field-value-container');
+            valueContainer.dataset.index = `${index}`;
+
+            const inputField = content.querySelector('.anki-card-field-value');
+            inputField.value = fieldValue;
+            inputField.dataset.setting = ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'fields', fieldName]);
+            this._validateFieldPermissions(inputField, index, false);
+
+            this._fieldEventListeners.addEventListener(inputField, 'change', this._onFieldChange.bind(this, index), false);
+            this._fieldEventListeners.addEventListener(inputField, 'input', this._onFieldInput.bind(this, index), false);
+            this._fieldEventListeners.addEventListener(inputField, 'settingChanged', this._onFieldSettingChanged.bind(this, index), false);
+            this._validateField(inputField, index);
+
+            const markerList = content.querySelector('.anki-card-field-marker-list');
+            if (markerList !== null) {
+                const markersFragment = this._ankiController.getFieldMarkersHtml(markers);
+                for (const element of markersFragment.querySelectorAll('.marker-link')) {
+                    this._fieldEventListeners.addEventListener(element, 'click', this._onFieldMarkerLinkClick.bind(this), false);
+                }
+                markerList.appendChild(markersFragment);
+            }
+
+            const menuButton = content.querySelector('.anki-card-field-value-menu-button');
+            if (menuButton !== null) {
+                if (typeof this._cardMenu !== 'undefined') {
+                    menuButton.dataset.menu = this._cardMenu;
+                } else {
+                    delete menuButton.dataset.menu;
+                }
+                this._fieldEventListeners.addEventListener(menuButton, 'menuClose', this._onFieldMenuClose.bind(this), false);
+            }
+
+            totalFragment.appendChild(content);
+            this._fieldEntries.push({fieldName, inputField, fieldNameContainerNode});
+
+            ++index;
+        }
+
+        const ELEMENT_NODE = Node.ELEMENT_NODE;
+        const container = this._ankiCardFieldsContainer;
+        for (const node of [...container.childNodes]) {
+            if (node.nodeType === ELEMENT_NODE && node.dataset.persistent === 'true') { continue; }
+            container.removeChild(node);
+        }
+        container.appendChild(totalFragment);
+
+        this._validateFields();
+    }
+
+    async _validateFields() {
+        const token = {};
+        this._validateFieldsToken = token;
+
+        let fieldNames;
+        try {
+            fieldNames = await this._ankiController.getModelFieldNames(this._model);
+        } catch (e) {
+            return;
+        }
+
+        if (token !== this._validateFieldsToken) { return; }
+
+        const fieldNamesSet = new Set(fieldNames);
+        let index = 0;
+        for (const {fieldName, fieldNameContainerNode} of this._fieldEntries) {
+            fieldNameContainerNode.dataset.invalid = `${!fieldNamesSet.has(fieldName)}`;
+            fieldNameContainerNode.dataset.orderMatches = `${index < fieldNames.length && fieldName === fieldNames[index]}`;
+            ++index;
+        }
+    }
+
+    async _setDeck(value) {
+        if (this._deck === value) { return; }
+        this._deck = value;
+
+        await this._settingsController.modifyProfileSettings([{
+            action: 'set',
+            path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'deck']),
+            value
+        }]);
+    }
+
+    async _setModel(value) {
+        if (this._modelChangingTo !== null) {
+            // Revert
+            this._ankiCardModelSelect.value = this._modelChangingTo;
+            return;
+        }
+        if (this._model === value) { return; }
+
+        let fieldNames;
+        let options;
+        try {
+            this._modelChangingTo = value;
+            fieldNames = await this._ankiController.getModelFieldNames(value);
+            options = await this._ankiController.settingsController.getOptions();
+        } catch (e) {
+            // Revert
+            this._ankiCardModelSelect.value = this._model;
+            return;
+        } finally {
+            this._modelChangingTo = null;
+        }
+
+        const cardType = this._cardType;
+        const cardOptions = this._getCardOptions(options.anki, cardType);
+        const oldFields = cardOptions !== null ? cardOptions.fields : null;
+
+        const fields = {};
+        for (let i = 0, ii = fieldNames.length; i < ii; ++i) {
+            const fieldName = fieldNames[i];
+            fields[fieldName] = this._getDefaultFieldValue(fieldName, i, cardType, oldFields);
+        }
+
+        const targets = [
+            {
+                action: 'set',
+                path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'model']),
+                value
+            },
+            {
+                action: 'set',
+                path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'fields']),
+                value: fields
+            }
+        ];
+
+        this._model = value;
+        this._fields = fields;
+
+        await this._settingsController.modifyProfileSettings(targets);
+
+        this._setupFields();
+    }
+
+    async _requestPermissions(permissions) {
+        try {
+            await this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, true);
+        } catch (e) {
+            yomichan.logError(e);
+        }
+    }
+
+    async _validateFieldPermissions(node, index, request) {
+        const fieldValue = node.value;
+        const permissions = this._ankiController.getRequiredPermissions(fieldValue);
+        if (permissions.length > 0) {
+            node.dataset.requiredPermission = permissions.join(' ');
+            const hasPermissions = await (
+                request ?
+                this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, true) :
+                this._settingsController.permissionsUtil.hasPermissions({permissions})
+            );
+            node.dataset.hasPermissions = `${hasPermissions}`;
+        } else {
+            delete node.dataset.requiredPermission;
+            delete node.dataset.hasPermissions;
+        }
+
+        this._validateField(node, index);
+    }
+
+    _onPermissionsChanged({permissions: {permissions}}) {
+        const permissionsSet = new Set(permissions);
+        for (let i = 0, ii = this._fieldEntries.length; i < ii; ++i) {
+            const {inputField} = this._fieldEntries[i];
+            let {requiredPermission} = inputField.dataset;
+            if (typeof requiredPermission !== 'string') { continue; }
+            requiredPermission = (requiredPermission.length === 0 ? [] : requiredPermission.split(' '));
+
+            let hasPermissions = true;
+            for (const permission of requiredPermission) {
+                if (!permissionsSet.has(permission)) {
+                    hasPermissions = false;
+                    break;
+                }
+            }
+
+            inputField.dataset.hasPermissions = `${hasPermissions}`;
+            this._validateField(inputField, i);
+        }
+    }
+
+    _getDefaultFieldValue(fieldName, index, cardType, oldFields) {
+        if (
+            typeof oldFields === 'object' &&
+            oldFields !== null &&
+            Object.prototype.hasOwnProperty.call(oldFields, fieldName)
+        ) {
+            return oldFields[fieldName];
+        }
+
+        if (index === 0) {
+            return (cardType === 'kanji' ? '{character}' : '{expression}');
+        }
+
+        const markers = this._ankiController.getFieldMarkers(cardType);
+        const markerAliases = new Map([
+            ['glossary', ['definition', 'meaning']],
+            ['audio', ['sound']],
+            ['dictionary', ['dict']]
+        ]);
+
+        const hyphenPattern = /-/g;
+        for (const marker of markers) {
+            const names = [marker];
+            const aliases = markerAliases.get(marker);
+            if (typeof aliases !== 'undefined') {
+                names.push(...aliases);
+            }
+
+            let pattern = '^(?:';
+            for (let i = 0, ii = names.length; i < ii; ++i) {
+                const name = names[i];
+                if (i > 0) { pattern += '|'; }
+                pattern += name.replace(hyphenPattern, '[-_ ]*');
+            }
+            pattern += ')$';
+            pattern = new RegExp(pattern, 'i');
+
+            if (pattern.test(fieldName)) {
+                return `{${marker}}`;
+            }
+        }
+
+        return '';
+    }
+}
diff --git a/ext/js/settings/anki-templates-controller.js b/ext/js/settings/anki-templates-controller.js
new file mode 100644
index 00000000..31bd1e92
--- /dev/null
+++ b/ext/js/settings/anki-templates-controller.js
@@ -0,0 +1,228 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * AnkiNoteBuilder
+ * api
+ */
+
+class AnkiTemplatesController {
+    constructor(settingsController, modalController, ankiController) {
+        this._settingsController = settingsController;
+        this._modalController = modalController;
+        this._ankiController = ankiController;
+        this._cachedDefinitionValue = null;
+        this._cachedDefinitionText = null;
+        this._defaultFieldTemplates = null;
+        this._fieldTemplatesTextarea = null;
+        this._compileResultInfo = null;
+        this._renderFieldInput = null;
+        this._renderResult = null;
+        this._fieldTemplateResetModal = null;
+        this._ankiNoteBuilder = new AnkiNoteBuilder(true);
+    }
+
+    async prepare() {
+        this._defaultFieldTemplates = await api.getDefaultAnkiFieldTemplates();
+
+        this._fieldTemplatesTextarea = document.querySelector('#anki-card-templates-textarea');
+        this._compileResultInfo = document.querySelector('#anki-card-templates-compile-result');
+        this._renderFieldInput = document.querySelector('#anki-card-templates-test-field-input');
+        this._renderTextInput = document.querySelector('#anki-card-templates-test-text-input');
+        this._renderResult = document.querySelector('#anki-card-templates-render-result');
+        const menuButton = document.querySelector('#anki-card-templates-test-field-menu-button');
+        const testRenderButton = document.querySelector('#anki-card-templates-test-render-button');
+        const resetButton = document.querySelector('#anki-card-templates-reset-button');
+        const resetConfirmButton = document.querySelector('#anki-card-templates-reset-button-confirm');
+        const fieldList = document.querySelector('#anki-card-templates-field-list');
+        this._fieldTemplateResetModal = this._modalController.getModal('anki-card-templates-reset');
+
+        const markers = new Set([
+            ...this._ankiController.getFieldMarkers('terms'),
+            ...this._ankiController.getFieldMarkers('kanji')
+        ]);
+
+        if (fieldList !== null) {
+            const fragment = this._ankiController.getFieldMarkersHtml(markers);
+            fieldList.appendChild(fragment);
+            for (const node of fieldList.querySelectorAll('.marker-link')) {
+                node.addEventListener('click', this._onMarkerClicked.bind(this), false);
+            }
+        }
+
+        this._fieldTemplatesTextarea.addEventListener('change', this._onChanged.bind(this), false);
+        testRenderButton.addEventListener('click', this._onRender.bind(this), false);
+        resetButton.addEventListener('click', this._onReset.bind(this), false);
+        resetConfirmButton.addEventListener('click', this._onResetConfirm.bind(this), false);
+        if (menuButton !== null) {
+            menuButton.addEventListener('menuClose', this._onFieldMenuClose.bind(this), false);
+        }
+
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+
+        const options = await this._settingsController.getOptions();
+        this._onOptionsChanged({options});
+    }
+
+    // Private
+
+    _onOptionsChanged({options}) {
+        let templates = options.anki.fieldTemplates;
+        if (typeof templates !== 'string') { templates = this._defaultFieldTemplates; }
+        this._fieldTemplatesTextarea.value = templates;
+
+        this._onValidateCompile();
+    }
+
+    _onReset(e) {
+        e.preventDefault();
+        this._fieldTemplateResetModal.setVisible(true);
+    }
+
+    _onResetConfirm(e) {
+        e.preventDefault();
+
+        this._fieldTemplateResetModal.setVisible(false);
+
+        const value = this._defaultFieldTemplates;
+
+        this._fieldTemplatesTextarea.value = value;
+        this._fieldTemplatesTextarea.dispatchEvent(new Event('change'));
+    }
+
+    async _onChanged(e) {
+        // Get value
+        let templates = e.currentTarget.value;
+        if (templates === this._defaultFieldTemplates) {
+            // Default
+            templates = null;
+        }
+
+        // Overwrite
+        await this._settingsController.setProfileSetting('anki.fieldTemplates', templates);
+
+        // Compile
+        this._onValidateCompile();
+    }
+
+    _onValidateCompile() {
+        this._validate(this._compileResultInfo, '{expression}', 'term-kanji', false, true);
+    }
+
+    _onMarkerClicked(e) {
+        e.preventDefault();
+        this._renderFieldInput.value = `{${e.target.textContent}}`;
+    }
+
+    _onRender(e) {
+        e.preventDefault();
+
+        const field = this._renderFieldInput.value;
+        const infoNode = this._renderResult;
+        infoNode.hidden = true;
+        this._cachedDefinitionText = null;
+        this._validate(infoNode, field, 'term-kanji', true, false);
+    }
+
+    _onFieldMenuClose({currentTarget: button, detail: {action, item}}) {
+        switch (action) {
+            case 'setFieldMarker':
+                this._setFieldMarker(button, item.dataset.marker);
+                break;
+        }
+    }
+
+    _setFieldMarker(element, marker) {
+        const input = this._renderFieldInput;
+        input.value = `{${marker}}`;
+        input.dispatchEvent(new Event('change'));
+    }
+
+    async _getDefinition(text, optionsContext) {
+        if (this._cachedDefinitionText !== text) {
+            const {definitions} = await api.termsFind(text, {}, optionsContext);
+            if (definitions.length === 0) { return null; }
+
+            this._cachedDefinitionValue = definitions[0];
+            this._cachedDefinitionText = text;
+        }
+        return this._cachedDefinitionValue;
+    }
+
+    async _validate(infoNode, field, mode, showSuccessResult, invalidateInput) {
+        const text = this._renderTextInput.value || '';
+        const errors = [];
+        let result = `No definition found for ${text}`;
+        try {
+            const optionsContext = this._settingsController.getOptionsContext();
+            const definition = await this._getDefinition(text, optionsContext);
+            if (definition !== null) {
+                const options = await this._settingsController.getOptions();
+                const context = {
+                    url: window.location.href,
+                    sentence: {text: definition.rawSource, offset: 0},
+                    documentTitle: document.title
+                };
+                let templates = options.anki.fieldTemplates;
+                if (typeof templates !== 'string') { templates = this._defaultFieldTemplates; }
+                const {general: {resultOutputMode, glossaryLayoutMode, compactTags}} = options;
+                const note = await this._ankiNoteBuilder.createNote({
+                    definition,
+                    mode,
+                    context,
+                    templates,
+                    deckName: '',
+                    modelName: '',
+                    fields: [
+                        ['field', field]
+                    ],
+                    resultOutputMode,
+                    glossaryLayoutMode,
+                    compactTags,
+                    errors
+                });
+                result = note.fields.field;
+            }
+        } catch (e) {
+            errors.push(e);
+        }
+
+        const errorToMessageString = (e) => {
+            if (isObject(e)) {
+                let v = e.data;
+                if (isObject(v)) {
+                    v = v.error;
+                    if (isObject(v)) {
+                        e = v;
+                    }
+                }
+
+                v = e.message;
+                if (typeof v === 'string') { return v; }
+            }
+            return `${e}`;
+        };
+
+        const hasError = errors.length > 0;
+        infoNode.hidden = !(showSuccessResult || hasError);
+        infoNode.textContent = hasError ? errors.map(errorToMessageString).join('\n') : (showSuccessResult ? result : '');
+        infoNode.classList.toggle('text-danger', hasError);
+        if (invalidateInput) {
+            this._fieldTemplatesTextarea.dataset.invalid = `${hasError}`;
+        }
+    }
+}
diff --git a/ext/js/settings/audio-controller.js b/ext/js/settings/audio-controller.js
new file mode 100644
index 00000000..e62383a8
--- /dev/null
+++ b/ext/js/settings/audio-controller.js
@@ -0,0 +1,234 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * AudioSystem
+ */
+
+class AudioController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._audioSystem = new AudioSystem();
+        this._audioSourceContainer = null;
+        this._audioSourceAddButton = null;
+        this._audioSourceEntries = [];
+        this._ttsVoiceTestTextInput = null;
+    }
+
+    async prepare() {
+        this._audioSystem.prepare();
+
+        this._ttsVoiceTestTextInput = document.querySelector('#text-to-speech-voice-test-text');
+        this._audioSourceContainer = document.querySelector('#audio-source-list');
+        this._audioSourceAddButton = document.querySelector('#audio-source-add');
+        this._audioSourceContainer.textContent = '';
+
+        this._audioSourceAddButton.addEventListener('click', this._onAddAudioSource.bind(this), false);
+
+        if (typeof speechSynthesis !== 'undefined') {
+            speechSynthesis.addEventListener('voiceschanged', this._updateTextToSpeechVoices.bind(this), false);
+        }
+        this._updateTextToSpeechVoices();
+
+        document.querySelector('#text-to-speech-voice-test').addEventListener('click', this._onTestTextToSpeech.bind(this), false);
+
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+
+        const options = await this._settingsController.getOptions();
+        this._onOptionsChanged({options});
+    }
+
+    // Private
+
+    _onOptionsChanged({options}) {
+        for (let i = this._audioSourceEntries.length - 1; i >= 0; --i) {
+            this._cleanupAudioSourceEntry(i);
+        }
+
+        for (const audioSource of options.audio.sources) {
+            this._createAudioSourceEntry(audioSource);
+        }
+    }
+
+    _onTestTextToSpeech() {
+        try {
+            const text = this._ttsVoiceTestTextInput.value || '';
+            const voiceUri = document.querySelector('[data-setting="audio.textToSpeechVoice"]').value;
+
+            const audio = this._audioSystem.createTextToSpeechAudio(text, voiceUri);
+            audio.volume = 1.0;
+            audio.play();
+        } catch (e) {
+            // NOP
+        }
+    }
+
+    _updateTextToSpeechVoices() {
+        const voices = (
+            typeof speechSynthesis !== 'undefined' ?
+            [...speechSynthesis.getVoices()].map((voice, index) => ({
+                voice,
+                isJapanese: this._languageTagIsJapanese(voice.lang),
+                index
+            })) :
+            []
+        );
+        voices.sort(this._textToSpeechVoiceCompare.bind(this));
+
+        for (const select of document.querySelectorAll('[data-setting="audio.textToSpeechVoice"]')) {
+            const fragment = document.createDocumentFragment();
+
+            let option = document.createElement('option');
+            option.value = '';
+            option.textContent = 'None';
+            fragment.appendChild(option);
+
+            for (const {voice} of voices) {
+                option = document.createElement('option');
+                option.value = voice.voiceURI;
+                option.textContent = `${voice.name} (${voice.lang})`;
+                fragment.appendChild(option);
+            }
+
+            select.textContent = '';
+            select.appendChild(fragment);
+        }
+    }
+
+    _textToSpeechVoiceCompare(a, b) {
+        if (a.isJapanese) {
+            if (!b.isJapanese) { return -1; }
+        } else {
+            if (b.isJapanese) { return 1; }
+        }
+
+        if (a.voice.default) {
+            if (!b.voice.default) { return -1; }
+        } else {
+            if (b.voice.default) { return 1; }
+        }
+
+        return a.index - b.index;
+    }
+
+    _languageTagIsJapanese(languageTag) {
+        return (
+            languageTag.startsWith('ja_') ||
+            languageTag.startsWith('ja-') ||
+            languageTag.startsWith('jpn-')
+        );
+    }
+
+    _getUnusedAudioSource() {
+        const audioSourcesAvailable = [
+            'jpod101',
+            'jpod101-alternate',
+            'jisho',
+            'custom'
+        ];
+        for (const source of audioSourcesAvailable) {
+            if (!this._audioSourceEntries.some((metadata) => metadata.value === source)) {
+                return source;
+            }
+        }
+        return audioSourcesAvailable[0];
+    }
+
+    _createAudioSourceEntry(value) {
+        const eventListeners = new EventListenerCollection();
+        const container = this._settingsController.instantiateTemplate('audio-source');
+        const select = container.querySelector('.audio-source-select');
+        const removeButton = container.querySelector('.audio-source-remove');
+        const menuButton = container.querySelector('.audio-source-menu-button');
+
+        select.value = value;
+
+        const entry = {
+            container,
+            eventListeners,
+            value
+        };
+
+        eventListeners.addEventListener(select, 'change', this._onAudioSourceSelectChange.bind(this, entry), false);
+        if (removeButton !== null) {
+            eventListeners.addEventListener(removeButton, 'click', this._onAudioSourceRemoveClicked.bind(this, entry), false);
+        }
+        if (menuButton !== null) {
+            eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this, entry), false);
+        }
+
+        this._audioSourceContainer.appendChild(container);
+        this._audioSourceEntries.push(entry);
+    }
+
+    async _removeAudioSourceEntry(entry) {
+        const index = this._audioSourceEntries.indexOf(entry);
+        if (index < 0) { return; }
+
+        this._cleanupAudioSourceEntry(index);
+        await this._settingsController.modifyProfileSettings([{
+            action: 'splice',
+            path: 'audio.sources',
+            start: index,
+            deleteCount: 1,
+            items: []
+        }]);
+    }
+
+    _cleanupAudioSourceEntry(index) {
+        const {container, eventListeners} = this._audioSourceEntries[index];
+        if (container.parentNode !== null) {
+            container.parentNode.removeChild(container);
+        }
+        eventListeners.removeAllEventListeners();
+        this._audioSourceEntries.splice(index, 1);
+    }
+
+    async _onAddAudioSource() {
+        const audioSource = this._getUnusedAudioSource();
+        const index = this._audioSourceEntries.length;
+        this._createAudioSourceEntry(audioSource);
+        await this._settingsController.modifyProfileSettings([{
+            action: 'splice',
+            path: 'audio.sources',
+            start: index,
+            deleteCount: 0,
+            items: [audioSource]
+        }]);
+    }
+
+    async _onAudioSourceSelectChange(entry, event) {
+        const index = this._audioSourceEntries.indexOf(entry);
+        if (index < 0) { return; }
+
+        const value = event.currentTarget.value;
+        entry.value = value;
+        await this._settingsController.setProfileSetting(`audio.sources[${index}]`, value);
+    }
+
+    _onAudioSourceRemoveClicked(entry) {
+        this._removeAudioSourceEntry(entry);
+    }
+
+    _onMenuClose(entry, e) {
+        switch (e.detail.action) {
+            case 'remove':
+                this._removeAudioSourceEntry(entry);
+                break;
+        }
+    }
+}
diff --git a/ext/js/settings/backup-controller.js b/ext/js/settings/backup-controller.js
new file mode 100644
index 00000000..8837b927
--- /dev/null
+++ b/ext/js/settings/backup-controller.js
@@ -0,0 +1,419 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * DictionaryController
+ * OptionsUtil
+ * api
+ */
+
+class BackupController {
+    constructor(settingsController, modalController) {
+        this._settingsController = settingsController;
+        this._modalController = modalController;
+        this._settingsExportToken = null;
+        this._settingsExportRevoke = null;
+        this._currentVersion = 0;
+        this._settingsResetModal = null;
+        this._settingsImportErrorModal = null;
+        this._settingsImportWarningModal = null;
+        this._optionsUtil = null;
+        try {
+            this._optionsUtil = new OptionsUtil();
+        } catch (e) {
+            // NOP
+        }
+    }
+
+    async prepare() {
+        if (this._optionsUtil !== null) {
+            await this._optionsUtil.prepare();
+        }
+
+        if (this._modalController !== null) {
+            this._settingsResetModal = this._modalController.getModal('settings-reset');
+            this._settingsImportErrorModal = this._modalController.getModal('settings-import-error');
+            this._settingsImportWarningModal = this._modalController.getModal('settings-import-warning');
+        }
+
+        this._addNodeEventListener('#settings-export-button', 'click', this._onSettingsExportClick.bind(this), false);
+        this._addNodeEventListener('#settings-import-button', 'click', this._onSettingsImportClick.bind(this), false);
+        this._addNodeEventListener('#settings-import-file', 'change', this._onSettingsImportFileChange.bind(this), false);
+        this._addNodeEventListener('#settings-reset-button', 'click', this._onSettingsResetClick.bind(this), false);
+        this._addNodeEventListener('#settings-reset-confirm-button', 'click', this._onSettingsResetConfirmClick.bind(this), false);
+    }
+
+    // Private
+
+    _addNodeEventListener(selector, ...args) {
+        const node = document.querySelector(selector);
+        if (node === null) { return; }
+
+        node.addEventListener(...args);
+    }
+
+    _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) {
+        const values = [
+            date.getUTCFullYear().toString(),
+            dateSeparator,
+            (date.getUTCMonth() + 1).toString().padStart(2, '0'),
+            dateSeparator,
+            date.getUTCDate().toString().padStart(2, '0'),
+            dateTimeSeparator,
+            date.getUTCHours().toString().padStart(2, '0'),
+            timeSeparator,
+            date.getUTCMinutes().toString().padStart(2, '0'),
+            timeSeparator,
+            date.getUTCSeconds().toString().padStart(2, '0')
+        ];
+        return values.slice(0, resolution * 2 - 1).join('');
+    }
+
+    async _getSettingsExportData(date) {
+        const optionsFull = await this._settingsController.getOptionsFull();
+        const environment = await api.getEnvironmentInfo();
+        const fieldTemplatesDefault = await api.getDefaultAnkiFieldTemplates();
+        const permissions = await this._settingsController.permissionsUtil.getAllPermissions();
+
+        // Format options
+        for (const {options} of optionsFull.profiles) {
+            if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) {
+                options.anki.fieldTemplates = null;
+            }
+        }
+
+        const data = {
+            version: this._currentVersion,
+            date: this._getSettingsExportDateString(date, '-', ' ', ':', 6),
+            url: chrome.runtime.getURL('/'),
+            manifest: chrome.runtime.getManifest(),
+            environment,
+            userAgent: navigator.userAgent,
+            permissions,
+            options: optionsFull
+        };
+
+        return data;
+    }
+
+    _saveBlob(blob, fileName) {
+        if (typeof navigator === 'object' && typeof navigator.msSaveBlob === 'function') {
+            if (navigator.msSaveBlob(blob)) {
+                return;
+            }
+        }
+
+        const blobUrl = URL.createObjectURL(blob);
+
+        const a = document.createElement('a');
+        a.href = blobUrl;
+        a.download = fileName;
+        a.rel = 'noopener';
+        a.target = '_blank';
+
+        const revoke = () => {
+            URL.revokeObjectURL(blobUrl);
+            a.href = '';
+            this._settingsExportRevoke = null;
+        };
+        this._settingsExportRevoke = revoke;
+
+        a.dispatchEvent(new MouseEvent('click'));
+        setTimeout(revoke, 60000);
+    }
+
+    async _onSettingsExportClick() {
+        if (this._settingsExportRevoke !== null) {
+            this._settingsExportRevoke();
+            this._settingsExportRevoke = null;
+        }
+
+        const date = new Date(Date.now());
+
+        const token = {};
+        this._settingsExportToken = token;
+        const data = await this._getSettingsExportData(date);
+        if (this._settingsExportToken !== token) {
+            // A new export has been started
+            return;
+        }
+        this._settingsExportToken = null;
+
+        const fileName = `yomichan-settings-${this._getSettingsExportDateString(date, '-', '-', '-', 6)}.json`;
+        const blob = new Blob([JSON.stringify(data, null, 4)], {type: 'application/json'});
+        this._saveBlob(blob, fileName);
+    }
+
+    _readFileArrayBuffer(file) {
+        return new Promise((resolve, reject) => {
+            const reader = new FileReader();
+            reader.onload = () => resolve(reader.result);
+            reader.onerror = () => reject(reader.error);
+            reader.readAsArrayBuffer(file);
+        });
+    }
+
+    // Importing
+
+    async _settingsImportSetOptionsFull(optionsFull) {
+        await this._settingsController.setAllSettings(optionsFull);
+    }
+
+    _showSettingsImportError(error) {
+        yomichan.logError(error);
+        document.querySelector('#settings-import-error-message').textContent = `${error}`;
+        this._settingsImportErrorModal.setVisible(true);
+    }
+
+    async _showSettingsImportWarnings(warnings) {
+        const modal = this._settingsImportWarningModal;
+        const buttons = document.querySelectorAll('.settings-import-warning-import-button');
+        const messageContainer = document.querySelector('#settings-import-warning-message');
+        if (buttons.length === 0 || messageContainer === null) {
+            return {result: false};
+        }
+
+        // Set message
+        const fragment = document.createDocumentFragment();
+        for (const warning of warnings) {
+            const node = document.createElement('li');
+            node.textContent = `${warning}`;
+            fragment.appendChild(node);
+        }
+        messageContainer.textContent = '';
+        messageContainer.appendChild(fragment);
+
+        // Show modal
+        modal.setVisible(true);
+
+        // Wait for modal to close
+        return new Promise((resolve) => {
+            const onButtonClick = (e) => {
+                e.preventDefault();
+                complete({
+                    result: true,
+                    sanitize: e.currentTarget.dataset.importSanitize === 'true'
+                });
+                modal.setVisible(false);
+            };
+            const onModalVisibilityChanged = ({visible}) => {
+                if (visible) { return; }
+                complete({result: false});
+            };
+
+            let completed = false;
+            const complete = (result) => {
+                if (completed) { return; }
+                completed = true;
+
+                modal.off('visibilityChanged', onModalVisibilityChanged);
+                for (const button of buttons) {
+                    button.removeEventListener('click', onButtonClick, false);
+                }
+
+                resolve(result);
+            };
+
+            // Hook events
+            modal.on('visibilityChanged', onModalVisibilityChanged);
+            for (const button of buttons) {
+                button.addEventListener('click', onButtonClick, false);
+            }
+        });
+    }
+
+    _isLocalhostUrl(urlString) {
+        try {
+            const url = new URL(urlString);
+            switch (url.hostname.toLowerCase()) {
+                case 'localhost':
+                case '127.0.0.1':
+                case '[::1]':
+                    switch (url.protocol.toLowerCase()) {
+                        case 'http:':
+                        case 'https:':
+                            return true;
+                    }
+                    break;
+            }
+        } catch (e) {
+            // NOP
+        }
+        return false;
+    }
+
+    _settingsImportSanitizeProfileOptions(options, dryRun) {
+        const warnings = [];
+
+        const anki = options.anki;
+        if (isObject(anki)) {
+            const fieldTemplates = anki.fieldTemplates;
+            if (typeof fieldTemplates === 'string') {
+                warnings.push('anki.fieldTemplates contains a non-default value');
+                if (!dryRun) {
+                    anki.fieldTemplates = null;
+                }
+            }
+            const server = anki.server;
+            if (typeof server === 'string' && server.length > 0 && !this._isLocalhostUrl(server)) {
+                warnings.push('anki.server uses a non-localhost URL');
+                if (!dryRun) {
+                    anki.server = 'http://127.0.0.1:8765';
+                }
+            }
+        }
+
+        const audio = options.audio;
+        if (isObject(audio)) {
+            const customSourceUrl = audio.customSourceUrl;
+            if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !this._isLocalhostUrl(customSourceUrl)) {
+                warnings.push('audio.customSourceUrl uses a non-localhost URL');
+                if (!dryRun) {
+                    audio.customSourceUrl = '';
+                }
+            }
+        }
+
+        return warnings;
+    }
+
+    _settingsImportSanitizeOptions(optionsFull, dryRun) {
+        const warnings = new Set();
+
+        const profiles = optionsFull.profiles;
+        if (Array.isArray(profiles)) {
+            for (const profile of profiles) {
+                if (!isObject(profile)) { continue; }
+                const options = profile.options;
+                if (!isObject(options)) { continue; }
+
+                const warnings2 = this._settingsImportSanitizeProfileOptions(options, dryRun);
+                for (const warning of warnings2) {
+                    warnings.add(warning);
+                }
+            }
+        }
+
+        return warnings;
+    }
+
+    _utf8Decode(arrayBuffer) {
+        try {
+            return new TextDecoder('utf-8').decode(arrayBuffer);
+        } catch (e) {
+            const binaryString = String.fromCharCode.apply(null, new Uint8Array(arrayBuffer));
+            return decodeURIComponent(escape(binaryString));
+        }
+    }
+
+    async _importSettingsFile(file) {
+        const dataString = this._utf8Decode(await this._readFileArrayBuffer(file));
+        const data = JSON.parse(dataString);
+
+        // Type check
+        if (!isObject(data)) {
+            throw new Error(`Invalid data type: ${typeof data}`);
+        }
+
+        // Version check
+        const version = data.version;
+        if (!(
+            typeof version === 'number' &&
+            Number.isFinite(version) &&
+            version === Math.floor(version)
+        )) {
+            throw new Error(`Invalid version: ${version}`);
+        }
+
+        if (!(
+            version >= 0 &&
+            version <= this._currentVersion
+        )) {
+            throw new Error(`Unsupported version: ${version}`);
+        }
+
+        // Verify options exists
+        let optionsFull = data.options;
+        if (!isObject(optionsFull)) {
+            throw new Error(`Invalid options type: ${typeof optionsFull}`);
+        }
+
+        // Upgrade options
+        optionsFull = await this._optionsUtil.update(optionsFull);
+
+        // Check for warnings
+        const sanitizationWarnings = this._settingsImportSanitizeOptions(optionsFull, true);
+
+        // Show sanitization warnings
+        if (sanitizationWarnings.size > 0) {
+            const {result, sanitize} = await this._showSettingsImportWarnings(sanitizationWarnings);
+            if (!result) { return; }
+
+            if (sanitize !== false) {
+                this._settingsImportSanitizeOptions(optionsFull, false);
+            }
+        }
+
+        // Assign options
+        await this._settingsImportSetOptionsFull(optionsFull);
+    }
+
+    _onSettingsImportClick() {
+        document.querySelector('#settings-import-file').click();
+    }
+
+    async _onSettingsImportFileChange(e) {
+        const files = e.target.files;
+        if (files.length === 0) { return; }
+
+        const file = files[0];
+        e.target.value = null;
+        try {
+            await this._importSettingsFile(file);
+        } catch (error) {
+            this._showSettingsImportError(error);
+        }
+    }
+
+    // Resetting
+
+    _onSettingsResetClick() {
+        this._settingsResetModal.setVisible(true);
+    }
+
+    async _onSettingsResetConfirmClick() {
+        this._settingsResetModal.setVisible(false);
+
+        // Get default options
+        const optionsFull = this._optionsUtil.getDefault();
+
+        // Update dictionaries
+        const dictionaries = await this._settingsController.getDictionaryInfo();
+        for (const {options: {dictionaries: optionsDictionaries}} of optionsFull.profiles) {
+            for (const {title} of dictionaries) {
+                optionsDictionaries[title] = DictionaryController.createDefaultDictionarySettings();
+            }
+        }
+
+        // Assign options
+        try {
+            await this._settingsImportSetOptionsFull(optionsFull);
+        } catch (e) {
+            yomichan.logError(e);
+        }
+    }
+}
diff --git a/ext/js/settings/dictionary-controller.js b/ext/js/settings/dictionary-controller.js
new file mode 100644
index 00000000..ea9f7503
--- /dev/null
+++ b/ext/js/settings/dictionary-controller.js
@@ -0,0 +1,558 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * DictionaryDatabase
+ * ObjectPropertyAccessor
+ * api
+ */
+
+class DictionaryEntry {
+    constructor(dictionaryController, node, dictionaryInfo) {
+        this._dictionaryController = dictionaryController;
+        this._node = node;
+        this._dictionaryInfo = dictionaryInfo;
+        this._eventListeners = new EventListenerCollection();
+        this._detailsContainer = null;
+        this._hasDetails = false;
+        this._hasCounts = false;
+    }
+
+    get node() {
+        return this._node;
+    }
+
+    get dictionaryTitle() {
+        return this._dictionaryInfo.title;
+    }
+
+    prepare() {
+        const node = this._node;
+        const {title, revision, prefixWildcardsSupported, version} = this._dictionaryInfo;
+
+        this._detailsContainer = node.querySelector('.dictionary-details');
+
+        const enabledCheckbox = node.querySelector('.dictionary-enabled');
+        const allowSecondarySearchesCheckbox = node.querySelector('.dictionary-allow-secondary-searches');
+        const priorityInput = node.querySelector('.dictionary-priority');
+        const deleteButton = node.querySelector('.dictionary-delete-button');
+        const menuButton = node.querySelector('.dictionary-menu-button');
+        const detailsTable = node.querySelector('.dictionary-details-table');
+        const detailsToggleLink = node.querySelector('.dictionary-details-toggle-link');
+        const outdatedContainer = node.querySelector('.dictionary-outdated-notification');
+        const titleNode = node.querySelector('.dictionary-title');
+        const versionNode = node.querySelector('.dictionary-version');
+        const wildcardSupportedCheckbox = node.querySelector('.dictionary-prefix-wildcard-searches-supported');
+
+        const hasDetails = (detailsTable !== null && this._setupDetails(detailsTable));
+        this._hasDetails = hasDetails;
+
+        titleNode.textContent = title;
+        versionNode.textContent = `rev.${revision}`;
+        if (wildcardSupportedCheckbox !== null) {
+            wildcardSupportedCheckbox.checked = !!prefixWildcardsSupported;
+        }
+        if (outdatedContainer !== null) {
+            outdatedContainer.hidden = (version >= 3);
+        }
+        if (detailsToggleLink !== null) {
+            detailsToggleLink.hidden = !hasDetails;
+        }
+        if (enabledCheckbox !== null) {
+            enabledCheckbox.dataset.setting = ObjectPropertyAccessor.getPathString(['dictionaries', title, 'enabled']);
+            this._eventListeners.addEventListener(enabledCheckbox, 'settingChanged', this._onEnabledChanged.bind(this), false);
+        }
+        if (priorityInput !== null) {
+            priorityInput.dataset.setting = ObjectPropertyAccessor.getPathString(['dictionaries', title, 'priority']);
+        }
+        if (allowSecondarySearchesCheckbox !== null) {
+            allowSecondarySearchesCheckbox.dataset.setting = ObjectPropertyAccessor.getPathString(['dictionaries', title, 'allowSecondarySearches']);
+        }
+        if (deleteButton !== null) {
+            this._eventListeners.addEventListener(deleteButton, 'click', this._onDeleteButtonClicked.bind(this), false);
+        }
+        if (menuButton !== null) {
+            this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
+            this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
+        }
+        if (detailsToggleLink !== null && this._detailsContainer !== null) {
+            this._eventListeners.addEventListener(detailsToggleLink, 'click', this._onDetailsToggleLinkClicked.bind(this), false);
+        }
+    }
+
+    cleanup() {
+        this._eventListeners.removeAllEventListeners();
+        const node = this._node;
+        if (node.parentNode !== null) {
+            node.parentNode.removeChild(node);
+        }
+    }
+
+    setCounts(counts) {
+        const node = this._node.querySelector('.dictionary-counts');
+        node.textContent = JSON.stringify({info: this._dictionaryInfo, counts}, null, 4);
+        node.hidden = false;
+        this._hasCounts = true;
+    }
+
+    // Private
+
+    _onDeleteButtonClicked(e) {
+        e.preventDefault();
+        this._delete();
+    }
+
+    _onMenuOpen(e) {
+        const bodyNode = e.detail.menu.bodyNode;
+        const showDetails = bodyNode.querySelector('.popup-menu-item[data-menu-action="showDetails"]');
+        const hideDetails = bodyNode.querySelector('.popup-menu-item[data-menu-action="hideDetails"]');
+        const hasDetails = (this._detailsContainer !== null);
+        const detailsVisible = (hasDetails && !this._detailsContainer.hidden);
+        if (showDetails !== null) {
+            showDetails.hidden = detailsVisible;
+            showDetails.disabled = !hasDetails;
+        }
+        if (hideDetails !== null) {
+            hideDetails.hidden = !detailsVisible;
+            hideDetails.disabled = !hasDetails;
+        }
+    }
+
+    _onMenuClose(e) {
+        switch (e.detail.action) {
+            case 'delete':
+                this._delete();
+                break;
+            case 'showDetails':
+                if (this._detailsContainer !== null) { this._detailsContainer.hidden = false; }
+                break;
+            case 'hideDetails':
+                if (this._detailsContainer !== null) { this._detailsContainer.hidden = true; }
+                break;
+        }
+    }
+
+    _onDetailsToggleLinkClicked(e) {
+        e.preventDefault();
+        this._detailsContainer.hidden = !this._detailsContainer.hidden;
+    }
+
+    _onEnabledChanged(e) {
+        const {detail: {value}} = e;
+        this._node.dataset.enabled = `${value}`;
+        this._dictionaryController.updateDictionariesEnabled();
+    }
+
+    _setupDetails(detailsTable) {
+        const targets = [
+            ['Author', 'author'],
+            ['URL', 'url'],
+            ['Description', 'description'],
+            ['Attribution', 'attribution']
+        ];
+
+        const dictionaryInfo = this._dictionaryInfo;
+        const fragment = document.createDocumentFragment();
+        let any = false;
+        for (const [label, key] of targets) {
+            const info = dictionaryInfo[key];
+            if (typeof info !== 'string') { continue; }
+
+            const details = this._dictionaryController.instantiateTemplate('dictionary-details-entry');
+            details.dataset.type = key;
+            details.querySelector('.dictionary-details-entry-label').textContent = `${label}:`;
+            details.querySelector('.dictionary-details-entry-info').textContent = info;
+            fragment.appendChild(details);
+
+            any = true;
+        }
+
+        detailsTable.appendChild(fragment);
+        return any;
+    }
+
+    _delete() {
+        this._dictionaryController.deleteDictionary(this.dictionaryTitle);
+    }
+}
+
+class DictionaryController {
+    constructor(settingsController, modalController, storageController, statusFooter) {
+        this._settingsController = settingsController;
+        this._modalController = modalController;
+        this._storageController = storageController;
+        this._statusFooter = statusFooter;
+        this._dictionaries = null;
+        this._dictionaryEntries = [];
+        this._databaseStateToken = null;
+        this._checkingIntegrity = false;
+        this._checkIntegrityButton = null;
+        this._dictionaryEntryContainer = null;
+        this._integrityExtraInfoContainer = null;
+        this._dictionaryInstallCountNode = null;
+        this._dictionaryEnabledCountNode = null;
+        this._noDictionariesInstalledWarnings = null;
+        this._noDictionariesEnabledWarnings = null;
+        this._deleteDictionaryModal = null;
+        this._integrityExtraInfoNode = null;
+        this._isDeleting = false;
+    }
+
+    async prepare() {
+        this._checkIntegrityButton = document.querySelector('#dictionary-check-integrity');
+        this._dictionaryEntryContainer = document.querySelector('#dictionary-list');
+        this._integrityExtraInfoContainer = document.querySelector('#dictionary-list-extra');
+        this._dictionaryInstallCountNode = document.querySelector('#dictionary-install-count');
+        this._dictionaryEnabledCountNode = document.querySelector('#dictionary-enabled-count');
+        this._noDictionariesInstalledWarnings = document.querySelectorAll('.no-dictionaries-installed-warning');
+        this._noDictionariesEnabledWarnings = document.querySelectorAll('.no-dictionaries-enabled-warning');
+        this._deleteDictionaryModal = this._modalController.getModal('dictionary-confirm-delete');
+
+        yomichan.on('databaseUpdated', this._onDatabaseUpdated.bind(this));
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+
+        document.querySelector('#dictionary-confirm-delete-button').addEventListener('click', this._onDictionaryConfirmDelete.bind(this), false);
+        if (this._checkIntegrityButton !== null) {
+            this._checkIntegrityButton.addEventListener('click', this._onCheckIntegrityButtonClick.bind(this), false);
+        }
+
+        await this._onDatabaseUpdated();
+    }
+
+    deleteDictionary(dictionaryTitle) {
+        if (this._isDeleting) { return; }
+        const modal = this._deleteDictionaryModal;
+        modal.node.dataset.dictionaryTitle = dictionaryTitle;
+        modal.node.querySelector('#dictionary-confirm-delete-name').textContent = dictionaryTitle;
+        modal.setVisible(true);
+    }
+
+    instantiateTemplate(name) {
+        return this._settingsController.instantiateTemplate(name);
+    }
+
+    async updateDictionariesEnabled() {
+        const options = await this._settingsController.getOptions();
+        this._updateDictionariesEnabledWarnings(options);
+    }
+
+    // Private
+
+    _onOptionsChanged({options}) {
+        this._updateDictionariesEnabledWarnings(options);
+    }
+
+    async _onDatabaseUpdated() {
+        const token = {};
+        this._databaseStateToken = token;
+        this._dictionaries = null;
+        const dictionaries = await this._settingsController.getDictionaryInfo();
+        const options = await this._settingsController.getOptions();
+        if (this._databaseStateToken !== token) { return; }
+        this._dictionaries = dictionaries;
+
+        this._updateMainDictionarySelectOptions(dictionaries);
+
+        for (const entry of this._dictionaryEntries) {
+            entry.cleanup();
+        }
+        this._dictionaryEntries = [];
+
+        if (this._dictionaryInstallCountNode !== null) {
+            this._dictionaryInstallCountNode.textContent = `${dictionaries.length}`;
+        }
+
+        const hasDictionary = (dictionaries.length > 0);
+        for (const node of this._noDictionariesInstalledWarnings) {
+            node.hidden = hasDictionary;
+        }
+
+        this._updateDictionariesEnabledWarnings(options);
+
+        await this._ensureDictionarySettings(dictionaries);
+        for (const dictionary of dictionaries) {
+            this._createDictionaryEntry(dictionary);
+        }
+    }
+
+    _updateDictionariesEnabledWarnings(options) {
+        let enabledCount = 0;
+        if (this._dictionaries !== null) {
+            for (const {title} of this._dictionaries) {
+                if (Object.prototype.hasOwnProperty.call(options.dictionaries, title)) {
+                    const {enabled} = options.dictionaries[title];
+                    if (enabled) {
+                        ++enabledCount;
+                    }
+                }
+            }
+        }
+
+        const hasEnabledDictionary = (enabledCount > 0);
+        for (const node of this._noDictionariesEnabledWarnings) {
+            node.hidden = hasEnabledDictionary;
+        }
+
+        if (this._dictionaryEnabledCountNode !== null) {
+            this._dictionaryEnabledCountNode.textContent = `${enabledCount}`;
+        }
+    }
+
+    _onDictionaryConfirmDelete(e) {
+        e.preventDefault();
+
+        const modal = this._deleteDictionaryModal;
+        modal.setVisible(false);
+
+        const title = modal.node.dataset.dictionaryTitle;
+        if (typeof title !== 'string') { return; }
+        delete modal.node.dataset.dictionaryTitle;
+
+        this._deleteDictionary(title);
+    }
+
+    _onCheckIntegrityButtonClick(e) {
+        e.preventDefault();
+        this._checkIntegrity();
+    }
+
+    _updateMainDictionarySelectOptions(dictionaries) {
+        for (const select of document.querySelectorAll('[data-setting="general.mainDictionary"]')) {
+            const fragment = document.createDocumentFragment();
+
+            let option = document.createElement('option');
+            option.className = 'text-muted';
+            option.value = '';
+            option.textContent = 'Not selected';
+            fragment.appendChild(option);
+
+            for (const {title, sequenced} of dictionaries) {
+                if (!sequenced) { continue; }
+                option = document.createElement('option');
+                option.value = title;
+                option.textContent = title;
+                fragment.appendChild(option);
+            }
+
+            select.textContent = ''; // Empty
+            select.appendChild(fragment);
+        }
+    }
+
+    async _checkIntegrity() {
+        if (this._dictionaries === null || this._checkingIntegrity || this._isDeleting) { return; }
+
+        try {
+            this._checkingIntegrity = true;
+            this._setButtonsEnabled(false);
+
+            const token = this._databaseStateToken;
+            const dictionaryTitles = this._dictionaries.map(({title}) => title);
+            const {counts, total} = await api.getDictionaryCounts(dictionaryTitles, true);
+            if (this._databaseStateToken !== token) { return; }
+
+            for (let i = 0, ii = Math.min(counts.length, this._dictionaryEntries.length); i < ii; ++i) {
+                const entry = this._dictionaryEntries[i];
+                entry.setCounts(counts[i]);
+            }
+
+            this._setCounts(counts, total);
+        } finally {
+            this._setButtonsEnabled(true);
+            this._checkingIntegrity = false;
+        }
+    }
+
+    _setCounts(dictionaryCounts, totalCounts) {
+        const remainders = Object.assign({}, totalCounts);
+        const keys = Object.keys(remainders);
+
+        for (const counts of dictionaryCounts) {
+            for (const key of keys) {
+                remainders[key] -= counts[key];
+            }
+        }
+
+        let totalRemainder = 0;
+        for (const key of keys) {
+            totalRemainder += remainders[key];
+        }
+
+        this._cleanupExtra();
+        if (totalRemainder > 0) {
+            this.extra = this._createExtra(totalCounts, remainders, totalRemainder);
+        }
+    }
+
+    _createExtra(totalCounts, remainders, totalRemainder) {
+        const node = this.instantiateTemplate('dictionary-extra');
+        this._integrityExtraInfoNode = node;
+
+        node.querySelector('.dictionary-total-count').textContent = `${totalRemainder} item${totalRemainder !== 1 ? 's' : ''}`;
+
+        const n = node.querySelector('.dictionary-counts');
+        n.textContent = JSON.stringify({counts: totalCounts, remainders}, null, 4);
+        n.hidden = false;
+
+        this._integrityExtraInfoContainer.appendChild(node);
+    }
+
+    _cleanupExtra() {
+        const node = this._integrityExtraInfoNode;
+        if (node === null) { return; }
+        this._integrityExtraInfoNode = null;
+
+        const parent = node.parentNode;
+        if (parent === null) { return; }
+
+        parent.removeChild(node);
+    }
+
+    _createDictionaryEntry(dictionary) {
+        const node = this.instantiateTemplate('dictionary');
+        this._dictionaryEntryContainer.appendChild(node);
+
+        const entry = new DictionaryEntry(this, node, dictionary);
+        this._dictionaryEntries.push(entry);
+        entry.prepare();
+    }
+
+    async _deleteDictionary(dictionaryTitle) {
+        if (this._isDeleting || this._checkingIntegrity) { return; }
+
+        const index = this._dictionaryEntries.findIndex((entry) => entry.dictionaryTitle === dictionaryTitle);
+        if (index < 0) { return; }
+
+        const storageController = this._storageController;
+        const statusFooter = this._statusFooter;
+        const {node} = this._dictionaryEntries[index];
+        const progressSelector = '.dictionary-delete-progress';
+        const progressContainers = [
+            ...node.querySelectorAll('.progress-container'),
+            ...document.querySelectorAll(`#dictionaries-modal ${progressSelector}`)
+        ];
+        const progressBars = [
+            ...node.querySelectorAll('.progress-bar'),
+            ...document.querySelectorAll(`${progressSelector} .progress-bar`)
+        ];
+        const infoLabels = document.querySelectorAll(`${progressSelector} .progress-info`);
+        const statusLabels = document.querySelectorAll(`${progressSelector} .progress-status`);
+        const prevention = this._settingsController.preventPageExit();
+        try {
+            this._isDeleting = true;
+            this._setButtonsEnabled(false);
+
+            const onProgress = ({processed, count, storeCount, storesProcesed}) => {
+                const percent = (
+                    (count > 0 && storesProcesed > 0) ?
+                    (processed / count) * (storesProcesed / storeCount) * 100.0 :
+                    0.0
+                );
+                const cssString = `${percent}%`;
+                const statusString = `${percent.toFixed(0)}%`;
+                for (const progressBar of progressBars) { progressBar.style.width = cssString; }
+                for (const label of statusLabels) { label.textContent = statusString; }
+            };
+
+            onProgress({processed: 0, count: 1, storeCount: 1, storesProcesed: 0});
+
+            for (const progress of progressContainers) { progress.hidden = false; }
+            for (const label of infoLabels) { label.textContent = 'Deleting dictionary...'; }
+            if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, true); }
+
+            await this._deleteDictionaryInternal(dictionaryTitle, onProgress);
+            await this._deleteDictionarySettings(dictionaryTitle);
+        } catch (e) {
+            yomichan.logError(e);
+        } finally {
+            prevention.end();
+            for (const progress of progressContainers) { progress.hidden = true; }
+            if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, false); }
+            this._setButtonsEnabled(true);
+            this._isDeleting = false;
+            if (storageController !== null) { storageController.updateStats(); }
+        }
+    }
+
+    _setButtonsEnabled(value) {
+        value = !value;
+        for (const node of document.querySelectorAll('.dictionary-database-mutating-input')) {
+            node.disabled = value;
+        }
+    }
+
+    async _deleteDictionaryInternal(dictionaryTitle, onProgress) {
+        const dictionaryDatabase = await this._getPreparedDictionaryDatabase();
+        try {
+            await dictionaryDatabase.deleteDictionary(dictionaryTitle, {rate: 1000}, onProgress);
+            api.triggerDatabaseUpdated('dictionary', 'delete');
+        } finally {
+            dictionaryDatabase.close();
+        }
+    }
+
+    async _getPreparedDictionaryDatabase() {
+        const dictionaryDatabase = new DictionaryDatabase();
+        await dictionaryDatabase.prepare();
+        return dictionaryDatabase;
+    }
+
+    async _deleteDictionarySettings(dictionaryTitle) {
+        const optionsFull = await this._settingsController.getOptionsFull();
+        const {profiles} = optionsFull;
+        const targets = [];
+        for (let i = 0, ii = profiles.length; i < ii; ++i) {
+            const {options: {dictionaries}} = profiles[i];
+            if (Object.prototype.hasOwnProperty.call(dictionaries, dictionaryTitle)) {
+                const path = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'dictionaries', dictionaryTitle]);
+                targets.push({action: 'delete', path});
+            }
+        }
+        await this._settingsController.modifyGlobalSettings(targets);
+    }
+
+    async _ensureDictionarySettings(dictionaries2) {
+        const optionsFull = await this._settingsController.getOptionsFull();
+        const {profiles} = optionsFull;
+        const targets = [];
+        for (const {title} of dictionaries2) {
+            for (let i = 0, ii = profiles.length; i < ii; ++i) {
+                const {options: {dictionaries: dictionaryOptions}} = profiles[i];
+                if (Object.prototype.hasOwnProperty.call(dictionaryOptions, title)) { continue; }
+
+                const path = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'dictionaries', title]);
+                targets.push({
+                    action: 'set',
+                    path,
+                    value: DictionaryController.createDefaultDictionarySettings()
+                });
+            }
+        }
+
+        if (targets.length > 0) {
+            await this._settingsController.modifyGlobalSettings(targets);
+        }
+    }
+
+    static createDefaultDictionarySettings() {
+        return {
+            enabled: false,
+            allowSecondarySearches: false,
+            priority: 0
+        };
+    }
+}
diff --git a/ext/js/settings/dictionary-import-controller.js b/ext/js/settings/dictionary-import-controller.js
new file mode 100644
index 00000000..c4ad9e59
--- /dev/null
+++ b/ext/js/settings/dictionary-import-controller.js
@@ -0,0 +1,346 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * DictionaryDatabase
+ * DictionaryImporter
+ * ObjectPropertyAccessor
+ * api
+ */
+
+class DictionaryImportController {
+    constructor(settingsController, modalController, storageController, statusFooter) {
+        this._settingsController = settingsController;
+        this._modalController = modalController;
+        this._storageController = storageController;
+        this._statusFooter = statusFooter;
+        this._modifying = false;
+        this._purgeButton = null;
+        this._purgeConfirmButton = null;
+        this._importFileButton = null;
+        this._importFileInput = null;
+        this._purgeConfirmModal = null;
+        this._errorContainer = null;
+        this._spinner = null;
+        this._purgeNotification = null;
+        this._errorToStringOverrides = [
+            [
+                'A mutation operation was attempted on a database that did not allow mutations.',
+                'Access to IndexedDB appears to be restricted. Firefox seems to require that the history preference is set to "Remember history" before IndexedDB use of any kind is allowed.'
+            ],
+            [
+                'The operation failed for reasons unrelated to the database itself and not covered by any other error code.',
+                'Unable to access IndexedDB due to a possibly corrupt user profile. Try using the "Refresh Firefox" feature to reset your user profile.'
+            ]
+        ];
+    }
+
+    async prepare() {
+        this._purgeButton = document.querySelector('#dictionary-delete-all-button');
+        this._purgeConfirmButton = document.querySelector('#dictionary-confirm-delete-all-button');
+        this._importFileButton = document.querySelector('#dictionary-import-file-button');
+        this._importFileInput = document.querySelector('#dictionary-import-file-input');
+        this._purgeConfirmModal = this._modalController.getModal('dictionary-confirm-delete-all');
+        this._errorContainer = document.querySelector('#dictionary-error');
+        this._spinner = document.querySelector('#dictionary-spinner');
+        this._purgeNotification = document.querySelector('#dictionary-delete-all-status');
+
+        this._purgeButton.addEventListener('click', this._onPurgeButtonClick.bind(this), false);
+        this._purgeConfirmButton.addEventListener('click', this._onPurgeConfirmButtonClick.bind(this), false);
+        this._importFileButton.addEventListener('click', this._onImportButtonClick.bind(this), false);
+        this._importFileInput.addEventListener('change', this._onImportFileChange.bind(this), false);
+    }
+
+    // Private
+
+    _onImportButtonClick() {
+        this._importFileInput.click();
+    }
+
+    _onPurgeButtonClick(e) {
+        e.preventDefault();
+        this._purgeConfirmModal.setVisible(true);
+    }
+
+    _onPurgeConfirmButtonClick(e) {
+        e.preventDefault();
+        this._purgeConfirmModal.setVisible(false);
+        this._purgeDatabase();
+    }
+
+    _onImportFileChange(e) {
+        const node = e.currentTarget;
+        const files = [...node.files];
+        node.value = null;
+        this._importDictionaries(files);
+    }
+
+    async _purgeDatabase() {
+        if (this._modifying) { return; }
+
+        const purgeNotification = this._purgeNotification;
+        const storageController = this._storageController;
+        const prevention = this._preventPageExit();
+
+        try {
+            this._setModifying(true);
+            this._hideErrors();
+            this._setSpinnerVisible(true);
+            if (purgeNotification !== null) { purgeNotification.hidden = false; }
+
+            await api.purgeDatabase();
+            const errors = await this._clearDictionarySettings();
+
+            if (errors.length > 0) {
+                this._showErrors(errors);
+            }
+        } catch (error) {
+            this._showErrors([error]);
+        } finally {
+            prevention.end();
+            if (purgeNotification !== null) { purgeNotification.hidden = true; }
+            this._setSpinnerVisible(false);
+            this._setModifying(false);
+            if (storageController !== null) { storageController.updateStats(); }
+        }
+    }
+
+    async _importDictionaries(files) {
+        if (this._modifying) { return; }
+
+        const statusFooter = this._statusFooter;
+        const storageController = this._storageController;
+        const importInfo = document.querySelector('#dictionary-import-info');
+        const progressSelector = '.dictionary-import-progress';
+        const progressContainers = [
+            ...document.querySelectorAll('#dictionary-import-progress-container'),
+            ...document.querySelectorAll(`#dictionaries-modal ${progressSelector}`)
+        ];
+        const progressBars = [
+            ...document.querySelectorAll('#dictionary-import-progress-container .progress-bar'),
+            ...document.querySelectorAll(`${progressSelector} .progress-bar`)
+        ];
+        const infoLabels = document.querySelectorAll(`${progressSelector} .progress-info`);
+        const statusLabels = document.querySelectorAll(`${progressSelector} .progress-status`);
+
+        const prevention = this._preventPageExit();
+
+        try {
+            this._setModifying(true);
+            this._hideErrors();
+            this._setSpinnerVisible(true);
+
+            for (const progress of progressContainers) { progress.hidden = false; }
+
+            const optionsFull = await this._settingsController.getOptionsFull();
+            const importDetails = {
+                prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported
+            };
+
+            const onProgress = (total, current) => {
+                const percent = (current / total * 100.0);
+                const cssString = `${percent}%`;
+                const statusString = `${percent.toFixed(0)}%`;
+                for (const progressBar of progressBars) { progressBar.style.width = cssString; }
+                for (const label of statusLabels) { label.textContent = statusString; }
+                if (storageController !== null) { storageController.updateStats(); }
+            };
+
+            const fileCount = files.length;
+            for (let i = 0; i < fileCount; ++i) {
+                if (importInfo !== null && fileCount > 1) {
+                    importInfo.hidden = false;
+                    importInfo.textContent = `(${i + 1} of ${fileCount})`;
+                }
+
+                onProgress(1, 0);
+
+                const labelText = `Importing dictionary${fileCount > 1 ? ` (${i + 1} of ${fileCount})` : ''}...`;
+                for (const label of infoLabels) { label.textContent = labelText; }
+                if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, true); }
+
+                await this._importDictionary(files[i], importDetails, onProgress);
+            }
+        } catch (err) {
+            this._showErrors([err]);
+        } finally {
+            prevention.end();
+            for (const progress of progressContainers) { progress.hidden = true; }
+            if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, false); }
+            if (importInfo !== null) {
+                importInfo.textContent = '';
+                importInfo.hidden = true;
+            }
+            this._setSpinnerVisible(false);
+            this._setModifying(false);
+            if (storageController !== null) { storageController.updateStats(); }
+        }
+    }
+
+    async _importDictionary(file, importDetails, onProgress) {
+        const dictionaryDatabase = await this._getPreparedDictionaryDatabase();
+        try {
+            const dictionaryImporter = new DictionaryImporter();
+            const archiveContent = await this._readFile(file);
+            const {result, errors} = await dictionaryImporter.importDictionary(dictionaryDatabase, archiveContent, importDetails, onProgress);
+            api.triggerDatabaseUpdated('dictionary', 'import');
+            const errors2 = await this._addDictionarySettings(result.sequenced, result.title);
+
+            if (errors.length > 0) {
+                const allErrors = [...errors, ...errors2];
+                allErrors.push(new Error(`Dictionary may not have been imported properly: ${allErrors.length} error${allErrors.length === 1 ? '' : 's'} reported.`));
+                this._showErrors(allErrors);
+            }
+        } finally {
+            dictionaryDatabase.close();
+        }
+    }
+
+    async _addDictionarySettings(sequenced, title) {
+        const optionsFull = await this._settingsController.getOptionsFull();
+        const targets = [];
+        const profileCount = optionsFull.profiles.length;
+        for (let i = 0; i < profileCount; ++i) {
+            const {options} = optionsFull.profiles[i];
+            const value = this._createDictionaryOptions();
+            const path1 = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'dictionaries', title]);
+            targets.push({action: 'set', path: path1, value});
+
+            if (sequenced && options.general.mainDictionary === '') {
+                const path2 = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'general', 'mainDictionary']);
+                targets.push({action: 'set', path: path2, value: title});
+            }
+        }
+        return await this._modifyGlobalSettings(targets);
+    }
+
+    async _clearDictionarySettings() {
+        const optionsFull = await this._settingsController.getOptionsFull();
+        const targets = [];
+        const profileCount = optionsFull.profiles.length;
+        for (let i = 0; i < profileCount; ++i) {
+            const path1 = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'dictionaries']);
+            targets.push({action: 'set', path: path1, value: {}});
+            const path2 = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'general', 'mainDictionary']);
+            targets.push({action: 'set', path: path2, value: ''});
+        }
+        return await this._modifyGlobalSettings(targets);
+    }
+
+    _setSpinnerVisible(visible) {
+        if (this._spinner !== null) {
+            this._spinner.hidden = !visible;
+        }
+    }
+
+    _preventPageExit() {
+        return this._settingsController.preventPageExit();
+    }
+
+    _showErrors(errors) {
+        const uniqueErrors = new Map();
+        for (const error of errors) {
+            yomichan.logError(error);
+            const errorString = this._errorToString(error);
+            let count = uniqueErrors.get(errorString);
+            if (typeof count === 'undefined') {
+                count = 0;
+            }
+            uniqueErrors.set(errorString, count + 1);
+        }
+
+        const fragment = document.createDocumentFragment();
+        for (const [e, count] of uniqueErrors.entries()) {
+            const div = document.createElement('p');
+            if (count > 1) {
+                div.textContent = `${e} `;
+                const em = document.createElement('em');
+                em.textContent = `(${count})`;
+                div.appendChild(em);
+            } else {
+                div.textContent = `${e}`;
+            }
+            fragment.appendChild(div);
+        }
+
+        this._errorContainer.appendChild(fragment);
+        this._errorContainer.hidden = false;
+    }
+
+    _hideErrors() {
+        this._errorContainer.textContent = '';
+        this._errorContainer.hidden = true;
+    }
+
+    _readFile(file) {
+        return new Promise((resolve, reject) => {
+            const reader = new FileReader();
+            reader.onload = () => resolve(reader.result);
+            reader.onerror = () => reject(reader.error);
+            reader.readAsBinaryString(file);
+        });
+    }
+
+    _createDictionaryOptions() {
+        return {
+            priority: 0,
+            enabled: true,
+            allowSecondarySearches: false
+        };
+    }
+
+    _errorToString(error) {
+        error = (typeof error.toString === 'function' ? error.toString() : `${error}`);
+
+        for (const [match, newErrorString] of this._errorToStringOverrides) {
+            if (error.includes(match)) {
+                return newErrorString;
+            }
+        }
+
+        return error;
+    }
+
+    _setModifying(value) {
+        this._modifying = value;
+        this._setButtonsEnabled(!value);
+    }
+
+    _setButtonsEnabled(value) {
+        value = !value;
+        for (const node of document.querySelectorAll('.dictionary-database-mutating-input')) {
+            node.disabled = value;
+        }
+    }
+
+    async _getPreparedDictionaryDatabase() {
+        const dictionaryDatabase = new DictionaryDatabase();
+        await dictionaryDatabase.prepare();
+        return dictionaryDatabase;
+    }
+
+    async _modifyGlobalSettings(targets) {
+        const results = await this._settingsController.modifyGlobalSettings(targets);
+        const errors = [];
+        for (const {error} of results) {
+            if (typeof error !== 'undefined') {
+                errors.push(deserializeError(error));
+            }
+        }
+        return errors;
+    }
+}
diff --git a/ext/js/settings/extension-keyboard-shortcuts-controller.js b/ext/js/settings/extension-keyboard-shortcuts-controller.js
new file mode 100644
index 00000000..9c930703
--- /dev/null
+++ b/ext/js/settings/extension-keyboard-shortcuts-controller.js
@@ -0,0 +1,292 @@
+/*
+ * Copyright (C) 2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * HotkeyUtil
+ * KeyboardMouseInputField
+ * api
+ */
+
+class ExtensionKeyboardShortcutController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._resetButton = null;
+        this._clearButton = null;
+        this._listContainer = null;
+        this._hotkeyUtil = new HotkeyUtil();
+        this._os = null;
+        this._entries = [];
+    }
+
+    get hotkeyUtil() {
+        return this._hotkeyUtil;
+    }
+
+    async prepare() {
+        this._resetButton = document.querySelector('#extension-hotkey-list-reset-all');
+        this._clearButton = document.querySelector('#extension-hotkey-list-clear-all');
+        this._listContainer = document.querySelector('#extension-hotkey-list');
+
+        const canResetCommands = this.canResetCommands();
+        const canModifyCommands = this.canModifyCommands();
+        this._resetButton.hidden = !canResetCommands;
+        this._clearButton.hidden = !canModifyCommands;
+
+        if (canResetCommands) {
+            this._resetButton.addEventListener('click', this._onResetClick.bind(this));
+        }
+        if (canModifyCommands) {
+            this._clearButton.addEventListener('click', this._onClearClick.bind(this));
+        }
+
+        const {platform: {os}} = await api.getEnvironmentInfo();
+        this._os = os;
+        this._hotkeyUtil.os = os;
+
+        const commands = await this._getCommands();
+        this._setupCommands(commands);
+    }
+
+    async resetCommand(name) {
+        await this._resetCommand(name);
+
+        let key = null;
+        let modifiers = [];
+
+        const commands = await this._getCommands();
+        for (const {name: name2, shortcut} of commands) {
+            if (name === name2) {
+                ({key, modifiers} = this._hotkeyUtil.convertCommandToInput(shortcut));
+                break;
+            }
+        }
+
+        return {key, modifiers};
+    }
+
+    async updateCommand(name, key, modifiers) {
+        // Firefox-only; uses Promise API
+        const shortcut = this._hotkeyUtil.convertInputToCommand(key, modifiers);
+        return await chrome.commands.update({name, shortcut});
+    }
+
+    canResetCommands() {
+        return isObject(chrome.commands) && typeof chrome.commands.reset === 'function';
+    }
+
+    canModifyCommands() {
+        return isObject(chrome.commands) && typeof chrome.commands.update === 'function';
+    }
+
+    // Add
+
+    _onResetClick(e) {
+        e.preventDefault();
+        this._resetAllCommands();
+    }
+
+    _onClearClick(e) {
+        e.preventDefault();
+        this._clearAllCommands();
+    }
+
+    _getCommands() {
+        return new Promise((resolve, reject) => {
+            if (!(isObject(chrome.commands) && typeof chrome.commands.getAll === 'function')) {
+                resolve([]);
+                return;
+            }
+
+            chrome.commands.getAll((result) => {
+                const e = chrome.runtime.lastError;
+                if (e) {
+                    reject(new Error(e.message));
+                } else {
+                    resolve(result);
+                }
+            });
+        });
+    }
+
+    _setupCommands(commands) {
+        for (const entry of this._entries) {
+            entry.cleanup();
+        }
+        this._entries = [];
+
+        const fragment = document.createDocumentFragment();
+
+        for (const {name, description, shortcut} of commands) {
+            if (name.startsWith('_')) { continue; }
+
+            const {key, modifiers} = this._hotkeyUtil.convertCommandToInput(shortcut);
+
+            const node = this._settingsController.instantiateTemplate('extension-hotkey-list-item');
+            fragment.appendChild(node);
+
+            const entry = new ExtensionKeyboardShortcutHotkeyEntry(this, node, name, description, key, modifiers, this._os);
+            entry.prepare();
+            this._entries.push(entry);
+        }
+
+        this._listContainer.textContent = '';
+        this._listContainer.appendChild(fragment);
+    }
+
+    async _resetAllCommands() {
+        if (!this.canModifyCommands()) { return; }
+
+        let commands = await this._getCommands();
+        const promises = [];
+
+        for (const {name} of commands) {
+            if (name.startsWith('_')) { continue; }
+            promises.push(this._resetCommand(name));
+        }
+
+        await Promise.all(promises);
+
+        commands = await this._getCommands();
+        this._setupCommands(commands);
+    }
+
+    async _clearAllCommands() {
+        if (!this.canModifyCommands()) { return; }
+
+        let commands = await this._getCommands();
+        const promises = [];
+
+        for (const {name} of commands) {
+            if (name.startsWith('_')) { continue; }
+            promises.push(this.updateCommand(name, null, []));
+        }
+
+        await Promise.all(promises);
+
+        commands = await this._getCommands();
+        this._setupCommands(commands);
+    }
+
+    async _resetCommand(name) {
+        // Firefox-only; uses Promise API
+        return await chrome.commands.reset(name);
+    }
+}
+
+class ExtensionKeyboardShortcutHotkeyEntry {
+    constructor(parent, node, name, description, key, modifiers, os) {
+        this._parent = parent;
+        this._node = node;
+        this._name = name;
+        this._description = description;
+        this._key = key;
+        this._modifiers = modifiers;
+        this._os = os;
+        this._input = null;
+        this._inputField = null;
+        this._eventListeners = new EventListenerCollection();
+    }
+
+    prepare() {
+        this._node.querySelector('.settings-item-label').textContent = this._description || this._name;
+
+        const button = this._node.querySelector('.extension-hotkey-list-item-button');
+        const input = this._node.querySelector('input');
+
+        this._input = input;
+
+        if (this._parent.canModifyCommands()) {
+            this._inputField = new KeyboardMouseInputField(input, null, this._os);
+            this._inputField.prepare(this._key, this._modifiers, false, true);
+            this._eventListeners.on(this._inputField, 'change', this._onInputFieldChange.bind(this));
+            this._eventListeners.addEventListener(button, 'menuClose', this._onMenuClose.bind(this));
+            this._eventListeners.addEventListener(input, 'blur', this._onInputFieldBlur.bind(this));
+        } else {
+            input.readOnly = true;
+            input.value = this._parent.hotkeyUtil.getInputDisplayValue(this._key, this._modifiers);
+            button.hidden = true;
+        }
+    }
+
+    cleanup() {
+        this._eventListeners.removeAllEventListeners();
+        if (this._node.parentNode !== null) {
+            this._node.parentNode.removeChild(this._node);
+        }
+        if (this._inputField !== null) {
+            this._inputField.cleanup();
+            this._inputField = null;
+        }
+    }
+
+    // Private
+
+    _onInputFieldChange(e) {
+        const {key, modifiers} = e;
+        this._tryUpdateInput(key, modifiers, false);
+    }
+
+    _onInputFieldBlur() {
+        this._updateInput();
+    }
+
+    _onMenuClose(e) {
+        switch (e.detail.action) {
+            case 'clearInput':
+                this._tryUpdateInput(null, [], true);
+                break;
+            case 'resetInput':
+                this._resetInput();
+                break;
+        }
+    }
+
+    _updateInput() {
+        this._inputField.setInput(this._key, this._modifiers);
+        delete this._input.dataset.invalid;
+    }
+
+    async _tryUpdateInput(key, modifiers, updateInput) {
+        let okay = (key === null ? (modifiers.length === 0) : (modifiers.length !== 0));
+        if (okay) {
+            try {
+                await this._parent.updateCommand(this._name, key, modifiers);
+            } catch (e) {
+                okay = false;
+            }
+        }
+
+        if (okay) {
+            this._key = key;
+            this._modifiers = modifiers;
+            delete this._input.dataset.invalid;
+        } else {
+            this._input.dataset.invalid = 'true';
+        }
+
+        if (updateInput) {
+            this._updateInput();
+        }
+    }
+
+    async _resetInput() {
+        const {key, modifiers} = await this._parent.resetCommand(this._name);
+        this._key = key;
+        this._modifiers = modifiers;
+        this._updateInput();
+    }
+}
diff --git a/ext/js/settings/generic-setting-controller.js b/ext/js/settings/generic-setting-controller.js
new file mode 100644
index 00000000..7d6fc2e6
--- /dev/null
+++ b/ext/js/settings/generic-setting-controller.js
@@ -0,0 +1,232 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* globals
+ * DOMDataBinder
+ */
+
+class GenericSettingController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._defaultScope = 'profile';
+        this._dataBinder = new DOMDataBinder({
+            selector: '[data-setting]',
+            createElementMetadata: this._createElementMetadata.bind(this),
+            compareElementMetadata: this._compareElementMetadata.bind(this),
+            getValues: this._getValues.bind(this),
+            setValues: this._setValues.bind(this)
+        });
+        this._transforms = new Map([
+            ['setAttribute', this._setAttribute.bind(this)],
+            ['setVisibility', this._setVisibility.bind(this)],
+            ['splitTags', this._splitTags.bind(this)],
+            ['joinTags', this._joinTags.bind(this)],
+            ['toNumber', this._toNumber.bind(this)],
+            ['toBoolean', this._toBoolean.bind(this)],
+            ['toString', this._toString.bind(this)],
+            ['conditionalConvert', this._conditionalConvert.bind(this)]
+        ]);
+    }
+
+    async prepare() {
+        this._dataBinder.observe(document.body);
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+    }
+
+    async refresh() {
+        await this._dataBinder.refresh();
+    }
+
+    // Private
+
+    _onOptionsChanged() {
+        this._dataBinder.refresh();
+    }
+
+    _createElementMetadata(element) {
+        const {dataset: {setting: path, scope, transform: transformRaw}} = element;
+        let transforms;
+        if (typeof transformRaw === 'string') {
+            transforms = JSON.parse(transformRaw);
+            if (!Array.isArray(transforms)) { transforms = [transforms]; }
+        } else {
+            transforms = [];
+        }
+        return {
+            path,
+            scope,
+            transforms,
+            transformRaw
+        };
+    }
+
+    _compareElementMetadata(metadata1, metadata2) {
+        return (
+            metadata1.path === metadata2.path &&
+            metadata1.scope === metadata2.scope &&
+            metadata1.transformRaw === metadata2.transformRaw
+        );
+    }
+
+    async _getValues(targets) {
+        const defaultScope = this._defaultScope;
+        const settingsTargets = [];
+        for (const {metadata: {path, scope}} of targets) {
+            const target = {
+                path,
+                scope: scope || defaultScope
+            };
+            settingsTargets.push(target);
+        }
+        return this._transformResults(await this._settingsController.getSettings(settingsTargets), targets);
+    }
+
+    async _setValues(targets) {
+        const defaultScope = this._defaultScope;
+        const settingsTargets = [];
+        for (const {metadata: {path, scope, transforms}, value, element} of targets) {
+            const transformedValue = this._applyTransforms(value, transforms, 'pre', element);
+            const target = {
+                path,
+                scope: scope || defaultScope,
+                action: 'set',
+                value: transformedValue
+            };
+            settingsTargets.push(target);
+        }
+        return this._transformResults(await this._settingsController.modifySettings(settingsTargets), targets);
+    }
+
+    _transformResults(values, targets) {
+        return values.map((value, i) => {
+            const error = value.error;
+            if (error) { return deserializeError(error); }
+            const {metadata: {transforms}, element} = targets[i];
+            const result = this._applyTransforms(value.result, transforms, 'post', element);
+            return {result};
+        });
+    }
+
+    _applyTransforms(value, transforms, step, element) {
+        for (const transform of transforms) {
+            const transformStep = transform.step;
+            if (typeof transformStep !== 'undefined' && transformStep !== step) { continue; }
+
+            const transformFunction = this._transforms.get(transform.type);
+            if (typeof transformFunction === 'undefined') { continue; }
+
+            value = transformFunction(value, transform, element);
+        }
+        return value;
+    }
+
+    _getAncestor(node, ancestorDistance) {
+        if (ancestorDistance < 0) {
+            return document.documentElement;
+        }
+        for (let i = 0; i < ancestorDistance && node !== null; ++i) {
+            node = node.parentNode;
+        }
+        return node;
+    }
+
+    _getRelativeElement(node, ancestorDistance, selector) {
+        const selectorRoot = (
+            typeof ancestorDistance === 'number' ?
+            this._getAncestor(node, ancestorDistance) :
+            document
+        );
+        if (selectorRoot === null) { return null; }
+
+        return (
+            typeof selector === 'string' ?
+            selectorRoot.querySelector(selector) :
+            (selectorRoot === document ? document.documentElement : selectorRoot)
+        );
+    }
+
+    _evaluateSimpleOperation(operation, lhs, rhs) {
+        switch (operation) {
+            case '!': return !lhs;
+            case '!!': return !!lhs;
+            case '===': return lhs === rhs;
+            case '!==': return lhs !== rhs;
+            case '>=': return lhs >= rhs;
+            case '<=': return lhs <= rhs;
+            case '>': return lhs > rhs;
+            case '<': return lhs < rhs;
+            default: return false;
+        }
+    }
+
+    // Transforms
+
+    _setAttribute(value, data, element) {
+        const {ancestorDistance, selector, attribute} = data;
+        const relativeElement = this._getRelativeElement(element, ancestorDistance, selector);
+        if (relativeElement !== null) {
+            relativeElement.setAttribute(attribute, `${value}`);
+        }
+        return value;
+    }
+
+    _setVisibility(value, data, element) {
+        const {ancestorDistance, selector, condition} = data;
+        const relativeElement = this._getRelativeElement(element, ancestorDistance, selector);
+        if (relativeElement !== null) {
+            relativeElement.hidden = !this._evaluateSimpleOperation(condition.op, value, condition.value);
+        }
+        return value;
+    }
+
+    _splitTags(value) {
+        return `${value}`.split(/[,; ]+/).filter((v) => !!v);
+    }
+
+    _joinTags(value) {
+        return value.join(' ');
+    }
+
+    _toNumber(value, data) {
+        let {constraints} = data;
+        if (!isObject(constraints)) { constraints = {}; }
+        return DOMDataBinder.convertToNumber(value, constraints);
+    }
+
+    _toBoolean(value) {
+        return (value === 'true');
+    }
+
+    _toString(value) {
+        return `${value}`;
+    }
+
+    _conditionalConvert(value, data) {
+        const {cases} = data;
+        if (Array.isArray(cases)) {
+            for (const {op, value: value2, default: isDefault, result} of cases) {
+                if (isDefault === true) {
+                    value = result;
+                } else if (this._evaluateSimpleOperation(op, value, value2)) {
+                    value = result;
+                    break;
+                }
+            }
+        }
+        return value;
+    }
+}
diff --git a/ext/js/settings/keyboard-mouse-input-field.js b/ext/js/settings/keyboard-mouse-input-field.js
new file mode 100644
index 00000000..09477519
--- /dev/null
+++ b/ext/js/settings/keyboard-mouse-input-field.js
@@ -0,0 +1,243 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * DocumentUtil
+ * HotkeyUtil
+ */
+
+class KeyboardMouseInputField extends EventDispatcher {
+    constructor(inputNode, mouseButton, os, isPointerTypeSupported=null) {
+        super();
+        this._inputNode = inputNode;
+        this._mouseButton = mouseButton;
+        this._isPointerTypeSupported = isPointerTypeSupported;
+        this._hotkeyUtil = new HotkeyUtil(os);
+        this._eventListeners = new EventListenerCollection();
+        this._key = null;
+        this._modifiers = [];
+        this._penPointerIds = new Set();
+        this._mouseModifiersSupported = false;
+        this._keySupported = false;
+    }
+
+    get modifiers() {
+        return this._modifiers;
+    }
+
+    prepare(key, modifiers, mouseModifiersSupported=false, keySupported=false) {
+        this.cleanup();
+
+        this._mouseModifiersSupported = mouseModifiersSupported;
+        this._keySupported = keySupported;
+        this.setInput(key, modifiers);
+        const events = [
+            [this._inputNode, 'keydown', this._onModifierKeyDown.bind(this), false],
+            [this._inputNode, 'keyup', this._onModifierKeyUp.bind(this), false]
+        ];
+        if (mouseModifiersSupported && this._mouseButton !== null) {
+            events.push(
+                [this._mouseButton, 'mousedown', this._onMouseButtonMouseDown.bind(this), false],
+                [this._mouseButton, 'pointerdown', this._onMouseButtonPointerDown.bind(this), false],
+                [this._mouseButton, 'pointerover', this._onMouseButtonPointerOver.bind(this), false],
+                [this._mouseButton, 'pointerout', this._onMouseButtonPointerOut.bind(this), false],
+                [this._mouseButton, 'pointercancel', this._onMouseButtonPointerCancel.bind(this), false],
+                [this._mouseButton, 'mouseup', this._onMouseButtonMouseUp.bind(this), false],
+                [this._mouseButton, 'contextmenu', this._onMouseButtonContextMenu.bind(this), false]
+            );
+        }
+        for (const args of events) {
+            this._eventListeners.addEventListener(...args);
+        }
+    }
+
+    setInput(key, modifiers) {
+        this._key = key;
+        this._modifiers = this._sortModifiers(modifiers);
+        this._updateDisplayString();
+    }
+
+    cleanup() {
+        this._eventListeners.removeAllEventListeners();
+        this._modifiers = [];
+        this._key = null;
+        this._mouseModifiersSupported = false;
+        this._keySupported = false;
+        this._penPointerIds.clear();
+    }
+
+    clearInputs() {
+        this._updateModifiers([], null);
+    }
+
+    // Private
+
+    _sortModifiers(modifiers) {
+        return this._hotkeyUtil.sortModifiers(modifiers);
+    }
+
+    _updateDisplayString() {
+        const displayValue = this._hotkeyUtil.getInputDisplayValue(this._key, this._modifiers);
+        this._inputNode.value = displayValue;
+    }
+
+    _getModifierKeys(e) {
+        const modifiers = new Set(DocumentUtil.getActiveModifiers(e));
+        // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey
+        // https://askubuntu.com/questions/567731/why-is-shift-alt-being-mapped-to-meta
+        // It works with mouse events on some platforms, so try to determine if metaKey is pressed.
+        // This is a hack and only works when both Shift and Alt are not pressed.
+        if (
+            !modifiers.has('meta') &&
+            e.key === 'Meta' &&
+            !(
+                modifiers.size === 2 &&
+                modifiers.has('shift') &&
+                modifiers.has('alt')
+            )
+        ) {
+            modifiers.add('meta');
+        }
+        return modifiers;
+    }
+
+    _isModifierKey(keyName) {
+        switch (keyName) {
+            case 'AltLeft':
+            case 'AltRight':
+            case 'ControlLeft':
+            case 'ControlRight':
+            case 'MetaLeft':
+            case 'MetaRight':
+            case 'ShiftLeft':
+            case 'ShiftRight':
+            case 'OSLeft':
+            case 'OSRight':
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    _onModifierKeyDown(e) {
+        e.preventDefault();
+
+        let key = e.code;
+        if (key === 'Unidentified' || key === '') { key = void 0; }
+        if (this._keySupported) {
+            this._updateModifiers([...this._getModifierKeys(e)], this._isModifierKey(key) ? void 0 : key);
+        } else {
+            switch (key) {
+                case 'Escape':
+                case 'Backspace':
+                    this.clearInputs();
+                    break;
+                default:
+                    this._addModifiers(this._getModifierKeys(e));
+                    break;
+            }
+        }
+    }
+
+    _onModifierKeyUp(e) {
+        e.preventDefault();
+    }
+
+    _onMouseButtonMouseDown(e) {
+        e.preventDefault();
+        this._addModifiers(DocumentUtil.getActiveButtons(e));
+    }
+
+    _onMouseButtonPointerDown(e) {
+        if (!e.isPrimary) { return; }
+
+        let {pointerType, pointerId} = e;
+        // Workaround for Firefox bug not detecting certain 'touch' events as 'pen' events.
+        if (this._penPointerIds.has(pointerId)) { pointerType = 'pen'; }
+
+        if (
+            typeof this._isPointerTypeSupported !== 'function' ||
+            !this._isPointerTypeSupported(pointerType)
+        ) {
+            return;
+        }
+        e.preventDefault();
+        this._addModifiers(DocumentUtil.getActiveButtons(e));
+    }
+
+    _onMouseButtonPointerOver(e) {
+        const {pointerType, pointerId} = e;
+        if (pointerType === 'pen') {
+            this._penPointerIds.add(pointerId);
+        }
+    }
+
+    _onMouseButtonPointerOut(e) {
+        const {pointerId} = e;
+        this._penPointerIds.delete(pointerId);
+    }
+
+    _onMouseButtonPointerCancel(e) {
+        this._onMouseButtonPointerOut(e);
+    }
+
+    _onMouseButtonMouseUp(e) {
+        e.preventDefault();
+    }
+
+    _onMouseButtonContextMenu(e) {
+        e.preventDefault();
+    }
+
+    _addModifiers(newModifiers, newKey) {
+        const modifiers = new Set(this._modifiers);
+        for (const modifier of newModifiers) {
+            modifiers.add(modifier);
+        }
+        this._updateModifiers([...modifiers], newKey);
+    }
+
+    _updateModifiers(modifiers, newKey) {
+        modifiers = this._sortModifiers(modifiers);
+
+        let changed = false;
+        if (typeof newKey !== 'undefined' && this._key !== newKey) {
+            this._key = newKey;
+            changed = true;
+        }
+        if (!this._areArraysEqual(this._modifiers, modifiers)) {
+            this._modifiers = modifiers;
+            changed = true;
+        }
+
+        this._updateDisplayString();
+        if (changed) {
+            this.trigger('change', {modifiers: this._modifiers, key: this._key});
+        }
+    }
+
+    _areArraysEqual(array1, array2) {
+        const length = array1.length;
+        if (length !== array2.length) { return false; }
+
+        for (let i = 0; i < length; ++i) {
+            if (array1[i] !== array2[i]) { return false; }
+        }
+
+        return true;
+    }
+}
diff --git a/ext/js/settings/keyboard-shortcuts-controller.js b/ext/js/settings/keyboard-shortcuts-controller.js
new file mode 100644
index 00000000..0dcfa2ee
--- /dev/null
+++ b/ext/js/settings/keyboard-shortcuts-controller.js
@@ -0,0 +1,368 @@
+/*
+ * Copyright (C) 2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * KeyboardMouseInputField
+ * api
+ */
+
+class KeyboardShortcutController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._entries = [];
+        this._os = null;
+        this._addButton = null;
+        this._resetButton = null;
+        this._listContainer = null;
+        this._emptyIndicator = null;
+        this._stringComparer = new Intl.Collator('en-US'); // Invariant locale
+        this._scrollContainer = null;
+    }
+
+    get settingsController() {
+        return this._settingsController;
+    }
+
+    async prepare() {
+        const {platform: {os}} = await api.getEnvironmentInfo();
+        this._os = os;
+
+        this._addButton = document.querySelector('#hotkey-list-add');
+        this._resetButton = document.querySelector('#hotkey-list-reset');
+        this._listContainer = document.querySelector('#hotkey-list');
+        this._emptyIndicator = document.querySelector('#hotkey-list-empty');
+        this._scrollContainer = document.querySelector('#keyboard-shortcuts-modal .modal-body');
+
+        this._addButton.addEventListener('click', this._onAddClick.bind(this));
+        this._resetButton.addEventListener('click', this._onResetClick.bind(this));
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+
+        await this._updateOptions();
+    }
+
+    async addEntry(terminationCharacterEntry) {
+        const options = await this._settingsController.getOptions();
+        const {inputs: {hotkeys}} = options;
+
+        await this._settingsController.modifyProfileSettings([{
+            action: 'splice',
+            path: 'inputs.hotkeys',
+            start: hotkeys.length,
+            deleteCount: 0,
+            items: [terminationCharacterEntry]
+        }]);
+
+        await this._updateOptions();
+        this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
+    }
+
+    async deleteEntry(index) {
+        const options = await this._settingsController.getOptions();
+        const {inputs: {hotkeys}} = options;
+
+        if (index < 0 || index >= hotkeys.length) { return false; }
+
+        await this._settingsController.modifyProfileSettings([{
+            action: 'splice',
+            path: 'inputs.hotkeys',
+            start: index,
+            deleteCount: 1,
+            items: []
+        }]);
+
+        await this._updateOptions();
+        return true;
+    }
+
+    async modifyProfileSettings(targets) {
+        return await this._settingsController.modifyProfileSettings(targets);
+    }
+
+    async getDefaultHotkeys() {
+        const defaultOptions = await this._settingsController.getDefaultOptions();
+        return defaultOptions.profiles[0].options.inputs.hotkeys;
+    }
+
+    // Private
+
+    _onOptionsChanged({options}) {
+        for (const entry of this._entries) {
+            entry.cleanup();
+        }
+
+        this._entries = [];
+        const {inputs: {hotkeys}} = options;
+        const fragment = document.createDocumentFragment();
+
+        for (let i = 0, ii = hotkeys.length; i < ii; ++i) {
+            const hotkeyEntry = hotkeys[i];
+            const node = this._settingsController.instantiateTemplate('hotkey-list-item');
+            fragment.appendChild(node);
+            const entry = new KeyboardShortcutHotkeyEntry(this, hotkeyEntry, i, node, this._os, this._stringComparer);
+            this._entries.push(entry);
+            entry.prepare();
+        }
+
+        this._listContainer.appendChild(fragment);
+        this._listContainer.hidden = (hotkeys.length === 0);
+        this._emptyIndicator.hidden = (hotkeys.length !== 0);
+    }
+
+    _onAddClick(e) {
+        e.preventDefault();
+        this._addNewEntry();
+    }
+
+    _onResetClick(e) {
+        e.preventDefault();
+        this._reset();
+    }
+
+    async _addNewEntry() {
+        const newEntry = {
+            action: '',
+            key: null,
+            modifiers: [],
+            scopes: ['popup', 'search'],
+            enabled: true
+        };
+        return await this.addEntry(newEntry);
+    }
+
+    async _updateOptions() {
+        const options = await this._settingsController.getOptions();
+        this._onOptionsChanged({options});
+    }
+
+    async _reset() {
+        const value = await this.getDefaultHotkeys();
+        await this._settingsController.setProfileSetting('inputs.hotkeys', value);
+        await this._updateOptions();
+    }
+}
+
+class KeyboardShortcutHotkeyEntry {
+    constructor(parent, data, index, node, os, stringComparer) {
+        this._parent = parent;
+        this._data = data;
+        this._index = index;
+        this._node = node;
+        this._os = os;
+        this._eventListeners = new EventListenerCollection();
+        this._inputField = null;
+        this._actionSelect = null;
+        this._scopeCheckboxes = null;
+        this._scopeCheckboxContainers = null;
+        this._basePath = `inputs.hotkeys[${this._index}]`;
+        this._stringComparer = stringComparer;
+    }
+
+    prepare() {
+        const node = this._node;
+
+        const menuButton = node.querySelector('.hotkey-list-item-button');
+        const input = node.querySelector('.hotkey-list-item-input');
+        const action = node.querySelector('.hotkey-list-item-action');
+        const scopeCheckboxes = node.querySelectorAll('.hotkey-scope-checkbox');
+        const scopeCheckboxContainers = node.querySelectorAll('.hotkey-scope-checkbox-container');
+        const enabledToggle = node.querySelector('.hotkey-list-item-enabled');
+
+        this._actionSelect = action;
+        this._scopeCheckboxes = scopeCheckboxes;
+        this._scopeCheckboxContainers = scopeCheckboxContainers;
+
+        this._inputField = new KeyboardMouseInputField(input, null, this._os);
+        this._inputField.prepare(this._data.key, this._data.modifiers, false, true);
+
+        action.value = this._data.action;
+
+        enabledToggle.checked = this._data.enabled;
+        enabledToggle.dataset.setting = `${this._basePath}.enabled`;
+
+        this._updateCheckboxVisibility();
+        this._updateCheckboxStates();
+
+        for (const scopeCheckbox of scopeCheckboxes) {
+            this._eventListeners.addEventListener(scopeCheckbox, 'change', this._onScopeCheckboxChange.bind(this), false);
+        }
+        this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
+        this._eventListeners.addEventListener(this._actionSelect, 'change', this._onActionSelectChange.bind(this), false);
+        this._eventListeners.on(this._inputField, 'change', this._onInputFieldChange.bind(this));
+    }
+
+    cleanup() {
+        this._eventListeners.removeAllEventListeners();
+        this._inputField.cleanup();
+        if (this._node.parentNode !== null) {
+            this._node.parentNode.removeChild(this._node);
+        }
+    }
+
+    // Private
+
+    _onMenuClose(e) {
+        switch (e.detail.action) {
+            case 'delete':
+                this._delete();
+                break;
+            case 'clearInputs':
+                this._inputField.clearInputs();
+                break;
+            case 'resetInput':
+                this._resetInput();
+                break;
+        }
+    }
+
+    _onInputFieldChange({key, modifiers}) {
+        this._setKeyAndModifiers(key, modifiers);
+    }
+
+    _onScopeCheckboxChange(e) {
+        const node = e.currentTarget;
+        const {scope} = node.dataset;
+        if (typeof scope !== 'string') { return; }
+        this._setScopeEnabled(scope, node.checked);
+    }
+
+    _onActionSelectChange(e) {
+        const value = e.currentTarget.value;
+        this._setAction(value);
+    }
+
+    async _delete() {
+        this._parent.deleteEntry(this._index);
+    }
+
+    async _setKeyAndModifiers(key, modifiers) {
+        this._data.key = key;
+        this._data.modifiers = modifiers;
+        await this._modifyProfileSettings([
+            {
+                action: 'set',
+                path: `${this._basePath}.key`,
+                value: key
+            },
+            {
+                action: 'set',
+                path: `${this._basePath}.modifiers`,
+                value: modifiers
+            }
+        ]);
+    }
+
+    async _setScopeEnabled(scope, enabled) {
+        const scopes = this._data.scopes;
+        const index = scopes.indexOf(scope);
+        if ((index >= 0) === enabled) { return; }
+
+        if (enabled) {
+            scopes.push(scope);
+            const stringComparer = this._stringComparer;
+            scopes.sort((scope1, scope2) => stringComparer.compare(scope1, scope2));
+        } else {
+            scopes.splice(index, 1);
+        }
+
+        await this._modifyProfileSettings([{
+            action: 'set',
+            path: `${this._basePath}.scopes`,
+            value: scopes
+        }]);
+    }
+
+    async _modifyProfileSettings(targets) {
+        return await this._parent.settingsController.modifyProfileSettings(targets);
+    }
+
+    async _resetInput() {
+        const defaultHotkeys = await this._parent.getDefaultHotkeys();
+        const defaultValue = this._getDefaultKeyAndModifiers(defaultHotkeys, this._data.action);
+        if (defaultValue === null) { return; }
+
+        const {key, modifiers} = defaultValue;
+        await this._setKeyAndModifiers(key, modifiers);
+        this._inputField.setInput(key, modifiers);
+    }
+
+    _getDefaultKeyAndModifiers(defaultHotkeys, action) {
+        for (const {action: action2, key, modifiers} of defaultHotkeys) {
+            if (action2 !== action) { continue; }
+            return {modifiers, key};
+        }
+        return null;
+    }
+
+    async _setAction(value) {
+        const targets = [{
+            action: 'set',
+            path: `${this._basePath}.action`,
+            value
+        }];
+
+        this._data.action = value;
+
+        const scopes = this._data.scopes;
+        const validScopes = this._getValidScopesForAction(value);
+        if (validScopes !== null) {
+            let changed = false;
+            for (let i = 0, ii = scopes.length; i < ii; ++i) {
+                if (!validScopes.has(scopes[i])) {
+                    scopes.splice(i, 1);
+                    --i;
+                    --ii;
+                    changed = true;
+                }
+            }
+            if (changed) {
+                if (scopes.length === 0) {
+                    scopes.push(...validScopes);
+                }
+                targets.push({
+                    action: 'set',
+                    path: `${this._basePath}.scopes`,
+                    value: scopes
+                });
+                this._updateCheckboxStates();
+            }
+        }
+
+        await this._modifyProfileSettings(targets);
+
+        this._updateCheckboxVisibility();
+    }
+
+    _updateCheckboxStates() {
+        const scopes = this._data.scopes;
+        for (const scopeCheckbox of this._scopeCheckboxes) {
+            scopeCheckbox.checked = scopes.includes(scopeCheckbox.dataset.scope);
+        }
+    }
+
+    _updateCheckboxVisibility() {
+        const validScopes = this._getValidScopesForAction(this._data.action);
+        for (const node of this._scopeCheckboxContainers) {
+            node.hidden = !(validScopes === null || validScopes.has(node.dataset.scope));
+        }
+    }
+
+    _getValidScopesForAction(action) {
+        const optionNode = this._actionSelect.querySelector(`option[value="${action}"]`);
+        const scopesString = (optionNode !== null ? optionNode.dataset.scopes : void 0);
+        return (typeof scopesString === 'string' ? new Set(scopesString.split(' ')) : null);
+    }
+}
diff --git a/ext/js/settings/main.js b/ext/js/settings/main.js
new file mode 100644
index 00000000..9786d196
--- /dev/null
+++ b/ext/js/settings/main.js
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2016-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * AnkiController
+ * AnkiTemplatesController
+ * AudioController
+ * BackupController
+ * DictionaryController
+ * DictionaryImportController
+ * GenericSettingController
+ * ModalController
+ * PermissionsToggleController
+ * PopupPreviewController
+ * ProfileController
+ * ScanInputsController
+ * ScanInputsSimpleController
+ * SettingsController
+ * StorageController
+ * api
+ */
+
+function showExtensionInformation() {
+    const node = document.getElementById('extension-info');
+    if (node === null) { return; }
+
+    const manifest = chrome.runtime.getManifest();
+    node.textContent = `${manifest.name} v${manifest.version}`;
+}
+
+async function setupEnvironmentInfo() {
+    const {browser, platform} = await api.getEnvironmentInfo();
+    document.documentElement.dataset.browser = browser;
+    document.documentElement.dataset.operatingSystem = platform.os;
+}
+
+
+(async () => {
+    try {
+        api.forwardLogsToBackend();
+        await yomichan.backendReady();
+
+        setupEnvironmentInfo();
+        showExtensionInformation();
+
+        const optionsFull = await api.optionsGetFull();
+
+        const modalController = new ModalController();
+        modalController.prepare();
+
+        const settingsController = new SettingsController(optionsFull.profileCurrent);
+        settingsController.prepare();
+
+        const storageController = new StorageController();
+        storageController.prepare();
+
+        const genericSettingController = new GenericSettingController(settingsController);
+        genericSettingController.prepare();
+
+        const permissionsToggleController = new PermissionsToggleController(settingsController);
+        permissionsToggleController.prepare();
+
+        const popupPreviewController = new PopupPreviewController(settingsController);
+        popupPreviewController.prepare();
+
+        const audioController = new AudioController(settingsController);
+        audioController.prepare();
+
+        const profileController = new ProfileController(settingsController, modalController);
+        profileController.prepare();
+
+        const dictionaryController = new DictionaryController(settingsController, modalController, storageController, null);
+        dictionaryController.prepare();
+
+        const dictionaryImportController = new DictionaryImportController(settingsController, modalController, storageController, null);
+        dictionaryImportController.prepare();
+
+        const ankiController = new AnkiController(settingsController);
+        ankiController.prepare();
+
+        const ankiTemplatesController = new AnkiTemplatesController(settingsController, modalController, ankiController);
+        ankiTemplatesController.prepare();
+
+        const settingsBackup = new BackupController(settingsController, modalController);
+        settingsBackup.prepare();
+
+        const scanInputsController = new ScanInputsController(settingsController);
+        scanInputsController.prepare();
+
+        const simpleScanningInputController = new ScanInputsSimpleController(settingsController);
+        simpleScanningInputController.prepare();
+
+        yomichan.ready();
+    } catch (e) {
+        yomichan.logError(e);
+    }
+})();
diff --git a/ext/js/settings/mecab-controller.js b/ext/js/settings/mecab-controller.js
new file mode 100644
index 00000000..ff2a4a66
--- /dev/null
+++ b/ext/js/settings/mecab-controller.js
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * api
+ */
+
+class MecabController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._testButton = null;
+        this._resultsContainer = null;
+        this._testActive = false;
+    }
+
+    prepare() {
+        this._testButton = document.querySelector('#test-mecab-button');
+        this._resultsContainer = document.querySelector('#test-mecab-results');
+
+        this._testButton.addEventListener('click', this._onTestButtonClick.bind(this), false);
+    }
+
+    // Private
+
+    _onTestButtonClick(e) {
+        e.preventDefault();
+        this._testMecab();
+    }
+
+    async _testMecab() {
+        if (this._testActive) { return; }
+
+        try {
+            this._testActive = true;
+            this._testButton.disabled = true;
+            this._resultsContainer.textContent = '';
+            this._resultsContainer.hidden = true;
+            await api.testMecab();
+            this._setStatus('Connection was successful', false);
+        } catch (e) {
+            this._setStatus(e.message, true);
+        } finally {
+            this._testActive = false;
+            this._testButton.disabled = false;
+        }
+    }
+
+    _setStatus(message, isError) {
+        this._resultsContainer.textContent = message;
+        this._resultsContainer.hidden = false;
+        this._resultsContainer.classList.toggle('danger-text', isError);
+    }
+}
diff --git a/ext/js/settings/modal-controller.js b/ext/js/settings/modal-controller.js
new file mode 100644
index 00000000..fe4f911b
--- /dev/null
+++ b/ext/js/settings/modal-controller.js
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * Modal
+ */
+
+class ModalController {
+    constructor() {
+        this._modals = [];
+        this._modalMap = new Map();
+    }
+
+    prepare() {
+        const idSuffix = '-modal';
+        for (const node of document.querySelectorAll('.modal')) {
+            let {id} = node;
+            if (typeof id !== 'string') { continue; }
+
+            if (id.endsWith(idSuffix)) {
+                id = id.substring(0, id.length - idSuffix.length);
+            }
+
+            const modal = new Modal(node);
+            modal.prepare();
+            this._modalMap.set(id, modal);
+            this._modalMap.set(node, modal);
+            this._modals.push(modal);
+        }
+    }
+
+    getModal(nameOrNode) {
+        const modal = this._modalMap.get(nameOrNode);
+        return (typeof modal !== 'undefined' ? modal : null);
+    }
+
+    getTopVisibleModal() {
+        for (let i = this._modals.length - 1; i >= 0; --i) {
+            const modal = this._modals[i];
+            if (modal.isVisible()) {
+                return modal;
+            }
+        }
+        return null;
+    }
+}
diff --git a/ext/js/settings/modal-jquery.js b/ext/js/settings/modal-jquery.js
new file mode 100644
index 00000000..8c69ae6d
--- /dev/null
+++ b/ext/js/settings/modal-jquery.js
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class Modal extends EventDispatcher {
+    constructor(node) {
+        super();
+        this._node = node;
+        this._eventListeners = new EventListenerCollection();
+    }
+
+    get node() {
+        return this._node;
+    }
+
+    prepare() {
+        // NOP
+    }
+
+    isVisible() {
+        return !!(this._getWrappedNode().data('bs.modal') || {}).isShown;
+    }
+
+    setVisible(value) {
+        this._getWrappedNode().modal(value ? 'show' : 'hide');
+    }
+
+    on(eventName, callback) {
+        if (eventName === 'visibilityChanged') {
+            if (this._eventListeners.size === 0) {
+                const wrappedNode = this._getWrappedNode();
+                this._eventListeners.on(wrappedNode, 'hidden.bs.modal', this._onModalHide.bind(this));
+                this._eventListeners.on(wrappedNode, 'shown.bs.modal', this._onModalShow.bind(this));
+            }
+        }
+        return super.on(eventName, callback);
+    }
+
+    off(eventName, callback) {
+        const result = super.off(eventName, callback);
+        if (eventName === 'visibilityChanged' && !this.hasListeners(eventName)) {
+            this._eventListeners.removeAllEventListeners();
+        }
+        return result;
+    }
+
+    // Private
+
+    _onModalHide() {
+        this.trigger('visibilityChanged', {visible: false});
+    }
+
+    _onModalShow() {
+        this.trigger('visibilityChanged', {visible: true});
+    }
+
+    _getWrappedNode() {
+        return $(this._node);
+    }
+}
diff --git a/ext/js/settings/modal.js b/ext/js/settings/modal.js
new file mode 100644
index 00000000..2ef49540
--- /dev/null
+++ b/ext/js/settings/modal.js
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * PanelElement
+ */
+
+class Modal extends PanelElement {
+    constructor(node) {
+        super({
+            node,
+            closingAnimationDuration: 375 // Milliseconds; includes buffer
+        });
+        this._contentNode = null;
+        this._canCloseOnClick = false;
+    }
+
+    prepare() {
+        const node = this.node;
+        this._contentNode = node.querySelector('.modal-content');
+        let dimmerNode = node.querySelector('.modal-content-dimmer');
+        if (dimmerNode === null) { dimmerNode = node; }
+        dimmerNode.addEventListener('mousedown', this._onModalContainerMouseDown.bind(this), false);
+        dimmerNode.addEventListener('mouseup', this._onModalContainerMouseUp.bind(this), false);
+        dimmerNode.addEventListener('click', this._onModalContainerClick.bind(this), false);
+
+        for (const actionNode of node.querySelectorAll('[data-modal-action]')) {
+            actionNode.addEventListener('click', this._onActionNodeClick.bind(this), false);
+        }
+    }
+
+    // Private
+
+    _onModalContainerMouseDown(e) {
+        this._canCloseOnClick = (e.currentTarget === e.target);
+    }
+
+    _onModalContainerMouseUp(e) {
+        if (!this._canCloseOnClick) { return; }
+        this._canCloseOnClick = (e.currentTarget === e.target);
+    }
+
+    _onModalContainerClick(e) {
+        if (!this._canCloseOnClick) { return; }
+        this._canCloseOnClick = false;
+        if (e.currentTarget !== e.target) { return; }
+        this.setVisible(false);
+    }
+
+    _onActionNodeClick(e) {
+        const {modalAction} = e.currentTarget.dataset;
+        switch (modalAction) {
+            case 'expand':
+                this._setExpanded(true);
+                break;
+            case 'collapse':
+                this._setExpanded(false);
+                break;
+        }
+    }
+
+    _setExpanded(expanded) {
+        if (this._contentNode === null) { return; }
+        this._contentNode.classList.toggle('modal-content-full', expanded);
+    }
+}
diff --git a/ext/js/settings/nested-popups-controller.js b/ext/js/settings/nested-popups-controller.js
new file mode 100644
index 00000000..1ebc7389
--- /dev/null
+++ b/ext/js/settings/nested-popups-controller.js
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * DOMDataBinder
+ */
+
+class NestedPopupsController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._popupNestingMaxDepth = 0;
+    }
+
+    async prepare() {
+        this._nestedPopupsEnabled = document.querySelector('#nested-popups-enabled');
+        this._nestedPopupsCount = document.querySelector('#nested-popups-count');
+        this._nestedPopupsEnabledMoreOptions = document.querySelector('#nested-popups-enabled-more-options');
+
+        const options = await this._settingsController.getOptions();
+
+        this._nestedPopupsEnabled.addEventListener('change', this._onNestedPopupsEnabledChange.bind(this), false);
+        this._nestedPopupsCount.addEventListener('change', this._onNestedPopupsCountChange.bind(this), false);
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+        this._onOptionsChanged({options});
+    }
+
+    // Private
+
+    _onOptionsChanged({options}) {
+        this._updatePopupNestingMaxDepth(options.scanning.popupNestingMaxDepth);
+    }
+
+    _onNestedPopupsEnabledChange(e) {
+        const value = e.currentTarget.checked;
+        if (value && this._popupNestingMaxDepth > 0) { return; }
+        this._setPopupNestingMaxDepth(value ? 1 : 0);
+    }
+
+    _onNestedPopupsCountChange(e) {
+        const node = e.currentTarget;
+        const value = Math.max(1, DOMDataBinder.convertToNumber(node.value, node));
+        this._setPopupNestingMaxDepth(value);
+    }
+
+    _updatePopupNestingMaxDepth(value) {
+        const enabled = (value > 0);
+        this._popupNestingMaxDepth = value;
+        this._nestedPopupsEnabled.checked = enabled;
+        this._nestedPopupsCount.value = `${value}`;
+        this._nestedPopupsEnabledMoreOptions.hidden = !enabled;
+    }
+
+    async _setPopupNestingMaxDepth(value) {
+        this._updatePopupNestingMaxDepth(value);
+        await this._settingsController.setProfileSetting('scanning.popupNestingMaxDepth', value);
+    }
+}
diff --git a/ext/js/settings/permissions-toggle-controller.js b/ext/js/settings/permissions-toggle-controller.js
new file mode 100644
index 00000000..f80e7585
--- /dev/null
+++ b/ext/js/settings/permissions-toggle-controller.js
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * ObjectPropertyAccessor
+ */
+
+class PermissionsToggleController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._toggles = null;
+    }
+
+    async prepare() {
+        this._toggles = document.querySelectorAll('.permissions-toggle');
+
+        for (const toggle of this._toggles) {
+            toggle.addEventListener('change', this._onPermissionsToggleChange.bind(this), false);
+        }
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+        this._settingsController.on('permissionsChanged', this._onPermissionsChanged.bind(this));
+
+        const options = await this._settingsController.getOptions();
+        this._onOptionsChanged({options});
+    }
+
+    // Private
+
+    _onOptionsChanged({options}) {
+        let accessor = null;
+        for (const toggle of this._toggles) {
+            const {permissionsSetting} = toggle.dataset;
+            if (typeof permissionsSetting !== 'string') { continue; }
+
+            if (accessor === null) {
+                accessor = new ObjectPropertyAccessor(options);
+            }
+
+            const path = ObjectPropertyAccessor.getPathArray(permissionsSetting);
+            let value;
+            try {
+                value = accessor.get(path, path.length);
+            } catch (e) {
+                continue;
+            }
+            toggle.checked = !!value;
+        }
+        this._updateValidity();
+    }
+
+    async _onPermissionsToggleChange(e) {
+        const toggle = e.currentTarget;
+        let value = toggle.checked;
+        const valuePre = !value;
+        const {permissionsSetting} = toggle.dataset;
+        const hasPermissionsSetting = typeof permissionsSetting === 'string';
+
+        if (value || !hasPermissionsSetting) {
+            toggle.checked = valuePre;
+            const permissions = this._getRequiredPermissions(toggle);
+            try {
+                value = await this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, value);
+            } catch (error) {
+                value = valuePre;
+                try {
+                    value = await this._settingsController.permissionsUtil.hasPermissions({permissions});
+                } catch (error2) {
+                    // NOP
+                }
+            }
+            toggle.checked = value;
+        }
+
+        if (hasPermissionsSetting) {
+            this._setToggleValid(toggle, true);
+            await this._settingsController.setProfileSetting(permissionsSetting, value);
+        }
+    }
+
+    _onPermissionsChanged({permissions: {permissions}}) {
+        const permissionsSet = new Set(permissions);
+        for (const toggle of this._toggles) {
+            const {permissionsSetting} = toggle.dataset;
+            const hasPermissions = this._hasAll(permissionsSet, this._getRequiredPermissions(toggle));
+
+            if (typeof permissionsSetting === 'string') {
+                const valid = !toggle.checked || hasPermissions;
+                this._setToggleValid(toggle, valid);
+            } else {
+                toggle.checked = hasPermissions;
+            }
+        }
+    }
+
+    _setToggleValid(toggle, valid) {
+        const relative = toggle.closest('.settings-item');
+        if (relative === null) { return; }
+        relative.dataset.invalid = `${!valid}`;
+    }
+
+    async _updateValidity() {
+        const permissions = await this._settingsController.permissionsUtil.getAllPermissions();
+        this._onPermissionsChanged({permissions});
+    }
+
+    _hasAll(set, values) {
+        for (const value of values) {
+            if (!set.has(value)) { return false; }
+        }
+        return true;
+    }
+
+    _getRequiredPermissions(toggle) {
+        const requiredPermissions = toggle.dataset.requiredPermissions;
+        return (typeof requiredPermissions === 'string' && requiredPermissions.length > 0 ? requiredPermissions.split(' ') : []);
+    }
+}
diff --git a/ext/js/settings/pitch-accents-preview-main.js b/ext/js/settings/pitch-accents-preview-main.js
new file mode 100644
index 00000000..f292170a
--- /dev/null
+++ b/ext/js/settings/pitch-accents-preview-main.js
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * DisplayGenerator
+ */
+
+(async () => {
+    try {
+        const displayGenerator = new DisplayGenerator({
+            japaneseUtil: null,
+            mediaLoader: null
+        });
+        await displayGenerator.prepare();
+        displayGenerator.preparePitchAccents();
+    } catch (e) {
+        yomichan.logError(e);
+    }
+})();
diff --git a/ext/js/settings/popup-preview-controller.js b/ext/js/settings/popup-preview-controller.js
new file mode 100644
index 00000000..f98b0679
--- /dev/null
+++ b/ext/js/settings/popup-preview-controller.js
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * wanakana
+ */
+
+class PopupPreviewController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._previewVisible = false;
+        this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
+        this._frame = null;
+        this._previewTextInput = null;
+        this._customCss = null;
+        this._customOuterCss = null;
+        this._previewFrameContainer = null;
+    }
+
+    async prepare() {
+        const button = document.querySelector('#settings-popup-preview-button');
+        if (button !== null) {
+            button.addEventListener('click', this._onShowPopupPreviewButtonClick.bind(this), false);
+        } else {
+            this._frame = document.querySelector('#popup-preview-frame');
+            this._customCss = document.querySelector('#custom-popup-css');
+            this._customOuterCss = document.querySelector('#custom-popup-outer-css');
+            this._previewFrameContainer = document.querySelector('.preview-frame-container');
+
+            this._customCss.addEventListener('input', this._onCustomCssChange.bind(this), false);
+            this._customCss.addEventListener('settingChanged', this._onCustomCssChange.bind(this), false);
+            this._customOuterCss.addEventListener('input', this._onCustomOuterCssChange.bind(this), false);
+            this._customOuterCss.addEventListener('settingChanged', this._onCustomOuterCssChange.bind(this), false);
+            this._frame.addEventListener('load', this._onFrameLoad2.bind(this), false);
+            this._settingsController.on('optionsContextChanged', this._onOptionsContextChange.bind(this));
+        }
+    }
+
+    // Private
+
+    _onShowPopupPreviewButtonClick() {
+        if (this._previewVisible) { return; }
+        this._showAppearancePreview();
+        this._previewVisible = true;
+    }
+
+    _showAppearancePreview() {
+        const container = document.querySelector('#settings-popup-preview-container');
+        const buttonContainer = document.querySelector('#settings-popup-preview-button-container');
+        const settings = document.querySelector('#settings-popup-preview-settings');
+        const text = document.querySelector('#settings-popup-preview-text');
+        const customCss = document.querySelector('#custom-popup-css');
+        const customOuterCss = document.querySelector('#custom-popup-outer-css');
+        const frame = document.createElement('iframe');
+
+        this._previewTextInput = text;
+        this._frame = frame;
+        this._customCss = customCss;
+        this._customOuterCss = customOuterCss;
+
+        wanakana.bind(text);
+
+        frame.addEventListener('load', this._onFrameLoad.bind(this), false);
+        text.addEventListener('input', this._onTextChange.bind(this), false);
+        customCss.addEventListener('input', this._onCustomCssChange.bind(this), false);
+        customOuterCss.addEventListener('input', this._onCustomOuterCssChange.bind(this), false);
+        this._settingsController.on('optionsContextChanged', this._onOptionsContextChange.bind(this));
+
+        frame.src = '/popup-preview.html';
+        frame.id = 'settings-popup-preview-frame';
+
+        container.appendChild(frame);
+        if (buttonContainer.parentNode !== null) {
+            buttonContainer.parentNode.removeChild(buttonContainer);
+        }
+        settings.style.display = '';
+    }
+
+    _onFrameLoad() {
+        this._onOptionsContextChange();
+        this._setText(this._previewTextInput.value);
+    }
+
+    _onFrameLoad2() {
+        this._onOptionsContextChange();
+        this._onCustomCssChange();
+        this._onCustomOuterCssChange();
+    }
+
+    _onTextChange(e) {
+        this._setText(e.currentTarget.value);
+    }
+
+    _onCustomCssChange() {
+        this._invoke('setCustomCss', {css: this._customCss.value});
+    }
+
+    _onCustomOuterCssChange() {
+        this._invoke('setCustomOuterCss', {css: this._customOuterCss.value});
+    }
+
+    _onOptionsContextChange() {
+        const optionsContext = this._settingsController.getOptionsContext();
+        this._invoke('updateOptionsContext', {optionsContext});
+    }
+
+    _setText(text) {
+        this._invoke('setText', {text});
+    }
+
+    _invoke(action, params) {
+        if (this._frame === null || this._frame.contentWindow === null) { return; }
+        this._frame.contentWindow.postMessage({action, params}, this._targetOrigin);
+    }
+}
diff --git a/ext/js/settings/popup-preview-frame-main.js b/ext/js/settings/popup-preview-frame-main.js
new file mode 100644
index 00000000..dcbc0d96
--- /dev/null
+++ b/ext/js/settings/popup-preview-frame-main.js
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * HotkeyHandler
+ * PopupFactory
+ * PopupPreviewFrame
+ * api
+ */
+
+(async () => {
+    try {
+        api.forwardLogsToBackend();
+
+        const {tabId, frameId} = await api.frameInformationGet();
+
+        const hotkeyHandler = new HotkeyHandler();
+        hotkeyHandler.prepare();
+
+        const popupFactory = new PopupFactory(frameId);
+        popupFactory.prepare();
+
+        const preview = new PopupPreviewFrame(tabId, frameId, popupFactory, hotkeyHandler);
+        await preview.prepare();
+
+        document.documentElement.dataset.loaded = 'true';
+    } catch (e) {
+        yomichan.logError(e);
+    }
+})();
diff --git a/ext/js/settings/popup-preview-frame.js b/ext/js/settings/popup-preview-frame.js
new file mode 100644
index 00000000..56100fb3
--- /dev/null
+++ b/ext/js/settings/popup-preview-frame.js
@@ -0,0 +1,233 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * Frontend
+ * TextSourceRange
+ * api
+ * wanakana
+ */
+
+class PopupPreviewFrame {
+    constructor(tabId, frameId, popupFactory, hotkeyHandler) {
+        this._tabId = tabId;
+        this._frameId = frameId;
+        this._popupFactory = popupFactory;
+        this._hotkeyHandler = hotkeyHandler;
+        this._frontend = null;
+        this._apiOptionsGetOld = null;
+        this._popupShown = false;
+        this._themeChangeTimeout = null;
+        this._textSource = null;
+        this._optionsContext = null;
+        this._exampleText = null;
+        this._exampleTextInput = null;
+        this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
+
+        this._windowMessageHandlers = new Map([
+            ['setText',              this._onSetText.bind(this)],
+            ['setCustomCss',         this._setCustomCss.bind(this)],
+            ['setCustomOuterCss',    this._setCustomOuterCss.bind(this)],
+            ['updateOptionsContext', this._updateOptionsContext.bind(this)]
+        ]);
+    }
+
+    async prepare() {
+        this._exampleText = document.querySelector('#example-text');
+        this._exampleTextInput = document.querySelector('#example-text-input');
+
+        if (this._exampleTextInput !== null && typeof wanakana !== 'undefined') {
+            wanakana.bind(this._exampleTextInput);
+        }
+
+        window.addEventListener('message', this._onMessage.bind(this), false);
+
+        // Setup events
+        document.querySelector('#theme-dark-checkbox').addEventListener('change', this._onThemeDarkCheckboxChanged.bind(this), false);
+        this._exampleText.addEventListener('click', this._onExampleTextClick.bind(this), false);
+        this._exampleTextInput.addEventListener('blur', this._onExampleTextInputBlur.bind(this), false);
+        this._exampleTextInput.addEventListener('input', this._onExampleTextInputInput.bind(this), false);
+
+        // Overwrite API functions
+        this._apiOptionsGetOld = api.optionsGet.bind(api);
+        api.optionsGet = this._apiOptionsGet.bind(this);
+
+        // Overwrite frontend
+        this._frontend = new Frontend({
+            tabId: this._tabId,
+            frameId: this._frameId,
+            popupFactory: this._popupFactory,
+            depth: 0,
+            parentPopupId: null,
+            parentFrameId: null,
+            useProxyPopup: false,
+            canUseWindowPopup: false,
+            pageType: 'web',
+            allowRootFramePopupProxy: false,
+            childrenSupported: false,
+            hotkeyHandler: this._hotkeyHandler
+        });
+        this._frontend.setOptionsContextOverride(this._optionsContext);
+        await this._frontend.prepare();
+        this._frontend.setDisabledOverride(true);
+        this._frontend.canClearSelection = false;
+        this._frontend.popup.on('customOuterCssChanged', this._onCustomOuterCssChanged.bind(this));
+
+        // Update search
+        this._updateSearch();
+    }
+
+    // Private
+
+    async _apiOptionsGet(...args) {
+        const options = await this._apiOptionsGetOld(...args);
+        options.general.enable = true;
+        options.general.debugInfo = false;
+        options.general.popupWidth = 400;
+        options.general.popupHeight = 250;
+        options.general.popupHorizontalOffset = 0;
+        options.general.popupVerticalOffset = 10;
+        options.general.popupHorizontalOffset2 = 10;
+        options.general.popupVerticalOffset2 = 0;
+        options.general.popupHorizontalTextPosition = 'below';
+        options.general.popupVerticalTextPosition = 'before';
+        options.scanning.selectText = false;
+        return options;
+    }
+
+    _onCustomOuterCssChanged({node, inShadow}) {
+        if (node === null || inShadow) { return; }
+
+        const node2 = document.querySelector('#popup-outer-css');
+        if (node2 === null) { return; }
+
+        // This simulates the stylesheet priorities when injecting using the web extension API.
+        node2.parentNode.insertBefore(node, node2);
+    }
+
+    _onMessage(e) {
+        if (e.origin !== this._targetOrigin) { return; }
+
+        const {action, params} = e.data;
+        const handler = this._windowMessageHandlers.get(action);
+        if (typeof handler !== 'function') { return; }
+
+        handler(params);
+    }
+
+    _onThemeDarkCheckboxChanged(e) {
+        document.documentElement.classList.toggle('dark', e.target.checked);
+        if (this._themeChangeTimeout !== null) {
+            clearTimeout(this._themeChangeTimeout);
+        }
+        this._themeChangeTimeout = setTimeout(() => {
+            this._themeChangeTimeout = null;
+            const popup = this._frontend.popup;
+            if (popup === null) { return; }
+            popup.updateTheme();
+        }, 300);
+    }
+
+    _onExampleTextClick() {
+        if (this._exampleTextInput === null) { return; }
+        const visible = this._exampleTextInput.hidden;
+        this._exampleTextInput.hidden = !visible;
+        if (!visible) { return; }
+        this._exampleTextInput.focus();
+        this._exampleTextInput.select();
+    }
+
+    _onExampleTextInputBlur() {
+        if (this._exampleTextInput === null) { return; }
+        this._exampleTextInput.hidden = true;
+    }
+
+    _onExampleTextInputInput(e) {
+        this._setText(e.currentTarget.value);
+    }
+
+    _onSetText({text}) {
+        this._setText(text, true);
+    }
+
+    _setText(text, setInput) {
+        if (setInput && this._exampleTextInput !== null) {
+            this._exampleTextInput.value = text;
+        }
+
+        if (this._exampleText === null) { return; }
+
+        this._exampleText.textContent = text;
+        if (this._frontend === null) { return; }
+        this._updateSearch();
+    }
+
+    _setInfoVisible(visible) {
+        const node = document.querySelector('.placeholder-info');
+        if (node === null) { return; }
+
+        node.classList.toggle('placeholder-info-visible', visible);
+    }
+
+    _setCustomCss({css}) {
+        if (this._frontend === null) { return; }
+        const popup = this._frontend.popup;
+        if (popup === null) { return; }
+        popup.setCustomCss(css);
+    }
+
+    _setCustomOuterCss({css}) {
+        if (this._frontend === null) { return; }
+        const popup = this._frontend.popup;
+        if (popup === null) { return; }
+        popup.setCustomOuterCss(css, false);
+    }
+
+    async _updateOptionsContext({optionsContext}) {
+        this._optionsContext = optionsContext;
+        if (this._frontend === null) { return; }
+        this._frontend.setOptionsContextOverride(optionsContext);
+        await this._frontend.updateOptions();
+        await this._updateSearch();
+    }
+
+    async _updateSearch() {
+        if (this._exampleText === null) { return; }
+
+        const textNode = this._exampleText.firstChild;
+        if (textNode === null) { return; }
+
+        const range = document.createRange();
+        range.selectNodeContents(textNode);
+        const source = new TextSourceRange(range, range.toString(), null, null);
+
+        try {
+            await this._frontend.setTextSource(source);
+        } finally {
+            source.cleanup();
+        }
+        this._textSource = source;
+        await this._frontend.showContentCompleted();
+
+        const popup = this._frontend.popup;
+        if (popup !== null && popup.isVisibleSync()) {
+            this._popupShown = true;
+        }
+
+        this._setInfoVisible(!this._popupShown);
+    }
+}
diff --git a/ext/js/settings/popup-window-controller.js b/ext/js/settings/popup-window-controller.js
new file mode 100644
index 00000000..cc83db68
--- /dev/null
+++ b/ext/js/settings/popup-window-controller.js
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * api
+ */
+
+class PopupWindowController {
+    prepare() {
+        const testLink = document.querySelector('#test-window-open-link');
+        testLink.addEventListener('click', this._onTestWindowOpenLinkClick.bind(this), false);
+    }
+
+    // Private
+
+    _onTestWindowOpenLinkClick(e) {
+        e.preventDefault();
+        this._testWindowOpen();
+    }
+
+    async _testWindowOpen() {
+        await api.getOrCreateSearchPopup({focus: true});
+    }
+}
diff --git a/ext/js/settings/profile-conditions-ui.js b/ext/js/settings/profile-conditions-ui.js
new file mode 100644
index 00000000..5fda1dc0
--- /dev/null
+++ b/ext/js/settings/profile-conditions-ui.js
@@ -0,0 +1,712 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * KeyboardMouseInputField
+ */
+
+class ProfileConditionsUI extends EventDispatcher {
+    constructor(settingsController) {
+        super();
+        this._settingsController = settingsController;
+        this._os = null;
+        this._conditionGroupsContainer = null;
+        this._addConditionGroupButton = null;
+        this._children = [];
+        this._eventListeners = new EventListenerCollection();
+        this._defaultType = 'popupLevel';
+        this._profileIndex = 0;
+        this._descriptors = new Map([
+            [
+                'popupLevel',
+                {
+                    displayName: 'Popup Level',
+                    defaultOperator: 'equal',
+                    operators: new Map([
+                        ['equal',              {displayName: '=',      type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}],
+                        ['notEqual',           {displayName: '\u2260', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}],
+                        ['lessThan',           {displayName: '<',      type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}],
+                        ['greaterThan',        {displayName: '>',      type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}],
+                        ['lessThanOrEqual',    {displayName: '\u2264', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}],
+                        ['greaterThanOrEqual', {displayName: '\u2265', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}]
+                    ])
+                }
+            ],
+            [
+                'url',
+                {
+                    displayName: 'URL',
+                    defaultOperator: 'matchDomain',
+                    operators: new Map([
+                        ['matchDomain', {displayName: 'Matches Domain', type: 'string', defaultValue: 'example.com',   resetDefaultOnChange: true, validate: this._validateDomains.bind(this), normalize: this._normalizeDomains.bind(this)}],
+                        ['matchRegExp', {displayName: 'Matches RegExp', type: 'string', defaultValue: 'example\\.com', resetDefaultOnChange: true, validate: this._validateRegExp.bind(this)}]
+                    ])
+                }
+            ],
+            [
+                'modifierKeys',
+                {
+                    displayName: 'Modifier Keys',
+                    defaultOperator: 'are',
+                    operators: new Map([
+                        ['are',        {displayName: 'Are',            type: 'modifierKeys', defaultValue: ''}],
+                        ['areNot',     {displayName: 'Are Not',        type: 'modifierKeys', defaultValue: ''}],
+                        ['include',    {displayName: 'Include',        type: 'modifierKeys', defaultValue: ''}],
+                        ['notInclude', {displayName: 'Don\'t Include', type: 'modifierKeys', defaultValue: ''}]
+                    ])
+                }
+            ]
+        ]);
+    }
+
+    get settingsController() {
+        return this._settingsController;
+    }
+
+    get profileIndex() {
+        return this._profileIndex;
+    }
+
+    get os() {
+        return this._os;
+    }
+
+    set os(value) {
+        this._os = value;
+    }
+
+    async prepare(profileIndex) {
+        const options = await this._settingsController.getOptionsFull();
+        const {profiles} = options;
+        if (profileIndex < 0 || profileIndex >= profiles.length) { return; }
+        const {conditionGroups} = profiles[profileIndex];
+
+        this._profileIndex = profileIndex;
+        this._conditionGroupsContainer = document.querySelector('#profile-condition-groups');
+        this._addConditionGroupButton = document.querySelector('#profile-add-condition-group');
+
+        for (let i = 0, ii = conditionGroups.length; i < ii; ++i) {
+            this._addConditionGroup(conditionGroups[i], i);
+        }
+
+        this._eventListeners.addEventListener(this._addConditionGroupButton, 'click', this._onAddConditionGroupButtonClick.bind(this), false);
+    }
+
+    cleanup() {
+        this._eventListeners.removeAllEventListeners();
+
+        for (const child of this._children) {
+            child.cleanup();
+        }
+        this._children = [];
+
+        this._conditionGroupsContainer = null;
+        this._addConditionGroupButton = null;
+    }
+
+    instantiateTemplate(names) {
+        return this._settingsController.instantiateTemplate(names);
+    }
+
+    getDescriptorTypes() {
+        const results = [];
+        for (const [name, {displayName}] of this._descriptors.entries()) {
+            results.push({name, displayName});
+        }
+        return results;
+    }
+
+    getDescriptorOperators(type) {
+        const info = this._descriptors.get(type);
+        const results = [];
+        if (typeof info !== 'undefined') {
+            for (const [name, {displayName}] of info.operators.entries()) {
+                results.push({name, displayName});
+            }
+        }
+        return results;
+    }
+
+    getDefaultType() {
+        return this._defaultType;
+    }
+
+    getDefaultOperator(type) {
+        const info = this._descriptors.get(type);
+        return (typeof info !== 'undefined' ? info.defaultOperator : '');
+    }
+
+    getOperatorDetails(type, operator) {
+        const info = this._getOperatorDetails(type, operator);
+
+        const {
+            displayName=operator,
+            type: type2='string',
+            defaultValue='',
+            resetDefaultOnChange=false,
+            validate=null,
+            normalize=null
+        } = (typeof info === 'undefined' ? {} : info);
+
+        return {
+            displayName,
+            type: type2,
+            defaultValue,
+            resetDefaultOnChange,
+            validate,
+            normalize
+        };
+    }
+
+    getDefaultCondition() {
+        const type = this.getDefaultType();
+        const operator = this.getDefaultOperator(type);
+        const {defaultValue: value} = this.getOperatorDetails(type, operator);
+        return {type, operator, value};
+    }
+
+    removeConditionGroup(child) {
+        const index = child.index;
+        if (index < 0 || index >= this._children.length) { return false; }
+
+        const child2 = this._children[index];
+        if (child !== child2) { return false; }
+
+        this._children.splice(index, 1);
+        child.cleanup();
+
+        for (let i = index, ii = this._children.length; i < ii; ++i) {
+            this._children[i].index = i;
+        }
+
+        this.settingsController.modifyGlobalSettings([{
+            action: 'splice',
+            path: this.getPath('conditionGroups'),
+            start: index,
+            deleteCount: 1,
+            items: []
+        }]);
+
+        this._triggerConditionGroupCountChanged(this._children.length);
+
+        return true;
+    }
+
+    splitValue(value) {
+        return value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
+    }
+
+    getPath(property) {
+        property = (typeof property === 'string' ? `.${property}` : '');
+        return `profiles[${this.profileIndex}]${property}`;
+    }
+
+    createKeyboardMouseInputField(inputNode, mouseButton) {
+        return new KeyboardMouseInputField(inputNode, mouseButton, this._os);
+    }
+
+    // Private
+
+    _onAddConditionGroupButtonClick() {
+        const conditionGroup = {
+            conditions: [this.getDefaultCondition()]
+        };
+        const index = this._children.length;
+
+        this._addConditionGroup(conditionGroup, index);
+
+        this.settingsController.modifyGlobalSettings([{
+            action: 'splice',
+            path: this.getPath('conditionGroups'),
+            start: index,
+            deleteCount: 0,
+            items: [conditionGroup]
+        }]);
+
+        this._triggerConditionGroupCountChanged(this._children.length);
+    }
+
+    _addConditionGroup(conditionGroup, index) {
+        const child = new ProfileConditionGroupUI(this, index);
+        child.prepare(conditionGroup);
+        this._children.push(child);
+        this._conditionGroupsContainer.appendChild(child.node);
+        return child;
+    }
+
+    _getOperatorDetails(type, operator) {
+        const info = this._descriptors.get(type);
+        return (typeof info !== 'undefined' ? info.operators.get(operator) : void 0);
+    }
+
+    _validateInteger(value) {
+        const number = Number.parseFloat(value);
+        return Number.isFinite(number) && Math.floor(number) === number;
+    }
+
+    _validateDomains(value) {
+        return this.splitValue(value).length > 0;
+    }
+
+    _validateRegExp(value) {
+        try {
+            new RegExp(value, 'i');
+            return true;
+        } catch (e) {
+            return false;
+        }
+    }
+
+    _normalizeInteger(value) {
+        const number = Number.parseFloat(value);
+        return `${number}`;
+    }
+
+    _normalizeDomains(value) {
+        return this.splitValue(value).join(', ');
+    }
+
+    _triggerConditionGroupCountChanged(count) {
+        this.trigger('conditionGroupCountChanged', {count, profileIndex: this._profileIndex});
+    }
+}
+
+class ProfileConditionGroupUI {
+    constructor(parent, index) {
+        this._parent = parent;
+        this._index = index;
+        this._node = null;
+        this._conditionContainer = null;
+        this._addConditionButton = null;
+        this._children = [];
+        this._eventListeners = new EventListenerCollection();
+    }
+
+    get settingsController() {
+        return this._parent.settingsController;
+    }
+
+    get parent() {
+        return this._parent;
+    }
+
+    get index() {
+        return this._index;
+    }
+
+    set index(value) {
+        this._index = value;
+    }
+
+    get node() {
+        return this._node;
+    }
+
+    get childCount() {
+        return this._children.length;
+    }
+
+    prepare(conditionGroup) {
+        this._node = this._parent.instantiateTemplate('profile-condition-group');
+        this._conditionContainer = this._node.querySelector('.profile-condition-list');
+        this._addConditionButton = this._node.querySelector('.profile-condition-add-button');
+
+        const conditions = conditionGroup.conditions;
+        for (let i = 0, ii = conditions.length; i < ii; ++i) {
+            this._addCondition(conditions[i], i);
+        }
+
+        this._eventListeners.addEventListener(this._addConditionButton, 'click', this._onAddConditionButtonClick.bind(this), false);
+    }
+
+    cleanup() {
+        this._eventListeners.removeAllEventListeners();
+
+        for (const child of this._children) {
+            child.cleanup();
+        }
+        this._children = [];
+
+        if (this._node === null) { return; }
+
+        const node = this._node;
+        this._node = null;
+        this._conditionContainer = null;
+        this._addConditionButton = null;
+
+        if (node.parentNode !== null) {
+            node.parentNode.removeChild(node);
+        }
+    }
+
+    removeCondition(child) {
+        const index = child.index;
+        if (index < 0 || index >= this._children.length) { return false; }
+
+        const child2 = this._children[index];
+        if (child !== child2) { return false; }
+
+        this._children.splice(index, 1);
+        child.cleanup();
+
+        for (let i = index, ii = this._children.length; i < ii; ++i) {
+            this._children[i].index = i;
+        }
+
+        this.settingsController.modifyGlobalSettings([{
+            action: 'splice',
+            path: this.getPath('conditions'),
+            start: index,
+            deleteCount: 1,
+            items: []
+        }]);
+
+        if (this._children.length === 0) {
+            this.removeSelf();
+        }
+
+        return true;
+    }
+
+    getPath(property) {
+        property = (typeof property === 'string' ? `.${property}` : '');
+        return this._parent.getPath(`conditionGroups[${this._index}]${property}`);
+    }
+
+    removeSelf() {
+        this._parent.removeConditionGroup(this);
+    }
+
+    // Private
+
+    _onAddConditionButtonClick() {
+        const condition = this._parent.getDefaultCondition();
+        const index = this._children.length;
+
+        this._addCondition(condition, index);
+
+        this.settingsController.modifyGlobalSettings([{
+            action: 'splice',
+            path: this.getPath('conditions'),
+            start: index,
+            deleteCount: 0,
+            items: [condition]
+        }]);
+    }
+
+    _addCondition(condition, index) {
+        const child = new ProfileConditionUI(this, index);
+        child.prepare(condition);
+        this._children.push(child);
+        this._conditionContainer.appendChild(child.node);
+        return child;
+    }
+}
+
+class ProfileConditionUI {
+    constructor(parent, index) {
+        this._parent = parent;
+        this._index = index;
+        this._node = null;
+        this._typeInput = null;
+        this._operatorInput = null;
+        this._valueInputContainer = null;
+        this._removeButton = null;
+        this._mouseButton = null;
+        this._mouseButtonContainer = null;
+        this._menuButton = null;
+        this._value = '';
+        this._kbmInputField = null;
+        this._eventListeners = new EventListenerCollection();
+        this._inputEventListeners = new EventListenerCollection();
+    }
+
+    get settingsController() {
+        return this._parent.parent.settingsController;
+    }
+
+    get parent() {
+        return this._parent;
+    }
+
+    get index() {
+        return this._index;
+    }
+
+    set index(value) {
+        this._index = value;
+    }
+
+    get node() {
+        return this._node;
+    }
+
+    prepare(condition) {
+        const {type, operator, value} = condition;
+
+        this._node = this._parent.parent.instantiateTemplate('profile-condition');
+        this._typeInput = this._node.querySelector('.profile-condition-type');
+        this._typeOptionContainer = this._typeInput.querySelector('optgroup');
+        this._operatorInput = this._node.querySelector('.profile-condition-operator');
+        this._operatorOptionContainer = this._operatorInput.querySelector('optgroup');
+        this._valueInput = this._node.querySelector('.profile-condition-input');
+        this._removeButton = this._node.querySelector('.profile-condition-remove');
+        this._mouseButton = this._node.querySelector('.mouse-button');
+        this._mouseButtonContainer = this._node.querySelector('.mouse-button-container');
+        this._menuButton = this._node.querySelector('.profile-condition-menu-button');
+
+        const operatorDetails = this._getOperatorDetails(type, operator);
+        this._updateTypes(type);
+        this._updateOperators(type, operator);
+        this._updateValueInput(value, operatorDetails);
+
+        this._eventListeners.addEventListener(this._typeInput, 'change', this._onTypeChange.bind(this), false);
+        this._eventListeners.addEventListener(this._operatorInput, 'change', this._onOperatorChange.bind(this), false);
+        if (this._removeButton !== null) { this._eventListeners.addEventListener(this._removeButton, 'click', this._onRemoveButtonClick.bind(this), false); }
+        if (this._menuButton !== null) {
+            this._eventListeners.addEventListener(this._menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
+            this._eventListeners.addEventListener(this._menuButton, 'menuClose', this._onMenuClose.bind(this), false);
+        }
+    }
+
+    cleanup() {
+        this._eventListeners.removeAllEventListeners();
+        this._value = '';
+
+        if (this._node === null) { return; }
+
+        const node = this._node;
+        this._node = null;
+        this._typeInput = null;
+        this._operatorInput = null;
+        this._valueInputContainer = null;
+        this._removeButton = null;
+
+        if (node.parentNode !== null) {
+            node.parentNode.removeChild(node);
+        }
+    }
+
+    getPath(property) {
+        property = (typeof property === 'string' ? `.${property}` : '');
+        return this._parent.getPath(`conditions[${this._index}]${property}`);
+    }
+
+    // Private
+
+    _onTypeChange(e) {
+        const type = e.currentTarget.value;
+        this._setType(type);
+    }
+
+    _onOperatorChange(e) {
+        const type = this._typeInput.value;
+        const operator = e.currentTarget.value;
+        this._setOperator(type, operator);
+    }
+
+    _onValueInputChange({validate, normalize}, e) {
+        const node = e.currentTarget;
+        const value = node.value;
+        const okay = this._validateValue(value, validate);
+        this._value = value;
+        if (okay) {
+            const normalizedValue = this._normalizeValue(value, normalize);
+            node.value = normalizedValue;
+            this.settingsController.setGlobalSetting(this.getPath('value'), normalizedValue);
+        }
+    }
+
+    _onModifierInputChange({validate, normalize}, {modifiers}) {
+        modifiers = this._joinModifiers(modifiers);
+        const okay = this._validateValue(modifiers, validate);
+        this._value = modifiers;
+        if (okay) {
+            const normalizedValue = this._normalizeValue(modifiers, normalize);
+            this.settingsController.setGlobalSetting(this.getPath('value'), normalizedValue);
+        }
+    }
+
+    _onRemoveButtonClick() {
+        this._removeSelf();
+    }
+
+    _onMenuOpen(e) {
+        const bodyNode = e.detail.menu.bodyNode;
+        const deleteGroup = bodyNode.querySelector('.popup-menu-item[data-menu-action="deleteGroup"]');
+        if (deleteGroup !== null) {
+            deleteGroup.hidden = (this._parent.childCount <= 1);
+        }
+    }
+
+    _onMenuClose(e) {
+        switch (e.detail.action) {
+            case 'delete':
+                this._removeSelf();
+                break;
+            case 'deleteGroup':
+                this._parent.removeSelf();
+                break;
+            case 'resetValue':
+                this._resetValue();
+                break;
+        }
+    }
+
+    _getDescriptorTypes() {
+        return this._parent.parent.getDescriptorTypes();
+    }
+
+    _getDescriptorOperators(type) {
+        return this._parent.parent.getDescriptorOperators(type);
+    }
+
+    _getOperatorDetails(type, operator) {
+        return this._parent.parent.getOperatorDetails(type, operator);
+    }
+
+    _updateTypes(type) {
+        const types = this._getDescriptorTypes();
+        this._updateSelect(this._typeInput, this._typeOptionContainer, types, type);
+    }
+
+    _updateOperators(type, operator) {
+        const operators = this._getDescriptorOperators(type);
+        this._updateSelect(this._operatorInput, this._operatorOptionContainer, operators, operator);
+    }
+
+    _updateSelect(select, optionContainer, values, value) {
+        optionContainer.textContent = '';
+        for (const {name, displayName} of values) {
+            const option = document.createElement('option');
+            option.value = name;
+            option.textContent = displayName;
+            optionContainer.appendChild(option);
+        }
+        select.value = value;
+    }
+
+    _updateValueInput(value, {type, validate, normalize}) {
+        this._inputEventListeners.removeAllEventListeners();
+        if (this._kbmInputField !== null) {
+            this._kbmInputField.cleanup();
+            this._kbmInputField = null;
+        }
+
+        let inputType = 'text';
+        let inputValue = value;
+        let inputStep = null;
+        let showMouseButton = false;
+        const events = [];
+        const inputData = {validate, normalize};
+        const node = this._valueInput;
+
+        switch (type) {
+            case 'integer':
+                inputType = 'number';
+                inputStep = '1';
+                events.push(['addEventListener', node, 'change', this._onValueInputChange.bind(this, inputData), false]);
+                break;
+            case 'modifierKeys':
+            case 'modifierInputs':
+                inputValue = null;
+                showMouseButton = (type === 'modifierInputs');
+                this._kbmInputField = this._parent.parent.createKeyboardMouseInputField(node, this._mouseButton);
+                this._kbmInputField.prepare(null, this._splitModifiers(value), showMouseButton, false);
+                events.push(['on', this._kbmInputField, 'change', this._onModifierInputChange.bind(this, inputData), false]);
+                break;
+            default: // 'string'
+                events.push(['addEventListener', node, 'change', this._onValueInputChange.bind(this, inputData), false]);
+                break;
+        }
+
+        this._value = value;
+        delete node.dataset.invalid;
+        node.type = inputType;
+        if (inputValue !== null) {
+            node.value = inputValue;
+        }
+        if (typeof inputStep === 'string') {
+            node.step = inputStep;
+        } else {
+            node.removeAttribute('step');
+        }
+        this._mouseButtonContainer.hidden = !showMouseButton;
+        for (const args of events) {
+            this._inputEventListeners.addGeneric(...args);
+        }
+
+        this._validateValue(value, validate);
+    }
+
+    _validateValue(value, validate) {
+        const okay = (validate === null || validate(value));
+        this._valueInput.dataset.invalid = `${!okay}`;
+        return okay;
+    }
+
+    _normalizeValue(value, normalize) {
+        return (normalize !== null ? normalize(value) : value);
+    }
+
+    _removeSelf() {
+        this._parent.removeCondition(this);
+    }
+
+    _splitModifiers(modifiersString) {
+        return modifiersString.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
+    }
+
+    _joinModifiers(modifiersArray) {
+        return modifiersArray.join(', ');
+    }
+
+    async _setType(type, operator) {
+        const operators = this._getDescriptorOperators(type);
+        if (typeof operator === 'undefined') {
+            operator = operators.length > 0 ? operators[0].name : '';
+        }
+        const operatorDetails = this._getOperatorDetails(type, operator);
+        const {defaultValue} = operatorDetails;
+        this._updateSelect(this._operatorInput, this._operatorOptionContainer, operators, operator);
+        this._updateValueInput(defaultValue, operatorDetails);
+        await this.settingsController.modifyGlobalSettings([
+            {action: 'set', path: this.getPath('type'), value: type},
+            {action: 'set', path: this.getPath('operator'), value: operator},
+            {action: 'set', path: this.getPath('value'), value: defaultValue}
+        ]);
+    }
+
+    async _setOperator(type, operator) {
+        const operatorDetails = this._getOperatorDetails(type, operator);
+        const settingsModifications = [{action: 'set', path: this.getPath('operator'), value: operator}];
+        if (operatorDetails.resetDefaultOnChange) {
+            const {defaultValue} = operatorDetails;
+            const okay = this._updateValueInput(defaultValue, operatorDetails);
+            if (okay) {
+                settingsModifications.push({action: 'set', path: this.getPath('value'), value: defaultValue});
+            }
+        }
+        await this.settingsController.modifyGlobalSettings(settingsModifications);
+    }
+
+    async _resetValue() {
+        const type = this._typeInput.value;
+        const operator = this._operatorInput.value;
+        await this._setType(type, operator);
+    }
+}
diff --git a/ext/js/settings/profile-controller.js b/ext/js/settings/profile-controller.js
new file mode 100644
index 00000000..914fc679
--- /dev/null
+++ b/ext/js/settings/profile-controller.js
@@ -0,0 +1,698 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * ProfileConditionsUI
+ * api
+ */
+
+class ProfileController {
+    constructor(settingsController, modalController) {
+        this._settingsController = settingsController;
+        this._modalController = modalController;
+        this._profileConditionsUI = new ProfileConditionsUI(settingsController);
+        this._profileConditionsIndex = null;
+        this._profileActiveSelect = null;
+        this._profileTargetSelect = null;
+        this._profileCopySourceSelect = null;
+        this._profileNameInput = null;
+        this._removeProfileNameElement = null;
+        this._profileAddButton = null;
+        this._profileRemoveButton = null;
+        this._profileRemoveConfirmButton = null;
+        this._profileCopyButton = null;
+        this._profileCopyConfirmButton = null;
+        this._profileMoveUpButton = null;
+        this._profileMoveDownButton = null;
+        this._profileEntryListContainer = null;
+        this._profileConditionsProfileName = null;
+        this._profileRemoveModal = null;
+        this._profileCopyModal = null;
+        this._profileConditionsModal = null;
+        this._profileEntriesSupported = false;
+        this._profileEntryList = [];
+        this._profiles = [];
+        this._profileCurrent = 0;
+    }
+
+    get profileCount() {
+        return this._profiles.length;
+    }
+
+    get profileCurrentIndex() {
+        return this._profileCurrent;
+    }
+
+    async prepare() {
+        const {platform: {os}} = await api.getEnvironmentInfo();
+        this._profileConditionsUI.os = os;
+
+        this._profileActiveSelect = document.querySelector('#profile-active-select');
+        this._profileTargetSelect = document.querySelector('#profile-target-select');
+        this._profileCopySourceSelect = document.querySelector('#profile-copy-source-select');
+        this._profileNameInput = document.querySelector('#profile-name-input');
+        this._removeProfileNameElement = document.querySelector('#profile-remove-name');
+        this._profileAddButton = document.querySelector('#profile-add-button');
+        this._profileRemoveButton = document.querySelector('#profile-remove-button');
+        this._profileRemoveConfirmButton = document.querySelector('#profile-remove-confirm-button');
+        this._profileCopyButton = document.querySelector('#profile-copy-button');
+        this._profileCopyConfirmButton = document.querySelector('#profile-copy-confirm-button');
+        this._profileMoveUpButton = document.querySelector('#profile-move-up-button');
+        this._profileMoveDownButton = document.querySelector('#profile-move-down-button');
+        this._profileEntryListContainer = document.querySelector('#profile-entry-list');
+        this._profileConditionsProfileName = document.querySelector('#profile-conditions-profile-name');
+        this._profileRemoveModal = this._modalController.getModal('profile-remove');
+        this._profileCopyModal = this._modalController.getModal('profile-copy');
+        this._profileConditionsModal = this._modalController.getModal('profile-conditions');
+
+        this._profileEntriesSupported = (this._profileEntryListContainer !== null);
+
+        if (this._profileActiveSelect !== null) { this._profileActiveSelect.addEventListener('change', this._onProfileActiveChange.bind(this), false); }
+        if (this._profileTargetSelect !== null) { this._profileTargetSelect.addEventListener('change', this._onProfileTargetChange.bind(this), false); }
+        if (this._profileNameInput !== null) { this._profileNameInput.addEventListener('change', this._onNameChanged.bind(this), false); }
+        if (this._profileAddButton !== null) { this._profileAddButton.addEventListener('click', this._onAdd.bind(this), false); }
+        if (this._profileRemoveButton !== null) { this._profileRemoveButton.addEventListener('click', this._onDelete.bind(this), false); }
+        if (this._profileRemoveConfirmButton !== null) { this._profileRemoveConfirmButton.addEventListener('click', this._onDeleteConfirm.bind(this), false); }
+        if (this._profileCopyButton !== null) { this._profileCopyButton.addEventListener('click', this._onCopy.bind(this), false); }
+        if (this._profileCopyConfirmButton !== null) { this._profileCopyConfirmButton.addEventListener('click', this._onCopyConfirm.bind(this), false); }
+        if (this._profileMoveUpButton !== null) { this._profileMoveUpButton.addEventListener('click', this._onMove.bind(this, -1), false); }
+        if (this._profileMoveDownButton !== null) { this._profileMoveDownButton.addEventListener('click', this._onMove.bind(this, 1), false); }
+
+        this._profileConditionsUI.on('conditionGroupCountChanged', this._onConditionGroupCountChanged.bind(this));
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+        this._onOptionsChanged();
+    }
+
+    async moveProfile(profileIndex, offset) {
+        if (this._getProfile(profileIndex) === null) { return; }
+
+        const profileIndexNew = Math.max(0, Math.min(this._profiles.length - 1, profileIndex + offset));
+        if (profileIndex === profileIndexNew) { return; }
+
+        await this.swapProfiles(profileIndex, profileIndexNew);
+    }
+
+    async setProfileName(profileIndex, value) {
+        const profile = this._getProfile(profileIndex);
+        if (profile === null) { return; }
+
+        profile.name = value;
+        this._updateSelectName(profileIndex, value);
+
+        const profileEntry = this._getProfileEntry(profileIndex);
+        if (profileEntry !== null) { profileEntry.setName(value); }
+
+        await this._settingsController.setGlobalSetting(`profiles[${profileIndex}].name`, value);
+    }
+
+    async setDefaultProfile(profileIndex) {
+        const profile = this._getProfile(profileIndex);
+        if (profile === null) { return; }
+
+        this._profileActiveSelect.value = `${profileIndex}`;
+        this._profileCurrent = profileIndex;
+
+        const profileEntry = this._getProfileEntry(profileIndex);
+        if (profileEntry !== null) { profileEntry.setIsDefault(true); }
+
+        await this._settingsController.setGlobalSetting('profileCurrent', profileIndex);
+    }
+
+    async copyProfile(sourceProfileIndex, destinationProfileIndex) {
+        const sourceProfile = this._getProfile(sourceProfileIndex);
+        if (sourceProfile === null || !this._getProfile(destinationProfileIndex)) { return; }
+
+        const options = clone(sourceProfile.options);
+        this._profiles[destinationProfileIndex].options = options;
+
+        this._updateProfileSelectOptions();
+
+        const destinationProfileEntry = this._getProfileEntry(destinationProfileIndex);
+        if (destinationProfileEntry !== null) {
+            destinationProfileEntry.updateState();
+        }
+
+        await this._settingsController.modifyGlobalSettings([{
+            action: 'set',
+            path: `profiles[${destinationProfileIndex}].options`,
+            value: options
+        }]);
+
+        await this._settingsController.refresh();
+    }
+
+    async duplicateProfile(profileIndex) {
+        const profile = this._getProfile(profileIndex);
+        if (this.profile === null) { return; }
+
+        // Create new profile
+        const newProfile = clone(profile);
+        newProfile.name = this._createCopyName(profile.name, this._profiles, 100);
+
+        // Update state
+        const index = this._profiles.length;
+        this._profiles.push(newProfile);
+        if (this._profileEntriesSupported) {
+            this._addProfileEntry(index);
+        }
+        this._updateProfileSelectOptions();
+
+        // Modify settings
+        await this._settingsController.modifyGlobalSettings([{
+            action: 'splice',
+            path: 'profiles',
+            start: index,
+            deleteCount: 0,
+            items: [newProfile]
+        }]);
+
+        // Update profile index
+        this._settingsController.profileIndex = index;
+    }
+
+    async deleteProfile(profileIndex) {
+        const profile = this._getProfile(profileIndex);
+        if (profile === null || this.profileCount <= 1) { return; }
+
+        // Get indices
+        let profileCurrentNew = this._profileCurrent;
+        const settingsProfileIndex = this._settingsController.profileIndex;
+
+        // Construct settings modifications
+        const modifications = [{
+            action: 'splice',
+            path: 'profiles',
+            start: profileIndex,
+            deleteCount: 1,
+            items: []
+        }];
+        if (profileCurrentNew >= profileIndex) {
+            profileCurrentNew = Math.min(profileCurrentNew - 1, this._profiles.length - 1);
+            modifications.push({
+                action: 'set',
+                path: 'profileCurrent',
+                value: profileCurrentNew
+            });
+        }
+
+        // Update state
+        this._profileCurrent = profileCurrentNew;
+
+        this._profiles.splice(profileIndex, 1);
+
+        if (profileIndex < this._profileEntryList.length) {
+            const profileEntry = this._profileEntryList[profileIndex];
+            profileEntry.cleanup();
+            this._profileEntryList.splice(profileIndex, 1);
+
+            for (let i = profileIndex, ii = this._profileEntryList.length; i < ii; ++i) {
+                this._profileEntryList[i].index = i;
+            }
+        }
+
+        const profileEntry2 = this._getProfileEntry(profileCurrentNew);
+        if (profileEntry2 !== null) {
+            profileEntry2.setIsDefault(true);
+        }
+
+        this._updateProfileSelectOptions();
+
+        // Modify settings
+        await this._settingsController.modifyGlobalSettings(modifications);
+
+        // Update profile index
+        if (settingsProfileIndex === profileIndex) {
+            this._settingsController.profileIndex = profileCurrentNew;
+        }
+    }
+
+    async swapProfiles(index1, index2) {
+        const profile1 = this._getProfile(index1);
+        const profile2 = this._getProfile(index2);
+        if (profile1 === null || profile2 === null || index1 === index2) { return; }
+
+        // Get swapped indices
+        const profileCurrent = this._profileCurrent;
+        const profileCurrentNew = this._getSwappedValue(profileCurrent, index1, index2);
+
+        const settingsProfileIndex = this._settingsController.profileIndex;
+        const settingsProfileIndexNew = this._getSwappedValue(settingsProfileIndex, index1, index2);
+
+        // Construct settings modifications
+        const modifications = [{
+            action: 'swap',
+            path1: `profiles[${index1}]`,
+            path2: `profiles[${index2}]`
+        }];
+        if (profileCurrentNew !== profileCurrent) {
+            modifications.push({
+                action: 'set',
+                path: 'profileCurrent',
+                value: profileCurrentNew
+            });
+        }
+
+        // Update state
+        this._profileCurrent = profileCurrentNew;
+
+        this._profiles[index1] = profile2;
+        this._profiles[index2] = profile1;
+
+        const entry1 = this._getProfileEntry(index1);
+        const entry2 = this._getProfileEntry(index2);
+        if (entry1 !== null && entry2 !== null) {
+            entry1.index = index2;
+            entry2.index = index1;
+            this._swapDomNodes(entry1.node, entry2.node);
+            this._profileEntryList[index1] = entry2;
+            this._profileEntryList[index2] = entry1;
+        }
+
+        this._updateProfileSelectOptions();
+
+        // Modify settings
+        await this._settingsController.modifyGlobalSettings(modifications);
+
+        // Update profile index
+        if (settingsProfileIndex !== settingsProfileIndexNew) {
+            this._settingsController.profileIndex = settingsProfileIndexNew;
+        }
+    }
+
+    openDeleteProfileModal(profileIndex) {
+        const profile = this._getProfile(profileIndex);
+        if (profile === null || this.profileCount <= 1) { return; }
+
+        this._removeProfileNameElement.textContent = profile.name;
+        this._profileRemoveModal.node.dataset.profileIndex = `${profileIndex}`;
+        this._profileRemoveModal.setVisible(true);
+    }
+
+    openCopyProfileModal(profileIndex) {
+        const profile = this._getProfile(profileIndex);
+        if (profile === null || this.profileCount <= 1) { return; }
+
+        let copyFromIndex = this._profileCurrent;
+        if (copyFromIndex === profileIndex) {
+            if (profileIndex !== 0) {
+                copyFromIndex = 0;
+            } else if (this.profileCount > 1) {
+                copyFromIndex = 1;
+            }
+        }
+
+        const profileIndexString = `${profileIndex}`;
+        for (const option of this._profileCopySourceSelect.querySelectorAll('option')) {
+            const {value} = option;
+            option.disabled = (value === profileIndexString);
+        }
+        this._profileCopySourceSelect.value = `${copyFromIndex}`;
+
+        this._profileCopyModal.node.dataset.profileIndex = `${profileIndex}`;
+        this._profileCopyModal.setVisible(true);
+    }
+
+    openProfileConditionsModal(profileIndex) {
+        const profile = this._getProfile(profileIndex);
+        if (profile === null) { return; }
+
+        if (this._profileConditionsModal === null) { return; }
+        this._profileConditionsModal.setVisible(true);
+
+        this._profileConditionsUI.cleanup();
+        this._profileConditionsIndex = profileIndex;
+        this._profileConditionsUI.prepare(profileIndex);
+        if (this._profileConditionsProfileName !== null) {
+            this._profileConditionsProfileName.textContent = profile.name;
+        }
+    }
+
+    // Private
+
+    async _onOptionsChanged() {
+        // Update state
+        const {profiles, profileCurrent} = await this._settingsController.getOptionsFull();
+        this._profiles = profiles;
+        this._profileCurrent = profileCurrent;
+
+        const settingsProfileIndex = this._settingsController.profileIndex;
+        const settingsProfile = this._getProfile(settingsProfileIndex);
+
+        // Udpate UI
+        this._updateProfileSelectOptions();
+
+        this._profileActiveSelect.value = `${profileCurrent}`;
+        this._profileTargetSelect.value = `${settingsProfileIndex}`;
+
+        if (this._profileRemoveButton !== null) { this._profileRemoveButton.disabled = (profiles.length <= 1); }
+        if (this._profileCopyButton !== null) { this._profileCopyButton.disabled = (profiles.length <= 1); }
+        if (this._profileMoveUpButton !== null) { this._profileMoveUpButton.disabled = (settingsProfileIndex <= 0); }
+        if (this._profileMoveDownButton !== null) { this._profileMoveDownButton.disabled = (settingsProfileIndex >= profiles.length - 1); }
+
+        if (this._profileNameInput !== null && settingsProfile !== null) { this._profileNameInput.value = settingsProfile.name; }
+
+        // Update profile conditions
+        this._profileConditionsUI.cleanup();
+        const conditionsProfile = this._getProfile(this._profileConditionsIndex !== null ? this._profileConditionsIndex : settingsProfileIndex);
+        if (conditionsProfile !== null) {
+            this._profileConditionsUI.prepare(settingsProfileIndex);
+        }
+
+        // Udpate profile entries
+        for (const entry of this._profileEntryList) {
+            entry.cleanup();
+        }
+        this._profileEntryList = [];
+        if (this._profileEntriesSupported) {
+            for (let i = 0, ii = profiles.length; i < ii; ++i) {
+                this._addProfileEntry(i);
+            }
+        }
+    }
+
+    _onProfileActiveChange(e) {
+        const value = this._tryGetValidProfileIndex(e.currentTarget.value);
+        if (value === null) { return; }
+        this.setDefaultProfile(value);
+    }
+
+    _onProfileTargetChange(e) {
+        const value = this._tryGetValidProfileIndex(e.currentTarget.value);
+        if (value === null) { return; }
+        this._settingsController.profileIndex = value;
+    }
+
+    _onNameChanged(e) {
+        this.setProfileName(this._settingsController.profileIndex, e.currentTarget.value);
+    }
+
+    _onAdd() {
+        this.duplicateProfile(this._settingsController.profileIndex);
+    }
+
+    _onDelete(e) {
+        const profileIndex = this._settingsController.profileIndex;
+        if (e.shiftKey) {
+            this.deleteProfile(profileIndex);
+        } else {
+            this.openDeleteProfileModal(profileIndex);
+        }
+    }
+
+    _onDeleteConfirm() {
+        const modal = this._profileRemoveModal;
+        modal.setVisible(false);
+        const {node} = modal;
+        let profileIndex = node.dataset.profileIndex;
+        delete node.dataset.profileIndex;
+
+        profileIndex = this._tryGetValidProfileIndex(profileIndex);
+        if (profileIndex === null) { return; }
+
+        this.deleteProfile(profileIndex);
+    }
+
+    _onCopy() {
+        this.openCopyProfileModal(this._settingsController.profileIndex);
+    }
+
+    _onCopyConfirm() {
+        const modal = this._profileCopyModal;
+        modal.setVisible(false);
+        const {node} = modal;
+        let destinationProfileIndex = node.dataset.profileIndex;
+        delete node.dataset.profileIndex;
+
+        destinationProfileIndex = this._tryGetValidProfileIndex(destinationProfileIndex);
+        if (destinationProfileIndex === null) { return; }
+
+        const sourceProfileIndex = this._tryGetValidProfileIndex(this._profileCopySourceSelect.value);
+        if (sourceProfileIndex === null) { return; }
+
+        this.copyProfile(sourceProfileIndex, destinationProfileIndex);
+    }
+
+    _onMove(offset) {
+        this.moveProfile(this._settingsController.profileIndex, offset);
+    }
+
+    _onConditionGroupCountChanged({count, profileIndex}) {
+        if (profileIndex >= 0 && profileIndex < this._profileEntryList.length) {
+            const profileEntry = this._profileEntryList[profileIndex];
+            profileEntry.setConditionGroupsCount(count);
+        }
+    }
+
+    _addProfileEntry(profileIndex) {
+        const profile = this._profiles[profileIndex];
+        const node = this._settingsController.instantiateTemplate('profile-entry');
+        const entry = new ProfileEntry(this, node);
+        this._profileEntryList.push(entry);
+        entry.prepare(profile, profileIndex);
+        this._profileEntryListContainer.appendChild(node);
+    }
+
+    _updateProfileSelectOptions() {
+        for (const select of this._getAllProfileSelects()) {
+            const fragment = document.createDocumentFragment();
+            for (let i = 0; i < this._profiles.length; ++i) {
+                const profile = this._profiles[i];
+                const option = document.createElement('option');
+                option.value = `${i}`;
+                option.textContent = profile.name;
+                fragment.appendChild(option);
+            }
+            select.textContent = '';
+            select.appendChild(fragment);
+        }
+    }
+
+    _updateSelectName(index, name) {
+        const optionValue = `${index}`;
+        for (const select of this._getAllProfileSelects()) {
+            for (const option of select.querySelectorAll('option')) {
+                if (option.value === optionValue) {
+                    option.textContent = name;
+                }
+            }
+        }
+    }
+
+    _getAllProfileSelects() {
+        return [
+            this._profileActiveSelect,
+            this._profileTargetSelect,
+            this._profileCopySourceSelect
+        ];
+    }
+
+    _tryGetValidProfileIndex(stringValue) {
+        if (typeof stringValue !== 'string') { return null; }
+        const intValue = parseInt(stringValue, 10);
+        return (
+            Number.isFinite(intValue) &&
+            intValue >= 0 &&
+            intValue < this.profileCount ?
+            intValue : null
+        );
+    }
+
+    _createCopyName(name, profiles, maxUniqueAttempts) {
+        let space, index, prefix, suffix;
+        const match = /^([\w\W]*\(Copy)((\s+)(\d+))?(\)\s*)$/.exec(name);
+        if (match === null) {
+            prefix = `${name} (Copy`;
+            space = '';
+            index = '';
+            suffix = ')';
+        } else {
+            prefix = match[1];
+            suffix = match[5];
+            if (typeof match[2] === 'string') {
+                space = match[3];
+                index = parseInt(match[4], 10) + 1;
+            } else {
+                space = ' ';
+                index = 2;
+            }
+        }
+
+        let i = 0;
+        while (true) {
+            const newName = `${prefix}${space}${index}${suffix}`;
+            if (i++ >= maxUniqueAttempts || profiles.findIndex((profile) => profile.name === newName) < 0) {
+                return newName;
+            }
+            if (typeof index !== 'number') {
+                index = 2;
+                space = ' ';
+            } else {
+                ++index;
+            }
+        }
+    }
+
+    _getSwappedValue(currentValue, value1, value2) {
+        if (currentValue === value1) { return value2; }
+        if (currentValue === value2) { return value1; }
+        return currentValue;
+    }
+
+    _getProfile(profileIndex) {
+        return (profileIndex >= 0 && profileIndex < this._profiles.length ? this._profiles[profileIndex] : null);
+    }
+
+    _getProfileEntry(profileIndex) {
+        return (profileIndex >= 0 && profileIndex < this._profileEntryList.length ? this._profileEntryList[profileIndex] : null);
+    }
+
+    _swapDomNodes(node1, node2) {
+        const parent1 = node1.parentNode;
+        const parent2 = node2.parentNode;
+        const next1 = node1.nextSibling;
+        const next2 = node2.nextSibling;
+        if (node2 !== next1) { parent1.insertBefore(node2, next1); }
+        if (node1 !== next2) { parent2.insertBefore(node1, next2); }
+    }
+}
+
+class ProfileEntry {
+    constructor(profileController, node) {
+        this._profileController = profileController;
+        this._node = node;
+        this._profile = null;
+        this._index = 0;
+        this._isDefaultRadio = null;
+        this._nameInput = null;
+        this._countLink = null;
+        this._countText = null;
+        this._menuButton = null;
+        this._eventListeners = new EventListenerCollection();
+    }
+
+    get index() {
+        return this._index;
+    }
+
+    set index(value) {
+        this._index = value;
+    }
+
+    get node() {
+        return this._node;
+    }
+
+    prepare(profile, index) {
+        this._profile = profile;
+        this._index = index;
+
+        const node = this._node;
+        this._isDefaultRadio = node.querySelector('.profile-entry-is-default-radio');
+        this._nameInput = node.querySelector('.profile-entry-name-input');
+        this._countLink = node.querySelector('.profile-entry-condition-count-link');
+        this._countText = node.querySelector('.profile-entry-condition-count');
+        this._menuButton = node.querySelector('.profile-entry-menu-button');
+
+        this.updateState();
+
+        this._eventListeners.addEventListener(this._isDefaultRadio, 'change', this._onIsDefaultRadioChange.bind(this), false);
+        this._eventListeners.addEventListener(this._nameInput, 'input', this._onNameInputInput.bind(this), false);
+        this._eventListeners.addEventListener(this._countLink, 'click', this._onConditionsCountLinkClick.bind(this), false);
+        this._eventListeners.addEventListener(this._menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
+        this._eventListeners.addEventListener(this._menuButton, 'menuClose', this._onMenuClose.bind(this), false);
+    }
+
+    cleanup() {
+        this._eventListeners.removeAllEventListeners();
+        if (this._node.parentNode !== null) {
+            this._node.parentNode.removeChild(this._node);
+        }
+    }
+
+    setName(value) {
+        if (this._nameInput.value === value) { return; }
+        this._nameInput.value = value;
+    }
+
+    setIsDefault(value) {
+        this._isDefaultRadio.checked = value;
+    }
+
+    updateState() {
+        this._nameInput.value = this._profile.name;
+        this._countText.textContent = `${this._profile.conditionGroups.length}`;
+        this._isDefaultRadio.checked = (this._index === this._profileController.profileCurrentIndex);
+    }
+
+    setConditionGroupsCount(count) {
+        this._countText.textContent = `${count}`;
+    }
+
+    // Private
+
+    _onIsDefaultRadioChange(e) {
+        if (!e.currentTarget.checked) { return; }
+        this._profileController.setDefaultProfile(this._index);
+    }
+
+    _onNameInputInput(e) {
+        const name = e.currentTarget.value;
+        this._profileController.setProfileName(this._index, name);
+    }
+
+    _onConditionsCountLinkClick() {
+        this._profileController.openProfileConditionsModal(this._index);
+    }
+
+    _onMenuOpen(e) {
+        const bodyNode = e.detail.menu.bodyNode;
+        const count = this._profileController.profileCount;
+        this._setMenuActionEnabled(bodyNode, 'moveUp', this._index > 0);
+        this._setMenuActionEnabled(bodyNode, 'moveDown', this._index < count - 1);
+        this._setMenuActionEnabled(bodyNode, 'copyFrom', count > 1);
+        this._setMenuActionEnabled(bodyNode, 'delete', count > 1);
+    }
+
+    _onMenuClose(e) {
+        switch (e.detail.action) {
+            case 'moveUp':
+                this._profileController.moveProfile(this._index, -1);
+                break;
+            case 'moveDown':
+                this._profileController.moveProfile(this._index, 1);
+                break;
+            case 'copyFrom':
+                this._profileController.openCopyProfileModal(this._index);
+                break;
+            case 'editConditions':
+                this._profileController.openProfileConditionsModal(this._index);
+                break;
+            case 'duplicate':
+                this._profileController.duplicateProfile(this._index);
+                break;
+            case 'delete':
+                this._profileController.openDeleteProfileModal(this._index);
+                break;
+        }
+    }
+
+    _setMenuActionEnabled(menu, action, enabled) {
+        const element = menu.querySelector(`[data-menu-action="${action}"]`);
+        if (element === null) { return; }
+        element.disabled = !enabled;
+    }
+}
diff --git a/ext/js/settings/scan-inputs-controller.js b/ext/js/settings/scan-inputs-controller.js
new file mode 100644
index 00000000..eb179c6a
--- /dev/null
+++ b/ext/js/settings/scan-inputs-controller.js
@@ -0,0 +1,310 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * KeyboardMouseInputField
+ * api
+ */
+
+class ScanInputsController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._os = null;
+        this._container = null;
+        this._addButton = null;
+        this._scanningInputCountNodes = null;
+        this._entries = [];
+    }
+
+    async prepare() {
+        const {platform: {os}} = await api.getEnvironmentInfo();
+        this._os = os;
+
+        this._container = document.querySelector('#scan-input-list');
+        this._addButton = document.querySelector('#scan-input-add');
+        this._scanningInputCountNodes = document.querySelectorAll('.scanning-input-count');
+
+        this._addButton.addEventListener('click', this._onAddButtonClick.bind(this), false);
+        this._settingsController.on('scanInputsChanged', this._onScanInputsChanged.bind(this));
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+
+        this.refresh();
+    }
+
+    removeInput(index) {
+        if (index < 0 || index >= this._entries.length) { return false; }
+        const input = this._entries[index];
+        input.cleanup();
+        this._entries.splice(index, 1);
+        for (let i = index, ii = this._entries.length; i < ii; ++i) {
+            this._entries[i].index = i;
+        }
+        this._updateCounts();
+        this._modifyProfileSettings([{
+            action: 'splice',
+            path: 'scanning.inputs',
+            start: index,
+            deleteCount: 1,
+            items: []
+        }]);
+        return true;
+    }
+
+    async setProperty(index, property, value, event) {
+        const path = `scanning.inputs[${index}].${property}`;
+        await this._settingsController.setProfileSetting(path, value);
+        if (event) {
+            this._triggerScanInputsChanged();
+        }
+    }
+
+    instantiateTemplate(name) {
+        return this._settingsController.instantiateTemplate(name);
+    }
+
+    async refresh() {
+        const options = await this._settingsController.getOptions();
+        this._onOptionsChanged({options});
+    }
+
+    // Private
+
+    _onScanInputsChanged({source}) {
+        if (source === this) { return; }
+        this.refresh();
+    }
+
+    _onOptionsChanged({options}) {
+        const {inputs} = options.scanning;
+
+        for (let i = this._entries.length - 1; i >= 0; --i) {
+            this._entries[i].cleanup();
+        }
+        this._entries.length = 0;
+
+        for (let i = 0, ii = inputs.length; i < ii; ++i) {
+            this._addOption(i, inputs[i]);
+        }
+
+        this._updateCounts();
+    }
+
+    _onAddButtonClick(e) {
+        e.preventDefault();
+
+        const index = this._entries.length;
+        const scanningInput = ScanInputsController.createDefaultMouseInput('', '');
+        this._addOption(index, scanningInput);
+        this._updateCounts();
+        this._modifyProfileSettings([{
+            action: 'splice',
+            path: 'scanning.inputs',
+            start: index,
+            deleteCount: 0,
+            items: [scanningInput]
+        }]);
+    }
+
+    _addOption(index, scanningInput) {
+        const field = new ScanInputField(this, index, this._os);
+        this._entries.push(field);
+        field.prepare(this._container, scanningInput);
+    }
+
+    _updateCounts() {
+        const stringValue = `${this._entries.length}`;
+        for (const node of this._scanningInputCountNodes) {
+            node.textContent = stringValue;
+        }
+    }
+
+    async _modifyProfileSettings(targets) {
+        await this._settingsController.modifyProfileSettings(targets);
+        this._triggerScanInputsChanged();
+    }
+
+    _triggerScanInputsChanged() {
+        this._settingsController.trigger('scanInputsChanged', {source: this});
+    }
+
+    static createDefaultMouseInput(include, exclude) {
+        return {
+            include,
+            exclude,
+            types: {mouse: true, touch: false, pen: false},
+            options: {
+                showAdvanced: false,
+                searchTerms: true,
+                searchKanji: true,
+                scanOnTouchMove: true,
+                scanOnPenHover: true,
+                scanOnPenPress: true,
+                scanOnPenRelease: false,
+                preventTouchScrolling: true
+            }
+        };
+    }
+}
+
+class ScanInputField {
+    constructor(parent, index, os) {
+        this._parent = parent;
+        this._index = index;
+        this._os = os;
+        this._node = null;
+        this._includeInputField = null;
+        this._excludeInputField = null;
+        this._eventListeners = new EventListenerCollection();
+    }
+
+    get index() {
+        return this._index;
+    }
+
+    set index(value) {
+        this._index = value;
+        this._updateDataSettingTargets();
+    }
+
+    prepare(container, scanningInput) {
+        const {include, exclude, options: {showAdvanced}} = scanningInput;
+
+        const node = this._parent.instantiateTemplate('scan-input');
+        const includeInputNode = node.querySelector('.scan-input-field[data-property=include]');
+        const includeMouseButton = node.querySelector('.mouse-button[data-property=include]');
+        const excludeInputNode = node.querySelector('.scan-input-field[data-property=exclude]');
+        const excludeMouseButton = node.querySelector('.mouse-button[data-property=exclude]');
+        const removeButton = node.querySelector('.scan-input-remove');
+        const menuButton = node.querySelector('.scanning-input-menu-button');
+
+        node.dataset.showAdvanced = `${showAdvanced}`;
+
+        this._node = node;
+        container.appendChild(node);
+
+        const isPointerTypeSupported = this._isPointerTypeSupported.bind(this);
+        this._includeInputField = new KeyboardMouseInputField(includeInputNode, includeMouseButton, this._os, isPointerTypeSupported);
+        this._excludeInputField = new KeyboardMouseInputField(excludeInputNode, excludeMouseButton, this._os, isPointerTypeSupported);
+        this._includeInputField.prepare(null, this._splitModifiers(include), true, false);
+        this._excludeInputField.prepare(null, this._splitModifiers(exclude), true, false);
+
+        this._eventListeners.on(this._includeInputField, 'change', this._onIncludeValueChange.bind(this));
+        this._eventListeners.on(this._excludeInputField, 'change', this._onExcludeValueChange.bind(this));
+        if (removeButton !== null) {
+            this._eventListeners.addEventListener(removeButton, 'click', this._onRemoveClick.bind(this));
+        }
+        if (menuButton !== null) {
+            this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this));
+            this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this));
+        }
+
+        this._updateDataSettingTargets();
+    }
+
+    cleanup() {
+        this._eventListeners.removeAllEventListeners();
+        if (this._includeInputField !== null) {
+            this._includeInputField.cleanup();
+            this._includeInputField = null;
+        }
+        if (this._node !== null) {
+            const parent = this._node.parentNode;
+            if (parent !== null) { parent.removeChild(this._node); }
+            this._node = null;
+        }
+    }
+
+    // Private
+
+    _onIncludeValueChange({modifiers}) {
+        modifiers = this._joinModifiers(modifiers);
+        this._parent.setProperty(this._index, 'include', modifiers, true);
+    }
+
+    _onExcludeValueChange({modifiers}) {
+        modifiers = this._joinModifiers(modifiers);
+        this._parent.setProperty(this._index, 'exclude', modifiers, true);
+    }
+
+    _onRemoveClick(e) {
+        e.preventDefault();
+        this._removeSelf();
+    }
+
+    _onMenuOpen(e) {
+        const bodyNode = e.detail.menu.bodyNode;
+        const showAdvanced = bodyNode.querySelector('.popup-menu-item[data-menu-action="showAdvanced"]');
+        const hideAdvanced = bodyNode.querySelector('.popup-menu-item[data-menu-action="hideAdvanced"]');
+        const advancedVisible = (this._node.dataset.showAdvanced === 'true');
+        if (showAdvanced !== null) {
+            showAdvanced.hidden = advancedVisible;
+        }
+        if (hideAdvanced !== null) {
+            hideAdvanced.hidden = !advancedVisible;
+        }
+    }
+
+    _onMenuClose(e) {
+        switch (e.detail.action) {
+            case 'remove':
+                this._removeSelf();
+                break;
+            case 'showAdvanced':
+                this._setAdvancedOptionsVisible(true);
+                break;
+            case 'hideAdvanced':
+                this._setAdvancedOptionsVisible(false);
+                break;
+            case 'clearInputs':
+                this._includeInputField.clearInputs();
+                this._excludeInputField.clearInputs();
+                break;
+        }
+    }
+
+    _isPointerTypeSupported(pointerType) {
+        if (this._node === null) { return false; }
+        const node = this._node.querySelector(`input.scan-input-settings-checkbox[data-property="types.${pointerType}"]`);
+        return node !== null && node.checked;
+    }
+
+    _updateDataSettingTargets() {
+        const index = this._index;
+        for (const typeCheckbox of this._node.querySelectorAll('.scan-input-settings-checkbox')) {
+            const {property} = typeCheckbox.dataset;
+            typeCheckbox.dataset.setting = `scanning.inputs[${index}].${property}`;
+        }
+    }
+
+    _removeSelf() {
+        this._parent.removeInput(this._index);
+    }
+
+    _setAdvancedOptionsVisible(showAdvanced) {
+        showAdvanced = !!showAdvanced;
+        this._node.dataset.showAdvanced = `${showAdvanced}`;
+        this._parent.setProperty(this._index, 'options.showAdvanced', showAdvanced, false);
+    }
+
+    _splitModifiers(modifiersString) {
+        return modifiersString.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
+    }
+
+    _joinModifiers(modifiersArray) {
+        return modifiersArray.join(', ');
+    }
+}
diff --git a/ext/js/settings/scan-inputs-simple-controller.js b/ext/js/settings/scan-inputs-simple-controller.js
new file mode 100644
index 00000000..01f044c2
--- /dev/null
+++ b/ext/js/settings/scan-inputs-simple-controller.js
@@ -0,0 +1,247 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * HotkeyUtil
+ * ScanInputsController
+ * api
+ */
+
+class ScanInputsSimpleController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._middleMouseButtonScan = null;
+        this._mainScanModifierKeyInput = null;
+        this._mainScanModifierKeyInputHasOther = false;
+        this._hotkeyUtil = new HotkeyUtil();
+    }
+
+    async prepare() {
+        this._middleMouseButtonScan = document.querySelector('#middle-mouse-button-scan');
+        this._mainScanModifierKeyInput = document.querySelector('#main-scan-modifier-key');
+
+        const {platform: {os}} = await api.getEnvironmentInfo();
+        this._hotkeyUtil.os = os;
+
+        this._mainScanModifierKeyInputHasOther = false;
+        this._populateSelect(this._mainScanModifierKeyInput, this._mainScanModifierKeyInputHasOther);
+
+        const options = await this._settingsController.getOptions();
+
+        this._middleMouseButtonScan.addEventListener('change', this.onMiddleMouseButtonScanChange.bind(this), false);
+        this._mainScanModifierKeyInput.addEventListener('change', this._onMainScanModifierKeyInputChange.bind(this), false);
+
+        this._settingsController.on('scanInputsChanged', this._onScanInputsChanged.bind(this));
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+        this._onOptionsChanged({options});
+    }
+
+    async refresh() {
+        const options = await this._settingsController.getOptions();
+        this._onOptionsChanged({options});
+    }
+
+    // Private
+
+    _onScanInputsChanged({source}) {
+        if (source === this) { return; }
+        this.refresh();
+    }
+
+    _onOptionsChanged({options}) {
+        const {scanning: {inputs}} = options;
+        const middleMouseSupportedIndex = this._getIndexOfMiddleMouseButtonScanInput(inputs);
+        const mainScanInputIndex = this._getIndexOfMainScanInput(inputs);
+        const hasMainScanInput = (mainScanInputIndex >= 0);
+
+        let middleMouseSupported = false;
+        if (middleMouseSupportedIndex >= 0) {
+            const includeValues = this._splitValue(inputs[middleMouseSupportedIndex].include);
+            if (includeValues.includes('mouse2')) {
+                middleMouseSupported = true;
+            }
+        }
+
+        let mainScanInput = 'none';
+        if (hasMainScanInput) {
+            const includeValues = this._splitValue(inputs[mainScanInputIndex].include);
+            if (includeValues.length > 0) {
+                mainScanInput = includeValues[0];
+            }
+        } else {
+            mainScanInput = 'other';
+        }
+
+        this._setHasMainScanInput(hasMainScanInput);
+
+        this._middleMouseButtonScan.checked = middleMouseSupported;
+        this._mainScanModifierKeyInput.value = mainScanInput;
+    }
+
+    onMiddleMouseButtonScanChange(e) {
+        const middleMouseSupported = e.currentTarget.checked;
+        this._setMiddleMouseSuppported(middleMouseSupported);
+    }
+
+    _onMainScanModifierKeyInputChange(e) {
+        const mainScanKey = e.currentTarget.value;
+        if (mainScanKey === 'other') { return; }
+        const mainScanInputs = (mainScanKey === 'none' ? [] : [mainScanKey]);
+        this._setMainScanInputs(mainScanInputs);
+    }
+
+    _populateSelect(select, hasOther) {
+        const modifierKeys = [
+            {value: 'none', name: 'No key'}
+        ];
+        for (const value of ['alt', 'ctrl', 'shift', 'meta']) {
+            const name = this._hotkeyUtil.getModifierDisplayValue(value);
+            modifierKeys.push({value, name});
+        }
+
+        if (hasOther) {
+            modifierKeys.push({value: 'other', name: 'Other'});
+        }
+
+        const fragment = document.createDocumentFragment();
+        for (const {value, name} of modifierKeys) {
+            const option = document.createElement('option');
+            option.value = value;
+            option.textContent = name;
+            fragment.appendChild(option);
+        }
+        select.textContent = '';
+        select.appendChild(fragment);
+    }
+
+    _splitValue(value) {
+        return value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
+    }
+
+    async _setMiddleMouseSuppported(value) {
+        // Find target index
+        const options = await this._settingsController.getOptions();
+        const {scanning: {inputs}} = options;
+        const index = this._getIndexOfMiddleMouseButtonScanInput(inputs);
+
+        if (value) {
+            // Add new
+            if (index >= 0) { return; }
+            let insertionPosition = this._getIndexOfMainScanInput(inputs);
+            insertionPosition = (insertionPosition >= 0 ? insertionPosition + 1 : inputs.length);
+            const input = ScanInputsController.createDefaultMouseInput('mouse2', '');
+            await this._modifyProfileSettings([{
+                action: 'splice',
+                path: 'scanning.inputs',
+                start: insertionPosition,
+                deleteCount: 0,
+                items: [input]
+            }]);
+        } else {
+            // Modify existing
+            if (index < 0) { return; }
+            await this._modifyProfileSettings([{
+                action: 'splice',
+                path: 'scanning.inputs',
+                start: index,
+                deleteCount: 1,
+                items: []
+            }]);
+        }
+    }
+
+    async _setMainScanInputs(value) {
+        value = value.join(', ');
+
+        // Find target index
+        const options = await this._settingsController.getOptions();
+        const {scanning: {inputs}} = options;
+        const index = this._getIndexOfMainScanInput(inputs);
+
+        this._setHasMainScanInput(true);
+
+        if (index < 0) {
+            // Add new
+            const input = ScanInputsController.createDefaultMouseInput(value, 'mouse0');
+            await this._modifyProfileSettings([{
+                action: 'splice',
+                path: 'scanning.inputs',
+                start: inputs.length,
+                deleteCount: 0,
+                items: [input]
+            }]);
+        } else {
+            // Modify existing
+            await this._modifyProfileSettings([{
+                action: 'set',
+                path: `scanning.inputs[${index}].include`,
+                value
+            }]);
+        }
+    }
+
+    async _modifyProfileSettings(targets) {
+        await this._settingsController.modifyProfileSettings(targets);
+        this._settingsController.trigger('scanInputsChanged', {source: this});
+    }
+
+    _getIndexOfMainScanInput(inputs) {
+        for (let i = 0, ii = inputs.length; i < ii; ++i) {
+            const {include, exclude, types: {mouse}} = inputs[i];
+            if (!mouse) { continue; }
+            const includeValues = this._splitValue(include);
+            const excludeValues = this._splitValue(exclude);
+            if (
+                (
+                    includeValues.length === 0 ||
+                    (includeValues.length === 1 && !this._isMouseInput(includeValues[0]))
+                ) &&
+                excludeValues.length === 1 &&
+                excludeValues[0] === 'mouse0'
+            ) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    _getIndexOfMiddleMouseButtonScanInput(inputs) {
+        for (let i = 0, ii = inputs.length; i < ii; ++i) {
+            const {include, exclude, types: {mouse}} = inputs[i];
+            if (!mouse) { continue; }
+            const includeValues = this._splitValue(include);
+            const excludeValues = this._splitValue(exclude);
+            if (
+                (includeValues.length === 1 && includeValues[0] === 'mouse2') &&
+                excludeValues.length === 0
+            ) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    _isMouseInput(input) {
+        return /^mouse\d+$/.test(input);
+    }
+
+    _setHasMainScanInput(hasMainScanInput) {
+        if (this._mainScanModifierKeyInputHasOther !== hasMainScanInput) { return; }
+        this._mainScanModifierKeyInputHasOther = !hasMainScanInput;
+        this._populateSelect(this._mainScanModifierKeyInput, this._mainScanModifierKeyInputHasOther);
+    }
+}
\ No newline at end of file
diff --git a/ext/js/settings/secondary-search-dictionary-controller.js b/ext/js/settings/secondary-search-dictionary-controller.js
new file mode 100644
index 00000000..2fb3de67
--- /dev/null
+++ b/ext/js/settings/secondary-search-dictionary-controller.js
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * ObjectPropertyAccessor
+ */
+
+class SecondarySearchDictionaryController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._getDictionaryInfoToken = null;
+        this._container = null;
+        this._eventListeners = new EventListenerCollection();
+    }
+
+    async prepare() {
+        this._container = document.querySelector('#secondary-search-dictionary-list');
+
+        yomichan.on('databaseUpdated', this._onDatabaseUpdated.bind(this));
+
+        await this._onDatabaseUpdated();
+    }
+
+    // Private
+
+    async _onDatabaseUpdated() {
+        this._eventListeners.removeAllEventListeners();
+
+        const token = {};
+        this._getDictionaryInfoToken = token;
+        const dictionaries = await this._settingsController.getDictionaryInfo();
+        if (this._getDictionaryInfoToken !== token) { return; }
+        this._getDictionaryInfoToken = null;
+
+        const fragment = document.createDocumentFragment();
+        for (const {title, revision} of dictionaries) {
+            const node = this._settingsController.instantiateTemplate('secondary-search-dictionary');
+            fragment.appendChild(node);
+
+            const nameNode = node.querySelector('.dictionary-title');
+            nameNode.textContent = title;
+
+            const versionNode = node.querySelector('.dictionary-version');
+            versionNode.textContent = `rev.${revision}`;
+
+            const toggle = node.querySelector('.dictionary-allow-secondary-searches');
+            toggle.dataset.setting = ObjectPropertyAccessor.getPathString(['dictionaries', title, 'allowSecondarySearches']);
+            this._eventListeners.addEventListener(toggle, 'settingChanged', this._onEnabledChanged.bind(this, node), false);
+        }
+
+        this._container.textContent = '';
+        this._container.appendChild(fragment);
+    }
+
+    _onEnabledChanged(node, e) {
+        const {detail: {value}} = e;
+        node.dataset.enabled = `${value}`;
+    }
+}
\ No newline at end of file
diff --git a/ext/js/settings/sentence-termination-characters-controller.js b/ext/js/settings/sentence-termination-characters-controller.js
new file mode 100644
index 00000000..d62771ec
--- /dev/null
+++ b/ext/js/settings/sentence-termination-characters-controller.js
@@ -0,0 +1,243 @@
+/*
+ * Copyright (C) 2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class SentenceTerminationCharactersController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._entries = [];
+        this._addButton = null;
+        this._resetButton = null;
+        this._listTable = null;
+        this._listContainer = null;
+        this._emptyIndicator = null;
+    }
+
+    get settingsController() {
+        return this._settingsController;
+    }
+
+    async prepare() {
+        this._addButton = document.querySelector('#sentence-termination-character-list-add');
+        this._resetButton = document.querySelector('#sentence-termination-character-list-reset');
+        this._listTable = document.querySelector('#sentence-termination-character-list-table');
+        this._listContainer = document.querySelector('#sentence-termination-character-list');
+        this._emptyIndicator = document.querySelector('#sentence-termination-character-list-empty');
+
+        this._addButton.addEventListener('click', this._onAddClick.bind(this));
+        this._resetButton.addEventListener('click', this._onResetClick.bind(this));
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+
+        await this._updateOptions();
+    }
+
+    async addEntry(terminationCharacterEntry) {
+        const options = await this._settingsController.getOptions();
+        const {sentenceParsing: {terminationCharacters}} = options;
+
+        await this._settingsController.modifyProfileSettings([{
+            action: 'splice',
+            path: 'sentenceParsing.terminationCharacters',
+            start: terminationCharacters.length,
+            deleteCount: 0,
+            items: [terminationCharacterEntry]
+        }]);
+
+        await this._updateOptions();
+    }
+
+    async deleteEntry(index) {
+        const options = await this._settingsController.getOptions();
+        const {sentenceParsing: {terminationCharacters}} = options;
+
+        if (index < 0 || index >= terminationCharacters.length) { return false; }
+
+        await this._settingsController.modifyProfileSettings([{
+            action: 'splice',
+            path: 'sentenceParsing.terminationCharacters',
+            start: index,
+            deleteCount: 1,
+            items: []
+        }]);
+
+        await this._updateOptions();
+        return true;
+    }
+
+    async modifyProfileSettings(targets) {
+        return await this._settingsController.modifyProfileSettings(targets);
+    }
+
+    // Private
+
+    _onOptionsChanged({options}) {
+        for (const entry of this._entries) {
+            entry.cleanup();
+        }
+
+        this._entries = [];
+        const {sentenceParsing: {terminationCharacters}} = options;
+
+        for (let i = 0, ii = terminationCharacters.length; i < ii; ++i) {
+            const terminationCharacterEntry = terminationCharacters[i];
+            const node = this._settingsController.instantiateTemplate('sentence-termination-character-entry');
+            this._listContainer.appendChild(node);
+            const entry = new SentenceTerminationCharacterEntry(this, terminationCharacterEntry, i, node);
+            this._entries.push(entry);
+            entry.prepare();
+        }
+
+        this._listTable.hidden = (terminationCharacters.length === 0);
+        this._emptyIndicator.hidden = (terminationCharacters.length !== 0);
+    }
+
+    _onAddClick(e) {
+        e.preventDefault();
+        this._addNewEntry();
+    }
+
+    _onResetClick(e) {
+        e.preventDefault();
+        this._reset();
+    }
+
+    async _addNewEntry() {
+        const newEntry = {
+            enabled: true,
+            character1: '"',
+            character2: '"',
+            includeCharacterAtStart: false,
+            includeCharacterAtEnd: false
+        };
+        return await this.addEntry(newEntry);
+    }
+
+    async _updateOptions() {
+        const options = await this._settingsController.getOptions();
+        this._onOptionsChanged({options});
+    }
+
+    async _reset() {
+        const defaultOptions = await this._settingsController.getDefaultOptions();
+        const value = defaultOptions.profiles[0].options.sentenceParsing.terminationCharacters;
+        await this._settingsController.setProfileSetting('sentenceParsing.terminationCharacters', value);
+        await this._updateOptions();
+    }
+}
+
+class SentenceTerminationCharacterEntry {
+    constructor(parent, data, index, node) {
+        this._parent = parent;
+        this._data = data;
+        this._index = index;
+        this._node = node;
+        this._eventListeners = new EventListenerCollection();
+        this._character1Input = null;
+        this._character2Input = null;
+        this._basePath = `sentenceParsing.terminationCharacters[${this._index}]`;
+    }
+
+    prepare() {
+        const {enabled, character1, character2, includeCharacterAtStart, includeCharacterAtEnd} = this._data;
+        const node = this._node;
+
+        const enabledToggle = node.querySelector('.sentence-termination-character-enabled');
+        const typeSelect = node.querySelector('.sentence-termination-character-type');
+        const character1Input = node.querySelector('.sentence-termination-character-input1');
+        const character2Input = node.querySelector('.sentence-termination-character-input2');
+        const includeAtStartCheckbox = node.querySelector('.sentence-termination-character-include-at-start');
+        const includeAtEndheckbox = node.querySelector('.sentence-termination-character-include-at-end');
+        const menuButton = node.querySelector('.sentence-termination-character-entry-button');
+
+        this._character1Input = character1Input;
+        this._character2Input = character2Input;
+
+        const type = (character2 === null ? 'terminator' : 'quote');
+        node.dataset.type = type;
+
+        enabledToggle.checked = enabled;
+        typeSelect.value = type;
+        character1Input.value = character1;
+        character2Input.value = (character2 !== null ? character2 : '');
+        includeAtStartCheckbox.checked = includeCharacterAtStart;
+        includeAtEndheckbox.checked = includeCharacterAtEnd;
+
+        enabledToggle.dataset.setting = `${this._basePath}.enabled`;
+        includeAtStartCheckbox.dataset.setting = `${this._basePath}.includeCharacterAtStart`;
+        includeAtEndheckbox.dataset.setting = `${this._basePath}.includeCharacterAtEnd`;
+
+        this._eventListeners.addEventListener(typeSelect, 'change', this._onTypeSelectChange.bind(this), false);
+        this._eventListeners.addEventListener(character1Input, 'change', this._onCharacterChange.bind(this, 1), false);
+        this._eventListeners.addEventListener(character2Input, 'change', this._onCharacterChange.bind(this, 2), false);
+        this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
+    }
+
+    cleanup() {
+        this._eventListeners.removeAllEventListeners();
+        if (this._node.parentNode !== null) {
+            this._node.parentNode.removeChild(this._node);
+        }
+    }
+
+    // Private
+
+    _onTypeSelectChange(e) {
+        this._setHasCharacter2(e.currentTarget.value === 'quote');
+    }
+
+    _onCharacterChange(characterNumber, e) {
+        const node = e.currentTarget;
+        if (characterNumber === 2 && this._data.character2 === null) {
+            node.value = '';
+        }
+
+        const value = node.value.substring(0, 1);
+        this._setCharacterValue(node, characterNumber, value);
+    }
+
+    _onMenuClose(e) {
+        switch (e.detail.action) {
+            case 'delete':
+                this._delete();
+                break;
+        }
+    }
+
+    async _delete() {
+        this._parent.deleteEntry(this._index);
+    }
+
+    async _setHasCharacter2(has) {
+        const okay = await this._setCharacterValue(this._character2Input, 2, has ? this._data.character1 : null);
+        if (okay) {
+            const type = (!has ? 'terminator' : 'quote');
+            this._node.dataset.type = type;
+        }
+    }
+
+    async _setCharacterValue(inputNode, characterNumber, value) {
+        const pathEnd = `character${characterNumber}`;
+        const r = await this._parent.settingsController.setProfileSetting(`${this._basePath}.${pathEnd}`, value);
+        const okay = !r[0].error;
+        if (okay) {
+            this._data[pathEnd] = value;
+        } else {
+            value = this._data[pathEnd];
+        }
+        inputNode.value = (value !== null ? value : '');
+        return okay;
+    }
+}
diff --git a/ext/js/settings/settings-controller.js b/ext/js/settings/settings-controller.js
new file mode 100644
index 00000000..11a9435c
--- /dev/null
+++ b/ext/js/settings/settings-controller.js
@@ -0,0 +1,210 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * HtmlTemplateCollection
+ * OptionsUtil
+ * PermissionsUtil
+ * api
+ */
+
+class SettingsController extends EventDispatcher {
+    constructor(profileIndex=0) {
+        super();
+        this._profileIndex = profileIndex;
+        this._source = generateId(16);
+        this._pageExitPreventions = new Set();
+        this._pageExitPreventionEventListeners = new EventListenerCollection();
+        this._templates = new HtmlTemplateCollection(document);
+        this._permissionsUtil = new PermissionsUtil();
+    }
+
+    get source() {
+        return this._source;
+    }
+
+    get profileIndex() {
+        return this._profileIndex;
+    }
+
+    set profileIndex(value) {
+        if (this._profileIndex === value) { return; }
+        this._setProfileIndex(value);
+    }
+
+    get permissionsUtil() {
+        return this._permissionsUtil;
+    }
+
+    prepare() {
+        yomichan.on('optionsUpdated', this._onOptionsUpdated.bind(this));
+        chrome.permissions.onAdded.addListener(this._onPermissionsChanged.bind(this));
+        chrome.permissions.onRemoved.addListener(this._onPermissionsChanged.bind(this));
+    }
+
+    async refresh() {
+        await this._onOptionsUpdatedInternal();
+    }
+
+    async getOptions() {
+        const optionsContext = this.getOptionsContext();
+        return await api.optionsGet(optionsContext);
+    }
+
+    async getOptionsFull() {
+        return await api.optionsGetFull();
+    }
+
+    async setAllSettings(value) {
+        const profileIndex = value.profileCurrent;
+        await api.setAllSettings(value, this._source);
+        this._setProfileIndex(profileIndex);
+    }
+
+    async getSettings(targets) {
+        return await this._getSettings(targets, {});
+    }
+
+    async getGlobalSettings(targets) {
+        return await this._getSettings(targets, {scope: 'global'});
+    }
+
+    async getProfileSettings(targets) {
+        return await this._getSettings(targets, {scope: 'profile'});
+    }
+
+    async modifySettings(targets) {
+        return await this._modifySettings(targets, {});
+    }
+
+    async modifyGlobalSettings(targets) {
+        return await this._modifySettings(targets, {scope: 'global'});
+    }
+
+    async modifyProfileSettings(targets) {
+        return await this._modifySettings(targets, {scope: 'profile'});
+    }
+
+    async setGlobalSetting(path, value) {
+        return await this.modifyGlobalSettings([{action: 'set', path, value}]);
+    }
+
+    async setProfileSetting(path, value) {
+        return await this.modifyProfileSettings([{action: 'set', path, value}]);
+    }
+
+    async getDictionaryInfo() {
+        return await api.getDictionaryInfo();
+    }
+
+    getOptionsContext() {
+        return {index: this._profileIndex};
+    }
+
+    preventPageExit() {
+        const obj = {end: null};
+        obj.end = this._endPreventPageExit.bind(this, obj);
+        if (this._pageExitPreventionEventListeners.size === 0) {
+            this._pageExitPreventionEventListeners.addEventListener(window, 'beforeunload', this._onBeforeUnload.bind(this), false);
+        }
+        this._pageExitPreventions.add(obj);
+        return obj;
+    }
+
+    instantiateTemplate(name) {
+        return this._templates.instantiate(name);
+    }
+
+    instantiateTemplateFragment(name) {
+        return this._templates.instantiateFragment(name);
+    }
+
+    async getDefaultOptions() {
+        const optionsUtil = new OptionsUtil();
+        await optionsUtil.prepare();
+        const optionsFull = optionsUtil.getDefault();
+        return optionsFull;
+    }
+
+    // Private
+
+    _setProfileIndex(value) {
+        this._profileIndex = value;
+        this.trigger('optionsContextChanged');
+        this._onOptionsUpdatedInternal();
+    }
+
+    _onOptionsUpdated({source}) {
+        if (source === this._source) { return; }
+        this._onOptionsUpdatedInternal();
+    }
+
+    async _onOptionsUpdatedInternal() {
+        const optionsContext = this.getOptionsContext();
+        const options = await this.getOptions();
+        this.trigger('optionsChanged', {options, optionsContext});
+    }
+
+    _setupTargets(targets, extraFields) {
+        return targets.map((target) => {
+            target = Object.assign({}, extraFields, target);
+            if (target.scope === 'profile') {
+                target.optionsContext = this.getOptionsContext();
+            }
+            return target;
+        });
+    }
+
+    async _getSettings(targets, extraFields) {
+        targets = this._setupTargets(targets, extraFields);
+        return await api.getSettings(targets);
+    }
+
+    async _modifySettings(targets, extraFields) {
+        targets = this._setupTargets(targets, extraFields);
+        return await api.modifySettings(targets, this._source);
+    }
+
+    _onBeforeUnload(e) {
+        if (this._pageExitPreventions.size === 0) {
+            return;
+        }
+
+        e.preventDefault();
+        e.returnValue = '';
+        return '';
+    }
+
+    _endPreventPageExit(obj) {
+        this._pageExitPreventions.delete(obj);
+        if (this._pageExitPreventions.size === 0) {
+            this._pageExitPreventionEventListeners.removeAllEventListeners();
+        }
+    }
+
+    _onPermissionsChanged() {
+        this._triggerPermissionsChanged();
+    }
+
+    async _triggerPermissionsChanged() {
+        const event = 'permissionsChanged';
+        if (!this.hasListeners(event)) { return; }
+
+        const permissions = await this._permissionsUtil.getAllPermissions();
+        this.trigger(event, {permissions});
+    }
+}
diff --git a/ext/js/settings/settings-display-controller.js b/ext/js/settings/settings-display-controller.js
new file mode 100644
index 00000000..9d3e5459
--- /dev/null
+++ b/ext/js/settings/settings-display-controller.js
@@ -0,0 +1,400 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * PopupMenu
+ * SelectorObserver
+ */
+
+class SettingsDisplayController {
+    constructor(settingsController, modalController) {
+        this._settingsController = settingsController;
+        this._modalController = modalController;
+        this._contentNode = null;
+        this._menuContainer = null;
+        this._onMoreToggleClickBind = null;
+        this._onMenuButtonClickBind = null;
+    }
+
+    prepare() {
+        this._contentNode = document.querySelector('.content');
+        this._menuContainer = document.querySelector('#popup-menus');
+
+        const onFabButtonClick = this._onFabButtonClick.bind(this);
+        for (const fabButton of document.querySelectorAll('.fab-button')) {
+            fabButton.addEventListener('click', onFabButtonClick, false);
+        }
+
+        const onModalAction = this._onModalAction.bind(this);
+        for (const node of document.querySelectorAll('[data-modal-action]')) {
+            node.addEventListener('click', onModalAction, false);
+        }
+
+        const onSelectOnClickElementClick = this._onSelectOnClickElementClick.bind(this);
+        for (const node of document.querySelectorAll('[data-select-on-click]')) {
+            node.addEventListener('click', onSelectOnClickElementClick, false);
+        }
+
+        const onInputTabActionKeyDown = this._onInputTabActionKeyDown.bind(this);
+        for (const node of document.querySelectorAll('[data-tab-action]')) {
+            node.addEventListener('keydown', onInputTabActionKeyDown, false);
+        }
+
+        const onSpecialUrlLinkClick = this._onSpecialUrlLinkClick.bind(this);
+        const onSpecialUrlLinkMouseDown = this._onSpecialUrlLinkMouseDown.bind(this);
+        for (const node of document.querySelectorAll('[data-special-url]')) {
+            node.addEventListener('click', onSpecialUrlLinkClick, false);
+            node.addEventListener('auxclick', onSpecialUrlLinkClick, false);
+            node.addEventListener('mousedown', onSpecialUrlLinkMouseDown, false);
+        }
+
+        for (const node of document.querySelectorAll('.defer-load-iframe')) {
+            this._setupDeferLoadIframe(node);
+        }
+
+        this._onMoreToggleClickBind = this._onMoreToggleClick.bind(this);
+        const moreSelectorObserver = new SelectorObserver({
+            selector: '.more-toggle',
+            onAdded: this._onMoreSetup.bind(this),
+            onRemoved: this._onMoreCleanup.bind(this)
+        });
+        moreSelectorObserver.observe(document.documentElement, false);
+
+        this._onMenuButtonClickBind = this._onMenuButtonClick.bind(this);
+        const menuSelectorObserver = new SelectorObserver({
+            selector: '[data-menu]',
+            onAdded: this._onMenuSetup.bind(this),
+            onRemoved: this._onMenuCleanup.bind(this)
+        });
+        menuSelectorObserver.observe(document.documentElement, false);
+
+        window.addEventListener('keydown', this._onKeyDown.bind(this), false);
+        window.addEventListener('popstate', this._onPopState.bind(this), false);
+        this._updateScrollTarget();
+    }
+
+    // Private
+
+    _onMoreSetup(element) {
+        element.addEventListener('click', this._onMoreToggleClickBind, false);
+        return null;
+    }
+
+    _onMoreCleanup(element) {
+        element.removeEventListener('click', this._onMoreToggleClickBind, false);
+    }
+
+    _onMenuSetup(element) {
+        element.addEventListener('click', this._onMenuButtonClickBind, false);
+        return null;
+    }
+
+    _onMenuCleanup(element) {
+        element.removeEventListener('click', this._onMenuButtonClickBind, false);
+    }
+
+    _onMenuButtonClick(e) {
+        const element = e.currentTarget;
+        const {menu} = element.dataset;
+        this._showMenu(element, menu);
+    }
+
+    _onFabButtonClick(e) {
+        const action = e.currentTarget.dataset.action;
+        switch (action) {
+            case 'toggle-sidebar':
+                document.body.classList.toggle('sidebar-visible');
+                break;
+            case 'toggle-preview-sidebar':
+                document.body.classList.toggle('preview-sidebar-visible');
+                break;
+        }
+    }
+
+    _onMoreToggleClick(e) {
+        const container = this._getMoreContainer(e.currentTarget);
+        if (container === null) { return; }
+
+        const more = container.querySelector('.more');
+        if (more === null) { return; }
+
+        const moreVisible = more.hidden;
+        more.hidden = !moreVisible;
+        for (const moreToggle of container.querySelectorAll('.more-toggle')) {
+            const container2 = this._getMoreContainer(moreToggle);
+            if (container2 === null) { continue; }
+
+            const more2 = container2.querySelector('.more');
+            if (more2 === null || more2 !== more) { continue; }
+
+            moreToggle.dataset.expanded = `${moreVisible}`;
+        }
+
+        e.preventDefault();
+        return false;
+    }
+
+    _onPopState() {
+        this._updateScrollTarget();
+    }
+
+    _onKeyDown(e) {
+        switch (e.code) {
+            case 'Escape':
+                if (!this._isElementAnInput(document.activeElement)) {
+                    this._closeTopMenuOrModal();
+                    e.preventDefault();
+                }
+                break;
+        }
+    }
+
+    _onModalAction(e) {
+        const node = e.currentTarget;
+        const {modalAction} = node.dataset;
+        if (typeof modalAction !== 'string') { return; }
+
+        let [action, target] = modalAction.split(',');
+        if (typeof target === 'undefined') {
+            const currentModal = node.closest('.modal');
+            if (currentModal === null) { return; }
+            target = currentModal;
+        }
+
+        const modal = this._modalController.getModal(target);
+        if (modal === null) { return; }
+
+        switch (action) {
+            case 'show':
+                modal.setVisible(true);
+                break;
+            case 'hide':
+                modal.setVisible(false);
+                break;
+            case 'toggle':
+                modal.setVisible(!modal.isVisible());
+                break;
+        }
+
+        e.preventDefault();
+    }
+
+    _onSelectOnClickElementClick(e) {
+        if (e.button !== 0) { return; }
+
+        const node = e.currentTarget;
+        const range = document.createRange();
+        range.selectNode(node);
+
+        const selection = window.getSelection();
+        selection.removeAllRanges();
+        selection.addRange(range);
+
+        e.preventDefault();
+        e.stopPropagation();
+        return false;
+    }
+
+    _onInputTabActionKeyDown(e) {
+        if (e.key !== 'Tab' || e.ctrlKey) { return; }
+
+        const node = e.currentTarget;
+        const {tabAction} = node.dataset;
+        if (typeof tabAction !== 'string') { return; }
+
+        const args = tabAction.split(',');
+        switch (args[0]) {
+            case 'ignore':
+                e.preventDefault();
+                break;
+            case 'indent':
+                e.preventDefault();
+                this._indentInput(e, node, args);
+                break;
+        }
+    }
+
+    _onSpecialUrlLinkClick(e) {
+        switch (e.button) {
+            case 0:
+            case 1:
+                e.preventDefault();
+                this._createTab(e.currentTarget.dataset.specialUrl, true);
+                break;
+        }
+    }
+
+    _onSpecialUrlLinkMouseDown(e) {
+        switch (e.button) {
+            case 0:
+            case 1:
+                e.preventDefault();
+                break;
+        }
+    }
+
+    async _createTab(url, useOpener) {
+        let openerTabId;
+        if (useOpener) {
+            try {
+                const tab = await new Promise((resolve, reject) => {
+                    chrome.tabs.getCurrent((result) => {
+                        const e = chrome.runtime.lastError;
+                        if (e) {
+                            reject(new Error(e.message));
+                        } else {
+                            resolve(result);
+                        }
+                    });
+                });
+                openerTabId = tab.id;
+            } catch (e) {
+                // NOP
+            }
+        }
+
+        return await new Promise((resolve, reject) => {
+            chrome.tabs.create({url, openerTabId}, (tab2) => {
+                const e = chrome.runtime.lastError;
+                if (e) {
+                    reject(new Error(e.message));
+                } else {
+                    resolve(tab2);
+                }
+            });
+        });
+    }
+
+    _updateScrollTarget() {
+        const hash = window.location.hash;
+        if (!hash.startsWith('#!')) { return; }
+
+        const content = this._contentNode;
+        const target = document.getElementById(hash.substring(2));
+        if (content === null || target === null) { return; }
+
+        const rect1 = content.getBoundingClientRect();
+        const rect2 = target.getBoundingClientRect();
+        content.scrollTop += rect2.top - rect1.top;
+    }
+
+    _getMoreContainer(link) {
+        const v = link.dataset.parentDistance;
+        const distance = v ? parseInt(v, 10) : 1;
+        if (Number.isNaN(distance)) { return null; }
+
+        for (let i = 0; i < distance; ++i) {
+            link = link.parentNode;
+            if (link === null) { break; }
+        }
+        return link;
+    }
+
+    _closeTopMenuOrModal() {
+        for (const popupMenu of PopupMenu.openMenus) {
+            popupMenu.close();
+            return;
+        }
+
+        const modal = this._modalController.getTopVisibleModal();
+        if (modal !== null) {
+            modal.setVisible(false);
+        }
+    }
+
+    _showMenu(element, menuName) {
+        const menu = this._settingsController.instantiateTemplate(menuName);
+        if (menu === null) { return; }
+
+        this._menuContainer.appendChild(menu);
+
+        const popupMenu = new PopupMenu(element, menu);
+        popupMenu.prepare();
+    }
+
+    _indentInput(e, node, args) {
+        let indent = '\t';
+        if (args.length > 1) {
+            const count = parseInt(args[1], 10);
+            indent = (Number.isFinite(count) && count >= 0 ? ' '.repeat(count) : args[1]);
+        }
+
+        const {selectionStart: start, selectionEnd: end, value} = node;
+        const lineStart = value.substring(0, start).lastIndexOf('\n') + 1;
+        const lineWhitespace = /^[ \t]*/.exec(value.substring(lineStart))[0];
+
+        if (e.shiftKey) {
+            const whitespaceLength = Math.max(0, Math.floor((lineWhitespace.length - 1) / 4) * 4);
+            const selectionStartNew = lineStart + whitespaceLength;
+            const selectionEndNew = lineStart + lineWhitespace.length;
+            const removeCount = selectionEndNew - selectionStartNew;
+            if (removeCount > 0) {
+                node.selectionStart = selectionStartNew;
+                node.selectionEnd = selectionEndNew;
+                document.execCommand('delete', false);
+                node.selectionStart = Math.max(lineStart, start - removeCount);
+                node.selectionEnd = Math.max(lineStart, end - removeCount);
+            }
+        } else {
+            if (indent.length > 0) {
+                const indentLength = (Math.ceil((start - lineStart + 1) / indent.length) * indent.length - (start - lineStart));
+                document.execCommand('insertText', false, indent.substring(0, indentLength));
+            }
+        }
+    }
+
+    _isElementAnInput(element) {
+        const type = element !== null ? element.nodeName.toUpperCase() : null;
+        switch (type) {
+            case 'INPUT':
+            case 'TEXTAREA':
+            case 'SELECT':
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    _setupDeferLoadIframe(element) {
+        const parent = this._getMoreContainer(element);
+        if (parent === null) { return; }
+
+        let mutationObserver = null;
+        const callback = () => {
+            if (!this._isElementVisible(element)) { return false; }
+
+            const src = element.dataset.src;
+            delete element.dataset.src;
+            element.src = src;
+
+            if (mutationObserver === null) { return true; }
+
+            mutationObserver.disconnect();
+            mutationObserver = null;
+            return true;
+        };
+
+        if (callback()) { return; }
+
+        mutationObserver = new MutationObserver(callback);
+        mutationObserver.observe(parent, {attributes: true});
+    }
+
+    _isElementVisible(element) {
+        return (element.offsetParent !== null);
+    }
+}
diff --git a/ext/js/settings/settings-main.js b/ext/js/settings/settings-main.js
new file mode 100644
index 00000000..24248110
--- /dev/null
+++ b/ext/js/settings/settings-main.js
@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * AnkiController
+ * AnkiTemplatesController
+ * AudioController
+ * BackupController
+ * DictionaryController
+ * DictionaryImportController
+ * DocumentFocusController
+ * ExtensionKeyboardShortcutController
+ * GenericSettingController
+ * KeyboardShortcutController
+ * MecabController
+ * ModalController
+ * NestedPopupsController
+ * PermissionsToggleController
+ * PopupPreviewController
+ * PopupWindowController
+ * ProfileController
+ * ScanInputsController
+ * ScanInputsSimpleController
+ * SecondarySearchDictionaryController
+ * SentenceTerminationCharactersController
+ * SettingsController
+ * SettingsDisplayController
+ * StatusFooter
+ * StorageController
+ * TranslationTextReplacementsController
+ * api
+ */
+
+async function setupEnvironmentInfo() {
+    const {manifest_version: manifestVersion} = chrome.runtime.getManifest();
+    const {browser, platform} = await api.getEnvironmentInfo();
+    document.documentElement.dataset.browser = browser;
+    document.documentElement.dataset.os = platform.os;
+    document.documentElement.dataset.manifestVersion = `${manifestVersion}`;
+}
+
+async function setupGenericSettingsController(genericSettingController) {
+    await genericSettingController.prepare();
+    await genericSettingController.refresh();
+}
+
+(async () => {
+    try {
+        const documentFocusController = new DocumentFocusController();
+        documentFocusController.prepare();
+
+        const statusFooter = new StatusFooter(document.querySelector('.status-footer-container'));
+        statusFooter.prepare();
+
+        api.forwardLogsToBackend();
+        await yomichan.prepare();
+
+        setupEnvironmentInfo();
+
+        const optionsFull = await api.optionsGetFull();
+
+        const preparePromises = [];
+
+        const modalController = new ModalController();
+        modalController.prepare();
+
+        const settingsController = new SettingsController(optionsFull.profileCurrent);
+        settingsController.prepare();
+
+        const storageController = new StorageController();
+        storageController.prepare();
+
+        const dictionaryController = new DictionaryController(settingsController, modalController, storageController, statusFooter);
+        dictionaryController.prepare();
+
+        const dictionaryImportController = new DictionaryImportController(settingsController, modalController, storageController, statusFooter);
+        dictionaryImportController.prepare();
+
+        const genericSettingController = new GenericSettingController(settingsController);
+        preparePromises.push(setupGenericSettingsController(genericSettingController));
+
+        const audioController = new AudioController(settingsController);
+        audioController.prepare();
+
+        const profileController = new ProfileController(settingsController, modalController);
+        profileController.prepare();
+
+        const settingsBackup = new BackupController(settingsController, modalController);
+        settingsBackup.prepare();
+
+        const ankiController = new AnkiController(settingsController);
+        ankiController.prepare();
+
+        const ankiTemplatesController = new AnkiTemplatesController(settingsController, modalController, ankiController);
+        ankiTemplatesController.prepare();
+
+        const popupPreviewController = new PopupPreviewController(settingsController);
+        popupPreviewController.prepare();
+
+        const scanInputsController = new ScanInputsController(settingsController);
+        scanInputsController.prepare();
+
+        const simpleScanningInputController = new ScanInputsSimpleController(settingsController);
+        simpleScanningInputController.prepare();
+
+        const nestedPopupsController = new NestedPopupsController(settingsController);
+        nestedPopupsController.prepare();
+
+        const permissionsToggleController = new PermissionsToggleController(settingsController);
+        permissionsToggleController.prepare();
+
+        const secondarySearchDictionaryController = new SecondarySearchDictionaryController(settingsController);
+        secondarySearchDictionaryController.prepare();
+
+        const translationTextReplacementsController = new TranslationTextReplacementsController(settingsController);
+        translationTextReplacementsController.prepare();
+
+        const sentenceTerminationCharactersController = new SentenceTerminationCharactersController(settingsController);
+        sentenceTerminationCharactersController.prepare();
+
+        const keyboardShortcutController = new KeyboardShortcutController(settingsController);
+        keyboardShortcutController.prepare();
+
+        const extensionKeyboardShortcutController = new ExtensionKeyboardShortcutController(settingsController);
+        extensionKeyboardShortcutController.prepare();
+
+        const popupWindowController = new PopupWindowController();
+        popupWindowController.prepare();
+
+        const mecabController = new MecabController();
+        mecabController.prepare();
+
+        await Promise.all(preparePromises);
+
+        document.documentElement.dataset.loaded = 'true';
+
+        const settingsDisplayController = new SettingsDisplayController(settingsController, modalController);
+        settingsDisplayController.prepare();
+    } catch (e) {
+        yomichan.logError(e);
+    }
+})();
diff --git a/ext/js/settings/status-footer.js b/ext/js/settings/status-footer.js
new file mode 100644
index 00000000..c03e6775
--- /dev/null
+++ b/ext/js/settings/status-footer.js
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * PanelElement
+ */
+
+class StatusFooter extends PanelElement {
+    constructor(node) {
+        super({
+            node,
+            closingAnimationDuration: 375 // Milliseconds; includes buffer
+        });
+        this._body = node.querySelector('.status-footer');
+    }
+
+    prepare() {
+        this.on('closeCompleted', this._onCloseCompleted.bind(this), false);
+        this._body.querySelector('.status-footer-header-close').addEventListener('click', this._onCloseClick.bind(this), false);
+    }
+
+    getTaskContainer(selector) {
+        return this._body.querySelector(selector);
+    }
+
+    isTaskActive(selector) {
+        const target = this.getTaskContainer(selector);
+        return (target !== null && target.dataset.active);
+    }
+
+    setTaskActive(selector, active) {
+        const target = this.getTaskContainer(selector);
+        if (target === null) { return; }
+
+        const activeElements = new Set();
+        for (const element of this._body.querySelectorAll('.status-footer-item')) {
+            if (element.dataset.active) {
+                activeElements.add(element);
+            }
+        }
+
+        if (active) {
+            target.dataset.active = 'true';
+            if (!this.isVisible()) {
+                this.setVisible(true);
+            }
+            target.hidden = false;
+        } else {
+            delete target.dataset.active;
+            if (activeElements.size <= 1) {
+                this.setVisible(false);
+            }
+        }
+    }
+
+    // Private
+
+    _onCloseClick(e) {
+        e.preventDefault();
+        this.setVisible(false);
+    }
+
+    _onCloseCompleted() {
+        for (const element of this._body.querySelectorAll('.status-footer-item')) {
+            if (!element.dataset.active) {
+                element.hidden = true;
+            }
+        }
+    }
+}
diff --git a/ext/js/settings/storage-controller.js b/ext/js/settings/storage-controller.js
new file mode 100644
index 00000000..c27c8690
--- /dev/null
+++ b/ext/js/settings/storage-controller.js
@@ -0,0 +1,183 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class StorageController {
+    constructor() {
+        this._mostRecentStorageEstimate = null;
+        this._storageEstimateFailed = false;
+        this._isUpdating = false;
+        this._persistentStorageCheckbox = false;
+        this._storageUsageNode = null;
+        this._storageQuotaNode = null;
+        this._storageUseFiniteNodes = null;
+        this._storageUseInfiniteNodes = null;
+        this._storageUseValidNodes = null;
+        this._storageUseInvalidNodes = null;
+    }
+
+    prepare() {
+        this._persistentStorageCheckbox = document.querySelector('#storage-persistent-checkbox');
+        this._storageUsageNodes = document.querySelectorAll('.storage-usage');
+        this._storageQuotaNodes = document.querySelectorAll('.storage-quota');
+        this._storageUseFiniteNodes = document.querySelectorAll('.storage-use-finite');
+        this._storageUseInfiniteNodes = document.querySelectorAll('.storage-use-infinite');
+        this._storageUseValidNodes = document.querySelectorAll('.storage-use-valid');
+        this._storageUseInvalidNodes = document.querySelectorAll('.storage-use-invalid');
+
+        this._preparePersistentStorage();
+        this.updateStats();
+        this._persistentStorageCheckbox.addEventListener('change', this._onPersistentStorageCheckboxChange.bind(this), false);
+        document.querySelector('#storage-refresh').addEventListener('click', this.updateStats.bind(this), false);
+    }
+
+    async updateStats() {
+        if (this._isUpdating) { return; }
+
+        try {
+            this._isUpdating = true;
+
+            const estimate = await this._storageEstimate();
+            const valid = (estimate !== null);
+
+            // Firefox reports usage as 0 when persistent storage is enabled.
+            const finite = valid && (estimate.usage > 0 || !(await this._isStoragePeristent()));
+            if (finite) {
+                for (const node of this._storageUsageNodes) {
+                    node.textContent = this._bytesToLabeledString(estimate.usage);
+                }
+                for (const node of this._storageQuotaNodes) {
+                    node.textContent = this._bytesToLabeledString(estimate.quota);
+                }
+            }
+
+            this._setElementsVisible(this._storageUseFiniteNodes, valid && finite);
+            this._setElementsVisible(this._storageUseInfiniteNodes, valid && !finite);
+            this._setElementsVisible(this._storageUseValidNodes, valid);
+            this._setElementsVisible(this._storageUseInvalidNodes, !valid);
+
+            return valid;
+        } finally {
+            this._isUpdating = false;
+        }
+    }
+
+    // Private
+
+    async _preparePersistentStorage() {
+        if (!(navigator.storage && navigator.storage.persist)) {
+            // Not supported
+            return;
+        }
+
+        const info = document.querySelector('#storage-persistent-info');
+        if (info !== null) { info.hidden = false; }
+
+        const isStoragePeristent = await this._isStoragePeristent();
+        this._updateCheckbox(isStoragePeristent);
+
+        const button = document.querySelector('#storage-persistent-button');
+        if (button !== null) {
+            button.hidden = false;
+            button.addEventListener('click', this._onPersistStorageButtonClick.bind(this), false);
+        }
+    }
+
+    _onPersistentStorageCheckboxChange(e) {
+        const node = e.currentTarget;
+        if (!node.checked) {
+            node.checked = true;
+            return;
+        }
+        this._attemptPersistStorage();
+    }
+
+    _onPersistStorageButtonClick() {
+        const {checked} = this._persistentStorageCheckbox;
+        if (checked) { return; }
+        this._persistentStorageCheckbox.checked = !checked;
+        this._persistentStorageCheckbox.dispatchEvent(new Event('change'));
+    }
+
+    async _attemptPersistStorage() {
+        if (await this._isStoragePeristent()) { return; }
+
+        let isStoragePeristent = false;
+        try {
+            isStoragePeristent = await navigator.storage.persist();
+        } catch (e) {
+            // NOP
+        }
+
+        this._updateCheckbox(isStoragePeristent);
+
+        if (isStoragePeristent) {
+            this.updateStats();
+        } else {
+            const node = document.querySelector('#storage-persistent-fail-warning');
+            if (node !== null) { node.hidden = false; }
+        }
+    }
+
+    async _storageEstimate() {
+        if (this._storageEstimateFailed && this._mostRecentStorageEstimate === null) {
+            return null;
+        }
+        try {
+            const value = await navigator.storage.estimate();
+            this._mostRecentStorageEstimate = value;
+            return value;
+        } catch (e) {
+            this._storageEstimateFailed = true;
+        }
+        return null;
+    }
+
+    _bytesToLabeledString(size) {
+        const base = 1000;
+        const labels = [' bytes', 'KB', 'MB', 'GB', 'TB'];
+        const maxLabelIndex = labels.length - 1;
+        let labelIndex = 0;
+        while (size >= base && labelIndex < maxLabelIndex) {
+            size /= base;
+            ++labelIndex;
+        }
+        const label = labelIndex === 0 ? `${size}` : size.toFixed(1);
+        return `${label}${labels[labelIndex]}`;
+    }
+
+    async _isStoragePeristent() {
+        try {
+            return await navigator.storage.persisted();
+        } catch (e) {
+            // NOP
+        }
+        return false;
+    }
+
+    _updateCheckbox(isStoragePeristent) {
+        const checkbox = this._persistentStorageCheckbox;
+        checkbox.checked = isStoragePeristent;
+        checkbox.readOnly = isStoragePeristent;
+    }
+
+    _setElementsVisible(elements, visible) {
+        visible = !visible;
+        for (const element of elements) {
+            element.hidden = visible;
+        }
+    }
+}
diff --git a/ext/js/settings/translation-text-replacements-controller.js b/ext/js/settings/translation-text-replacements-controller.js
new file mode 100644
index 00000000..8d13f7e9
--- /dev/null
+++ b/ext/js/settings/translation-text-replacements-controller.js
@@ -0,0 +1,242 @@
+/*
+ * Copyright (C) 2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class TranslationTextReplacementsController {
+    constructor(settingsController) {
+        this._settingsController = settingsController;
+        this._entryContainer = null;
+        this._entries = [];
+    }
+
+    async prepare() {
+        this._entryContainer = document.querySelector('#translation-text-replacement-list');
+        const addButton = document.querySelector('#translation-text-replacement-add');
+
+        addButton.addEventListener('click', this._onAdd.bind(this), false);
+        this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+
+        await this._updateOptions();
+    }
+
+
+    async addGroup() {
+        const options = await this._settingsController.getOptions();
+        const {groups} = options.translation.textReplacements;
+        const newEntry = this._createNewEntry();
+        const target = (
+            (groups.length === 0) ?
+            {
+                action: 'splice',
+                path: 'translation.textReplacements.groups',
+                start: 0,
+                deleteCount: 0,
+                items: [[newEntry]]
+            } :
+            {
+                action: 'splice',
+                path: 'translation.textReplacements.groups[0]',
+                start: groups[0].length,
+                deleteCount: 0,
+                items: [newEntry]
+            }
+        );
+
+        await this._settingsController.modifyProfileSettings([target]);
+        await this._updateOptions();
+    }
+
+    async deleteGroup(index) {
+        const options = await this._settingsController.getOptions();
+        const {groups} = options.translation.textReplacements;
+        if (groups.length === 0) { return false; }
+
+        const group0 = groups[0];
+        if (index < 0 || index >= group0.length) { return false; }
+
+        const target = (
+            (group0.length > 1) ?
+            {
+                action: 'splice',
+                path: 'translation.textReplacements.groups[0]',
+                start: index,
+                deleteCount: 1,
+                items: []
+            } :
+            {
+                action: 'splice',
+                path: 'translation.textReplacements.groups',
+                start: 0,
+                deleteCount: group0.length,
+                items: []
+            }
+        );
+
+        await this._settingsController.modifyProfileSettings([target]);
+        await this._updateOptions();
+        return true;
+    }
+
+    // Private
+
+    _onOptionsChanged({options}) {
+        for (const entry of this._entries) {
+            entry.cleanup();
+        }
+        this._entries = [];
+
+        const {groups} = options.translation.textReplacements;
+        if (groups.length > 0) {
+            const group0 = groups[0];
+            for (let i = 0, ii = group0.length; i < ii; ++i) {
+                const data = group0[i];
+                const node = this._settingsController.instantiateTemplate('translation-text-replacement-entry');
+                this._entryContainer.appendChild(node);
+                const entry = new TranslationTextReplacementsEntry(this, node, i, data);
+                this._entries.push(entry);
+                entry.prepare();
+            }
+        }
+    }
+
+    _onAdd() {
+        this.addGroup();
+    }
+
+    async _updateOptions() {
+        const options = await this._settingsController.getOptions();
+        this._onOptionsChanged({options});
+    }
+
+    _createNewEntry() {
+        return {pattern: '', ignoreCase: false, replacement: ''};
+    }
+}
+
+class TranslationTextReplacementsEntry {
+    constructor(parent, node, index) {
+        this._parent = parent;
+        this._node = node;
+        this._index = index;
+        this._eventListeners = new EventListenerCollection();
+        this._patternInput = null;
+        this._replacementInput = null;
+        this._ignoreCaseToggle = null;
+        this._testInput = null;
+        this._testOutput = null;
+    }
+
+    prepare() {
+        const patternInput = this._node.querySelector('.translation-text-replacement-pattern');
+        const replacementInput = this._node.querySelector('.translation-text-replacement-replacement');
+        const ignoreCaseToggle = this._node.querySelector('.translation-text-replacement-pattern-ignore-case');
+        const menuButton = this._node.querySelector('.translation-text-replacement-button');
+        const testInput = this._node.querySelector('.translation-text-replacement-test-input');
+        const testOutput = this._node.querySelector('.translation-text-replacement-test-output');
+
+        this._patternInput = patternInput;
+        this._replacementInput = replacementInput;
+        this._ignoreCaseToggle = ignoreCaseToggle;
+        this._testInput = testInput;
+        this._testOutput = testOutput;
+
+        const pathBase = `translation.textReplacements.groups[0][${this._index}]`;
+        patternInput.dataset.setting = `${pathBase}.pattern`;
+        replacementInput.dataset.setting = `${pathBase}.replacement`;
+        ignoreCaseToggle.dataset.setting = `${pathBase}.ignoreCase`;
+
+        this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
+        this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
+        this._eventListeners.addEventListener(patternInput, 'settingChanged', this._onPatternChanged.bind(this), false);
+        this._eventListeners.addEventListener(ignoreCaseToggle, 'settingChanged', this._updateTestInput.bind(this), false);
+        this._eventListeners.addEventListener(replacementInput, 'settingChanged', this._updateTestInput.bind(this), false);
+        this._eventListeners.addEventListener(testInput, 'input', this._updateTestInput.bind(this), false);
+    }
+
+    cleanup() {
+        this._eventListeners.removeAllEventListeners();
+        if (this._node.parentNode !== null) {
+            this._node.parentNode.removeChild(this._node);
+        }
+    }
+
+    // Private
+
+    _onMenuOpen(e) {
+        const bodyNode = e.detail.menu.bodyNode;
+        const testVisible = this._isTestVisible();
+        bodyNode.querySelector('[data-menu-action=showTest]').hidden = testVisible;
+        bodyNode.querySelector('[data-menu-action=hideTest]').hidden = !testVisible;
+    }
+
+    _onMenuClose(e) {
+        switch (e.detail.action) {
+            case 'remove':
+                this._parent.deleteGroup(this._index);
+                break;
+            case 'showTest':
+                this._setTestVisible(true);
+                break;
+            case 'hideTest':
+                this._setTestVisible(false);
+                break;
+        }
+    }
+
+    _onPatternChanged({detail: {value}}) {
+        this._validatePattern(value);
+        this._updateTestInput();
+    }
+
+    _validatePattern(value) {
+        let okay = false;
+        try {
+            new RegExp(value, 'g');
+            okay = true;
+        } catch (e) {
+            // NOP
+        }
+
+        this._patternInput.dataset.invalid = `${!okay}`;
+    }
+
+    _isTestVisible() {
+        return this._node.dataset.testVisible === 'true';
+    }
+
+    _setTestVisible(visible) {
+        this._node.dataset.testVisible = `${visible}`;
+        this._updateTestInput();
+    }
+
+    _updateTestInput() {
+        if (!this._isTestVisible()) { return; }
+
+        const ignoreCase = this._ignoreCaseToggle.checked;
+        const pattern = this._patternInput.value;
+        let regex;
+        try {
+            regex = new RegExp(pattern, ignoreCase ? 'gi' : 'g');
+        } catch (e) {
+            return;
+        }
+
+        const replacement = this._replacementInput.value;
+        const input = this._testInput.value;
+        const output = input.replace(regex, replacement);
+        this._testOutput.value = output;
+    }
+}
-- 
cgit v1.2.3