diff options
Diffstat (limited to 'ext/js')
34 files changed, 8277 insertions, 0 deletions
| 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; +    } +} |