diff options
| author | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2021-02-14 11:19:54 -0500 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2021-02-14 11:19:54 -0500 | 
| commit | e419a418f6f03ef0a24330b67e7b76c5e3a7c22d (patch) | |
| tree | a4c27bdfabc9280d9f6262d93d5152a58de8bc15 /ext/js/data | |
| parent | 43d1457ebfe23196348649c245dfb942a0f00a1a (diff) | |
Move bg/js (#1387)
* Move bg/js/anki.js to js/comm/anki.js
* Move bg/js/mecab.js to js/comm/mecab.js
* Move bg/js/search-main.js to js/display/search-main.js
* Move bg/js/template-patcher.js to js/templates/template-patcher.js
* Move bg/js/template-renderer-frame-api.js to js/templates/template-renderer-frame-api.js
* Move bg/js/template-renderer-frame-main.js to js/templates/template-renderer-frame-main.js
* Move bg/js/template-renderer-proxy.js to js/templates/template-renderer-proxy.js
* Move bg/js/template-renderer.js to js/templates/template-renderer.js
* Move bg/js/media-utility.js to js/media/media-utility.js
* Move bg/js/native-simple-dom-parser.js to js/dom/native-simple-dom-parser.js
* Move bg/js/simple-dom-parser.js to js/dom/simple-dom-parser.js
* Move bg/js/audio-downloader.js to js/media/audio-downloader.js
* Move bg/js/deinflector.js to js/language/deinflector.js
* Move bg/js/backend.js to js/background/backend.js
* Move bg/js/translator.js to js/language/translator.js
* Move bg/js/search-display-controller.js to js/display/search-display-controller.js
* Move bg/js/request-builder.js to js/background/request-builder.js
* Move bg/js/text-source-map.js to js/general/text-source-map.js
* Move bg/js/clipboard-reader.js to js/comm/clipboard-reader.js
* Move bg/js/clipboard-monitor.js to js/comm/clipboard-monitor.js
* Move bg/js/query-parser.js to js/display/query-parser.js
* Move bg/js/profile-conditions.js to js/background/profile-conditions.js
* Move bg/js/dictionary-database.js to js/language/dictionary-database.js
* Move bg/js/dictionary-importer.js to js/language/dictionary-importer.js
* Move bg/js/anki-note-builder.js to js/data/anki-note-builder.js
* Move bg/js/anki-note-data.js to js/data/anki-note-data.js
* Move bg/js/database.js to js/data/database.js
* Move bg/js/json-schema.js to js/data/json-schema.js
* Move bg/js/options.js to js/data/options-util.js
* Move bg/js/background-main.js to js/background/background-main.js
* Move bg/js/permissions-util.js to js/data/permissions-util.js
* Move bg/js/context-main.js to js/pages/action-popup-main.js
* Move bg/js/generic-page-main.js to js/pages/generic-page-main.js
* Move bg/js/info-main.js to js/pages/info-main.js
* Move bg/js/permissions-main.js to js/pages/permissions-main.js
* Move bg/js/welcome-main.js to js/pages/welcome-main.js
Diffstat (limited to 'ext/js/data')
| -rw-r--r-- | ext/js/data/anki-note-builder.js | 148 | ||||
| -rw-r--r-- | ext/js/data/anki-note-data.js | 240 | ||||
| -rw-r--r-- | ext/js/data/database.js | 327 | ||||
| -rw-r--r-- | ext/js/data/json-schema.js | 757 | ||||
| -rw-r--r-- | ext/js/data/options-util.js | 739 | ||||
| -rw-r--r-- | ext/js/data/permissions-util.js | 126 | 
6 files changed, 2337 insertions, 0 deletions
| diff --git a/ext/js/data/anki-note-builder.js b/ext/js/data/anki-note-builder.js new file mode 100644 index 00000000..e1399f66 --- /dev/null +++ b/ext/js/data/anki-note-builder.js @@ -0,0 +1,148 @@ +/* + * 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 + * TemplateRendererProxy + */ + +class AnkiNoteBuilder { +    constructor(enabled) { +        this._markerPattern = /\{([\w-]+)\}/g; +        this._templateRenderer = enabled ? new TemplateRendererProxy() : null; +    } + +    async createNote({ +        definition, +        mode, +        context, +        templates, +        deckName, +        modelName, +        fields, +        tags=[], +        injectedMedia=null, +        checkForDuplicates=true, +        duplicateScope='collection', +        resultOutputMode='split', +        glossaryLayoutMode='default', +        compactTags=false, +        errors=null +    }) { +        let duplicateScopeDeckName = null; +        let duplicateScopeCheckChildren = false; +        if (duplicateScope === 'deck-root') { +            duplicateScope = 'deck'; +            duplicateScopeDeckName = this.getRootDeckName(deckName); +            duplicateScopeCheckChildren = true; +        } + +        const data = { +            definition, +            mode, +            context, +            resultOutputMode, +            glossaryLayoutMode, +            compactTags, +            injectedMedia +        }; +        const formattedFieldValuePromises = []; +        for (const [, fieldValue] of fields) { +            const formattedFieldValuePromise = this._formatField(fieldValue, data, templates, errors); +            formattedFieldValuePromises.push(formattedFieldValuePromise); +        } + +        const formattedFieldValues = await Promise.all(formattedFieldValuePromises); +        const noteFields = {}; +        for (let i = 0, ii = fields.length; i < ii; ++i) { +            const fieldName = fields[i][0]; +            const formattedFieldValue = formattedFieldValues[i]; +            noteFields[fieldName] = formattedFieldValue; +        } + +        return { +            fields: noteFields, +            tags, +            deckName, +            modelName, +            options: { +                allowDuplicate: !checkForDuplicates, +                duplicateScope, +                duplicateScopeOptions: { +                    deckName: duplicateScopeDeckName, +                    checkChildren: duplicateScopeCheckChildren +                } +            } +        }; +    } + +    containsMarker(fields, marker) { +        marker = `{${marker}}`; +        for (const [, fieldValue] of fields) { +            if (fieldValue.includes(marker)) { +                return true; +            } +        } +        return false; +    } + +    containsAnyMarker(field) { +        const result = this._markerPattern.test(field); +        this._markerPattern.lastIndex = 0; +        return result; +    } + +    getRootDeckName(deckName) { +        const index = deckName.indexOf('::'); +        return index >= 0 ? deckName.substring(0, index) : deckName; +    } + +    // Private + +    async _formatField(field, data, templates, errors=null) { +        return await this._stringReplaceAsync(field, this._markerPattern, async (g0, marker) => { +            try { +                return await this._renderTemplate(templates, data, marker); +            } catch (e) { +                if (errors) { +                    const error = new Error(`Template render error for {${marker}}`); +                    error.data = {error: e}; +                    errors.push(error); +                } +                return `{${marker}-render-error}`; +            } +        }); +    } + +    async _stringReplaceAsync(str, regex, replacer) { +        let match; +        let index = 0; +        const parts = []; +        while ((match = regex.exec(str)) !== null) { +            parts.push(str.substring(index, match.index), replacer(...match, match.index, str)); +            index = regex.lastIndex; +        } +        if (parts.length === 0) { +            return str; +        } +        parts.push(str.substring(index)); +        return (await Promise.all(parts)).join(''); +    } + +    async _renderTemplate(template, data, marker) { +        return await this._templateRenderer.render(template, {data, marker}, 'ankiNote'); +    } +} diff --git a/ext/js/data/anki-note-data.js b/ext/js/data/anki-note-data.js new file mode 100644 index 00000000..a7d0f9f6 --- /dev/null +++ b/ext/js/data/anki-note-data.js @@ -0,0 +1,240 @@ +/* + * 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 + * DictionaryDataUtil + */ + +/** + * This class represents the data that is exposed to the Anki template renderer. + * The public properties and data should be backwards compatible. + */ +class AnkiNoteData { +    constructor({ +        definition, +        resultOutputMode, +        mode, +        glossaryLayoutMode, +        compactTags, +        context, +        injectedMedia=null +    }, marker) { +        this._definition = definition; +        this._resultOutputMode = resultOutputMode; +        this._mode = mode; +        this._glossaryLayoutMode = glossaryLayoutMode; +        this._compactTags = compactTags; +        this._context = context; +        this._marker = marker; +        this._injectedMedia = injectedMedia; +        this._pitches = null; +        this._pitchCount = null; +        this._uniqueExpressions = null; +        this._uniqueReadings = null; +        this._publicContext = null; +        this._cloze = null; + +        this._prepareDefinition(definition, injectedMedia, context); +    } + +    get marker() { +        return this._marker; +    } + +    set marker(value) { +        this._marker = value; +    } + +    get definition() { +        return this._definition; +    } + +    get uniqueExpressions() { +        if (this._uniqueExpressions === null) { +            this._uniqueExpressions = this._getUniqueExpressions(); +        } +        return this._uniqueExpressions; +    } + +    get uniqueReadings() { +        if (this._uniqueReadings === null) { +            this._uniqueReadings = this._getUniqueReadings(); +        } +        return this._uniqueReadings; +    } + +    get pitches() { +        if (this._pitches === null) { +            this._pitches = DictionaryDataUtil.getPitchAccentInfos(this._definition); +        } +        return this._pitches; +    } + +    get pitchCount() { +        if (this._pitchCount === null) { +            this._pitchCount = this.pitches.reduce((i, v) => i + v.pitches.length, 0); +        } +        return this._pitchCount; +    } + +    get group() { +        return this._resultOutputMode === 'group'; +    } + +    get merge() { +        return this._resultOutputMode === 'merge'; +    } + +    get modeTermKanji() { +        return this._mode === 'term-kanji'; +    } + +    get modeTermKana() { +        return this._mode === 'term-kana'; +    } + +    get modeKanji() { +        return this._mode === 'kanji'; +    } + +    get compactGlossaries() { +        return this._glossaryLayoutMode === 'compact'; +    } + +    get glossaryLayoutMode() { +        return this._glossaryLayoutMode; +    } + +    get compactTags() { +        return this._compactTags; +    } + +    get context() { +        if (this._publicContext === null) { +            this._publicContext = this._getPublicContext(); +        } +        return this._publicContext; +    } + +    createPublic() { +        const self = this; +        return { +            get marker() { return self.marker; }, +            set marker(value) { self.marker = value; }, +            get definition() { return self.definition; }, +            get glossaryLayoutMode() { return self.glossaryLayoutMode; }, +            get compactTags() { return self.compactTags; }, +            get group() { return self.group; }, +            get merge() { return self.merge; }, +            get modeTermKanji() { return self.modeTermKanji; }, +            get modeTermKana() { return self.modeTermKana; }, +            get modeKanji() { return self.modeKanji; }, +            get compactGlossaries() { return self.compactGlossaries; }, +            get uniqueExpressions() { return self.uniqueExpressions; }, +            get uniqueReadings() { return self.uniqueReadings; }, +            get pitches() { return self.pitches; }, +            get pitchCount() { return self.pitchCount; }, +            get context() { return self.context; } +        }; +    } + +    // Private + +    _asObject(value) { +        return (typeof value === 'object' && value !== null ? value : {}); +    } + +    _getUniqueExpressions() { +        const results = new Set(); +        const definition = this._definition; +        if (definition.type !== 'kanji') { +            for (const {expression} of definition.expressions) { +                results.add(expression); +            } +        } +        return [...results]; +    } + +    _getUniqueReadings() { +        const results = new Set(); +        const definition = this._definition; +        if (definition.type !== 'kanji') { +            for (const {reading} of definition.expressions) { +                results.add(reading); +            } +        } +        return [...results]; +    } + +    _getPublicContext() { +        let {documentTitle} = this._asObject(this._context); +        if (typeof documentTitle !== 'string') { documentTitle = ''; } + +        return { +            document: { +                title: documentTitle +            } +        }; +    } + +    _getCloze() { +        const {sentence} = this._asObject(this._context); +        let {text, offset} = this._asObject(sentence); +        if (typeof text !== 'string') { text = ''; } +        if (typeof offset !== 'number') { offset = 0; } + +        const definition = this._definition; +        const source = definition.type === 'kanji' ? definition.character : definition.rawSource; + +        return { +            sentence: text, +            prefix: text.substring(0, offset), +            body: text.substring(offset, offset + source.length), +            suffix: text.substring(offset + source.length) +        }; +    } + +    _getClozeCached() { +        if (this._cloze === null) { +            this._cloze = this._getCloze(); +        } +        return this._cloze; +    } + +    _prepareDefinition(definition, injectedMedia, context) { +        const { +            screenshotFileName=null, +            clipboardImageFileName=null, +            clipboardText=null, +            audioFileName=null +        } = this._asObject(injectedMedia); + +        let {url} = this._asObject(context); +        if (typeof url !== 'string') { url = ''; } + +        definition.screenshotFileName = screenshotFileName; +        definition.clipboardImageFileName = clipboardImageFileName; +        definition.clipboardText = clipboardText; +        definition.audioFileName = audioFileName; +        definition.url = url; +        Object.defineProperty(definition, 'cloze', { +            configurable: true, +            enumerable: true, +            get: this._getClozeCached.bind(this) +        }); +    } +} diff --git a/ext/js/data/database.js b/ext/js/data/database.js new file mode 100644 index 00000000..068f4a5f --- /dev/null +++ b/ext/js/data/database.js @@ -0,0 +1,327 @@ +/* + * 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 Database { +    constructor() { +        this._db = null; +        this._isOpening = false; +    } + +    // Public + +    async open(databaseName, version, structure) { +        if (this._db !== null) { +            throw new Error('Database already open'); +        } +        if (this._isOpening) { +            throw new Error('Already opening'); +        } + +        try { +            this._isOpening = true; +            this._db = await this._open(databaseName, version, (db, transaction, oldVersion) => { +                this._upgrade(db, transaction, oldVersion, structure); +            }); +        } finally { +            this._isOpening = false; +        } +    } + +    close() { +        if (this._db === null) { +            throw new Error('Database is not open'); +        } + +        this._db.close(); +        this._db = null; +    } + +    isOpening() { +        return this._isOpening; +    } + +    isOpen() { +        return this._db !== null; +    } + +    transaction(storeNames, mode) { +        if (this._db === null) { +            throw new Error(this._isOpening ? 'Database not ready' : 'Database not open'); +        } +        return this._db.transaction(storeNames, mode); +    } + +    bulkAdd(objectStoreName, items, start, count) { +        return new Promise((resolve, reject) => { +            if (start + count > items.length) { +                count = items.length - start; +            } + +            if (count <= 0) { +                resolve(); +                return; +            } + +            const end = start + count; +            let completedCount = 0; +            const onError = (e) => reject(e.target.error); +            const onSuccess = () => { +                if (++completedCount >= count) { +                    resolve(); +                } +            }; + +            const transaction = this.transaction([objectStoreName], 'readwrite'); +            const objectStore = transaction.objectStore(objectStoreName); +            for (let i = start; i < end; ++i) { +                const request = objectStore.add(items[i]); +                request.onerror = onError; +                request.onsuccess = onSuccess; +            } +        }); +    } + +    getAll(objectStoreOrIndex, query, resolve, reject) { +        if (typeof objectStoreOrIndex.getAll === 'function') { +            this._getAllFast(objectStoreOrIndex, query, resolve, reject); +        } else { +            this._getAllUsingCursor(objectStoreOrIndex, query, resolve, reject); +        } +    } + +    getAllKeys(objectStoreOrIndex, query, resolve, reject) { +        if (typeof objectStoreOrIndex.getAll === 'function') { +            this._getAllKeysFast(objectStoreOrIndex, query, resolve, reject); +        } else { +            this._getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject); +        } +    } + +    find(objectStoreName, indexName, query, predicate=null, defaultValue) { +        return new Promise((resolve, reject) => { +            const transaction = this.transaction([objectStoreName], 'readonly'); +            const objectStore = transaction.objectStore(objectStoreName); +            const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; +            const request = objectStoreOrIndex.openCursor(query, 'next'); +            request.onerror = (e) => reject(e.target.error); +            request.onsuccess = (e) => { +                const cursor = e.target.result; +                if (cursor) { +                    const value = cursor.value; +                    if (typeof predicate !== 'function' || predicate(value)) { +                        resolve(value); +                    } else { +                        cursor.continue(); +                    } +                } else { +                    resolve(defaultValue); +                } +            }; +        }); +    } + +    bulkCount(targets, resolve, reject) { +        const targetCount = targets.length; +        if (targetCount <= 0) { +            resolve(); +            return; +        } + +        let completedCount = 0; +        const results = new Array(targetCount).fill(null); + +        const onError = (e) => reject(e.target.error); +        const onSuccess = (e, index) => { +            const count = e.target.result; +            results[index] = count; +            if (++completedCount >= targetCount) { +                resolve(results); +            } +        }; + +        for (let i = 0; i < targetCount; ++i) { +            const index = i; +            const [objectStoreOrIndex, query] = targets[i]; +            const request = objectStoreOrIndex.count(query); +            request.onerror = onError; +            request.onsuccess = (e) => onSuccess(e, index); +        } +    } + +    delete(objectStoreName, key) { +        return new Promise((resolve, reject) => { +            const transaction = this.transaction([objectStoreName], 'readwrite'); +            const objectStore = transaction.objectStore(objectStoreName); +            const request = objectStore.delete(key); +            request.onerror = (e) => reject(e.target.error); +            request.onsuccess = () => resolve(); +        }); +    } + +    bulkDelete(objectStoreName, indexName, query, filterKeys=null, onProgress=null) { +        return new Promise((resolve, reject) => { +            const transaction = this.transaction([objectStoreName], 'readwrite'); +            const objectStore = transaction.objectStore(objectStoreName); +            const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; + +            const onGetKeys = (keys) => { +                try { +                    if (typeof filterKeys === 'function') { +                        keys = filterKeys(keys); +                    } +                    this._bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject); +                } catch (e) { +                    reject(e); +                } +            }; + +            this.getAllKeys(objectStoreOrIndex, query, onGetKeys, reject); +        }); +    } + +    static deleteDatabase(databaseName) { +        return new Promise((resolve, reject) => { +            const request = indexedDB.deleteDatabase(databaseName); +            request.onerror = (e) => reject(e.target.error); +            request.onsuccess = () => resolve(); +            request.onblocked = () => reject(new Error('Database deletion blocked')); +        }); +    } + +    // Private + +    _open(name, version, onUpgradeNeeded) { +        return new Promise((resolve, reject) => { +            const request = indexedDB.open(name, version); + +            request.onupgradeneeded = (event) => { +                try { +                    request.transaction.onerror = (e) => reject(e.target.error); +                    onUpgradeNeeded(request.result, request.transaction, event.oldVersion, event.newVersion); +                } catch (e) { +                    reject(e); +                } +            }; + +            request.onerror = (e) => reject(e.target.error); +            request.onsuccess = () => resolve(request.result); +        }); +    } + +    _upgrade(db, transaction, oldVersion, upgrades) { +        for (const {version, stores} of upgrades) { +            if (oldVersion >= version) { continue; } + +            for (const [objectStoreName, {primaryKey, indices}] of Object.entries(stores)) { +                const existingObjectStoreNames = transaction.objectStoreNames || db.objectStoreNames; +                const objectStore = ( +                    this._listContains(existingObjectStoreNames, objectStoreName) ? +                    transaction.objectStore(objectStoreName) : +                    db.createObjectStore(objectStoreName, primaryKey) +                ); +                const existingIndexNames = objectStore.indexNames; + +                for (const indexName of indices) { +                    if (this._listContains(existingIndexNames, indexName)) { continue; } + +                    objectStore.createIndex(indexName, indexName, {}); +                } +            } +        } +    } + +    _listContains(list, value) { +        for (let i = 0, ii = list.length; i < ii; ++i) { +            if (list[i] === value) { return true; } +        } +        return false; +    } + +    _getAllFast(objectStoreOrIndex, query, resolve, reject) { +        const request = objectStoreOrIndex.getAll(query); +        request.onerror = (e) => reject(e.target.error); +        request.onsuccess = (e) => resolve(e.target.result); +    } + +    _getAllUsingCursor(objectStoreOrIndex, query, resolve, reject) { +        const results = []; +        const request = objectStoreOrIndex.openCursor(query, 'next'); +        request.onerror = (e) => reject(e.target.error); +        request.onsuccess = (e) => { +            const cursor = e.target.result; +            if (cursor) { +                results.push(cursor.value); +                cursor.continue(); +            } else { +                resolve(results); +            } +        }; +    } + +    _getAllKeysFast(objectStoreOrIndex, query, resolve, reject) { +        const request = objectStoreOrIndex.getAllKeys(query); +        request.onerror = (e) => reject(e.target.error); +        request.onsuccess = (e) => resolve(e.target.result); +    } + +    _getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject) { +        const results = []; +        const request = objectStoreOrIndex.openKeyCursor(query, 'next'); +        request.onerror = (e) => reject(e.target.error); +        request.onsuccess = (e) => { +            const cursor = e.target.result; +            if (cursor) { +                results.push(cursor.primaryKey); +                cursor.continue(); +            } else { +                resolve(results); +            } +        }; +    } + +    _bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject) { +        const count = keys.length; +        if (count === 0) { +            resolve(); +            return; +        } + +        let completedCount = 0; +        const hasProgress = (typeof onProgress === 'function'); + +        const onError = (e) => reject(e.target.error); +        const onSuccess = () => { +            ++completedCount; +            if (hasProgress) { +                try { +                    onProgress(completedCount, count); +                } catch (e) { +                    // NOP +                } +            } +            if (completedCount >= count) { +                resolve(); +            } +        }; + +        for (const key of keys) { +            const request = objectStore.delete(key); +            request.onerror = onError; +            request.onsuccess = onSuccess; +        } +    } +} diff --git a/ext/js/data/json-schema.js b/ext/js/data/json-schema.js new file mode 100644 index 00000000..7b6b9c53 --- /dev/null +++ b/ext/js/data/json-schema.js @@ -0,0 +1,757 @@ +/* + * 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 + * CacheMap + */ + +class JsonSchemaProxyHandler { +    constructor(schema, jsonSchemaValidator) { +        this._schema = schema; +        this._jsonSchemaValidator = jsonSchemaValidator; +    } + +    getPrototypeOf(target) { +        return Object.getPrototypeOf(target); +    } + +    setPrototypeOf() { +        throw new Error('setPrototypeOf not supported'); +    } + +    isExtensible(target) { +        return Object.isExtensible(target); +    } + +    preventExtensions(target) { +        Object.preventExtensions(target); +        return true; +    } + +    getOwnPropertyDescriptor(target, property) { +        return Object.getOwnPropertyDescriptor(target, property); +    } + +    defineProperty() { +        throw new Error('defineProperty not supported'); +    } + +    has(target, property) { +        return property in target; +    } + +    get(target, property) { +        if (typeof property === 'symbol') { +            return target[property]; +        } + +        if (Array.isArray(target)) { +            if (typeof property === 'string' && /^\d+$/.test(property)) { +                property = parseInt(property, 10); +            } else if (typeof property === 'string') { +                return target[property]; +            } +        } + +        const propertySchema = this._jsonSchemaValidator.getPropertySchema(this._schema, property, target); +        if (propertySchema === null) { +            return; +        } + +        const value = target[property]; +        return value !== null && typeof value === 'object' ? this._jsonSchemaValidator.createProxy(value, propertySchema) : value; +    } + +    set(target, property, value) { +        if (Array.isArray(target)) { +            if (typeof property === 'string' && /^\d+$/.test(property)) { +                property = parseInt(property, 10); +                if (property > target.length) { +                    throw new Error('Array index out of range'); +                } +            } else if (typeof property === 'string') { +                target[property] = value; +                return true; +            } +        } + +        const propertySchema = this._jsonSchemaValidator.getPropertySchema(this._schema, property, target); +        if (propertySchema === null) { +            throw new Error(`Property ${property} not supported`); +        } + +        value = clone(value); + +        this._jsonSchemaValidator.validate(value, propertySchema); + +        target[property] = value; +        return true; +    } + +    deleteProperty(target, property) { +        const required = this._schema.required; +        if (Array.isArray(required) && required.includes(property)) { +            throw new Error(`${property} cannot be deleted`); +        } +        return Reflect.deleteProperty(target, property); +    } + +    ownKeys(target) { +        return Reflect.ownKeys(target); +    } + +    apply() { +        throw new Error('apply not supported'); +    } + +    construct() { +        throw new Error('construct not supported'); +    } +} + +class JsonSchemaValidator { +    constructor() { +        this._regexCache = new CacheMap(100); +    } + +    createProxy(target, schema) { +        return new Proxy(target, new JsonSchemaProxyHandler(schema, this)); +    } + +    isValid(value, schema) { +        try { +            this.validate(value, schema); +            return true; +        } catch (e) { +            return false; +        } +    } + +    validate(value, schema) { +        const info = new JsonSchemaTraversalInfo(value, schema); +        this._validate(value, schema, info); +    } + +    getValidValueOrDefault(schema, value) { +        const info = new JsonSchemaTraversalInfo(value, schema); +        return this._getValidValueOrDefault(schema, value, info); +    } + +    getPropertySchema(schema, property, value) { +        return this._getPropertySchema(schema, property, value, null); +    } + +    clearCache() { +        this._regexCache.clear(); +    } + +    // Private + +    _getPropertySchema(schema, property, value, path) { +        const type = this._getSchemaOrValueType(schema, value); +        switch (type) { +            case 'object': +            { +                const properties = schema.properties; +                if (this._isObject(properties)) { +                    const propertySchema = properties[property]; +                    if (this._isObject(propertySchema)) { +                        if (path !== null) { path.push(['properties', properties], [property, propertySchema]); } +                        return propertySchema; +                    } +                } + +                const additionalProperties = schema.additionalProperties; +                if (additionalProperties === false) { +                    return null; +                } else if (this._isObject(additionalProperties)) { +                    if (path !== null) { path.push(['additionalProperties', additionalProperties]); } +                    return additionalProperties; +                } else { +                    const result = JsonSchemaValidator.unconstrainedSchema; +                    if (path !== null) { path.push([null, result]); } +                    return result; +                } +            } +            case 'array': +            { +                const items = schema.items; +                if (this._isObject(items)) { +                    return items; +                } +                if (Array.isArray(items)) { +                    if (property >= 0 && property < items.length) { +                        const propertySchema = items[property]; +                        if (this._isObject(propertySchema)) { +                            if (path !== null) { path.push(['items', items], [property, propertySchema]); } +                            return propertySchema; +                        } +                    } +                } + +                const additionalItems = schema.additionalItems; +                if (additionalItems === false) { +                    return null; +                } else if (this._isObject(additionalItems)) { +                    if (path !== null) { path.push(['additionalItems', additionalItems]); } +                    return additionalItems; +                } else { +                    const result = JsonSchemaValidator.unconstrainedSchema; +                    if (path !== null) { path.push([null, result]); } +                    return result; +                } +            } +            default: +                return null; +        } +    } + +    _getSchemaOrValueType(schema, value) { +        const type = schema.type; + +        if (Array.isArray(type)) { +            if (typeof value !== 'undefined') { +                const valueType = this._getValueType(value); +                if (type.indexOf(valueType) >= 0) { +                    return valueType; +                } +            } +            return null; +        } + +        if (typeof type === 'undefined') { +            if (typeof value !== 'undefined') { +                return this._getValueType(value); +            } +            return null; +        } + +        return type; +    } + +    _validate(value, schema, info) { +        this._validateSingleSchema(value, schema, info); +        this._validateConditional(value, schema, info); +        this._validateAllOf(value, schema, info); +        this._validateAnyOf(value, schema, info); +        this._validateOneOf(value, schema, info); +        this._validateNoneOf(value, schema, info); +    } + +    _validateConditional(value, schema, info) { +        const ifSchema = schema.if; +        if (!this._isObject(ifSchema)) { return; } + +        let okay = true; +        info.schemaPush('if', ifSchema); +        try { +            this._validate(value, ifSchema, info); +        } catch (e) { +            okay = false; +        } +        info.schemaPop(); + +        const nextSchema = okay ? schema.then : schema.else; +        if (this._isObject(nextSchema)) { +            info.schemaPush(okay ? 'then' : 'else', nextSchema); +            this._validate(value, nextSchema, info); +            info.schemaPop(); +        } +    } + +    _validateAllOf(value, schema, info) { +        const subSchemas = schema.allOf; +        if (!Array.isArray(subSchemas)) { return; } + +        info.schemaPush('allOf', subSchemas); +        for (let i = 0; i < subSchemas.length; ++i) { +            const subSchema = subSchemas[i]; +            info.schemaPush(i, subSchema); +            this._validate(value, subSchema, info); +            info.schemaPop(); +        } +        info.schemaPop(); +    } + +    _validateAnyOf(value, schema, info) { +        const subSchemas = schema.anyOf; +        if (!Array.isArray(subSchemas)) { return; } + +        info.schemaPush('anyOf', subSchemas); +        for (let i = 0; i < subSchemas.length; ++i) { +            const subSchema = subSchemas[i]; +            info.schemaPush(i, subSchema); +            try { +                this._validate(value, subSchema, info); +                return; +            } catch (e) { +                // NOP +            } +            info.schemaPop(); +        } + +        throw new JsonSchemaValidationError('0 anyOf schemas matched', value, schema, info); +        // info.schemaPop(); // Unreachable +    } + +    _validateOneOf(value, schema, info) { +        const subSchemas = schema.oneOf; +        if (!Array.isArray(subSchemas)) { return; } + +        info.schemaPush('oneOf', subSchemas); +        let count = 0; +        for (let i = 0; i < subSchemas.length; ++i) { +            const subSchema = subSchemas[i]; +            info.schemaPush(i, subSchema); +            try { +                this._validate(value, subSchema, info); +                ++count; +            } catch (e) { +                // NOP +            } +            info.schemaPop(); +        } + +        if (count !== 1) { +            throw new JsonSchemaValidationError(`${count} oneOf schemas matched`, value, schema, info); +        } + +        info.schemaPop(); +    } + +    _validateNoneOf(value, schema, info) { +        const subSchemas = schema.not; +        if (!Array.isArray(subSchemas)) { return; } + +        info.schemaPush('not', subSchemas); +        for (let i = 0; i < subSchemas.length; ++i) { +            const subSchema = subSchemas[i]; +            info.schemaPush(i, subSchema); +            try { +                this._validate(value, subSchema, info); +            } catch (e) { +                info.schemaPop(); +                continue; +            } +            throw new JsonSchemaValidationError(`not[${i}] schema matched`, value, schema, info); +        } +        info.schemaPop(); +    } + +    _validateSingleSchema(value, schema, info) { +        const type = this._getValueType(value); +        const schemaType = schema.type; +        if (!this._isValueTypeAny(value, type, schemaType)) { +            throw new JsonSchemaValidationError(`Value type ${type} does not match schema type ${schemaType}`, value, schema, info); +        } + +        const schemaConst = schema.const; +        if (typeof schemaConst !== 'undefined' && !this._valuesAreEqual(value, schemaConst)) { +            throw new JsonSchemaValidationError('Invalid constant value', value, schema, info); +        } + +        const schemaEnum = schema.enum; +        if (Array.isArray(schemaEnum) && !this._valuesAreEqualAny(value, schemaEnum)) { +            throw new JsonSchemaValidationError('Invalid enum value', value, schema, info); +        } + +        switch (type) { +            case 'number': +                this._validateNumber(value, schema, info); +                break; +            case 'string': +                this._validateString(value, schema, info); +                break; +            case 'array': +                this._validateArray(value, schema, info); +                break; +            case 'object': +                this._validateObject(value, schema, info); +                break; +        } +    } + +    _validateNumber(value, schema, info) { +        const multipleOf = schema.multipleOf; +        if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { +            throw new JsonSchemaValidationError(`Number is not a multiple of ${multipleOf}`, value, schema, info); +        } + +        const minimum = schema.minimum; +        if (typeof minimum === 'number' && value < minimum) { +            throw new JsonSchemaValidationError(`Number is less than ${minimum}`, value, schema, info); +        } + +        const exclusiveMinimum = schema.exclusiveMinimum; +        if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) { +            throw new JsonSchemaValidationError(`Number is less than or equal to ${exclusiveMinimum}`, value, schema, info); +        } + +        const maximum = schema.maximum; +        if (typeof maximum === 'number' && value > maximum) { +            throw new JsonSchemaValidationError(`Number is greater than ${maximum}`, value, schema, info); +        } + +        const exclusiveMaximum = schema.exclusiveMaximum; +        if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) { +            throw new JsonSchemaValidationError(`Number is greater than or equal to ${exclusiveMaximum}`, value, schema, info); +        } +    } + +    _validateString(value, schema, info) { +        const minLength = schema.minLength; +        if (typeof minLength === 'number' && value.length < minLength) { +            throw new JsonSchemaValidationError('String length too short', value, schema, info); +        } + +        const maxLength = schema.maxLength; +        if (typeof maxLength === 'number' && value.length > maxLength) { +            throw new JsonSchemaValidationError('String length too long', value, schema, info); +        } + +        const pattern = schema.pattern; +        if (typeof pattern === 'string') { +            let patternFlags = schema.patternFlags; +            if (typeof patternFlags !== 'string') { patternFlags = ''; } + +            let regex; +            try { +                regex = this._getRegex(pattern, patternFlags); +            } catch (e) { +                throw new JsonSchemaValidationError(`Pattern is invalid (${e.message})`, value, schema, info); +            } + +            if (!regex.test(value)) { +                throw new JsonSchemaValidationError('Pattern match failed', value, schema, info); +            } +        } +    } + +    _validateArray(value, schema, info) { +        const minItems = schema.minItems; +        if (typeof minItems === 'number' && value.length < minItems) { +            throw new JsonSchemaValidationError('Array length too short', value, schema, info); +        } + +        const maxItems = schema.maxItems; +        if (typeof maxItems === 'number' && value.length > maxItems) { +            throw new JsonSchemaValidationError('Array length too long', value, schema, info); +        } + +        this._validateArrayContains(value, schema, info); + +        for (let i = 0, ii = value.length; i < ii; ++i) { +            const schemaPath = []; +            const propertySchema = this._getPropertySchema(schema, i, value, schemaPath); +            if (propertySchema === null) { +                throw new JsonSchemaValidationError(`No schema found for array[${i}]`, value, schema, info); +            } + +            const propertyValue = value[i]; + +            for (const [p, s] of schemaPath) { info.schemaPush(p, s); } +            info.valuePush(i, propertyValue); +            this._validate(propertyValue, propertySchema, info); +            info.valuePop(); +            for (let j = 0, jj = schemaPath.length; j < jj; ++j) { info.schemaPop(); } +        } +    } + +    _validateArrayContains(value, schema, info) { +        const containsSchema = schema.contains; +        if (!this._isObject(containsSchema)) { return; } + +        info.schemaPush('contains', containsSchema); +        for (let i = 0, ii = value.length; i < ii; ++i) { +            const propertyValue = value[i]; +            info.valuePush(i, propertyValue); +            try { +                this._validate(propertyValue, containsSchema, info); +                info.schemaPop(); +                return; +            } catch (e) { +                // NOP +            } +            info.valuePop(); +        } +        throw new JsonSchemaValidationError('contains schema didn\'t match', value, schema, info); +    } + +    _validateObject(value, schema, info) { +        const properties = new Set(Object.getOwnPropertyNames(value)); + +        const required = schema.required; +        if (Array.isArray(required)) { +            for (const property of required) { +                if (!properties.has(property)) { +                    throw new JsonSchemaValidationError(`Missing property ${property}`, value, schema, info); +                } +            } +        } + +        const minProperties = schema.minProperties; +        if (typeof minProperties === 'number' && properties.length < minProperties) { +            throw new JsonSchemaValidationError('Not enough object properties', value, schema, info); +        } + +        const maxProperties = schema.maxProperties; +        if (typeof maxProperties === 'number' && properties.length > maxProperties) { +            throw new JsonSchemaValidationError('Too many object properties', value, schema, info); +        } + +        for (const property of properties) { +            const schemaPath = []; +            const propertySchema = this._getPropertySchema(schema, property, value, schemaPath); +            if (propertySchema === null) { +                throw new JsonSchemaValidationError(`No schema found for ${property}`, value, schema, info); +            } + +            const propertyValue = value[property]; + +            for (const [p, s] of schemaPath) { info.schemaPush(p, s); } +            info.valuePush(property, propertyValue); +            this._validate(propertyValue, propertySchema, info); +            info.valuePop(); +            for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); } +        } +    } + +    _isValueTypeAny(value, type, schemaTypes) { +        if (typeof schemaTypes === 'string') { +            return this._isValueType(value, type, schemaTypes); +        } else if (Array.isArray(schemaTypes)) { +            for (const schemaType of schemaTypes) { +                if (this._isValueType(value, type, schemaType)) { +                    return true; +                } +            } +            return false; +        } +        return true; +    } + +    _isValueType(value, type, schemaType) { +        return ( +            type === schemaType || +            (schemaType === 'integer' && Math.floor(value) === value) +        ); +    } + +    _getValueType(value) { +        const type = typeof value; +        if (type === 'object') { +            if (value === null) { return 'null'; } +            if (Array.isArray(value)) { return 'array'; } +        } +        return type; +    } + +    _valuesAreEqualAny(value1, valueList) { +        for (const value2 of valueList) { +            if (this._valuesAreEqual(value1, value2)) { +                return true; +            } +        } +        return false; +    } + +    _valuesAreEqual(value1, value2) { +        return value1 === value2; +    } + +    _getDefaultTypeValue(type) { +        if (typeof type === 'string') { +            switch (type) { +                case 'null': +                    return null; +                case 'boolean': +                    return false; +                case 'number': +                case 'integer': +                    return 0; +                case 'string': +                    return ''; +                case 'array': +                    return []; +                case 'object': +                    return {}; +            } +        } +        return null; +    } + +    _getDefaultSchemaValue(schema) { +        const schemaType = schema.type; +        const schemaDefault = schema.default; +        return ( +            typeof schemaDefault !== 'undefined' && +            this._isValueTypeAny(schemaDefault, this._getValueType(schemaDefault), schemaType) ? +            clone(schemaDefault) : +            this._getDefaultTypeValue(schemaType) +        ); +    } + +    _getValidValueOrDefault(schema, value, info) { +        let type = this._getValueType(value); +        if (typeof value === 'undefined' || !this._isValueTypeAny(value, type, schema.type)) { +            value = this._getDefaultSchemaValue(schema); +            type = this._getValueType(value); +        } + +        switch (type) { +            case 'object': +                value = this._populateObjectDefaults(value, schema, info); +                break; +            case 'array': +                value = this._populateArrayDefaults(value, schema, info); +                break; +            default: +                if (!this.isValid(value, schema)) { +                    const schemaDefault = this._getDefaultSchemaValue(schema); +                    if (this.isValid(schemaDefault, schema)) { +                        value = schemaDefault; +                    } +                } +                break; +        } + +        return value; +    } + +    _populateObjectDefaults(value, schema, info) { +        const properties = new Set(Object.getOwnPropertyNames(value)); + +        const required = schema.required; +        if (Array.isArray(required)) { +            for (const property of required) { +                properties.delete(property); + +                const propertySchema = this._getPropertySchema(schema, property, value, null); +                if (propertySchema === null) { continue; } +                info.valuePush(property, value); +                info.schemaPush(property, propertySchema); +                const hasValue = Object.prototype.hasOwnProperty.call(value, property); +                value[property] = this._getValidValueOrDefault(propertySchema, hasValue ? value[property] : void 0, info); +                info.schemaPop(); +                info.valuePop(); +            } +        } + +        for (const property of properties) { +            const propertySchema = this._getPropertySchema(schema, property, value, null); +            if (propertySchema === null) { +                Reflect.deleteProperty(value, property); +            } else { +                info.valuePush(property, value); +                info.schemaPush(property, propertySchema); +                value[property] = this._getValidValueOrDefault(propertySchema, value[property], info); +                info.schemaPop(); +                info.valuePop(); +            } +        } + +        return value; +    } + +    _populateArrayDefaults(value, schema, info) { +        for (let i = 0, ii = value.length; i < ii; ++i) { +            const propertySchema = this._getPropertySchema(schema, i, value, null); +            if (propertySchema === null) { continue; } +            info.valuePush(i, value); +            info.schemaPush(i, propertySchema); +            value[i] = this._getValidValueOrDefault(propertySchema, value[i], info); +            info.schemaPop(); +            info.valuePop(); +        } + +        const minItems = schema.minItems; +        if (typeof minItems === 'number' && value.length < minItems) { +            for (let i = value.length; i < minItems; ++i) { +                const propertySchema = this._getPropertySchema(schema, i, value, null); +                if (propertySchema === null) { break; } +                info.valuePush(i, value); +                info.schemaPush(i, propertySchema); +                const item = this._getValidValueOrDefault(propertySchema, void 0, info); +                info.schemaPop(); +                info.valuePop(); +                value.push(item); +            } +        } + +        const maxItems = schema.maxItems; +        if (typeof maxItems === 'number' && value.length > maxItems) { +            value.splice(maxItems, value.length - maxItems); +        } + +        return value; +    } + +    _isObject(value) { +        return typeof value === 'object' && value !== null && !Array.isArray(value); +    } + +    _getRegex(pattern, flags) { +        const key = `${flags}:${pattern}`; +        let regex = this._regexCache.get(key); +        if (typeof regex === 'undefined') { +            regex = new RegExp(pattern, flags); +            this._regexCache.set(key, regex); +        } +        return regex; +    } +} + +Object.defineProperty(JsonSchemaValidator, 'unconstrainedSchema', { +    value: Object.freeze({}), +    configurable: false, +    enumerable: true, +    writable: false +}); + +class JsonSchemaTraversalInfo { +    constructor(value, schema) { +        this.valuePath = []; +        this.schemaPath = []; +        this.valuePush(null, value); +        this.schemaPush(null, schema); +    } + +    valuePush(path, value) { +        this.valuePath.push([path, value]); +    } + +    valuePop() { +        this.valuePath.pop(); +    } + +    schemaPush(path, schema) { +        this.schemaPath.push([path, schema]); +    } + +    schemaPop() { +        this.schemaPath.pop(); +    } +} + +class JsonSchemaValidationError extends Error { +    constructor(message, value, schema, info) { +        super(message); +        this.value = value; +        this.schema = schema; +        this.info = info; +    } +} diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js new file mode 100644 index 00000000..1105dfed --- /dev/null +++ b/ext/js/data/options-util.js @@ -0,0 +1,739 @@ +/* + * 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 + * JsonSchemaValidator + * TemplatePatcher + */ + +class OptionsUtil { +    constructor() { +        this._schemaValidator = new JsonSchemaValidator(); +        this._templatePatcher = null; +        this._optionsSchema = null; +    } + +    async prepare() { +        this._optionsSchema = await this._fetchAsset('/data/schemas/options-schema.json', true); +    } + +    async update(options) { +        // Invalid options +        if (!isObject(options)) { +            options = {}; +        } + +        // Check for legacy options +        let defaultProfileOptions = {}; +        if (!Array.isArray(options.profiles)) { +            defaultProfileOptions = options; +            options = {}; +        } + +        // Ensure profiles is an array +        if (!Array.isArray(options.profiles)) { +            options.profiles = []; +        } + +        // Remove invalid profiles +        const profiles = options.profiles; +        for (let i = profiles.length - 1; i >= 0; --i) { +            if (!isObject(profiles[i])) { +                profiles.splice(i, 1); +            } +        } + +        // Require at least one profile +        if (profiles.length === 0) { +            profiles.push({ +                name: 'Default', +                options: defaultProfileOptions, +                conditionGroups: [] +            }); +        } + +        // Ensure profileCurrent is valid +        const profileCurrent = options.profileCurrent; +        if (!( +            typeof profileCurrent === 'number' && +            Number.isFinite(profileCurrent) && +            Math.floor(profileCurrent) === profileCurrent && +            profileCurrent >= 0 && +            profileCurrent < profiles.length +        )) { +            options.profileCurrent = 0; +        } + +        // Version +        if (typeof options.version !== 'number') { +            options.version = 0; +        } + +        // Generic updates +        options = await this._applyUpdates(options, this._getVersionUpdates()); + +        // Validation +        options = this._schemaValidator.getValidValueOrDefault(this._optionsSchema, options); + +        // Result +        return options; +    } + +    async load() { +        let options; +        try { +            const optionsStr = await new Promise((resolve, reject) => { +                chrome.storage.local.get(['options'], (store) => { +                    const error = chrome.runtime.lastError; +                    if (error) { +                        reject(new Error(error.message)); +                    } else { +                        resolve(store.options); +                    } +                }); +            }); +            options = JSON.parse(optionsStr); +        } catch (e) { +            // NOP +        } + +        if (typeof options !== 'undefined') { +            options = await this.update(options); +        } else { +            options = this.getDefault(); +        } + +        return options; +    } + +    save(options) { +        return new Promise((resolve, reject) => { +            chrome.storage.local.set({options: JSON.stringify(options)}, () => { +                const error = chrome.runtime.lastError; +                if (error) { +                    reject(new Error(error.message)); +                } else { +                    resolve(); +                } +            }); +        }); +    } + +    getDefault() { +        const optionsVersion = this._getVersionUpdates().length; +        const options = this._schemaValidator.getValidValueOrDefault(this._optionsSchema); +        options.version = optionsVersion; +        return options; +    } + +    createValidatingProxy(options) { +        return this._schemaValidator.createProxy(options, this._optionsSchema); +    } + +    validate(options) { +        return this._schemaValidator.validate(options, this._optionsSchema); +    } + +    // Legacy profile updating + +    _legacyProfileUpdateGetUpdates() { +        return [ +            null, +            null, +            null, +            null, +            (options) => { +                options.general.audioSource = options.general.audioPlayback ? 'jpod101' : 'disabled'; +            }, +            (options) => { +                options.general.showGuide = false; +            }, +            (options) => { +                options.scanning.modifier = options.scanning.requireShift ? 'shift' : 'none'; +            }, +            (options) => { +                options.general.resultOutputMode = options.general.groupResults ? 'group' : 'split'; +                options.anki.fieldTemplates = null; +            }, +            (options) => { +                if (this._getStringHashCode(options.anki.fieldTemplates) === 1285806040) { +                    options.anki.fieldTemplates = null; +                } +            }, +            (options) => { +                if (this._getStringHashCode(options.anki.fieldTemplates) === -250091611) { +                    options.anki.fieldTemplates = null; +                } +            }, +            (options) => { +                const oldAudioSource = options.general.audioSource; +                const disabled = oldAudioSource === 'disabled'; +                options.audio.enabled = !disabled; +                options.audio.volume = options.general.audioVolume; +                options.audio.autoPlay = options.general.autoPlayAudio; +                options.audio.sources = [disabled ? 'jpod101' : oldAudioSource]; + +                delete options.general.audioSource; +                delete options.general.audioVolume; +                delete options.general.autoPlayAudio; +            }, +            (options) => { +                // Version 12 changes: +                //  The preferred default value of options.anki.fieldTemplates has been changed to null. +                if (this._getStringHashCode(options.anki.fieldTemplates) === 1444379824) { +                    options.anki.fieldTemplates = null; +                } +            }, +            (options) => { +                // Version 13 changes: +                //  Default anki field tempaltes updated to include {document-title}. +                let fieldTemplates = options.anki.fieldTemplates; +                if (typeof fieldTemplates === 'string') { +                    fieldTemplates += '\n\n{{#*inline "document-title"}}\n    {{~context.document.title~}}\n{{/inline}}'; +                    options.anki.fieldTemplates = fieldTemplates; +                } +            }, +            (options) => { +                // Version 14 changes: +                //  Changed template for Anki audio and tags. +                let fieldTemplates = options.anki.fieldTemplates; +                if (typeof fieldTemplates !== 'string') { return; } + +                const replacements = [ +                    [ +                        '{{#*inline "audio"}}{{/inline}}', +                        '{{#*inline "audio"}}\n    {{~#if definition.audioFileName~}}\n        [sound:{{definition.audioFileName}}]\n    {{~/if~}}\n{{/inline}}' +                    ], +                    [ +                        '{{#*inline "tags"}}\n    {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}}\n{{/inline}}', +                        '{{#*inline "tags"}}\n    {{~#mergeTags definition group merge}}{{this}}{{/mergeTags~}}\n{{/inline}}' +                    ] +                ]; + +                for (const [pattern, replacement] of replacements) { +                    let replaced = false; +                    fieldTemplates = fieldTemplates.replace(new RegExp(escapeRegExp(pattern), 'g'), () => { +                        replaced = true; +                        return replacement; +                    }); + +                    if (!replaced) { +                        fieldTemplates += '\n\n' + replacement; +                    } +                } + +                options.anki.fieldTemplates = fieldTemplates; +            } +        ]; +    } + +    _legacyProfileUpdateGetDefaults() { +        return { +            general: { +                enable: true, +                enableClipboardPopups: false, +                resultOutputMode: 'group', +                debugInfo: false, +                maxResults: 32, +                showAdvanced: false, +                popupDisplayMode: 'default', +                popupWidth: 400, +                popupHeight: 250, +                popupHorizontalOffset: 0, +                popupVerticalOffset: 10, +                popupHorizontalOffset2: 10, +                popupVerticalOffset2: 0, +                popupHorizontalTextPosition: 'below', +                popupVerticalTextPosition: 'before', +                popupScalingFactor: 1, +                popupScaleRelativeToPageZoom: false, +                popupScaleRelativeToVisualViewport: true, +                showGuide: true, +                compactTags: false, +                compactGlossaries: false, +                mainDictionary: '', +                popupTheme: 'default', +                popupOuterTheme: 'default', +                customPopupCss: '', +                customPopupOuterCss: '', +                enableWanakana: true, +                enableClipboardMonitor: false, +                showPitchAccentDownstepNotation: true, +                showPitchAccentPositionNotation: true, +                showPitchAccentGraph: false, +                showIframePopupsInRootFrame: false, +                useSecurePopupFrameUrl: true, +                usePopupShadowDom: true +            }, + +            audio: { +                enabled: true, +                sources: ['jpod101'], +                volume: 100, +                autoPlay: false, +                customSourceUrl: '', +                textToSpeechVoice: '' +            }, + +            scanning: { +                middleMouse: true, +                touchInputEnabled: true, +                selectText: true, +                alphanumeric: true, +                autoHideResults: false, +                delay: 20, +                length: 10, +                modifier: 'shift', +                deepDomScan: false, +                popupNestingMaxDepth: 0, +                enablePopupSearch: false, +                enableOnPopupExpressions: false, +                enableOnSearchPage: true, +                enableSearchTags: false, +                layoutAwareScan: false +            }, + +            translation: { +                convertHalfWidthCharacters: 'false', +                convertNumericCharacters: 'false', +                convertAlphabeticCharacters: 'false', +                convertHiraganaToKatakana: 'false', +                convertKatakanaToHiragana: 'variant', +                collapseEmphaticSequences: 'false' +            }, + +            dictionaries: {}, + +            parsing: { +                enableScanningParser: true, +                enableMecabParser: false, +                selectedParser: null, +                termSpacing: true, +                readingMode: 'hiragana' +            }, + +            anki: { +                enable: false, +                server: 'http://127.0.0.1:8765', +                tags: ['yomichan'], +                sentenceExt: 200, +                screenshot: {format: 'png', quality: 92}, +                terms: {deck: '', model: '', fields: {}}, +                kanji: {deck: '', model: '', fields: {}}, +                duplicateScope: 'collection', +                fieldTemplates: null +            } +        }; +    } + +    _legacyProfileUpdateAssignDefaults(options) { +        const defaults = this._legacyProfileUpdateGetDefaults(); + +        const combine = (target, source) => { +            for (const key in source) { +                if (!Object.prototype.hasOwnProperty.call(target, key)) { +                    target[key] = source[key]; +                } +            } +        }; + +        combine(options, defaults); +        combine(options.general, defaults.general); +        combine(options.scanning, defaults.scanning); +        combine(options.anki, defaults.anki); +        combine(options.anki.terms, defaults.anki.terms); +        combine(options.anki.kanji, defaults.anki.kanji); + +        return options; +    } + +    _legacyProfileUpdateUpdateVersion(options) { +        const updates = this._legacyProfileUpdateGetUpdates(); +        this._legacyProfileUpdateAssignDefaults(options); + +        const targetVersion = updates.length; +        const currentVersion = options.version; + +        if (typeof currentVersion === 'number' && Number.isFinite(currentVersion)) { +            for (let i = Math.max(0, Math.floor(currentVersion)); i < targetVersion; ++i) { +                const update = updates[i]; +                if (update !== null) { +                    update(options); +                } +            } +        } + +        options.version = targetVersion; +        return options; +    } + +    // Private + +    async _applyAnkiFieldTemplatesPatch(options, modificationsUrl) { +        let patch = null; +        for (const {options: profileOptions} of options.profiles) { +            const fieldTemplates = profileOptions.anki.fieldTemplates; +            if (fieldTemplates === null) { continue; } + +            if (patch === null) { +                const content = await this._fetchAsset(modificationsUrl); +                if (this._templatePatcher === null) { +                    this._templatePatcher = new TemplatePatcher(); +                } +                patch = this._templatePatcher.parsePatch(content); +            } + +            profileOptions.anki.fieldTemplates = this._templatePatcher.applyPatch(fieldTemplates, patch); +        } +    } + +    async _fetchAsset(url, json=false) { +        url = chrome.runtime.getURL(url); +        const response = await fetch(url, { +            method: 'GET', +            mode: 'no-cors', +            cache: 'default', +            credentials: 'omit', +            redirect: 'follow', +            referrerPolicy: 'no-referrer' +        }); +        if (!response.ok) { +            throw new Error(`Failed to fetch ${url}: ${response.status}`); +        } +        return await (json ? response.json() : response.text()); +    } + +    _getStringHashCode(string) { +        let hashCode = 0; + +        if (typeof string !== 'string') { return hashCode; } + +        for (let i = 0, charCode = string.charCodeAt(i); i < string.length; charCode = string.charCodeAt(++i)) { +            hashCode = ((hashCode << 5) - hashCode) + charCode; +            hashCode |= 0; +        } + +        return hashCode; +    } + +    async _applyUpdates(options, updates) { +        const targetVersion = updates.length; +        let currentVersion = options.version; + +        if (typeof currentVersion !== 'number' || !Number.isFinite(currentVersion)) { +            currentVersion = 0; +        } + +        for (let i = Math.max(0, Math.floor(currentVersion)); i < targetVersion; ++i) { +            const {update, async} = updates[i]; +            const result = update(options); +            options = (async ? await result : result); +        } + +        options.version = targetVersion; +        return options; +    } + +    _getVersionUpdates() { +        return [ +            {async: false, update: this._updateVersion1.bind(this)}, +            {async: false, update: this._updateVersion2.bind(this)}, +            {async: true,  update: this._updateVersion3.bind(this)}, +            {async: true,  update: this._updateVersion4.bind(this)}, +            {async: false, update: this._updateVersion5.bind(this)}, +            {async: true,  update: this._updateVersion6.bind(this)}, +            {async: false, update: this._updateVersion7.bind(this)}, +            {async: true,  update: this._updateVersion8.bind(this)} +        ]; +    } + +    _updateVersion1(options) { +        // Version 1 changes: +        //  Added options.global.database.prefixWildcardsSupported = false. +        options.global = { +            database: { +                prefixWildcardsSupported: false +            } +        }; +        return options; +    } + +    _updateVersion2(options) { +        // Version 2 changes: +        //  Legacy profile update process moved into this upgrade function. +        for (const profile of options.profiles) { +            if (!Array.isArray(profile.conditionGroups)) { +                profile.conditionGroups = []; +            } +            profile.options = this._legacyProfileUpdateUpdateVersion(profile.options); +        } +        return options; +    } + +    async _updateVersion3(options) { +        // Version 3 changes: +        //  Pitch accent Anki field templates added. +        await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v2.handlebars'); +        return options; +    } + +    async _updateVersion4(options) { +        // Version 4 changes: +        //  Options conditions converted to string representations. +        //  Added usePopupWindow. +        //  Updated handlebars templates to include "clipboard-image" definition. +        //  Updated handlebars templates to include "clipboard-text" definition. +        //  Added hideDelay. +        //  Added inputs to profileOptions.scanning. +        //  Added pointerEventsEnabled to profileOptions.scanning. +        //  Added preventMiddleMouse to profileOptions.scanning. +        for (const {conditionGroups} of options.profiles) { +            for (const {conditions} of conditionGroups) { +                for (const condition of conditions) { +                    const value = condition.value; +                    condition.value = ( +                        Array.isArray(value) ? +                        value.join(', ') : +                        `${value}` +                    ); +                } +            } +        } +        const createInputDefaultOptions = () => ({ +            showAdvanced: false, +            searchTerms: true, +            searchKanji: true, +            scanOnTouchMove: true, +            scanOnPenHover: true, +            scanOnPenPress: true, +            scanOnPenRelease: false, +            preventTouchScrolling: true +        }); +        for (const {options: profileOptions} of options.profiles) { +            profileOptions.general.usePopupWindow = false; +            profileOptions.scanning.hideDelay = 0; +            profileOptions.scanning.pointerEventsEnabled = false; +            profileOptions.scanning.preventMiddleMouse = { +                onWebPages: false, +                onPopupPages: false, +                onSearchPages: false, +                onSearchQuery: false +            }; + +            const {modifier, middleMouse} = profileOptions.scanning; +            delete profileOptions.scanning.modifier; +            delete profileOptions.scanning.middleMouse; +            const scanningInputs = []; +            let modifierInput = ''; +            switch (modifier) { +                case 'alt': +                case 'ctrl': +                case 'shift': +                case 'meta': +                    modifierInput = modifier; +                    break; +                case 'none': +                    modifierInput = ''; +                    break; +            } +            scanningInputs.push({ +                include: modifierInput, +                exclude: 'mouse0', +                types: {mouse: true, touch: false, pen: false}, +                options: createInputDefaultOptions() +            }); +            if (middleMouse) { +                scanningInputs.push({ +                    include: 'mouse2', +                    exclude: '', +                    types: {mouse: true, touch: false, pen: false}, +                    options: createInputDefaultOptions() +                }); +            } +            scanningInputs.push({ +                include: '', +                exclude: '', +                types: {mouse: false, touch: true, pen: true}, +                options: createInputDefaultOptions() +            }); +            profileOptions.scanning.inputs = scanningInputs; +        } +        await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v4.handlebars'); +        return options; +    } + +    _updateVersion5(options) { +        // Version 5 changes: +        //  Removed legacy version number from profile options. +        for (const profile of options.profiles) { +            delete profile.options.version; +        } +        return options; +    } + +    async _updateVersion6(options) { +        // Version 6 changes: +        //  Updated handlebars templates to include "conjugation" definition. +        //  Added global option showPopupPreview. +        //  Added global option useSettingsV2. +        //  Added anki.checkForDuplicates. +        //  Added general.glossaryLayoutMode; removed general.compactGlossaries. +        await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v6.handlebars'); +        options.global.showPopupPreview = false; +        options.global.useSettingsV2 = false; +        for (const profile of options.profiles) { +            profile.options.anki.checkForDuplicates = true; +            profile.options.general.glossaryLayoutMode = (profile.options.general.compactGlossaries ? 'compact' : 'default'); +            delete profile.options.general.compactGlossaries; +            const fieldTemplates = profile.options.anki.fieldTemplates; +            if (typeof fieldTemplates === 'string') { +                profile.options.anki.fieldTemplates = this._updateVersion6AnkiTemplatesCompactTags(fieldTemplates); +            } +        } +        return options; +    } + +    _updateVersion6AnkiTemplatesCompactTags(templates) { +        const rawPattern1 = '{{~#if definitionTags~}}<i>({{#each definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each}})</i> {{/if~}}'; +        const pattern1 = new RegExp(`((\r?\n)?[ \t]*)${escapeRegExp(rawPattern1)}`, 'g'); +        const replacement1 = ( +        // eslint-disable-next-line indent +`{{~#scope~}} +    {{~#set "any" false}}{{/set~}} +    {{~#if definitionTags~}}{{#each definitionTags~}} +        {{~#if (op "||" (op "!" ../data.compactTags) (op "!" redundant))~}} +            {{~#if (get "any")}}, {{else}}<i>({{/if~}} +            {{name}} +            {{~#set "any" true}}{{/set~}} +        {{~/if~}} +    {{~/each~}} +    {{~#if (get "any")}})</i> {{/if~}} +    {{~/if~}} +{{~/scope~}}` +        ); +        const simpleNewline = /\n/g; +        templates = templates.replace(pattern1, (g0, space) => (space + replacement1.replace(simpleNewline, space))); +        templates = templates.replace(/\bcompactGlossaries=((?:\.*\/)*)compactGlossaries\b/g, (g0, g1) => `${g0} data=${g1}.`); +        return templates; +    } + +    _updateVersion7(options) { +        // Version 7 changes: +        //  Added general.maximumClipboardSearchLength. +        //  Added general.popupCurrentIndicatorMode. +        //  Added general.popupActionBarVisibility. +        //  Added general.popupActionBarLocation. +        //  Removed global option showPopupPreview. +        delete options.global.showPopupPreview; +        for (const profile of options.profiles) { +            profile.options.general.maximumClipboardSearchLength = 1000; +            profile.options.general.popupCurrentIndicatorMode = 'triangle'; +            profile.options.general.popupActionBarVisibility = 'auto'; +            profile.options.general.popupActionBarLocation = 'right'; +        } +        return options; +    } + +    async _updateVersion8(options) { +        // Version 8 changes: +        //  Added translation.textReplacements. +        //  Moved anki.sentenceExt to sentenceParsing.scanExtent. +        //  Added sentenceParsing.enableTerminationCharacters. +        //  Added sentenceParsing.terminationCharacters. +        //  Changed general.popupActionBarLocation. +        //  Added inputs.hotkeys. +        //  Added anki.suspendNewCards. +        //  Added popupWindow. +        //  Updated handlebars templates to include "stroke-count" definition. +        //  Updated global.useSettingsV2 to be true (opt-out). +        //  Added audio.customSourceType. +        //  Moved general.enableClipboardPopups => clipboard.enableBackgroundMonitor. +        //  Moved general.enableClipboardMonitor => clipboard.enableSearchPageMonitor. Forced value to false due to a bug which caused its value to not be read. +        //  Moved general.maximumClipboardSearchLength => clipboard.maximumSearchLength. +        //  Added clipboard.autoSearchContent. +        await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v8.handlebars'); +        options.global.useSettingsV2 = true; +        for (const profile of options.profiles) { +            profile.options.translation.textReplacements = { +                searchOriginal: true, +                groups: [] +            }; +            profile.options.sentenceParsing = { +                scanExtent: profile.options.anki.sentenceExt, +                enableTerminationCharacters: true, +                terminationCharacters: [ +                    {enabled: true, character1: '「', character2: '」', includeCharacterAtStart: false, includeCharacterAtEnd: false}, +                    {enabled: true, character1: '『', character2: '』', includeCharacterAtStart: false, includeCharacterAtEnd: false}, +                    {enabled: true, character1: '"', character2: '"', includeCharacterAtStart: false, includeCharacterAtEnd: false}, +                    {enabled: true, character1: '\'', character2: '\'', includeCharacterAtStart: false, includeCharacterAtEnd: false}, +                    {enabled: true, character1: '.', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, +                    {enabled: true, character1: '!', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, +                    {enabled: true, character1: '?', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, +                    {enabled: true, character1: '.', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, +                    {enabled: true, character1: '。', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, +                    {enabled: true, character1: '!', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, +                    {enabled: true, character1: '?', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, +                    {enabled: true, character1: '…', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true} +                ] +            }; +            delete profile.options.anki.sentenceExt; +            profile.options.general.popupActionBarLocation = 'top'; +            profile.options.inputs = { +                hotkeys: [ +                    {action: 'close',             key: 'Escape',    modifiers: [],       scopes: ['popup'], enabled: true}, +                    {action: 'focusSearchBox',    key: 'Escape',    modifiers: [],       scopes: ['search'], enabled: true}, +                    {action: 'previousEntry3',    key: 'PageUp',    modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'nextEntry3',        key: 'PageDown',  modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'lastEntry',         key: 'End',       modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'firstEntry',        key: 'Home',      modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'previousEntry',     key: 'ArrowUp',   modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'nextEntry',         key: 'ArrowDown', modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'historyBackward',   key: 'KeyB',      modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'historyForward',    key: 'KeyF',      modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'addNoteKanji',      key: 'KeyK',      modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'addNoteTermKanji',  key: 'KeyE',      modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'addNoteTermKana',   key: 'KeyR',      modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'playAudio',         key: 'KeyP',      modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'viewNote',          key: 'KeyV',      modifiers: ['alt'],  scopes: ['popup', 'search'], enabled: true}, +                    {action: 'copyHostSelection', key: 'KeyC',      modifiers: ['ctrl'], scopes: ['popup'], enabled: true} +                ] +            }; +            profile.options.anki.suspendNewCards = false; +            profile.options.popupWindow = { +                width: profile.options.general.popupWidth, +                height: profile.options.general.popupHeight, +                left: 0, +                top: 0, +                useLeft: false, +                useTop: false, +                windowType: 'popup', +                windowState: 'normal' +            }; +            profile.options.audio.customSourceType = 'audio'; +            profile.options.clipboard = { +                enableBackgroundMonitor: profile.options.general.enableClipboardPopups, +                enableSearchPageMonitor: false, +                autoSearchContent: true, +                maximumSearchLength: profile.options.general.maximumClipboardSearchLength +            }; +            delete profile.options.general.enableClipboardPopups; +            delete profile.options.general.enableClipboardMonitor; +            delete profile.options.general.maximumClipboardSearchLength; +        } +        return options; +    } +} diff --git a/ext/js/data/permissions-util.js b/ext/js/data/permissions-util.js new file mode 100644 index 00000000..bd3a18ce --- /dev/null +++ b/ext/js/data/permissions-util.js @@ -0,0 +1,126 @@ +/* + * 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 PermissionsUtil { +    constructor() { +        this._ankiFieldMarkersRequiringClipboardPermission = new Set([ +            'clipboard-image', +            'clipboard-text' +        ]); +        this._ankiMarkerPattern = /\{([\w-]+)\}/g; +    } + +    hasPermissions(permissions) { +        return new Promise((resolve, reject) => chrome.permissions.contains(permissions, (result) => { +            const e = chrome.runtime.lastError; +            if (e) { +                reject(new Error(e.message)); +            } else { +                resolve(result); +            } +        })); +    } + +    setPermissionsGranted(permissions, shouldHave) { +        return ( +            shouldHave ? +            new Promise((resolve, reject) => chrome.permissions.request(permissions, (result) => { +                const e = chrome.runtime.lastError; +                if (e) { +                    reject(new Error(e.message)); +                } else { +                    resolve(result); +                } +            })) : +            new Promise((resolve, reject) => chrome.permissions.remove(permissions, (result) => { +                const e = chrome.runtime.lastError; +                if (e) { +                    reject(new Error(e.message)); +                } else { +                    resolve(!result); +                } +            })) +        ); +    } + +    getAllPermissions() { +        return new Promise((resolve, reject) => chrome.permissions.getAll((result) => { +            const e = chrome.runtime.lastError; +            if (e) { +                reject(new Error(e.message)); +            } else { +                resolve(result); +            } +        })); +    } + +    getRequiredPermissionsForAnkiFieldValue(fieldValue) { +        const markers = this._getAnkiFieldMarkers(fieldValue); +        const markerPermissions = this._ankiFieldMarkersRequiringClipboardPermission; +        for (const marker of markers) { +            if (markerPermissions.has(marker)) { +                return ['clipboardRead']; +            } +        } +        return []; +    } + +    hasRequiredPermissionsForOptions(permissions, options) { +        const permissionsSet = new Set(permissions.permissions); + +        if (!permissionsSet.has('nativeMessaging')) { +            if (options.parsing.enableMecabParser) { +                return false; +            } +        } + +        if (!permissionsSet.has('clipboardRead')) { +            if (options.clipboard.enableBackgroundMonitor || options.clipboard.enableSearchPageMonitor) { +                return false; +            } +            const fieldMarkersRequiringClipboardPermission = this._ankiFieldMarkersRequiringClipboardPermission; +            const fieldsList = [ +                options.anki.terms.fields, +                options.anki.kanji.fields +            ]; +            for (const fields of fieldsList) { +                for (const fieldValue of Object.values(fields)) { +                    const markers = this._getAnkiFieldMarkers(fieldValue); +                    for (const marker of markers) { +                        if (fieldMarkersRequiringClipboardPermission.has(marker)) { +                            return false; +                        } +                    } +                } +            } +        } + +        return true; +    } + +    // Private + +    _getAnkiFieldMarkers(fieldValue) { +        const pattern = this._ankiMarkerPattern; +        const markers = []; +        let match; +        while ((match = pattern.exec(fieldValue)) !== null) { +            markers.push(match[1]); +        } +        return markers; +    } +} |