diff options
67 files changed, 3029 insertions, 1326 deletions
diff --git a/.eslintrc.json b/.eslintrc.json index fcc6995b..db8ff1fa 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -22,6 +22,7 @@          "dot-notation": "error",          "eqeqeq": "error",          "func-names": ["error", "always"], +        "guard-for-in": "error",          "no-case-declarations": "error",          "no-const-assign": "error",          "no-constant-condition": "off", @@ -62,7 +63,7 @@          "semi-spacing": ["error", {"before": false, "after": true}],          "space-in-parens": ["error", "never"],          "space-unary-ops": "error", -        "spaced-comment": ["error", "always", {"markers": ["global"]}], +        "spaced-comment": ["error", "always"],          "switch-colon-spacing": ["error", {"after": true, "before": false}],          "template-curly-spacing": ["error", "never"],          "template-tag-spacing": ["error", "never"], @@ -73,7 +74,7 @@      },      "overrides": [          { -            "files": ["*.js"], +            "files": ["ext/**/*.js"],              "excludedFiles": ["ext/mixed/js/core.js"],              "globals": {                  "yomichan": "readonly", @@ -85,7 +86,6 @@                  "toIterable": "readonly",                  "stringReverse": "readonly",                  "promiseTimeout": "readonly", -                "stringReplaceAsync": "readonly",                  "parseUrl": "readonly",                  "EventDispatcher": "readonly",                  "EventListenerCollection": "readonly", diff --git a/ext/bg/background.html b/ext/bg/background.html index 7fd1c477..44abe8fd 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -22,9 +22,9 @@          <script src="/mixed/js/dom.js"></script>          <script src="/bg/js/anki.js"></script> -        <script src="/bg/js/api.js"></script> +        <script src="/bg/js/anki-note-builder.js"></script>          <script src="/bg/js/mecab.js"></script> -        <script src="/bg/js/audio.js"></script> +        <script src="/bg/js/audio-uri-builder.js"></script>          <script src="/bg/js/backend-api-forwarder.js"></script>          <script src="/bg/js/clipboard-monitor.js"></script>          <script src="/bg/js/conditions.js"></script> @@ -39,7 +39,7 @@          <script src="/bg/js/request.js"></script>          <script src="/bg/js/translator.js"></script>          <script src="/bg/js/util.js"></script> -        <script src="/mixed/js/audio.js"></script> +        <script src="/mixed/js/audio-system.js"></script>          <script src="/bg/js/backend.js"></script>      </body> diff --git a/ext/bg/data/default-anki-field-templates.handlebars b/ext/bg/data/default-anki-field-templates.handlebars new file mode 100644 index 00000000..0442f7c5 --- /dev/null +++ b/ext/bg/data/default-anki-field-templates.handlebars @@ -0,0 +1,161 @@ +{{#*inline "glossary-single"}} +    {{~#unless brief~}} +        {{~#if definitionTags~}}<i>({{#each definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each}})</i> {{/if~}} +        {{~#if only~}}({{#each only}}{{{.}}}{{#unless @last}}, {{/unless}}{{/each}} only) {{/if~}} +    {{~/unless~}} +    {{~#if glossary.[1]~}} +        {{~#if compactGlossaries~}} +            {{#each glossary}}{{#multiLine}}{{.}}{{/multiLine}}{{#unless @last}} | {{/unless}}{{/each}} +        {{~else~}} +            <ul>{{#each glossary}}<li>{{#multiLine}}{{.}}{{/multiLine}}</li>{{/each}}</ul> +        {{~/if~}} +    {{~else~}} +        {{~#multiLine}}{{glossary.[0]}}{{/multiLine~}} +    {{~/if~}} +{{/inline}} + +{{#*inline "audio"}}{{/inline}} + +{{#*inline "character"}} +    {{~definition.character~}} +{{/inline}} + +{{#*inline "dictionary"}} +    {{~definition.dictionary~}} +{{/inline}} + +{{#*inline "expression"}} +    {{~#if merge~}} +        {{~#if modeTermKana~}} +            {{~#each definition.reading~}} +                {{{.}}} +                {{~#unless @last}}、{{/unless~}} +            {{~else~}} +                {{~#each definition.expression~}} +                    {{{.}}} +                    {{~#unless @last}}、{{/unless~}} +                {{~/each~}} +            {{~/each~}} +        {{~else~}} +            {{~#each definition.expression~}} +                {{{.}}} +                {{~#unless @last}}、{{/unless~}} +            {{~/each~}} +        {{~/if~}} +    {{~else~}} +        {{~#if modeTermKana~}} +            {{~#if definition.reading~}} +                {{definition.reading}} +            {{~else~}} +                {{definition.expression}} +            {{~/if~}} +        {{~else~}} +            {{definition.expression}} +        {{~/if~}} +    {{~/if~}} +{{/inline}} + +{{#*inline "furigana"}} +    {{~#if merge~}} +        {{~#each definition.expressions~}} +            <span class="expression-{{termFrequency}}">{{~#furigana}}{{{.}}}{{/furigana~}}</span> +            {{~#unless @last}}、{{/unless~}} +        {{~/each~}} +    {{~else~}} +        {{#furigana}}{{{definition}}}{{/furigana}} +    {{~/if~}} +{{/inline}} + +{{#*inline "furigana-plain"}} +    {{~#if merge~}} +        {{~#each definition.expressions~}} +            <span class="expression-{{termFrequency}}">{{~#furiganaPlain}}{{{.}}}{{/furiganaPlain~}}</span> +            {{~#unless @last}}、{{/unless~}} +        {{~/each~}} +    {{~else~}} +        {{#furiganaPlain}}{{{definition}}}{{/furiganaPlain}} +    {{~/if~}} +{{/inline}} + +{{#*inline "glossary"}} +    <div style="text-align: left;"> +    {{~#if modeKanji~}} +        {{~#if definition.glossary.[1]~}} +            <ol>{{#each definition.glossary}}<li>{{.}}</li>{{/each}}</ol> +        {{~else~}} +            {{definition.glossary.[0]}} +        {{~/if~}} +    {{~else~}} +        {{~#if group~}} +            {{~#if definition.definitions.[1]~}} +                <ol>{{#each definition.definitions}}<li>{{> glossary-single brief=../brief compactGlossaries=../compactGlossaries}}</li>{{/each}}</ol> +            {{~else~}} +                {{~> glossary-single definition.definitions.[0] brief=brief compactGlossaries=compactGlossaries~}} +            {{~/if~}} +        {{~else if merge~}} +            {{~#if definition.definitions.[1]~}} +                <ol>{{#each definition.definitions}}<li>{{> glossary-single brief=../brief compactGlossaries=../compactGlossaries}}</li>{{/each}}</ol> +            {{~else~}} +                {{~> glossary-single definition.definitions.[0] brief=brief compactGlossaries=compactGlossaries~}} +            {{~/if~}} +        {{~else~}} +            {{~> glossary-single definition brief=brief compactGlossaries=compactGlossaries~}} +        {{~/if~}} +    {{~/if~}} +    </div> +{{/inline}} + +{{#*inline "glossary-brief"}} +    {{~> glossary brief=true ~}} +{{/inline}} + +{{#*inline "kunyomi"}} +    {{~#each definition.kunyomi}}{{.}}{{#unless @last}}, {{/unless}}{{/each~}} +{{/inline}} + +{{#*inline "onyomi"}} +    {{~#each definition.onyomi}}{{.}}{{#unless @last}}, {{/unless}}{{/each~}} +{{/inline}} + +{{#*inline "reading"}} +    {{~#unless modeTermKana~}} +        {{~#if merge~}} +            {{~#each definition.reading~}} +                {{{.}}} +                {{~#unless @last}}、{{/unless~}} +            {{~/each~}} +        {{~else~}} +            {{~definition.reading~}} +        {{~/if~}} +    {{~/unless~}} +{{/inline}} + +{{#*inline "sentence"}} +    {{~#if definition.cloze}}{{definition.cloze.sentence}}{{/if~}} +{{/inline}} + +{{#*inline "cloze-prefix"}} +    {{~#if definition.cloze}}{{definition.cloze.prefix}}{{/if~}} +{{/inline}} + +{{#*inline "cloze-body"}} +    {{~#if definition.cloze}}{{definition.cloze.body}}{{/if~}} +{{/inline}} + +{{#*inline "cloze-suffix"}} +    {{~#if definition.cloze}}{{definition.cloze.suffix}}{{/if~}} +{{/inline}} + +{{#*inline "tags"}} +    {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}} +{{/inline}} + +{{#*inline "url"}} +    <a href="{{definition.url}}">{{definition.url}}</a> +{{/inline}} + +{{#*inline "screenshot"}} +    <img src="{{definition.screenshotFileName}}" /> +{{/inline}} + +{{~> (lookup . "marker") ~}}
\ No newline at end of file diff --git a/ext/bg/js/anki-note-builder.js b/ext/bg/js/anki-note-builder.js new file mode 100644 index 00000000..d0ff8205 --- /dev/null +++ b/ext/bg/js/anki-note-builder.js @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2020  Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 AnkiNoteBuilder { +    constructor({renderTemplate}) { +        this._renderTemplate = renderTemplate; +    } + +    async createNote(definition, mode, options, templates) { +        const isKanji = (mode === 'kanji'); +        const tags = options.anki.tags; +        const modeOptions = isKanji ? options.anki.kanji : options.anki.terms; +        const modeOptionsFieldEntries = Object.entries(modeOptions.fields); + +        const note = { +            fields: {}, +            tags, +            deckName: modeOptions.deck, +            modelName: modeOptions.model +        }; + +        for (const [fieldName, fieldValue] of modeOptionsFieldEntries) { +            note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, options, templates, null); +        } + +        if (!isKanji && definition.audio) { +            const audioFields = []; + +            for (const [fieldName, fieldValue] of modeOptionsFieldEntries) { +                if (fieldValue.includes('{audio}')) { +                    audioFields.push(fieldName); +                } +            } + +            if (audioFields.length > 0) { +                note.audio = { +                    url: definition.audio.url, +                    filename: definition.audio.filename, +                    skipHash: '7e2c2f954ef6051373ba916f000168dc', // hash of audio data that should be skipped +                    fields: audioFields +                }; +            } +        } + +        return note; +    } + +    async formatField(field, definition, mode, options, templates, errors=null) { +        const data = { +            marker: null, +            definition, +            group: options.general.resultOutputMode === 'group', +            merge: options.general.resultOutputMode === 'merge', +            modeTermKanji: mode === 'term-kanji', +            modeTermKana: mode === 'term-kana', +            modeKanji: mode === 'kanji', +            compactGlossaries: options.general.compactGlossaries +        }; +        const pattern = /\{([\w-]+)\}/g; +        return await AnkiNoteBuilder.stringReplaceAsync(field, pattern, async (g0, marker) => { +            data.marker = marker; +            try { +                return await this._renderTemplate(templates, data); +            } catch (e) { +                if (errors) { errors.push(e); } +                return `{${marker}-render-error}`; +            } +        }); +    } + +    static 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 Promise.resolve(str); +        } +        parts.push(str.substring(index)); +        return Promise.all(parts).then((v) => v.join('')); +    } +} diff --git a/ext/bg/js/anki.js b/ext/bg/js/anki.js index 39c6ad51..a70388bd 100644 --- a/ext/bg/js/anki.js +++ b/ext/bg/js/anki.js @@ -16,7 +16,9 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global requestJson*/ +/* global + * requestJson + */  /*   * AnkiConnect diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js deleted file mode 100644 index 0c244ffa..00000000 --- a/ext/bg/js/api.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (C) 2019-2020  Alex Yatskov <alex@foosoft.net> - * Author: Alex Yatskov <alex@foosoft.net> - * - * 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/>. - */ - - -function apiTemplateRender(template, data) { -    return _apiInvoke('templateRender', {data, template}); -} - -function apiAudioGetUrl(definition, source, optionsContext) { -    return _apiInvoke('audioGetUrl', {definition, source, optionsContext}); -} - -function apiClipboardGet() { -    return _apiInvoke('clipboardGet'); -} - -function _apiInvoke(action, params={}) { -    const data = {action, params}; -    return new Promise((resolve, reject) => { -        try { -            const callback = (response) => { -                if (response !== null && typeof response === 'object') { -                    if (typeof response.error !== 'undefined') { -                        reject(jsonToError(response.error)); -                    } else { -                        resolve(response.result); -                    } -                } else { -                    const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`; -                    reject(new Error(`${message} (${JSON.stringify(data)})`)); -                } -            }; -            const backend = window.yomichanBackend; -            backend.onMessage({action, params}, null, callback); -        } catch (e) { -            reject(e); -            yomichan.triggerOrphaned(e); -        } -    }); -} diff --git a/ext/bg/js/audio.js b/ext/bg/js/audio-uri-builder.js index d300570b..499c3441 100644 --- a/ext/bg/js/audio.js +++ b/ext/bg/js/audio-uri-builder.js @@ -16,10 +16,53 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global jpIsStringEntirelyKana, audioGetFromSources*/ +/* global + * jpIsStringEntirelyKana + */ + +class AudioUriBuilder { +    constructor() { +        this._getUrlHandlers = new Map([ +            ['jpod101', this._getUriJpod101.bind(this)], +            ['jpod101-alternate', this._getUriJpod101Alternate.bind(this)], +            ['jisho', this._getUriJisho.bind(this)], +            ['text-to-speech', this._getUriTextToSpeech.bind(this)], +            ['text-to-speech-reading', this._getUriTextToSpeechReading.bind(this)], +            ['custom', this._getUriCustom.bind(this)] +        ]); +    } + +    normalizeUrl(url, baseUrl, basePath) { +        if (url) { +            if (url[0] === '/') { +                if (url.length >= 2 && url[1] === '/') { +                    // Begins with "//" +                    url = baseUrl.substring(0, baseUrl.indexOf(':') + 1) + url; +                } else { +                    // Begins with "/" +                    url = baseUrl + url; +                } +            } else if (!/^[a-z][a-z0-9\-+.]*:/i.test(url)) { +                // No URI scheme => relative path +                url = baseUrl + basePath + url; +            } +        } +        return url; +    } -const audioUrlBuilders = new Map([ -    ['jpod101', async (definition) => { +    async getUri(definition, source, options) { +        const handler = this._getUrlHandlers.get(source); +        if (typeof handler === 'function') { +            try { +                return await handler(definition, options); +            } catch (e) { +                // NOP +            } +        } +        return null; +    } + +    async _getUriJpod101(definition) {          let kana = definition.reading;          let kanji = definition.expression; @@ -37,8 +80,9 @@ const audioUrlBuilders = new Map([          }          return `https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?${params.join('&')}`; -    }], -    ['jpod101-alternate', async (definition) => { +    } + +    async _getUriJpod101Alternate(definition) {          const response = await new Promise((resolve, reject) => {              const xhr = new XMLHttpRequest();              xhr.open('POST', 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'); @@ -54,7 +98,7 @@ const audioUrlBuilders = new Map([                  const url = row.querySelector('audio>source[src]').getAttribute('src');                  const reading = row.getElementsByClassName('dc-vocab_kana').item(0).textContent;                  if (url && reading && (!definition.reading || definition.reading === reading)) { -                    return audioUrlNormalize(url, 'https://www.japanesepod101.com', '/learningcenter/reference/'); +                    return this.normalizeUrl(url, 'https://www.japanesepod101.com', '/learningcenter/reference/');                  }              } catch (e) {                  // NOP @@ -62,8 +106,9 @@ const audioUrlBuilders = new Map([          }          throw new Error('Failed to find audio URL'); -    }], -    ['jisho', async (definition) => { +    } + +    async _getUriJisho(definition) {          const response = await new Promise((resolve, reject) => {              const xhr = new XMLHttpRequest();              xhr.open('GET', `https://jisho.org/search/${definition.expression}`); @@ -78,7 +123,7 @@ const audioUrlBuilders = new Map([              if (audio !== null) {                  const url = audio.getElementsByTagName('source').item(0).getAttribute('src');                  if (url) { -                    return audioUrlNormalize(url, 'https://jisho.org', '/search/'); +                    return this.normalizeUrl(url, 'https://jisho.org', '/search/');                  }              }          } catch (e) { @@ -86,101 +131,28 @@ const audioUrlBuilders = new Map([          }          throw new Error('Failed to find audio URL'); -    }], -    ['text-to-speech', async (definition, options) => { +    } + +    async _getUriTextToSpeech(definition, options) {          const voiceURI = options.audio.textToSpeechVoice;          if (!voiceURI) {              throw new Error('No voice');          }          return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(voiceURI)}`; -    }], -    ['text-to-speech-reading', async (definition, options) => { +    } + +    async _getUriTextToSpeechReading(definition, options) {          const voiceURI = options.audio.textToSpeechVoice;          if (!voiceURI) {              throw new Error('No voice');          }          return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(voiceURI)}`; -    }], -    ['custom', async (definition, options) => { -        const customSourceUrl = options.audio.customSourceUrl; -        return customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(definition, m1) ? `${definition[m1]}` : m0)); -    }] -]); - -async function audioGetUrl(definition, mode, options, download) { -    const handler = audioUrlBuilders.get(mode); -    if (typeof handler === 'function') { -        try { -            return await handler(definition, options, download); -        } catch (e) { -            // NOP -        } -    } -    return null; -} - -function audioUrlNormalize(url, baseUrl, basePath) { -    if (url) { -        if (url[0] === '/') { -            if (url.length >= 2 && url[1] === '/') { -                // Begins with "//" -                url = baseUrl.substring(0, baseUrl.indexOf(':') + 1) + url; -            } else { -                // Begins with "/" -                url = baseUrl + url; -            } -        } else if (!/^[a-z][a-z0-9\-+.]*:/i.test(url)) { -            // No URI scheme => relative path -            url = baseUrl + basePath + url; -        } -    } -    return url; -} - -function audioBuildFilename(definition) { -    if (definition.reading || definition.expression) { -        let filename = 'yomichan'; -        if (definition.reading) { -            filename += `_${definition.reading}`; -        } -        if (definition.expression) { -            filename += `_${definition.expression}`; -        } - -        return filename += '.mp3'; -    } -    return null; -} - -async function audioInject(definition, fields, sources, optionsContext) { -    let usesAudio = false; -    for (const name in fields) { -        if (fields[name].includes('{audio}')) { -            usesAudio = true; -            break; -        } -    } - -    if (!usesAudio) { -        return true;      } -    try { -        const expressions = definition.expressions; -        const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition; - -        const {url} = await audioGetFromSources(audioSourceDefinition, sources, optionsContext, true); -        if (url !== null) { -            const filename = audioBuildFilename(audioSourceDefinition); -            if (filename !== null) { -                definition.audio = {url, filename}; -            } -        } - -        return true; -    } catch (e) { -        return false; +    async _getUriCustom(definition, options) { +        const customSourceUrl = options.audio.customSourceUrl; +        return customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(definition, m1) ? `${definition[m1]}` : m0));      }  } diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index e3bf7bda..978c5a4a 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -16,30 +16,51 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global optionsSave, utilIsolate -conditionsTestValue, profileConditionsDescriptor, profileOptionsGetDefaultFieldTemplates -handlebarsRenderDynamic -requestText, requestJson, optionsLoad -dictConfigured, dictTermsSort, dictEnabledSet, dictNoteFormat -audioGetUrl, audioInject -jpConvertReading, jpDistributeFuriganaInflected, jpKatakanaToHiragana -Translator, AnkiConnect, AnkiNull, Mecab, BackendApiForwarder, JsonSchema, ClipboardMonitor*/ +/* global + * AnkiConnect + * AnkiNoteBuilder + * AnkiNull + * AudioSystem + * AudioUriBuilder + * BackendApiForwarder + * ClipboardMonitor + * JsonSchema + * Mecab + * Translator + * conditionsTestValue + * dictConfigured + * dictEnabledSet + * dictTermsSort + * handlebarsRenderDynamic + * jpConvertReading + * jpDistributeFuriganaInflected + * jpKatakanaToHiragana + * optionsLoad + * optionsSave + * profileConditionsDescriptor + * requestJson + * requestText + * utilIsolate + */  class Backend {      constructor() {          this.translator = new Translator();          this.anki = new AnkiNull();          this.mecab = new Mecab(); -        this.clipboardMonitor = new ClipboardMonitor(); +        this.clipboardMonitor = new ClipboardMonitor({getClipboard: this._onApiClipboardGet.bind(this)}); +        this.ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: this._renderTemplate.bind(this)});          this.options = null;          this.optionsSchema = null; +        this.defaultAnkiFieldTemplates = null; +        this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)}); +        this.audioUriBuilder = new AudioUriBuilder();          this.optionsContext = {              depth: 0,              url: window.location.href          }; -        this.isPreparedResolve = null; -        this.isPreparedPromise = new Promise((resolve) => (this.isPreparedResolve = resolve)); +        this.isPrepared = false;          this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target'); @@ -48,12 +69,50 @@ class Backend {          this.apiForwarder = new BackendApiForwarder();          this.messageToken = yomichan.generateId(16); + +        this._messageHandlers = new Map([ +            ['yomichanCoreReady', this._onApiYomichanCoreReady.bind(this)], +            ['optionsSchemaGet', this._onApiOptionsSchemaGet.bind(this)], +            ['optionsGet', this._onApiOptionsGet.bind(this)], +            ['optionsGetFull', this._onApiOptionsGetFull.bind(this)], +            ['optionsSet', this._onApiOptionsSet.bind(this)], +            ['optionsSave', this._onApiOptionsSave.bind(this)], +            ['kanjiFind', this._onApiKanjiFind.bind(this)], +            ['termsFind', this._onApiTermsFind.bind(this)], +            ['textParse', this._onApiTextParse.bind(this)], +            ['textParseMecab', this._onApiTextParseMecab.bind(this)], +            ['definitionAdd', this._onApiDefinitionAdd.bind(this)], +            ['definitionsAddable', this._onApiDefinitionsAddable.bind(this)], +            ['noteView', this._onApiNoteView.bind(this)], +            ['templateRender', this._onApiTemplateRender.bind(this)], +            ['commandExec', this._onApiCommandExec.bind(this)], +            ['audioGetUri', this._onApiAudioGetUri.bind(this)], +            ['screenshotGet', this._onApiScreenshotGet.bind(this)], +            ['forward', this._onApiForward.bind(this)], +            ['frameInformationGet', this._onApiFrameInformationGet.bind(this)], +            ['injectStylesheet', this._onApiInjectStylesheet.bind(this)], +            ['getEnvironmentInfo', this._onApiGetEnvironmentInfo.bind(this)], +            ['clipboardGet', this._onApiClipboardGet.bind(this)], +            ['getDisplayTemplatesHtml', this._onApiGetDisplayTemplatesHtml.bind(this)], +            ['getQueryParserTemplatesHtml', this._onApiGetQueryParserTemplatesHtml.bind(this)], +            ['getZoom', this._onApiGetZoom.bind(this)], +            ['getMessageToken', this._onApiGetMessageToken.bind(this)], +            ['getDefaultAnkiFieldTemplates', this._onApiGetDefaultAnkiFieldTemplates.bind(this)] +        ]); + +        this._commandHandlers = new Map([ +            ['search', this._onCommandSearch.bind(this)], +            ['help', this._onCommandHelp.bind(this)], +            ['options', this._onCommandOptions.bind(this)], +            ['toggle', this._onCommandToggle.bind(this)] +        ]);      }      async prepare() {          await this.translator.prepare();          this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET'); +        this.defaultAnkiFieldTemplates = await requestText(chrome.runtime.getURL('/bg/data/default-anki-field-templates.handlebars'), 'GET');          this.options = await optionsLoad();          try {              this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, this.options); @@ -65,42 +124,47 @@ class Backend {          this.onOptionsUpdated('background');          if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) { -            chrome.commands.onCommand.addListener((command) => this._runCommand(command)); +            chrome.commands.onCommand.addListener(this._runCommand.bind(this));          }          if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) { -            chrome.tabs.onZoomChange.addListener((info) => this._onZoomChange(info)); +            chrome.tabs.onZoomChange.addListener(this._onZoomChange.bind(this));          }          chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); -        const options = this.getOptionsSync(this.optionsContext); +        this.isPrepared = true; + +        const options = this.getOptions(this.optionsContext);          if (options.general.showGuide) {              chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')});          } -        this.isPreparedResolve(); -        this.isPreparedResolve = null; -        this.isPreparedPromise = null; +        this.clipboardMonitor.on('change', this._onClipboardText.bind(this)); -        this.clipboardMonitor.onClipboardText = (text) => this._onClipboardText(text); +        this._sendMessageAllTabs('backendPrepared'); +        const callback = () => this.checkLastError(chrome.runtime.lastError); +        chrome.runtime.sendMessage({action: 'backendPrepared'}, callback);      } -    onOptionsUpdated(source) { -        this.applyOptions(); - +    _sendMessageAllTabs(action, params={}) {          const callback = () => this.checkLastError(chrome.runtime.lastError);          chrome.tabs.query({}, (tabs) => {              for (const tab of tabs) { -                chrome.tabs.sendMessage(tab.id, {action: 'optionsUpdated', params: {source}}, callback); +                chrome.tabs.sendMessage(tab.id, {action, params}, callback);              }          });      } +    onOptionsUpdated(source) { +        this.applyOptions(); +        this._sendMessageAllTabs('optionsUpdated', {source}); +    } +      onMessage({action, params}, sender, callback) { -        const handler = Backend._messageHandlers.get(action); +        const handler = this._messageHandlers.get(action);          if (typeof handler !== 'function') { return false; }          try { -            const promise = handler(this, params, sender); +            const promise = handler(params, sender);              promise.then(                  (result) => callback({result}),                  (error) => callback({error: errorToJson(error)}) @@ -112,7 +176,7 @@ class Backend {          }      } -    _onClipboardText(text) { +    _onClipboardText({text}) {          this._onCommandSearch({mode: 'popup', query: text});      } @@ -122,7 +186,7 @@ class Backend {      }      applyOptions() { -        const options = this.getOptionsSync(this.optionsContext); +        const options = this.getOptions(this.optionsContext);          if (!options.general.enable) {              this.setExtensionBadgeBackgroundColor('#555555');              this.setExtensionBadgeText('off'); @@ -148,24 +212,15 @@ class Backend {          }      } -    async getOptionsSchema() { -        if (this.isPreparedPromise !== null) { -            await this.isPreparedPromise; -        } +    getOptionsSchema() {          return this.optionsSchema;      } -    async getFullOptions() { -        if (this.isPreparedPromise !== null) { -            await this.isPreparedPromise; -        } +    getFullOptions() {          return this.options;      } -    async setFullOptions(options) { -        if (this.isPreparedPromise !== null) { -            await this.isPreparedPromise; -        } +    setFullOptions(options) {          try {              this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, utilIsolate(options));          } catch (e) { @@ -174,18 +229,11 @@ class Backend {          }      } -    async getOptions(optionsContext) { -        if (this.isPreparedPromise !== null) { -            await this.isPreparedPromise; -        } -        return this.getOptionsSync(optionsContext); -    } - -    getOptionsSync(optionsContext) { -        return this.getProfileSync(optionsContext).options; +    getOptions(optionsContext) { +        return this.getProfile(optionsContext).options;      } -    getProfileSync(optionsContext) { +    getProfile(optionsContext) {          const profiles = this.options.profiles;          if (typeof optionsContext.index === 'number') {              return profiles[optionsContext.index]; @@ -243,29 +291,43 @@ class Backend {      }      _runCommand(command, params) { -        const handler = Backend._commandHandlers.get(command); +        const handler = this._commandHandlers.get(command);          if (typeof handler !== 'function') { return false; } -        handler(this, params); +        handler(params);          return true;      }      // Message handlers -    _onApiOptionsSchemaGet() { +    _onApiYomichanCoreReady(_params, sender) { +        // tab ID isn't set in background (e.g. browser_action) +        if (typeof sender.tab === 'undefined') { +            const callback = () => this.checkLastError(chrome.runtime.lastError); +            chrome.runtime.sendMessage({action: 'backendPrepared'}, callback); +            return Promise.resolve(); +        } + +        const tabId = sender.tab.id; +        return new Promise((resolve) => { +            chrome.tabs.sendMessage(tabId, {action: 'backendPrepared'}, resolve); +        }); +    } + +    async _onApiOptionsSchemaGet() {          return this.getOptionsSchema();      } -    _onApiOptionsGet({optionsContext}) { +    async _onApiOptionsGet({optionsContext}) {          return this.getOptions(optionsContext);      } -    _onApiOptionsGetFull() { +    async _onApiOptionsGetFull() {          return this.getFullOptions();      }      async _onApiOptionsSet({changedOptions, optionsContext, source}) { -        const options = await this.getOptions(optionsContext); +        const options = this.getOptions(optionsContext);          function getValuePaths(obj) {              const valuePaths = []; @@ -305,20 +367,20 @@ class Backend {      }      async _onApiOptionsSave({source}) { -        const options = await this.getFullOptions(); +        const options = this.getFullOptions();          await optionsSave(options);          this.onOptionsUpdated(source);      }      async _onApiKanjiFind({text, optionsContext}) { -        const options = await this.getOptions(optionsContext); +        const options = this.getOptions(optionsContext);          const definitions = await this.translator.findKanji(text, options);          definitions.splice(options.general.maxResults);          return definitions;      }      async _onApiTermsFind({text, details, optionsContext}) { -        const options = await this.getOptions(optionsContext); +        const options = this.getOptions(optionsContext);          const mode = options.general.resultOutputMode;          const [definitions, length] = await this.translator.findTerms(mode, text, details, options);          definitions.splice(options.general.maxResults); @@ -326,7 +388,7 @@ class Backend {      }      async _onApiTextParse({text, optionsContext}) { -        const options = await this.getOptions(optionsContext); +        const options = this.getOptions(optionsContext);          const results = [];          while (text.length > 0) {              const term = []; @@ -356,12 +418,12 @@ class Backend {      }      async _onApiTextParseMecab({text, optionsContext}) { -        const options = await this.getOptions(optionsContext); -        const results = {}; +        const options = this.getOptions(optionsContext); +        const results = [];          const rawResults = await this.mecab.parseText(text); -        for (const mecabName in rawResults) { +        for (const [mecabName, parsedLines] of Object.entries(rawResults)) {              const result = []; -            for (const parsedLine of rawResults[mecabName]) { +            for (const parsedLine of parsedLines) {                  for (const {expression, reading, source} of parsedLine) {                      const term = [];                      if (expression !== null && reading !== null) { @@ -381,17 +443,17 @@ class Backend {                  }                  result.push([{text: '\n'}]);              } -            results[mecabName] = result; +            results.push([mecabName, result]);          }          return results;      }      async _onApiDefinitionAdd({definition, mode, context, optionsContext}) { -        const options = await this.getOptions(optionsContext); -        const templates = Backend._getTemplates(options); +        const options = this.getOptions(optionsContext); +        const templates = this.defaultAnkiFieldTemplates;          if (mode !== 'kanji') { -            await audioInject( +            await this._audioInject(                  definition,                  options.anki.terms.fields,                  options.audio.sources, @@ -407,20 +469,20 @@ class Backend {              );          } -        const note = await dictNoteFormat(definition, mode, options, templates); +        const note = await this.ankiNoteBuilder.createNote(definition, mode, options, templates);          return this.anki.addNote(note);      }      async _onApiDefinitionsAddable({definitions, modes, optionsContext}) { -        const options = await this.getOptions(optionsContext); -        const templates = Backend._getTemplates(options); +        const options = this.getOptions(optionsContext); +        const templates = this.defaultAnkiFieldTemplates;          const states = [];          try {              const notes = [];              for (const definition of definitions) {                  for (const mode of modes) { -                    const note = await dictNoteFormat(definition, mode, options, templates); +                    const note = await this.ankiNoteBuilder.createNote(definition, mode, options, templates);                      notes.push(note);                  }              } @@ -459,20 +521,20 @@ class Backend {      }      async _onApiNoteView({noteId}) { -        return this.anki.guiBrowse(`nid:${noteId}`); +        return await this.anki.guiBrowse(`nid:${noteId}`);      }      async _onApiTemplateRender({template, data}) { -        return handlebarsRenderDynamic(template, data); +        return this._renderTemplate(template, data);      }      async _onApiCommandExec({command, params}) {          return this._runCommand(command, params);      } -    async _onApiAudioGetUrl({definition, source, optionsContext}) { -        const options = await this.getOptions(optionsContext); -        return await audioGetUrl(definition, source, options); +    async _onApiAudioGetUri({definition, source, optionsContext}) { +        const options = this.getOptions(optionsContext); +        return await this.audioUriBuilder.getUri(definition, source, options);      }      _onApiScreenshotGet({options}, sender) { @@ -621,12 +683,16 @@ class Backend {          return this.messageToken;      } +    async _onApiGetDefaultAnkiFieldTemplates() { +        return this.defaultAnkiFieldTemplates; +    } +      // Command handlers      async _onCommandSearch(params) {          const {mode='existingOrNewTab', query} = params || {}; -        const options = await this.getOptions(this.optionsContext); +        const options = this.getOptions(this.optionsContext);          const {popupWidth, popupHeight} = options.general;          const baseUrl = chrome.runtime.getURL('/bg/search.html'); @@ -647,7 +713,7 @@ class Backend {                  await Backend._focusTab(tab);                  if (queryParams.query) {                      await new Promise((resolve) => chrome.tabs.sendMessage( -                        tab.id, {action: 'searchQueryUpdate', params: {query: queryParams.query}}, resolve +                        tab.id, {action: 'searchQueryUpdate', params: {text: queryParams.query}}, resolve                      ));                  }                  return true; @@ -693,9 +759,10 @@ class Backend {      }      _onCommandOptions(params) { -        if (!(params && params.newTab)) { +        const {mode='existingOrNewTab'} = params || {}; +        if (mode === 'existingOrNewTab') {              chrome.runtime.openOptionsPage(); -        } else { +        } else if (mode === 'newTab') {              const manifest = chrome.runtime.getManifest();              const url = chrome.runtime.getURL(manifest.options_ui.page);              chrome.tabs.create({url}); @@ -709,17 +776,56 @@ class Backend {          };          const source = 'popup'; -        const options = await this.getOptions(optionsContext); +        const options = this.getOptions(optionsContext);          options.general.enable = !options.general.enable;          await this._onApiOptionsSave({source});      }      // Utilities +    async _getAudioUri(definition, source, details) { +        let optionsContext = (typeof details === 'object' && details !== null ? details.optionsContext : null); +        if (!(typeof optionsContext === 'object' && optionsContext !== null)) { +            optionsContext = this.optionsContext; +        } + +        const options = this.getOptions(optionsContext); +        return await this.audioUriBuilder.getUri(definition, source, options); +    } + +    async _audioInject(definition, fields, sources, optionsContext) { +        let usesAudio = false; +        for (const fieldValue of Object.values(fields)) { +            if (fieldValue.includes('{audio}')) { +                usesAudio = true; +                break; +            } +        } + +        if (!usesAudio) { +            return true; +        } + +        try { +            const expressions = definition.expressions; +            const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition; + +            const {uri} = await this.audioSystem.getDefinitionAudio(audioSourceDefinition, sources, {tts: false, optionsContext}); +            const filename = this._createInjectedAudioFileName(audioSourceDefinition); +            if (filename !== null) { +                definition.audio = {url: uri, filename}; +            } + +            return true; +        } catch (e) { +            return false; +        } +    } +      async _injectScreenshot(definition, fields, screenshot) {          let usesScreenshot = false; -        for (const name in fields) { -            if (fields[name].includes('{screenshot}')) { +        for (const fieldValue of Object.values(fields)) { +            if (fieldValue.includes('{screenshot}')) {                  usesScreenshot = true;                  break;              } @@ -752,6 +858,21 @@ class Backend {          definition.screenshotFileName = filename;      } +    async _renderTemplate(template, data) { +        return handlebarsRenderDynamic(template, data); +    } + +    _createInjectedAudioFileName(definition) { +        const {reading, expression} = definition; +        if (!reading && !expression) { return null; } + +        let filename = 'yomichan'; +        if (reading) { filename += `_${reading}`; } +        if (expression) { filename += `_${expression}`; } +        filename += '.mp3'; +        return filename; +    } +      static _getTabUrl(tab) {          return new Promise((resolve) => {              chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => { @@ -860,47 +981,7 @@ class Backend {              return 'chrome';          }      } - -    static _getTemplates(options) { -        const templates = options.anki.fieldTemplates; -        return typeof templates === 'string' ? templates : profileOptionsGetDefaultFieldTemplates(); -    }  } -Backend._messageHandlers = new Map([ -    ['optionsSchemaGet', (self, ...args) => self._onApiOptionsSchemaGet(...args)], -    ['optionsGet', (self, ...args) => self._onApiOptionsGet(...args)], -    ['optionsGetFull', (self, ...args) => self._onApiOptionsGetFull(...args)], -    ['optionsSet', (self, ...args) => self._onApiOptionsSet(...args)], -    ['optionsSave', (self, ...args) => self._onApiOptionsSave(...args)], -    ['kanjiFind', (self, ...args) => self._onApiKanjiFind(...args)], -    ['termsFind', (self, ...args) => self._onApiTermsFind(...args)], -    ['textParse', (self, ...args) => self._onApiTextParse(...args)], -    ['textParseMecab', (self, ...args) => self._onApiTextParseMecab(...args)], -    ['definitionAdd', (self, ...args) => self._onApiDefinitionAdd(...args)], -    ['definitionsAddable', (self, ...args) => self._onApiDefinitionsAddable(...args)], -    ['noteView', (self, ...args) => self._onApiNoteView(...args)], -    ['templateRender', (self, ...args) => self._onApiTemplateRender(...args)], -    ['commandExec', (self, ...args) => self._onApiCommandExec(...args)], -    ['audioGetUrl', (self, ...args) => self._onApiAudioGetUrl(...args)], -    ['screenshotGet', (self, ...args) => self._onApiScreenshotGet(...args)], -    ['forward', (self, ...args) => self._onApiForward(...args)], -    ['frameInformationGet', (self, ...args) => self._onApiFrameInformationGet(...args)], -    ['injectStylesheet', (self, ...args) => self._onApiInjectStylesheet(...args)], -    ['getEnvironmentInfo', (self, ...args) => self._onApiGetEnvironmentInfo(...args)], -    ['clipboardGet', (self, ...args) => self._onApiClipboardGet(...args)], -    ['getDisplayTemplatesHtml', (self, ...args) => self._onApiGetDisplayTemplatesHtml(...args)], -    ['getQueryParserTemplatesHtml', (self, ...args) => self._onApiGetQueryParserTemplatesHtml(...args)], -    ['getZoom', (self, ...args) => self._onApiGetZoom(...args)], -    ['getMessageToken', (self, ...args) => self._onApiGetMessageToken(...args)] -]); - -Backend._commandHandlers = new Map([ -    ['search', (self, ...args) => self._onCommandSearch(...args)], -    ['help', (self, ...args) => self._onCommandHelp(...args)], -    ['options', (self, ...args) => self._onCommandOptions(...args)], -    ['toggle', (self, ...args) => self._onCommandToggle(...args)] -]); -  window.yomichanBackend = new Backend();  window.yomichanBackend.prepare(); diff --git a/ext/bg/js/clipboard-monitor.js b/ext/bg/js/clipboard-monitor.js index c2f41385..9a881f57 100644 --- a/ext/bg/js/clipboard-monitor.js +++ b/ext/bg/js/clipboard-monitor.js @@ -16,66 +16,66 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiClipboardGet, jpIsStringPartiallyJapanese*/ - -class ClipboardMonitor { -    constructor() { -        this.timerId = null; -        this.timerToken = null; -        this.interval = 250; -        this.previousText = null; -    } +/* global + * jpIsStringPartiallyJapanese + */ -    onClipboardText(_text) { -        throw new Error('Override me'); +class ClipboardMonitor extends EventDispatcher { +    constructor({getClipboard}) { +        super(); +        this._timerId = null; +        this._timerToken = null; +        this._interval = 250; +        this._previousText = null; +        this._getClipboard = getClipboard;      }      start() {          this.stop();          // The token below is used as a unique identifier to ensure that a new clipboard monitor -        // hasn't been started during the await call. The check below the await apiClipboardGet() +        // hasn't been started during the await call. The check below the await this._getClipboard()          // call will exit early if the reference has changed.          const token = {};          const intervalCallback = async () => { -            this.timerId = null; +            this._timerId = null;              let text = null;              try { -                text = await apiClipboardGet(); +                text = await this._getClipboard();              } catch (e) {                  // NOP              } -            if (this.timerToken !== token) { return; } +            if (this._timerToken !== token) { return; }              if (                  typeof text === 'string' &&                  (text = text.trim()).length > 0 && -                text !== this.previousText +                text !== this._previousText              ) { -                this.previousText = text; +                this._previousText = text;                  if (jpIsStringPartiallyJapanese(text)) { -                    this.onClipboardText(text); +                    this.trigger('change', {text});                  }              } -            this.timerId = setTimeout(intervalCallback, this.interval); +            this._timerId = setTimeout(intervalCallback, this._interval);          }; -        this.timerToken = token; +        this._timerToken = token;          intervalCallback();      }      stop() { -        this.timerToken = null; -        if (this.timerId !== null) { -            clearTimeout(this.timerId); -            this.timerId = null; +        this._timerToken = null; +        if (this._timerId !== null) { +            clearTimeout(this._timerId); +            this._timerId = null;          }      }      setPreviousText(text) { -        this.previousText = text; +        this._previousText = text;      }  } diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js index bec964fb..c3e74656 100644 --- a/ext/bg/js/context.js +++ b/ext/bg/js/context.js @@ -16,7 +16,11 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiCommandExec, apiGetEnvironmentInfo, apiOptionsGet*/ +/* global + * apiCommandExec + * apiGetEnvironmentInfo + * apiOptionsGet + */  function showExtensionInfo() {      const node = document.getElementById('extension-info'); @@ -48,7 +52,9 @@ function setupButtonEvents(selector, command, url) {      }  } -window.addEventListener('DOMContentLoaded', () => { +window.addEventListener('DOMContentLoaded', async () => { +    await yomichan.prepare(); +      showExtensionInfo();      apiGetEnvironmentInfo().then(({browser}) => { diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js index 558f3ceb..08a2a39f 100644 --- a/ext/bg/js/database.js +++ b/ext/bg/js/database.js @@ -16,7 +16,12 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global dictFieldSplit, requestJson, JsonSchema, JSZip*/ +/* global + * JSZip + * JsonSchema + * dictFieldSplit + * requestJson + */  class Database {      constructor() { diff --git a/ext/bg/js/deinflector.js b/ext/bg/js/deinflector.js index e2ced965..d548d271 100644 --- a/ext/bg/js/deinflector.js +++ b/ext/bg/js/deinflector.js @@ -57,9 +57,9 @@ class Deinflector {      static normalizeReasons(reasons) {          const normalizedReasons = []; -        for (const reason in reasons) { +        for (const [reason, reasonInfo] of Object.entries(reasons)) {              const variants = []; -            for (const {kanaIn, kanaOut, rulesIn, rulesOut} of reasons[reason]) { +            for (const {kanaIn, kanaOut, rulesIn, rulesOut} of reasonInfo) {                  variants.push([                      kanaIn,                      kanaOut, diff --git a/ext/bg/js/dictionary.js b/ext/bg/js/dictionary.js index f5c5b21b..3dd1d0c1 100644 --- a/ext/bg/js/dictionary.js +++ b/ext/bg/js/dictionary.js @@ -16,26 +16,18 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiTemplateRender*/ -  function dictEnabledSet(options) {      const enabledDictionaryMap = new Map(); -    const optionsDictionaries = options.dictionaries; -    for (const title in optionsDictionaries) { -        if (!hasOwn(optionsDictionaries, title)) { continue; } -        const dictionary = optionsDictionaries[title]; -        if (!dictionary.enabled) { continue; } -        enabledDictionaryMap.set(title, { -            priority: dictionary.priority || 0, -            allowSecondarySearches: !!dictionary.allowSecondarySearches -        }); +    for (const [title, {enabled, priority, allowSecondarySearches}] of Object.entries(options.dictionaries)) { +        if (!enabled) { continue; } +        enabledDictionaryMap.set(title, {priority, allowSecondarySearches});      }      return enabledDictionaryMap;  }  function dictConfigured(options) { -    for (const title in options.dictionaries) { -        if (options.dictionaries[title].enabled) { +    for (const {enabled} of Object.values(options.dictionaries)) { +        if (enabled) {              return true;          }      } @@ -339,90 +331,3 @@ function dictTagsSort(tags) {  function dictFieldSplit(field) {      return field.length === 0 ? [] : field.split(' ');  } - -async function dictFieldFormat(field, definition, mode, options, templates, exceptions) { -    const data = { -        marker: null, -        definition, -        group: options.general.resultOutputMode === 'group', -        merge: options.general.resultOutputMode === 'merge', -        modeTermKanji: mode === 'term-kanji', -        modeTermKana: mode === 'term-kana', -        modeKanji: mode === 'kanji', -        compactGlossaries: options.general.compactGlossaries -    }; -    const markers = dictFieldFormat.markers; -    const pattern = /\{([\w-]+)\}/g; -    return await stringReplaceAsync(field, pattern, async (g0, marker) => { -        if (!markers.has(marker)) { -            return g0; -        } -        data.marker = marker; -        try { -            return await apiTemplateRender(templates, data); -        } catch (e) { -            if (exceptions) { exceptions.push(e); } -            return `{${marker}-render-error}`; -        } -    }); -} -dictFieldFormat.markers = new Set([ -    'audio', -    'character', -    'cloze-body', -    'cloze-prefix', -    'cloze-suffix', -    'dictionary', -    'expression', -    'furigana', -    'furigana-plain', -    'glossary', -    'glossary-brief', -    'kunyomi', -    'onyomi', -    'reading', -    'screenshot', -    'sentence', -    'tags', -    'url' -]); - -async function dictNoteFormat(definition, mode, options, templates) { -    const note = {fields: {}, tags: options.anki.tags}; -    let fields = []; - -    if (mode === 'kanji') { -        fields = options.anki.kanji.fields; -        note.deckName = options.anki.kanji.deck; -        note.modelName = options.anki.kanji.model; -    } else { -        fields = options.anki.terms.fields; -        note.deckName = options.anki.terms.deck; -        note.modelName = options.anki.terms.model; - -        if (definition.audio) { -            const audio = { -                url: definition.audio.url, -                filename: definition.audio.filename, -                skipHash: '7e2c2f954ef6051373ba916f000168dc', -                fields: [] -            }; - -            for (const name in fields) { -                if (fields[name].includes('{audio}')) { -                    audio.fields.push(name); -                } -            } - -            if (audio.fields.length > 0) { -                note.audio = audio; -            } -        } -    } - -    for (const name in fields) { -        note.fields[name] = await dictFieldFormat(fields[name], definition, mode, options, templates); -    } - -    return note; -} diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js index b1443447..e3ce6bd0 100644 --- a/ext/bg/js/handlebars.js +++ b/ext/bg/js/handlebars.js @@ -16,7 +16,11 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global jpIsCharCodeKanji, jpDistributeFurigana, Handlebars*/ +/* global + * Handlebars + * jpDistributeFurigana + * jpIsCodePointKanji + */  function handlebarsEscape(text) {      return Handlebars.Utils.escapeExpression(text); @@ -62,7 +66,7 @@ function handlebarsFuriganaPlain(options) {  function handlebarsKanjiLinks(options) {      let result = '';      for (const c of options.fn(this)) { -        if (jpIsCharCodeKanji(c.charCodeAt(0))) { +        if (jpIsCodePointKanji(c.codePointAt(0))) {              result += `<a href="#" class="kanji-link">${c}</a>`;          } else {              result += c; diff --git a/ext/bg/js/japanese.js b/ext/bg/js/japanese.js index abb32da4..3b37754d 100644 --- a/ext/bg/js/japanese.js +++ b/ext/bg/js/japanese.js @@ -16,7 +16,9 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global wanakana*/ +/* global + * wanakana + */  const JP_HALFWIDTH_KATAKANA_MAPPING = new Map([      ['ヲ', 'ヲヺ-'], @@ -115,9 +117,9 @@ const JP_JAPANESE_RANGES = [  // Helper functions -function _jpIsCharCodeInRanges(charCode, ranges) { +function _jpIsCodePointInRanges(codePoint, ranges) {      for (const [min, max] of ranges) { -        if (charCode >= min && charCode <= max) { +        if (codePoint >= min && codePoint <= max) {              return true;          }      } @@ -127,16 +129,16 @@ function _jpIsCharCodeInRanges(charCode, ranges) {  // Character code testing functions -function jpIsCharCodeKanji(charCode) { -    return _jpIsCharCodeInRanges(charCode, JP_CJK_RANGES); +function jpIsCodePointKanji(codePoint) { +    return _jpIsCodePointInRanges(codePoint, JP_CJK_RANGES);  } -function jpIsCharCodeKana(charCode) { -    return _jpIsCharCodeInRanges(charCode, JP_KANA_RANGES); +function jpIsCodePointKana(codePoint) { +    return _jpIsCodePointInRanges(codePoint, JP_KANA_RANGES);  } -function jpIsCharCodeJapanese(charCode) { -    return _jpIsCharCodeInRanges(charCode, JP_JAPANESE_RANGES); +function jpIsCodePointJapanese(codePoint) { +    return _jpIsCodePointInRanges(codePoint, JP_JAPANESE_RANGES);  } @@ -144,8 +146,8 @@ function jpIsCharCodeJapanese(charCode) {  function jpIsStringEntirelyKana(str) {      if (str.length === 0) { return false; } -    for (let i = 0, ii = str.length; i < ii; ++i) { -        if (!jpIsCharCodeKana(str.charCodeAt(i))) { +    for (const c of str) { +        if (!jpIsCodePointKana(c.codePointAt(0))) {              return false;          }      } @@ -154,8 +156,8 @@ function jpIsStringEntirelyKana(str) {  function jpIsStringPartiallyJapanese(str) {      if (str.length === 0) { return false; } -    for (let i = 0, ii = str.length; i < ii; ++i) { -        if (jpIsCharCodeJapanese(str.charCodeAt(i))) { +    for (const c of str) { +        if (jpIsCodePointJapanese(c.codePointAt(0))) {              return true;          }      } @@ -264,8 +266,8 @@ function jpDistributeFurigana(expression, reading) {      const groups = [];      let modePrev = null;      for (const c of expression) { -        const charCode = c.charCodeAt(0); -        const modeCurr = jpIsCharCodeKanji(charCode) || charCode === JP_ITERATION_MARK_CHAR_CODE ? 'kanji' : 'kana'; +        const codePoint = c.codePointAt(0); +        const modeCurr = jpIsCodePointKanji(codePoint) || codePoint === JP_ITERATION_MARK_CHAR_CODE ? 'kanji' : 'kana';          if (modeCurr === modePrev) {              groups[groups.length - 1].text += c;          } else { @@ -311,10 +313,11 @@ function jpDistributeFuriganaInflected(expression, reading, source) {  function jpConvertHalfWidthKanaToFullWidth(text, sourceMapping) {      let result = ''; -    const ii = text.length;      const hasSourceMapping = Array.isArray(sourceMapping); -    for (let i = 0; i < ii; ++i) { +    // This function is safe to use charCodeAt instead of codePointAt, since all +    // the relevant characters are represented with a single UTF-16 character code. +    for (let i = 0, ii = text.length; i < ii; ++i) {          const c = text[i];          const mapping = JP_HALFWIDTH_KATAKANA_MAPPING.get(c);          if (typeof mapping !== 'string') { @@ -355,13 +358,13 @@ function jpConvertHalfWidthKanaToFullWidth(text, sourceMapping) {  function jpConvertNumericTofullWidth(text) {      let result = ''; -    for (let i = 0, ii = text.length; i < ii; ++i) { -        let c = text.charCodeAt(i); +    for (const char of text) { +        let c = char.codePointAt(0);          if (c >= 0x30 && c <= 0x39) { // ['0', '9']              c += 0xff10 - 0x30; // 0xff10 = '0' full width -            result += String.fromCharCode(c); +            result += String.fromCodePoint(c);          } else { -            result += text[i]; +            result += char;          }      }      return result; @@ -377,9 +380,9 @@ function jpConvertAlphabeticToKana(text, sourceMapping) {          sourceMapping.fill(1);      } -    for (let i = 0; i < ii; ++i) { +    for (const char of text) {          // Note: 0x61 is the character code for 'a' -        let c = text.charCodeAt(i); +        let c = char.codePointAt(0);          if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z']              c += (0x61 - 0x41);          } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z'] @@ -395,10 +398,10 @@ function jpConvertAlphabeticToKana(text, sourceMapping) {                  result += jpToHiragana(part, sourceMapping, result.length);                  part = '';              } -            result += text[i]; +            result += char;              continue;          } -        part += String.fromCharCode(c); +        part += String.fromCodePoint(c);      }      if (part.length > 0) { diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index f9db99a2..bd0bbe0e 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -16,7 +16,9 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global utilStringHashCode*/ +/* global + * utilStringHashCode + */  /*   * Generic options functions @@ -58,22 +60,17 @@ const profileOptionsVersionUpdates = [          options.scanning.modifier = options.scanning.requireShift ? 'shift' : 'none';      },      (options) => { -        const fieldTemplatesDefault = profileOptionsGetDefaultFieldTemplates();          options.general.resultOutputMode = options.general.groupResults ? 'group' : 'split'; -        options.anki.fieldTemplates = ( -            (utilStringHashCode(options.anki.fieldTemplates) !== -805327496) ? -            `{{#if merge}}${fieldTemplatesDefault}{{else}}${options.anki.fieldTemplates}{{/if}}` : -            fieldTemplatesDefault -        ); +        options.anki.fieldTemplates = null;      },      (options) => {          if (utilStringHashCode(options.anki.fieldTemplates) === 1285806040) { -            options.anki.fieldTemplates = profileOptionsGetDefaultFieldTemplates(); +            options.anki.fieldTemplates = null;          }      },      (options) => {          if (utilStringHashCode(options.anki.fieldTemplates) === -250091611) { -            options.anki.fieldTemplates = profileOptionsGetDefaultFieldTemplates(); +            options.anki.fieldTemplates = null;          }      },      (options) => { @@ -97,172 +94,6 @@ const profileOptionsVersionUpdates = [      }  ]; -function profileOptionsGetDefaultFieldTemplates() { -    return ` -{{#*inline "glossary-single"}} -    {{~#unless brief~}} -        {{~#if definitionTags~}}<i>({{#each definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each}})</i> {{/if~}} -        {{~#if only~}}({{#each only}}{{{.}}}{{#unless @last}}, {{/unless}}{{/each}} only) {{/if~}} -    {{~/unless~}} -    {{~#if glossary.[1]~}} -        {{~#if compactGlossaries~}} -            {{#each glossary}}{{#multiLine}}{{.}}{{/multiLine}}{{#unless @last}} | {{/unless}}{{/each}} -        {{~else~}} -            <ul>{{#each glossary}}<li>{{#multiLine}}{{.}}{{/multiLine}}</li>{{/each}}</ul> -        {{~/if~}} -    {{~else~}} -        {{~#multiLine}}{{glossary.[0]}}{{/multiLine~}} -    {{~/if~}} -{{/inline}} - -{{#*inline "audio"}}{{/inline}} - -{{#*inline "character"}} -    {{~definition.character~}} -{{/inline}} - -{{#*inline "dictionary"}} -    {{~definition.dictionary~}} -{{/inline}} - -{{#*inline "expression"}} -    {{~#if merge~}} -        {{~#if modeTermKana~}} -            {{~#each definition.reading~}} -                {{{.}}} -                {{~#unless @last}}、{{/unless~}} -            {{~else~}} -                {{~#each definition.expression~}} -                    {{{.}}} -                    {{~#unless @last}}、{{/unless~}} -                {{~/each~}} -            {{~/each~}} -        {{~else~}} -            {{~#each definition.expression~}} -                {{{.}}} -                {{~#unless @last}}、{{/unless~}} -            {{~/each~}} -        {{~/if~}} -    {{~else~}} -        {{~#if modeTermKana~}} -            {{~#if definition.reading~}} -                {{definition.reading}} -            {{~else~}} -                {{definition.expression}} -            {{~/if~}} -        {{~else~}} -            {{definition.expression}} -        {{~/if~}} -    {{~/if~}} -{{/inline}} - -{{#*inline "furigana"}} -    {{~#if merge~}} -        {{~#each definition.expressions~}} -            <span class="expression-{{termFrequency}}">{{~#furigana}}{{{.}}}{{/furigana~}}</span> -            {{~#unless @last}}、{{/unless~}} -        {{~/each~}} -    {{~else~}} -        {{#furigana}}{{{definition}}}{{/furigana}} -    {{~/if~}} -{{/inline}} - -{{#*inline "furigana-plain"}} -    {{~#if merge~}} -        {{~#each definition.expressions~}} -            <span class="expression-{{termFrequency}}">{{~#furiganaPlain}}{{{.}}}{{/furiganaPlain~}}</span> -            {{~#unless @last}}、{{/unless~}} -        {{~/each~}} -    {{~else~}} -        {{#furiganaPlain}}{{{definition}}}{{/furiganaPlain}} -    {{~/if~}} -{{/inline}} - -{{#*inline "glossary"}} -    <div style="text-align: left;"> -    {{~#if modeKanji~}} -        {{~#if definition.glossary.[1]~}} -            <ol>{{#each definition.glossary}}<li>{{.}}</li>{{/each}}</ol> -        {{~else~}} -            {{definition.glossary.[0]}} -        {{~/if~}} -    {{~else~}} -        {{~#if group~}} -            {{~#if definition.definitions.[1]~}} -                <ol>{{#each definition.definitions}}<li>{{> glossary-single brief=../brief compactGlossaries=../compactGlossaries}}</li>{{/each}}</ol> -            {{~else~}} -                {{~> glossary-single definition.definitions.[0] brief=brief compactGlossaries=compactGlossaries~}} -            {{~/if~}} -        {{~else if merge~}} -            {{~#if definition.definitions.[1]~}} -                <ol>{{#each definition.definitions}}<li>{{> glossary-single brief=../brief compactGlossaries=../compactGlossaries}}</li>{{/each}}</ol> -            {{~else~}} -                {{~> glossary-single definition.definitions.[0] brief=brief compactGlossaries=compactGlossaries~}} -            {{~/if~}} -        {{~else~}} -            {{~> glossary-single definition brief=brief compactGlossaries=compactGlossaries~}} -        {{~/if~}} -    {{~/if~}} -    </div> -{{/inline}} - -{{#*inline "glossary-brief"}} -    {{~> glossary brief=true ~}} -{{/inline}} - -{{#*inline "kunyomi"}} -    {{~#each definition.kunyomi}}{{.}}{{#unless @last}}, {{/unless}}{{/each~}} -{{/inline}} - -{{#*inline "onyomi"}} -    {{~#each definition.onyomi}}{{.}}{{#unless @last}}, {{/unless}}{{/each~}} -{{/inline}} - -{{#*inline "reading"}} -    {{~#unless modeTermKana~}} -        {{~#if merge~}} -            {{~#each definition.reading~}} -                {{{.}}} -                {{~#unless @last}}、{{/unless~}} -            {{~/each~}} -        {{~else~}} -            {{~definition.reading~}} -        {{~/if~}} -    {{~/unless~}} -{{/inline}} - -{{#*inline "sentence"}} -    {{~#if definition.cloze}}{{definition.cloze.sentence}}{{/if~}} -{{/inline}} - -{{#*inline "cloze-prefix"}} -    {{~#if definition.cloze}}{{definition.cloze.prefix}}{{/if~}} -{{/inline}} - -{{#*inline "cloze-body"}} -    {{~#if definition.cloze}}{{definition.cloze.body}}{{/if~}} -{{/inline}} - -{{#*inline "cloze-suffix"}} -    {{~#if definition.cloze}}{{definition.cloze.suffix}}{{/if~}} -{{/inline}} - -{{#*inline "tags"}} -    {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}} -{{/inline}} - -{{#*inline "url"}} -    <a href="{{definition.url}}">{{definition.url}}</a> -{{/inline}} - -{{#*inline "screenshot"}} -    <img src="{{definition.screenshotFileName}}" /> -{{/inline}} - -{{~> (lookup . "marker") ~}} -`.trim(); -} -  function profileOptionsCreateDefaults() {      return {          general: { diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js index 509c4009..a470e873 100644 --- a/ext/bg/js/search-frontend.js +++ b/ext/bg/js/search-frontend.js @@ -16,9 +16,13 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiOptionsGet*/ +/* global + * apiOptionsGet + */  async function searchFrontendSetup() { +    await yomichan.prepare(); +      const optionsContext = {          depth: 0,          url: window.location.href diff --git a/ext/bg/js/search-query-parser-generator.js b/ext/bg/js/search-query-parser-generator.js index 1ab23a82..664858a4 100644 --- a/ext/bg/js/search-query-parser-generator.js +++ b/ext/bg/js/search-query-parser-generator.js @@ -16,7 +16,10 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiGetQueryParserTemplatesHtml, TemplateHandler*/ +/* global + * TemplateHandler + * apiGetQueryParserTemplatesHtml + */  class QueryParserGenerator {      constructor() { diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js index 0d4aaa50..06316ce2 100644 --- a/ext/bg/js/search-query-parser.js +++ b/ext/bg/js/search-query-parser.js @@ -16,7 +16,15 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiTermsFind, apiOptionsSet, apiTextParse, apiTextParseMecab, TextScanner, QueryParserGenerator*/ +/* global + * QueryParserGenerator + * TextScanner + * apiOptionsSet + * apiTermsFind + * apiTextParse + * apiTextParseMecab + * docSentenceExtract + */  class QueryParser extends TextScanner {      constructor(search) { @@ -55,12 +63,14 @@ class QueryParser extends TextScanner {          const {definitions, length} = await apiTermsFind(searchText, {}, this.search.getOptionsContext());          if (definitions.length === 0) { return null; } +        const sentence = docSentenceExtract(textSource, this.search.options.anki.sentenceExt); +          textSource.setEndOffset(length);          this.search.setContent('terms', {definitions, context: {              focus: false,              disableHistory: cause === 'mouse', -            sentence: {text: searchText, offset: 0}, +            sentence,              url: window.location.href          }}); @@ -142,11 +152,11 @@ class QueryParser extends TextScanner {          }          if (this.search.options.parsing.enableMecabParser) {              const mecabResults = await apiTextParseMecab(text, this.search.getOptionsContext()); -            for (const mecabDictName in mecabResults) { +            for (const [mecabDictName, mecabDictResults] of mecabResults) {                  results.push({                      name: `MeCab: ${mecabDictName}`,                      id: `mecab-${mecabDictName}`, -                    parsedText: mecabResults[mecabDictName] +                    parsedText: mecabDictResults                  });              }          } diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index 98e167ad..e2bdff73 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -16,7 +16,14 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiOptionsSet, apiTermsFind, Display, QueryParser, ClipboardMonitor*/ +/* global + * ClipboardMonitor + * Display + * QueryParser + * apiClipboardGet + * apiOptionsSet + * apiTermsFind + */  class DisplaySearch extends Display {      constructor() { @@ -38,7 +45,26 @@ class DisplaySearch extends Display {          this.introVisible = true;          this.introAnimationTimer = null; -        this.clipboardMonitor = new ClipboardMonitor(); +        this.clipboardMonitor = new ClipboardMonitor({getClipboard: apiClipboardGet}); + +        this._onKeyDownIgnoreKeys = new Map([ +            ['ANY_MOD', new Set([ +                'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageDown', 'PageUp', 'Home', 'End', +                'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', +                'F11', 'F12', 'F13', 'F14', 'F15', 'F16', 'F17', 'F18', 'F19', 'F20', +                'F21', 'F22', 'F23', 'F24' +            ])], +            ['Control', new Set(['C', 'A', 'Z', 'Y', 'X', 'F', 'G'])], +            ['Meta', new Set(['C', 'A', 'Z', 'Y', 'X', 'F', 'G'])], +            ['OS', new Set()], +            ['Alt', new Set()], +            ['AltGraph', new Set()], +            ['Shift', new Set()] +        ]); + +        this._runtimeMessageHandlers = new Map([ +            ['searchQueryUpdate', this.onExternalSearchUpdate.bind(this)] +        ]);      }      static create() { @@ -49,76 +75,41 @@ class DisplaySearch extends Display {      async prepare() {          try { -            const superPromise = super.prepare(); -            const queryParserPromise = this.queryParser.prepare(); -            await Promise.all([superPromise, queryParserPromise]); +            await super.prepare(); +            await this.queryParser.prepare();              const {queryParams: {query='', mode=''}} = parseUrl(window.location.href); -            if (this.search !== null) { -                this.search.addEventListener('click', (e) => this.onSearch(e), false); -            } -            if (this.query !== null) { -                document.documentElement.dataset.searchMode = mode; -                this.query.addEventListener('input', () => this.onSearchInput(), false); - -                if (this.wanakanaEnable !== null) { -                    if (this.options.general.enableWanakana === true) { -                        this.wanakanaEnable.checked = true; -                        window.wanakana.bind(this.query); -                    } else { -                        this.wanakanaEnable.checked = false; -                    } -                    this.wanakanaEnable.addEventListener('change', (e) => { -                        const {queryParams: {query: query2=''}} = parseUrl(window.location.href); -                        if (e.target.checked) { -                            window.wanakana.bind(this.query); -                            apiOptionsSet({general: {enableWanakana: true}}, this.getOptionsContext()); -                        } else { -                            window.wanakana.unbind(this.query); -                            apiOptionsSet({general: {enableWanakana: false}}, this.getOptionsContext()); -                        } -                        this.setQuery(query2); -                        this.onSearchQueryUpdated(this.query.value, false); -                    }); -                } +            document.documentElement.dataset.searchMode = mode; -                this.setQuery(query); -                this.onSearchQueryUpdated(this.query.value, false); +            if (this.options.general.enableWanakana === true) { +                this.wanakanaEnable.checked = true; +                window.wanakana.bind(this.query); +            } else { +                this.wanakanaEnable.checked = false;              } -            if (this.clipboardMonitorEnable !== null && mode !== 'popup') { + +            this.setQuery(query); +            this.onSearchQueryUpdated(this.query.value, false); + +            if (mode !== 'popup') {                  if (this.options.general.enableClipboardMonitor === true) {                      this.clipboardMonitorEnable.checked = true;                      this.clipboardMonitor.start();                  } else {                      this.clipboardMonitorEnable.checked = false;                  } -                this.clipboardMonitorEnable.addEventListener('change', (e) => { -                    if (e.target.checked) { -                        chrome.permissions.request( -                            {permissions: ['clipboardRead']}, -                            (granted) => { -                                if (granted) { -                                    this.clipboardMonitor.start(); -                                    apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext()); -                                } else { -                                    e.target.checked = false; -                                } -                            } -                        ); -                    } else { -                        this.clipboardMonitor.stop(); -                        apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext()); -                    } -                }); +                this.clipboardMonitorEnable.addEventListener('change', this.onClipboardMonitorEnableChange.bind(this));              }              chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); -            window.addEventListener('popstate', (e) => this.onPopState(e)); -            window.addEventListener('copy', (e) => this.onCopy(e)); - -            this.clipboardMonitor.onClipboardText = (text) => this.onExternalSearchUpdate(text); +            this.search.addEventListener('click', this.onSearch.bind(this), false); +            this.query.addEventListener('input', this.onSearchInput.bind(this), false); +            this.wanakanaEnable.addEventListener('change', this.onWanakanaEnableChange.bind(this)); +            window.addEventListener('popstate', this.onPopState.bind(this)); +            window.addEventListener('copy', this.onCopy.bind(this)); +            this.clipboardMonitor.on('change', this.onExternalSearchUpdate.bind(this));              this.updateSearchButton();          } catch (e) { @@ -174,28 +165,30 @@ class DisplaySearch extends Display {      }      onRuntimeMessage({action, params}, sender, callback) { -        const handler = DisplaySearch._runtimeMessageHandlers.get(action); +        const handler = this._runtimeMessageHandlers.get(action);          if (typeof handler !== 'function') { return false; } -        const result = handler(this, params, sender); +        const result = handler(params, sender);          callback(result);          return false;      }      onKeyDown(e) {          const key = Display.getKeyFromEvent(e); -        const ignoreKeys = DisplaySearch.onKeyDownIgnoreKeys; +        const ignoreKeys = this._onKeyDownIgnoreKeys; -        const activeModifierMap = { -            'Control': e.ctrlKey, -            'Meta': e.metaKey, -            'ANY_MOD': true -        }; +        const activeModifierMap = new Map([ +            ['Control', e.ctrlKey], +            ['Meta', e.metaKey], +            ['Shift', e.shiftKey], +            ['Alt', e.altKey], +            ['ANY_MOD', true] +        ]);          let preventFocus = false; -        for (const [modifier, keys] of Object.entries(ignoreKeys)) { -            const modifierActive = activeModifierMap[modifier]; -            if (key === modifier || (modifierActive && keys.includes(key))) { +        for (const [modifier, keys] of ignoreKeys.entries()) { +            const modifierActive = activeModifierMap.get(modifier); +            if (key === modifier || (modifierActive && keys.has(key))) {                  preventFocus = true;                  break;              } @@ -211,7 +204,7 @@ class DisplaySearch extends Display {          this.clipboardMonitor.setPreviousText(document.getSelection().toString().trim());      } -    onExternalSearchUpdate(text) { +    onExternalSearchUpdate({text}) {          this.setQuery(text);          const url = new URL(window.location.href);          url.searchParams.set('query', text); @@ -253,6 +246,38 @@ class DisplaySearch extends Display {          }      } +    onWanakanaEnableChange(e) { +        const {queryParams: {query=''}} = parseUrl(window.location.href); +        const enableWanakana = e.target.checked; +        if (enableWanakana) { +            window.wanakana.bind(this.query); +        } else { +            window.wanakana.unbind(this.query); +        } +        this.setQuery(query); +        this.onSearchQueryUpdated(this.query.value, false); +        apiOptionsSet({general: {enableWanakana}}, this.getOptionsContext()); +    } + +    onClipboardMonitorEnableChange(e) { +        if (e.target.checked) { +            chrome.permissions.request( +                {permissions: ['clipboardRead']}, +                (granted) => { +                    if (granted) { +                        this.clipboardMonitor.start(); +                        apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext()); +                    } else { +                        e.target.checked = false; +                    } +                } +            ); +        } else { +            this.clipboardMonitor.stop(); +            apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext()); +        } +    } +      async updateOptions(options) {          await super.updateOptions(options);          this.queryParser.setOptions(this.options); @@ -346,23 +371,4 @@ class DisplaySearch extends Display {      }  } -DisplaySearch.onKeyDownIgnoreKeys = { -    'ANY_MOD': [ -        'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageDown', 'PageUp', 'Home', 'End', -        'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', -        'F11', 'F12', 'F13', 'F14', 'F15', 'F16', 'F17', 'F18', 'F19', 'F20', -        'F21', 'F22', 'F23', 'F24' -    ], -    'Control': ['C', 'A', 'Z', 'Y', 'X', 'F', 'G'], -    'Meta': ['C', 'A', 'Z', 'Y', 'X', 'F', 'G'], -    'OS': [], -    'Alt': [], -    'AltGraph': [], -    'Shift': [] -}; - -DisplaySearch._runtimeMessageHandlers = new Map([ -    ['searchQueryUpdate', (self, {query}) => { self.onExternalSearchUpdate(query); }] -]); -  DisplaySearch.instance = DisplaySearch.create(); diff --git a/ext/bg/js/settings/anki-templates.js b/ext/bg/js/settings/anki-templates.js index 2e80e334..c5222d30 100644 --- a/ext/bg/js/settings/anki-templates.js +++ b/ext/bg/js/settings/anki-templates.js @@ -16,22 +16,33 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global getOptionsContext, getOptionsMutable, settingsSaveOptions -profileOptionsGetDefaultFieldTemplates, ankiGetFieldMarkers, ankiGetFieldMarkersHtml, dictFieldFormat -apiOptionsGet, apiTermsFind*/ +/* global + * AnkiNoteBuilder + * ankiGetFieldMarkers + * ankiGetFieldMarkersHtml + * apiGetDefaultAnkiFieldTemplates + * apiOptionsGet + * apiTemplateRender + * apiTermsFind + * getOptionsContext + * getOptionsMutable + * settingsSaveOptions + */  function onAnkiFieldTemplatesReset(e) {      e.preventDefault();      $('#field-template-reset-modal').modal('show');  } -function onAnkiFieldTemplatesResetConfirm(e) { +async function onAnkiFieldTemplatesResetConfirm(e) {      e.preventDefault();      $('#field-template-reset-modal').modal('hide'); +    const value = await apiGetDefaultAnkiFieldTemplates(); +      const element = document.querySelector('#field-templates'); -    element.value = profileOptionsGetDefaultFieldTemplates(); +    element.value = value;      element.dispatchEvent(new Event('change'));  } @@ -45,10 +56,10 @@ function ankiTemplatesInitialize() {          node.addEventListener('click', onAnkiTemplateMarkerClicked, false);      } -    $('#field-templates').on('change', (e) => onAnkiFieldTemplatesChanged(e)); -    $('#field-template-render').on('click', (e) => onAnkiTemplateRender(e)); -    $('#field-templates-reset').on('click', (e) => onAnkiFieldTemplatesReset(e)); -    $('#field-templates-reset-confirm').on('click', (e) => onAnkiFieldTemplatesResetConfirm(e)); +    $('#field-templates').on('change', onAnkiFieldTemplatesChanged); +    $('#field-template-render').on('click', onAnkiTemplateRender); +    $('#field-templates-reset').on('click', onAnkiFieldTemplatesReset); +    $('#field-templates-reset-confirm').on('click', onAnkiFieldTemplatesResetConfirm);      ankiTemplatesUpdateValue();  } @@ -57,7 +68,7 @@ async function ankiTemplatesUpdateValue() {      const optionsContext = getOptionsContext();      const options = await apiOptionsGet(optionsContext);      let templates = options.anki.fieldTemplates; -    if (typeof templates !== 'string') { templates = profileOptionsGetDefaultFieldTemplates(); } +    if (typeof templates !== 'string') { templates = await apiGetDefaultAnkiFieldTemplates(); }      $('#field-templates').val(templates);      onAnkiTemplatesValidateCompile(); @@ -89,8 +100,9 @@ async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, i          if (definition !== null) {              const options = await apiOptionsGet(optionsContext);              let templates = options.anki.fieldTemplates; -            if (typeof templates !== 'string') { templates = profileOptionsGetDefaultFieldTemplates(); } -            result = await dictFieldFormat(field, definition, mode, options, templates, exceptions); +            if (typeof templates !== 'string') { templates = await apiGetDefaultAnkiFieldTemplates(); } +            const ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: apiTemplateRender}); +            result = await ankiNoteBuilder.formatField(field, definition, mode, options, templates, exceptions);          }      } catch (e) {          exceptions.push(e); @@ -109,7 +121,7 @@ async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, i  async function onAnkiFieldTemplatesChanged(e) {      // Get value      let templates = e.currentTarget.value; -    if (templates === profileOptionsGetDefaultFieldTemplates()) { +    if (templates === await apiGetDefaultAnkiFieldTemplates()) {          // Default          templates = null;      } diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js index 4263fc51..b706cd1b 100644 --- a/ext/bg/js/settings/anki.js +++ b/ext/bg/js/settings/anki.js @@ -16,9 +16,16 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global getOptionsContext, getOptionsMutable, settingsSaveOptions -utilBackgroundIsolate, utilAnkiGetDeckNames, utilAnkiGetModelNames, utilAnkiGetModelFieldNames -onFormOptionsChanged*/ +/* global + * getOptionsContext + * getOptionsMutable + * onFormOptionsChanged + * settingsSaveOptions + * utilAnkiGetDeckNames + * utilAnkiGetModelFieldNames + * utilAnkiGetModelNames + * utilBackgroundIsolate + */  // Private @@ -154,10 +161,10 @@ async function _ankiFieldsPopulate(tabId, options) {      container.appendChild(fragment);      for (const node of container.querySelectorAll('.anki-field-value')) { -        node.addEventListener('change', (e) => onFormOptionsChanged(e), false); +        node.addEventListener('change', onFormOptionsChanged, false);      }      for (const node of container.querySelectorAll('.marker-link')) { -        node.addEventListener('click', (e) => _onAnkiMarkerClicked(e), false); +        node.addEventListener('click', _onAnkiMarkerClicked, false);      }  } @@ -267,7 +274,7 @@ function ankiGetFieldMarkers(type) {  function ankiInitialize() {      for (const node of document.querySelectorAll('#anki-terms-model,#anki-kanji-model')) { -        node.addEventListener('change', (e) => _onAnkiModelChanged(e), false); +        node.addEventListener('change', _onAnkiModelChanged, false);      }  } diff --git a/ext/bg/js/settings/audio-ui.js b/ext/bg/js/settings/audio-ui.js index 555380b4..206539a4 100644 --- a/ext/bg/js/settings/audio-ui.js +++ b/ext/bg/js/settings/audio-ui.js @@ -37,7 +37,7 @@ AudioSourceUI.Container = class Container {              this.children.push(new AudioSourceUI.AudioSource(this, audioSource, this.children.length));          } -        this._clickListener = () => this.onAddAudioSource(); +        this._clickListener = this.onAddAudioSource.bind(this);          this.addButton.addEventListener('click', this._clickListener, false);      } @@ -105,8 +105,8 @@ AudioSourceUI.AudioSource = class AudioSource {          this.select.value = audioSource; -        this._selectChangeListener = () => this.onSelectChanged(); -        this._removeClickListener = () => this.onRemoveClicked(); +        this._selectChangeListener = this.onSelectChanged.bind(this); +        this._removeClickListener = this.onRemoveClicked.bind(this);          this.select.addEventListener('change', this._selectChangeListener, false);          this.removeButton.addEventListener('click', this._removeClickListener, false); diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js index 588d9a11..38dd6349 100644 --- a/ext/bg/js/settings/audio.js +++ b/ext/bg/js/settings/audio.js @@ -16,12 +16,26 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global getOptionsContext, getOptionsMutable, settingsSaveOptions -AudioSourceUI, audioGetTextToSpeechVoice*/ +/* global + * AudioSourceUI + * AudioSystem + * apiAudioGetUri + * getOptionsContext + * getOptionsMutable + * settingsSaveOptions + */  let audioSourceUI = null; +let audioSystem = null;  async function audioSettingsInitialize() { +    audioSystem = new AudioSystem({ +        getAudioUri: async (definition, source) => { +            const optionsContext = getOptionsContext(); +            return await apiAudioGetUri(definition, source, optionsContext); +        } +    }); +      const optionsContext = getOptionsContext();      const options = await getOptionsMutable(optionsContext);      audioSourceUI = new AudioSourceUI.Container( @@ -29,7 +43,7 @@ async function audioSettingsInitialize() {          document.querySelector('.audio-source-list'),          document.querySelector('.audio-source-add')      ); -    audioSourceUI.save = () => settingsSaveOptions(); +    audioSourceUI.save = settingsSaveOptions;      textToSpeechInitialize();  } @@ -37,11 +51,11 @@ async function audioSettingsInitialize() {  function textToSpeechInitialize() {      if (typeof speechSynthesis === 'undefined') { return; } -    speechSynthesis.addEventListener('voiceschanged', () => updateTextToSpeechVoices(), false); +    speechSynthesis.addEventListener('voiceschanged', updateTextToSpeechVoices, false);      updateTextToSpeechVoices(); -    document.querySelector('#text-to-speech-voice').addEventListener('change', (e) => onTextToSpeechVoiceChange(e), false); -    document.querySelector('#text-to-speech-voice-test').addEventListener('click', () => textToSpeechTest(), false); +    document.querySelector('#text-to-speech-voice').addEventListener('change', onTextToSpeechVoiceChange, false); +    document.querySelector('#text-to-speech-voice-test').addEventListener('click', textToSpeechTest, false);  }  function updateTextToSpeechVoices() { @@ -100,16 +114,11 @@ function textToSpeechVoiceCompare(a, b) {  function textToSpeechTest() {      try {          const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || ''; -        const voiceURI = document.querySelector('#text-to-speech-voice').value; -        const voice = audioGetTextToSpeechVoice(voiceURI); -        if (voice === null) { return; } - -        const utterance = new SpeechSynthesisUtterance(text); -        utterance.lang = 'ja-JP'; -        utterance.voice = voice; -        utterance.volume = 1.0; +        const voiceUri = document.querySelector('#text-to-speech-voice').value; -        speechSynthesis.speak(utterance); +        const audio = audioSystem.createTextToSpeechAudio({text, voiceUri}); +        audio.volume = 1.0; +        audio.play();      } catch (e) {          // NOP      } diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js index f4d622a4..21417dfb 100644 --- a/ext/bg/js/settings/backup.js +++ b/ext/bg/js/settings/backup.js @@ -16,10 +16,17 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiOptionsGetFull, apiGetEnvironmentInfo -utilBackend, utilIsolate, utilBackgroundIsolate, utilReadFileArrayBuffer -optionsGetDefault, optionsUpdateVersion -profileOptionsGetDefaultFieldTemplates*/ +/* global + * apiGetDefaultAnkiFieldTemplates + * apiGetEnvironmentInfo + * apiOptionsGetFull + * optionsGetDefault + * optionsUpdateVersion + * utilBackend + * utilBackgroundIsolate + * utilIsolate + * utilReadFileArrayBuffer + */  // Exporting @@ -47,8 +54,7 @@ function _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, ti  async function _getSettingsExportData(date) {      const optionsFull = await apiOptionsGetFull();      const environment = await apiGetEnvironmentInfo(); - -    const fieldTemplatesDefault = profileOptionsGetDefaultFieldTemplates(); +    const fieldTemplatesDefault = await apiGetDefaultAnkiFieldTemplates();      // Format options      for (const {options} of optionsFull.profiles) { @@ -122,7 +128,7 @@ async function _onSettingsExportClick() {  // Importing  async function _settingsImportSetOptionsFull(optionsFull) { -    return utilIsolate(await utilBackend().setFullOptions( +    return utilIsolate(utilBackend().setFullOptions(          utilBackgroundIsolate(optionsFull)      ));  } @@ -364,10 +370,10 @@ async function _onSettingsResetConfirmClick() {  // Setup -window.addEventListener('DOMContentLoaded', () => { +function backupInitialize() {      document.querySelector('#settings-export').addEventListener('click', _onSettingsExportClick, false);      document.querySelector('#settings-import').addEventListener('click', _onSettingsImportClick, false);      document.querySelector('#settings-import-file').addEventListener('change', _onSettingsImportFileChange, false);      document.querySelector('#settings-reset').addEventListener('click', _onSettingsResetClick, false);      document.querySelector('#settings-reset-modal-confirm').addEventListener('click', _onSettingsResetConfirmClick, false); -}, false); +} diff --git a/ext/bg/js/settings/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js index 5a271321..9d61d25e 100644 --- a/ext/bg/js/settings/conditions-ui.js +++ b/ext/bg/js/settings/conditions-ui.js @@ -16,7 +16,9 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global conditionsNormalizeOptionValue*/ +/* global + * conditionsNormalizeOptionValue + */  class ConditionsUI {      static instantiateTemplate(templateSelector) { @@ -41,7 +43,7 @@ ConditionsUI.Container = class Container {              this.children.push(new ConditionsUI.ConditionGroup(this, conditionGroup));          } -        this.addButton.on('click', () => this.onAddConditionGroup()); +        this.addButton.on('click', this.onAddConditionGroup.bind(this));      }      cleanup() { @@ -127,7 +129,7 @@ ConditionsUI.ConditionGroup = class ConditionGroup {              this.children.push(new ConditionsUI.Condition(this, condition));          } -        this.addButton.on('click', () => this.onAddCondition()); +        this.addButton.on('click', this.onAddCondition.bind(this));      }      cleanup() { @@ -185,10 +187,10 @@ ConditionsUI.Condition = class Condition {          this.updateOperators();          this.updateInput(); -        this.input.on('change', () => this.onInputChanged()); -        this.typeSelect.on('change', () => this.onConditionTypeChanged()); -        this.operatorSelect.on('change', () => this.onConditionOperatorChanged()); -        this.removeButton.on('click', () => this.onRemoveClicked()); +        this.input.on('change', this.onInputChanged.bind(this)); +        this.typeSelect.on('change', this.onConditionTypeChanged.bind(this)); +        this.operatorSelect.on('change', this.onConditionOperatorChanged.bind(this)); +        this.removeButton.on('click', this.onRemoveClicked.bind(this));      }      cleanup() { @@ -235,10 +237,10 @@ ConditionsUI.Condition = class Condition {      updateInput() {          const conditionDescriptors = this.parent.parent.conditionDescriptors;          const {type, operator} = this.condition; -        const props = { -            placeholder: '', -            type: 'text' -        }; +        const props = new Map([ +            ['placeholder', ''], +            ['type', 'text'] +        ]);          const objects = [];          if (hasOwn(conditionDescriptors, type)) { @@ -252,20 +254,20 @@ ConditionsUI.Condition = class Condition {          for (const object of objects) {              if (hasOwn(object, 'placeholder')) { -                props.placeholder = object.placeholder; +                props.set('placeholder', object.placeholder);              }              if (object.type === 'number') { -                props.type = 'number'; +                props.set('type', 'number');                  for (const prop of ['step', 'min', 'max']) {                      if (hasOwn(object, prop)) { -                        props[prop] = object[prop]; +                        props.set(prop, object[prop]);                      }                  }              }          } -        for (const prop in props) { -            this.input.prop(prop, props[prop]); +        for (const [prop, value] of props.entries()) { +            this.input.prop(prop, value);          }          const {valid} = this.validateValue(this.condition.value); diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js index 70a22a16..5e59cc3d 100644 --- a/ext/bg/js/settings/dictionaries.js +++ b/ext/bg/js/settings/dictionaries.js @@ -16,11 +16,23 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global getOptionsContext, getOptionsMutable, getOptionsFullMutable, settingsSaveOptions, apiOptionsGetFull, apiOptionsGet -utilBackgroundIsolate, utilDatabaseDeleteDictionary, utilDatabaseGetDictionaryInfo, utilDatabaseGetDictionaryCounts -utilDatabasePurge, utilDatabaseImport -storageUpdateStats, storageEstimate -PageExitPrevention*/ +/* global + * PageExitPrevention + * apiOptionsGet + * apiOptionsGetFull + * getOptionsContext + * getOptionsFullMutable + * getOptionsMutable + * settingsSaveOptions + * storageEstimate + * storageUpdateStats + * utilBackgroundIsolate + * utilDatabaseDeleteDictionary + * utilDatabaseGetDictionaryCounts + * utilDatabaseGetDictionaryInfo + * utilDatabaseImport + * utilDatabasePurge + */  let dictionaryUI = null; @@ -36,7 +48,7 @@ class SettingsDictionaryListUI {          this.dictionaryEntries = [];          this.extra = null; -        document.querySelector('#dict-delete-confirm').addEventListener('click', (e) => this.onDictionaryConfirmDelete(e), false); +        document.querySelector('#dict-delete-confirm').addEventListener('click', this.onDictionaryConfirmDelete.bind(this), false);      }      setOptionsDictionaries(optionsDictionaries) { @@ -198,10 +210,10 @@ class SettingsDictionaryEntryUI {          this.applyValues(); -        this.eventListeners.addEventListener(this.enabledCheckbox, 'change', (e) => this.onEnabledChanged(e), false); -        this.eventListeners.addEventListener(this.allowSecondarySearchesCheckbox, 'change', (e) => this.onAllowSecondarySearchesChanged(e), false); -        this.eventListeners.addEventListener(this.priorityInput, 'change', (e) => this.onPriorityChanged(e), false); -        this.eventListeners.addEventListener(this.deleteButton, 'click', (e) => this.onDeleteButtonClicked(e), false); +        this.eventListeners.addEventListener(this.enabledCheckbox, 'change', this.onEnabledChanged.bind(this), false); +        this.eventListeners.addEventListener(this.allowSecondarySearchesCheckbox, 'change', this.onAllowSecondarySearchesChanged.bind(this), false); +        this.eventListeners.addEventListener(this.priorityInput, 'change', this.onPriorityChanged.bind(this), false); +        this.eventListeners.addEventListener(this.deleteButton, 'click', this.onDeleteButtonClicked.bind(this), false);      }      cleanup() { @@ -341,14 +353,14 @@ async function dictSettingsInitialize() {          document.querySelector('#dict-groups-extra'),          document.querySelector('#dict-extra-template')      ); -    dictionaryUI.save = () => settingsSaveOptions(); - -    document.querySelector('#dict-purge-button').addEventListener('click', (e) => onDictionaryPurgeButtonClick(e), false); -    document.querySelector('#dict-purge-confirm').addEventListener('click', (e) => onDictionaryPurge(e), false); -    document.querySelector('#dict-file-button').addEventListener('click', (e) => onDictionaryImportButtonClick(e), false); -    document.querySelector('#dict-file').addEventListener('change', (e) => onDictionaryImport(e), false); -    document.querySelector('#dict-main').addEventListener('change', (e) => onDictionaryMainChanged(e), false); -    document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', (e) => onDatabaseEnablePrefixWildcardSearchesChanged(e), false); +    dictionaryUI.save = settingsSaveOptions; + +    document.querySelector('#dict-purge-button').addEventListener('click', onDictionaryPurgeButtonClick, false); +    document.querySelector('#dict-purge-confirm').addEventListener('click', onDictionaryPurge, false); +    document.querySelector('#dict-file-button').addEventListener('click', onDictionaryImportButtonClick, false); +    document.querySelector('#dict-file').addEventListener('change', onDictionaryImport, false); +    document.querySelector('#dict-main').addEventListener('change', onDictionaryMainChanged, false); +    document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', onDatabaseEnablePrefixWildcardSearchesChanged, false);      await onDictionaryOptionsChanged();      await onDatabaseUpdated(); diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js index d1ad2c6b..ebc443df 100644 --- a/ext/bg/js/settings/main.js +++ b/ext/bg/js/settings/main.js @@ -16,13 +16,26 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global getOptionsContext, apiOptionsSave -utilBackend, utilIsolate, utilBackgroundIsolate -ankiErrorShown, ankiFieldsToDict -ankiTemplatesUpdateValue, onAnkiOptionsChanged, onDictionaryOptionsChanged -appearanceInitialize, audioSettingsInitialize, profileOptionsSetup, dictSettingsInitialize -ankiInitialize, ankiTemplatesInitialize, storageInfoInitialize -*/ +/* global + * ankiErrorShown + * ankiFieldsToDict + * ankiInitialize + * ankiTemplatesInitialize + * ankiTemplatesUpdateValue + * apiOptionsSave + * appearanceInitialize + * audioSettingsInitialize + * backupInitialize + * dictSettingsInitialize + * getOptionsContext + * onAnkiOptionsChanged + * onDictionaryOptionsChanged + * profileOptionsSetup + * storageInfoInitialize + * utilBackend + * utilBackgroundIsolate + * utilIsolate + */  function getOptionsMutable(optionsContext) {      return utilBackend().getOptions( @@ -200,7 +213,7 @@ async function formWrite(options) {  }  function formSetupEventListeners() { -    $('input, select, textarea').not('.anki-model').not('.ignore-form-changes *').change((e) => onFormOptionsChanged(e)); +    $('input, select, textarea').not('.anki-model').not('.ignore-form-changes *').change(onFormOptionsChanged);  }  function formUpdateVisibility(options) { @@ -262,6 +275,8 @@ function showExtensionInformation() {  async function onReady() { +    await yomichan.prepare(); +      showExtensionInformation();      formSetupEventListeners(); @@ -271,6 +286,7 @@ async function onReady() {      await dictSettingsInitialize();      ankiInitialize();      ankiTemplatesInitialize(); +    backupInitialize();      storageInfoInitialize(); diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js index aa2b6100..6a149841 100644 --- a/ext/bg/js/settings/popup-preview-frame.js +++ b/ext/bg/js/settings/popup-preview-frame.js @@ -16,7 +16,13 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiOptionsGet, Popup, PopupProxyHost, Frontend, TextSourceRange*/ +/* global + * Frontend + * Popup + * PopupProxyHost + * TextSourceRange + * apiOptionsGet + */  class SettingsPopupPreview {      constructor() { @@ -28,6 +34,12 @@ class SettingsPopupPreview {          this.themeChangeTimeout = null;          this.textSource = null;          this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); + +        this._windowMessageHandlers = new Map([ +            ['setText', ({text}) => this.setText(text)], +            ['setCustomCss', ({css}) => this.setCustomCss(css)], +            ['setCustomOuterCss', ({css}) => this.setCustomOuterCss(css)] +        ]);      }      static create() { @@ -38,15 +50,12 @@ class SettingsPopupPreview {      async prepare() {          // Setup events -        window.addEventListener('message', (e) => this.onMessage(e), false); +        window.addEventListener('message', this.onMessage.bind(this), false); -        const themeDarkCheckbox = document.querySelector('#theme-dark-checkbox'); -        if (themeDarkCheckbox !== null) { -            themeDarkCheckbox.addEventListener('change', () => this.onThemeDarkCheckboxChanged(themeDarkCheckbox), false); -        } +        document.querySelector('#theme-dark-checkbox').addEventListener('change', this.onThemeDarkCheckboxChanged.bind(this), false);          // Overwrite API functions -        window.apiOptionsGet = (...args) => this.apiOptionsGet(...args); +        window.apiOptionsGet = this.apiOptionsGet.bind(this);          // Overwrite frontend          const popupHost = new PopupProxyHost(); @@ -56,7 +65,7 @@ class SettingsPopupPreview {          this.popup.setChildrenSupported(false);          this.popupSetCustomOuterCssOld = this.popup.setCustomOuterCss; -        this.popup.setCustomOuterCss = (...args) => this.popupSetCustomOuterCss(...args); +        this.popup.setCustomOuterCss = this.popupSetCustomOuterCss.bind(this);          this.frontend = new Frontend(this.popup); @@ -101,14 +110,14 @@ class SettingsPopupPreview {          if (e.origin !== this._targetOrigin) { return; }          const {action, params} = e.data; -        const handler = SettingsPopupPreview._messageHandlers.get(action); +        const handler = this._windowMessageHandlers.get(action);          if (typeof handler !== 'function') { return; } -        handler(this, params); +        handler(params);      } -    onThemeDarkCheckboxChanged(node) { -        document.documentElement.classList.toggle('dark', node.checked); +    onThemeDarkCheckboxChanged(e) { +        document.documentElement.classList.toggle('dark', e.target.checked);          if (this.themeChangeTimeout !== null) {              clearTimeout(this.themeChangeTimeout);          } @@ -171,12 +180,6 @@ class SettingsPopupPreview {      }  } -SettingsPopupPreview._messageHandlers = new Map([ -    ['setText', (self, {text}) => self.setText(text)], -    ['setCustomCss', (self, {css}) => self.setCustomCss(css)], -    ['setCustomOuterCss', (self, {css}) => self.setCustomOuterCss(css)] -]); -  SettingsPopupPreview.instance = SettingsPopupPreview.create(); diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js index 3e589809..b35b6309 100644 --- a/ext/bg/js/settings/profiles.js +++ b/ext/bg/js/settings/profiles.js @@ -16,9 +16,17 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global getOptionsMutable, getOptionsFullMutable, settingsSaveOptions, apiOptionsGetFull -utilBackgroundIsolate, formWrite -conditionsClearCaches, ConditionsUI, profileConditionsDescriptor*/ +/* global + * ConditionsUI + * apiOptionsGetFull + * conditionsClearCaches + * formWrite + * getOptionsFullMutable + * getOptionsMutable + * profileConditionsDescriptor + * settingsSaveOptions + * utilBackgroundIsolate + */  let currentProfileIndex = 0;  let profileConditionsContainer = null; @@ -39,16 +47,16 @@ async function profileOptionsSetup() {  }  function profileOptionsSetupEventListeners() { -    $('#profile-target').change((e) => onTargetProfileChanged(e)); -    $('#profile-name').change((e) => onProfileNameChanged(e)); -    $('#profile-add').click((e) => onProfileAdd(e)); -    $('#profile-remove').click((e) => onProfileRemove(e)); -    $('#profile-remove-confirm').click((e) => onProfileRemoveConfirm(e)); -    $('#profile-copy').click((e) => onProfileCopy(e)); -    $('#profile-copy-confirm').click((e) => onProfileCopyConfirm(e)); +    $('#profile-target').change(onTargetProfileChanged); +    $('#profile-name').change(onProfileNameChanged); +    $('#profile-add').click(onProfileAdd); +    $('#profile-remove').click(onProfileRemove); +    $('#profile-remove-confirm').click(onProfileRemoveConfirm); +    $('#profile-copy').click(onProfileCopy); +    $('#profile-copy-confirm').click(onProfileCopyConfirm);      $('#profile-move-up').click(() => onProfileMove(-1));      $('#profile-move-down').click(() => onProfileMove(1)); -    $('.profile-form').find('input, select, textarea').not('.profile-form-manual').change((e) => onProfileOptionsChanged(e)); +    $('.profile-form').find('input, select, textarea').not('.profile-form-manual').change(onProfileOptionsChanged);  }  function tryGetIntegerValue(selector, min, max) { diff --git a/ext/bg/js/settings/storage.js b/ext/bg/js/settings/storage.js index cbe1bb4d..ae305e22 100644 --- a/ext/bg/js/settings/storage.js +++ b/ext/bg/js/settings/storage.js @@ -16,7 +16,9 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiGetEnvironmentInfo*/ +/* global + * apiGetEnvironmentInfo + */  function storageBytesToLabeledString(size) {      const base = 1000; @@ -57,7 +59,7 @@ async function storageInfoInitialize() {      await storageShowInfo(); -    document.querySelector('#storage-refresh').addEventListener('click', () => storageShowInfo(), false); +    document.querySelector('#storage-refresh').addEventListener('click', storageShowInfo, false);  }  async function storageUpdateStats() { diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js index a675a9f7..25da9bf0 100644 --- a/ext/bg/js/translator.js +++ b/ext/bg/js/translator.js @@ -16,12 +16,28 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global requestJson -dictTermsMergeBySequence, dictTagBuildSource, dictTermsMergeByGloss, dictTermsSort, dictTagsSort -dictEnabledSet, dictTermsGroup, dictTermsCompressTags, dictTermsUndupe, dictTagSanitize -jpDistributeFurigana, jpConvertHalfWidthKanaToFullWidth, jpConvertNumericTofullWidth -jpConvertAlphabeticToKana, jpHiraganaToKatakana, jpKatakanaToHiragana, jpIsCharCodeJapanese -Database, Deinflector*/ +/* global + * Database + * Deinflector + * dictEnabledSet + * dictTagBuildSource + * dictTagSanitize + * dictTagsSort + * dictTermsCompressTags + * dictTermsGroup + * dictTermsMergeByGloss + * dictTermsMergeBySequence + * dictTermsSort + * dictTermsUndupe + * jpConvertAlphabeticToKana + * jpConvertHalfWidthKanaToFullWidth + * jpConvertNumericTofullWidth + * jpDistributeFurigana + * jpHiraganaToKatakana + * jpIsCodePointJapanese + * jpKatakanaToHiragana + * requestJson + */  class Translator {      constructor() { @@ -199,8 +215,19 @@ class Translator {          const strayDefinitions = defaultDefinitions.filter((definition, index) => !mergedByTermIndices.has(index));          for (const groupedDefinition of dictTermsGroup(strayDefinitions, dictionaries)) { -            groupedDefinition.expressions = [Translator.createExpression(groupedDefinition.expression, groupedDefinition.reading)]; -            definitionsMerged.push(groupedDefinition); +            // from dictTermsMergeBySequence +            const {reasons, score, expression, reading, source, dictionary} = groupedDefinition; +            const compatibilityDefinition = { +                reasons, +                score, +                expression: [expression], +                reading: [reading], +                expressions: [Translator.createExpression(groupedDefinition.expression, groupedDefinition.reading)], +                source, +                dictionary, +                definitions: groupedDefinition.definitions +            }; +            definitionsMerged.push(compatibilityDefinition);          }          await this.buildTermMeta(definitionsMerged, dictionaries); @@ -610,13 +637,14 @@ class Translator {      static getSearchableText(text, options) {          if (!options.scanning.alphanumeric) { -            const ii = text.length; -            for (let i = 0; i < ii; ++i) { -                if (!jpIsCharCodeJapanese(text.charCodeAt(i))) { -                    text = text.substring(0, i); +            let newText = ''; +            for (const c of text) { +                if (!jpIsCodePointJapanese(c.codePointAt(0))) {                      break;                  } +                newText += c;              } +            text = newText;          }          return text; diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js index 5ce4b08c..79c6af06 100644 --- a/ext/bg/js/util.js +++ b/ext/bg/js/util.js @@ -73,7 +73,11 @@ function utilStringHashCode(string) {  }  function utilBackend() { -    return chrome.extension.getBackgroundPage().yomichanBackend; +    const backend = chrome.extension.getBackgroundPage().yomichanBackend; +    if (!backend.isPrepared) { +        throw new Error('Backend not ready yet'); +    } +    return backend;  }  async function utilAnkiGetModelNames() { diff --git a/ext/bg/search.html b/ext/bg/search.html index d6336826..f4c1a737 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -80,7 +80,7 @@          <script src="/bg/js/japanese.js"></script>          <script src="/fg/js/document.js"></script>          <script src="/fg/js/source.js"></script> -        <script src="/mixed/js/audio.js"></script> +        <script src="/mixed/js/audio-system.js"></script>          <script src="/mixed/js/display-context.js"></script>          <script src="/mixed/js/display.js"></script>          <script src="/mixed/js/display-generator.js"></script> diff --git a/ext/bg/settings.html b/ext/bg/settings.html index b048a36c..0db76d71 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -1090,6 +1090,7 @@          <script src="/mixed/js/api.js"></script>          <script src="/bg/js/anki.js"></script> +        <script src="/bg/js/anki-note-builder.js"></script>          <script src="/bg/js/conditions.js"></script>          <script src="/bg/js/dictionary.js"></script>          <script src="/bg/js/handlebars.js"></script> @@ -1098,7 +1099,7 @@          <script src="/bg/js/page-exit-prevention.js"></script>          <script src="/bg/js/profile-conditions.js"></script>          <script src="/bg/js/util.js"></script> -        <script src="/mixed/js/audio.js"></script> +        <script src="/mixed/js/audio-system.js"></script>          <script src="/bg/js/settings/anki.js"></script>          <script src="/bg/js/settings/anki-templates.js"></script> diff --git a/ext/fg/float.html b/ext/fg/float.html index 352a866a..7bbed565 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -46,7 +46,7 @@          <script src="/fg/js/document.js"></script>          <script src="/fg/js/source.js"></script> -        <script src="/mixed/js/audio.js"></script> +        <script src="/mixed/js/audio-system.js"></script>          <script src="/mixed/js/display-context.js"></script>          <script src="/mixed/js/display.js"></script>          <script src="/mixed/js/display-generator.js"></script> diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js index 35861475..490f61bb 100644 --- a/ext/fg/js/document.js +++ b/ext/fg/js/document.js @@ -16,7 +16,11 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global TextSourceElement, TextSourceRange, DOM*/ +/* global + * DOM + * TextSourceElement + * TextSourceRange + */  const REGEX_TRANSPARENT_COLOR = /rgba\s*\([^)]*,\s*0(?:\.0+)?\s*\)/; diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js index 8f21a9c5..393c2719 100644 --- a/ext/fg/js/float.js +++ b/ext/fg/js/float.js @@ -16,7 +16,12 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global popupNestedInitialize, apiForward, apiGetMessageToken, Display*/ +/* global + * Display + * apiForward + * apiGetMessageToken + * popupNestedInitialize + */  class DisplayFloat extends Display {      constructor() { @@ -33,8 +38,27 @@ class DisplayFloat extends Display {          this._messageToken = null;          this._messageTokenPromise = null; -        yomichan.on('orphaned', () => this.onOrphaned()); -        window.addEventListener('message', (e) => this.onMessage(e), false); +        this._onKeyDownHandlers = new Map([ +            ['C', (e) => { +                if (e.ctrlKey && !window.getSelection().toString()) { +                    this.onSelectionCopy(); +                    return true; +                } +                return false; +            }], +            ...this._onKeyDownHandlers +        ]); + +        this._windowMessageHandlers = new Map([ +            ['setContent', ({type, details}) => this.setContent(type, details)], +            ['clearAutoPlayTimer', () => this.clearAutoPlayTimer()], +            ['setCustomCss', ({css}) => this.setCustomCss(css)], +            ['prepare', ({options, popupInfo, url, childrenSupported, scale, uniqueId}) => this.prepare(options, popupInfo, url, childrenSupported, scale, uniqueId)], +            ['setContentScale', ({scale}) => this.setContentScale(scale)] +        ]); + +        yomichan.on('orphaned', this.onOrphaned.bind(this)); +        window.addEventListener('message', this.onMessage.bind(this), false);      }      async prepare(options, popupInfo, url, childrenSupported, scale, uniqueId) { @@ -96,18 +120,6 @@ class DisplayFloat extends Display {          }      } -    onKeyDown(e) { -        const key = Display.getKeyFromEvent(e); -        const handler = DisplayFloat._onKeyDownHandlers.get(key); -        if (typeof handler === 'function') { -            if (handler(this, e)) { -                e.preventDefault(); -                return true; -            } -        } -        return super.onKeyDown(e); -    } -      async getMessageToken() {          // this._messageTokenPromise is used to ensure that only one call to apiGetMessageToken is made.          if (this._messageTokenPromise === null) { @@ -126,10 +138,10 @@ class DisplayFloat extends Display {              return;          } -        const handler = DisplayFloat._messageHandlers.get(action); +        const handler = this._windowMessageHandlers.get(action);          if (typeof handler !== 'function') { return; } -        handler(this, params); +        handler(params);      }      getOptionsContext() { @@ -153,22 +165,4 @@ class DisplayFloat extends Display {      }  } -DisplayFloat._onKeyDownHandlers = new Map([ -    ['C', (self, e) => { -        if (e.ctrlKey && !window.getSelection().toString()) { -            self.onSelectionCopy(); -            return true; -        } -        return false; -    }] -]); - -DisplayFloat._messageHandlers = new Map([ -    ['setContent', (self, {type, details}) => self.setContent(type, details)], -    ['clearAutoPlayTimer', (self) => self.clearAutoPlayTimer()], -    ['setCustomCss', (self, {css}) => self.setCustomCss(css)], -    ['prepare', (self, {options, popupInfo, url, childrenSupported, scale, uniqueId}) => self.prepare(options, popupInfo, url, childrenSupported, scale, uniqueId)], -    ['setContentScale', (self, {scale}) => self.setContentScale(scale)] -]); -  DisplayFloat.instance = new DisplayFloat(); diff --git a/ext/fg/js/frontend-api-sender.js b/ext/fg/js/frontend-api-sender.js index 8dc6aaf3..4431df61 100644 --- a/ext/fg/js/frontend-api-sender.js +++ b/ext/fg/js/frontend-api-sender.js @@ -31,6 +31,8 @@ class FrontendApiSender {      invoke(action, params, target) {          if (this.disconnected) { +            // attempt to reconnect the next time +            this.disconnected = false;              return Promise.reject(new Error('Disconnected'));          } @@ -70,6 +72,7 @@ class FrontendApiSender {      onDisconnect() {          this.disconnected = true; +        this.port = null;          for (const id of this.callbacks.keys()) {              this.onError(id, 'Disconnected'); diff --git a/ext/fg/js/frontend-initialize.js b/ext/fg/js/frontend-initialize.js index 54b874f2..8424b21d 100644 --- a/ext/fg/js/frontend-initialize.js +++ b/ext/fg/js/frontend-initialize.js @@ -16,9 +16,15 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global PopupProxyHost, PopupProxy, Frontend*/ +/* global + * Frontend + * PopupProxy + * PopupProxyHost + */  async function main() { +    await yomichan.prepare(); +      const data = window.frontendInitializationData || {};      const {id, depth=0, parentFrameId, ignoreNodes, url, proxy=false} = data; @@ -29,7 +35,7 @@ async function main() {          const popupHost = new PopupProxyHost();          await popupHost.prepare(); -        popup = popupHost.getOrCreatePopup(); +        popup = popupHost.getOrCreatePopup(null, null, depth);      }      const frontend = new Frontend(popup, ignoreNodes); diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index 67045241..768b9326 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -16,7 +16,14 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiGetZoom, apiOptionsGet, apiTermsFind, apiKanjiFind, docSentenceExtract, TextScanner*/ +/* global + * TextScanner + * apiGetZoom + * apiKanjiFind + * apiOptionsGet + * apiTermsFind + * docSentenceExtract + */  class Frontend extends TextScanner {      constructor(popup, ignoreNodes) { @@ -39,6 +46,15 @@ class Frontend extends TextScanner {          this._contentScale = 1.0;          this._orphaned = true;          this._lastShowPromise = Promise.resolve(); + +        this._windowMessageHandlers = new Map([ +            ['popupClose', () => this.onSearchClear(true)], +            ['selectionCopy', () => document.execCommand('copy')] +        ]); + +        this._runtimeMessageHandlers = new Map([ +            ['popupSetVisibleOverride', ({visible}) => { this.popup.setVisibleOverride(visible); }] +        ]);      }      async prepare() { @@ -55,9 +71,9 @@ class Frontend extends TextScanner {                  window.visualViewport.addEventListener('resize', this.onVisualViewportResize.bind(this));              } -            yomichan.on('orphaned', () => this.onOrphaned()); -            yomichan.on('optionsUpdated', () => this.updateOptions()); -            yomichan.on('zoomChanged', (e) => this.onZoomChanged(e)); +            yomichan.on('orphaned', this.onOrphaned.bind(this)); +            yomichan.on('optionsUpdated', this.updateOptions.bind(this)); +            yomichan.on('zoomChanged', this.onZoomChanged.bind(this));              chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this));              this._updateContentScale(); @@ -72,17 +88,17 @@ class Frontend extends TextScanner {      onWindowMessage(e) {          const action = e.data; -        const handler = Frontend._windowMessageHandlers.get(action); +        const handler = this._windowMessageHandlers.get(action);          if (typeof handler !== 'function') { return false; } -        handler(this); +        handler();      }      onRuntimeMessage({action, params}, sender, callback) { -        const handler = Frontend._runtimeMessageHandlers.get(action); +        const handler = this._runtimeMessageHandlers.get(action);          if (typeof handler !== 'function') { return false; } -        const result = handler(this, params, sender); +        const result = handler(params, sender);          callback(result);          return false;      } @@ -237,12 +253,3 @@ class Frontend extends TextScanner {          return visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0;      }  } - -Frontend._windowMessageHandlers = new Map([ -    ['popupClose', (self) => self.onSearchClear(true)], -    ['selectionCopy', () => document.execCommand('copy')] -]); - -Frontend._runtimeMessageHandlers = new Map([ -    ['popupSetVisibleOverride', (self, {visible}) => { self.popup.setVisibleOverride(visible); }] -]); diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/popup-nested.js index 3e5f5b80..06f8fc4b 100644 --- a/ext/fg/js/popup-nested.js +++ b/ext/fg/js/popup-nested.js @@ -16,7 +16,9 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiOptionsGet*/ +/* global + * apiOptionsGet + */  let popupNestedInitialized = false; diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-proxy-host.js index e55801ff..793d3949 100644 --- a/ext/fg/js/popup-proxy-host.js +++ b/ext/fg/js/popup-proxy-host.js @@ -16,7 +16,11 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiFrameInformationGet, FrontendApiReceiver, Popup*/ +/* global + * FrontendApiReceiver + * Popup + * apiFrameInformationGet + */  class PopupProxyHost {      constructor() { @@ -34,20 +38,20 @@ class PopupProxyHost {          if (typeof frameId !== 'number') { return; }          this._apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${frameId}`, new Map([ -            ['getOrCreatePopup', ({id, parentId}) => this._onApiGetOrCreatePopup(id, parentId)], -            ['setOptions', ({id, options}) => this._onApiSetOptions(id, options)], -            ['hide', ({id, changeFocus}) => this._onApiHide(id, changeFocus)], -            ['isVisible', ({id}) => this._onApiIsVisibleAsync(id)], -            ['setVisibleOverride', ({id, visible}) => this._onApiSetVisibleOverride(id, visible)], -            ['containsPoint', ({id, x, y}) => this._onApiContainsPoint(id, x, y)], -            ['showContent', ({id, elementRect, writingMode, type, details}) => this._onApiShowContent(id, elementRect, writingMode, type, details)], -            ['setCustomCss', ({id, css}) => this._onApiSetCustomCss(id, css)], -            ['clearAutoPlayTimer', ({id}) => this._onApiClearAutoPlayTimer(id)], -            ['setContentScale', ({id, scale}) => this._onApiSetContentScale(id, scale)] +            ['getOrCreatePopup', this._onApiGetOrCreatePopup.bind(this)], +            ['setOptions', this._onApiSetOptions.bind(this)], +            ['hide', this._onApiHide.bind(this)], +            ['isVisible', this._onApiIsVisibleAsync.bind(this)], +            ['setVisibleOverride', this._onApiSetVisibleOverride.bind(this)], +            ['containsPoint', this._onApiContainsPoint.bind(this)], +            ['showContent', this._onApiShowContent.bind(this)], +            ['setCustomCss', this._onApiSetCustomCss.bind(this)], +            ['clearAutoPlayTimer', this._onApiClearAutoPlayTimer.bind(this)], +            ['setContentScale', this._onApiSetContentScale.bind(this)]          ]));      } -    getOrCreatePopup(id=null, parentId=null) { +    getOrCreatePopup(id=null, parentId=null, depth=null) {          // Find by existing id          if (id !== null) {              const popup = this._popups.get(id); @@ -76,7 +80,14 @@ class PopupProxyHost {          }          // Create new popup -        const depth = (parent !== null ? parent.depth + 1 : 0); +        if (parent !== null) { +            if (depth !== null) { +                throw new Error('Depth cannot be set when parent exists'); +            } +            depth = parent.depth + 1; +        } else if (depth === null) { +            depth = 0; +        }          const popup = new Popup(id, depth, this._frameIdPromise);          if (parent !== null) {              popup.setParent(parent); @@ -87,56 +98,57 @@ class PopupProxyHost {      // Message handlers -    async _onApiGetOrCreatePopup(id, parentId) { +    async _onApiGetOrCreatePopup({id, parentId}) {          const popup = this.getOrCreatePopup(id, parentId);          return {              id: popup.id          };      } -    async _onApiSetOptions(id, options) { +    async _onApiSetOptions({id, options}) {          const popup = this._getPopup(id);          return await popup.setOptions(options);      } -    async _onApiHide(id, changeFocus) { +    async _onApiHide({id, changeFocus}) {          const popup = this._getPopup(id);          return popup.hide(changeFocus);      } -    async _onApiIsVisibleAsync(id) { +    async _onApiIsVisibleAsync({id}) {          const popup = this._getPopup(id);          return await popup.isVisible();      } -    async _onApiSetVisibleOverride(id, visible) { +    async _onApiSetVisibleOverride({id, visible}) {          const popup = this._getPopup(id);          return await popup.setVisibleOverride(visible);      } -    async _onApiContainsPoint(id, x, y) { +    async _onApiContainsPoint({id, x, y}) {          const popup = this._getPopup(id); +        [x, y] = PopupProxyHost._convertPopupPointToRootPagePoint(popup, x, y);          return await popup.containsPoint(x, y);      } -    async _onApiShowContent(id, elementRect, writingMode, type, details) { +    async _onApiShowContent({id, elementRect, writingMode, type, details}) {          const popup = this._getPopup(id);          elementRect = PopupProxyHost._convertJsonRectToDOMRect(popup, elementRect);          if (!PopupProxyHost._popupCanShow(popup)) { return; }          return await popup.showContent(elementRect, writingMode, type, details);      } -    async _onApiSetCustomCss(id, css) { +    async _onApiSetCustomCss({id, css}) {          const popup = this._getPopup(id);          return popup.setCustomCss(css);      } -    async _onApiClearAutoPlayTimer(id) { +    async _onApiClearAutoPlayTimer({id}) {          const popup = this._getPopup(id);          return popup.clearAutoPlayTimer();      } -    async _onApiSetContentScale(id, scale) { +    async _onApiSetContentScale({id, scale}) {          const popup = this._getPopup(id);          return popup.setContentScale(scale);      } @@ -152,14 +164,17 @@ class PopupProxyHost {      }      static _convertJsonRectToDOMRect(popup, jsonRect) { -        let x = jsonRect.x; -        let y = jsonRect.y; +        const [x, y] = PopupProxyHost._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y); +        return new DOMRect(x, y, jsonRect.width, jsonRect.height); +    } + +    static _convertPopupPointToRootPagePoint(popup, x, y) {          if (popup.parent !== null) {              const popupRect = popup.parent.getContainerRect();              x += popupRect.x;              y += popupRect.y;          } -        return new DOMRect(x, y, jsonRect.width, jsonRect.height); +        return [x, y];      }      static _popupCanShow(popup) { diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js index 093cdd2e..f7cef214 100644 --- a/ext/fg/js/popup-proxy.js +++ b/ext/fg/js/popup-proxy.js @@ -16,7 +16,9 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global FrontendApiSender*/ +/* global + * FrontendApiSender + */  class PopupProxy {      constructor(id, depth, parentId, parentFrameId, url) { diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index 4927f4bd..d752812e 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -16,7 +16,10 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global apiInjectStylesheet, apiGetMessageToken*/ +/* global + * apiGetMessageToken + * apiInjectStylesheet + */  class Popup {      constructor(id, depth, frameIdPromise) { @@ -260,7 +263,7 @@ class Popup {              'mozfullscreenchange',              'webkitfullscreenchange'          ]; -        const onFullscreenChanged = () => this._onFullscreenChanged(); +        const onFullscreenChanged = this._onFullscreenChanged.bind(this);          for (const eventName of fullscreenEvents) {              this._fullscreenEventListeners.addEventListener(document, eventName, onFullscreenChanged, false);          } diff --git a/ext/manifest.json b/ext/manifest.json index fd9b6fec..2a602e3b 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -1,7 +1,7 @@  {      "manifest_version": 2,      "name": "Yomichan (testing)", -    "version": "20.2.24.0", +    "version": "20.3.14.0",      "description": "Japanese dictionary with Anki integration (testing)",      "icons": {"16": "mixed/img/icon16.png", "48": "mixed/img/icon48.png", "128": "mixed/img/icon128.png"}, diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css index 6a5383bc..688a357c 100644 --- a/ext/mixed/css/display.css +++ b/ext/mixed/css/display.css @@ -30,8 +30,8 @@   * General   */ -html:root[data-yomichan-page=float]:not([data-yomichan-theme]), -html:root[data-yomichan-page=float]:not([data-yomichan-theme]) body { +:root[data-yomichan-page=float]:not([data-yomichan-theme]), +:root[data-yomichan-page=float]:not([data-yomichan-theme]) body {      background-color: transparent;  } @@ -65,10 +65,6 @@ ol, ul {      height: 2.28571428em; /* 14px => 32px */  } -.invisible { -    visibility: hidden; -} -  /*   * Navigation   */ @@ -82,17 +78,18 @@ ol, ul {      padding: 0.25em 0.5em;      border-bottom-width: 0.07142857em; /* 14px => 1px */      border-bottom-style: solid; +    z-index: 10;  } -html:root[data-yomichan-page=search] .navigation-header { +:root[data-yomichan-page=search] .navigation-header {      position: sticky;  } -html:root[data-yomichan-page=float] .navigation-header { +:root[data-yomichan-page=float] .navigation-header {      position: fixed;  } -html:root[data-yomichan-page=float] .navigation-header:not([hidden])~.navigation-header-spacer { +:root[data-yomichan-page=float] .navigation-header:not([hidden])~.navigation-header-spacer {      height: 2.1em;  } @@ -136,7 +133,7 @@ html:root[data-yomichan-page=float] .navigation-header:not([hidden])~.navigation      margin-right: 0.2em;  } -html:root[data-yomichan-page=search][data-search-mode=popup] .search-input { +:root[data-yomichan-page=search][data-search-mode=popup] .search-input {      display: none;  } @@ -150,7 +147,7 @@ html:root[data-yomichan-page=search][data-search-mode=popup] .search-input {      padding-bottom: 0.72em;  } -html:root[data-yomichan-page=float] .entry { +:root[data-yomichan-page=float] .entry {      padding-left: 0.72em;      padding-right: 0.72em;  } @@ -231,7 +228,7 @@ button.action-button {      margin-right: 0.375em;  } -html:root:not([data-enable-search-tags=true]) .tag[data-category=search] { +:root:not([data-enable-search-tags=true]) .tag[data-category=search] {      display: none;  } @@ -280,10 +277,6 @@ html:root:not([data-enable-search-tags=true]) .tag[data-category=search] {      display: inline;  } -.term-expression-details>.action-play-audio { -    display: none; -} -  .term-expression-details>.tags {      display: inline;  } @@ -321,8 +314,8 @@ html:root:not([data-enable-search-tags=true]) .tag[data-category=search] {      bottom: 0.5em;  } -.term-expression-list[data-multi=true] .term-expression-details>.action-play-audio { -    display: block; +.term-expression-list:not([data-multi=true]) .term-expression-details>.action-play-audio { +    display: none;  }  .term-expression-list[data-multi=true] .term-expression-details>.tags { diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index 7ea68d59..0ab07039 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -69,8 +69,8 @@ function apiTemplateRender(template, data) {      return _apiInvoke('templateRender', {data, template});  } -function apiAudioGetUrl(definition, source, optionsContext) { -    return _apiInvoke('audioGetUrl', {definition, source, optionsContext}); +function apiAudioGetUri(definition, source, optionsContext) { +    return _apiInvoke('audioGetUri', {definition, source, optionsContext});  }  function apiCommandExec(command, params) { @@ -117,6 +117,10 @@ function apiGetMessageToken() {      return _apiInvoke('getMessageToken');  } +function apiGetDefaultAnkiFieldTemplates() { +    return _apiInvoke('getDefaultAnkiFieldTemplates'); +} +  function _apiInvoke(action, params={}) {      const data = {action, params};      return new Promise((resolve, reject) => { diff --git a/ext/mixed/js/audio-system.js b/ext/mixed/js/audio-system.js new file mode 100644 index 00000000..31c476b1 --- /dev/null +++ b/ext/mixed/js/audio-system.js @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2019-2020  Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 TextToSpeechAudio { +    constructor(text, voice) { +        this.text = text; +        this.voice = voice; +        this._utterance = null; +        this._volume = 1; +    } + +    get currentTime() { +        return 0; +    } +    set currentTime(value) { +        // NOP +    } + +    get volume() { +        return this._volume; +    } +    set volume(value) { +        this._volume = value; +        if (this._utterance !== null) { +            this._utterance.volume = value; +        } +    } + +    play() { +        try { +            if (this._utterance === null) { +                this._utterance = new SpeechSynthesisUtterance(this.text || ''); +                this._utterance.lang = 'ja-JP'; +                this._utterance.volume = this._volume; +                this._utterance.voice = this.voice; +            } + +            speechSynthesis.cancel(); +            speechSynthesis.speak(this._utterance); +        } catch (e) { +            // NOP +        } +    } + +    pause() { +        try { +            speechSynthesis.cancel(); +        } catch (e) { +            // NOP +        } +    } +} + +class AudioSystem { +    constructor({getAudioUri}) { +        this._cache = new Map(); +        this._cacheSizeMaximum = 32; +        this._getAudioUri = getAudioUri; + +        if (typeof speechSynthesis !== 'undefined') { +            // speechSynthesis.getVoices() will not be populated unless some API call is made. +            speechSynthesis.addEventListener('voiceschanged', this._onVoicesChanged.bind(this)); +        } +    } + +    async getDefinitionAudio(definition, sources, details) { +        const key = `${definition.expression}:${definition.reading}`; +        const cacheValue = this._cache.get(definition); +        if (typeof cacheValue !== 'undefined') { +            const {audio, uri, source} = cacheValue; +            return {audio, uri, source}; +        } + +        for (const source of sources) { +            const uri = await this._getAudioUri(definition, source, details); +            if (uri === null) { continue; } + +            try { +                const audio = await this._createAudio(uri, details); +                this._cacheCheck(); +                this._cache.set(key, {audio, uri, source}); +                return {audio, uri, source}; +            } catch (e) { +                // NOP +            } +        } + +        throw new Error('Could not create audio'); +    } + +    createTextToSpeechAudio({text, voiceUri}) { +        const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri); +        if (voice === null) { +            throw new Error('Invalid text-to-speech voice'); +        } +        return new TextToSpeechAudio(text, voice); +    } + +    _onVoicesChanged() { +        // NOP +    } + +    async _createAudio(uri, details) { +        const ttsParameters = this._getTextToSpeechParameters(uri); +        if (ttsParameters !== null) { +            if (typeof details === 'object' && details !== null) { +                if (details.tts === false) { +                    throw new Error('Text-to-speech not permitted'); +                } +            } +            return this.createTextToSpeechAudio(ttsParameters); +        } + +        return await this._createAudioFromUrl(uri); +    } + +    _createAudioFromUrl(url) { +        return new Promise((resolve, reject) => { +            const audio = new Audio(url); +            audio.addEventListener('loadeddata', () => { +                const duration = audio.duration; +                if (duration === 5.694694 || duration === 5.720718) { +                    // Hardcoded values for invalid audio +                    reject(new Error('Could not retrieve audio')); +                } else { +                    resolve(audio); +                } +            }); +            audio.addEventListener('error', () => reject(audio.error)); +        }); +    } + +    _getTextToSpeechVoiceFromVoiceUri(voiceUri) { +        try { +            for (const voice of speechSynthesis.getVoices()) { +                if (voice.voiceURI === voiceUri) { +                    return voice; +                } +            } +        } catch (e) { +            // NOP +        } +        return null; +    } + +    _getTextToSpeechParameters(uri) { +        const m = /^tts:[^#?]*\?([^#]*)/.exec(uri); +        if (m === null) { return null; } + +        const searchParameters = new URLSearchParams(m[1]); +        const text = searchParameters.get('text'); +        const voiceUri = searchParameters.get('voice'); +        return (text !== null && voiceUri !== null ? {text, voiceUri} : null); +    } + +    _cacheCheck() { +        const removeCount = this._cache.size - this._cacheSizeMaximum; +        if (removeCount <= 0) { return; } + +        const removeKeys = []; +        for (const key of this._cache.keys()) { +            removeKeys.push(key); +            if (removeKeys.length >= removeCount) { break; } +        } + +        for (const key of removeKeys) { +            this._cache.delete(key); +        } +    } +} diff --git a/ext/mixed/js/audio.js b/ext/mixed/js/audio.js deleted file mode 100644 index b5a025be..00000000 --- a/ext/mixed/js/audio.js +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (C) 2019-2020  Alex Yatskov <alex@foosoft.net> - * Author: Alex Yatskov <alex@foosoft.net> - * - * 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 apiAudioGetUrl*/ - -class TextToSpeechAudio { -    constructor(text, voice) { -        this.text = text; -        this.voice = voice; -        this._utterance = null; -        this._volume = 1; -    } - -    get currentTime() { -        return 0; -    } -    set currentTime(value) { -        // NOP -    } - -    get volume() { -        return this._volume; -    } -    set volume(value) { -        this._volume = value; -        if (this._utterance !== null) { -            this._utterance.volume = value; -        } -    } - -    play() { -        try { -            if (this._utterance === null) { -                this._utterance = new SpeechSynthesisUtterance(this.text || ''); -                this._utterance.lang = 'ja-JP'; -                this._utterance.volume = this._volume; -                this._utterance.voice = this.voice; -            } - -            speechSynthesis.cancel(); -            speechSynthesis.speak(this._utterance); -        } catch (e) { -            // NOP -        } -    } - -    pause() { -        try { -            speechSynthesis.cancel(); -        } catch (e) { -            // NOP -        } -    } - -    static createFromUri(ttsUri) { -        const m = /^tts:[^#?]*\?([^#]*)/.exec(ttsUri); -        if (m === null) { return null; } - -        const searchParameters = new URLSearchParams(m[1]); -        const text = searchParameters.get('text'); -        let voice = searchParameters.get('voice'); -        if (text === null || voice === null) { return null; } - -        voice = audioGetTextToSpeechVoice(voice); -        if (voice === null) { return null; } - -        return new TextToSpeechAudio(text, voice); -    } -} - -function audioGetFromUrl(url, willDownload) { -    const tts = TextToSpeechAudio.createFromUri(url); -    if (tts !== null) { -        if (willDownload) { -            throw new Error('AnkiConnect does not support downloading text-to-speech audio.'); -        } -        return Promise.resolve(tts); -    } - -    return new Promise((resolve, reject) => { -        const audio = new Audio(url); -        audio.addEventListener('loadeddata', () => { -            if (audio.duration === 5.694694 || audio.duration === 5.720718) { -                // Hardcoded values for invalid audio -                reject(new Error('Could not retrieve audio')); -            } else { -                resolve(audio); -            } -        }); -        audio.addEventListener('error', () => reject(audio.error)); -    }); -} - -async function audioGetFromSources(expression, sources, optionsContext, willDownload, cache=null) { -    const key = `${expression.expression}:${expression.reading}`; -    if (cache !== null) { -        const cacheValue = cache.get(expression); -        if (typeof cacheValue !== 'undefined') { -            return cacheValue; -        } -    } - -    for (let i = 0, ii = sources.length; i < ii; ++i) { -        const source = sources[i]; -        const url = await apiAudioGetUrl(expression, source, optionsContext); -        if (url === null) { -            continue; -        } - -        try { -            let audio = await audioGetFromUrl(url, willDownload); -            if (willDownload) { -                // AnkiConnect handles downloading URLs into cards -                audio = null; -            } -            const result = {audio, url, source}; -            if (cache !== null) { -                cache.set(key, result); -            } -            return result; -        } catch (e) { -            // NOP -        } -    } -    return {audio: null, url: null, source: null}; -} - -function audioGetTextToSpeechVoice(voiceURI) { -    try { -        for (const voice of speechSynthesis.getVoices()) { -            if (voice.voiceURI === voiceURI) { -                return voice; -            } -        } -    } catch (e) { -        // NOP -    } -    return null; -} - -function audioPrepareTextToSpeech(options) { -    if ( -        audioPrepareTextToSpeech.state || -        !options.audio.textToSpeechVoice || -        !( -            options.audio.sources.includes('text-to-speech') || -            options.audio.sources.includes('text-to-speech-reading') -        ) -    ) { -        // Text-to-speech not in use. -        return; -    } - -    // Chrome needs this value called once before it will become populated. -    // The first call will return an empty list. -    audioPrepareTextToSpeech.state = true; -    try { -        speechSynthesis.getVoices(); -    } catch (e) { -        // NOP -    } -} -audioPrepareTextToSpeech.state = false; diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index 83813796..0d50e915 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -175,21 +175,6 @@ function promiseTimeout(delay, resolveValue) {      return promise;  } -function 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 Promise.resolve(str); -    } -    parts.push(str.substring(index)); -    return Promise.all(parts).then((v) => v.join('')); -} -  /*   * Common events @@ -269,7 +254,11 @@ const yomichan = (() => {          constructor() {              super(); +            this._isBackendPreparedResolve = null; +            this._isBackendPreparedPromise = new Promise((resolve) => (this._isBackendPreparedResolve = resolve)); +              this._messageHandlers = new Map([ +                ['backendPrepared', this._onBackendPrepared.bind(this)],                  ['getUrl', this._onMessageGetUrl.bind(this)],                  ['optionsUpdated', this._onMessageOptionsUpdated.bind(this)],                  ['zoomChanged', this._onMessageZoomChanged.bind(this)] @@ -280,6 +269,11 @@ const yomichan = (() => {          // Public +        prepare() { +            chrome.runtime.sendMessage({action: 'yomichanCoreReady'}); +            return this._isBackendPreparedPromise; +        } +          generateId(length) {              const array = new Uint8Array(length);              window.crypto.getRandomValues(array); @@ -305,6 +299,10 @@ const yomichan = (() => {              return false;          } +        _onBackendPrepared() { +            this._isBackendPreparedResolve(); +        } +          _onMessageGetUrl() {              return {url: window.location.href};          } diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js index d7e77cc0..49afc44b 100644 --- a/ext/mixed/js/display-generator.js +++ b/ext/mixed/js/display-generator.js @@ -16,7 +16,10 @@   * along with this program.  If not, see <http://www.gnu.org/licenses/>.   */ -/*global apiGetDisplayTemplatesHtml, TemplateHandler*/ +/* global + * TemplateHandler + * apiGetDisplayTemplatesHtml + */  class DisplayGenerator {      constructor() { @@ -298,7 +301,7 @@ class DisplayGenerator {      }      static _isCharacterKanji(c) { -        const code = c.charCodeAt(0); +        const code = c.codePointAt(0);          return (              code >= 0x4e00 && code < 0x9fb0 ||              code >= 0x3400 && code < 0x4dc0 diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 5d3076ee..515e28a7 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -16,11 +16,24 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global docRangeFromPoint, docSentenceExtract -apiKanjiFind, apiTermsFind, apiNoteView, apiOptionsGet, apiDefinitionsAddable, apiDefinitionAdd -apiScreenshotGet, apiForward -audioPrepareTextToSpeech, audioGetFromSources -DisplayGenerator, WindowScroll, DisplayContext, DOM*/ +/* global + * AudioSystem + * DOM + * DisplayContext + * DisplayGenerator + * WindowScroll + * apiAudioGetUri + * apiDefinitionAdd + * apiDefinitionsAddable + * apiForward + * apiKanjiFind + * apiNoteView + * apiOptionsGet + * apiScreenshotGet + * apiTermsFind + * docRangeFromPoint + * docSentenceExtract + */  class Display {      constructor(spinner, container) { @@ -32,7 +45,7 @@ class Display {          this.index = 0;          this.audioPlaying = null;          this.audioFallback = null; -        this.audioCache = new Map(); +        this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)});          this.styleNode = null;          this.eventListeners = new EventListenerCollection(); @@ -45,10 +58,115 @@ class Display {          this.displayGenerator = new DisplayGenerator();          this.windowScroll = new WindowScroll(); +        this._onKeyDownHandlers = new Map([ +            ['Escape', () => { +                this.onSearchClear(); +                return true; +            }], +            ['PageUp', (e) => { +                if (e.altKey) { +                    this.entryScrollIntoView(this.index - 3, null, true); +                    return true; +                } +                return false; +            }], +            ['PageDown', (e) => { +                if (e.altKey) { +                    this.entryScrollIntoView(this.index + 3, null, true); +                    return true; +                } +                return false; +            }], +            ['End', (e) => { +                if (e.altKey) { +                    this.entryScrollIntoView(this.definitions.length - 1, null, true); +                    return true; +                } +                return false; +            }], +            ['Home', (e) => { +                if (e.altKey) { +                    this.entryScrollIntoView(0, null, true); +                    return true; +                } +                return false; +            }], +            ['ArrowUp', (e) => { +                if (e.altKey) { +                    this.entryScrollIntoView(this.index - 1, null, true); +                    return true; +                } +                return false; +            }], +            ['ArrowDown', (e) => { +                if (e.altKey) { +                    this.entryScrollIntoView(this.index + 1, null, true); +                    return true; +                } +                return false; +            }], +            ['B', (e) => { +                if (e.altKey) { +                    this.sourceTermView(); +                    return true; +                } +                return false; +            }], +            ['F', (e) => { +                if (e.altKey) { +                    this.nextTermView(); +                    return true; +                } +                return false; +            }], +            ['E', (e) => { +                if (e.altKey) { +                    this.noteTryAdd('term-kanji'); +                    return true; +                } +                return false; +            }], +            ['K', (e) => { +                if (e.altKey) { +                    this.noteTryAdd('kanji'); +                    return true; +                } +                return false; +            }], +            ['R', (e) => { +                if (e.altKey) { +                    this.noteTryAdd('term-kana'); +                    return true; +                } +                return false; +            }], +            ['P', (e) => { +                if (e.altKey) { +                    const index = this.index; +                    if (index < 0 || index >= this.definitions.length) { return; } + +                    const entry = this.getEntry(index); +                    if (entry !== null && entry.dataset.type === 'term') { +                        this.audioPlay(this.definitions[index], this.firstExpressionIndex, index); +                    } +                    return true; +                } +                return false; +            }], +            ['V', (e) => { +                if (e.altKey) { +                    this.noteTryView(); +                    return true; +                } +                return false; +            }] +        ]); +          this.setInteractive(true);      }      async prepare(options=null) { +        await yomichan.prepare();          const displayGeneratorPromise = this.displayGenerator.prepare();          const updateOptionsPromise = this.updateOptions(options);          await Promise.all([displayGeneratorPromise, updateOptionsPromise]); @@ -215,9 +333,9 @@ class Display {      onKeyDown(e) {          const key = Display.getKeyFromEvent(e); -        const handler = Display._onKeyDownHandlers.get(key); +        const handler = this._onKeyDownHandlers.get(key);          if (typeof handler === 'function') { -            if (handler(this, e)) { +            if (handler(e)) {                  e.preventDefault();                  return true;              } @@ -259,13 +377,12 @@ class Display {          this.updateDocumentOptions(this.options);          this.updateTheme(this.options.general.popupTheme);          this.setCustomCss(this.options.general.customPopupCss); -        audioPrepareTextToSpeech(this.options);      }      updateDocumentOptions(options) {          const data = document.documentElement.dataset;          data.ankiEnabled = `${options.anki.enable}`; -        data.audioEnabled = `${options.audio.enable}`; +        data.audioEnabled = `${options.audio.enabled}`;          data.compactGlossaries = `${options.general.compactGlossaries}`;          data.enableSearchTags = `${options.scanning.enableSearchTags}`;          data.debug = `${options.general.debugInfo}`; @@ -520,15 +637,13 @@ class Display {      updateAdderButtons(states) {          for (let i = 0; i < states.length; ++i) { -            const state = states[i];              let noteId = null; -            for (const mode in state) { +            for (const [mode, info] of Object.entries(states[i])) {                  const button = this.adderButtonFind(i, mode);                  if (button === null) {                      continue;                  } -                const info = state[mode];                  if (!info.canAdd && noteId === null && info.noteId) {                      noteId = info.noteId;                  } @@ -635,7 +750,7 @@ class Display {              this.setSpinnerVisible(true);              const context = {}; -            if (this.noteUsesScreenshot()) { +            if (this.noteUsesScreenshot(mode)) {                  const screenshot = await this.getScreenshot();                  if (screenshot) {                      context.screenshot = screenshot; @@ -672,16 +787,16 @@ class Display {              }              const sources = this.options.audio.sources; -            let {audio, source} = await audioGetFromSources(expression, sources, this.getOptionsContext(), false, this.audioCache); -            let info; -            if (audio === null) { +            let audio, source, info; +            try { +                ({audio, source} = await this.audioSystem.getDefinitionAudio(expression, sources)); +                info = `From source ${1 + sources.indexOf(source)}: ${source}`; +            } catch (e) {                  if (this.audioFallback === null) {                      this.audioFallback = new Audio('/mixed/mp3/button.mp3');                  }                  audio = this.audioFallback;                  info = 'Could not find audio'; -            } else { -                info = `From source ${1 + sources.indexOf(source)}: ${source}`;              }              const button = this.audioButtonFindImage(entryIndex); @@ -705,10 +820,11 @@ class Display {          }      } -    noteUsesScreenshot() { -        const fields = this.options.anki.terms.fields; -        for (const name in fields) { -            if (fields[name].includes('{screenshot}')) { +    noteUsesScreenshot(mode) { +        const optionsAnki = this.options.anki; +        const fields = (mode === 'kanji' ? optionsAnki.kanji : optionsAnki.terms).fields; +        for (const fieldValue of Object.values(fields)) { +            if (fieldValue.includes('{screenshot}')) {                  return true;              }          } @@ -814,121 +930,9 @@ class Display {          const key = event.key;          return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : '');      } -} - -Display._onKeyDownHandlers = new Map([ -    ['Escape', (self) => { -        self.onSearchClear(); -        return true; -    }], - -    ['PageUp', (self, e) => { -        if (e.altKey) { -            self.entryScrollIntoView(self.index - 3, null, true); -            return true; -        } -        return false; -    }], - -    ['PageDown', (self, e) => { -        if (e.altKey) { -            self.entryScrollIntoView(self.index + 3, null, true); -            return true; -        } -        return false; -    }], - -    ['End', (self, e) => { -        if (e.altKey) { -            self.entryScrollIntoView(self.definitions.length - 1, null, true); -            return true; -        } -        return false; -    }], - -    ['Home', (self, e) => { -        if (e.altKey) { -            self.entryScrollIntoView(0, null, true); -            return true; -        } -        return false; -    }], -    ['ArrowUp', (self, e) => { -        if (e.altKey) { -            self.entryScrollIntoView(self.index - 1, null, true); -            return true; -        } -        return false; -    }], - -    ['ArrowDown', (self, e) => { -        if (e.altKey) { -            self.entryScrollIntoView(self.index + 1, null, true); -            return true; -        } -        return false; -    }], - -    ['B', (self, e) => { -        if (e.altKey) { -            self.sourceTermView(); -            return true; -        } -        return false; -    }], - -    ['F', (self, e) => { -        if (e.altKey) { -            self.nextTermView(); -            return true; -        } -        return false; -    }], - -    ['E', (self, e) => { -        if (e.altKey) { -            self.noteTryAdd('term-kanji'); -            return true; -        } -        return false; -    }], - -    ['K', (self, e) => { -        if (e.altKey) { -            self.noteTryAdd('kanji'); -            return true; -        } -        return false; -    }], - -    ['R', (self, e) => { -        if (e.altKey) { -            self.noteTryAdd('term-kana'); -            return true; -        } -        return false; -    }], - -    ['P', (self, e) => { -        if (e.altKey) { -            const index = self.index; -            if (index < 0 || index >= self.definitions.length) { return; } - -            const entry = self.getEntry(index); -            if (entry !== null && entry.dataset.type === 'term') { -                self.audioPlay(self.definitions[index], self.firstExpressionIndex, index); -            } -            return true; -        } -        return false; -    }], - -    ['V', (self, e) => { -        if (e.altKey) { -            self.noteTryView(); -            return true; -        } -        return false; -    }] -]); +    async _getAudioUri(definition, source) { +        const optionsContext = this.getOptionsContext(); +        return await apiAudioGetUri(definition, source, optionsContext); +    } +} diff --git a/ext/mixed/js/scroll.js b/ext/mixed/js/scroll.js index 5829d294..72da8b65 100644 --- a/ext/mixed/js/scroll.js +++ b/ext/mixed/js/scroll.js @@ -26,7 +26,7 @@ class WindowScroll {          this.animationEndTime = 0;          this.animationEndX = 0;          this.animationEndY = 0; -        this.requestAnimationFrameCallback = (t) => this.onAnimationFrame(t); +        this.requestAnimationFrameCallback = this.onAnimationFrame.bind(this);      }      toY(y) { diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index ff0eac8b..a08e09fb 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -16,7 +16,11 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -/*global docRangeFromPoint, TextSourceRange, DOM*/ +/* global + * DOM + * TextSourceRange + * docRangeFromPoint + */  class TextScanner {      constructor(node, ignoreNodes, ignoreElements, ignorePoints) { diff --git a/package-lock.json b/package-lock.json index 505c71db..88ba43f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,18 +24,48 @@                  "js-tokens": "^4.0.0"              }          }, +        "abab": { +            "version": "2.0.3", +            "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz", +            "integrity": "sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==", +            "dev": true +        },          "acorn": {              "version": "7.1.0",              "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz",              "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==",              "dev": true          }, +        "acorn-globals": { +            "version": "4.3.4", +            "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.4.tgz", +            "integrity": "sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==", +            "dev": true, +            "requires": { +                "acorn": "^6.0.1", +                "acorn-walk": "^6.0.1" +            }, +            "dependencies": { +                "acorn": { +                    "version": "6.4.0", +                    "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz", +                    "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==", +                    "dev": true +                } +            } +        },          "acorn-jsx": {              "version": "5.1.0",              "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.1.0.tgz",              "integrity": "sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw==",              "dev": true          }, +        "acorn-walk": { +            "version": "6.2.0", +            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz", +            "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==", +            "dev": true +        },          "ajv": {              "version": "6.11.0",              "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.11.0.tgz", @@ -81,12 +111,45 @@                  "sprintf-js": "~1.0.2"              }          }, +        "asn1": { +            "version": "0.2.4", +            "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", +            "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", +            "dev": true, +            "requires": { +                "safer-buffer": "~2.1.0" +            } +        }, +        "assert-plus": { +            "version": "1.0.0", +            "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", +            "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", +            "dev": true +        },          "astral-regex": {              "version": "1.0.0",              "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz",              "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==",              "dev": true          }, +        "asynckit": { +            "version": "0.4.0", +            "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", +            "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", +            "dev": true +        }, +        "aws-sign2": { +            "version": "0.7.0", +            "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", +            "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", +            "dev": true +        }, +        "aws4": { +            "version": "1.9.1", +            "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz", +            "integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==", +            "dev": true +        },          "balanced-match": {              "version": "1.0.0",              "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -99,6 +162,15 @@              "integrity": "sha512-UCIPaDJrNNj5jG2ZL+nzJ7czvZV/ZYX6LaIRgfVU1k1edJOQg7dkbiSKzwHkNp6aHEHER/PhlFBrMYnlvJJQEw==",              "dev": true          }, +        "bcrypt-pbkdf": { +            "version": "1.0.2", +            "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", +            "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", +            "dev": true, +            "requires": { +                "tweetnacl": "^0.14.3" +            } +        },          "brace-expansion": {              "version": "1.1.11",              "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -109,12 +181,24 @@                  "concat-map": "0.0.1"              }          }, +        "browser-process-hrtime": { +            "version": "0.1.3", +            "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz", +            "integrity": "sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw==", +            "dev": true +        },          "callsites": {              "version": "3.1.0",              "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",              "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",              "dev": true          }, +        "caseless": { +            "version": "0.12.0", +            "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", +            "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", +            "dev": true +        },          "chalk": {              "version": "2.4.2",              "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -162,6 +246,15 @@              "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",              "dev": true          }, +        "combined-stream": { +            "version": "1.0.8", +            "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", +            "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", +            "dev": true, +            "requires": { +                "delayed-stream": "~1.0.0" +            } +        },          "concat-map": {              "version": "0.0.1",              "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -174,6 +267,12 @@              "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==",              "dev": true          }, +        "core-util-is": { +            "version": "1.0.2", +            "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", +            "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", +            "dev": true +        },          "cross-spawn": {              "version": "6.0.5",              "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -195,6 +294,77 @@                  }              }          }, +        "cssom": { +            "version": "0.4.4", +            "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", +            "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", +            "dev": true +        }, +        "cssstyle": { +            "version": "2.2.0", +            "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.2.0.tgz", +            "integrity": "sha512-sEb3XFPx3jNnCAMtqrXPDeSgQr+jojtCeNf8cvMNMh1cG970+lljssvQDzPq6lmmJu2Vhqood/gtEomBiHOGnA==", +            "dev": true, +            "requires": { +                "cssom": "~0.3.6" +            }, +            "dependencies": { +                "cssom": { +                    "version": "0.3.8", +                    "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", +                    "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", +                    "dev": true +                } +            } +        }, +        "dashdash": { +            "version": "1.14.1", +            "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", +            "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", +            "dev": true, +            "requires": { +                "assert-plus": "^1.0.0" +            } +        }, +        "data-urls": { +            "version": "2.0.0", +            "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", +            "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", +            "dev": true, +            "requires": { +                "abab": "^2.0.3", +                "whatwg-mimetype": "^2.3.0", +                "whatwg-url": "^8.0.0" +            }, +            "dependencies": { +                "tr46": { +                    "version": "2.0.2", +                    "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.0.2.tgz", +                    "integrity": "sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg==", +                    "dev": true, +                    "requires": { +                        "punycode": "^2.1.1" +                    } +                }, +                "webidl-conversions": { +                    "version": "5.0.0", +                    "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", +                    "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", +                    "dev": true +                }, +                "whatwg-url": { +                    "version": "8.0.0", +                    "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.0.0.tgz", +                    "integrity": "sha512-41ou2Dugpij8/LPO5Pq64K5q++MnRCBpEHvQr26/mArEKTkCV5aoXIqyhuYtE0pkqScXwhf2JP57rkRTYM29lQ==", +                    "dev": true, +                    "requires": { +                        "lodash.sortby": "^4.7.0", +                        "tr46": "^2.0.0", +                        "webidl-conversions": "^5.0.0" +                    } +                } +            } +        },          "debug": {              "version": "4.1.1",              "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", @@ -204,12 +374,24 @@                  "ms": "^2.1.1"              }          }, +        "decimal.js": { +            "version": "10.2.0", +            "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.0.tgz", +            "integrity": "sha512-vDPw+rDgn3bZe1+F/pyEwb1oMG2XTlRVgAa6B4KccTEpYgF8w6eQllVbQcfIJnZyvzFtFpxnpGtx8dd7DJp/Rw==", +            "dev": true +        },          "deep-is": {              "version": "0.1.3",              "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",              "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=",              "dev": true          }, +        "delayed-stream": { +            "version": "1.0.0", +            "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", +            "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", +            "dev": true +        },          "doctrine": {              "version": "3.0.0",              "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -228,6 +410,16 @@                  "webidl-conversions": "^4.0.2"              }          }, +        "ecc-jsbn": { +            "version": "0.1.2", +            "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", +            "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", +            "dev": true, +            "requires": { +                "jsbn": "~0.1.0", +                "safer-buffer": "^2.1.0" +            } +        },          "emoji-regex": {              "version": "8.0.0",              "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -240,6 +432,19 @@              "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",              "dev": true          }, +        "escodegen": { +            "version": "1.14.1", +            "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.1.tgz", +            "integrity": "sha512-Bmt7NcRySdIfNPfU2ZoXDrrXsG9ZjvDxcAlMfDUgRBjLOWTuIACXPBFJH7Z+cLb40JeQco5toikyc9t9P8E9SQ==", +            "dev": true, +            "requires": { +                "esprima": "^4.0.1", +                "estraverse": "^4.2.0", +                "esutils": "^2.0.2", +                "optionator": "^0.8.1", +                "source-map": "~0.6.1" +            } +        },          "eslint": {              "version": "6.8.0",              "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.8.0.tgz", @@ -363,6 +568,12 @@              "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",              "dev": true          }, +        "extend": { +            "version": "3.0.2", +            "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", +            "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", +            "dev": true +        },          "external-editor": {              "version": "3.1.0",              "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -374,6 +585,12 @@                  "tmp": "^0.0.33"              }          }, +        "extsprintf": { +            "version": "1.3.0", +            "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", +            "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", +            "dev": true +        },          "fake-indexeddb": {              "version": "3.0.0",              "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-3.0.0.tgz", @@ -437,6 +654,23 @@              "integrity": "sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==",              "dev": true          }, +        "forever-agent": { +            "version": "0.6.1", +            "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", +            "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", +            "dev": true +        }, +        "form-data": { +            "version": "2.3.3", +            "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", +            "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", +            "dev": true, +            "requires": { +                "asynckit": "^0.4.0", +                "combined-stream": "^1.0.6", +                "mime-types": "^2.1.12" +            } +        },          "fs.realpath": {              "version": "1.0.0",              "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -449,6 +683,15 @@              "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",              "dev": true          }, +        "getpass": { +            "version": "0.1.7", +            "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", +            "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", +            "dev": true, +            "requires": { +                "assert-plus": "^1.0.0" +            } +        },          "glob": {              "version": "7.1.6",              "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -481,12 +724,48 @@                  "type-fest": "^0.8.1"              }          }, +        "har-schema": { +            "version": "2.0.0", +            "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", +            "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", +            "dev": true +        }, +        "har-validator": { +            "version": "5.1.3", +            "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", +            "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", +            "dev": true, +            "requires": { +                "ajv": "^6.5.5", +                "har-schema": "^2.0.0" +            } +        },          "has-flag": {              "version": "3.0.0",              "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",              "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",              "dev": true          }, +        "html-encoding-sniffer": { +            "version": "2.0.0", +            "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.0.tgz", +            "integrity": "sha512-Y9prnPKkM7FXxQevZ5UH8Z6aVTY0ede1tHquck5UxGmKWDshxXh95gSa2xXYjS8AsGO5iOvrCI5+GttRKnLdNA==", +            "dev": true, +            "requires": { +                "whatwg-encoding": "^1.0.5" +            } +        }, +        "http-signature": { +            "version": "1.2.0", +            "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", +            "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", +            "dev": true, +            "requires": { +                "assert-plus": "^1.0.0", +                "jsprim": "^1.2.2", +                "sshpk": "^1.7.0" +            } +        },          "iconv-lite": {              "version": "0.4.24",              "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -555,6 +834,12 @@                  "through": "^2.3.6"              }          }, +        "ip-regex": { +            "version": "2.1.0", +            "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", +            "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=", +            "dev": true +        },          "is-extglob": {              "version": "2.1.1",              "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -576,18 +861,36 @@                  "is-extglob": "^2.1.1"              }          }, +        "is-potential-custom-element-name": { +            "version": "1.0.0", +            "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz", +            "integrity": "sha1-DFLlS8yjkbssSUsh6GJtczbG45c=", +            "dev": true +        },          "is-promise": {              "version": "2.1.0",              "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz",              "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=",              "dev": true          }, +        "is-typedarray": { +            "version": "1.0.0", +            "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", +            "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", +            "dev": true +        },          "isexe": {              "version": "2.0.0",              "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",              "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",              "dev": true          }, +        "isstream": { +            "version": "0.1.2", +            "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", +            "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", +            "dev": true +        },          "js-tokens": {              "version": "4.0.0",              "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -604,6 +907,89 @@                  "esprima": "^4.0.0"              }          }, +        "jsbn": { +            "version": "0.1.1", +            "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", +            "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", +            "dev": true +        }, +        "jsdom": { +            "version": "16.2.0", +            "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.2.0.tgz", +            "integrity": "sha512-6VaW3UWyKbm9DFVIAgTfhuwnvqiqlRYNg5Rk6dINTVoZT0eKz+N86vQZr+nqt1ny1lSB1TWZJWSEWQAfu8oTpA==", +            "dev": true, +            "requires": { +                "abab": "^2.0.3", +                "acorn": "^7.1.0", +                "acorn-globals": "^4.3.4", +                "cssom": "^0.4.4", +                "cssstyle": "^2.2.0", +                "data-urls": "^2.0.0", +                "decimal.js": "^10.2.0", +                "domexception": "^2.0.1", +                "escodegen": "^1.13.0", +                "html-encoding-sniffer": "^2.0.0", +                "is-potential-custom-element-name": "^1.0.0", +                "nwsapi": "^2.2.0", +                "parse5": "5.1.1", +                "request": "^2.88.0", +                "request-promise-native": "^1.0.8", +                "saxes": "^4.0.2", +                "symbol-tree": "^3.2.4", +                "tough-cookie": "^3.0.1", +                "w3c-hr-time": "^1.0.1", +                "w3c-xmlserializer": "^2.0.0", +                "webidl-conversions": "^5.0.0", +                "whatwg-encoding": "^1.0.5", +                "whatwg-mimetype": "^2.3.0", +                "whatwg-url": "^8.0.0", +                "ws": "^7.2.1", +                "xml-name-validator": "^3.0.0" +            }, +            "dependencies": { +                "domexception": { +                    "version": "2.0.1", +                    "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", +                    "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", +                    "dev": true, +                    "requires": { +                        "webidl-conversions": "^5.0.0" +                    } +                }, +                "tr46": { +                    "version": "2.0.2", +                    "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.0.2.tgz", +                    "integrity": "sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg==", +                    "dev": true, +                    "requires": { +                        "punycode": "^2.1.1" +                    } +                }, +                "webidl-conversions": { +                    "version": "5.0.0", +                    "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", +                    "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", +                    "dev": true +                }, +                "whatwg-url": { +                    "version": "8.0.0", +                    "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.0.0.tgz", +                    "integrity": "sha512-41ou2Dugpij8/LPO5Pq64K5q++MnRCBpEHvQr26/mArEKTkCV5aoXIqyhuYtE0pkqScXwhf2JP57rkRTYM29lQ==", +                    "dev": true, +                    "requires": { +                        "lodash.sortby": "^4.7.0", +                        "tr46": "^2.0.0", +                        "webidl-conversions": "^5.0.0" +                    } +                } +            } +        }, +        "json-schema": { +            "version": "0.2.3", +            "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", +            "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", +            "dev": true +        },          "json-schema-traverse": {              "version": "0.4.1",              "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -616,6 +1002,24 @@              "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",              "dev": true          }, +        "json-stringify-safe": { +            "version": "5.0.1", +            "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", +            "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", +            "dev": true +        }, +        "jsprim": { +            "version": "1.4.1", +            "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", +            "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", +            "dev": true, +            "requires": { +                "assert-plus": "1.0.0", +                "extsprintf": "1.3.0", +                "json-schema": "0.2.3", +                "verror": "1.10.0" +            } +        },          "levn": {              "version": "0.3.0",              "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", @@ -638,6 +1042,21 @@              "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=",              "dev": true          }, +        "mime-db": { +            "version": "1.43.0", +            "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", +            "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==", +            "dev": true +        }, +        "mime-types": { +            "version": "2.1.26", +            "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz", +            "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==", +            "dev": true, +            "requires": { +                "mime-db": "1.43.0" +            } +        },          "mimic-fn": {              "version": "2.1.0",              "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -692,6 +1111,18 @@              "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",              "dev": true          }, +        "nwsapi": { +            "version": "2.2.0", +            "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", +            "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", +            "dev": true +        }, +        "oauth-sign": { +            "version": "0.9.0", +            "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", +            "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", +            "dev": true +        },          "once": {              "version": "1.4.0",              "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -739,6 +1170,12 @@                  "callsites": "^3.0.0"              }          }, +        "parse5": { +            "version": "5.1.1", +            "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", +            "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", +            "dev": true +        },          "path-is-absolute": {              "version": "1.0.1",              "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -751,6 +1188,12 @@              "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=",              "dev": true          }, +        "performance-now": { +            "version": "2.1.0", +            "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", +            "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", +            "dev": true +        },          "prelude-ls": {              "version": "1.1.2",              "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -763,12 +1206,24 @@              "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",              "dev": true          }, +        "psl": { +            "version": "1.7.0", +            "resolved": "https://registry.npmjs.org/psl/-/psl-1.7.0.tgz", +            "integrity": "sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ==", +            "dev": true +        },          "punycode": {              "version": "2.1.1",              "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",              "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",              "dev": true          }, +        "qs": { +            "version": "6.5.2", +            "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", +            "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", +            "dev": true +        },          "realistic-structured-clone": {              "version": "2.0.2",              "resolved": "https://registry.npmjs.org/realistic-structured-clone/-/realistic-structured-clone-2.0.2.tgz", @@ -787,6 +1242,78 @@              "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==",              "dev": true          }, +        "request": { +            "version": "2.88.2", +            "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", +            "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", +            "dev": true, +            "requires": { +                "aws-sign2": "~0.7.0", +                "aws4": "^1.8.0", +                "caseless": "~0.12.0", +                "combined-stream": "~1.0.6", +                "extend": "~3.0.2", +                "forever-agent": "~0.6.1", +                "form-data": "~2.3.2", +                "har-validator": "~5.1.3", +                "http-signature": "~1.2.0", +                "is-typedarray": "~1.0.0", +                "isstream": "~0.1.2", +                "json-stringify-safe": "~5.0.1", +                "mime-types": "~2.1.19", +                "oauth-sign": "~0.9.0", +                "performance-now": "^2.1.0", +                "qs": "~6.5.2", +                "safe-buffer": "^5.1.2", +                "tough-cookie": "~2.5.0", +                "tunnel-agent": "^0.6.0", +                "uuid": "^3.3.2" +            }, +            "dependencies": { +                "tough-cookie": { +                    "version": "2.5.0", +                    "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", +                    "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", +                    "dev": true, +                    "requires": { +                        "psl": "^1.1.28", +                        "punycode": "^2.1.1" +                    } +                } +            } +        }, +        "request-promise-core": { +            "version": "1.1.3", +            "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz", +            "integrity": "sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==", +            "dev": true, +            "requires": { +                "lodash": "^4.17.15" +            } +        }, +        "request-promise-native": { +            "version": "1.0.8", +            "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.8.tgz", +            "integrity": "sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ==", +            "dev": true, +            "requires": { +                "request-promise-core": "1.1.3", +                "stealthy-require": "^1.1.1", +                "tough-cookie": "^2.3.3" +            }, +            "dependencies": { +                "tough-cookie": { +                    "version": "2.5.0", +                    "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", +                    "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", +                    "dev": true, +                    "requires": { +                        "psl": "^1.1.28", +                        "punycode": "^2.1.1" +                    } +                } +            } +        },          "resolve-from": {              "version": "4.0.0",              "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -830,12 +1357,27 @@                  "tslib": "^1.9.0"              }          }, +        "safe-buffer": { +            "version": "5.2.0", +            "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", +            "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==", +            "dev": true +        },          "safer-buffer": {              "version": "2.1.2",              "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",              "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",              "dev": true          }, +        "saxes": { +            "version": "4.0.2", +            "resolved": "https://registry.npmjs.org/saxes/-/saxes-4.0.2.tgz", +            "integrity": "sha512-EZOTeQ4bgkOaGCDaTKux+LaRNcLNbdbvMH7R3/yjEEULPEmqvkFbFub6DJhJTub2iGMT93CfpZ5LTdKZmAbVeQ==", +            "dev": true, +            "requires": { +                "xmlchars": "^2.2.0" +            } +        },          "semver": {              "version": "6.3.0",              "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", @@ -888,12 +1430,42 @@                  }              }          }, +        "source-map": { +            "version": "0.6.1", +            "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", +            "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", +            "dev": true, +            "optional": true +        },          "sprintf-js": {              "version": "1.0.3",              "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",              "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",              "dev": true          }, +        "sshpk": { +            "version": "1.16.1", +            "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", +            "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", +            "dev": true, +            "requires": { +                "asn1": "~0.2.3", +                "assert-plus": "^1.0.0", +                "bcrypt-pbkdf": "^1.0.0", +                "dashdash": "^1.12.0", +                "ecc-jsbn": "~0.1.1", +                "getpass": "^0.1.1", +                "jsbn": "~0.1.0", +                "safer-buffer": "^2.0.2", +                "tweetnacl": "~0.14.0" +            } +        }, +        "stealthy-require": { +            "version": "1.1.1", +            "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", +            "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", +            "dev": true +        },          "string-width": {              "version": "4.2.0",              "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", @@ -948,6 +1520,12 @@                  "has-flag": "^3.0.0"              }          }, +        "symbol-tree": { +            "version": "3.2.4", +            "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", +            "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", +            "dev": true +        },          "table": {              "version": "5.4.6",              "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", @@ -1006,6 +1584,17 @@                  "os-tmpdir": "~1.0.2"              }          }, +        "tough-cookie": { +            "version": "3.0.1", +            "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz", +            "integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==", +            "dev": true, +            "requires": { +                "ip-regex": "^2.1.0", +                "psl": "^1.1.28", +                "punycode": "^2.1.1" +            } +        },          "tr46": {              "version": "1.0.1",              "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -1021,6 +1610,21 @@              "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==",              "dev": true          }, +        "tunnel-agent": { +            "version": "0.6.0", +            "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", +            "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", +            "dev": true, +            "requires": { +                "safe-buffer": "^5.0.1" +            } +        }, +        "tweetnacl": { +            "version": "0.14.5", +            "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", +            "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", +            "dev": true +        },          "type-check": {              "version": "0.3.2",              "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -1062,18 +1666,68 @@                  "punycode": "^2.1.0"              }          }, +        "uuid": { +            "version": "3.4.0", +            "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", +            "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", +            "dev": true +        },          "v8-compile-cache": {              "version": "2.1.0",              "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz",              "integrity": "sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g==",              "dev": true          }, +        "verror": { +            "version": "1.10.0", +            "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", +            "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", +            "dev": true, +            "requires": { +                "assert-plus": "^1.0.0", +                "core-util-is": "1.0.2", +                "extsprintf": "^1.2.0" +            } +        }, +        "w3c-hr-time": { +            "version": "1.0.1", +            "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", +            "integrity": "sha1-gqwr/2PZUOqeMYmlimViX+3xkEU=", +            "dev": true, +            "requires": { +                "browser-process-hrtime": "^0.1.2" +            } +        }, +        "w3c-xmlserializer": { +            "version": "2.0.0", +            "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", +            "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", +            "dev": true, +            "requires": { +                "xml-name-validator": "^3.0.0" +            } +        },          "webidl-conversions": {              "version": "4.0.2",              "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",              "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",              "dev": true          }, +        "whatwg-encoding": { +            "version": "1.0.5", +            "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", +            "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", +            "dev": true, +            "requires": { +                "iconv-lite": "0.4.24" +            } +        }, +        "whatwg-mimetype": { +            "version": "2.3.0", +            "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", +            "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", +            "dev": true +        },          "whatwg-url": {              "version": "7.1.0",              "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", @@ -1114,6 +1768,24 @@              "requires": {                  "mkdirp": "^0.5.1"              } +        }, +        "ws": { +            "version": "7.2.1", +            "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.1.tgz", +            "integrity": "sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A==", +            "dev": true +        }, +        "xml-name-validator": { +            "version": "3.0.0", +            "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", +            "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", +            "dev": true +        }, +        "xmlchars": { +            "version": "2.2.0", +            "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", +            "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", +            "dev": true          }      }  } diff --git a/package.json b/package.json index 17fdfa82..eb449ea9 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@      },      "scripts": {          "test": "npm run test-lint && npm run test-code", -        "test-lint": "eslint .", -        "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js" +        "test-lint": "eslint . && node ./test/lint/global-declarations.js", +        "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document.js"      },      "repository": {          "type": "git", @@ -29,6 +29,7 @@      "devDependencies": {          "eslint": "^6.8.0",          "eslint-plugin-no-unsanitized": "^3.0.2", -        "fake-indexeddb": "^3.0.0" +        "fake-indexeddb": "^3.0.0", +        "jsdom": "^16.2.0"      }  } diff --git a/test/data/html/test-document1.html b/test/data/html/test-document1.html new file mode 100644 index 00000000..0754a314 --- /dev/null +++ b/test/data/html/test-document1.html @@ -0,0 +1,264 @@ +<!DOCTYPE html> +<html> +    <head> +        <meta charset="UTF-8"> +        <meta name="viewport" content="width=device-width,initial-scale=1" /> +        <title>Yomichan Tests</title> +        <link rel="icon" type="image/gif" href="data:image/gif;base64,R0lGODlhEAAQAKEBAAAAAP///////////yH5BAEKAAIALAAAAAAQABAAAAImFI6Zpt0B4YkS0TCpq07xbmEgcGVRUpLaI46ZG7ppalY0jDCwUAAAOw==" /> +        <link rel="stylesheet" href="test-stylesheet.css" /> +    </head> +<body> + +    <h1>Yomichan Tests</h1> + +    <div +        class="test" +        data-test-type="scan" +        data-element-from-point-selector="span" +        data-caret-range-from-point-selector="span" +        data-start-node-selector="span" +        data-start-offset="0" +        data-end-node-selector="span" +        data-end-offset="0" +        data-result-type="TextSourceRange", +        data-sentence-extent="100" +        data-sentence="真白「心配してくださって、ありがとございます」" +    > +        <span>真白「心配してくださって、ありがとございます」</span> +    </div> + +    <div +        class="test" +        data-test-type="scan" +        data-element-from-point-selector="span" +        data-caret-range-from-point-selector="span" +        data-start-node-selector="span" +        data-start-offset="5" +        data-end-node-selector="span" +        data-end-offset="5" +        data-result-type="TextSourceRange", +        data-sentence-extent="100" +        data-sentence="心配してくださって、ありがとございます" +    > +        <span>真白「心配してくださって、ありがとございます」</span> +    </div> + +    <div +        class="test" +        data-test-type="scan" +        data-element-from-point-selector="input" +        data-caret-range-from-point-selector="input" +        data-start-node-selector="input" +        data-start-offset="0" +        data-end-node-selector="input" +        data-end-offset="0" +        data-result-type="TextSourceRange", +        data-sentence-extent="100" +        data-sentence="真白「心配してくださって、ありがとございます」" +        data-has-imposter="true" +    > +        <input type="text" value="真白「心配してくださって、ありがとございます」" style="width: 100%; box-sizing: border-box; font-family: inherit; font-size: inherit; border: 1px solid #d8d8d8; padding: 0.2em;" /> +    </div> + +    <div +        class="test" +        data-test-type="scan" +        data-element-from-point-selector="textarea" +        data-caret-range-from-point-selector="textarea" +        data-start-node-selector="textarea" +        data-start-offset="0" +        data-end-node-selector="textarea" +        data-end-offset="0" +        data-result-type="TextSourceRange", +        data-sentence-extent="100" +        data-sentence="真白「心配してくださって、ありがとございます」" +        data-has-imposter="true" +    > +        <textarea style="width: 100%; height: 3em; box-sizing: border-box; font-family: inherit; font-size: inherit; border: 1px solid #d8d8d8; padding: 0.2em;">真白「心配してくださって、ありがとございます」</textarea> +    </div> + +    <div +        class="test" +        data-test-type="scan" +        data-element-from-point-selector="button" +        data-caret-range-from-point-selector="button" +        data-start-node-selector="button" +        data-start-offset="0" +        data-end-node-selector="button" +        data-end-offset="0" +        data-result-type="TextSourceElement", +        data-sentence-extent="100" +        data-sentence="よみちゃん" +    > +        <button style="width: 100%; box-sizing: border-box; font-family: inherit; font-size: inherit; border: 1px solid #d8d8d8; background-color: #f0f0f0; padding: 0.2em;">よみちゃん</button> +    </div> + +    <div +        class="test" +        data-test-type="scan" +        data-element-from-point-selector="img" +        data-caret-range-from-point-selector="img" +        data-start-node-selector="img" +        data-start-offset="0" +        data-end-node-selector="img" +        data-end-offset="0" +        data-result-type="TextSourceElement" +        data-sentence="よみちゃん" +    > +        <img src="data:image/gif;base64,R0lGODdhBwAHAIABAAAAAP///ywAAAAABwAHAAACDIRvEaC32FpCbEkKCgA7" alt="よみちゃん" title="よみちゃん" style="width: 70px; height: 70px; image-rendering: crisp-edges; image-rendering: pixelated; display: block;" /> +    </div> + +    <div +        class="test" +        data-test-type="text-source-range-seek" +        data-seek-node-selector="span:nth-of-type(1)" +        data-seek-node-is-text="true" +        data-seek-offset="0" +        data-seek-length="149" +        data-seek-direction="forward" +        data-expected-result-node-selector="span:nth-of-type(1)" +        data-expected-result-node-is-text="true" +        data-expected-result-offset="149" +        data-expected-result-content=" +        あいうえお +        かきくけこ +        さしすせそ +        たちつてと +        なにぬねの +        はひふへほ +        まみむめも +        や ゆ よ +        らりるれろ +        わゐ ゑを +        " +    > +        <span> +        あいうえお +        かきくけこ +        さしすせそ +        たちつてと +        なにぬねの +        はひふへほ +        まみむめも +        や ゆ よ +        らりるれろ +        わゐ ゑを +        </span><span>trailing content</span> +    </div> + +    <div +        class="test" +        data-test-type="text-source-range-seek" +        data-seek-node-selector="span:nth-of-type(1)" +        data-seek-node-is-text="true" +        data-seek-offset="149" +        data-seek-length="149" +        data-seek-direction="backward" +        data-expected-result-node-selector="span:nth-of-type(1)" +        data-expected-result-node-is-text="true" +        data-expected-result-offset="0" +        data-expected-result-content=" +        あいうえお +        かきくけこ +        さしすせそ +        たちつてと +        なにぬねの +        はひふへほ +        まみむめも +        や ゆ よ +        らりるれろ +        わゐ ゑを +        " +    > +        <span> +        あいうえお +        かきくけこ +        さしすせそ +        たちつてと +        なにぬねの +        はひふへほ +        まみむめも +        や ゆ よ +        らりるれろ +        わゐ ゑを +        </span><span>trailing content</span> +    </div> + +    <div +        class="test" +        data-test-type="text-source-range-seek" +        data-seek-node-selector="span:nth-of-type(1)" +        data-seek-node-is-text="true" +        data-seek-offset="0" +        data-seek-length="150" +        data-seek-direction="forward" +        data-expected-result-node-selector="span:nth-of-type(2)" +        data-expected-result-node-is-text="true" +        data-expected-result-offset="1" +        data-expected-result-content=" +        あいうえお +        かきくけこ +        さしすせそ +        たちつてと +        なにぬねの +        はひふへほ +        まみむめも +        や ゆ よ +        らりるれろ +        わゐ ゑを +        t" +    > +        <span> +        あいうえお +        かきくけこ +        さしすせそ +        たちつてと +        なにぬねの +        はひふへほ +        まみむめも +        や ゆ よ +        らりるれろ +        わゐ ゑを +        </span><span>trailing content</span> +    </div> + +    <div +        class="test" +        data-test-type="text-source-range-seek" +        data-seek-node-selector="span:nth-of-type(2)" +        data-seek-node-is-text="true" +        data-seek-offset="1" +        data-seek-length="150" +        data-seek-direction="backward" +        data-expected-result-node-selector="span:nth-of-type(1)" +        data-expected-result-node-is-text="true" +        data-expected-result-offset="0" +        data-expected-result-content=" +        あいうえお +        かきくけこ +        さしすせそ +        たちつてと +        なにぬねの +        はひふへほ +        まみむめも +        や ゆ よ +        らりるれろ +        わゐ ゑを +        t" +    > +        <span> +        あいうえお +        かきくけこ +        さしすせそ +        たちつてと +        なにぬねの +        はひふへほ +        まみむめも +        や ゆ よ +        らりるれろ +        わゐ ゑを +        </span><span>trailing content</span> +    </div> + +</body> +</html>
\ No newline at end of file diff --git a/test/data/html/test-stylesheet.css b/test/data/html/test-stylesheet.css new file mode 100644 index 00000000..ab25732e --- /dev/null +++ b/test/data/html/test-stylesheet.css @@ -0,0 +1,32 @@ +body { +    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; +    font-size: 14px; +    max-width: 680px; +    padding: 0 1em; +    box-sizing: border-box; +    margin: 0 auto; +    background-color: #f8f8f8; +    counter-reset: test-id; +} + +h1 { +    font-size: 2em; +    margin: 0.67em 0; +} + +.test { +    background-color: #ffffff; +    margin: 1em 0; +    padding: 0.5em; +    box-shadow: rgba(64, 64, 64, 0.3) 0px 1px 2px 0px, rgba(64, 64, 64, 0.15) 0px 1px 3px 1px; +    border-radius: 4px; +} + +.test:before { +    content: "Test " counter(test-id); +    display: block; +    counter-increment: test-id; +    margin-bottom: 0.5em; +    border-bottom: 1px solid #d8d8d8; +    font-weight: bold; +} diff --git a/test/dictionary-validate.js b/test/dictionary-validate.js index 14eee2ed..6496f2ac 100644 --- a/test/dictionary-validate.js +++ b/test/dictionary-validate.js @@ -18,10 +18,12 @@  const fs = require('fs');  const path = require('path'); -const yomichanTest = require('./yomichan-test'); +const {JSZip} = require('./yomichan-test'); +const {VM} = require('./yomichan-vm'); -const JSZip = yomichanTest.JSZip; -const {JsonSchema} = yomichanTest.requireScript('ext/bg/js/json-schema.js', ['JsonSchema']); +const vm = new VM(); +vm.execute('bg/js/json-schema.js'); +const JsonSchema = vm.get('JsonSchema');  function readSchema(relativeFileName) { diff --git a/test/lint/global-declarations.js b/test/lint/global-declarations.js new file mode 100644 index 00000000..2629cc5e --- /dev/null +++ b/test/lint/global-declarations.js @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2020  Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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/>. + */ + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); +const {getAllFiles} = require('../yomichan-test'); + + +function countOccurences(string, pattern) { +    return (string.match(pattern) || []).length; +} + +function getNewline(string) { +    const count1 = countOccurences(string, /(?:^|[^\r])\n/g); +    const count2 = countOccurences(string, /\r\n/g); +    const count3 = countOccurences(string, /\r(?:[^\n]|$)/g); +    if (count2 > count1) { +        return (count3 > count2) ? '\r' : '\r\n'; +    } else { +        return (count3 > count1) ? '\r' : '\n'; +    } +} + + +function validateGlobals(fileName, fix) { +    const pattern = /\/\*\s*global\s+([\w\W]*?)\*\//g; +    const trimPattern = /^[\s,*]+|[\s,*]+$/g; +    const splitPattern = /[\s,*]+/; +    const source = fs.readFileSync(fileName, {encoding: 'utf8'}); +    let match; +    let first = true; +    let endIndex = 0; +    let newSource = ''; +    const newline = getNewline(source); +    while ((match = pattern.exec(source)) !== null) { +        if (!first) { +            console.error(`Encountered more than one global declaration in ${fileName}`); +            return false; +        } +        first = false; + +        const parts = match[1].replace(trimPattern, '').split(splitPattern); +        parts.sort(); + +        const actual = match[0]; +        const expected = `/* global${parts.map((v) => `${newline} * ${v}`).join('')}${newline} */`; + +        try { +            assert.strictEqual(actual, expected); +        } catch (e) { +            console.error(`Global declaration error encountered in ${fileName}:`); +            console.error(e.message); +            if (!fix) { +                return false; +            } +        } + +        newSource += source.substring(0, match.index); +        newSource += expected; +        endIndex = match.index + match[0].length; +    } + +    newSource += source.substring(endIndex); + +    if (fix) { +        fs.writeFileSync(fileName, newSource, {encoding: 'utf8'}); +    } + +    return true; +} + + +function main() { +    const fix = (process.argv.length >= 2 && process.argv[2] === '--fix'); +    const directory = path.resolve(__dirname, '..', '..', 'ext'); +    const pattern = /\.js$/; +    const ignorePattern = /[\\/]ext[\\/]mixed[\\/]lib[\\/]/; +    const fileNames = getAllFiles(directory, (f) => pattern.test(f) && !ignorePattern.test(f)); +    for (const fileName of fileNames) { +        if (!validateGlobals(fileName, fix)) { +            process.exit(-1); +            return; +        } +    } +    process.exit(0); +} + + +if (require.main === module) { main(); } diff --git a/test/schema-validate.js b/test/schema-validate.js index a4f2d94c..eb31aa8d 100644 --- a/test/schema-validate.js +++ b/test/schema-validate.js @@ -17,9 +17,11 @@   */  const fs = require('fs'); -const yomichanTest = require('./yomichan-test'); +const {VM} = require('./yomichan-vm'); -const {JsonSchema} = yomichanTest.requireScript('ext/bg/js/json-schema.js', ['JsonSchema']); +const vm = new VM(); +vm.execute('bg/js/json-schema.js'); +const JsonSchema = vm.get('JsonSchema');  function main() { diff --git a/test/test-database.js b/test/test-database.js index c2317881..833aa75d 100644 --- a/test/test-database.js +++ b/test/test-database.js @@ -21,6 +21,7 @@ const url = require('url');  const path = require('path');  const assert = require('assert');  const yomichanTest = require('./yomichan-test'); +const {VM} = require('./yomichan-vm');  require('fake-indexeddb/auto');  const chrome = { @@ -30,6 +31,9 @@ const chrome = {          },          getURL(path2) {              return url.pathToFileURL(path.join(__dirname, '..', 'ext', path2.replace(/^\//, ''))); +        }, +        sendMessage() { +            // NOP          }      }  }; @@ -88,24 +92,24 @@ class XMLHttpRequest {      }  } -const {JsonSchema} = yomichanTest.requireScript('ext/bg/js/json-schema.js', ['JsonSchema']); -const {dictFieldSplit, dictTagSanitize} = yomichanTest.requireScript('ext/bg/js/dictionary.js', ['dictFieldSplit', 'dictTagSanitize']); -const {stringReverse, hasOwn} = yomichanTest.requireScript('ext/mixed/js/core.js', ['stringReverse', 'hasOwn'], {chrome}); -const {requestJson} = yomichanTest.requireScript('ext/bg/js/request.js', ['requestJson'], {XMLHttpRequest}); -const databaseGlobals = { +const vm = new VM({      chrome, -    JsonSchema, -    requestJson, -    stringReverse, -    hasOwn, -    dictFieldSplit, -    dictTagSanitize, +    XMLHttpRequest,      indexedDB: global.indexedDB, +    IDBKeyRange: global.IDBKeyRange,      JSZip: yomichanTest.JSZip -}; -databaseGlobals.window = databaseGlobals; -const {Database} = yomichanTest.requireScript('ext/bg/js/database.js', ['Database'], databaseGlobals); +}); +vm.context.window = vm.context; + +vm.execute([ +    'bg/js/json-schema.js', +    'bg/js/dictionary.js', +    'mixed/js/core.js', +    'bg/js/request.js', +    'bg/js/database.js' +]); +const Database = vm.get('Database');  function countTermsWithExpression(terms, expression) { @@ -213,20 +217,20 @@ async function testDatabase1() {              },              {prefixWildcardsSupported: true}          ); -        assert.deepStrictEqual(errors, []); -        assert.deepStrictEqual(result, expectedSummary); +        vm.assert.deepStrictEqual(errors, []); +        vm.assert.deepStrictEqual(result, expectedSummary);          assert.ok(progressEvent);          // Get info summary          const info = await database.getDictionaryInfo(); -        assert.deepStrictEqual(info, [expectedSummary]); +        vm.assert.deepStrictEqual(info, [expectedSummary]);          // Get counts          const counts = await database.getDictionaryCounts(              info.map((v) => v.title),              true          ); -        assert.deepStrictEqual(counts, { +        vm.assert.deepStrictEqual(counts, {              counts: [{kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 3, tagMeta: 12}],              total: {kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 3, tagMeta: 12}          }); @@ -249,10 +253,10 @@ async function testDatabase1() {  async function testDatabaseEmpty1(database) {      const info = await database.getDictionaryInfo(); -    assert.deepStrictEqual(info, []); +    vm.assert.deepStrictEqual(info, []);      const counts = await database.getDictionaryCounts([], true); -    assert.deepStrictEqual(counts, { +    vm.assert.deepStrictEqual(counts, {          counts: [],          total: {kanji: 0, kanjiMeta: 0, terms: 0, termMeta: 0, tagMeta: 0}      }); @@ -825,7 +829,7 @@ async function testFindTagForTitle1(database, title) {      for (const {inputs, expectedResults} of data) {          for (const {name} of inputs) {              const result = await database.findTagForTitle(name, title); -            assert.deepStrictEqual(result, expectedResults.value); +            vm.assert.deepStrictEqual(result, expectedResults.value);          }      }  } diff --git a/test/test-document.js b/test/test-document.js new file mode 100644 index 00000000..80b9719d --- /dev/null +++ b/test/test-document.js @@ -0,0 +1,240 @@ +/* + * Copyright (C) 2020  Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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/>. + */ + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); +const {JSDOM} = require('jsdom'); +const {VM} = require('./yomichan-vm'); + + +// DOMRect class definition +class DOMRect { +    constructor(x, y, width, height) { +        this._x = x; +        this._y = y; +        this._width = width; +        this._height = height; +    } + +    get x() { return this._x; } +    get y() { return this._y; } +    get width() { return this._width; } +    get height() { return this._height; } +    get left() { return this._x + Math.min(0, this._width); } +    get right() { return this._x + Math.max(0, this._width); } +    get top() { return this._y + Math.min(0, this._height); } +    get bottom() { return this._y + Math.max(0, this._height); } +} + + +function createJSDOM(fileName) { +    const domSource = fs.readFileSync(fileName, {encoding: 'utf8'}); +    const dom = new JSDOM(domSource); +    const document = dom.window.document; +    const window = dom.window; + +    // Define innerText setter as an alias for textContent setter +    Object.defineProperty(window.HTMLDivElement.prototype, 'innerText', { +        set(value) { this.textContent = value; } +    }); + +    // Placeholder for feature detection +    document.caretRangeFromPoint = () => null; + +    return dom; +} + +function querySelectorChildOrSelf(element, selector) { +    return selector ? element.querySelector(selector) : element; +} + +function getChildTextNodeOrSelf(dom, node) { +    if (node === null) { return null; } +    const Node = dom.window.Node; +    const childNode = node.firstChild; +    return (childNode !== null && childNode.nodeType === Node.TEXT_NODE ? childNode : node); +} + +function getPrototypeOfOrNull(value) { +    try { +        return Object.getPrototypeOf(value); +    } catch (e) { +        return null; +    } +} + +function findImposterElement(document) { +    // Finds the imposter element based on it's z-index style +    return document.querySelector('div[style*="2147483646"]>*'); +} + + +async function testDocument1() { +    const dom = createJSDOM(path.join(__dirname, 'data', 'html', 'test-document1.html')); +    const window = dom.window; +    const document = window.document; +    const Node = window.Node; +    const Range = window.Range; + +    const vm = new VM({document, window, Range, Node}); +    vm.execute([ +        'mixed/js/dom.js', +        'fg/js/source.js', +        'fg/js/document.js' +    ]); +    const [TextSourceRange, TextSourceElement, docRangeFromPoint, docSentenceExtract] = vm.get([ +        'TextSourceRange', +        'TextSourceElement', +        'docRangeFromPoint', +        'docSentenceExtract' +    ]); + +    try { +        await testDocumentTextScanningFunctions(dom, {docRangeFromPoint, docSentenceExtract, TextSourceRange, TextSourceElement}); +        await testTextSourceRangeSeekFunctions(dom, {TextSourceRange}); +    } finally { +        window.close(); +    } +} + +async function testDocumentTextScanningFunctions(dom, {docRangeFromPoint, docSentenceExtract, TextSourceRange, TextSourceElement}) { +    const document = dom.window.document; + +    for (const testElement of document.querySelectorAll('.test[data-test-type=scan]')) { +        // Get test parameters +        let { +            elementFromPointSelector, +            caretRangeFromPointSelector, +            startNodeSelector, +            startOffset, +            endNodeSelector, +            endOffset, +            resultType, +            sentenceExtent, +            sentence, +            hasImposter +        } = testElement.dataset; + +        const elementFromPointValue = querySelectorChildOrSelf(testElement, elementFromPointSelector); +        const caretRangeFromPointValue = querySelectorChildOrSelf(testElement, caretRangeFromPointSelector); +        const startNode = getChildTextNodeOrSelf(dom, querySelectorChildOrSelf(testElement, startNodeSelector)); +        const endNode = getChildTextNodeOrSelf(dom, querySelectorChildOrSelf(testElement, endNodeSelector)); + +        startOffset = parseInt(startOffset, 10); +        endOffset = parseInt(endOffset, 10); +        sentenceExtent = parseInt(sentenceExtent, 10); + +        assert.notStrictEqual(elementFromPointValue, null); +        assert.notStrictEqual(caretRangeFromPointValue, null); +        assert.notStrictEqual(startNode, null); +        assert.notStrictEqual(endNode, null); + +        // Setup functions +        document.elementFromPoint = () => elementFromPointValue; + +        document.caretRangeFromPoint = (x, y) => { +            const imposter = getChildTextNodeOrSelf(dom, findImposterElement(document)); +            assert.strictEqual(!!imposter, hasImposter === 'true'); + +            const range = document.createRange(); +            range.setStart(imposter ? imposter : startNode, startOffset); +            range.setEnd(imposter ? imposter : startNode, endOffset); + +            // Override getClientRects to return a rect guaranteed to contain (x, y) +            range.getClientRects = () => [new DOMRect(x - 1, y - 1, 2, 2)]; +            return range; +        }; + +        // Test docRangeFromPoint +        const source = docRangeFromPoint(0, 0, false); +        switch (resultType) { +            case 'TextSourceRange': +                assert.strictEqual(getPrototypeOfOrNull(source), TextSourceRange.prototype); +                break; +            case 'TextSourceElement': +                assert.strictEqual(getPrototypeOfOrNull(source), TextSourceElement.prototype); +                break; +            case 'null': +                assert.strictEqual(source, null); +                break; +            default: +                assert.ok(false); +                break; +        } +        if (source === null) { continue; } + +        // Test docSentenceExtract +        const sentenceActual = docSentenceExtract(source, sentenceExtent).text; +        assert.strictEqual(sentenceActual, sentence); + +        // Clean +        source.cleanup(); +    } +} + +async function testTextSourceRangeSeekFunctions(dom, {TextSourceRange}) { +    const document = dom.window.document; + +    for (const testElement of document.querySelectorAll('.test[data-test-type=text-source-range-seek]')) { +        // Get test parameters +        let { +            seekNodeSelector, +            seekNodeIsText, +            seekOffset, +            seekLength, +            seekDirection, +            expectedResultNodeSelector, +            expectedResultNodeIsText, +            expectedResultOffset, +            expectedResultContent +        } = testElement.dataset; + +        seekOffset = parseInt(seekOffset, 10); +        seekLength = parseInt(seekLength, 10); +        expectedResultOffset = parseInt(expectedResultOffset, 10); + +        let seekNode = testElement.querySelector(seekNodeSelector); +        if (seekNodeIsText === 'true') { +            seekNode = seekNode.firstChild; +        } + +        let expectedResultNode = testElement.querySelector(expectedResultNodeSelector); +        if (expectedResultNodeIsText === 'true') { +            expectedResultNode = expectedResultNode.firstChild; +        } + +        const {node, offset, content} = ( +            seekDirection === 'forward' ? +            TextSourceRange.seekForward(seekNode, seekOffset, seekLength) : +            TextSourceRange.seekBackward(seekNode, seekOffset, seekLength) +        ); + +        assert.strictEqual(node, expectedResultNode); +        assert.strictEqual(offset, expectedResultOffset); +        assert.strictEqual(content, expectedResultContent); +    } +} + + +async function main() { +    await testDocument1(); +} + + +if (require.main === module) { main(); } diff --git a/test/test-schema.js b/test/test-schema.js index f4612f86..5f9915fd 100644 --- a/test/test-schema.js +++ b/test/test-schema.js @@ -17,9 +17,11 @@   */  const assert = require('assert'); -const yomichanTest = require('./yomichan-test'); +const {VM} = require('./yomichan-vm'); -const {JsonSchema} = yomichanTest.requireScript('ext/bg/js/json-schema.js', ['JsonSchema']); +const vm = new VM(); +vm.execute('bg/js/json-schema.js'); +const JsonSchema = vm.get('JsonSchema');  function testValidate1() { @@ -138,7 +140,7 @@ function testGetValidValueOrDefault1() {      for (const [value, expected] of testData) {          const actual = JsonSchema.getValidValueOrDefault(schema, value); -        assert.deepStrictEqual(actual, expected); +        vm.assert.deepStrictEqual(actual, expected);      }  } @@ -177,7 +179,7 @@ function testGetValidValueOrDefault2() {      for (const [value, expected] of testData) {          const actual = JsonSchema.getValidValueOrDefault(schema, value); -        assert.deepStrictEqual(actual, expected); +        vm.assert.deepStrictEqual(actual, expected);      }  } @@ -235,7 +237,7 @@ function testGetValidValueOrDefault3() {      for (const [value, expected] of testData) {          const actual = JsonSchema.getValidValueOrDefault(schema, value); -        assert.deepStrictEqual(actual, expected); +        vm.assert.deepStrictEqual(actual, expected);      }  } diff --git a/test/yomichan-test.js b/test/yomichan-test.js index 78bfb9c6..5fa7730b 100644 --- a/test/yomichan-test.js +++ b/test/yomichan-test.js @@ -22,18 +22,6 @@ const path = require('path');  let JSZip = null; -function requireScript(fileName, exportNames, variables) { -    const absoluteFileName = path.join(__dirname, '..', fileName); -    const source = fs.readFileSync(absoluteFileName, {encoding: 'utf8'}); -    const exportNamesString = Array.isArray(exportNames) ? exportNames.join(',') : ''; -    const variablesArgumentName = '__variables__'; -    let variableString = ''; -    if (typeof variables === 'object' && variables !== null) { -        variableString = Object.keys(variables).join(','); -        variableString = `const {${variableString}} = ${variablesArgumentName};`; -    } -    return Function(variablesArgumentName, `'use strict';${variableString}${source}\n;return {${exportNamesString}};`)(variables); -}  function getJSZip() {      if (JSZip === null) { @@ -62,9 +50,29 @@ function createTestDictionaryArchive(dictionary, dictionaryName) {      return archive;  } +function getAllFiles(baseDirectory, predicate=null) { +    const results = []; +    const directories = [path.resolve(baseDirectory)]; +    while (directories.length > 0) { +        const directory = directories.shift(); +        for (const fileName of fs.readdirSync(directory)) { +            const fullFileName = path.resolve(directory, fileName); +            const stats = fs.statSync(fullFileName); +            if (stats.isFile()) { +                if (typeof predicate !== 'function' || predicate(fullFileName, directory, baseDirectory)) { +                    results.push(fullFileName); +                } +            } else if (stats.isDirectory()) { +                directories.push(fullFileName); +            } +        } +    } +    return results; +} +  module.exports = { -    requireScript,      createTestDictionaryArchive, +    getAllFiles,      get JSZip() { return getJSZip(); }  }; diff --git a/test/yomichan-vm.js b/test/yomichan-vm.js new file mode 100644 index 00000000..ff478844 --- /dev/null +++ b/test/yomichan-vm.js @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2020  Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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/>. + */ + +const fs = require('fs'); +const vm = require('vm'); +const path = require('path'); +const assert = require('assert'); + + +function getContextEnvironmentRecords(context, names) { +    // Enables export of values from the declarative environment record +    if (!Array.isArray(names) || names.length === 0) { +        return []; +    } + +    let scriptSource = '(() => {\n    "use strict";\n    const results = [];'; +    for (const name of names) { +        scriptSource += `\n    try { results.push(${name}); } catch (e) { results.push(void 0); }`; +    } +    scriptSource += '\n    return results;\n})();'; + +    const script = new vm.Script(scriptSource, {filename: 'getContextEnvironmentRecords'}); + +    const contextHasNames = Object.prototype.hasOwnProperty.call(context, 'names'); +    const contextNames = context.names; +    context.names = names; + +    const results = script.runInContext(context, {}); + +    if (contextHasNames) { +        context.names = contextNames; +    } else { +        delete context.names; +    } + +    return Array.from(results); +} + +function isDeepStrictEqual(val1, val2) { +    if (val1 === val2) { return true; } + +    if (Array.isArray(val1)) { +        if (Array.isArray(val2)) { +            return isArrayDeepStrictEqual(val1, val2); +        } +    } else if (typeof val1 === 'object' && val1 !== null) { +        if (typeof val2 === 'object' && val2 !== null) { +            return isObjectDeepStrictEqual(val1, val2); +        } +    } + +    return false; +} + +function isArrayDeepStrictEqual(val1, val2) { +    const ii = val1.length; +    if (ii !== val2.length) { return false; } + +    for (let i = 0; i < ii; ++i) { +        if (!isDeepStrictEqual(val1[i], val2[i])) { +            return false; +        } +    } + +    return true; +} + +function isObjectDeepStrictEqual(val1, val2) { +    const keys1 = Object.keys(val1); +    const keys2 = Object.keys(val2); + +    if (keys1.length !== keys2.length) { return false; } + +    const keySet = new Set(keys1); +    for (const key of keys2) { +        if (!keySet.delete(key)) { return false; } +    } + +    for (const key of keys1) { +        if (!isDeepStrictEqual(val1[key], val2[key])) { +            return false; +        } +    } + +    const tag1 = Object.prototype.toString.call(val1); +    const tag2 = Object.prototype.toString.call(val2); +    if (tag1 !== tag2) { return false; } + +    return true; +} + +function deepStrictEqual(actual, expected) { +    try { +        // This will fail on prototype === comparison on cross context objects +        assert.deepStrictEqual(actual, expected); +    } catch (e) { +        if (!isDeepStrictEqual(actual, expected)) { +            throw e; +        } +    } +} + + +class VM { +    constructor(context={}) { +        this._context = vm.createContext(context); +        this._assert = { +            deepStrictEqual +        }; +    } + +    get context() { +        return this._context; +    } + +    get assert() { +        return this._assert; +    } + +    get(names) { +        if (typeof names === 'string') { +            return getContextEnvironmentRecords(this._context, [names])[0]; +        } else if (Array.isArray(names)) { +            return getContextEnvironmentRecords(this._context, names); +        } else { +            throw new Error('Invalid argument'); +        } +    } + +    set(values) { +        if (typeof values === 'object' && values !== null) { +            Object.assign(this._context, values); +        } else { +            throw new Error('Invalid argument'); +        } +    } + +    execute(fileNames) { +        const single = !Array.isArray(fileNames); +        if (single) { +            fileNames = [fileNames]; +        } + +        const results = []; +        for (const fileName of fileNames) { +            const absoluteFileName = path.resolve(__dirname, '..', 'ext', fileName); +            const source = fs.readFileSync(absoluteFileName, {encoding: 'utf8'}); +            const script = new vm.Script(source, {filename: absoluteFileName}); +            results.push(script.runInContext(this._context, {})); +        } + +        return single ? results[0] : results; +    } +} + + +module.exports = { +    VM +};  |