diff options
103 files changed, 5415 insertions, 1477 deletions
diff --git a/.eslintrc.json b/.eslintrc.json index 0e3b939a..fcc6995b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,8 +1,16 @@  { +    "root": true,      "extends": "eslint:recommended",      "parserOptions": { -        "ecmaVersion": 8 +        "ecmaVersion": 8, +        "sourceType": "script"      }, +    "env": { +        "browser": true, +        "es2017": true, +        "webextensions": true +    }, +    "plugins": ["no-unsanitized"],      "ignorePatterns": [          "/ext/mixed/lib/",          "/ext/bg/js/templates.js" @@ -13,16 +21,101 @@          "curly": ["error", "all"],          "dot-notation": "error",          "eqeqeq": "error", +        "func-names": ["error", "always"],          "no-case-declarations": "error",          "no-const-assign": "error",          "no-constant-condition": "off", -        "no-undef": "off", +        "no-global-assign": "error", +        "no-param-reassign": "off", +        "no-prototype-builtins": "error", +        "no-shadow": ["error", {"builtinGlobals": false}], +        "no-undef": "error", +        "no-unneeded-ternary": "error",          "no-unused-vars": ["error", {"vars": "local", "args": "after-used", "argsIgnorePattern": "^_", "caughtErrors": "none"}], +        "no-unused-expressions": "error",          "no-var": "error",          "prefer-const": ["error", {"destructuring": "all"}],          "quote-props": ["error", "consistent"],          "quotes": ["error", "single", "avoid-escape"],          "require-atomic-updates": "off", -        "semi": "error" -    } +        "semi": "error", + +        // Whitespace rules +        "brace-style": ["error", "1tbs", {"allowSingleLine": true}], +        "indent": ["error", 4, {"SwitchCase": 1, "MemberExpression": 1, "flatTernaryExpressions": true, "ignoredNodes": ["ConditionalExpression"]}], +        "object-curly-newline": "error", +        "padded-blocks": ["error", "never"], + +        "array-bracket-spacing": ["error", "never"], +        "arrow-spacing": ["error", {"before": true, "after": true}], +        "block-spacing": ["error", "always"], +        "comma-spacing": ["error", { "before": false, "after": true }], +        "computed-property-spacing": ["error", "never"], +        "func-call-spacing": ["error", "never"], +        "generator-star-spacing": ["error", "before"], +        "key-spacing": ["error", {"beforeColon": false, "afterColon": true, "mode": "strict"}], +        "keyword-spacing": ["error", {"before": true, "after": true}], +        "no-trailing-spaces": "error", +        "no-whitespace-before-property": "error", +        "object-curly-spacing": ["error", "never"], +        "rest-spread-spacing": ["error", "never"], +        "semi-spacing": ["error", {"before": false, "after": true}], +        "space-in-parens": ["error", "never"], +        "space-unary-ops": "error", +        "spaced-comment": ["error", "always", {"markers": ["global"]}], +        "switch-colon-spacing": ["error", {"after": true, "before": false}], +        "template-curly-spacing": ["error", "never"], +        "template-tag-spacing": ["error", "never"], + +        // Extensions +        "no-unsanitized/method": "error", +        "no-unsanitized/property": "error" +    }, +    "overrides": [ +        { +            "files": ["*.js"], +            "excludedFiles": ["ext/mixed/js/core.js"], +            "globals": { +                "yomichan": "readonly", +                "errorToJson": "readonly", +                "jsonToError": "readonly", +                "logError": "readonly", +                "isObject": "readonly", +                "hasOwn": "readonly", +                "toIterable": "readonly", +                "stringReverse": "readonly", +                "promiseTimeout": "readonly", +                "stringReplaceAsync": "readonly", +                "parseUrl": "readonly", +                "EventDispatcher": "readonly", +                "EventListenerCollection": "readonly", +                "EXTENSION_IS_BROWSER_EDGE": "readonly" +            } +        }, +        { +            "files": ["ext/mixed/js/core.js"], +            "globals": { +                "chrome": "writable" +            } +        }, +        { +            "files": ["ext/bg/js/settings/*.js"], +            "env": { +                "jquery": true +            } +        }, +        { +            "files": ["test/**/*.js"], +            "parserOptions": { +                "ecmaVersion": 8, +                "sourceType": "module" +            }, +            "env": { +                "browser": false, +                "es2017": true, +                "node": true, +                "webextensions": false +            } +        } +    ]  } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c65d254b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: [push, pull_request] + +jobs: +  test: +    runs-on: ubuntu-latest + +    steps: +    - name: Checkout +      uses: actions/checkout@v2 +    - name: Setup node +      uses: actions/setup-node@v1 +      with: +        node-version: '12.x' +    - name: Install dependencies +      run: npm ci +    - name: Build +      run: npm run build --if-present +    - name: Lint +      run: npm run test-lint +      env: +        CI: true +    - name: Tests +      run: npm run test-code +      env: +        CI: true @@ -1 +1,2 @@  *.zip +node_modules @@ -27,7 +27,6 @@ Yomichan provides advanced features not available in other browser-based diction      *   [Flashcard Creation](https://foosoft.net/projects/yomichan/#flashcard-creation)  *   [Keyboard Shortcuts](https://foosoft.net/projects/yomichan/#keyboard-shortcuts)  *   [Development](https://foosoft.net/projects/yomichan/#development) -    *   [Templates](https://foosoft.net/projects/yomichan/#templates)      *   [Dependencies](https://foosoft.net/projects/yomichan/#dependencies)  *   [Frequently Asked Questions](https://foosoft.net/projects/yomichan/#frequently-asked-questions)  *   [Screenshots](https://foosoft.net/projects/yomichan/#screenshots) @@ -241,15 +240,6 @@ following basic guidelines when creating pull requests:  *   Large pull requests without a clear scope will not be merged.  *   Incomplete or non-standalone features will not be merged. -### Templates ### - -Yomichan uses [Handlebars](https://handlebarsjs.com/) templates for user interface generation. The source templates are -found in the `tmpl` directory and the compiled version is stored in the `ext/bg/js/templates.js` file. If you modify the -source templates, you will need to also recompile them. If you are developing on Linux or Mac OS X, you can use the -included `build_tmpl.sh` and `build_tmpl_auto.sh` shell scripts to do this for you -([inotify-tools](https://github.com/rvoicilas/inotify-tools/wiki) required). Otherwise, simply execute `handlebars -tmpl/*.html -f ext/bg/js/templates.js` from the project's base directory to compile all the templates. -  ### Dependencies ###  Yomichan uses several third-party libraries to function. Below are links to homepages, snapshots, and licenses of the exact diff --git a/build_tmpl.sh b/build_tmpl.sh deleted file mode 100755 index e91f8de8..00000000 --- a/build_tmpl.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -handlebars tmpl/*.html -f ext/bg/js/templates.js diff --git a/build_tmpl_auto.sh b/build_tmpl_auto.sh deleted file mode 100755 index 98065cb7..00000000 --- a/build_tmpl_auto.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -DIRECTORY_TO_OBSERVE="tmpl" -BUILD_SCRIPT="build_tmpl.sh" - -function block_for_change { -    inotifywait -e modify,move,create,delete $DIRECTORY_TO_OBSERVE -} - -function build { -    bash $BUILD_SCRIPT -} - -build -while block_for_change; do -  build -done diff --git a/ext/bg/background.html b/ext/bg/background.html index af87eddb..7fd1c477 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -26,20 +26,20 @@          <script src="/bg/js/mecab.js"></script>          <script src="/bg/js/audio.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>          <script src="/bg/js/database.js"></script>          <script src="/bg/js/deinflector.js"></script>          <script src="/bg/js/dictionary.js"></script>          <script src="/bg/js/handlebars.js"></script> +        <script src="/bg/js/japanese.js"></script>          <script src="/bg/js/json-schema.js"></script>          <script src="/bg/js/options.js"></script>          <script src="/bg/js/profile-conditions.js"></script>          <script src="/bg/js/request.js"></script> -        <script src="/bg/js/templates.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/japanese.js"></script>          <script src="/bg/js/backend.js"></script>      </body> diff --git a/ext/bg/css/settings.css b/ext/bg/css/settings.css index 815a88fa..d686e8f8 100644 --- a/ext/bg/css/settings.css +++ b/ext/bg/css/settings.css @@ -222,6 +222,20 @@ html:root[data-operating-system=openbsd] [data-show-for-operating-system~=openbs      display: initial;  } +html:root[data-browser=edge] [data-hide-for-browser~=edge], +html:root[data-browser=chrome] [data-hide-for-browser~=chrome], +html:root[data-browser=firefox] [data-hide-for-browser~=firefox], +html:root[data-browser=firefox-mobile] [data-hide-for-browser~=firefox-mobile], +html:root[data-operating-system=mac] [data-hide-for-operating-system~=mac], +html:root[data-operating-system=win] [data-hide-for-operating-system~=win], +html:root[data-operating-system=android] [data-hide-for-operating-system~=android], +html:root[data-operating-system=cros] [data-hide-for-operating-system~=cros], +html:root[data-operating-system=linux] [data-hide-for-operating-system~=linux], +html:root[data-operating-system=openbsd] [data-hide-for-operating-system~=openbsd] { +    display: none; +} + +  @media screen and (max-width: 740px) {      .col-xs-6 {          float: none; diff --git a/ext/bg/data/dictionary-index-schema.json b/ext/bg/data/dictionary-index-schema.json new file mode 100644 index 00000000..9311f14c --- /dev/null +++ b/ext/bg/data/dictionary-index-schema.json @@ -0,0 +1,69 @@ +{ +    "$schema": "http://json-schema.org/draft-07/schema#", +    "type": "object", +    "description": "Index file containing information about the data contained in the dictionary.", +    "required": [ +        "title", +        "revision" +    ], +    "properties": { +        "title": { +            "type": "string", +            "description": "Title of the dictionary." +        }, +        "revision": { +            "type": "string", +            "description": "Revision of the dictionary. This value is only used for displaying information." +        }, +        "sequenced": { +            "type": "boolean", +            "default": false, +            "description": "Whether or not this dictionary can be used as the primary dictionary. Primary dictionaries typically contain term/expression definitions." +        }, +        "format": { +            "type": "integer", +            "description": "Format of data found in the JSON data files.", +            "enum": [1, 2, 3] +        }, +        "version": { +            "type": "integer", +            "description": "Alias for format.", +            "enum": [1, 2, 3] +        }, +        "tagMeta": { +            "type": "object", +            "description": "Tag information for terms and kanji. This object is obsolete and individual tag files should be used instead.", +            "additionalProperties": { +                "type": "object", +                "description": "Information about a single tag. The object key is the name of the tag.", +                "properties": { +                    "category": { +                        "type": "string", +                        "description": "Category for the tag." +                    }, +                    "order": { +                        "type": "number", +                        "description": "Sorting order for the tag." +                    }, +                    "notes": { +                        "type": "string", +                        "description": "Notes for the tag." +                    }, +                    "score": { +                        "type": "number", +                        "description": "Score used to determine popularity. Negative values are more rare and positive values are more frequent. This score is also used to sort search results." +                    } +                }, +                "additionalProperties": false +            } +        } +    }, +    "anyOf": [ +        { +            "required": ["format"] +        }, +        { +            "required": ["version"] +        } +    ] +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-kanji-bank-v1-schema.json b/ext/bg/data/dictionary-kanji-bank-v1-schema.json new file mode 100644 index 00000000..6dad5a7a --- /dev/null +++ b/ext/bg/data/dictionary-kanji-bank-v1-schema.json @@ -0,0 +1,33 @@ +{ +    "$schema": "http://json-schema.org/draft-07/schema#", +    "type": "array", +    "description": "Data file containing kanji information.", +    "additionalItems": { +        "type": "array", +        "description": "Information about a single kanji character.", +        "minItems": 4, +        "items": [ +            { +                "type": "string", +                "description": "Kanji character.", +                "minLength": 1 +            }, +            { +                "type": "string", +                "description": "String of space-separated onyomi readings for the kanji character. An empty string is treated as no readings." +            }, +            { +                "type": "string", +                "description": "String of space-separated kunyomi readings for the kanji character. An empty string is treated as no readings." +            }, +            { +                "type": "string", +                "description": "String of space-separated tags for the kanji character. An empty string is treated as no tags." +            } +        ], +        "additionalItems": { +            "type": "string", +            "description": "A meaning for the kanji character." +        } +    } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-kanji-bank-v3-schema.json b/ext/bg/data/dictionary-kanji-bank-v3-schema.json new file mode 100644 index 00000000..a5b82039 --- /dev/null +++ b/ext/bg/data/dictionary-kanji-bank-v3-schema.json @@ -0,0 +1,44 @@ +{ +    "$schema": "http://json-schema.org/draft-07/schema#", +    "type": "array", +    "description": "Data file containing kanji information.", +    "additionalItems": { +        "type": "array", +        "description": "Information about a single kanji character.", +        "minItems": 6, +        "items": [ +            { +                "type": "string", +                "description": "Kanji character.", +                "minLength": 1 +            }, +            { +                "type": "string", +                "description": "String of space-separated onyomi readings for the kanji character. An empty string is treated as no readings." +            }, +            { +                "type": "string", +                "description": "String of space-separated kunyomi readings for the kanji character. An empty string is treated as no readings." +            }, +            { +                "type": "string", +                "description": "String of space-separated tags for the kanji character. An empty string is treated as no tags." +            }, +            { +                "type": "array", +                "description": "Array of meanings for the kanji character.", +                "items": { +                    "type": "string", +                    "description": "A meaning for the kanji character." +                } +            }, +            { +                "type": "object", +                "description": "Various stats for the kanji character.", +                "additionalProperties": { +                    "type": "string" +                } +            } +        ] +    } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-kanji-meta-bank-v3-schema.json b/ext/bg/data/dictionary-kanji-meta-bank-v3-schema.json new file mode 100644 index 00000000..62479026 --- /dev/null +++ b/ext/bg/data/dictionary-kanji-meta-bank-v3-schema.json @@ -0,0 +1,25 @@ +{ +    "$schema": "http://json-schema.org/draft-07/schema#", +    "type": "array", +    "description": "Custom metadata for kanji characters.", +    "additionalItems": { +        "type": "array", +        "description": "Metadata about a single kanji character.", +        "minItems": 3, +        "items": [ +            { +                "type": "string", +                "minLength": 1 +            }, +            { +                "type": "string", +                "enum": ["freq"], +                "description": "Type of data. \"freq\" corresponds to frequency information." +            }, +            { +                "type": ["string", "number"], +                "description": "Data for the character." +            } +        ] +    } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-tag-bank-v3-schema.json b/ext/bg/data/dictionary-tag-bank-v3-schema.json new file mode 100644 index 00000000..ee5ca64d --- /dev/null +++ b/ext/bg/data/dictionary-tag-bank-v3-schema.json @@ -0,0 +1,32 @@ +{ +    "$schema": "http://json-schema.org/draft-07/schema#", +    "type": "array", +    "description": "Data file containing tag information for terms and kanji.", +    "additionalItems": { +        "type": "array", +        "description": "Information about a single tag.", +        "minItems": 5, +        "items": [ +            { +                "type": "string", +                "description": "Tag name." +            }, +            { +                "type": "string", +                "description": "Category for the tag." +            }, +            { +                "type": "number", +                "description": "Sorting order for the tag." +            }, +            { +                "type": "string", +                "description": "Notes for the tag." +            }, +            { +                "type": "number", +                "description": "Score used to determine popularity. Negative values are more rare and positive values are more frequent. This score is also used to sort search results." +            } +        ] +    } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-term-bank-v1-schema.json b/ext/bg/data/dictionary-term-bank-v1-schema.json new file mode 100644 index 00000000..6ffb26e6 --- /dev/null +++ b/ext/bg/data/dictionary-term-bank-v1-schema.json @@ -0,0 +1,36 @@ +{ +    "$schema": "http://json-schema.org/draft-07/schema#", +    "type": "array", +    "description": "Data file containing term and expression information.", +    "additionalItems": { +        "type": "array", +        "description": "Information about a single term/expression.", +        "minItems": 5, +        "items": [ +            { +                "type": "string", +                "description": "Term or expression." +            }, +            { +                "type": "string", +                "description": "Reading of the term/expression, or an empty string if the reading is the same as the term/expression." +            }, +            { +                "type": ["string", "null"], +                "description": "String of space-separated tags for the definition. An empty string is treated as no tags." +            }, +            { +                "type": "string", +                "description": "String of space-separated rule identifiers for the definition which is used to validate delinflection. Valid rule identifiers are: v1: ichidan verb; v5: godan verb; vs: suru verb; vk: kuru verb; adj-i: i-adjective. An empty string corresponds to words which aren't inflected, such as nouns." +            }, +            { +                "type": "number", +                "description": "Score used to determine popularity. Negative values are more rare and positive values are more frequent. This score is also used to sort search results." +            } +        ], +        "additionalItems": { +            "type": "string", +            "description": "Single definition for the term/expression." +        } +    } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-term-bank-v3-schema.json b/ext/bg/data/dictionary-term-bank-v3-schema.json new file mode 100644 index 00000000..bb982e36 --- /dev/null +++ b/ext/bg/data/dictionary-term-bank-v3-schema.json @@ -0,0 +1,48 @@ +{ +    "$schema": "http://json-schema.org/draft-07/schema#", +    "type": "array", +    "description": "Data file containing term and expression information.", +    "additionalItems": { +        "type": "array", +        "description": "Information about a single term/expression.", +        "minItems": 8, +        "items": [ +            { +                "type": "string", +                "description": "Term or expression." +            }, +            { +                "type": "string", +                "description": "Reading of the term/expression, or an empty string if the reading is the same as the term/expression." +            }, +            { +                "type": ["string", "null"], +                "description": "String of space-separated tags for the definition. An empty string is treated as no tags." +            }, +            { +                "type": "string", +                "description": "String of space-separated rule identifiers for the definition which is used to validate delinflection. Valid rule identifiers are: v1: ichidan verb; v5: godan verb; vs: suru verb; vk: kuru verb; adj-i: i-adjective. An empty string corresponds to words which aren't inflected, such as nouns." +            }, +            { +                "type": "number", +                "description": "Score used to determine popularity. Negative values are more rare and positive values are more frequent. This score is also used to sort search results." +            }, +            { +                "type": "array", +                "description": "Array of definitions for the term/expression.", +                "items": { +                    "type": "string", +                    "description": "Single definition for the term/expression." +                } +            }, +            { +                "type": "integer", +                "description": "Sequence number for the term/expression. Terms/expressions with the same sequence number can be shown together when the \"resultOutputMode\" option is set to \"merge\"." +            }, +            { +                "type": "string", +                "description": "String of space-separated tags for the term/expression. An empty string is treated as no tags." +            } +        ] +    } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json new file mode 100644 index 00000000..1cc0557f --- /dev/null +++ b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json @@ -0,0 +1,25 @@ +{ +    "$schema": "http://json-schema.org/draft-07/schema#", +    "type": "array", +    "description": "Custom metadata for terms/expressions.", +    "additionalItems": { +        "type": "array", +        "description": "Metadata about a single term/expression.", +        "minItems": 3, +        "items": [ +            { +                "type": "string", +                "description": "Term or expression." +            }, +            { +                "type": "string", +                "enum": ["freq"], +                "description": "Type of data. \"freq\" corresponds to frequency information." +            }, +            { +                "type": ["string", "number"], +                "description": "Data for the term/expression." +            } +        ] +    } +}
\ No newline at end of file diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json index a20a0619..d6207952 100644 --- a/ext/bg/data/options-schema.json +++ b/ext/bg/data/options-schema.json @@ -79,6 +79,7 @@                                  "type": "object",                                  "required": [                                      "enable", +                                    "enableClipboardPopups",                                      "resultOutputMode",                                      "debugInfo",                                      "maxResults", @@ -111,6 +112,10 @@                                          "type": "boolean",                                          "default": true                                      }, +                                    "enableClipboardPopups": { +                                        "type": "boolean", +                                        "default": false +                                    },                                      "resultOutputMode": {                                          "type": "string",                                          "enum": ["group", "merge", "split"], @@ -290,7 +295,8 @@                                      "popupNestingMaxDepth",                                      "enablePopupSearch",                                      "enableOnPopupExpressions", -                                    "enableOnSearchPage" +                                    "enableOnSearchPage", +                                    "enableSearchTags"                                  ],                                  "properties": {                                      "middleMouse": { @@ -348,6 +354,10 @@                                      "enableOnSearchPage": {                                          "type": "boolean",                                          "default": true +                                    }, +                                    "enableSearchTags": { +                                        "type": "boolean", +                                        "default": false                                      }                                  }                              }, diff --git a/ext/bg/js/anki.js b/ext/bg/js/anki.js index 10a07061..39c6ad51 100644 --- a/ext/bg/js/anki.js +++ b/ext/bg/js/anki.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global requestJson*/  /*   * AnkiConnect diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js index 285b8016..0c244ffa 100644 --- a/ext/bg/js/api.js +++ b/ext/bg/js/api.js @@ -17,16 +17,16 @@   */ -function apiTemplateRender(template, data, dynamic) { -    return _apiInvoke('templateRender', {data, template, dynamic}); +function apiTemplateRender(template, data) { +    return _apiInvoke('templateRender', {data, template});  }  function apiAudioGetUrl(definition, source, optionsContext) {      return _apiInvoke('audioGetUrl', {definition, source, optionsContext});  } -function apiGetDisplayTemplatesHtml() { -    return _apiInvoke('getDisplayTemplatesHtml'); +function apiClipboardGet() { +    return _apiInvoke('clipboardGet');  }  function _apiInvoke(action, params={}) { diff --git a/ext/bg/js/audio.js b/ext/bg/js/audio.js index 36ac413b..d300570b 100644 --- a/ext/bg/js/audio.js +++ b/ext/bg/js/audio.js @@ -16,13 +16,14 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global jpIsStringEntirelyKana, audioGetFromSources*/  const audioUrlBuilders = new Map([      ['jpod101', async (definition) => {          let kana = definition.reading;          let kanji = definition.expression; -        if (!kana && wanakana.isHiragana(kanji)) { +        if (!kana && jpIsStringEntirelyKana(kanji)) {              kana = kanji;              kanji = null;          } @@ -51,7 +52,7 @@ const audioUrlBuilders = new Map([          for (const row of dom.getElementsByClassName('dc-result-row')) {              try {                  const url = row.querySelector('audio>source[src]').getAttribute('src'); -                const reading = row.getElementsByClassName('dc-vocab_kana').item(0).innerText; +                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/');                  } @@ -167,10 +168,8 @@ async function audioInject(definition, fields, sources, optionsContext) {      }      try { -        let audioSourceDefinition = definition; -        if (hasOwn(definition, 'expressions')) { -            audioSourceDefinition = definition.expressions[0]; -        } +        const expressions = definition.expressions; +        const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition;          const {url} = await audioGetFromSources(audioSourceDefinition, sources, optionsContext, true);          if (url !== null) { diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index eeab68a5..e3bf7bda 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -16,12 +16,21 @@   * 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*/  class Backend {      constructor() {          this.translator = new Translator();          this.anki = new AnkiNull();          this.mecab = new Mecab(); +        this.clipboardMonitor = new ClipboardMonitor();          this.options = null;          this.optionsSchema = null;          this.optionsContext = { @@ -34,7 +43,11 @@ class Backend {          this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target'); +        this.popupWindow = null; +          this.apiForwarder = new BackendApiForwarder(); + +        this.messageToken = yomichan.generateId(16);      }      async prepare() { @@ -67,6 +80,8 @@ class Backend {          this.isPreparedResolve();          this.isPreparedResolve = null;          this.isPreparedPromise = null; + +        this.clipboardMonitor.onClipboardText = (text) => this._onClipboardText(text);      }      onOptionsUpdated(source) { @@ -75,7 +90,7 @@ class Backend {          const callback = () => this.checkLastError(chrome.runtime.lastError);          chrome.tabs.query({}, (tabs) => {              for (const tab of tabs) { -                chrome.tabs.sendMessage(tab.id, {action: 'optionsUpdate', params: {source}}, callback); +                chrome.tabs.sendMessage(tab.id, {action: 'optionsUpdated', params: {source}}, callback);              }          });      } @@ -97,6 +112,10 @@ class Backend {          }      } +    _onClipboardText(text) { +        this._onCommandSearch({mode: 'popup', query: text}); +    } +      _onZoomChange({tabId, oldZoomFactor, newZoomFactor}) {          const callback = () => this.checkLastError(chrome.runtime.lastError);          chrome.tabs.sendMessage(tabId, {action: 'zoomChanged', params: {oldZoomFactor, newZoomFactor}}, callback); @@ -121,6 +140,12 @@ class Backend {          } else {              this.mecab.stopListener();          } + +        if (options.general.enableClipboardPopups) { +            this.clipboardMonitor.start(); +        } else { +            this.clipboardMonitor.stop(); +        }      }      async getOptionsSchema() { @@ -249,18 +274,18 @@ class Backend {                  const node = nodes.pop();                  for (const key of Object.keys(node.obj)) {                      const path = node.path.concat(key); -                    const obj = node.obj[key]; -                    if (obj !== null && typeof obj === 'object') { -                        nodes.unshift({obj, path}); +                    const obj2 = node.obj[key]; +                    if (obj2 !== null && typeof obj2 === 'object') { +                        nodes.unshift({obj: obj2, path});                      } else { -                        valuePaths.push([obj, path]); +                        valuePaths.push([obj2, path]);                      }                  }              }              return valuePaths;          } -        function modifyOption(path, value, options) { +        function modifyOption(path, value) {              let pivot = options;              for (const key of path.slice(0, -1)) {                  if (!hasOwn(pivot, key)) { @@ -273,7 +298,7 @@ class Backend {          }          for (const [value, path] of getValuePaths(changedOptions)) { -            modifyOption(path, value, options); +            modifyOption(path, value);          }          await this._onApiOptionsSave({source}); @@ -294,7 +319,8 @@ class Backend {      async _onApiTermsFind({text, details, optionsContext}) {          const options = await this.getOptions(optionsContext); -        const [definitions, length] = await this.translator.findTerms(text, details, options); +        const mode = options.general.resultOutputMode; +        const [definitions, length] = await this.translator.findTerms(mode, text, details, options);          definitions.splice(options.general.maxResults);          return {length, definitions};      } @@ -304,9 +330,9 @@ class Backend {          const results = [];          while (text.length > 0) {              const term = []; -            const [definitions, sourceLength] = await this.translator.findTermsInternal( +            const [definitions, sourceLength] = await this.translator.findTerms( +                'simple',                  text.substring(0, options.scanning.length), -                dictEnabledSet(options),                  {},                  options              ); @@ -314,9 +340,9 @@ class Backend {                  dictTermsSort(definitions);                  const {expression, reading} = definitions[0];                  const source = text.substring(0, sourceLength); -                for (const {text, furigana} of jpDistributeFuriganaInflected(expression, reading, source)) { -                    const reading = jpConvertReading(text, furigana, options.parsing.readingMode); -                    term.push({text, reading}); +                for (const {text: text2, furigana} of jpDistributeFuriganaInflected(expression, reading, source)) { +                    const reading2 = jpConvertReading(text2, furigana, options.parsing.readingMode); +                    term.push({text: text2, reading: reading2});                  }                  text = text.substring(source.length);              } else { @@ -339,17 +365,17 @@ class Backend {                  for (const {expression, reading, source} of parsedLine) {                      const term = [];                      if (expression !== null && reading !== null) { -                        for (const {text, furigana} of jpDistributeFuriganaInflected( +                        for (const {text: text2, furigana} of jpDistributeFuriganaInflected(                              expression,                              jpKatakanaToHiragana(reading),                              source                          )) { -                            const reading = jpConvertReading(text, furigana, options.parsing.readingMode); -                            term.push({text, reading}); +                            const reading2 = jpConvertReading(text2, furigana, options.parsing.readingMode); +                            term.push({text: text2, reading: reading2});                          }                      } else { -                        const reading = jpConvertReading(source, null, options.parsing.readingMode); -                        term.push({text: source, reading}); +                        const reading2 = jpConvertReading(source, null, options.parsing.readingMode); +                        term.push({text: source, reading: reading2});                      }                      result.push(term);                  } @@ -436,12 +462,8 @@ class Backend {          return this.anki.guiBrowse(`nid:${noteId}`);      } -    async _onApiTemplateRender({template, data, dynamic}) { -        return ( -            dynamic ? -            handlebarsRenderDynamic(template, data) : -            handlebarsRenderStatic(template, data) -        ); +    async _onApiTemplateRender({template, data}) { +        return handlebarsRenderDynamic(template, data);      }      async _onApiCommandExec({command, params}) { @@ -480,19 +502,30 @@ class Backend {          return Promise.resolve({frameId});      } -    _onApiInjectStylesheet({css}, sender) { +    _onApiInjectStylesheet({type, value}, sender) {          if (!sender.tab) {              return Promise.reject(new Error('Invalid tab'));          }          const tabId = sender.tab.id;          const frameId = sender.frameId; -        const details = { -            code: css, -            runAt: 'document_start', -            cssOrigin: 'user', -            allFrames: false -        }; +        const details = ( +            type === 'file' ? +            { +                file: value, +                runAt: 'document_start', +                cssOrigin: 'author', +                allFrames: false, +                matchAboutBlank: true +            } : +            { +                code: value, +                runAt: 'document_start', +                cssOrigin: 'user', +                allFrames: false, +                matchAboutBlank: true +            } +        );          if (typeof frameId === 'number') {              details.frameId = frameId;          } @@ -521,13 +554,30 @@ class Backend {      }      async _onApiClipboardGet() { -        const clipboardPasteTarget = this.clipboardPasteTarget; -        clipboardPasteTarget.value = ''; -        clipboardPasteTarget.focus(); -        document.execCommand('paste'); -        const result = clipboardPasteTarget.value; -        clipboardPasteTarget.value = ''; -        return result; +        /* +        Notes: +            document.execCommand('paste') doesn't work on Firefox. +            This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985 +            Therefore, navigator.clipboard.readText() is used on Firefox. + +            navigator.clipboard.readText() can't be used in Chrome for two reasons: +            * Requires page to be focused, else it rejects with an exception. +            * When the page is focused, Chrome will request clipboard permission, despite already +              being an extension with clipboard permissions. It effectively asks for the +              non-extension permission for clipboard access. +        */ +        const browser = await Backend._getBrowser(); +        if (browser === 'firefox' || browser === 'firefox-mobile') { +            return await navigator.clipboard.readText(); +        } else { +            const clipboardPasteTarget = this.clipboardPasteTarget; +            clipboardPasteTarget.value = ''; +            clipboardPasteTarget.focus(); +            document.execCommand('paste'); +            const result = clipboardPasteTarget.value; +            clipboardPasteTarget.value = ''; +            return result; +        }      }      async _onApiGetDisplayTemplatesHtml() { @@ -535,6 +585,11 @@ class Backend {          return await requestText(url, 'GET');      } +    async _onApiGetQueryParserTemplatesHtml() { +        const url = chrome.runtime.getURL('/bg/query-parser-templates.html'); +        return await requestText(url, 'GET'); +    } +      _onApiGetZoom(params, sender) {          if (!sender || !sender.tab) {              return Promise.reject(new Error('Invalid tab')); @@ -562,26 +617,75 @@ class Backend {          });      } +    async _onApiGetMessageToken() { +        return this.messageToken; +    } +      // Command handlers      async _onCommandSearch(params) { -        const url = chrome.runtime.getURL('/bg/search.html'); -        if (!(params && params.newTab)) { -            try { -                const tab = await Backend._findTab(1000, (url2) => ( -                    url2 !== null && -                    url2.startsWith(url) && -                    (url2.length === url.length || url2[url.length] === '?' || url2[url.length] === '#') -                )); -                if (tab !== null) { -                    await Backend._focusTab(tab); -                    return; +        const {mode='existingOrNewTab', query} = params || {}; + +        const options = await this.getOptions(this.optionsContext); +        const {popupWidth, popupHeight} = options.general; + +        const baseUrl = chrome.runtime.getURL('/bg/search.html'); +        const queryParams = {mode}; +        if (query && query.length > 0) { queryParams.query = query; } +        const queryString = new URLSearchParams(queryParams).toString(); +        const url = `${baseUrl}?${queryString}`; + +        const isTabMatch = (url2) => { +            if (url2 === null || !url2.startsWith(baseUrl)) { return false; } +            const {baseUrl: baseUrl2, queryParams: queryParams2} = parseUrl(url2); +            return baseUrl2 === baseUrl && (queryParams2.mode === mode || (!queryParams2.mode && mode === 'existingOrNewTab')); +        }; + +        const openInTab = async () => { +            const tab = await Backend._findTab(1000, isTabMatch); +            if (tab !== null) { +                await Backend._focusTab(tab); +                if (queryParams.query) { +                    await new Promise((resolve) => chrome.tabs.sendMessage( +                        tab.id, {action: 'searchQueryUpdate', params: {query: queryParams.query}}, resolve +                    ));                  } -            } catch (e) { -                // NOP +                return true;              } +        }; + +        switch (mode) { +            case 'existingOrNewTab': +                try { +                    if (await openInTab()) { return; } +                } catch (e) { +                    // NOP +                } +                chrome.tabs.create({url}); +                return; +            case 'newTab': +                chrome.tabs.create({url}); +                return; +            case 'popup': +                try { +                    // chrome.windows not supported (e.g. on Firefox mobile) +                    if (!isObject(chrome.windows)) { return; } +                    if (await openInTab()) { return; } +                    // if the previous popup is open in an invalid state, close it +                    if (this.popupWindow !== null) { +                        const callback = () => this.checkLastError(chrome.runtime.lastError); +                        chrome.windows.remove(this.popupWindow.id, callback); +                    } +                    // open new popup +                    this.popupWindow = await new Promise((resolve) => chrome.windows.create( +                        {url, width: popupWidth, height: popupHeight, type: 'popup'}, +                        resolve +                    )); +                } catch (e) { +                    // NOP +                } +                return;          } -        chrome.tabs.create({url});      }      _onCommandHelp() { @@ -697,8 +801,11 @@ class Backend {          await new Promise((resolve, reject) => {              chrome.tabs.update(tab.id, {active: true}, () => {                  const e = chrome.runtime.lastError; -                if (e) { reject(e); } -                else { resolve(); } +                if (e) { +                    reject(new Error(e.message)); +                } else { +                    resolve(); +                }              });          }); @@ -708,19 +815,25 @@ class Backend {          }          try { -            const tabWindow = await new Promise((resolve) => { -                chrome.windows.get(tab.windowId, {}, (tabWindow) => { +            const tabWindow = await new Promise((resolve, reject) => { +                chrome.windows.get(tab.windowId, {}, (value) => {                      const e = chrome.runtime.lastError; -                    if (e) { reject(e); } -                    else { resolve(tabWindow); } +                    if (e) { +                        reject(new Error(e.message)); +                    } else { +                        resolve(value); +                    }                  });              });              if (!tabWindow.focused) {                  await new Promise((resolve, reject) => {                      chrome.windows.update(tab.windowId, {focused: true}, () => {                          const e = chrome.runtime.lastError; -                        if (e) { reject(e); } -                        else { resolve(); } +                        if (e) { +                            reject(new Error(e.message)); +                        } else { +                            resolve(); +                        }                      });                  });              } @@ -777,7 +890,9 @@ Backend._messageHandlers = new Map([      ['getEnvironmentInfo', (self, ...args) => self._onApiGetEnvironmentInfo(...args)],      ['clipboardGet', (self, ...args) => self._onApiClipboardGet(...args)],      ['getDisplayTemplatesHtml', (self, ...args) => self._onApiGetDisplayTemplatesHtml(...args)], -    ['getZoom', (self, ...args) => self._onApiGetZoom(...args)] +    ['getQueryParserTemplatesHtml', (self, ...args) => self._onApiGetQueryParserTemplatesHtml(...args)], +    ['getZoom', (self, ...args) => self._onApiGetZoom(...args)], +    ['getMessageToken', (self, ...args) => self._onApiGetMessageToken(...args)]  ]);  Backend._commandHandlers = new Map([ diff --git a/ext/bg/js/clipboard-monitor.js b/ext/bg/js/clipboard-monitor.js new file mode 100644 index 00000000..c2f41385 --- /dev/null +++ b/ext/bg/js/clipboard-monitor.js @@ -0,0 +1,81 @@ +/* + * 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/>. + */ + +/*global apiClipboardGet, jpIsStringPartiallyJapanese*/ + +class ClipboardMonitor { +    constructor() { +        this.timerId = null; +        this.timerToken = null; +        this.interval = 250; +        this.previousText = null; +    } + +    onClipboardText(_text) { +        throw new Error('Override me'); +    } + +    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() +        // call will exit early if the reference has changed. +        const token = {}; +        const intervalCallback = async () => { +            this.timerId = null; + +            let text = null; +            try { +                text = await apiClipboardGet(); +            } catch (e) { +                // NOP +            } +            if (this.timerToken !== token) { return; } + +            if ( +                typeof text === 'string' && +                (text = text.trim()).length > 0 && +                text !== this.previousText +            ) { +                this.previousText = text; +                if (jpIsStringPartiallyJapanese(text)) { +                    this.onClipboardText(text); +                } +            } + +            this.timerId = setTimeout(intervalCallback, this.interval); +        }; + +        this.timerToken = token; + +        intervalCallback(); +    } + +    stop() { +        this.timerToken = null; +        if (this.timerId !== null) { +            clearTimeout(this.timerId); +            this.timerId = null; +        } +    } + +    setPreviousText(text) { +        this.previousText = text; +    } +} diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js index 834174bf..bec964fb 100644 --- a/ext/bg/js/context.js +++ b/ext/bg/js/context.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiCommandExec, apiGetEnvironmentInfo, apiOptionsGet*/  function showExtensionInfo() {      const node = document.getElementById('extension-info'); @@ -30,12 +31,12 @@ function setupButtonEvents(selector, command, url) {      for (const node of nodes) {          node.addEventListener('click', (e) => {              if (e.button !== 0) { return; } -            apiCommandExec(command, {newTab: e.ctrlKey}); +            apiCommandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'});              e.preventDefault();          }, false);          node.addEventListener('auxclick', (e) => {              if (e.button !== 1) { return; } -            apiCommandExec(command, {newTab: true}); +            apiCommandExec(command, {mode: 'newTab'});              e.preventDefault();          }, false); diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js index e87cc64b..558f3ceb 100644 --- a/ext/bg/js/database.js +++ b/ext/bg/js/database.js @@ -16,20 +16,24 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global dictFieldSplit, requestJson, JsonSchema, JSZip*/  class Database {      constructor() {          this.db = null; +        this._schemas = new Map();      } +    // Public +      async prepare() {          if (this.db !== null) {              throw new Error('Database already initialized');          }          try { -            this.db = await Database.open('dict', 5, (db, transaction, oldVersion) => { -                Database.upgrade(db, transaction, oldVersion, [ +            this.db = await Database._open('dict', 5, (db, transaction, oldVersion) => { +                Database._upgrade(db, transaction, oldVersion, [                      {                          version: 2,                          stores: { @@ -95,18 +99,24 @@ class Database {          }      } +    async close() { +        this._validate(); +        this.db.close(); +        this.db = null; +    } +      async purge() { -        this.validate(); +        this._validate();          this.db.close(); -        await Database.deleteDatabase(this.db.name); +        await Database._deleteDatabase(this.db.name);          this.db = null;          await this.prepare();      }      async deleteDictionary(dictionaryName, onProgress, progressSettings) { -        this.validate(); +        this._validate();          const targets = [              ['dictionaries', 'title'], @@ -133,22 +143,22 @@ class Database {              const dbObjectStore = dbTransaction.objectStore(objectStoreName);              const dbIndex = dbObjectStore.index(index);              const only = IDBKeyRange.only(dictionaryName); -            promises.push(Database.deleteValues(dbObjectStore, dbIndex, only, onProgress, progressData, progressRate)); +            promises.push(Database._deleteValues(dbObjectStore, dbIndex, only, onProgress, progressData, progressRate));          }          await Promise.all(promises);      } -    async findTermsBulk(termList, titles, wildcard) { -        this.validate(); +    async findTermsBulk(termList, dictionaries, wildcard) { +        this._validate();          const promises = []; -        const visited = {}; +        const visited = new Set();          const results = [];          const processRow = (row, index) => { -            if (titles.includes(row.dictionary) && !hasOwn(visited, row.id)) { -                visited[row.id] = true; -                results.push(Database.createTerm(row, index)); +            if (dictionaries.has(row.dictionary) && !visited.has(row.id)) { +                visited.add(row.id); +                results.push(Database._createTerm(row, index));              }          }; @@ -164,8 +174,8 @@ class Database {              const term = prefixWildcard ? stringReverse(termList[i]) : termList[i];              const query = useWildcard ? IDBKeyRange.bound(term, `${term}\uffff`, false, false) : IDBKeyRange.only(term);              promises.push( -                Database.getAll(dbIndex1, query, i, processRow), -                Database.getAll(dbIndex2, query, i, processRow) +                Database._getAll(dbIndex1, query, i, processRow), +                Database._getAll(dbIndex2, query, i, processRow)              );          } @@ -174,14 +184,14 @@ class Database {          return results;      } -    async findTermsExactBulk(termList, readingList, titles) { -        this.validate(); +    async findTermsExactBulk(termList, readingList, dictionaries) { +        this._validate();          const promises = [];          const results = [];          const processRow = (row, index) => { -            if (row.reading === readingList[index] && titles.includes(row.dictionary)) { -                results.push(Database.createTerm(row, index)); +            if (row.reading === readingList[index] && dictionaries.has(row.dictionary)) { +                results.push(Database._createTerm(row, index));              }          }; @@ -191,7 +201,7 @@ class Database {          for (let i = 0; i < termList.length; ++i) {              const only = IDBKeyRange.only(termList[i]); -            promises.push(Database.getAll(dbIndex, only, i, processRow)); +            promises.push(Database._getAll(dbIndex, only, i, processRow));          }          await Promise.all(promises); @@ -200,13 +210,13 @@ class Database {      }      async findTermsBySequenceBulk(sequenceList, mainDictionary) { -        this.validate(); +        this._validate();          const promises = [];          const results = [];          const processRow = (row, index) => {              if (row.dictionary === mainDictionary) { -                results.push(Database.createTerm(row, index)); +                results.push(Database._createTerm(row, index));              }          }; @@ -216,7 +226,7 @@ class Database {          for (let i = 0; i < sequenceList.length; ++i) {              const only = IDBKeyRange.only(sequenceList[i]); -            promises.push(Database.getAll(dbIndex, only, i, processRow)); +            promises.push(Database._getAll(dbIndex, only, i, processRow));          }          await Promise.all(promises); @@ -224,52 +234,27 @@ class Database {          return results;      } -    async findTermMetaBulk(termList, titles) { -        return this.findGenericBulk('termMeta', 'expression', termList, titles, Database.createTermMeta); +    async findTermMetaBulk(termList, dictionaries) { +        return this._findGenericBulk('termMeta', 'expression', termList, dictionaries, Database._createTermMeta);      } -    async findKanjiBulk(kanjiList, titles) { -        return this.findGenericBulk('kanji', 'character', kanjiList, titles, Database.createKanji); +    async findKanjiBulk(kanjiList, dictionaries) { +        return this._findGenericBulk('kanji', 'character', kanjiList, dictionaries, Database._createKanji);      } -    async findKanjiMetaBulk(kanjiList, titles) { -        return this.findGenericBulk('kanjiMeta', 'character', kanjiList, titles, Database.createKanjiMeta); -    } - -    async findGenericBulk(tableName, indexName, indexValueList, titles, createResult) { -        this.validate(); - -        const promises = []; -        const results = []; -        const processRow = (row, index) => { -            if (titles.includes(row.dictionary)) { -                results.push(createResult(row, index)); -            } -        }; - -        const dbTransaction = this.db.transaction([tableName], 'readonly'); -        const dbTerms = dbTransaction.objectStore(tableName); -        const dbIndex = dbTerms.index(indexName); - -        for (let i = 0; i < indexValueList.length; ++i) { -            const only = IDBKeyRange.only(indexValueList[i]); -            promises.push(Database.getAll(dbIndex, only, i, processRow)); -        } - -        await Promise.all(promises); - -        return results; +    async findKanjiMetaBulk(kanjiList, dictionaries) { +        return this._findGenericBulk('kanjiMeta', 'character', kanjiList, dictionaries, Database._createKanjiMeta);      }      async findTagForTitle(name, title) { -        this.validate(); +        this._validate();          let result = null;          const dbTransaction = this.db.transaction(['tagMeta'], 'readonly');          const dbTerms = dbTransaction.objectStore('tagMeta');          const dbIndex = dbTerms.index('name');          const only = IDBKeyRange.only(name); -        await Database.getAll(dbIndex, only, null, (row) => { +        await Database._getAll(dbIndex, only, null, (row) => {              if (title === row.dictionary) {                  result = row;              } @@ -279,19 +264,19 @@ class Database {      }      async getDictionaryInfo() { -        this.validate(); +        this._validate();          const results = [];          const dbTransaction = this.db.transaction(['dictionaries'], 'readonly');          const dbDictionaries = dbTransaction.objectStore('dictionaries'); -        await Database.getAll(dbDictionaries, null, null, (info) => results.push(info)); +        await Database._getAll(dbDictionaries, null, null, (info) => results.push(info));          return results;      }      async getDictionaryCounts(dictionaryNames, getTotal) { -        this.validate(); +        this._validate();          const objectStoreNames = [              'kanji', @@ -312,7 +297,7 @@ class Database {          // Query is required for Edge, otherwise index.count throws an exception.          const query1 = IDBKeyRange.lowerBound('', false); -        const totalPromise = getTotal ? Database.getCounts(targets, query1) : null; +        const totalPromise = getTotal ? Database._getCounts(targets, query1) : null;          const counts = [];          const countPromises = []; @@ -320,7 +305,7 @@ class Database {              counts.push(null);              const index = i;              const query2 = IDBKeyRange.only(dictionaryNames[i]); -            const countPromise = Database.getCounts(targets, query2).then((v) => counts[index] = v); +            const countPromise = Database._getCounts(targets, query2).then((v) => counts[index] = v);              countPromises.push(countPromise);          }          await Promise.all(countPromises); @@ -332,278 +317,287 @@ class Database {          return result;      } -    async importDictionary(archive, progressCallback, details) { -        this.validate(); +    async importDictionary(archiveSource, onProgress, details) { +        this._validate(); +        const db = this.db; +        const hasOnProgress = (typeof onProgress === 'function'); -        const errors = []; -        const prefixWildcardsSupported = details.prefixWildcardsSupported; +        // Read archive +        const archive = await JSZip.loadAsync(archiveSource); -        const maxTransactionLength = 1000; -        const bulkAdd = async (objectStoreName, items, total, current) => { -            const db = this.db; -            for (let i = 0; i < items.length; i += maxTransactionLength) { -                if (progressCallback) { -                    progressCallback(total, current + i / items.length); -                } +        // Read and validate index +        const indexFileName = 'index.json'; +        const indexFile = archive.files[indexFileName]; +        if (!indexFile) { +            throw new Error('No dictionary index found in archive'); +        } -                try { -                    const count = Math.min(maxTransactionLength, items.length - i); -                    const transaction = db.transaction([objectStoreName], 'readwrite'); -                    const objectStore = transaction.objectStore(objectStoreName); -                    await Database.bulkAdd(objectStore, items, i, count); -                } catch (e) { -                    errors.push(e); -                } -            } -        }; +        const index = JSON.parse(await indexFile.async('string')); -        const indexDataLoaded = async (summary) => { -            if (summary.version > 3) { -                throw new Error('Unsupported dictionary version'); -            } +        const indexSchema = await this._getSchema('/bg/data/dictionary-index-schema.json'); +        Database._validateJsonSchema(index, indexSchema, indexFileName); -            const db = this.db; -            const dbCountTransaction = db.transaction(['dictionaries'], 'readonly'); -            const dbIndex = dbCountTransaction.objectStore('dictionaries').index('title'); -            const only = IDBKeyRange.only(summary.title); -            const count = await Database.getCount(dbIndex, only); +        const dictionaryTitle = index.title; +        const version = index.format || index.version; -            if (count > 0) { -                throw new Error('Dictionary is already imported'); -            } +        if (!dictionaryTitle || !index.revision) { +            throw new Error('Unrecognized dictionary format'); +        } -            const transaction = db.transaction(['dictionaries'], 'readwrite'); -            const objectStore = transaction.objectStore('dictionaries'); -            await Database.bulkAdd(objectStore, [summary], 0, 1); -        }; +        // Verify database is not already imported +        if (await this._dictionaryExists(dictionaryTitle)) { +            throw new Error('Dictionary is already imported'); +        } -        const termDataLoaded = async (summary, entries, total, current) => { -            const rows = []; -            if (summary.version === 1) { -                for (const [expression, reading, definitionTags, rules, score, ...glossary] of entries) { -                    rows.push({ -                        expression, -                        reading, -                        definitionTags, -                        rules, -                        score, -                        glossary, -                        dictionary: summary.title -                    }); -                } +        // Data format converters +        const convertTermBankEntry = (entry) => { +            if (version === 1) { +                const [expression, reading, definitionTags, rules, score, ...glossary] = entry; +                return {expression, reading, definitionTags, rules, score, glossary};              } else { -                for (const [expression, reading, definitionTags, rules, score, glossary, sequence, termTags] of entries) { -                    rows.push({ -                        expression, -                        reading, -                        definitionTags, -                        rules, -                        score, -                        glossary, -                        sequence, -                        termTags, -                        dictionary: summary.title -                    }); -                } -            } - -            if (prefixWildcardsSupported) { -                for (const row of rows) { -                    row.expressionReverse = stringReverse(row.expression); -                    row.readingReverse = stringReverse(row.reading); -                } +                const [expression, reading, definitionTags, rules, score, glossary, sequence, termTags] = entry; +                return {expression, reading, definitionTags, rules, score, glossary, sequence, termTags};              } +        }; -            await bulkAdd('terms', rows, total, current); +        const convertTermMetaBankEntry = (entry) => { +            const [expression, mode, data] = entry; +            return {expression, mode, data};          }; -        const termMetaDataLoaded = async (summary, entries, total, current) => { -            const rows = []; -            for (const [expression, mode, data] of entries) { -                rows.push({ -                    expression, -                    mode, -                    data, -                    dictionary: summary.title -                }); +        const convertKanjiBankEntry = (entry) => { +            if (version === 1) { +                const [character, onyomi, kunyomi, tags, ...meanings] = entry; +                return {character, onyomi, kunyomi, tags, meanings}; +            } else { +                const [character, onyomi, kunyomi, tags, meanings, stats] = entry; +                return {character, onyomi, kunyomi, tags, meanings, stats};              } +        }; -            await bulkAdd('termMeta', rows, total, current); +        const convertKanjiMetaBankEntry = (entry) => { +            const [character, mode, data] = entry; +            return {character, mode, data};          }; -        const kanjiDataLoaded = async (summary, entries, total, current)  => { -            const rows = []; -            if (summary.version === 1) { -                for (const [character, onyomi, kunyomi, tags, ...meanings] of entries) { -                    rows.push({ -                        character, -                        onyomi, -                        kunyomi, -                        tags, -                        meanings, -                        dictionary: summary.title -                    }); -                } -            } else { -                for (const [character, onyomi, kunyomi, tags, meanings, stats] of entries) { -                    rows.push({ -                        character, -                        onyomi, -                        kunyomi, -                        tags, -                        meanings, -                        stats, -                        dictionary: summary.title -                    }); +        const convertTagBankEntry = (entry) => { +            const [name, category, order, notes, score] = entry; +            return {name, category, order, notes, score}; +        }; + +        // Archive file reading +        const readFileSequence = async (fileNameFormat, convertEntry, schema) => { +            const results = []; +            for (let i = 1; true; ++i) { +                const fileName = fileNameFormat.replace(/\?/, `${i}`); +                const file = archive.files[fileName]; +                if (!file) { break; } + +                const entries = JSON.parse(await file.async('string')); +                Database._validateJsonSchema(entries, schema, fileName); + +                for (let entry of entries) { +                    entry = convertEntry(entry); +                    entry.dictionary = dictionaryTitle; +                    results.push(entry);                  }              } - -            await bulkAdd('kanji', rows, total, current); +            return results;          }; -        const kanjiMetaDataLoaded = async (summary, entries, total, current) => { -            const rows = []; -            for (const [character, mode, data] of entries) { -                rows.push({ -                    character, -                    mode, -                    data, -                    dictionary: summary.title -                }); +        // Load schemas +        const dataBankSchemaPaths = this.constructor._getDataBankSchemaPaths(version); +        const dataBankSchemas = await Promise.all(dataBankSchemaPaths.map((path) => this._getSchema(path))); + +        // Load data +        const termList      = await readFileSequence('term_bank_?.json',       convertTermBankEntry,      dataBankSchemas[0]); +        const termMetaList  = await readFileSequence('term_meta_bank_?.json',  convertTermMetaBankEntry,  dataBankSchemas[1]); +        const kanjiList     = await readFileSequence('kanji_bank_?.json',      convertKanjiBankEntry,     dataBankSchemas[2]); +        const kanjiMetaList = await readFileSequence('kanji_meta_bank_?.json', convertKanjiMetaBankEntry, dataBankSchemas[3]); +        const tagList       = await readFileSequence('tag_bank_?.json',        convertTagBankEntry,       dataBankSchemas[4]); + +        // Old tags +        const indexTagMeta = index.tagMeta; +        if (typeof indexTagMeta === 'object' && indexTagMeta !== null) { +            for (const name of Object.keys(indexTagMeta)) { +                const {category, order, notes, score} = indexTagMeta[name]; +                tagList.push({name, category, order, notes, score});              } +        } -            await bulkAdd('kanjiMeta', rows, total, current); -        }; - -        const tagDataLoaded = async (summary, entries, total, current) => { -            const rows = []; -            for (const [name, category, order, notes, score] of entries) { -                const row = dictTagSanitize({ -                    name, -                    category, -                    order, -                    notes, -                    score, -                    dictionary: summary.title -                }); - -                rows.push(row); +        // Prefix wildcard support +        const prefixWildcardsSupported = !!details.prefixWildcardsSupported; +        if (prefixWildcardsSupported) { +            for (const entry of termList) { +                entry.expressionReverse = stringReverse(entry.expression); +                entry.readingReverse = stringReverse(entry.reading);              } +        } -            await bulkAdd('tagMeta', rows, total, current); +        // Add dictionary +        const summary = { +            title: dictionaryTitle, +            revision: index.revision, +            sequenced: index.sequenced, +            version, +            prefixWildcardsSupported          }; -        const result = await Database.importDictionaryZip( -            archive, -            indexDataLoaded, -            termDataLoaded, -            termMetaDataLoaded, -            kanjiDataLoaded, -            kanjiMetaDataLoaded, -            tagDataLoaded, -            details +        { +            const transaction = db.transaction(['dictionaries'], 'readwrite'); +            const objectStore = transaction.objectStore('dictionaries'); +            await Database._bulkAdd(objectStore, [summary], 0, 1); +        } + +        // Add data +        const errors = []; +        const total = ( +            termList.length + +            termMetaList.length + +            kanjiList.length + +            kanjiMetaList.length + +            tagList.length          ); +        let loadedCount = 0; +        const maxTransactionLength = 1000; + +        const bulkAdd = async (objectStoreName, entries) => { +            const ii = entries.length; +            for (let i = 0; i < ii; i += maxTransactionLength) { +                const count = Math.min(maxTransactionLength, ii - i); + +                try { +                    const transaction = db.transaction([objectStoreName], 'readwrite'); +                    const objectStore = transaction.objectStore(objectStoreName); +                    await Database._bulkAdd(objectStore, entries, i, count); +                } catch (e) { +                    errors.push(e); +                } -        return {result, errors}; +                loadedCount += count; +                if (hasOnProgress) { +                    onProgress(total, loadedCount); +                } +            } +        }; + +        await bulkAdd('terms', termList); +        await bulkAdd('termMeta', termMetaList); +        await bulkAdd('kanji', kanjiList); +        await bulkAdd('kanjiMeta', kanjiMetaList); +        await bulkAdd('tagMeta', tagList); + +        return {result: summary, errors};      } -    validate() { +    // Private + +    _validate() {          if (this.db === null) {              throw new Error('Database not initialized');          }      } -    static async importDictionaryZip( -        archive, -        indexDataLoaded, -        termDataLoaded, -        termMetaDataLoaded, -        kanjiDataLoaded, -        kanjiMetaDataLoaded, -        tagDataLoaded, -        details -    ) { -        const zip = await JSZip.loadAsync(archive); - -        const indexFile = zip.files['index.json']; -        if (!indexFile) { -            throw new Error('No dictionary index found in archive'); +    async _getSchema(fileName) { +        let schemaPromise = this._schemas.get(fileName); +        if (typeof schemaPromise !== 'undefined') { +            return schemaPromise;          } -        const index = JSON.parse(await indexFile.async('string')); -        if (!index.title || !index.revision) { -            throw new Error('Unrecognized dictionary format'); +        schemaPromise = requestJson(chrome.runtime.getURL(fileName), 'GET'); +        this._schemas.set(fileName, schemaPromise); +        return schemaPromise; +    } + +    static _validateJsonSchema(value, schema, fileName) { +        try { +            JsonSchema.validate(value, schema); +        } catch (e) { +            throw Database._formatSchemaError(e, fileName);          } +    } -        const summary = { -            title: index.title, -            revision: index.revision, -            sequenced: index.sequenced, -            version: index.format || index.version, -            prefixWildcardsSupported: !!details.prefixWildcardsSupported -        }; +    static _formatSchemaError(e, fileName) { +        const valuePathString = Database._getSchemaErrorPathString(e.info.valuePath, 'dictionary'); +        const schemaPathString = Database._getSchemaErrorPathString(e.info.schemaPath, 'schema'); -        await indexDataLoaded(summary); +        const e2 = new Error(`Dictionary has invalid data in '${fileName}' for value '${valuePathString}', validated against '${schemaPathString}': ${e.message}`); +        e2.data = e; -        const buildTermBankName      = (index) => `term_bank_${index + 1}.json`; -        const buildTermMetaBankName  = (index) => `term_meta_bank_${index + 1}.json`; -        const buildKanjiBankName     = (index) => `kanji_bank_${index + 1}.json`; -        const buildKanjiMetaBankName = (index) => `kanji_meta_bank_${index + 1}.json`; -        const buildTagBankName       = (index) => `tag_bank_${index + 1}.json`; +        return e2; +    } -        const countBanks = (namer) => { -            let count = 0; -            while (zip.files[namer(count)]) { -                ++count; +    static _getSchemaErrorPathString(infoList, base='') { +        let result = base; +        for (const [part] of infoList) { +            switch (typeof part) { +                case 'string': +                    if (result.length > 0) { +                        result += '.'; +                    } +                    result += part; +                    break; +                case 'number': +                    result += `[${part}]`; +                    break;              } +        } +        return result; +    } -            return count; -        }; +    static _getDataBankSchemaPaths(version) { +        const termBank = ( +            version === 1 ? +            '/bg/data/dictionary-term-bank-v1-schema.json' : +            '/bg/data/dictionary-term-bank-v3-schema.json' +        ); +        const termMetaBank = '/bg/data/dictionary-term-meta-bank-v3-schema.json'; +        const kanjiBank = ( +            version === 1 ? +            '/bg/data/dictionary-kanji-bank-v1-schema.json' : +            '/bg/data/dictionary-kanji-bank-v3-schema.json' +        ); +        const kanjiMetaBank = '/bg/data/dictionary-kanji-meta-bank-v3-schema.json'; +        const tagBank = '/bg/data/dictionary-tag-bank-v3-schema.json'; -        const termBankCount      = countBanks(buildTermBankName); -        const termMetaBankCount  = countBanks(buildTermMetaBankName); -        const kanjiBankCount     = countBanks(buildKanjiBankName); -        const kanjiMetaBankCount = countBanks(buildKanjiMetaBankName); -        const tagBankCount       = countBanks(buildTagBankName); - -        let bankLoadedCount = 0; -        let bankTotalCount = -            termBankCount + -            termMetaBankCount + -            kanjiBankCount + -            kanjiMetaBankCount + -            tagBankCount; - -        if (tagDataLoaded && index.tagMeta) { -            const bank = []; -            for (const name in index.tagMeta) { -                const tag = index.tagMeta[name]; -                bank.push([name, tag.category, tag.order, tag.notes, tag.score]); -            } +        return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank]; +    } -            tagDataLoaded(summary, bank, ++bankTotalCount, bankLoadedCount++); -        } +    async _dictionaryExists(title) { +        const db = this.db; +        const dbCountTransaction = db.transaction(['dictionaries'], 'readonly'); +        const dbIndex = dbCountTransaction.objectStore('dictionaries').index('title'); +        const only = IDBKeyRange.only(title); +        const count = await Database._getCount(dbIndex, only); +        return count > 0; +    } -        const loadBank = async (summary, namer, count, callback) => { -            if (callback) { -                for (let i = 0; i < count; ++i) { -                    const bankFile = zip.files[namer(i)]; -                    const bank = JSON.parse(await bankFile.async('string')); -                    await callback(summary, bank, bankTotalCount, bankLoadedCount++); -                } +    async _findGenericBulk(tableName, indexName, indexValueList, dictionaries, createResult) { +        this._validate(); + +        const promises = []; +        const results = []; +        const processRow = (row, index) => { +            if (dictionaries.has(row.dictionary)) { +                results.push(createResult(row, index));              }          }; -        await loadBank(summary, buildTermBankName, termBankCount, termDataLoaded); -        await loadBank(summary, buildTermMetaBankName, termMetaBankCount, termMetaDataLoaded); -        await loadBank(summary, buildKanjiBankName, kanjiBankCount, kanjiDataLoaded); -        await loadBank(summary, buildKanjiMetaBankName, kanjiMetaBankCount, kanjiMetaDataLoaded); -        await loadBank(summary, buildTagBankName, tagBankCount, tagDataLoaded); +        const dbTransaction = this.db.transaction([tableName], 'readonly'); +        const dbTerms = dbTransaction.objectStore(tableName); +        const dbIndex = dbTerms.index(indexName); + +        for (let i = 0; i < indexValueList.length; ++i) { +            const only = IDBKeyRange.only(indexValueList[i]); +            promises.push(Database._getAll(dbIndex, only, i, processRow)); +        } + +        await Promise.all(promises); -        return summary; +        return results;      } -    static createTerm(row, index) { +    static _createTerm(row, index) {          return {              index,              expression: row.expression, @@ -619,7 +613,7 @@ class Database {          };      } -    static createKanji(row, index) { +    static _createKanji(row, index) {          return {              index,              character: row.character, @@ -632,20 +626,20 @@ class Database {          };      } -    static createTermMeta({expression, mode, data, dictionary}, index) { +    static _createTermMeta({expression, mode, data, dictionary}, index) {          return {expression, mode, data, dictionary, index};      } -    static createKanjiMeta({character, mode, data, dictionary}, index) { +    static _createKanjiMeta({character, mode, data, dictionary}, index) {          return {character, mode, data, dictionary, index};      } -    static getAll(dbIndex, query, context, processRow) { -        const fn = typeof dbIndex.getAll === 'function' ? Database.getAllFast : Database.getAllUsingCursor; +    static _getAll(dbIndex, query, context, processRow) { +        const fn = typeof dbIndex.getAll === 'function' ? Database._getAllFast : Database._getAllUsingCursor;          return fn(dbIndex, query, context, processRow);      } -    static getAllFast(dbIndex, query, context, processRow) { +    static _getAllFast(dbIndex, query, context, processRow) {          return new Promise((resolve, reject) => {              const request = dbIndex.getAll(query);              request.onerror = (e) => reject(e); @@ -658,7 +652,7 @@ class Database {          });      } -    static getAllUsingCursor(dbIndex, query, context, processRow) { +    static _getAllUsingCursor(dbIndex, query, context, processRow) {          return new Promise((resolve, reject) => {              const request = dbIndex.openCursor(query, 'next');              request.onerror = (e) => reject(e); @@ -674,18 +668,18 @@ class Database {          });      } -    static getCounts(targets, query) { +    static _getCounts(targets, query) {          const countPromises = [];          const counts = {};          for (const [objectStoreName, index] of targets) {              const n = objectStoreName; -            const countPromise = Database.getCount(index, query).then((count) => counts[n] = count); +            const countPromise = Database._getCount(index, query).then((count) => counts[n] = count);              countPromises.push(countPromise);          }          return Promise.all(countPromises).then(() => counts);      } -    static getCount(dbIndex, query) { +    static _getCount(dbIndex, query) {          return new Promise((resolve, reject) => {              const request = dbIndex.count(query);              request.onerror = (e) => reject(e); @@ -693,12 +687,12 @@ class Database {          });      } -    static getAllKeys(dbIndex, query) { -        const fn = typeof dbIndex.getAllKeys === 'function' ? Database.getAllKeysFast : Database.getAllKeysUsingCursor; +    static _getAllKeys(dbIndex, query) { +        const fn = typeof dbIndex.getAllKeys === 'function' ? Database._getAllKeysFast : Database._getAllKeysUsingCursor;          return fn(dbIndex, query);      } -    static getAllKeysFast(dbIndex, query) { +    static _getAllKeysFast(dbIndex, query) {          return new Promise((resolve, reject) => {              const request = dbIndex.getAllKeys(query);              request.onerror = (e) => reject(e); @@ -706,7 +700,7 @@ class Database {          });      } -    static getAllKeysUsingCursor(dbIndex, query) { +    static _getAllKeysUsingCursor(dbIndex, query) {          return new Promise((resolve, reject) => {              const primaryKeys = [];              const request = dbIndex.openKeyCursor(query, 'next'); @@ -723,9 +717,9 @@ class Database {          });      } -    static async deleteValues(dbObjectStore, dbIndex, query, onProgress, progressData, progressRate) { +    static async _deleteValues(dbObjectStore, dbIndex, query, onProgress, progressData, progressRate) {          const hasProgress = (typeof onProgress === 'function'); -        const count = await Database.getCount(dbIndex, query); +        const count = await Database._getCount(dbIndex, query);          ++progressData.storesProcesed;          progressData.count += count;          if (hasProgress) { @@ -744,16 +738,16 @@ class Database {          );          const promises = []; -        const primaryKeys = await Database.getAllKeys(dbIndex, query); +        const primaryKeys = await Database._getAllKeys(dbIndex, query);          for (const key of primaryKeys) { -            const promise = Database.deleteValue(dbObjectStore, key).then(onValueDeleted); +            const promise = Database._deleteValue(dbObjectStore, key).then(onValueDeleted);              promises.push(promise);          }          await Promise.all(promises);      } -    static deleteValue(dbObjectStore, key) { +    static _deleteValue(dbObjectStore, key) {          return new Promise((resolve, reject) => {              const request = dbObjectStore.delete(key);              request.onerror = (e) => reject(e); @@ -761,7 +755,7 @@ class Database {          });      } -    static bulkAdd(objectStore, items, start, count) { +    static _bulkAdd(objectStore, items, start, count) {          return new Promise((resolve, reject) => {              if (start + count > items.length) {                  count = items.length - start; @@ -789,7 +783,7 @@ class Database {          });      } -    static open(name, version, onUpgradeNeeded) { +    static _open(name, version, onUpgradeNeeded) {          return new Promise((resolve, reject) => {              const request = window.indexedDB.open(name, version * 10); @@ -807,7 +801,7 @@ class Database {          });      } -    static upgrade(db, transaction, oldVersion, upgrades) { +    static _upgrade(db, transaction, oldVersion, upgrades) {          for (const {version, stores} of upgrades) {              if (oldVersion >= version) { continue; } @@ -815,15 +809,15 @@ class Database {              for (const objectStoreName of objectStoreNames) {                  const {primaryKey, indices} = stores[objectStoreName]; -                const objectStoreNames = transaction.objectStoreNames || db.objectStoreNames; +                const objectStoreNames2 = transaction.objectStoreNames || db.objectStoreNames;                  const objectStore = ( -                    Database.listContains(objectStoreNames, objectStoreName) ? +                    Database._listContains(objectStoreNames2, objectStoreName) ?                      transaction.objectStore(objectStoreName) :                      db.createObjectStore(objectStoreName, primaryKey)                  );                  for (const indexName of indices) { -                    if (Database.listContains(objectStore.indexNames, indexName)) { continue; } +                    if (Database._listContains(objectStore.indexNames, indexName)) { continue; }                      objectStore.createIndex(indexName, indexName, {});                  } @@ -831,7 +825,7 @@ class Database {          }      } -    static deleteDatabase(dbName) { +    static _deleteDatabase(dbName) {          return new Promise((resolve, reject) => {              const request = indexedDB.deleteDatabase(dbName);              request.onerror = (e) => reject(e); @@ -839,7 +833,7 @@ class Database {          });      } -    static listContains(list, value) { +    static _listContains(list, value) {          for (let i = 0, ii = list.length; i < ii; ++i) {              if (list[i] === value) { return true; }          } diff --git a/ext/bg/js/deinflector.js b/ext/bg/js/deinflector.js index 33b2a8b3..e2ced965 100644 --- a/ext/bg/js/deinflector.js +++ b/ext/bg/js/deinflector.js @@ -76,17 +76,19 @@ class Deinflector {          const ruleTypes = Deinflector.ruleTypes;          let value = 0;          for (const rule of rules) { -            value |= ruleTypes[rule]; +            const ruleBits = ruleTypes.get(rule); +            if (typeof ruleBits === 'undefined') { continue; } +            value |= ruleBits;          }          return value;      }  } -Deinflector.ruleTypes = { -    'v1':    0b0000001, // Verb ichidan -    'v5':    0b0000010, // Verb godan -    'vs':    0b0000100, // Verb suru -    'vk':    0b0001000, // Verb kuru -    'adj-i': 0b0010000, // Adjective i -    'iru':   0b0100000  // Intermediate -iru endings for progressive or perfect tense -}; +Deinflector.ruleTypes = new Map([ +    ['v1',    0b0000001], // Verb ichidan +    ['v5',    0b0000010], // Verb godan +    ['vs',    0b0000100], // Verb suru +    ['vk',    0b0001000], // Verb kuru +    ['adj-i', 0b0010000], // Adjective i +    ['iru',   0b0100000] // Intermediate -iru endings for progressive or perfect tense +]); diff --git a/ext/bg/js/dictionary.js b/ext/bg/js/dictionary.js index 67128725..f5c5b21b 100644 --- a/ext/bg/js/dictionary.js +++ b/ext/bg/js/dictionary.js @@ -16,17 +16,21 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiTemplateRender*/  function dictEnabledSet(options) { -    const dictionaries = {}; -    for (const title in options.dictionaries) { -        const dictionary = options.dictionaries[title]; -        if (dictionary.enabled) { -            dictionaries[title] = dictionary; -        } +    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 +        });      } - -    return dictionaries; +    return enabledDictionaryMap;  }  function dictConfigured(options) { @@ -39,28 +43,15 @@ function dictConfigured(options) {      return false;  } -function dictRowsSort(rows, options) { -    return rows.sort((ra, rb) => { -        const pa = (options.dictionaries[ra.title] || {}).priority || 0; -        const pb = (options.dictionaries[rb.title] || {}).priority || 0; -        if (pa > pb) { -            return -1; -        } else if (pa < pb) { -            return 1; -        } else { -            return 0; -        } -    }); -} -  function dictTermsSort(definitions, dictionaries=null) {      return definitions.sort((v1, v2) => {          let i;          if (dictionaries !== null) { -            i = ( -                ((dictionaries[v2.dictionary] || {}).priority || 0) - -                ((dictionaries[v1.dictionary] || {}).priority || 0) -            ); +            const dictionaryInfo1 = dictionaries.get(v1.dictionary); +            const dictionaryInfo2 = dictionaries.get(v2.dictionary); +            const priority1 = typeof dictionaryInfo1 !== 'undefined' ? dictionaryInfo1.priority : 0; +            const priority2 = typeof dictionaryInfo2 !== 'undefined' ? dictionaryInfo2.priority : 0; +            i = priority2 - priority1;              if (i !== 0) { return i; }          } @@ -78,20 +69,16 @@ function dictTermsSort(definitions, dictionaries=null) {  }  function dictTermsUndupe(definitions) { -    const definitionGroups = {}; +    const definitionGroups = new Map();      for (const definition of definitions) { -        const definitionExisting = definitionGroups[definition.id]; -        if (!hasOwn(definitionGroups, definition.id) || definition.expression.length > definitionExisting.expression.length) { -            definitionGroups[definition.id] = definition; +        const id = definition.id; +        const definitionExisting = definitionGroups.get(id); +        if (typeof definitionExisting === 'undefined' || definition.expression.length > definitionExisting.expression.length) { +            definitionGroups.set(id, definition);          }      } -    const definitionsUnique = []; -    for (const key in definitionGroups) { -        definitionsUnique.push(definitionGroups[key]); -    } - -    return definitionsUnique; +    return [...definitionGroups.values()];  }  function dictTermsCompressTags(definitions) { @@ -122,35 +109,35 @@ function dictTermsCompressTags(definitions) {  }  function dictTermsGroup(definitions, dictionaries) { -    const groups = {}; +    const groups = new Map();      for (const definition of definitions) { -        const key = [definition.source, definition.expression]; -        key.push(...definition.reasons); +        const key = [definition.source, definition.expression, ...definition.reasons];          if (definition.reading) {              key.push(definition.reading);          }          const keyString = key.toString(); -        if (hasOwn(groups, keyString)) { -            groups[keyString].push(definition); -        } else { -            groups[keyString] = [definition]; +        let groupDefinitions = groups.get(keyString); +        if (typeof groupDefinitions === 'undefined') { +            groupDefinitions = []; +            groups.set(keyString, groupDefinitions);          } + +        groupDefinitions.push(definition);      }      const results = []; -    for (const key in groups) { -        const groupDefs = groups[key]; -        const firstDef = groupDefs[0]; -        dictTermsSort(groupDefs, dictionaries); +    for (const groupDefinitions of groups.values()) { +        const firstDef = groupDefinitions[0]; +        dictTermsSort(groupDefinitions, dictionaries);          results.push({ -            definitions: groupDefs, +            definitions: groupDefinitions,              expression: firstDef.expression,              reading: firstDef.reading,              furiganaSegments: firstDef.furiganaSegments,              reasons: firstDef.reasons,              termTags: firstDef.termTags, -            score: groupDefs.reduce((p, v) => v.score > p ? v.score : p, Number.MIN_SAFE_INTEGER), +            score: groupDefinitions.reduce((p, v) => v.score > p ? v.score : p, Number.MIN_SAFE_INTEGER),              source: firstDef.source          });      } @@ -158,14 +145,41 @@ function dictTermsGroup(definitions, dictionaries) {      return dictTermsSort(results);  } +function dictAreSetsEqual(set1, set2) { +    if (set1.size !== set2.size) { +        return false; +    } + +    for (const value of set1) { +        if (!set2.has(value)) { +            return false; +        } +    } + +    return true; +} + +function dictGetSetIntersection(set1, set2) { +    const result = []; +    for (const value of set1) { +        if (set2.has(value)) { +            result.push(value); +        } +    } +    return result; +} +  function dictTermsMergeBySequence(definitions, mainDictionary) { -    const definitionsBySequence = {'-1': []}; +    const sequencedDefinitions = new Map(); +    const nonSequencedDefinitions = [];      for (const definition of definitions) { -        if (mainDictionary === definition.dictionary && definition.sequence >= 0) { -            if (!definitionsBySequence[definition.sequence]) { -                definitionsBySequence[definition.sequence] = { +        const sequence = definition.sequence; +        if (mainDictionary === definition.dictionary && sequence >= 0) { +            let sequencedDefinition = sequencedDefinitions.get(sequence); +            if (typeof sequencedDefinition === 'undefined') { +                sequencedDefinition = {                      reasons: definition.reasons, -                    score: Number.MIN_SAFE_INTEGER, +                    score: definition.score,                      expression: new Set(),                      reading: new Set(),                      expressions: new Map(), @@ -173,100 +187,115 @@ function dictTermsMergeBySequence(definitions, mainDictionary) {                      dictionary: definition.dictionary,                      definitions: []                  }; +                sequencedDefinitions.set(sequence, sequencedDefinition); +            } else { +                sequencedDefinition.score = Math.max(sequencedDefinition.score, definition.score);              } -            const score = Math.max(definitionsBySequence[definition.sequence].score, definition.score); -            definitionsBySequence[definition.sequence].score = score;          } else { -            definitionsBySequence['-1'].push(definition); +            nonSequencedDefinitions.push(definition);          }      } -    return definitionsBySequence; +    return [sequencedDefinitions, nonSequencedDefinitions];  } -function dictTermsMergeByGloss(result, definitions, appendTo, mergedIndices) { -    const definitionsByGloss = appendTo || {}; -    for (const [index, definition] of definitions.entries()) { -        if (appendTo) { -            let match = false; -            for (const expression of result.expressions.keys()) { -                if (definition.expression === expression) { -                    for (const reading of result.expressions.get(expression).keys()) { -                        if (definition.reading === reading) { -                            match = true; -                            break; -                        } -                    } -                } -                if (match) { -                    break; -                } -            } +function dictTermsMergeByGloss(result, definitions, appendTo=null, mergedIndices=null) { +    const definitionsByGloss = appendTo !== null ? appendTo : new Map(); -            if (!match) { -                continue; -            } else if (mergedIndices) { +    const resultExpressionsMap = result.expressions; +    const resultExpressionSet = result.expression; +    const resultReadingSet = result.reading; +    const resultSource = result.source; + +    for (const [index, definition] of definitions.entries()) { +        const {expression, reading} = definition; + +        if (mergedIndices !== null) { +            const expressionMap = resultExpressionsMap.get(expression); +            if ( +                typeof expressionMap !== 'undefined' && +                typeof expressionMap.get(reading) !== 'undefined' +            ) {                  mergedIndices.add(index); +            } else { +                continue;              }          }          const gloss = JSON.stringify(definition.glossary.concat(definition.dictionary)); -        if (!definitionsByGloss[gloss]) { -            definitionsByGloss[gloss] = { +        let glossDefinition = definitionsByGloss.get(gloss); +        if (typeof glossDefinition === 'undefined') { +            glossDefinition = {                  expression: new Set(),                  reading: new Set(),                  definitionTags: [],                  glossary: definition.glossary, -                source: result.source, +                source: resultSource,                  reasons: [],                  score: definition.score,                  id: definition.id,                  dictionary: definition.dictionary              }; +            definitionsByGloss.set(gloss, glossDefinition);          } -        definitionsByGloss[gloss].expression.add(definition.expression); -        definitionsByGloss[gloss].reading.add(definition.reading); +        glossDefinition.expression.add(expression); +        glossDefinition.reading.add(reading); -        result.expression.add(definition.expression); -        result.reading.add(definition.reading); +        resultExpressionSet.add(expression); +        resultReadingSet.add(reading);          for (const tag of definition.definitionTags) { -            if (!definitionsByGloss[gloss].definitionTags.find((existingTag) => existingTag.name === tag.name)) { -                definitionsByGloss[gloss].definitionTags.push(tag); +            if (!glossDefinition.definitionTags.find((existingTag) => existingTag.name === tag.name)) { +                glossDefinition.definitionTags.push(tag);              }          } -        if (!appendTo) { -            // result->expressions[ Expression1[ Reading1[ Tag1, Tag2 ] ], Expression2, ... ] -            if (!result.expressions.has(definition.expression)) { -                result.expressions.set(definition.expression, new Map()); +        if (appendTo === null) { +            /* +                Data layout: +                resultExpressionsMap = new Map([ +                    [expression, new Map([ +                        [reading, new Map([ +                            [tagName, tagInfo], +                            ... +                        ])], +                        ... +                    ])], +                    ... +                ]); +            */ +            let readingMap = resultExpressionsMap.get(expression); +            if (typeof readingMap === 'undefined') { +                readingMap = new Map(); +                resultExpressionsMap.set(expression, readingMap);              } -            if (!result.expressions.get(definition.expression).has(definition.reading)) { -                result.expressions.get(definition.expression).set(definition.reading, []); + +            let termTagsMap = readingMap.get(reading); +            if (typeof termTagsMap === 'undefined') { +                termTagsMap = new Map(); +                readingMap.set(reading, termTagsMap);              }              for (const tag of definition.termTags) { -                if (!result.expressions.get(definition.expression).get(definition.reading).find((existingTag) => existingTag.name === tag.name)) { -                    result.expressions.get(definition.expression).get(definition.reading).push(tag); +                if (!termTagsMap.has(tag.name)) { +                    termTagsMap.set(tag.name, tag);                  }              }          }      } -    for (const gloss in definitionsByGloss) { -        const definition = definitionsByGloss[gloss]; -        definition.only = []; -        if (!utilSetEqual(definition.expression, result.expression)) { -            for (const expression of utilSetIntersection(definition.expression, result.expression)) { -                definition.only.push(expression); -            } +    for (const definition of definitionsByGloss.values()) { +        const only = []; +        const expressionSet = definition.expression; +        const readingSet = definition.reading; +        if (!dictAreSetsEqual(expressionSet, resultExpressionSet)) { +            only.push(...dictGetSetIntersection(expressionSet, resultExpressionSet));          } -        if (!utilSetEqual(definition.reading, result.reading)) { -            for (const reading of utilSetIntersection(definition.reading, result.reading)) { -                definition.only.push(reading); -            } +        if (!dictAreSetsEqual(readingSet, resultReadingSet)) { +            only.push(...dictGetSetIntersection(readingSet, resultReadingSet));          } +        definition.only = only;      }      return definitionsByGloss; @@ -330,7 +359,7 @@ async function dictFieldFormat(field, definition, mode, options, templates, exce          }          data.marker = marker;          try { -            return await apiTemplateRender(templates, data, true); +            return await apiTemplateRender(templates, data);          } catch (e) {              if (exceptions) { exceptions.push(e); }              return `{${marker}-render-error}`; diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js index 62f89ee4..b1443447 100644 --- a/ext/bg/js/handlebars.js +++ b/ext/bg/js/handlebars.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global jpIsCharCodeKanji, jpDistributeFurigana, Handlebars*/  function handlebarsEscape(text) {      return Handlebars.Utils.escapeExpression(text); @@ -134,11 +135,6 @@ function handlebarsRegisterHelpers() {      }  } -function handlebarsRenderStatic(name, data) { -    handlebarsRegisterHelpers(); -    return Handlebars.templates[name](data).trim(); -} -  function handlebarsRenderDynamic(template, data) {      handlebarsRegisterHelpers();      const cache = handlebarsRenderDynamic._cache; diff --git a/ext/mixed/js/japanese.js b/ext/bg/js/japanese.js index 0da822d7..abb32da4 100644 --- a/ext/mixed/js/japanese.js +++ b/ext/bg/js/japanese.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global wanakana*/  const JP_HALFWIDTH_KATAKANA_MAPPING = new Map([      ['ヲ', 'ヲヺ-'], @@ -108,7 +109,7 @@ const JP_JAPANESE_RANGES = [      [0xff1a, 0xff1f], // Fullwidth punctuation 2      [0xff3b, 0xff3f], // Fullwidth punctuation 3      [0xff5b, 0xff60], // Fullwidth punctuation 4 -    [0xffe0, 0xffee], // Currency markers +    [0xffe0, 0xffee]  // Currency markers  ]; @@ -223,15 +224,15 @@ function jpDistributeFurigana(expression, reading) {      }      let isAmbiguous = false; -    const segmentize = (reading, groups) => { +    const segmentize = (reading2, groups) => {          if (groups.length === 0 || isAmbiguous) {              return [];          }          const group = groups[0];          if (group.mode === 'kana') { -            if (jpKatakanaToHiragana(reading).startsWith(jpKatakanaToHiragana(group.text))) { -                const readingLeft = reading.substring(group.text.length); +            if (jpKatakanaToHiragana(reading2).startsWith(jpKatakanaToHiragana(group.text))) { +                const readingLeft = reading2.substring(group.text.length);                  const segs = segmentize(readingLeft, groups.splice(1));                  if (segs) {                      return [{text: group.text}].concat(segs); @@ -239,9 +240,9 @@ function jpDistributeFurigana(expression, reading) {              }          } else {              let foundSegments = null; -            for (let i = reading.length; i >= group.text.length; --i) { -                const readingUsed = reading.substring(0, i); -                const readingLeft = reading.substring(i); +            for (let i = reading2.length; i >= group.text.length; --i) { +                const readingUsed = reading2.substring(0, i); +                const readingLeft = reading2.substring(i);                  const segs = segmentize(readingLeft, groups.slice(1));                  if (segs) {                      if (foundSegments !== null) { diff --git a/ext/bg/js/json-schema.js b/ext/bg/js/json-schema.js index 5d596a8b..58f804fd 100644 --- a/ext/bg/js/json-schema.js +++ b/ext/bg/js/json-schema.js @@ -64,7 +64,7 @@ class JsonSchemaProxyHandler {              }          } -        const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property); +        const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property, target);          if (propertySchema === null) {              return;          } @@ -86,17 +86,14 @@ class JsonSchemaProxyHandler {              }          } -        const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property); +        const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property, target);          if (propertySchema === null) {              throw new Error(`Property ${property} not supported`);          }          value = JsonSchema.isolate(value); -        const error = JsonSchemaProxyHandler.validate(value, propertySchema); -        if (error !== null) { -            throw new Error(`Invalid value: ${error}`); -        } +        JsonSchemaProxyHandler.validate(value, propertySchema, new JsonSchemaTraversalInfo(value, propertySchema));          target[property] = value;          return true; @@ -122,151 +119,329 @@ class JsonSchemaProxyHandler {          throw new Error('construct not supported');      } -    static getPropertySchema(schema, property) { -        const type = schema.type; -        if (Array.isArray(type)) { -            throw new Error(`Ambiguous property type for ${property}`); -        } +    static getPropertySchema(schema, property, value, path=null) { +        const type = JsonSchemaProxyHandler.getSchemaOrValueType(schema, value);          switch (type) {              case 'object':              {                  const properties = schema.properties; -                if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) { -                    if (Object.prototype.hasOwnProperty.call(properties, property)) { -                        return properties[property]; +                if (JsonSchemaProxyHandler.isObject(properties)) { +                    const propertySchema = properties[property]; +                    if (JsonSchemaProxyHandler.isObject(propertySchema)) { +                        if (path !== null) { path.push(['properties', properties], [property, propertySchema]); } +                        return propertySchema;                      }                  }                  const additionalProperties = schema.additionalProperties; -                return (additionalProperties !== null && typeof additionalProperties === 'object' && !Array.isArray(additionalProperties)) ? additionalProperties : null; +                if (additionalProperties === false) { +                    return null; +                } else if (JsonSchemaProxyHandler.isObject(additionalProperties)) { +                    if (path !== null) { path.push(['additionalProperties', additionalProperties]); } +                    return additionalProperties; +                } else { +                    const result = JsonSchemaProxyHandler._unconstrainedSchema; +                    if (path !== null) { path.push([null, result]); } +                    return result; +                }              }              case 'array':              {                  const items = schema.items; -                return (items !== null && typeof items === 'object' && !Array.isArray(items)) ? items : null; +                if (JsonSchemaProxyHandler.isObject(items)) { +                    return items; +                } +                if (Array.isArray(items)) { +                    if (property >= 0 && property < items.length) { +                        const propertySchema = items[property]; +                        if (JsonSchemaProxyHandler.isObject(propertySchema)) { +                            if (path !== null) { path.push(['items', items], [property, propertySchema]); } +                            return propertySchema; +                        } +                    } +                } + +                const additionalItems = schema.additionalItems; +                if (additionalItems === false) { +                    return null; +                } else if (JsonSchemaProxyHandler.isObject(additionalItems)) { +                    if (path !== null) { path.push(['additionalItems', additionalItems]); } +                    return additionalItems; +                } else { +                    const result = JsonSchemaProxyHandler._unconstrainedSchema; +                    if (path !== null) { path.push([null, result]); } +                    return result; +                }              }              default:                  return null;          }      } -    static validate(value, schema) { +    static getSchemaOrValueType(schema, value) { +        const type = schema.type; + +        if (Array.isArray(type)) { +            if (typeof value !== 'undefined') { +                const valueType = JsonSchemaProxyHandler.getValueType(value); +                if (type.indexOf(valueType) >= 0) { +                    return valueType; +                } +            } +            return null; +        } + +        if (typeof type === 'undefined') { +            if (typeof value !== 'undefined') { +                return JsonSchemaProxyHandler.getValueType(value); +            } +            return null; +        } + +        return type; +    } + +    static validate(value, schema, info) { +        JsonSchemaProxyHandler.validateSingleSchema(value, schema, info); +        JsonSchemaProxyHandler.validateConditional(value, schema, info); +        JsonSchemaProxyHandler.validateAllOf(value, schema, info); +        JsonSchemaProxyHandler.validateAnyOf(value, schema, info); +        JsonSchemaProxyHandler.validateOneOf(value, schema, info); +        JsonSchemaProxyHandler.validateNoneOf(value, schema, info); +    } + +    static validateConditional(value, schema, info) { +        const ifSchema = schema.if; +        if (!JsonSchemaProxyHandler.isObject(ifSchema)) { return; } + +        let okay = true; +        info.schemaPush('if', ifSchema); +        try { +            JsonSchemaProxyHandler.validate(value, ifSchema, info); +        } catch (e) { +            okay = false; +        } +        info.schemaPop(); + +        const nextSchema = okay ? schema.then : schema.else; +        if (JsonSchemaProxyHandler.isObject(nextSchema)) { +            info.schemaPush(okay ? 'then' : 'else', nextSchema); +            JsonSchemaProxyHandler.validate(value, nextSchema, info); +            info.schemaPop(); +        } +    } + +    static validateAllOf(value, schema, info) { +        const subSchemas = schema.allOf; +        if (!Array.isArray(subSchemas)) { return; } + +        info.schemaPush('allOf', subSchemas); +        for (let i = 0; i < subSchemas.length; ++i) { +            const subSchema = subSchemas[i]; +            info.schemaPush(i, subSchema); +            JsonSchemaProxyHandler.validate(value, subSchema, info); +            info.schemaPop(); +        } +        info.schemaPop(); +    } + +    static validateAnyOf(value, schema, info) { +        const subSchemas = schema.anyOf; +        if (!Array.isArray(subSchemas)) { return; } + +        info.schemaPush('anyOf', subSchemas); +        for (let i = 0; i < subSchemas.length; ++i) { +            const subSchema = subSchemas[i]; +            info.schemaPush(i, subSchema); +            try { +                JsonSchemaProxyHandler.validate(value, subSchema, info); +                return; +            } catch (e) { +                // NOP +            } +            info.schemaPop(); +        } + +        throw new JsonSchemaValidationError('0 anyOf schemas matched', value, schema, info); +        // info.schemaPop(); // Unreachable +    } + +    static validateOneOf(value, schema, info) { +        const subSchemas = schema.oneOf; +        if (!Array.isArray(subSchemas)) { return; } + +        info.schemaPush('oneOf', subSchemas); +        let count = 0; +        for (let i = 0; i < subSchemas.length; ++i) { +            const subSchema = subSchemas[i]; +            info.schemaPush(i, subSchema); +            try { +                JsonSchemaProxyHandler.validate(value, subSchema, info); +                ++count; +            } catch (e) { +                // NOP +            } +            info.schemaPop(); +        } + +        if (count !== 1) { +            throw new JsonSchemaValidationError(`${count} oneOf schemas matched`, value, schema, info); +        } + +        info.schemaPop(); +    } + +    static validateNoneOf(value, schema, info) { +        const subSchemas = schema.not; +        if (!Array.isArray(subSchemas)) { return; } + +        info.schemaPush('not', subSchemas); +        for (let i = 0; i < subSchemas.length; ++i) { +            const subSchema = subSchemas[i]; +            info.schemaPush(i, subSchema); +            try { +                JsonSchemaProxyHandler.validate(value, subSchema, info); +            } catch (e) { +                info.schemaPop(); +                continue; +            } +            throw new JsonSchemaValidationError(`not[${i}] schema matched`, value, schema, info); +        } +        info.schemaPop(); +    } + +    static validateSingleSchema(value, schema, info) {          const type = JsonSchemaProxyHandler.getValueType(value);          const schemaType = schema.type;          if (!JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType)) { -            return `Value type ${type} does not match schema type ${schemaType}`; +            throw new JsonSchemaValidationError(`Value type ${type} does not match schema type ${schemaType}`, value, schema, info);          }          const schemaEnum = schema.enum;          if (Array.isArray(schemaEnum) && !JsonSchemaProxyHandler.valuesAreEqualAny(value, schemaEnum)) { -            return 'Invalid enum value'; +            throw new JsonSchemaValidationError('Invalid enum value', value, schema, info);          }          switch (type) {              case 'number': -                return JsonSchemaProxyHandler.validateNumber(value, schema); +                JsonSchemaProxyHandler.validateNumber(value, schema, info); +                break;              case 'string': -                return JsonSchemaProxyHandler.validateString(value, schema); +                JsonSchemaProxyHandler.validateString(value, schema, info); +                break;              case 'array': -                return JsonSchemaProxyHandler.validateArray(value, schema); +                JsonSchemaProxyHandler.validateArray(value, schema, info); +                break;              case 'object': -                return JsonSchemaProxyHandler.validateObject(value, schema); -            default: -                return null; +                JsonSchemaProxyHandler.validateObject(value, schema, info); +                break;          }      } -    static validateNumber(value, schema) { +    static validateNumber(value, schema, info) {          const multipleOf = schema.multipleOf;          if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { -            return `Number is not a multiple of ${multipleOf}`; +            throw new JsonSchemaValidationError(`Number is not a multiple of ${multipleOf}`, value, schema, info);          }          const minimum = schema.minimum;          if (typeof minimum === 'number' && value < minimum) { -            return `Number is less than ${minimum}`; +            throw new JsonSchemaValidationError(`Number is less than ${minimum}`, value, schema, info);          }          const exclusiveMinimum = schema.exclusiveMinimum;          if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) { -            return `Number is less than or equal to ${exclusiveMinimum}`; +            throw new JsonSchemaValidationError(`Number is less than or equal to ${exclusiveMinimum}`, value, schema, info);          }          const maximum = schema.maximum;          if (typeof maximum === 'number' && value > maximum) { -            return `Number is greater than ${maximum}`; +            throw new JsonSchemaValidationError(`Number is greater than ${maximum}`, value, schema, info);          }          const exclusiveMaximum = schema.exclusiveMaximum;          if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) { -            return `Number is greater than or equal to ${exclusiveMaximum}`; +            throw new JsonSchemaValidationError(`Number is greater than or equal to ${exclusiveMaximum}`, value, schema, info);          } - -        return null;      } -    static validateString(value, schema) { +    static validateString(value, schema, info) {          const minLength = schema.minLength;          if (typeof minLength === 'number' && value.length < minLength) { -            return 'String length too short'; +            throw new JsonSchemaValidationError('String length too short', value, schema, info);          } -        const maxLength = schema.minLength; +        const maxLength = schema.maxLength;          if (typeof maxLength === 'number' && value.length > maxLength) { -            return 'String length too long'; +            throw new JsonSchemaValidationError('String length too long', value, schema, info);          } - -        return null;      } -    static validateArray(value, schema) { +    static validateArray(value, schema, info) {          const minItems = schema.minItems;          if (typeof minItems === 'number' && value.length < minItems) { -            return 'Array length too short'; +            throw new JsonSchemaValidationError('Array length too short', value, schema, info);          }          const maxItems = schema.maxItems;          if (typeof maxItems === 'number' && value.length > maxItems) { -            return 'Array length too long'; +            throw new JsonSchemaValidationError('Array length too long', value, schema, info);          } -        return null; +        for (let i = 0, ii = value.length; i < ii; ++i) { +            const schemaPath = []; +            const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i, value, schemaPath); +            if (propertySchema === null) { +                throw new JsonSchemaValidationError(`No schema found for array[${i}]`, value, schema, info); +            } + +            const propertyValue = value[i]; + +            for (const [p, s] of schemaPath) { info.schemaPush(p, s); } +            info.valuePush(i, propertyValue); +            JsonSchemaProxyHandler.validate(propertyValue, propertySchema, info); +            info.valuePop(); +            for (let j = 0, jj = schemaPath.length; j < jj; ++j) { info.schemaPop(); } +        }      } -    static validateObject(value, schema) { +    static validateObject(value, schema, info) {          const properties = new Set(Object.getOwnPropertyNames(value));          const required = schema.required;          if (Array.isArray(required)) {              for (const property of required) {                  if (!properties.has(property)) { -                    return `Missing property ${property}`; +                    throw new JsonSchemaValidationError(`Missing property ${property}`, value, schema, info);                  }              }          }          const minProperties = schema.minProperties;          if (typeof minProperties === 'number' && properties.length < minProperties) { -            return 'Not enough object properties'; +            throw new JsonSchemaValidationError('Not enough object properties', value, schema, info);          }          const maxProperties = schema.maxProperties;          if (typeof maxProperties === 'number' && properties.length > maxProperties) { -            return 'Too many object properties'; +            throw new JsonSchemaValidationError('Too many object properties', value, schema, info);          }          for (const property of properties) { -            const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); +            const schemaPath = []; +            const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value, schemaPath);              if (propertySchema === null) { -                return `No schema found for ${property}`; -            } -            const error = JsonSchemaProxyHandler.validate(value[property], propertySchema); -            if (error !== null) { -                return error; +                throw new JsonSchemaValidationError(`No schema found for ${property}`, value, schema, info);              } -        } -        return null; +            const propertyValue = value[property]; + +            for (const [p, s] of schemaPath) { info.schemaPush(p, s); } +            info.valuePush(property, propertyValue); +            JsonSchemaProxyHandler.validate(propertyValue, propertySchema, info); +            info.valuePop(); +            for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); } +        }      }      static isValueTypeAny(value, type, schemaTypes) { @@ -372,14 +547,14 @@ class JsonSchemaProxyHandler {              for (const property of required) {                  properties.delete(property); -                const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); +                const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value);                  if (propertySchema === null) { continue; }                  value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]);              }          }          for (const property of properties) { -            const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); +            const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value);              if (propertySchema === null) {                  Reflect.deleteProperty(value, property);              } else { @@ -392,13 +567,53 @@ class JsonSchemaProxyHandler {      static populateArrayDefaults(value, schema) {          for (let i = 0, ii = value.length; i < ii; ++i) { -            const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i); +            const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i, value);              if (propertySchema === null) { continue; }              value[i] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[i]);          }          return value;      } + +    static isObject(value) { +        return typeof value === 'object' && value !== null && !Array.isArray(value); +    } +} + +JsonSchemaProxyHandler._unconstrainedSchema = {}; + +class JsonSchemaTraversalInfo { +    constructor(value, schema) { +        this.valuePath = []; +        this.schemaPath = []; +        this.valuePush(null, value); +        this.schemaPush(null, schema); +    } + +    valuePush(path, value) { +        this.valuePath.push([path, value]); +    } + +    valuePop() { +        this.valuePath.pop(); +    } + +    schemaPush(path, schema) { +        this.schemaPath.push([path, schema]); +    } + +    schemaPop() { +        this.schemaPath.pop(); +    } +} + +class JsonSchemaValidationError extends Error { +    constructor(message, value, schema, info) { +        super(message); +        this.value = value; +        this.schema = schema; +        this.info = info; +    }  }  class JsonSchema { @@ -406,6 +621,10 @@ class JsonSchema {          return new Proxy(target, new JsonSchemaProxyHandler(schema));      } +    static validate(value, schema) { +        return JsonSchemaProxyHandler.validate(value, schema, new JsonSchemaTraversalInfo(value, schema)); +    } +      static getValidValueOrDefault(schema, value) {          return JsonSchemaProxyHandler.getValidValueOrDefault(schema, value);      } diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index d93862bf..f9db99a2 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global utilStringHashCode*/  /*   * Generic options functions @@ -266,6 +267,7 @@ function profileOptionsCreateDefaults() {      return {          general: {              enable: true, +            enableClipboardPopups: false,              resultOutputMode: 'group',              debugInfo: false,              maxResults: 32, @@ -316,7 +318,8 @@ function profileOptionsCreateDefaults() {              popupNestingMaxDepth: 0,              enablePopupSearch: false,              enableOnPopupExpressions: false, -            enableOnSearchPage: true +            enableOnSearchPage: true, +            enableSearchTags: false          },          translation: { diff --git a/ext/bg/js/page-exit-prevention.js b/ext/bg/js/page-exit-prevention.js index 3a320db3..be06c495 100644 --- a/ext/bg/js/page-exit-prevention.js +++ b/ext/bg/js/page-exit-prevention.js @@ -18,43 +18,43 @@  class PageExitPrevention { -  constructor() { -  } - -  start() { -      PageExitPrevention._addInstance(this); -  } - -  end() { -      PageExitPrevention._removeInstance(this); -  } - -  static _addInstance(instance) { -      const size = PageExitPrevention._instances.size; -      PageExitPrevention._instances.set(instance, true); -      if (size === 0) { -          window.addEventListener('beforeunload', PageExitPrevention._onBeforeUnload); -      } -  } - -  static _removeInstance(instance) { -      if ( -          PageExitPrevention._instances.delete(instance) && -          PageExitPrevention._instances.size === 0 -      ) { -          window.removeEventListener('beforeunload', PageExitPrevention._onBeforeUnload); -      } -  } - -  static _onBeforeUnload(e) { -      if (PageExitPrevention._instances.size === 0) { -          return; -      } - -      e.preventDefault(); -      e.returnValue = ''; -      return ''; -  } +    constructor() { +    } + +    start() { +        PageExitPrevention._addInstance(this); +    } + +    end() { +        PageExitPrevention._removeInstance(this); +    } + +    static _addInstance(instance) { +        const size = PageExitPrevention._instances.size; +        PageExitPrevention._instances.set(instance, true); +        if (size === 0) { +            window.addEventListener('beforeunload', PageExitPrevention._onBeforeUnload); +        } +    } + +    static _removeInstance(instance) { +        if ( +            PageExitPrevention._instances.delete(instance) && +            PageExitPrevention._instances.size === 0 +        ) { +            window.removeEventListener('beforeunload', PageExitPrevention._onBeforeUnload); +        } +    } + +    static _onBeforeUnload(e) { +        if (PageExitPrevention._instances.size === 0) { +            return; +        } + +        e.preventDefault(); +        e.returnValue = ''; +        return ''; +    }  }  PageExitPrevention._instances = new Map(); diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js index e453ccef..509c4009 100644 --- a/ext/bg/js/search-frontend.js +++ b/ext/bg/js/search-frontend.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiOptionsGet*/  async function searchFrontendSetup() {      const optionsContext = { diff --git a/ext/bg/js/search-query-parser-generator.js b/ext/bg/js/search-query-parser-generator.js new file mode 100644 index 00000000..1ab23a82 --- /dev/null +++ b/ext/bg/js/search-query-parser-generator.js @@ -0,0 +1,78 @@ +/* + * 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/>. + */ + +/*global apiGetQueryParserTemplatesHtml, TemplateHandler*/ + +class QueryParserGenerator { +    constructor() { +        this._templateHandler = null; +    } + +    async prepare() { +        const html = await apiGetQueryParserTemplatesHtml(); +        this._templateHandler = new TemplateHandler(html); +    } + +    createParseResult(terms, preview=false) { +        const fragment = document.createDocumentFragment(); +        for (const term of terms) { +            const termContainer = this._templateHandler.instantiate(preview ? 'term-preview' : 'term'); +            for (const segment of term) { +                if (!segment.text.trim()) { continue; } +                if (!segment.reading || !segment.reading.trim()) { +                    termContainer.appendChild(this.createSegmentText(segment.text)); +                } else { +                    termContainer.appendChild(this.createSegment(segment)); +                } +            } +            fragment.appendChild(termContainer); +        } +        return fragment; +    } + +    createSegment(segment) { +        const segmentContainer = this._templateHandler.instantiate('segment'); +        const segmentTextContainer = segmentContainer.querySelector('.query-parser-segment-text'); +        const segmentReadingContainer = segmentContainer.querySelector('.query-parser-segment-reading'); +        segmentTextContainer.appendChild(this.createSegmentText(segment.text)); +        segmentReadingContainer.textContent = segment.reading; +        return segmentContainer; +    } + +    createSegmentText(text) { +        const fragment = document.createDocumentFragment(); +        for (const chr of text) { +            const charContainer = this._templateHandler.instantiate('char'); +            charContainer.textContent = chr; +            fragment.appendChild(charContainer); +        } +        return fragment; +    } + +    createParserSelect(parseResults, selectedParser) { +        const selectContainer = this._templateHandler.instantiate('select'); +        for (const parseResult of parseResults) { +            const optionContainer = this._templateHandler.instantiate('select-option'); +            optionContainer.value = parseResult.id; +            optionContainer.textContent = parseResult.name; +            optionContainer.defaultSelected = selectedParser === parseResult.id; +            selectContainer.appendChild(optionContainer); +        } +        return selectContainer; +    } +} diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js index e8e6d11f..0d4aaa50 100644 --- a/ext/bg/js/search-query-parser.js +++ b/ext/bg/js/search-query-parser.js @@ -16,17 +16,24 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiTermsFind, apiOptionsSet, apiTextParse, apiTextParseMecab, TextScanner, QueryParserGenerator*/  class QueryParser extends TextScanner {      constructor(search) { -        super(document.querySelector('#query-parser'), [], [], []); +        super(document.querySelector('#query-parser-content'), [], [], []);          this.search = search;          this.parseResults = [];          this.selectedParser = null; -        this.queryParser = document.querySelector('#query-parser'); -        this.queryParserSelect = document.querySelector('#query-parser-select'); +        this.queryParser = document.querySelector('#query-parser-content'); +        this.queryParserSelect = document.querySelector('#query-parser-select-container'); + +        this.queryParserGenerator = new QueryParserGenerator(); +    } + +    async prepare() { +        await this.queryParserGenerator.prepare();      }      onError(error) { @@ -52,7 +59,7 @@ class QueryParser extends TextScanner {          this.search.setContent('terms', {definitions, context: {              focus: false, -            disableHistory: cause === 'mouse' ? true : false, +            disableHistory: cause === 'mouse',              sentence: {text: searchText, offset: 0},              url: window.location.href          }}); @@ -64,7 +71,7 @@ class QueryParser extends TextScanner {          const selectedParser = e.target.value;          this.selectedParser = selectedParser;          apiOptionsSet({parsing: {selectedParser}}, this.search.getOptionsContext()); -        this.renderParseResult(this.getParseResult()); +        this.renderParseResult();      }      getMouseEventListeners() { @@ -113,13 +120,13 @@ class QueryParser extends TextScanner {      async setText(text) {          this.search.setSpinnerVisible(true); -        await this.setPreview(text); +        this.setPreview(text);          this.parseResults = await this.parseText(text);          this.refreshSelectedParser();          this.renderParserSelect(); -        await this.renderParseResult(); +        this.renderParseResult();          this.search.setSpinnerVisible(false);      } @@ -146,57 +153,29 @@ class QueryParser extends TextScanner {          return results;      } -    async setPreview(text) { +    setPreview(text) {          const previewTerms = [];          for (let i = 0, ii = text.length; i < ii; i += 2) {              const tempText = text.substring(i, i + 2); -            previewTerms.push([{text: tempText.split('')}]); +            previewTerms.push([{text: tempText}]);          } -        this.queryParser.innerHTML = await apiTemplateRender('query-parser.html', { -            terms: previewTerms, -            preview: true -        }); +        this.queryParser.textContent = ''; +        this.queryParser.appendChild(this.queryParserGenerator.createParseResult(previewTerms, true));      }      renderParserSelect() { -        this.queryParserSelect.innerHTML = ''; +        this.queryParserSelect.textContent = '';          if (this.parseResults.length > 1) { -            const select = document.createElement('select'); -            select.classList.add('form-control'); -            for (const parseResult of this.parseResults) { -                const option = document.createElement('option'); -                option.value = parseResult.id; -                option.innerText = parseResult.name; -                option.defaultSelected = this.selectedParser === parseResult.id; -                select.appendChild(option); -            } +            const select = this.queryParserGenerator.createParserSelect(this.parseResults, this.selectedParser);              select.addEventListener('change', this.onParserChange.bind(this));              this.queryParserSelect.appendChild(select);          }      } -    async renderParseResult() { +    renderParseResult() {          const parseResult = this.getParseResult(); -        if (!parseResult) { -            this.queryParser.innerHTML = ''; -            return; -        } - -        this.queryParser.innerHTML = await apiTemplateRender( -            'query-parser.html', -            {terms: QueryParser.processParseResultForDisplay(parseResult.parsedText)} -        ); -    } - -    static processParseResultForDisplay(result) { -        return result.map((term) => { -            return term.filter((part) => part.text.trim()).map((part) => { -                return { -                    text: part.text.split(''), -                    reading: part.reading, -                    raw: !part.reading || !part.reading.trim() -                }; -            }); -        }); +        this.queryParser.textContent = ''; +        if (!parseResult) { return; } +        this.queryParser.appendChild(this.queryParserGenerator.createParseResult(parseResult.parsedText));      }  } diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index f5c641a8..98e167ad 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -16,6 +16,8 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiOptionsSet, apiTermsFind, Display, QueryParser, ClipboardMonitor*/ +  class DisplaySearch extends Display {      constructor() {          super(document.querySelector('#spinner'), document.querySelector('#content')); @@ -36,12 +38,7 @@ class DisplaySearch extends Display {          this.introVisible = true;          this.introAnimationTimer = null; -        this.isFirefox = false; - -        this.clipboardMonitorTimerId = null; -        this.clipboardMonitorTimerToken = null; -        this.clipboardInterval = 250; -        this.clipboardPreviousText = null; +        this.clipboardMonitor = new ClipboardMonitor();      }      static create() { @@ -52,13 +49,17 @@ class DisplaySearch extends Display {      async prepare() {          try { -            await this.initialize(); -            this.isFirefox = await DisplaySearch._isFirefox(); +            const superPromise = super.prepare(); +            const queryParserPromise = this.queryParser.prepare(); +            await Promise.all([superPromise, queryParserPromise]); + +            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) { @@ -69,34 +70,26 @@ class DisplaySearch extends Display {                          this.wanakanaEnable.checked = false;                      }                      this.wanakanaEnable.addEventListener('change', (e) => { -                        const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || ''; +                        const {queryParams: {query: query2=''}} = parseUrl(window.location.href);                          if (e.target.checked) {                              window.wanakana.bind(this.query); -                            this.setQuery(window.wanakana.toKana(query));                              apiOptionsSet({general: {enableWanakana: true}}, this.getOptionsContext());                          } else {                              window.wanakana.unbind(this.query); -                            this.setQuery(query);                              apiOptionsSet({general: {enableWanakana: false}}, this.getOptionsContext());                          } +                        this.setQuery(query2);                          this.onSearchQueryUpdated(this.query.value, false);                      });                  } -                const query = DisplaySearch.getSearchQueryFromLocation(window.location.href); -                if (query !== null) { -                    if (this.isWanakanaEnabled()) { -                        this.setQuery(window.wanakana.toKana(query)); -                    } else { -                        this.setQuery(query); -                    } -                    this.onSearchQueryUpdated(this.query.value, false); -                } +                this.setQuery(query); +                this.onSearchQueryUpdated(this.query.value, false);              } -            if (this.clipboardMonitorEnable !== null) { +            if (this.clipboardMonitorEnable !== null && mode !== 'popup') {                  if (this.options.general.enableClipboardMonitor === true) {                      this.clipboardMonitorEnable.checked = true; -                    this.startClipboardMonitor(); +                    this.clipboardMonitor.start();                  } else {                      this.clipboardMonitorEnable.checked = false;                  } @@ -106,7 +99,7 @@ class DisplaySearch extends Display {                              {permissions: ['clipboardRead']},                              (granted) => {                                  if (granted) { -                                    this.startClipboardMonitor(); +                                    this.clipboardMonitor.start();                                      apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext());                                  } else {                                      e.target.checked = false; @@ -114,16 +107,20 @@ class DisplaySearch extends Display {                              }                          );                      } else { -                        this.stopClipboardMonitor(); +                        this.clipboardMonitor.stop();                          apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext());                      }                  });              } +            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.updateSearchButton(); -            this.initClipboardMonitor();          } catch (e) {              this.onError(e);          } @@ -159,25 +156,32 @@ class DisplaySearch extends Display {          e.preventDefault();          const query = this.query.value; +          this.queryParser.setText(query); -        const queryString = query.length > 0 ? `?query=${encodeURIComponent(query)}` : ''; -        window.history.pushState(null, '', `${window.location.pathname}${queryString}`); + +        const url = new URL(window.location.href); +        url.searchParams.set('query', query); +        window.history.pushState(null, '', url.toString()); +          this.onSearchQueryUpdated(query, true);      }      onPopState() { -        const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || ''; -        if (this.query !== null) { -            if (this.isWanakanaEnabled()) { -                this.setQuery(window.wanakana.toKana(query)); -            } else { -                this.setQuery(query); -            } -        } - +        const {queryParams: {query='', mode=''}} = parseUrl(window.location.href); +        document.documentElement.dataset.searchMode = mode; +        this.setQuery(query);          this.onSearchQueryUpdated(this.query.value, false);      } +    onRuntimeMessage({action, params}, sender, callback) { +        const handler = DisplaySearch._runtimeMessageHandlers.get(action); +        if (typeof handler !== 'function') { return false; } + +        const result = handler(this, params, sender); +        callback(result); +        return false; +    } +      onKeyDown(e) {          const key = Display.getKeyFromEvent(e);          const ignoreKeys = DisplaySearch.onKeyDownIgnoreKeys; @@ -202,6 +206,19 @@ class DisplaySearch extends Display {          }      } +    onCopy() { +        // ignore copy from search page +        this.clipboardMonitor.setPreviousText(document.getSelection().toString().trim()); +    } + +    onExternalSearchUpdate(text) { +        this.setQuery(text); +        const url = new URL(window.location.href); +        url.searchParams.set('query', text); +        window.history.pushState(null, '', url.toString()); +        this.onSearchQueryUpdated(this.query.value, true); +    } +      async onSearchQueryUpdated(query, animate) {          try {              const details = {}; @@ -241,74 +258,6 @@ class DisplaySearch extends Display {          this.queryParser.setOptions(this.options);      } -    initClipboardMonitor() { -        // ignore copy from search page -        window.addEventListener('copy', () => { -            this.clipboardPreviousText = document.getSelection().toString().trim(); -        }); -    } - -    startClipboardMonitor() { -        // 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 this.getClipboardText() -        // call will exit early if the reference has changed. -        const token = {}; -        const intervalCallback = async () => { -            this.clipboardMonitorTimerId = null; - -            let text = await this.getClipboardText(); -            if (this.clipboardMonitorTimerToken !== token) { return; } - -            if ( -                typeof text === 'string' && -                (text = text.trim()).length > 0 && -                text !== this.clipboardPreviousText -            ) { -                this.clipboardPreviousText = text; -                if (jpIsStringPartiallyJapanese(text)) { -                    this.setQuery(this.isWanakanaEnabled() ? window.wanakana.toKana(text) : text); -                    window.history.pushState(null, '', `${window.location.pathname}?query=${encodeURIComponent(text)}`); -                    this.onSearchQueryUpdated(this.query.value, true); -                } -            } - -            this.clipboardMonitorTimerId = setTimeout(intervalCallback, this.clipboardInterval); -        }; - -        this.clipboardMonitorTimerToken = token; - -        intervalCallback(); -    } - -    stopClipboardMonitor() { -        this.clipboardMonitorTimerToken = null; -        if (this.clipboardMonitorTimerId !== null) { -            clearTimeout(this.clipboardMonitorTimerId); -            this.clipboardMonitorTimerId = null; -        } -    } - -    async getClipboardText() { -        /* -        Notes: -            apiClipboardGet doesn't work on Firefox because document.execCommand('paste') -            results in an empty string on the web extension background page. -            This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985 -            Therefore, navigator.clipboard.readText() is used on Firefox. - -            navigator.clipboard.readText() can't be used in Chrome for two reasons: -            * Requires page to be focused, else it rejects with an exception. -            * When the page is focused, Chrome will request clipboard permission, despite already -              being an extension with clipboard permissions. It effectively asks for the -              non-extension permission for clipboard access. -        */ -        try { -            return this.isFirefox ? await navigator.clipboard.readText() : await apiClipboardGet(); -        } catch (e) { -            return null; -        } -    } -      isWanakanaEnabled() {          return this.wanakanaEnable !== null && this.wanakanaEnable.checked;      } @@ -318,8 +267,9 @@ class DisplaySearch extends Display {      }      setQuery(query) { -        this.query.value = query; -        this.queryParser.setText(query); +        const interpretedQuery = this.isWanakanaEnabled() ? window.wanakana.toKana(query) : query; +        this.query.value = interpretedQuery; +        this.queryParser.setText(interpretedQuery);      }      setIntroVisible(visible, animate) { @@ -394,22 +344,6 @@ class DisplaySearch extends Display {              document.title = `${text} - Yomichan Search`;          }      } - -    static getSearchQueryFromLocation(url) { -        const match = /^[^?#]*\?(?:[^&#]*&)?query=([^&#]*)/.exec(url); -        return match !== null ? decodeURIComponent(match[1]) : null; -    } - -    static async _isFirefox() { -        const {browser} = await apiGetEnvironmentInfo(); -        switch (browser) { -            case 'firefox': -            case 'firefox-mobile': -                return true; -            default: -                return false; -        } -    }  }  DisplaySearch.onKeyDownIgnoreKeys = { @@ -427,4 +361,8 @@ DisplaySearch.onKeyDownIgnoreKeys = {      '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 5e74358f..2e80e334 100644 --- a/ext/bg/js/settings/anki-templates.js +++ b/ext/bg/js/settings/anki-templates.js @@ -16,6 +16,9 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global getOptionsContext, getOptionsMutable, settingsSaveOptions +profileOptionsGetDefaultFieldTemplates, ankiGetFieldMarkers, ankiGetFieldMarkersHtml, dictFieldFormat +apiOptionsGet, apiTermsFind*/  function onAnkiFieldTemplatesReset(e) {      e.preventDefault(); diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js index 9adb2f2a..4263fc51 100644 --- a/ext/bg/js/settings/anki.js +++ b/ext/bg/js/settings/anki.js @@ -16,6 +16,9 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global getOptionsContext, getOptionsMutable, settingsSaveOptions +utilBackgroundIsolate, utilAnkiGetDeckNames, utilAnkiGetModelNames, utilAnkiGetModelFieldNames +onFormOptionsChanged*/  // Private @@ -33,14 +36,27 @@ function _ankiSpinnerShow(show) {  function _ankiSetError(error) {      const node = document.querySelector('#anki-error'); -    if (!node) { return; } +    const node2 = document.querySelector('#anki-invalid-response-error');      if (error) { -        node.hidden = false; -        node.textContent = `${error}`; -        _ankiSetErrorData(node, error); +        const errorString = `${error}`; +        if (node !== null) { +            node.hidden = false; +            node.textContent = errorString; +            _ankiSetErrorData(node, error); +        } + +        if (node2 !== null) { +            node2.hidden = (errorString.indexOf('Invalid response') < 0); +        }      } else { -        node.hidden = true; -        node.textContent = ''; +        if (node !== null) { +            node.hidden = true; +            node.textContent = ''; +        } + +        if (node2 !== null) { +            node2.hidden = true; +        }      }  } diff --git a/ext/bg/js/settings/audio-ui.js b/ext/bg/js/settings/audio-ui.js index 711c2291..555380b4 100644 --- a/ext/bg/js/settings/audio-ui.js +++ b/ext/bg/js/settings/audio-ui.js @@ -16,7 +16,6 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -  class AudioSourceUI {      static instantiateTemplate(templateSelector) {          const template = document.querySelector(templateSelector); diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js index cff3f521..588d9a11 100644 --- a/ext/bg/js/settings/audio.js +++ b/ext/bg/js/settings/audio.js @@ -16,6 +16,8 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global getOptionsContext, getOptionsMutable, settingsSaveOptions +AudioSourceUI, audioGetTextToSpeechVoice*/  let audioSourceUI = null; diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js index becdc568..f4d622a4 100644 --- a/ext/bg/js/settings/backup.js +++ b/ext/bg/js/settings/backup.js @@ -16,6 +16,10 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiOptionsGetFull, apiGetEnvironmentInfo +utilBackend, utilIsolate, utilBackgroundIsolate, utilReadFileArrayBuffer +optionsGetDefault, optionsUpdateVersion +profileOptionsGetDefaultFieldTemplates*/  // Exporting @@ -159,7 +163,6 @@ async function _showSettingsImportWarnings(warnings) {                  sanitize: e.currentTarget.dataset.importSanitize === 'true'              });              modalNode.modal('hide'); -          };          const onModalHide = () => {              complete({result: false}); diff --git a/ext/bg/js/settings/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js index 4d041451..5a271321 100644 --- a/ext/bg/js/settings/conditions-ui.js +++ b/ext/bg/js/settings/conditions-ui.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global conditionsNormalizeOptionValue*/  class ConditionsUI {      static instantiateTemplate(templateSelector) { diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js index ed171ae9..70a22a16 100644 --- a/ext/bg/js/settings/dictionaries.js +++ b/ext/bg/js/settings/dictionaries.js @@ -16,6 +16,11 @@   * 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*/  let dictionaryUI = null; @@ -161,7 +166,7 @@ class SettingsDictionaryListUI {          delete n.dataset.dict;          $(n).modal('hide'); -        const index = this.dictionaryEntries.findIndex((e) => e.dictionaryInfo.title === title); +        const index = this.dictionaryEntries.findIndex((entry) => entry.dictionaryInfo.title === title);          if (index >= 0) {              this.dictionaryEntries[index].deleteDictionary();          } @@ -174,7 +179,7 @@ class SettingsDictionaryEntryUI {          this.dictionaryInfo = dictionaryInfo;          this.optionsDictionary = optionsDictionary;          this.counts = null; -        this.eventListeners = []; +        this.eventListeners = new EventListenerCollection();          this.isDeleting = false;          this.content = content; @@ -193,10 +198,10 @@ class SettingsDictionaryEntryUI {          this.applyValues(); -        this.addEventListener(this.enabledCheckbox, 'change', (e) => this.onEnabledChanged(e), false); -        this.addEventListener(this.allowSecondarySearchesCheckbox, 'change', (e) => this.onAllowSecondarySearchesChanged(e), false); -        this.addEventListener(this.priorityInput, 'change', (e) => this.onPriorityChanged(e), false); -        this.addEventListener(this.deleteButton, 'click', (e) => this.onDeleteButtonClicked(e), false); +        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);      }      cleanup() { @@ -207,7 +212,7 @@ class SettingsDictionaryEntryUI {              this.content = null;          }          this.dictionaryInfo = null; -        this.clearEventListeners(); +        this.eventListeners.removeAllEventListeners();      }      setCounts(counts) { @@ -224,18 +229,6 @@ class SettingsDictionaryEntryUI {          this.parent.save();      } -    addEventListener(node, type, listener, options) { -        node.addEventListener(type, listener, options); -        this.eventListeners.push([node, type, listener, options]); -    } - -    clearEventListeners() { -        for (const [node, type, listener, options] of this.eventListeners) { -            node.removeEventListener(type, listener, options); -        } -        this.eventListeners = []; -    } -      applyValues() {          this.enabledCheckbox.checked = this.optionsDictionary.enabled;          this.allowSecondarySearchesCheckbox.checked = this.optionsDictionary.allowSecondarySearches; @@ -272,9 +265,7 @@ class SettingsDictionaryEntryUI {              this.isDeleting = false;              progress.hidden = true; -            const optionsContext = getOptionsContext(); -            const options = await getOptionsMutable(optionsContext); -            onDatabaseUpdated(options); +            onDatabaseUpdated();          }      } @@ -359,28 +350,33 @@ async function dictSettingsInitialize() {      document.querySelector('#dict-main').addEventListener('change', (e) => onDictionaryMainChanged(e), false);      document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', (e) => onDatabaseEnablePrefixWildcardSearchesChanged(e), false); -    const optionsContext = getOptionsContext(); -    const options = await getOptionsMutable(optionsContext); -    onDictionaryOptionsChanged(options); -    onDatabaseUpdated(options); +    await onDictionaryOptionsChanged(); +    await onDatabaseUpdated();  } -async function onDictionaryOptionsChanged(options) { +async function onDictionaryOptionsChanged() {      if (dictionaryUI === null) { return; } + +    const optionsContext = getOptionsContext(); +    const options = await getOptionsMutable(optionsContext); +      dictionaryUI.setOptionsDictionaries(options.dictionaries);      const optionsFull = await apiOptionsGetFull();      document.querySelector('#database-enable-prefix-wildcard-searches').checked = optionsFull.global.database.prefixWildcardsSupported; + +    await updateMainDictionarySelectValue();  } -async function onDatabaseUpdated(options) { +async function onDatabaseUpdated() {      try {          const dictionaries = await utilDatabaseGetDictionaryInfo();          dictionaryUI.setDictionaries(dictionaries);          document.querySelector('#dict-warning').hidden = (dictionaries.length > 0); -        updateMainDictionarySelect(options, dictionaries); +        updateMainDictionarySelectOptions(dictionaries); +        await updateMainDictionarySelectValue();          const {counts, total} = await utilDatabaseGetDictionaryCounts(dictionaries.map((v) => v.title), true);          dictionaryUI.setCounts(counts, total); @@ -389,7 +385,7 @@ async function onDatabaseUpdated(options) {      }  } -async function updateMainDictionarySelect(options, dictionaries) { +function updateMainDictionarySelectOptions(dictionaries) {      const select = document.querySelector('#dict-main');      select.textContent = ''; // Empty @@ -399,8 +395,6 @@ async function updateMainDictionarySelect(options, dictionaries) {      option.textContent = 'Not selected';      select.appendChild(option); -    let value = ''; -    const currentValue = options.general.mainDictionary;      for (const {title, sequenced} of toIterable(dictionaries)) {          if (!sequenced) { continue; } @@ -408,26 +402,56 @@ async function updateMainDictionarySelect(options, dictionaries) {          option.value = title;          option.textContent = title;          select.appendChild(option); +    } +} + +async function updateMainDictionarySelectValue() { +    const optionsContext = getOptionsContext(); +    const options = await apiOptionsGet(optionsContext); -        if (title === currentValue) { -            value = title; +    const value = options.general.mainDictionary; + +    const select = document.querySelector('#dict-main'); +    let selectValue = null; +    for (const child of select.children) { +        if (child.nodeName.toUpperCase() === 'OPTION' && child.value === value) { +            selectValue = value; +            break;          }      } -    select.value = value; - -    if (options.general.mainDictionary !== value) { -        options.general.mainDictionary = value; -        settingsSaveOptions(); +    let missingNodeOption = select.querySelector('option[data-not-installed=true]'); +    if (selectValue === null) { +        if (missingNodeOption === null) { +            missingNodeOption = document.createElement('option'); +            missingNodeOption.className = 'text-muted'; +            missingNodeOption.value = value; +            missingNodeOption.textContent = `${value} (Not installed)`; +            missingNodeOption.dataset.notInstalled = 'true'; +            select.appendChild(missingNodeOption); +        } +    } else { +        if (missingNodeOption !== null) { +            missingNodeOption.parentNode.removeChild(missingNodeOption); +        }      } + +    select.value = value;  }  async function onDictionaryMainChanged(e) { -    const value = e.target.value; +    const select = e.target; +    const value = select.value; + +    const missingNodeOption = select.querySelector('option[data-not-installed=true]'); +    if (missingNodeOption !== null && missingNodeOption.value !== value) { +        missingNodeOption.parentNode.removeChild(missingNodeOption); +    } +      const optionsContext = getOptionsContext();      const options = await getOptionsMutable(optionsContext);      options.general.mainDictionary = value; -    settingsSaveOptions(); +    await settingsSaveOptions();  } @@ -467,15 +491,18 @@ function dictionaryErrorsShow(errors) {      dialog.textContent = '';      if (errors !== null && errors.length > 0) { -        const uniqueErrors = {}; +        const uniqueErrors = new Map();          for (let e of errors) {              console.error(e);              e = dictionaryErrorToString(e); -            uniqueErrors[e] = hasOwn(uniqueErrors, e) ? uniqueErrors[e] + 1 : 1; +            let count = uniqueErrors.get(e); +            if (typeof count === 'undefined') { +                count = 0; +            } +            uniqueErrors.set(e, count + 1);          } -        for (const e in uniqueErrors) { -            const count = uniqueErrors[e]; +        for (const [e, count] of uniqueErrors.entries()) {              const div = document.createElement('p');              if (count > 1) {                  div.textContent = `${e} `; @@ -537,9 +564,7 @@ async function onDictionaryPurge(e) {          }          await settingsSaveOptions(); -        const optionsContext = getOptionsContext(); -        const options = await getOptionsMutable(optionsContext); -        onDatabaseUpdated(options); +        onDatabaseUpdated();      } catch (err) {          dictionaryErrorsShow([err]);      } finally { @@ -611,9 +636,7 @@ async function onDictionaryImport(e) {                  dictionaryErrorsShow(errors);              } -            const optionsContext = getOptionsContext(); -            const options = await getOptionsMutable(optionsContext); -            onDatabaseUpdated(options); +            onDatabaseUpdated();          }      } catch (err) {          dictionaryErrorsShow([err]); diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js index 3bf65eda..d1ad2c6b 100644 --- a/ext/bg/js/settings/main.js +++ b/ext/bg/js/settings/main.js @@ -16,6 +16,14 @@   * 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 +*/ +  function getOptionsMutable(optionsContext) {      return utilBackend().getOptions(          utilBackgroundIsolate(optionsContext) @@ -28,6 +36,22 @@ function getOptionsFullMutable() {  async function formRead(options) {      options.general.enable = $('#enable').prop('checked'); +    const enableClipboardPopups = $('#enable-clipboard-popups').prop('checked'); +    if (enableClipboardPopups) { +        options.general.enableClipboardPopups = await new Promise((resolve, _reject) => { +            chrome.permissions.request( +                {permissions: ['clipboardRead']}, +                (granted) => { +                    if (!granted) { +                        $('#enable-clipboard-popups').prop('checked', false); +                    } +                    resolve(granted); +                } +            ); +        }); +    } else { +        options.general.enableClipboardPopups = false; +    }      options.general.showGuide = $('#show-usage-guide').prop('checked');      options.general.compactTags = $('#compact-tags').prop('checked');      options.general.compactGlossaries = $('#compact-glossaries').prop('checked'); @@ -44,7 +68,7 @@ async function formRead(options) {      options.general.popupVerticalOffset = parseInt($('#popup-vertical-offset').val(), 10);      options.general.popupHorizontalOffset2 = parseInt($('#popup-horizontal-offset2').val(), 0);      options.general.popupVerticalOffset2 = parseInt($('#popup-vertical-offset2').val(), 10); -    options.general.popupScalingFactor = parseInt($('#popup-scaling-factor').val(), 10); +    options.general.popupScalingFactor = parseFloat($('#popup-scaling-factor').val());      options.general.popupScaleRelativeToPageZoom = $('#popup-scale-relative-to-page-zoom').prop('checked');      options.general.popupScaleRelativeToVisualViewport = $('#popup-scale-relative-to-visual-viewport').prop('checked');      options.general.popupTheme = $('#popup-theme').val(); @@ -67,6 +91,7 @@ async function formRead(options) {      options.scanning.enablePopupSearch = $('#enable-search-within-first-popup').prop('checked');      options.scanning.enableOnPopupExpressions = $('#enable-scanning-of-popup-expressions').prop('checked');      options.scanning.enableOnSearchPage = $('#enable-scanning-on-search-page').prop('checked'); +    options.scanning.enableSearchTags = $('#enable-search-tags').prop('checked');      options.scanning.delay = parseInt($('#scan-delay').val(), 10);      options.scanning.length = parseInt($('#scan-length').val(), 10);      options.scanning.modifier = $('#scan-modifier-key').val(); @@ -103,6 +128,7 @@ async function formRead(options) {  async function formWrite(options) {      $('#enable').prop('checked', options.general.enable); +    $('#enable-clipboard-popups').prop('checked', options.general.enableClipboardPopups);      $('#show-usage-guide').prop('checked', options.general.showGuide);      $('#compact-tags').prop('checked', options.general.compactTags);      $('#compact-glossaries').prop('checked', options.general.compactGlossaries); @@ -142,6 +168,7 @@ async function formWrite(options) {      $('#enable-search-within-first-popup').prop('checked', options.scanning.enablePopupSearch);      $('#enable-scanning-of-popup-expressions').prop('checked', options.scanning.enableOnPopupExpressions);      $('#enable-scanning-on-search-page').prop('checked', options.scanning.enableOnSearchPage); +    $('#enable-search-tags').prop('checked', options.scanning.enableSearchTags);      $('#scan-delay').val(options.scanning.delay);      $('#scan-length').val(options.scanning.length);      $('#scan-modifier-key').val(options.scanning.modifier); @@ -167,7 +194,7 @@ async function formWrite(options) {      await ankiTemplatesUpdateValue();      await onAnkiOptionsChanged(options); -    await onDictionaryOptionsChanged(options); +    await onDictionaryOptionsChanged();      formUpdateVisibility(options);  } @@ -215,7 +242,7 @@ async function settingsSaveOptions() {      await apiOptionsSave(source);  } -async function onOptionsUpdate({source}) { +async function onOptionsUpdated({source}) {      const thisSource = await settingsGetSource();      if (source === thisSource) { return; } @@ -247,7 +274,7 @@ async function onReady() {      storageInfoInitialize(); -    yomichan.on('optionsUpdate', onOptionsUpdate); +    yomichan.on('optionsUpdated', onOptionsUpdated);  }  $(document).ready(() => onReady()); diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js index 37a4b416..aa2b6100 100644 --- a/ext/bg/js/settings/popup-preview-frame.js +++ b/ext/bg/js/settings/popup-preview-frame.js @@ -16,15 +16,18 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiOptionsGet, Popup, PopupProxyHost, Frontend, TextSourceRange*/  class SettingsPopupPreview {      constructor() {          this.frontend = null;          this.apiOptionsGetOld = apiOptionsGet; -        this.popupInjectOuterStylesheetOld = Popup.injectOuterStylesheet; +        this.popup = null; +        this.popupSetCustomOuterCssOld = null;          this.popupShown = false;          this.themeChangeTimeout = null;          this.textSource = null; +        this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');      }      static create() { @@ -49,18 +52,18 @@ class SettingsPopupPreview {          const popupHost = new PopupProxyHost();          await popupHost.prepare(); -        const popup = popupHost.createPopup(null, 0); -        popup.setChildrenSupported(false); +        this.popup = popupHost.getOrCreatePopup(); +        this.popup.setChildrenSupported(false); -        this.frontend = new Frontend(popup); +        this.popupSetCustomOuterCssOld = this.popup.setCustomOuterCss; +        this.popup.setCustomOuterCss = (...args) => this.popupSetCustomOuterCss(...args); -        this.frontend.setEnabled = function () {}; -        this.frontend.searchClear = function () {}; +        this.frontend = new Frontend(this.popup); -        await this.frontend.prepare(); +        this.frontend.setEnabled = () => {}; +        this.frontend.searchClear = () => {}; -        // Overwrite popup -        Popup.injectOuterStylesheet = (...args) => this.popupInjectOuterStylesheet(...args); +        await this.frontend.prepare();          // Update search          this.updateSearch(); @@ -82,20 +85,21 @@ class SettingsPopupPreview {          return options;      } -    popupInjectOuterStylesheet(...args) { +    async popupSetCustomOuterCss(...args) {          // This simulates the stylesheet priorities when injecting using the web extension API. -        const result = this.popupInjectOuterStylesheetOld(...args); +        const result = await this.popupSetCustomOuterCssOld.call(this.popup, ...args); -        const outerStylesheet = Popup.outerStylesheet;          const node = document.querySelector('#client-css'); -        if (node !== null && outerStylesheet !== null) { -            node.parentNode.insertBefore(outerStylesheet, node); +        if (node !== null && result !== null) { +            node.parentNode.insertBefore(result, node);          }          return result;      }      onMessage(e) { +        if (e.origin !== this._targetOrigin) { return; } +          const {action, params} = e.data;          const handler = SettingsPopupPreview._messageHandlers.get(action);          if (typeof handler !== 'function') { return; } @@ -136,7 +140,7 @@ class SettingsPopupPreview {      setCustomOuterCss(css) {          if (this.frontend === null) { return; } -        this.frontend.popup.setCustomOuterCss(css, true); +        this.frontend.popup.setCustomOuterCss(css, false);      }      async updateSearch() { diff --git a/ext/bg/js/settings/popup-preview.js b/ext/bg/js/settings/popup-preview.js index 0d20471e..d1d2ff5e 100644 --- a/ext/bg/js/settings/popup-preview.js +++ b/ext/bg/js/settings/popup-preview.js @@ -40,20 +40,22 @@ function showAppearancePreview() {      window.wanakana.bind(text[0]); +    const targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); +      text.on('input', () => {          const action = 'setText';          const params = {text: text.val()}; -        frame.contentWindow.postMessage({action, params}, '*'); +        frame.contentWindow.postMessage({action, params}, targetOrigin);      });      customCss.on('input', () => {          const action = 'setCustomCss';          const params = {css: customCss.val()}; -        frame.contentWindow.postMessage({action, params}, '*'); +        frame.contentWindow.postMessage({action, params}, targetOrigin);      });      customOuterCss.on('input', () => {          const action = 'setCustomOuterCss';          const params = {css: customOuterCss.val()}; -        frame.contentWindow.postMessage({action, params}, '*'); +        frame.contentWindow.postMessage({action, params}, targetOrigin);      });      container.append(frame); diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js index c4e68b53..3e589809 100644 --- a/ext/bg/js/settings/profiles.js +++ b/ext/bg/js/settings/profiles.js @@ -16,6 +16,10 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global getOptionsMutable, getOptionsFullMutable, settingsSaveOptions, apiOptionsGetFull +utilBackgroundIsolate, formWrite +conditionsClearCaches, ConditionsUI, profileConditionsDescriptor*/ +  let currentProfileIndex = 0;  let profileConditionsContainer = null; diff --git a/ext/bg/js/settings/storage.js b/ext/bg/js/settings/storage.js index 6c10f665..cbe1bb4d 100644 --- a/ext/bg/js/settings/storage.js +++ b/ext/bg/js/settings/storage.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiGetEnvironmentInfo*/  function storageBytesToLabeledString(size) {      const base = 1000; diff --git a/ext/bg/js/templates.js b/ext/bg/js/templates.js deleted file mode 100644 index 2f65be31..00000000 --- a/ext/bg/js/templates.js +++ /dev/null @@ -1,55 +0,0 @@ -(function() { -  var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; -templates['query-parser.html'] = template({"1":function(container,depth0,helpers,partials,data) { -    var stack1, alias1=depth0 != null ? depth0 : (container.nullContext || {}); - -  return ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.preview : depth0),{"name":"if","hash":{},"fn":container.program(2, data, 0),"inverse":container.program(4, data, 0),"data":data})) != null ? stack1 : "") -    + ((stack1 = helpers.each.call(alias1,depth0,{"name":"each","hash":{},"fn":container.program(6, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") -    + "</span>"; -},"2":function(container,depth0,helpers,partials,data) { -    return "<span class=\"query-parser-term-preview\">"; -},"4":function(container,depth0,helpers,partials,data) { -    return "<span class=\"query-parser-term\">"; -},"6":function(container,depth0,helpers,partials,data) { -    var stack1; - -  return ((stack1 = container.invokePartial(partials.part,depth0,{"name":"part","data":data,"helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : ""); -},"8":function(container,depth0,helpers,partials,data) { -    var stack1; - -  return ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.raw : depth0),{"name":"if","hash":{},"fn":container.program(9, data, 0),"inverse":container.program(12, data, 0),"data":data})) != null ? stack1 : ""); -},"9":function(container,depth0,helpers,partials,data) { -    var stack1; - -  return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.text : depth0),{"name":"each","hash":{},"fn":container.program(10, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : ""); -},"10":function(container,depth0,helpers,partials,data) { -    return "<span class=\"query-parser-char\">" -    + container.escapeExpression(container.lambda(depth0, depth0)) -    + "</span>"; -},"12":function(container,depth0,helpers,partials,data) { -    var stack1, helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}); - -  return "<ruby>" -    + ((stack1 = helpers.each.call(alias1,(depth0 != null ? depth0.text : depth0),{"name":"each","hash":{},"fn":container.program(10, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") -    + "<rt>" -    + container.escapeExpression(((helper = (helper = helpers.reading || (depth0 != null ? depth0.reading : depth0)) != null ? helper : helpers.helperMissing),(typeof helper === "function" ? helper.call(alias1,{"name":"reading","hash":{},"data":data}) : helper))) -    + "</rt></ruby>"; -},"14":function(container,depth0,helpers,partials,data,blockParams,depths) { -    var stack1; - -  return ((stack1 = container.invokePartial(partials.term,depth0,{"name":"term","hash":{"preview":(depths[1] != null ? depths[1].preview : depths[1])},"data":data,"helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : ""); -},"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data,blockParams,depths) { -    var stack1; - -  return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.terms : depth0),{"name":"each","hash":{},"fn":container.program(14, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : ""); -},"main_d":  function(fn, props, container, depth0, data, blockParams, depths) { - -  var decorators = container.decorators; - -  fn = decorators.inline(fn,props,container,{"name":"inline","hash":{},"fn":container.program(1, data, 0, blockParams, depths),"inverse":container.noop,"args":["term"],"data":data}) || fn; -  fn = decorators.inline(fn,props,container,{"name":"inline","hash":{},"fn":container.program(8, data, 0, blockParams, depths),"inverse":container.noop,"args":["part"],"data":data}) || fn; -  return fn; -  } - -,"useDecorators":true,"usePartial":true,"useData":true,"useDepths":true}); -})();
\ No newline at end of file diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js index dfec54ac..a675a9f7 100644 --- a/ext/bg/js/translator.js +++ b/ext/bg/js/translator.js @@ -16,12 +16,18 @@   * 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*/  class Translator {      constructor() {          this.database = null;          this.deinflector = null; -        this.tagCache = {}; +        this.tagCache = new Map();      }      async prepare() { @@ -38,24 +44,24 @@ class Translator {      }      async purgeDatabase() { -        this.tagCache = {}; +        this.tagCache.clear();          await this.database.purge();      }      async deleteDictionary(dictionaryName) { -        this.tagCache = {}; +        this.tagCache.clear();          await this.database.deleteDictionary(dictionaryName);      }      async getSequencedDefinitions(definitions, mainDictionary) { -        const definitionsBySequence = dictTermsMergeBySequence(definitions, mainDictionary); -        const defaultDefinitions = definitionsBySequence['-1']; +        const [definitionsBySequence, defaultDefinitions] = dictTermsMergeBySequence(definitions, mainDictionary); -        const sequenceList = Object.keys(definitionsBySequence).map((v) => Number(v)).filter((v) => v >= 0); -        const sequencedDefinitions = sequenceList.map((key) => ({ -            definitions: definitionsBySequence[key], -            rawDefinitions: [] -        })); +        const sequenceList = []; +        const sequencedDefinitions = []; +        for (const [key, value] of definitionsBySequence.entries()) { +            sequenceList.push(key); +            sequencedDefinitions.push({definitions: value, rawDefinitions: []}); +        }          for (const definition of await this.database.findTermsBySequenceBulk(sequenceList, mainDictionary)) {              sequencedDefinitions[definition.index].rawDefinitions.push(definition); @@ -64,8 +70,8 @@ class Translator {          return {sequencedDefinitions, defaultDefinitions};      } -    async getMergedSecondarySearchResults(text, expressionsMap, secondarySearchTitles) { -        if (secondarySearchTitles.length === 0) { +    async getMergedSecondarySearchResults(text, expressionsMap, secondarySearchDictionaries) { +        if (secondarySearchDictionaries.size === 0) {              return [];          } @@ -79,7 +85,7 @@ class Translator {              }          } -        const definitions = await this.database.findTermsExactBulk(expressionList, readingList, secondarySearchTitles); +        const definitions = await this.database.findTermsExactBulk(expressionList, readingList, secondarySearchDictionaries);          for (const definition of definitions) {              const definitionTags = await this.expandTags(definition.definitionTags, definition.dictionary);              definitionTags.push(dictTagBuildSource(definition.dictionary)); @@ -95,7 +101,7 @@ class Translator {          return definitions;      } -    async getMergedDefinition(text, dictionaries, sequencedDefinition, defaultDefinitions, secondarySearchTitles, mergedByTermIndices) { +    async getMergedDefinition(text, dictionaries, sequencedDefinition, defaultDefinitions, secondarySearchDictionaries, mergedByTermIndices) {          const result = sequencedDefinition.definitions;          const rawDefinitionsBySequence = sequencedDefinition.rawDefinitions; @@ -108,12 +114,11 @@ class Translator {          }          const definitionsByGloss = dictTermsMergeByGloss(result, rawDefinitionsBySequence); -        const secondarySearchResults = await this.getMergedSecondarySearchResults(text, result.expressions, secondarySearchTitles); +        const secondarySearchResults = await this.getMergedSecondarySearchResults(text, result.expressions, secondarySearchDictionaries);          dictTermsMergeByGloss(result, defaultDefinitions.concat(secondarySearchResults), definitionsByGloss, mergedByTermIndices); -        for (const gloss in definitionsByGloss) { -            const definition = definitionsByGloss[gloss]; +        for (const definition of definitionsByGloss.values()) {              dictTagsSort(definition.definitionTags);              result.definitions.push(definition);          } @@ -122,7 +127,8 @@ class Translator {          const expressions = [];          for (const [expression, readingMap] of result.expressions.entries()) { -            for (const [reading, termTags] of readingMap.entries()) { +            for (const [reading, termTagsMap] of readingMap.entries()) { +                const termTags = [...termTagsMap.values()];                  const score = termTags.map((tag) => tag.score).reduce((p, v) => p + v, 0);                  expressions.push(Translator.createExpression(expression, reading, dictTagsSort(termTags), Translator.scoreToTermFrequency(score)));              } @@ -135,14 +141,16 @@ class Translator {          return result;      } -    async findTerms(text, details, options) { -        switch (options.general.resultOutputMode) { +    async findTerms(mode, text, details, options) { +        switch (mode) {              case 'group':                  return await this.findTermsGrouped(text, details, options);              case 'merge':                  return await this.findTermsMerged(text, details, options);              case 'split':                  return await this.findTermsSplit(text, details, options); +            case 'simple': +                return await this.findTermsSimple(text, details, options);              default:                  return [[], 0];          } @@ -150,11 +158,10 @@ class Translator {      async findTermsGrouped(text, details, options) {          const dictionaries = dictEnabledSet(options); -        const titles = Object.keys(dictionaries);          const [definitions, length] = await this.findTermsInternal(text, dictionaries, details, options);          const definitionsGrouped = dictTermsGroup(definitions, dictionaries); -        await this.buildTermMeta(definitionsGrouped, titles); +        await this.buildTermMeta(definitionsGrouped, dictionaries);          if (options.general.compactTags) {              for (const definition of definitionsGrouped) { @@ -167,8 +174,12 @@ class Translator {      async findTermsMerged(text, details, options) {          const dictionaries = dictEnabledSet(options); -        const secondarySearchTitles = Object.keys(options.dictionaries).filter((dict) => options.dictionaries[dict].allowSecondarySearches); -        const titles = Object.keys(dictionaries); +        const secondarySearchDictionaries = new Map(); +        for (const [title, dictionary] of dictionaries.entries()) { +            if (!dictionary.allowSecondarySearches) { continue; } +            secondarySearchDictionaries.set(title, dictionary); +        } +          const [definitions, length] = await this.findTermsInternal(text, dictionaries, details, options);          const {sequencedDefinitions, defaultDefinitions} = await this.getSequencedDefinitions(definitions, options.general.mainDictionary);          const definitionsMerged = []; @@ -180,7 +191,7 @@ class Translator {                  dictionaries,                  sequencedDefinition,                  defaultDefinitions, -                secondarySearchTitles, +                secondarySearchDictionaries,                  mergedByTermIndices              );              definitionsMerged.push(result); @@ -192,7 +203,7 @@ class Translator {              definitionsMerged.push(groupedDefinition);          } -        await this.buildTermMeta(definitionsMerged, titles); +        await this.buildTermMeta(definitionsMerged, dictionaries);          if (options.general.compactTags) {              for (const definition of definitionsMerged) { @@ -205,25 +216,28 @@ class Translator {      async findTermsSplit(text, details, options) {          const dictionaries = dictEnabledSet(options); -        const titles = Object.keys(dictionaries);          const [definitions, length] = await this.findTermsInternal(text, dictionaries, details, options); -        await this.buildTermMeta(definitions, titles); +        await this.buildTermMeta(definitions, dictionaries);          return [definitions, length];      } +    async findTermsSimple(text, details, options) { +        const dictionaries = dictEnabledSet(options); +        return await this.findTermsInternal(text, dictionaries, details, options); +    } +      async findTermsInternal(text, dictionaries, details, options) {          text = Translator.getSearchableText(text, options);          if (text.length === 0) {              return [[], 0];          } -        const titles = Object.keys(dictionaries);          const deinflections = (              details.wildcard ? -            await this.findTermWildcard(text, titles, details.wildcard) : -            await this.findTermDeinflections(text, titles, options) +            await this.findTermWildcard(text, dictionaries, details.wildcard) : +            await this.findTermDeinflections(text, dictionaries, options)          );          let definitions = []; @@ -265,8 +279,8 @@ class Translator {          return [definitions, length];      } -    async findTermWildcard(text, titles, wildcard) { -        const definitions = await this.database.findTermsBulk([text], titles, wildcard); +    async findTermWildcard(text, dictionaries, wildcard) { +        const definitions = await this.database.findTermsBulk([text], dictionaries, wildcard);          if (definitions.length === 0) {              return [];          } @@ -281,7 +295,7 @@ class Translator {          }];      } -    async findTermDeinflections(text, titles, options) { +    async findTermDeinflections(text, dictionaries, options) {          const deinflections = this.getAllDeinflections(text, options);          if (deinflections.length === 0) { @@ -303,7 +317,7 @@ class Translator {              deinflectionArray.push(deinflection);          } -        const definitions = await this.database.findTermsBulk(uniqueDeinflectionTerms, titles, null); +        const definitions = await this.database.findTermsBulk(uniqueDeinflectionTerms, dictionaries, null);          for (const definition of definitions) {              const definitionRules = Deinflector.rulesToRuleFlags(definition.rules); @@ -393,17 +407,12 @@ class Translator {      async findKanji(text, options) {          const dictionaries = dictEnabledSet(options); -        const titles = Object.keys(dictionaries); -        const kanjiUnique = {}; -        const kanjiList = []; +        const kanjiUnique = new Set();          for (const c of text) { -            if (!hasOwn(kanjiUnique, c)) { -                kanjiList.push(c); -                kanjiUnique[c] = true; -            } +            kanjiUnique.add(c);          } -        const definitions = await this.database.findKanjiBulk(kanjiList, titles); +        const definitions = await this.database.findKanjiBulk([...kanjiUnique], dictionaries);          if (definitions.length === 0) {              return definitions;          } @@ -423,12 +432,12 @@ class Translator {              definition.stats = stats;          } -        await this.buildKanjiMeta(definitions, titles); +        await this.buildKanjiMeta(definitions, dictionaries);          return definitions;      } -    async buildTermMeta(definitions, titles) { +    async buildTermMeta(definitions, dictionaries) {          const terms = [];          for (const definition of definitions) {              if (definition.expressions) { @@ -454,7 +463,7 @@ class Translator {                  termList = [];                  expressionsUnique.push(expression);                  termsUnique.push(termList); -                termsUniqueMap[expression] = termList; +                termsUniqueMap.set(expression, termList);              }              termList.push(term); @@ -462,7 +471,7 @@ class Translator {              term.frequencies = [];          } -        const metas = await this.database.findTermMetaBulk(expressionsUnique, titles); +        const metas = await this.database.findTermMetaBulk(expressionsUnique, dictionaries);          for (const {expression, mode, data, dictionary, index} of metas) {              switch (mode) {                  case 'freq': @@ -474,14 +483,14 @@ class Translator {          }      } -    async buildKanjiMeta(definitions, titles) { +    async buildKanjiMeta(definitions, dictionaries) {          const kanjiList = [];          for (const definition of definitions) {              kanjiList.push(definition.character);              definition.frequencies = [];          } -        const metas = await this.database.findKanjiMetaBulk(kanjiList, titles); +        const metas = await this.database.findKanjiMetaBulk(kanjiList, dictionaries);          for (const {character, mode, data, dictionary, index} of metas) {              switch (mode) {                  case 'freq': @@ -504,49 +513,50 @@ class Translator {          const names = Object.keys(items);          const tagMetaList = await this.getTagMetaList(names, title); -        const stats = {}; +        const statsGroups = new Map();          for (let i = 0; i < names.length; ++i) {              const name = names[i];              const meta = tagMetaList[i];              if (meta === null) { continue; }              const category = meta.category; -            const group = ( -                hasOwn(stats, category) ? -                stats[category] : -                (stats[category] = []) -            ); +            let group = statsGroups.get(category); +            if (typeof group === 'undefined') { +                group = []; +                statsGroups.set(category, group); +            }              const stat = Object.assign({}, meta, {name, value: items[name]});              group.push(dictTagSanitize(stat));          } +        const stats = {};          const sortCompare = (a, b) => a.notes - b.notes; -        for (const category in stats) { -            stats[category].sort(sortCompare); +        for (const [category, group] of statsGroups.entries()) { +            group.sort(sortCompare); +            stats[category] = group;          } -          return stats;      }      async getTagMetaList(names, title) {          const tagMetaList = []; -        const cache = ( -            hasOwn(this.tagCache, title) ? -            this.tagCache[title] : -            (this.tagCache[title] = {}) -        ); +        let cache = this.tagCache.get(title); +        if (typeof cache === 'undefined') { +            cache = new Map(); +            this.tagCache.set(title, cache); +        }          for (const name of names) {              const base = Translator.getNameBase(name); -            if (hasOwn(cache, base)) { -                tagMetaList.push(cache[base]); -            } else { -                const tagMeta = await this.database.findTagForTitle(base, title); -                cache[base] = tagMeta; -                tagMetaList.push(tagMeta); +            let tagMeta = cache.get(base); +            if (typeof tagMeta === 'undefined') { +                tagMeta = await this.database.findTagForTitle(base, title); +                cache.set(base, tagMeta);              } + +            tagMetaList.push(tagMeta);          }          return tagMetaList; diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js index 333e814b..5ce4b08c 100644 --- a/ext/bg/js/util.js +++ b/ext/bg/js/util.js @@ -33,7 +33,7 @@ function utilIsolate(value) {  }  function utilFunctionIsolate(func) { -    return function (...args) { +    return function isolatedFunction(...args) {          try {              args = args.map((v) => utilIsolate(v));              return func.call(this, ...args); @@ -59,32 +59,6 @@ function utilBackgroundFunctionIsolate(func) {      return backgroundPage.utilFunctionIsolate(func);  } -function utilSetEqual(setA, setB) { -    if (setA.size !== setB.size) { -        return false; -    } - -    for (const value of setA) { -        if (!setB.has(value)) { -            return false; -        } -    } - -    return true; -} - -function utilSetIntersection(setA, setB) { -    return new Set( -        [...setA].filter((value) => setB.has(value)) -    ); -} - -function utilSetDifference(setA, setB) { -    return new Set( -        [...setA].filter((value) => !setB.has(value)) -    ); -} -  function utilStringHashCode(string) {      let hashCode = 0; diff --git a/ext/bg/query-parser-templates.html b/ext/bg/query-parser-templates.html new file mode 100644 index 00000000..7cab16a9 --- /dev/null +++ b/ext/bg/query-parser-templates.html @@ -0,0 +1,11 @@ +<!DOCTYPE html><html><head></head><body> + +<template id="term-template"><span class="query-parser-term" data-type="normal"></span></template> +<template id="term-preview-template"><span class="query-parser-term" data-type="preview"></span></template> +<template id="segment-template"><ruby class="query-parser-segment"><span class="query-parser-segment-text"></span><rt class="query-parser-segment-reading"></rt></ruby></template> +<template id="char-template"><span class="query-parser-char"></span></template> + +<template id="select-template"><select class="query-parser-select form-control"></select></template> +<template id="select-option-template"><option class="query-parser-select-option"></option></template> + +</body></html> diff --git a/ext/bg/search.html b/ext/bg/search.html index 74afbb68..d6336826 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -25,29 +25,31 @@                  <p style="margin-bottom: 0;">Search your installed dictionaries by entering a Japanese expression into the field below.</p>              </div> -            <div class="input-group" style="padding-top: 20px;"> -                <span title="Enable kana input method" class="input-group-text"> -                    <input type="checkbox" id="wanakana-enable" class="icon-checkbox" /> -                    <label for="wanakana-enable" class="scan-disable">あ</label> -                </span> -                <span title="Enable clipboard monitor" class="input-group-text"> -                    <input type="checkbox" id="clipboard-monitor-enable" class="icon-checkbox" /> -                    <label for="clipboard-monitor-enable"><span class="glyphicon glyphicon-paste"></span></label> -                </span> -            </div> +            <div class="search-input"> +                <div class="input-group" style="padding-top: 20px;"> +                    <span title="Enable kana input method" class="input-group-text"> +                        <input type="checkbox" id="wanakana-enable" class="icon-checkbox" /> +                        <label for="wanakana-enable" class="scan-disable">あ</label> +                    </span> +                    <span title="Enable clipboard monitor" class="input-group-text"> +                        <input type="checkbox" id="clipboard-monitor-enable" class="icon-checkbox" /> +                        <label for="clipboard-monitor-enable"><span class="glyphicon glyphicon-paste"></span></label> +                    </span> +                </div> -            <form class="input-group"> -                <input type="text" class="form-control" placeholder="Search for..." id="query" autofocus> -                <span class="input-group-btn"> -                    <input type="submit" class="btn btn-default form-control" id="search" value="Search"> -                </span> -            </form> +                <form class="input-group"> +                    <input type="text" class="form-control" placeholder="Search for..." id="query" autofocus> +                    <span class="input-group-btn"> +                        <input type="submit" class="btn btn-default form-control" id="search" value="Search"> +                    </span> +                </form> +            </div>              <div id="spinner" hidden><img src="/mixed/img/spinner.gif"></div>              <div class="scan-disable"> -                <div id="query-parser-select" class="input-group"></div> -                <div id="query-parser"></div> +                <div id="query-parser-select-container" class="input-group"></div> +                <div id="query-parser-content"></div>              </div>              <hr> @@ -75,18 +77,20 @@          <script src="/bg/js/dictionary.js"></script>          <script src="/bg/js/handlebars.js"></script> -        <script src="/bg/js/templates.js"></script> +        <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/display-context.js"></script>          <script src="/mixed/js/display.js"></script>          <script src="/mixed/js/display-generator.js"></script> -        <script src="/mixed/js/japanese.js"></script>          <script src="/mixed/js/scroll.js"></script>          <script src="/mixed/js/text-scanner.js"></script> +        <script src="/mixed/js/template-handler.js"></script> +        <script src="/bg/js/search-query-parser-generator.js"></script>          <script src="/bg/js/search-query-parser.js"></script> +        <script src="/bg/js/clipboard-monitor.js"></script>          <script src="/bg/js/search.js"></script>          <script src="/bg/js/search-frontend.js"></script>      </body> diff --git a/ext/bg/settings.html b/ext/bg/settings.html index 3e06d4b5..b048a36c 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -134,6 +134,10 @@                      <label><input type="checkbox" id="enable"> Enable content scanning</label>                  </div> +                <div class="checkbox" data-hide-for-browser="firefox-mobile"> +                    <label><input type="checkbox" id="enable-clipboard-popups"> Enable native popups when copying Japanese text</label> +                </div> +                  <div class="checkbox">                      <label><input type="checkbox" id="show-usage-guide"> Show usage guide on startup</label>                  </div> @@ -481,7 +485,7 @@                  </p>                  <div class="checkbox"> -                    <label><input type="checkbox" id="enable-search-within-first-popup"> Enable search when clicking glossary entries</label> +                    <label><input type="checkbox" id="enable-search-within-first-popup"> Enable search when clicking glossary entries and tags</label>                  </div>                  <div class="checkbox"> @@ -492,6 +496,10 @@                      <label><input type="checkbox" id="enable-scanning-of-popup-expressions"> Enable scanning of expressions in search results</label>                  </div> +                <div class="checkbox"> +                    <label><input type="checkbox" id="enable-search-tags"> Enable clickable and scannable tags for searching expressions and their readings</label> +                </div> +                  <div class="form-group">                      <label for="popup-nesting-max-depth">Maximum number of additional popups</label>                      <input type="number" min="0" step="1" id="popup-nesting-max-depth" class="form-control"> @@ -760,6 +768,13 @@                      <div class="alert alert-danger" id="anki-error" hidden></div> +                    <div class="alert alert-danger" id="anki-invalid-response-error" hidden> +                        Attempting to connect to Anki can sometimes return an error message which includes "Invalid response", +                        which may indicate that the value of the <strong>Interface server</strong> option is incorrect. +                        The <strong>Show advanced options</strong> checkbox under General Options must be ticked ticked to show this option. +                        Resetting it to the default value may fix issues that are occurring. +                    </div> +                      <div class="form-group">                          <label for="card-tags">Card tags <span class="label-light">(comma or space separated)</span></label>                          <input type="text" id="card-tags" class="form-control"> @@ -771,7 +786,7 @@                      </div>                      <div class="form-group options-advanced"> -                        <label for="interface-server">Interface server</label> +                        <label for="interface-server">Interface server <span class="label-light">(Default: http://127.0.0.1:8765)</span></label>                          <input type="text" id="interface-server" class="form-control">                      </div> @@ -1073,16 +1088,15 @@          <script src="/mixed/js/core.js"></script>          <script src="/mixed/js/dom.js"></script>          <script src="/mixed/js/api.js"></script> -        <script src="/mixed/js/japanese.js"></script>          <script src="/bg/js/anki.js"></script>          <script src="/bg/js/conditions.js"></script>          <script src="/bg/js/dictionary.js"></script>          <script src="/bg/js/handlebars.js"></script> +        <script src="/bg/js/japanese.js"></script>          <script src="/bg/js/options.js"></script>          <script src="/bg/js/page-exit-prevention.js"></script>          <script src="/bg/js/profile-conditions.js"></script> -        <script src="/bg/js/templates.js"></script>          <script src="/bg/js/util.js"></script>          <script src="/mixed/js/audio.js"></script> diff --git a/ext/fg/float.html b/ext/fg/float.html index bec5ae68..352a866a 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -35,7 +35,7 @@                  <h1>Yomichan Updated!</h1>                  <p>                      The Yomichan extension has been updated to a new version! In order to continue -                    viewing definitions on this page you must reload this tab or restart your browser. +                    viewing definitions on this page, you must reload this tab or restart your browser.                  </p>              </div>          </div> @@ -51,6 +51,7 @@          <script src="/mixed/js/display.js"></script>          <script src="/mixed/js/display-generator.js"></script>          <script src="/mixed/js/scroll.js"></script> +        <script src="/mixed/js/template-handler.js"></script>          <script src="/fg/js/float.js"></script> diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js index 71654b29..35861475 100644 --- a/ext/fg/js/document.js +++ b/ext/fg/js/document.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global TextSourceElement, TextSourceRange, DOM*/  const REGEX_TRANSPARENT_COLOR = /rgba\s*\([^)]*,\s*0(?:\.0+)?\s*\)/; @@ -49,7 +50,9 @@ function docImposterCreate(element, isTextarea) {      const imposter = document.createElement('div');      const imposterStyle = imposter.style; -    imposter.innerText = element.value; +    let value = element.value; +    if (value.endsWith('\n')) { value += '\n'; } +    imposter.textContent = value;      for (let i = 0, ii = elementStyle.length; i < ii; ++i) {          const property = elementStyle[i]; @@ -191,8 +194,7 @@ function docSentenceExtract(source, extent) {              if (terminators.includes(c)) {                  endPos = i + 1;                  break; -            } -            else if (c in quotesBwd) { +            } else if (c in quotesBwd) {                  endPos = i;                  break;              } diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js index 8d61d8f6..8f21a9c5 100644 --- a/ext/fg/js/float.js +++ b/ext/fg/js/float.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global popupNestedInitialize, apiForward, apiGetMessageToken, Display*/  class DisplayFloat extends Display {      constructor() { @@ -28,11 +29,33 @@ class DisplayFloat extends Display {          };          this._orphaned = false; +        this._prepareInvoked = false; +        this._messageToken = null; +        this._messageTokenPromise = null;          yomichan.on('orphaned', () => this.onOrphaned());          window.addEventListener('message', (e) => this.onMessage(e), false);      } +    async prepare(options, popupInfo, url, childrenSupported, scale, uniqueId) { +        if (this._prepareInvoked) { return; } +        this._prepareInvoked = true; + +        await super.prepare(options); + +        const {id, depth, parentFrameId} = popupInfo; +        this.optionsContext.depth = depth; +        this.optionsContext.url = url; + +        if (childrenSupported) { +            popupNestedInitialize(id, depth, parentFrameId, url); +        } + +        this.setContentScale(scale); + +        apiForward('popupPrepareCompleted', {uniqueId}); +    } +      onError(error) {          if (this._orphaned) {              this.setContent('orphaned'); @@ -54,11 +77,23 @@ class DisplayFloat extends Display {      }      onMessage(e) { -        const {action, params} = e.data; -        const handler = DisplayFloat._messageHandlers.get(action); -        if (typeof handler !== 'function') { return; } - -        handler(this, params); +        const data = e.data; +        if (typeof data !== 'object' || data === null) { return; } // Invalid data + +        const token = data.token; +        if (typeof token !== 'string') { return; } // Invalid data + +        if (this._messageToken === null) { +            // Async +            this.getMessageToken() +                .then( +                    () => { this.handleAction(token, data); }, +                    () => {} +                ); +        } else { +            // Sync +            this.handleAction(token, data); +        }      }      onKeyDown(e) { @@ -73,6 +108,30 @@ class DisplayFloat extends Display {          return super.onKeyDown(e);      } +    async getMessageToken() { +        // this._messageTokenPromise is used to ensure that only one call to apiGetMessageToken is made. +        if (this._messageTokenPromise === null) { +            this._messageTokenPromise = apiGetMessageToken(); +        } +        const messageToken = await this._messageTokenPromise; +        if (this._messageToken === null) { +            this._messageToken = messageToken; +        } +        this._messageTokenPromise = null; +    } + +    handleAction(token, {action, params}) { +        if (token !== this._messageToken) { +            // Invalid token +            return; +        } + +        const handler = DisplayFloat._messageHandlers.get(action); +        if (typeof handler !== 'function') { return; } + +        handler(this, params); +    } +      getOptionsContext() {          return this.optionsContext;      } @@ -92,20 +151,6 @@ class DisplayFloat extends Display {      setContentScale(scale) {          document.body.style.fontSize = `${scale}em`;      } - -    async initialize(options, popupInfo, url, childrenSupported, scale) { -        await super.initialize(options); - -        const {id, depth, parentFrameId} = popupInfo; -        this.optionsContext.depth = depth; -        this.optionsContext.url = url; - -        if (childrenSupported) { -            popupNestedInitialize(id, depth, parentFrameId, url); -        } - -        this.setContentScale(scale); -    }  }  DisplayFloat._onKeyDownHandlers = new Map([ @@ -122,7 +167,7 @@ DisplayFloat._messageHandlers = new Map([      ['setContent', (self, {type, details}) => self.setContent(type, details)],      ['clearAutoPlayTimer', (self) => self.clearAutoPlayTimer()],      ['setCustomCss', (self, {css}) => self.setCustomCss(css)], -    ['initialize', (self, {options, popupInfo, url, childrenSupported, scale}) => self.initialize(options, popupInfo, url, childrenSupported, scale)], +    ['prepare', (self, {options, popupInfo, url, childrenSupported, scale, uniqueId}) => self.prepare(options, popupInfo, url, childrenSupported, scale, uniqueId)],      ['setContentScale', (self, {scale}) => self.setContentScale(scale)]  ]); diff --git a/ext/fg/js/frontend-api-sender.js b/ext/fg/js/frontend-api-sender.js index 93c2e593..8dc6aaf3 100644 --- a/ext/fg/js/frontend-api-sender.js +++ b/ext/fg/js/frontend-api-sender.js @@ -19,7 +19,7 @@  class FrontendApiSender {      constructor() { -        this.senderId = FrontendApiSender.generateId(16); +        this.senderId = yomichan.generateId(16);          this.ackTimeout = 3000; // 3 seconds          this.responseTimeout = 10000; // 10 seconds          this.callbacks = new Map(); @@ -123,12 +123,4 @@ class FrontendApiSender {          info.timer = null;          info.reject(new Error(reason));      } - -    static generateId(length) { -        let id = ''; -        for (let i = 0; i < length; ++i) { -            id += Math.floor(Math.random() * 256).toString(16).padStart(2, '0'); -        } -        return id; -    }  } diff --git a/ext/fg/js/frontend-initialize.js b/ext/fg/js/frontend-initialize.js index 9c923fea..54b874f2 100644 --- a/ext/fg/js/frontend-initialize.js +++ b/ext/fg/js/frontend-initialize.js @@ -16,18 +16,22 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global PopupProxyHost, PopupProxy, Frontend*/  async function main() {      const data = window.frontendInitializationData || {};      const {id, depth=0, parentFrameId, ignoreNodes, url, proxy=false} = data; -    let popupHost = null; -    if (!proxy) { -        popupHost = new PopupProxyHost(); +    let popup; +    if (proxy) { +        popup = new PopupProxy(null, depth + 1, id, parentFrameId, url); +    } else { +        const popupHost = new PopupProxyHost();          await popupHost.prepare(); + +        popup = popupHost.getOrCreatePopup();      } -    const popup = proxy ? new PopupProxy(depth + 1, id, parentFrameId, url) : popupHost.createPopup(null, depth);      const frontend = new Frontend(popup, ignoreNodes);      await frontend.prepare();  } diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index 2286bf19..67045241 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiGetZoom, apiOptionsGet, apiTermsFind, apiKanjiFind, docSentenceExtract, TextScanner*/  class Frontend extends TextScanner {      constructor(popup, ignoreNodes) { @@ -55,7 +56,7 @@ class Frontend extends TextScanner {              }              yomichan.on('orphaned', () => this.onOrphaned()); -            yomichan.on('optionsUpdate', () => this.updateOptions()); +            yomichan.on('optionsUpdated', () => this.updateOptions());              yomichan.on('zoomChanged', (e) => this.onZoomChanged(e));              chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/popup-nested.js index 3f3c945e..3e5f5b80 100644 --- a/ext/fg/js/popup-nested.js +++ b/ext/fg/js/popup-nested.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiOptionsGet*/  let popupNestedInitialized = false; diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-proxy-host.js index 427172c6..e55801ff 100644 --- a/ext/fg/js/popup-proxy-host.js +++ b/ext/fg/js/popup-proxy-host.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiFrameInformationGet, FrontendApiReceiver, Popup*/  class PopupProxyHost {      constructor() { @@ -33,7 +34,7 @@ class PopupProxyHost {          if (typeof frameId !== 'number') { return; }          this._apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${frameId}`, new Map([ -            ['createNestedPopup', ({parentId}) => this._onApiCreateNestedPopup(parentId)], +            ['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)], @@ -46,14 +47,51 @@ class PopupProxyHost {          ]));      } -    createPopup(parentId, depth) { -        return this._createPopupInternal(parentId, depth).popup; +    getOrCreatePopup(id=null, parentId=null) { +        // Find by existing id +        if (id !== null) { +            const popup = this._popups.get(id); +            if (typeof popup !== 'undefined') { +                return popup; +            } +        } + +        // Find by existing parent id +        let parent = null; +        if (parentId !== null) { +            parent = this._popups.get(parentId); +            if (typeof parent !== 'undefined') { +                const popup = parent.child; +                if (popup !== null) { +                    return popup; +                } +            } else { +                parent = null; +            } +        } + +        // New unique id +        if (id === null) { +            id = this._nextId++; +        } + +        // Create new popup +        const depth = (parent !== null ? parent.depth + 1 : 0); +        const popup = new Popup(id, depth, this._frameIdPromise); +        if (parent !== null) { +            popup.setParent(parent); +        } +        this._popups.set(id, popup); +        return popup;      }      // Message handlers -    async _onApiCreateNestedPopup(parentId) { -        return this._createPopupInternal(parentId, 0).id; +    async _onApiGetOrCreatePopup(id, parentId) { +        const popup = this.getOrCreatePopup(id, parentId); +        return { +            id: popup.id +        };      }      async _onApiSetOptions(id, options) { @@ -105,25 +143,10 @@ class PopupProxyHost {      // Private functions -    _createPopupInternal(parentId, depth) { -        const parent = (typeof parentId === 'string' && this._popups.has(parentId) ? this._popups.get(parentId) : null); -        const id = `${this._nextId}`; -        if (parent !== null) { -            depth = parent.depth + 1; -        } -        ++this._nextId; -        const popup = new Popup(id, depth, this._frameIdPromise); -        if (parent !== null) { -            popup.setParent(parent); -        } -        this._popups.set(id, popup); -        return {popup, id}; -    } -      _getPopup(id) {          const popup = this._popups.get(id);          if (typeof popup === 'undefined') { -            throw new Error('Invalid popup ID'); +            throw new Error(`Invalid popup ID ${id}`);          }          return popup;      } diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js index 4cacee53..093cdd2e 100644 --- a/ext/fg/js/popup-proxy.js +++ b/ext/fg/js/popup-proxy.js @@ -16,12 +16,13 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global FrontendApiSender*/  class PopupProxy { -    constructor(depth, parentId, parentFrameId, url) { +    constructor(id, depth, parentId, parentFrameId, url) {          this._parentId = parentId;          this._parentFrameId = parentFrameId; -        this._id = null; +        this._id = id;          this._idPromise = null;          this._depth = depth;          this._url = url; @@ -69,7 +70,7 @@ class PopupProxy {          if (this._id === null) {              return;          } -        this._invokeHostApi('setVisibleOverride', {id, visible}); +        this._invokeHostApi('setVisibleOverride', {id: this._id, visible});      }      async containsPoint(x, y) { @@ -112,7 +113,7 @@ class PopupProxy {      }      async _getPopupIdAsync() { -        const id = await this._invokeHostApi('createNestedPopup', {parentId: this._parentId}); +        const {id} = await this._invokeHostApi('getOrCreatePopup', {id: this._id, parentId: this._parentId});          this._id = id;          return id;      } diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index e7dae93e..4927f4bd 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiInjectStylesheet, apiGetMessageToken*/  class Popup {      constructor(id, depth, frameIdPromise) { @@ -27,32 +28,40 @@ class Popup {          this._child = null;          this._childrenSupported = true;          this._injectPromise = null; -        this._isInjected = false; -        this._isInjectedAndLoaded = false;          this._visible = false;          this._visibleOverride = null;          this._options = null; -        this._stylesheetInjectedViaApi = false;          this._contentScale = 1.0;          this._containerSizeContentScale = null; +        this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); +        this._messageToken = null;          this._container = document.createElement('iframe');          this._container.className = 'yomichan-float';          this._container.addEventListener('mousedown', (e) => e.stopPropagation());          this._container.addEventListener('scroll', (e) => e.stopPropagation()); -        this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html'));          this._container.style.width = '0px';          this._container.style.height = '0px'; +        this._fullscreenEventListeners = new EventListenerCollection(); +          this._updateVisibility();      }      // Public properties +    get id() { +        return this._id; +    } +      get parent() {          return this._parent;      } +    get child() { +        return this._child; +    } +      get depth() {          return this._depth;      } @@ -117,16 +126,12 @@ class Popup {      }      clearAutoPlayTimer() { -        if (this._isInjectedAndLoaded) { -            this._invokeApi('clearAutoPlayTimer'); -        } +        this._invokeApi('clearAutoPlayTimer');      }      setContentScale(scale) {          this._contentScale = scale; -        if (this._isInjectedAndLoaded) { -            this._invokeApi('setContentScale', {scale}); -        } +        this._invokeApi('setContentScale', {scale});      }      // Popup-only public functions @@ -146,7 +151,7 @@ class Popup {      }      isVisibleSync() { -        return this._isInjected && (this._visibleOverride !== null ? this._visibleOverride : this._visible); +        return (this._visibleOverride !== null ? this._visibleOverride : this._visible);      }      updateTheme() { @@ -154,21 +159,13 @@ class Popup {          this._container.dataset.yomichanSiteColor = this._getSiteColor();      } -    async setCustomOuterCss(css, injectDirectly) { -        // Cannot repeatedly inject stylesheets using web extension APIs since there is no way to remove them. -        if (this._stylesheetInjectedViaApi) { return; } - -        if (injectDirectly || Popup._isOnExtensionPage()) { -            Popup.injectOuterStylesheet(css); -        } else { -            if (!css) { return; } -            try { -                await apiInjectStylesheet(css); -                this._stylesheetInjectedViaApi = true; -            } catch (e) { -                // NOP -            } -        } +    async setCustomOuterCss(css, useWebExtensionApi) { +        return await Popup._injectStylesheet( +            'yomichan-popup-outer-user-stylesheet', +            'code', +            css, +            useWebExtensionApi +        );      }      setChildrenSupported(value) { @@ -183,26 +180,6 @@ class Popup {          return this._container.getBoundingClientRect();      } -    static injectOuterStylesheet(css) { -        if (Popup.outerStylesheet === null) { -            if (!css) { return; } -            Popup.outerStylesheet = document.createElement('style'); -            Popup.outerStylesheet.id = 'yomichan-popup-outer-stylesheet'; -        } - -        const outerStylesheet = Popup.outerStylesheet; -        if (css) { -            outerStylesheet.textContent = css; - -            const par = document.head; -            if (par && outerStylesheet.parentNode !== par) { -                par.appendChild(outerStylesheet); -            } -        } else { -            outerStylesheet.textContent = ''; -        } -    } -      // Private functions      _inject() { @@ -222,11 +199,18 @@ class Popup {              // NOP          } +        if (this._messageToken === null) { +            this._messageToken = await apiGetMessageToken(); +        } +          return new Promise((resolve) => {              const parentFrameId = (typeof this._frameId === 'number' ? this._frameId : null); +            this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html'));              this._container.addEventListener('load', () => { -                this._isInjectedAndLoaded = true; -                this._invokeApi('initialize', { +                const uniqueId = yomichan.generateId(32); +                Popup._listenForDisplayPrepareCompleted(uniqueId, resolve); + +                this._invokeApi('prepare', {                      options: this._options,                      popupInfo: {                          id: this._id, @@ -235,17 +219,60 @@ class Popup {                      },                      url: this.url,                      childrenSupported: this._childrenSupported, -                    scale: this._contentScale +                    scale: this._contentScale, +                    uniqueId                  }); -                resolve();              }); -            this._observeFullscreen(); +            this._observeFullscreen(true);              this._onFullscreenChanged(); -            this.setCustomOuterCss(this._options.general.customPopupOuterCss, false); -            this._isInjected = true; +            this._injectStyles();          });      } +    async _injectStyles() { +        try { +            await Popup._injectStylesheet('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true); +        } catch (e) { +            // NOP +        } + +        try { +            await this.setCustomOuterCss(this._options.general.customPopupOuterCss, true); +        } catch (e) { +            // NOP +        } +    } + +    _observeFullscreen(observe) { +        if (!observe) { +            this._fullscreenEventListeners.removeAllEventListeners(); +            return; +        } + +        if (this._fullscreenEventListeners.size > 0) { +            // Already observing +            return; +        } + +        const fullscreenEvents = [ +            'fullscreenchange', +            'MSFullscreenChange', +            'mozfullscreenchange', +            'webkitfullscreenchange' +        ]; +        const onFullscreenChanged = () => this._onFullscreenChanged(); +        for (const eventName of fullscreenEvents) { +            this._fullscreenEventListeners.addEventListener(document, eventName, onFullscreenChanged, false); +        } +    } + +    _onFullscreenChanged() { +        const parent = (Popup._getFullscreenElement() || document.body || null); +        if (parent !== null && this._container.parentNode !== parent) { +            parent.appendChild(this._container); +        } +    } +      async _show(elementRect, writingMode) {          await this._inject(); @@ -327,38 +354,38 @@ class Popup {      }      _invokeApi(action, params={}) { -        if (!this._isInjectedAndLoaded) { -            throw new Error('Frame not loaded'); -        } -        this._container.contentWindow.postMessage({action, params}, '*'); -    } +        const token = this._messageToken; +        const contentWindow = this._container.contentWindow; +        if (token === null || contentWindow === null) { return; } -    _observeFullscreen() { -        const fullscreenEvents = [ -            'fullscreenchange', -            'MSFullscreenChange', -            'mozfullscreenchange', -            'webkitfullscreenchange' -        ]; -        for (const eventName of fullscreenEvents) { -            document.addEventListener(eventName, () => this._onFullscreenChanged(), false); -        } +        contentWindow.postMessage({action, params, token}, this._targetOrigin);      } -    _getFullscreenElement() { +    static _getFullscreenElement() {          return (              document.fullscreenElement ||              document.msFullscreenElement ||              document.mozFullScreenElement || -            document.webkitFullscreenElement +            document.webkitFullscreenElement || +            null          );      } -    _onFullscreenChanged() { -        const parent = (this._getFullscreenElement() || document.body || null); -        if (parent !== null && this._container.parentNode !== parent) { -            parent.appendChild(this._container); -        } +    static _listenForDisplayPrepareCompleted(uniqueId, resolve) { +        const runtimeMessageCallback = ({action, params}, sender, callback) => { +            if ( +                action === 'popupPrepareCompleted' && +                typeof params === 'object' && +                params !== null && +                params.uniqueId === uniqueId +            ) { +                chrome.runtime.onMessage.removeListener(runtimeMessageCallback); +                callback(); +                resolve(); +                return false; +            } +        }; +        chrome.runtime.onMessage.addListener(runtimeMessageCallback);      }      static _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) { @@ -492,15 +519,6 @@ class Popup {          ];      } -    static _isOnExtensionPage() { -        try { -            const url = chrome.runtime.getURL('/'); -            return window.location.href.substring(0, url.length) === url; -        } catch (e) { -            // NOP -        } -    } -      static _getViewport(useVisualViewport) {          const visualViewport = window.visualViewport;          if (visualViewport !== null && typeof visualViewport === 'object') { @@ -533,6 +551,80 @@ class Popup {              bottom: window.innerHeight          };      } + +    static _isOnExtensionPage() { +        try { +            const url = chrome.runtime.getURL('/'); +            return window.location.href.substring(0, url.length) === url; +        } catch (e) { +            // NOP +        } +    } + +    static async _injectStylesheet(id, type, value, useWebExtensionApi) { +        const injectedStylesheets = Popup._injectedStylesheets; + +        if (Popup._isOnExtensionPage()) { +            // Permissions error will occur if trying to use the WebExtension API to inject +            // into an extension page. +            useWebExtensionApi = false; +        } + +        let styleNode = injectedStylesheets.get(id); +        if (typeof styleNode !== 'undefined') { +            if (styleNode === null) { +                // Previously injected via WebExtension API +                throw new Error(`Stylesheet with id ${id} has already been injected using the WebExtension API`); +            } +        } else { +            styleNode = null; +        } + +        if (useWebExtensionApi) { +            // Inject via WebExtension API +            if (styleNode !== null && styleNode.parentNode !== null) { +                styleNode.parentNode.removeChild(styleNode); +            } + +            await apiInjectStylesheet(type, value); + +            injectedStylesheets.set(id, null); +            return null; +        } + +        // Create node in document +        const parentNode = document.head; +        if (parentNode === null) { +            throw new Error('No parent node'); +        } + +        // Create or reuse node +        const isFile = (type === 'file'); +        const tagName = isFile ? 'link' : 'style'; +        if (styleNode === null || styleNode.nodeName.toLowerCase() !== tagName) { +            if (styleNode !== null && styleNode.parentNode !== null) { +                styleNode.parentNode.removeChild(styleNode); +            } +            styleNode = document.createElement(tagName); +            styleNode.id = id; +        } + +        // Update node style +        if (isFile) { +            styleNode.rel = value; +        } else { +            styleNode.textContent = value; +        } + +        // Update parent +        if (styleNode.parentNode !== parentNode) { +            parentNode.appendChild(styleNode); +        } + +        // Add to map +        injectedStylesheets.set(id, styleNode); +        return styleNode; +    }  } -Popup.outerStylesheet = null; +Popup._injectedStylesheets = new Map(); diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js index 11d3ff0e..6dc482bd 100644 --- a/ext/fg/js/source.js +++ b/ext/fg/js/source.js @@ -82,7 +82,11 @@ class TextSourceRange {      }      equals(other) { -        if (other === null) { +        if (!( +            typeof other === 'object' && +            other !== null && +            other instanceof TextSourceRange +        )) {              return false;          }          if (this.imposterSourceElement !== null) { @@ -362,7 +366,7 @@ class TextSourceElement {      setEndOffset(length) {          switch (this.element.nodeName.toUpperCase()) {              case 'BUTTON': -                this.content = this.element.innerHTML; +                this.content = this.element.textContent;                  break;              case 'IMG':                  this.content = this.element.getAttribute('alt'); @@ -409,6 +413,12 @@ class TextSourceElement {      }      equals(other) { -        return other && other.element === this.element && other.content === this.content; +        return ( +            typeof other === 'object' && +            other !== null && +            other instanceof TextSourceElement && +            other.element === this.element && +            other.content === this.content +        );      }  } diff --git a/ext/manifest.json b/ext/manifest.json index 31729992..fd9b6fec 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -1,7 +1,7 @@  {      "manifest_version": 2,      "name": "Yomichan (testing)", -    "version": "20.1.26.0", +    "version": "20.2.24.0",      "description": "Japanese dictionary with Anki integration (testing)",      "icons": {"16": "mixed/img/icon16.png", "48": "mixed/img/icon48.png", "128": "mixed/img/icon128.png"}, @@ -30,7 +30,7 @@              "fg/js/frontend.js",              "fg/js/frontend-initialize.js"          ], -        "css": ["fg/css/client.css"], +        "match_about_blank": true,          "all_frames": true      }],      "minimum_chrome_version": "57.0.0.0", diff --git a/ext/mixed/css/display-dark.css b/ext/mixed/css/display-dark.css index 088fc741..c9cd9f90 100644 --- a/ext/mixed/css/display-dark.css +++ b/ext/mixed/css/display-dark.css @@ -38,6 +38,7 @@ body { background-color: #1e1e1e; color: #d4d4d4; }  .tag[data-category=dictionary]   { background-color: #9057ad; }  .tag[data-category=frequency]    { background-color: #489148; }  .tag[data-category=partOfSpeech] { background-color: #565656; } +.tag[data-category=search]       { background-color: #69696e; }  .term-reasons { color: #888888; } diff --git a/ext/mixed/css/display-default.css b/ext/mixed/css/display-default.css index 69141c9d..6eee43c4 100644 --- a/ext/mixed/css/display-default.css +++ b/ext/mixed/css/display-default.css @@ -38,6 +38,7 @@ body { background-color: #ffffff; color: #333333; }  .tag[data-category=dictionary]   { background-color: #aa66cc; }  .tag[data-category=frequency]    { background-color: #5cb85c; }  .tag[data-category=partOfSpeech] { background-color: #565656; } +.tag[data-category=search]       { background-color: #8a8a91; }  .term-reasons { color: #777777; } diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css index add2583e..6a5383bc 100644 --- a/ext/mixed/css/display.css +++ b/ext/mixed/css/display.css @@ -127,15 +127,19 @@ html:root[data-yomichan-page=float] .navigation-header:not([hidden])~.navigation      user-select: none;  } -#query-parser { +#query-parser-content {      margin-top: 0.5em;      font-size: 2em;  } -#query-parser[data-term-spacing=true] .query-parser-term { +#query-parser-content[data-term-spacing=true] .query-parser-term {      margin-right: 0.2em;  } +html:root[data-yomichan-page=search][data-search-mode=popup] .search-input { +    display: none; +} +  /*   * Entries @@ -208,19 +212,27 @@ button.action-button {  }  .tag { -    display: inline; +    display: inline-block;      padding: 0.2em 0.6em 0.3em;      font-size: 75%;      font-weight: 700; -    line-height: 1; +    line-height: 1.25;      text-align: center;      white-space: nowrap;      vertical-align: baseline;      border-radius: 0.25em;  } -.tag-list>.tag+.tag { -    margin-left: 0.375em; +.tag-inner { +    display: block; +} + +.tag-list>.tag:not(:last-child) { +    margin-right: 0.375em; +} + +html:root:not([data-enable-search-tags=true]) .tag[data-category=search] { +    display: none;  }  .entry-header2, @@ -237,7 +249,7 @@ button.action-button {      border-top-style: solid;  } -.entry[data-type=term][data-expression-multi=true]:not([data-expression-count="1"]) .actions>.action-play-audio { +.entry[data-type=term][data-expression-multi=true] .actions>.action-play-audio {      display: none;  } @@ -245,8 +257,9 @@ button.action-button {      display: inline-block;  } -.term-reasons>.term-reason+.term-reason:before { +.term-reasons>.term-reason+.term-reason-separator+.term-reason:before {      content: " \00AB  "; /* The two spaces is not a typo */ +    white-space: pre-wrap;      display: inline;  } @@ -284,13 +297,13 @@ button.action-button {      content: "\3001";  } -.term-expression-list>.term-expression:last-of-type:not(:first-of-type):after { +.term-expression-list[data-multi=true]>.term-expression:last-of-type:after {      font-size: 2em;      content: "\3000";      visibility: hidden;  } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression-details { +.term-expression-list[data-multi=true] .term-expression-details {      display: inline-block;      position: relative;      width: 0; @@ -298,21 +311,21 @@ button.action-button {      visibility: hidden;  } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression:hover .term-expression-details { +.term-expression-list[data-multi=true] .term-expression:hover .term-expression-details {      visibility: visible;  } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression-details>.action-play-audio { +.term-expression-list[data-multi=true] .term-expression-details>.action-play-audio {      position: absolute;      left: 0;      bottom: 0.5em;  } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression-details>.action-play-audio { +.term-expression-list[data-multi=true] .term-expression-details>.action-play-audio {      display: block;  } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression-details>.tags { +.term-expression-list[data-multi=true] .term-expression-details>.tags {      display: block;      position: absolute;      left: 0; @@ -320,7 +333,7 @@ button.action-button {      white-space: nowrap;  } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression-details>.frequencies { +.term-expression-list[data-multi=true] .term-expression-details>.frequencies {      display: block;      position: absolute;      left: 0; @@ -385,7 +398,7 @@ button.action-button {  :root[data-compact-glossaries=true] .term-definition-tag-list,  :root[data-compact-glossaries=true] .term-definition-only-list:not([data-count="0"]) { -    display: inline-block; +    display: inline;  }  :root[data-compact-glossaries=true] .term-glossary-list { @@ -399,9 +412,24 @@ button.action-button {  }  :root[data-compact-glossaries=true] .term-glossary-list>li:not(:first-child):before { +    white-space: pre-wrap;      content: " | "; +    display: inline;  } +.term-glossary-separator, +.term-reason-separator { +    display: inline; +    font-size: 0; +    opacity: 0; +    white-space: pre-wrap; +} + +.term-special-tags>.frequencies { +    display: inline; +} + +  /*   * Kanji   */ diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html index 62f3c69c..7ae51a62 100644 --- a/ext/mixed/display-templates.html +++ b/ext/mixed/display-templates.html @@ -15,7 +15,7 @@              </div>              <div class="term-reasons"></div>          </div> -        <div class="frequencies"></div> +        <div class="term-special-tags"><div class="frequencies tag-list"></div></div>      </div>      <div class="term-definition-container"><ol class="term-definition-list"></ol></div>      <pre class="debug-info"></pre> @@ -31,8 +31,8 @@      <ul class="term-glossary-list"></ul>  </li></template>  <template id="term-definition-only-template"><span class="term-definition-only"></span></template> -<template id="term-glossary-item-template"><li class="term-glossary-item"><span class="term-glossary"></span></li></template> -<template id="term-reason-template"><span class="term-reason"></span></template> +<template id="term-glossary-item-template"><li class="term-glossary-item"><span class="term-glossary-separator"> </span><span class="term-glossary"></span></li></template> +<template id="term-reason-template"><span class="term-reason"></span><span class="term-reason-separator"> </span></template>  <template id="kanji-entry-template"><div class="entry" data-type="kanji">      <div class="entry-header1"> @@ -75,7 +75,8 @@  <template id="kanji-glossary-item-template"><li class="kanji-glossary-item"><span class="kanji-glossary"></span></li></template>  <template id="kanji-reading-template"><dd class="kanji-reading"></dd></template> -<template id="tag-template"><span class="tag"></span></template> -<template id="tag-frequency-template"><span class="tag" data-category="frequency"><span class="term-frequency-dictionary-name"></span><span class="term-frequency-separator"></span><span class="term-frequency-value"></span></template> +<template id="tag-template"><span class="tag"><span class="tag-inner"></span></span></template> +<template id="tag-frequency-template"><span class="tag" data-category="frequency"><span class="tag-inner"><span class="term-frequency-dictionary-name"></span><span class="term-frequency-separator"></span><span class="term-frequency-value"></span></span></template> +<template id="tag-search-template"><span class="tag" data-category="search"></span></template>  </body></html> diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index 5ec93b01..7ea68d59 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -58,15 +58,15 @@ function apiDefinitionAdd(definition, mode, context, optionsContext) {  }  function apiDefinitionsAddable(definitions, modes, optionsContext) { -    return _apiInvoke('definitionsAddable', {definitions, modes, optionsContext}).catch(() => null); +    return _apiInvoke('definitionsAddable', {definitions, modes, optionsContext});  }  function apiNoteView(noteId) {      return _apiInvoke('noteView', {noteId});  } -function apiTemplateRender(template, data, dynamic) { -    return _apiInvoke('templateRender', {data, template, dynamic}); +function apiTemplateRender(template, data) { +    return _apiInvoke('templateRender', {data, template});  }  function apiAudioGetUrl(definition, source, optionsContext) { @@ -89,8 +89,8 @@ function apiFrameInformationGet() {      return _apiInvoke('frameInformationGet');  } -function apiInjectStylesheet(css) { -    return _apiInvoke('injectStylesheet', {css}); +function apiInjectStylesheet(type, value) { +    return _apiInvoke('injectStylesheet', {type, value});  }  function apiGetEnvironmentInfo() { @@ -105,10 +105,18 @@ function apiGetDisplayTemplatesHtml() {      return _apiInvoke('getDisplayTemplatesHtml');  } +function apiGetQueryParserTemplatesHtml() { +    return _apiInvoke('getQueryParserTemplatesHtml'); +} +  function apiGetZoom() {      return _apiInvoke('getZoom');  } +function apiGetMessageToken() { +    return _apiInvoke('getMessageToken'); +} +  function _apiInvoke(action, params={}) {      const data = {action, params};      return new Promise((resolve, reject) => { diff --git a/ext/mixed/js/audio.js b/ext/mixed/js/audio.js index b0c5fa82..b5a025be 100644 --- a/ext/mixed/js/audio.js +++ b/ext/mixed/js/audio.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global apiAudioGetUrl*/  class TextToSpeechAudio {      constructor(text, voice) { @@ -53,7 +54,6 @@ class TextToSpeechAudio {              speechSynthesis.cancel();              speechSynthesis.speak(this._utterance); -          } catch (e) {              // NOP          } @@ -71,21 +71,16 @@ class TextToSpeechAudio {          const m = /^tts:[^#?]*\?([^#]*)/.exec(ttsUri);          if (m === null) { return null; } -        const searchParameters = {}; -        for (const group of m[1].split('&')) { -            const sep = group.indexOf('='); -            if (sep < 0) { continue; } -            searchParameters[decodeURIComponent(group.substring(0, sep))] = decodeURIComponent(group.substring(sep + 1)); -        } - -        if (!searchParameters.text) { 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; } -        const voice = audioGetTextToSpeechVoice(searchParameters.voice); +        voice = audioGetTextToSpeechVoice(voice);          if (voice === null) { return null; } -        return new TextToSpeechAudio(searchParameters.text, voice); +        return new TextToSpeechAudio(text, voice);      } -  }  function audioGetFromUrl(url, willDownload) { @@ -113,8 +108,11 @@ function audioGetFromUrl(url, willDownload) {  async function audioGetFromSources(expression, sources, optionsContext, willDownload, cache=null) {      const key = `${expression.expression}:${expression.reading}`; -    if (cache !== null && hasOwn(cache, expression)) { -        return cache[key]; +    if (cache !== null) { +        const cacheValue = cache.get(expression); +        if (typeof cacheValue !== 'undefined') { +            return cacheValue; +        }      }      for (let i = 0, ii = sources.length; i < ii; ++i) { @@ -132,7 +130,7 @@ async function audioGetFromSources(expression, sources, optionsContext, willDown              }              const result = {audio, url, source};              if (cache !== null) { -                cache[key] = result; +                cache.set(key, result);              }              return result;          } catch (e) { diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index 0142d594..83813796 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -113,11 +113,7 @@ function toIterable(value) {      if (value !== null && typeof value === 'object') {          const length = value.length;          if (typeof length === 'number' && Number.isFinite(length)) { -            const array = []; -            for (let i = 0; i < length; ++i) { -                array.push(value[i]); -            } -            return array; +            return Array.from(value);          }      } @@ -128,6 +124,14 @@ function stringReverse(string) {      return string.split('').reverse().join('').replace(/([\uDC00-\uDFFF])([\uD800-\uDBFF])/g, '$2$1');  } +function parseUrl(url) { +    const parsedUrl = new URL(url); +    const baseUrl = `${parsedUrl.origin}${parsedUrl.pathname}`; +    const queryParams = Array.from(parsedUrl.searchParams.entries()) +        .reduce((a, [k, v]) => Object.assign({}, a, {[k]: v}), {}); +    return {baseUrl, queryParams}; +} +  /*   * Async utilities @@ -156,9 +160,9 @@ function promiseTimeout(delay, resolveValue) {      const resolve = (value) => complete(promiseResolve, value);      const reject = (value) => complete(promiseReject, value); -    const promise = new Promise((resolve, reject) => { -        promiseResolve = resolve; -        promiseReject = reject; +    const promise = new Promise((resolve2, reject2) => { +        promiseResolve = resolve2; +        promiseReject = reject2;      });      timer = window.setTimeout(() => {          timer = null; @@ -232,6 +236,29 @@ class EventDispatcher {      }  } +class EventListenerCollection { +    constructor() { +        this._eventListeners = []; +    } + +    get size() { +        return this._eventListeners.length; +    } + +    addEventListener(node, type, listener, options) { +        node.addEventListener(type, listener, options); +        this._eventListeners.push([node, type, listener, options]); +    } + +    removeAllEventListeners() { +        if (this._eventListeners.length === 0) { return; } +        for (const [node, type, listener, options] of this._eventListeners) { +            node.removeEventListener(type, listener, options); +        } +        this._eventListeners = []; +    } +} +  /*   * Default message handlers @@ -244,7 +271,7 @@ const yomichan = (() => {              this._messageHandlers = new Map([                  ['getUrl', this._onMessageGetUrl.bind(this)], -                ['optionsUpdate', this._onMessageOptionsUpdate.bind(this)], +                ['optionsUpdated', this._onMessageOptionsUpdated.bind(this)],                  ['zoomChanged', this._onMessageZoomChanged.bind(this)]              ]); @@ -253,6 +280,16 @@ const yomichan = (() => {          // Public +        generateId(length) { +            const array = new Uint8Array(length); +            window.crypto.getRandomValues(array); +            let id = ''; +            for (const value of array) { +                id += value.toString(16).padStart(2, '0'); +            } +            return id; +        } +          triggerOrphaned(error) {              this.trigger('orphaned', {error});          } @@ -272,8 +309,8 @@ const yomichan = (() => {              return {url: window.location.href};          } -        _onMessageOptionsUpdate({source}) { -            this.trigger('optionsUpdate', {source}); +        _onMessageOptionsUpdated({source}) { +            this.trigger('optionsUpdated', {source});          }          _onMessageZoomChanged({oldZoomFactor, newZoomFactor}) { diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js index e1710488..d7e77cc0 100644 --- a/ext/mixed/js/display-generator.js +++ b/ext/mixed/js/display-generator.js @@ -16,46 +16,20 @@   * along with this program.  If not, see <http://www.gnu.org/licenses/>.   */ +/*global apiGetDisplayTemplatesHtml, TemplateHandler*/  class DisplayGenerator {      constructor() { -        this._isInitialized = false; -        this._initializationPromise = null; - -        this._termEntryTemplate = null; -        this._termExpressionTemplate = null; -        this._termDefinitionItemTemplate = null; -        this._termDefinitionOnlyTemplate = null; -        this._termGlossaryItemTemplate = null; -        this._termReasonTemplate = null; - -        this._kanjiEntryTemplate = null; -        this._kanjiInfoTableTemplate = null; -        this._kanjiInfoTableItemTemplate = null; -        this._kanjiInfoTableEmptyTemplate = null; -        this._kanjiGlossaryItemTemplate = null; -        this._kanjiReadingTemplate = null; - -        this._tagTemplate = null; -        this._tagFrequencyTemplate = null; +        this._templateHandler = null;      } -    isInitialized() { -        return this._isInitialized; -    } - -    initialize() { -        if (this._isInitialized) { -            return Promise.resolve(); -        } -        if (this._initializationPromise === null) { -            this._initializationPromise = this._initializeInternal(); -        } -        return this._initializationPromise; +    async prepare() { +        const html = await apiGetDisplayTemplatesHtml(); +        this._templateHandler = new TemplateHandler(html);      }      createTermEntry(details) { -        const node = DisplayGenerator._instantiateTemplate(this._termEntryTemplate); +        const node = this._templateHandler.instantiate('term-entry');          const expressionsContainer = node.querySelector('.term-expression-list');          const reasonsContainer = node.querySelector('.term-reasons'); @@ -71,7 +45,11 @@ class DisplayGenerator {          node.dataset.expressionCount = `${expressionMulti ? details.expressions.length : 1}`;          node.dataset.definitionCount = `${definitionMulti ? details.definitions.length : 1}`; -        DisplayGenerator._appendMultiple(expressionsContainer, this.createTermExpression.bind(this), details.expressions, [details]); +        const termTags = details.termTags; +        let expressions = details.expressions; +        expressions = Array.isArray(expressions) ? expressions.map((e) => [e, termTags]) : null; + +        DisplayGenerator._appendMultiple(expressionsContainer, this.createTermExpression.bind(this), expressions, [[details, termTags]]);          DisplayGenerator._appendMultiple(reasonsContainer, this.createTermReason.bind(this), details.reasons);          DisplayGenerator._appendMultiple(frequenciesContainer, this.createFrequencyTag.bind(this), details.frequencies);          DisplayGenerator._appendMultiple(definitionsContainer, this.createTermDefinitionItem.bind(this), details.definitions, [details]); @@ -83,8 +61,8 @@ class DisplayGenerator {          return node;      } -    createTermExpression(details) { -        const node = DisplayGenerator._instantiateTemplate(this._termExpressionTemplate); +    createTermExpression([details, termTags]) { +        const node = this._templateHandler.instantiate('term-expression');          const expressionContainer = node.querySelector('.term-expression-text');          const tagContainer = node.querySelector('.tags'); @@ -103,21 +81,30 @@ class DisplayGenerator {              DisplayGenerator._appendFurigana(expressionContainer, furiganaSegments, this._appendKanjiLinks.bind(this));          } -        DisplayGenerator._appendMultiple(tagContainer, this.createTag.bind(this), details.termTags); +        if (!Array.isArray(termTags)) { +            // Fallback +            termTags = details.termTags; +        } +        const searchQueries = [details.expression, details.reading] +            .filter((x) => !!x) +            .map((x) => ({query: x})); +        DisplayGenerator._appendMultiple(tagContainer, this.createTag.bind(this), termTags); +        DisplayGenerator._appendMultiple(tagContainer, this.createSearchTag.bind(this), searchQueries);          DisplayGenerator._appendMultiple(frequencyContainer, this.createFrequencyTag.bind(this), details.frequencies);          return node;      }      createTermReason(reason) { -        const node = DisplayGenerator._instantiateTemplate(this._termReasonTemplate); +        const fragment = this._templateHandler.instantiateFragment('term-reason'); +        const node = fragment.querySelector('.term-reason');          node.textContent = reason;          node.dataset.reason = reason; -        return node; +        return fragment;      }      createTermDefinitionItem(details) { -        const node = DisplayGenerator._instantiateTemplate(this._termDefinitionItemTemplate); +        const node = this._templateHandler.instantiate('term-definition-item');          const tagListContainer = node.querySelector('.term-definition-tag-list');          const onlyListContainer = node.querySelector('.term-definition-only-list'); @@ -133,7 +120,7 @@ class DisplayGenerator {      }      createTermGlossaryItem(glossary) { -        const node = DisplayGenerator._instantiateTemplate(this._termGlossaryItemTemplate); +        const node = this._templateHandler.instantiate('term-glossary-item');          const container = node.querySelector('.term-glossary');          if (container !== null) {              DisplayGenerator._appendMultilineText(container, glossary); @@ -142,7 +129,7 @@ class DisplayGenerator {      }      createTermOnly(only) { -        const node = DisplayGenerator._instantiateTemplate(this._termDefinitionOnlyTemplate); +        const node = this._templateHandler.instantiate('term-definition-only');          node.dataset.only = only;          node.textContent = only;          return node; @@ -157,7 +144,7 @@ class DisplayGenerator {      }      createKanjiEntry(details) { -        const node = DisplayGenerator._instantiateTemplate(this._kanjiEntryTemplate); +        const node = this._templateHandler.instantiate('kanji-entry');          const glyphContainer = node.querySelector('.kanji-glyph');          const frequenciesContainer = node.querySelector('.frequencies'); @@ -202,7 +189,7 @@ class DisplayGenerator {      }      createKanjiGlossaryItem(glossary) { -        const node = DisplayGenerator._instantiateTemplate(this._kanjiGlossaryItemTemplate); +        const node = this._templateHandler.instantiate('kanji-glossary-item');          const container = node.querySelector('.kanji-glossary');          if (container !== null) {              DisplayGenerator._appendMultilineText(container, glossary); @@ -211,13 +198,13 @@ class DisplayGenerator {      }      createKanjiReading(reading) { -        const node = DisplayGenerator._instantiateTemplate(this._kanjiReadingTemplate); +        const node = this._templateHandler.instantiate('kanji-reading');          node.textContent = reading;          return node;      }      createKanjiInfoTable(details) { -        const node = DisplayGenerator._instantiateTemplate(this._kanjiInfoTableTemplate); +        const node = this._templateHandler.instantiate('kanji-info-table');          const container = node.querySelector('.kanji-info-table-body'); @@ -233,7 +220,7 @@ class DisplayGenerator {      }      createKanjiInfoTableItem(details) { -        const node = DisplayGenerator._instantiateTemplate(this._kanjiInfoTableItemTemplate); +        const node = this._templateHandler.instantiate('kanji-info-table-item');          const nameNode = node.querySelector('.kanji-info-table-item-header');          const valueNode = node.querySelector('.kanji-info-table-item-value');          if (nameNode !== null) { @@ -246,21 +233,33 @@ class DisplayGenerator {      }      createKanjiInfoTableItemEmpty() { -        return DisplayGenerator._instantiateTemplate(this._kanjiInfoTableEmptyTemplate); +        return this._templateHandler.instantiate('kanji-info-table-empty');      }      createTag(details) { -        const node = DisplayGenerator._instantiateTemplate(this._tagTemplate); +        const node = this._templateHandler.instantiate('tag'); + +        const inner = node.querySelector('.tag-inner');          node.title = details.notes; -        node.textContent = details.name; +        inner.textContent = details.name;          node.dataset.category = details.category;          return node;      } +    createSearchTag(details) { +        const node = this._templateHandler.instantiate('tag-search'); + +        node.textContent = details.query; + +        node.dataset.query = details.query; + +        return node; +    } +      createFrequencyTag(details) { -        const node = DisplayGenerator._instantiateTemplate(this._tagFrequencyTemplate); +        const node = this._templateHandler.instantiate('tag-frequency');          let n = node.querySelector('.term-frequency-dictionary-name');          if (n !== null) { @@ -278,31 +277,6 @@ class DisplayGenerator {          return node;      } -    async _initializeInternal() { -        const html = await apiGetDisplayTemplatesHtml(); -        const doc = new DOMParser().parseFromString(html, 'text/html'); -        this._setTemplates(doc); -    } - -    _setTemplates(doc) { -        this._termEntryTemplate = doc.querySelector('#term-entry-template'); -        this._termExpressionTemplate = doc.querySelector('#term-expression-template'); -        this._termDefinitionItemTemplate = doc.querySelector('#term-definition-item-template'); -        this._termDefinitionOnlyTemplate = doc.querySelector('#term-definition-only-template'); -        this._termGlossaryItemTemplate = doc.querySelector('#term-glossary-item-template'); -        this._termReasonTemplate = doc.querySelector('#term-reason-template'); - -        this._kanjiEntryTemplate = doc.querySelector('#kanji-entry-template'); -        this._kanjiInfoTableTemplate = doc.querySelector('#kanji-info-table-template'); -        this._kanjiInfoTableItemTemplate = doc.querySelector('#kanji-info-table-item-template'); -        this._kanjiInfoTableEmptyTemplate = doc.querySelector('#kanji-info-table-empty-template'); -        this._kanjiGlossaryItemTemplate = doc.querySelector('#kanji-glossary-item-template'); -        this._kanjiReadingTemplate = doc.querySelector('#kanji-reading-template'); - -        this._tagTemplate = doc.querySelector('#tag-template'); -        this._tagFrequencyTemplate = doc.querySelector('#tag-frequency-template'); -    } -      _appendKanjiLinks(container, text) {          let part = '';          for (const c of text) { @@ -372,8 +346,4 @@ class DisplayGenerator {              container.appendChild(document.createTextNode(parts[i]));          }      } - -    static _instantiateTemplate(template) { -        return document.importNode(template.content.firstChild, true); -    }  } diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index c4be02f2..5d3076ee 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -16,6 +16,11 @@   * 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*/  class Display {      constructor(spinner, container) { @@ -27,11 +32,11 @@ class Display {          this.index = 0;          this.audioPlaying = null;          this.audioFallback = null; -        this.audioCache = {}; +        this.audioCache = new Map();          this.styleNode = null; -        this.eventListeners = []; -        this.persistentEventListeners = []; +        this.eventListeners = new EventListenerCollection(); +        this.persistentEventListeners = new EventListenerCollection();          this.interactive = false;          this.eventListenersActive = false;          this.clickScanPrevent = false; @@ -43,6 +48,13 @@ class Display {          this.setInteractive(true);      } +    async prepare(options=null) { +        const displayGeneratorPromise = this.displayGenerator.prepare(); +        const updateOptionsPromise = this.updateOptions(options); +        await Promise.all([displayGeneratorPromise, updateOptionsPromise]); +        yomichan.on('optionsUpdated', () => this.updateOptions(null)); +    } +      onError(_error) {          throw new Error('Override me');      } @@ -174,15 +186,24 @@ class Display {          e.preventDefault();          const link = e.currentTarget;          const entry = link.closest('.entry'); -        const definitionIndex = this.entryIndexFind(entry); +        const index = this.entryIndexFind(entry); +        if (index < 0 || index >= this.definitions.length) { return; } +          const expressionIndex = Display.indexOf(entry.querySelectorAll('.term-expression .action-play-audio'), link); -        this.audioPlay(this.definitions[definitionIndex], expressionIndex, definitionIndex); +        this.audioPlay( +            this.definitions[index], +            // expressionIndex is used in audioPlay to detect result output mode +            Math.max(expressionIndex, this.options.general.resultOutputMode === 'merge' ? 0 : -1), +            index +        );      }      onNoteAdd(e) {          e.preventDefault();          const link = e.currentTarget;          const index = this.entryIndexFind(link); +        if (index < 0 || index >= this.definitions.length) { return; } +          this.noteAdd(this.definitions[index], link.dataset.mode);      } @@ -216,13 +237,16 @@ class Display {      }      onHistoryWheel(e) { +        if (e.altKey) { return; }          const delta = -e.deltaX || e.deltaY;          if (delta > 0) {              this.sourceTermView();              e.preventDefault(); +            e.stopPropagation();          } else if (delta < 0) {              this.nextTermView();              e.preventDefault(); +            e.stopPropagation();          }      } @@ -230,15 +254,6 @@ class Display {          throw new Error('Override me');      } -    isInitialized() { -        return this.options !== null; -    } - -    async initialize(options=null) { -        await this.updateOptions(options); -        yomichan.on('optionsUpdate', () => this.updateOptions(null)); -    } -      async updateOptions(options) {          this.options = options ? options : await apiOptionsGet(this.getOptionsContext());          this.updateDocumentOptions(this.options); @@ -252,6 +267,7 @@ class Display {          data.ankiEnabled = `${options.anki.enable}`;          data.audioEnabled = `${options.audio.enable}`;          data.compactGlossaries = `${options.general.compactGlossaries}`; +        data.enableSearchTags = `${options.scanning.enableSearchTags}`;          data.debug = `${options.general.debugInfo}`;      } @@ -285,13 +301,24 @@ class Display {          this.interactive = interactive;          if (interactive) { -            Display.addEventListener(this.persistentEventListeners, document, 'keydown', this.onKeyDown.bind(this), false); -            Display.addEventListener(this.persistentEventListeners, document, 'wheel', this.onWheel.bind(this), {passive: false}); -            Display.addEventListener(this.persistentEventListeners, document.querySelector('.action-previous'), 'click', this.onSourceTermView.bind(this)); -            Display.addEventListener(this.persistentEventListeners, document.querySelector('.action-next'), 'click', this.onNextTermView.bind(this)); -            Display.addEventListener(this.persistentEventListeners, document.querySelector('.navigation-header'), 'wheel', this.onHistoryWheel.bind(this), {passive: false}); +            const actionPrevious = document.querySelector('.action-previous'); +            const actionNext = document.querySelector('.action-next'); +            // const navigationHeader = document.querySelector('.navigation-header'); + +            this.persistentEventListeners.addEventListener(document, 'keydown', this.onKeyDown.bind(this), false); +            this.persistentEventListeners.addEventListener(document, 'wheel', this.onWheel.bind(this), {passive: false}); +            if (actionPrevious !== null) { +                this.persistentEventListeners.addEventListener(actionPrevious, 'click', this.onSourceTermView.bind(this)); +            } +            if (actionNext !== null) { +                this.persistentEventListeners.addEventListener(actionNext, 'click', this.onNextTermView.bind(this)); +            } +            // temporarily disabled +            // if (navigationHeader !== null) { +            //     this.persistentEventListeners.addEventListener(navigationHeader, 'wheel', this.onHistoryWheel.bind(this), {passive: false}); +            // }          } else { -            Display.clearEventListeners(this.persistentEventListeners); +            this.persistentEventListeners.removeAllEventListeners();          }          this.setEventListenersActive(this.eventListenersActive);      } @@ -302,23 +329,23 @@ class Display {          this.eventListenersActive = active;          if (active) { -            this.addEventListeners('.action-add-note', 'click', this.onNoteAdd.bind(this)); -            this.addEventListeners('.action-view-note', 'click', this.onNoteView.bind(this)); -            this.addEventListeners('.action-play-audio', 'click', this.onAudioPlay.bind(this)); -            this.addEventListeners('.kanji-link', 'click', this.onKanjiLookup.bind(this)); +            this.addMultipleEventListeners('.action-add-note', 'click', this.onNoteAdd.bind(this)); +            this.addMultipleEventListeners('.action-view-note', 'click', this.onNoteView.bind(this)); +            this.addMultipleEventListeners('.action-play-audio', 'click', this.onAudioPlay.bind(this)); +            this.addMultipleEventListeners('.kanji-link', 'click', this.onKanjiLookup.bind(this));              if (this.options.scanning.enablePopupSearch) { -                this.addEventListeners('.glossary-item', 'mouseup', this.onGlossaryMouseUp.bind(this)); -                this.addEventListeners('.glossary-item', 'mousedown', this.onGlossaryMouseDown.bind(this)); -                this.addEventListeners('.glossary-item', 'mousemove', this.onGlossaryMouseMove.bind(this)); +                this.addMultipleEventListeners('.term-glossary-item, .tag', 'mouseup', this.onGlossaryMouseUp.bind(this)); +                this.addMultipleEventListeners('.term-glossary-item, .tag', 'mousedown', this.onGlossaryMouseDown.bind(this)); +                this.addMultipleEventListeners('.term-glossary-item, .tag', 'mousemove', this.onGlossaryMouseMove.bind(this));              }          } else { -            Display.clearEventListeners(this.eventListeners); +            this.eventListeners.removeAllEventListeners();          }      } -    addEventListeners(selector, type, listener, options) { +    addMultipleEventListeners(selector, type, listener, options) {          for (const node of this.container.querySelectorAll(selector)) { -            Display.addEventListener(this.eventListeners, node, type, listener, options); +            this.eventListeners.addEventListener(node, type, listener, options);          }      } @@ -348,7 +375,6 @@ class Display {      async setContentTerms(definitions, context, token) {          if (!context) { throw new Error('Context expected'); } -        if (!this.isInitialized()) { return; }          this.setEventListenersActive(false); @@ -356,11 +382,6 @@ class Display {              window.focus();          } -        if (!this.displayGenerator.isInitialized()) { -            await this.displayGenerator.initialize(); -            if (this.setContentToken !== token) { return; } -        } -          this.definitions = definitions;          if (context.disableHistory) {              delete context.disableHistory; @@ -404,7 +425,7 @@ class Display {          this.setEventListenersActive(true); -        const states = await apiDefinitionsAddable(definitions, ['term-kanji', 'term-kana'], this.getOptionsContext()); +        const states = await this.getDefinitionsAddable(definitions, ['term-kanji', 'term-kana']);          if (this.setContentToken !== token) { return; }          this.updateAdderButtons(states); @@ -412,7 +433,6 @@ class Display {      async setContentKanji(definitions, context, token) {          if (!context) { throw new Error('Context expected'); } -        if (!this.isInitialized()) { return; }          this.setEventListenersActive(false); @@ -420,11 +440,6 @@ class Display {              window.focus();          } -        if (!this.displayGenerator.isInitialized()) { -            await this.displayGenerator.initialize(); -            if (this.setContentToken !== token) { return; } -        } -          this.definitions = definitions;          if (context.disableHistory) {              delete context.disableHistory; @@ -446,7 +461,7 @@ class Display {          for (let i = 0, ii = definitions.length; i < ii; ++i) {              if (i > 0) { -                await promiseTimeout(0); +                await promiseTimeout(1);                  if (this.setContentToken !== token) { return; }              } @@ -459,7 +474,7 @@ class Display {          this.setEventListenersActive(true); -        const states = await apiDefinitionsAddable(definitions, ['kanji'], this.getOptionsContext()); +        const states = await this.getDefinitionsAddable(definitions, ['kanji']);          if (this.setContentToken !== token) { return; }          this.updateAdderButtons(states); @@ -498,6 +513,8 @@ class Display {      }      autoPlayAudio() { +        if (this.definitions.length === 0) { return; } +          this.audioPlay(this.definitions[0], this.firstExpressionIndex, 0);      } @@ -597,9 +614,12 @@ class Display {      }      noteTryAdd(mode) { -        const button = this.adderButtonFind(this.index, mode); +        const index = this.index; +        if (index < 0 || index >= this.definitions.length) { return; } + +        const button = this.adderButtonFind(index, mode);          if (button !== null && !button.classList.contains('disabled')) { -            this.noteAdd(this.definitions[this.index], mode); +            this.noteAdd(this.definitions[index], mode);          }      } @@ -698,7 +718,7 @@ class Display {      async getScreenshot() {          try {              await this.setPopupVisibleOverride(false); -            await Display.delay(1); // Wait for popup to be hidden. +            await promiseTimeout(1); // Wait for popup to be hidden.              const {format, quality} = this.options.anki.screenshot;              const dataUrl = await apiScreenshotGet({format, quality}); @@ -767,8 +787,12 @@ class Display {          return entry !== null ? entry.querySelector('.action-play-audio>img') : null;      } -    static delay(time) { -        return new Promise((resolve) => setTimeout(resolve, time)); +    async getDefinitionsAddable(definitions, modes) { +        try { +            return await apiDefinitionsAddable(definitions, modes, this.getOptionsContext()); +        } catch (e) { +            return []; +        }      }      static indexOf(nodeList, node) { @@ -780,19 +804,6 @@ class Display {          return -1;      } -    static addEventListener(eventListeners, object, type, listener, options) { -        if (object === null) { return; } -        object.addEventListener(type, listener, options); -        eventListeners.push([object, type, listener, options]); -    } - -    static clearEventListeners(eventListeners) { -        for (const [object, type, listener, options] of eventListeners) { -            object.removeEventListener(type, listener, options); -        } -        eventListeners.length = 0; -    } -      static getElementTop(element) {          const elementRect = element.getBoundingClientRect();          const documentRect = document.documentElement.getBoundingClientRect(); @@ -901,9 +912,12 @@ Display._onKeyDownHandlers = new Map([      ['P', (self, e) => {          if (e.altKey) { -            const entry = self.getEntry(self.index); +            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[self.index], self.firstExpressionIndex, self.index); +                self.audioPlay(self.definitions[index], self.firstExpressionIndex, index);              }              return true;          } diff --git a/ext/mixed/js/template-handler.js b/ext/mixed/js/template-handler.js new file mode 100644 index 00000000..a5a62937 --- /dev/null +++ b/ext/mixed/js/template-handler.js @@ -0,0 +1,47 @@ +/* + * 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 <http://www.gnu.org/licenses/>. + */ + + +class TemplateHandler { +    constructor(html) { +        this._templates = new Map(); + +        const doc = new DOMParser().parseFromString(html, 'text/html'); +        for (const template of doc.querySelectorAll('template')) { +            this._setTemplate(template); +        } +    } + +    _setTemplate(template) { +        const idMatch = template.id.match(/^([a-z-]+)-template$/); +        if (!idMatch) { +            throw new Error(`Invalid template ID: ${template.id}`); +        } +        this._templates.set(idMatch[1], template); +    } + +    instantiate(name) { +        const template = this._templates.get(name); +        return document.importNode(template.content.firstChild, true); +    } + +    instantiateFragment(name) { +        const template = this._templates.get(name); +        return document.importNode(template.content, true); +    } +} diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index 88f1e27a..ff0eac8b 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -16,6 +16,7 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +/*global docRangeFromPoint, TextSourceRange, DOM*/  class TextScanner {      constructor(node, ignoreNodes, ignoreElements, ignorePoints) { @@ -30,7 +31,7 @@ class TextScanner {          this.options = null;          this.enabled = false; -        this.eventListeners = []; +        this.eventListeners = new EventListenerCollection();          this.primaryTouchIdentifier = null;          this.preventNextContextMenu = false; @@ -140,24 +141,24 @@ class TextScanner {          const textSourceCurrentPrevious = this.textSourceCurrent !== null ? this.textSourceCurrent.clone() : null;          this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchStart') -        .then(() => { -            if ( -                this.textSourceCurrent === null || -                this.textSourceCurrent.equals(textSourceCurrentPrevious) -            ) { -                return; -            } +            .then(() => { +                if ( +                    this.textSourceCurrent === null || +                    this.textSourceCurrent.equals(textSourceCurrentPrevious) +                ) { +                    return; +                } -            this.preventScroll = true; -            this.preventNextContextMenu = true; -            this.preventNextMouseDown = true; -        }); +                this.preventScroll = true; +                this.preventNextContextMenu = true; +                this.preventNextMouseDown = true; +            });      }      onTouchEnd(e) {          if (              this.primaryTouchIdentifier === null || -            TextScanner.getIndexOfTouch(e.changedTouches, this.primaryTouchIdentifier) < 0 +            TextScanner.getTouch(e.changedTouches, this.primaryTouchIdentifier) === null          ) {              return;          } @@ -180,13 +181,11 @@ class TextScanner {              return;          } -        const touches = e.changedTouches; -        const index = TextScanner.getIndexOfTouch(touches, this.primaryTouchIdentifier); -        if (index < 0) { +        const primaryTouch = TextScanner.getTouch(e.changedTouches, this.primaryTouchIdentifier); +        if (primaryTouch === null) {              return;          } -        const primaryTouch = touches[index];          this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchMove');          e.preventDefault(); // Disable scroll @@ -228,7 +227,7 @@ class TextScanner {              }          } else {              if (this.enabled) { -                this.clearEventListeners(); +                this.eventListeners.removeAllEventListeners();                  this.enabled = false;              }              this.onSearchClear(false); @@ -236,13 +235,13 @@ class TextScanner {      }      hookEvents() { -        let eventListeners = this.getMouseEventListeners(); +        let eventListenerInfos = this.getMouseEventListeners();          if (this.options.scanning.touchInputEnabled) { -            eventListeners = eventListeners.concat(this.getTouchEventListeners()); +            eventListenerInfos = eventListenerInfos.concat(this.getTouchEventListeners());          } -        for (const [node, type, listener, options] of eventListeners) { -            this.addEventListener(node, type, listener, options); +        for (const [node, type, listener, options] of eventListenerInfos) { +            this.eventListeners.addEventListener(node, type, listener, options);          }      } @@ -267,18 +266,6 @@ class TextScanner {          ];      } -    addEventListener(node, type, listener, options) { -        node.addEventListener(type, listener, options); -        this.eventListeners.push([node, type, listener, options]); -    } - -    clearEventListeners() { -        for (const [node, type, listener, options] of this.eventListeners) { -            node.removeEventListener(type, listener, options); -        } -        this.eventListeners = []; -    } -      setOptions(options) {          this.options = options;          this.setEnabled(this.options.general.enable); @@ -367,13 +354,12 @@ class TextScanner {          }      } -    static getIndexOfTouch(touchList, identifier) { -        for (const i in touchList) { -            const t = touchList[i]; -            if (t.identifier === identifier) { -                return i; +    static getTouch(touchList, identifier) { +        for (const touch of touchList) { +            if (touch.identifier === identifier) { +                return touch;              }          } -        return -1; +        return null;      }  } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..505c71db --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1119 @@ +{ +    "name": "yomichan", +    "version": "0.0.0", +    "lockfileVersion": 1, +    "requires": true, +    "dependencies": { +        "@babel/code-frame": { +            "version": "7.8.3", +            "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", +            "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", +            "dev": true, +            "requires": { +                "@babel/highlight": "^7.8.3" +            } +        }, +        "@babel/highlight": { +            "version": "7.8.3", +            "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.8.3.tgz", +            "integrity": "sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==", +            "dev": true, +            "requires": { +                "chalk": "^2.0.0", +                "esutils": "^2.0.2", +                "js-tokens": "^4.0.0" +            } +        }, +        "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-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 +        }, +        "ajv": { +            "version": "6.11.0", +            "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.11.0.tgz", +            "integrity": "sha512-nCprB/0syFYy9fVYU1ox1l2KN8S9I+tziH8D4zdZuLT3N6RMlGSGt5FSTpAiHB/Whv8Qs1cWHma1aMKZyaHRKA==", +            "dev": true, +            "requires": { +                "fast-deep-equal": "^3.1.1", +                "fast-json-stable-stringify": "^2.0.0", +                "json-schema-traverse": "^0.4.1", +                "uri-js": "^4.2.2" +            } +        }, +        "ansi-escapes": { +            "version": "4.3.0", +            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.0.tgz", +            "integrity": "sha512-EiYhwo0v255HUL6eDyuLrXEkTi7WwVCLAw+SeOQ7M7qdun1z1pum4DEm/nuqIVbPvi9RPPc9k9LbyBv6H0DwVg==", +            "dev": true, +            "requires": { +                "type-fest": "^0.8.1" +            } +        }, +        "ansi-regex": { +            "version": "5.0.0", +            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", +            "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", +            "dev": true +        }, +        "ansi-styles": { +            "version": "3.2.1", +            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", +            "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", +            "dev": true, +            "requires": { +                "color-convert": "^1.9.0" +            } +        }, +        "argparse": { +            "version": "1.0.10", +            "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", +            "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", +            "dev": true, +            "requires": { +                "sprintf-js": "~1.0.2" +            } +        }, +        "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 +        }, +        "balanced-match": { +            "version": "1.0.0", +            "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", +            "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", +            "dev": true +        }, +        "base64-arraybuffer-es6": { +            "version": "0.5.0", +            "resolved": "https://registry.npmjs.org/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.5.0.tgz", +            "integrity": "sha512-UCIPaDJrNNj5jG2ZL+nzJ7czvZV/ZYX6LaIRgfVU1k1edJOQg7dkbiSKzwHkNp6aHEHER/PhlFBrMYnlvJJQEw==", +            "dev": true +        }, +        "brace-expansion": { +            "version": "1.1.11", +            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", +            "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", +            "dev": true, +            "requires": { +                "balanced-match": "^1.0.0", +                "concat-map": "0.0.1" +            } +        }, +        "callsites": { +            "version": "3.1.0", +            "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", +            "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", +            "dev": true +        }, +        "chalk": { +            "version": "2.4.2", +            "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", +            "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", +            "dev": true, +            "requires": { +                "ansi-styles": "^3.2.1", +                "escape-string-regexp": "^1.0.5", +                "supports-color": "^5.3.0" +            } +        }, +        "chardet": { +            "version": "0.7.0", +            "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", +            "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", +            "dev": true +        }, +        "cli-cursor": { +            "version": "3.1.0", +            "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", +            "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", +            "dev": true, +            "requires": { +                "restore-cursor": "^3.1.0" +            } +        }, +        "cli-width": { +            "version": "2.2.0", +            "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", +            "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", +            "dev": true +        }, +        "color-convert": { +            "version": "1.9.3", +            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", +            "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", +            "dev": true, +            "requires": { +                "color-name": "1.1.3" +            } +        }, +        "color-name": { +            "version": "1.1.3", +            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", +            "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", +            "dev": true +        }, +        "concat-map": { +            "version": "0.0.1", +            "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", +            "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", +            "dev": true +        }, +        "core-js": { +            "version": "2.6.11", +            "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", +            "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==", +            "dev": true +        }, +        "cross-spawn": { +            "version": "6.0.5", +            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", +            "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", +            "dev": true, +            "requires": { +                "nice-try": "^1.0.4", +                "path-key": "^2.0.1", +                "semver": "^5.5.0", +                "shebang-command": "^1.2.0", +                "which": "^1.2.9" +            }, +            "dependencies": { +                "semver": { +                    "version": "5.7.1", +                    "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", +                    "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", +                    "dev": true +                } +            } +        }, +        "debug": { +            "version": "4.1.1", +            "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", +            "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", +            "dev": true, +            "requires": { +                "ms": "^2.1.1" +            } +        }, +        "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 +        }, +        "doctrine": { +            "version": "3.0.0", +            "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", +            "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", +            "dev": true, +            "requires": { +                "esutils": "^2.0.2" +            } +        }, +        "domexception": { +            "version": "1.0.1", +            "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", +            "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", +            "dev": true, +            "requires": { +                "webidl-conversions": "^4.0.2" +            } +        }, +        "emoji-regex": { +            "version": "8.0.0", +            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", +            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", +            "dev": true +        }, +        "escape-string-regexp": { +            "version": "1.0.5", +            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", +            "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", +            "dev": true +        }, +        "eslint": { +            "version": "6.8.0", +            "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.8.0.tgz", +            "integrity": "sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==", +            "dev": true, +            "requires": { +                "@babel/code-frame": "^7.0.0", +                "ajv": "^6.10.0", +                "chalk": "^2.1.0", +                "cross-spawn": "^6.0.5", +                "debug": "^4.0.1", +                "doctrine": "^3.0.0", +                "eslint-scope": "^5.0.0", +                "eslint-utils": "^1.4.3", +                "eslint-visitor-keys": "^1.1.0", +                "espree": "^6.1.2", +                "esquery": "^1.0.1", +                "esutils": "^2.0.2", +                "file-entry-cache": "^5.0.1", +                "functional-red-black-tree": "^1.0.1", +                "glob-parent": "^5.0.0", +                "globals": "^12.1.0", +                "ignore": "^4.0.6", +                "import-fresh": "^3.0.0", +                "imurmurhash": "^0.1.4", +                "inquirer": "^7.0.0", +                "is-glob": "^4.0.0", +                "js-yaml": "^3.13.1", +                "json-stable-stringify-without-jsonify": "^1.0.1", +                "levn": "^0.3.0", +                "lodash": "^4.17.14", +                "minimatch": "^3.0.4", +                "mkdirp": "^0.5.1", +                "natural-compare": "^1.4.0", +                "optionator": "^0.8.3", +                "progress": "^2.0.0", +                "regexpp": "^2.0.1", +                "semver": "^6.1.2", +                "strip-ansi": "^5.2.0", +                "strip-json-comments": "^3.0.1", +                "table": "^5.2.3", +                "text-table": "^0.2.0", +                "v8-compile-cache": "^2.0.3" +            } +        }, +        "eslint-plugin-no-unsanitized": { +            "version": "3.0.2", +            "resolved": "https://registry.npmjs.org/eslint-plugin-no-unsanitized/-/eslint-plugin-no-unsanitized-3.0.2.tgz", +            "integrity": "sha512-JnwpoH8Sv4QOjrTDutENBHzSnyYtspdjtglYtqUtAHe6f6LLKqykJle+UwFPg23GGwt5hI3amS9CRDezW8GAww==", +            "dev": true +        }, +        "eslint-scope": { +            "version": "5.0.0", +            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz", +            "integrity": "sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw==", +            "dev": true, +            "requires": { +                "esrecurse": "^4.1.0", +                "estraverse": "^4.1.1" +            } +        }, +        "eslint-utils": { +            "version": "1.4.3", +            "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", +            "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", +            "dev": true, +            "requires": { +                "eslint-visitor-keys": "^1.1.0" +            } +        }, +        "eslint-visitor-keys": { +            "version": "1.1.0", +            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz", +            "integrity": "sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==", +            "dev": true +        }, +        "espree": { +            "version": "6.1.2", +            "resolved": "https://registry.npmjs.org/espree/-/espree-6.1.2.tgz", +            "integrity": "sha512-2iUPuuPP+yW1PZaMSDM9eyVf8D5P0Hi8h83YtZ5bPc/zHYjII5khoixIUTMO794NOY8F/ThF1Bo8ncZILarUTA==", +            "dev": true, +            "requires": { +                "acorn": "^7.1.0", +                "acorn-jsx": "^5.1.0", +                "eslint-visitor-keys": "^1.1.0" +            } +        }, +        "esprima": { +            "version": "4.0.1", +            "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", +            "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", +            "dev": true +        }, +        "esquery": { +            "version": "1.0.1", +            "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz", +            "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==", +            "dev": true, +            "requires": { +                "estraverse": "^4.0.0" +            } +        }, +        "esrecurse": { +            "version": "4.2.1", +            "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", +            "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", +            "dev": true, +            "requires": { +                "estraverse": "^4.1.0" +            } +        }, +        "estraverse": { +            "version": "4.3.0", +            "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", +            "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", +            "dev": true +        }, +        "esutils": { +            "version": "2.0.3", +            "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", +            "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", +            "dev": true +        }, +        "external-editor": { +            "version": "3.1.0", +            "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", +            "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", +            "dev": true, +            "requires": { +                "chardet": "^0.7.0", +                "iconv-lite": "^0.4.24", +                "tmp": "^0.0.33" +            } +        }, +        "fake-indexeddb": { +            "version": "3.0.0", +            "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-3.0.0.tgz", +            "integrity": "sha512-VrnV9dJWlVWvd8hp9MMR+JS4RLC4ZmToSkuCg91ZwpYE5mSODb3n5VEaV62Hf3AusnbrPfwQhukU+rGZm5W8PQ==", +            "dev": true, +            "requires": { +                "realistic-structured-clone": "^2.0.1", +                "setimmediate": "^1.0.5" +            } +        }, +        "fast-deep-equal": { +            "version": "3.1.1", +            "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", +            "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==", +            "dev": true +        }, +        "fast-json-stable-stringify": { +            "version": "2.1.0", +            "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", +            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", +            "dev": true +        }, +        "fast-levenshtein": { +            "version": "2.0.6", +            "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", +            "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", +            "dev": true +        }, +        "figures": { +            "version": "3.1.0", +            "resolved": "https://registry.npmjs.org/figures/-/figures-3.1.0.tgz", +            "integrity": "sha512-ravh8VRXqHuMvZt/d8GblBeqDMkdJMBdv/2KntFH+ra5MXkO7nxNKpzQ3n6QD/2da1kH0aWmNISdvhM7gl2gVg==", +            "dev": true, +            "requires": { +                "escape-string-regexp": "^1.0.5" +            } +        }, +        "file-entry-cache": { +            "version": "5.0.1", +            "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", +            "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", +            "dev": true, +            "requires": { +                "flat-cache": "^2.0.1" +            } +        }, +        "flat-cache": { +            "version": "2.0.1", +            "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", +            "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", +            "dev": true, +            "requires": { +                "flatted": "^2.0.0", +                "rimraf": "2.6.3", +                "write": "1.0.3" +            } +        }, +        "flatted": { +            "version": "2.0.1", +            "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.1.tgz", +            "integrity": "sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==", +            "dev": true +        }, +        "fs.realpath": { +            "version": "1.0.0", +            "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", +            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", +            "dev": true +        }, +        "functional-red-black-tree": { +            "version": "1.0.1", +            "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", +            "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", +            "dev": true +        }, +        "glob": { +            "version": "7.1.6", +            "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", +            "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", +            "dev": true, +            "requires": { +                "fs.realpath": "^1.0.0", +                "inflight": "^1.0.4", +                "inherits": "2", +                "minimatch": "^3.0.4", +                "once": "^1.3.0", +                "path-is-absolute": "^1.0.0" +            } +        }, +        "glob-parent": { +            "version": "5.1.0", +            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz", +            "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==", +            "dev": true, +            "requires": { +                "is-glob": "^4.0.1" +            } +        }, +        "globals": { +            "version": "12.3.0", +            "resolved": "https://registry.npmjs.org/globals/-/globals-12.3.0.tgz", +            "integrity": "sha512-wAfjdLgFsPZsklLJvOBUBmzYE8/CwhEqSBEMRXA3qxIiNtyqvjYurAtIfDh6chlEPUfmTY3MnZh5Hfh4q0UlIw==", +            "dev": true, +            "requires": { +                "type-fest": "^0.8.1" +            } +        }, +        "has-flag": { +            "version": "3.0.0", +            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", +            "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", +            "dev": true +        }, +        "iconv-lite": { +            "version": "0.4.24", +            "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", +            "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", +            "dev": true, +            "requires": { +                "safer-buffer": ">= 2.1.2 < 3" +            } +        }, +        "ignore": { +            "version": "4.0.6", +            "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", +            "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", +            "dev": true +        }, +        "import-fresh": { +            "version": "3.2.1", +            "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", +            "integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==", +            "dev": true, +            "requires": { +                "parent-module": "^1.0.0", +                "resolve-from": "^4.0.0" +            } +        }, +        "imurmurhash": { +            "version": "0.1.4", +            "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", +            "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", +            "dev": true +        }, +        "inflight": { +            "version": "1.0.6", +            "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", +            "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", +            "dev": true, +            "requires": { +                "once": "^1.3.0", +                "wrappy": "1" +            } +        }, +        "inherits": { +            "version": "2.0.4", +            "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", +            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", +            "dev": true +        }, +        "inquirer": { +            "version": "7.0.4", +            "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.0.4.tgz", +            "integrity": "sha512-Bu5Td5+j11sCkqfqmUTiwv+tWisMtP0L7Q8WrqA2C/BbBhy1YTdFrvjjlrKq8oagA/tLQBski2Gcx/Sqyi2qSQ==", +            "dev": true, +            "requires": { +                "ansi-escapes": "^4.2.1", +                "chalk": "^2.4.2", +                "cli-cursor": "^3.1.0", +                "cli-width": "^2.0.0", +                "external-editor": "^3.0.3", +                "figures": "^3.0.0", +                "lodash": "^4.17.15", +                "mute-stream": "0.0.8", +                "run-async": "^2.2.0", +                "rxjs": "^6.5.3", +                "string-width": "^4.1.0", +                "strip-ansi": "^5.1.0", +                "through": "^2.3.6" +            } +        }, +        "is-extglob": { +            "version": "2.1.1", +            "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", +            "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", +            "dev": true +        }, +        "is-fullwidth-code-point": { +            "version": "3.0.0", +            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", +            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", +            "dev": true +        }, +        "is-glob": { +            "version": "4.0.1", +            "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", +            "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", +            "dev": true, +            "requires": { +                "is-extglob": "^2.1.1" +            } +        }, +        "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 +        }, +        "isexe": { +            "version": "2.0.0", +            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", +            "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", +            "dev": true +        }, +        "js-tokens": { +            "version": "4.0.0", +            "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", +            "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", +            "dev": true +        }, +        "js-yaml": { +            "version": "3.13.1", +            "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", +            "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", +            "dev": true, +            "requires": { +                "argparse": "^1.0.7", +                "esprima": "^4.0.0" +            } +        }, +        "json-schema-traverse": { +            "version": "0.4.1", +            "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", +            "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", +            "dev": true +        }, +        "json-stable-stringify-without-jsonify": { +            "version": "1.0.1", +            "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", +            "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", +            "dev": true +        }, +        "levn": { +            "version": "0.3.0", +            "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", +            "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", +            "dev": true, +            "requires": { +                "prelude-ls": "~1.1.2", +                "type-check": "~0.3.2" +            } +        }, +        "lodash": { +            "version": "4.17.15", +            "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", +            "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", +            "dev": true +        }, +        "lodash.sortby": { +            "version": "4.7.0", +            "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", +            "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", +            "dev": true +        }, +        "mimic-fn": { +            "version": "2.1.0", +            "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", +            "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", +            "dev": true +        }, +        "minimatch": { +            "version": "3.0.4", +            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", +            "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", +            "dev": true, +            "requires": { +                "brace-expansion": "^1.1.7" +            } +        }, +        "minimist": { +            "version": "0.0.8", +            "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", +            "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", +            "dev": true +        }, +        "mkdirp": { +            "version": "0.5.1", +            "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", +            "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", +            "dev": true, +            "requires": { +                "minimist": "0.0.8" +            } +        }, +        "ms": { +            "version": "2.1.2", +            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", +            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", +            "dev": true +        }, +        "mute-stream": { +            "version": "0.0.8", +            "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", +            "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", +            "dev": true +        }, +        "natural-compare": { +            "version": "1.4.0", +            "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", +            "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", +            "dev": true +        }, +        "nice-try": { +            "version": "1.0.5", +            "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", +            "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", +            "dev": true +        }, +        "once": { +            "version": "1.4.0", +            "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", +            "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", +            "dev": true, +            "requires": { +                "wrappy": "1" +            } +        }, +        "onetime": { +            "version": "5.1.0", +            "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", +            "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", +            "dev": true, +            "requires": { +                "mimic-fn": "^2.1.0" +            } +        }, +        "optionator": { +            "version": "0.8.3", +            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", +            "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", +            "dev": true, +            "requires": { +                "deep-is": "~0.1.3", +                "fast-levenshtein": "~2.0.6", +                "levn": "~0.3.0", +                "prelude-ls": "~1.1.2", +                "type-check": "~0.3.2", +                "word-wrap": "~1.2.3" +            } +        }, +        "os-tmpdir": { +            "version": "1.0.2", +            "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", +            "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", +            "dev": true +        }, +        "parent-module": { +            "version": "1.0.1", +            "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", +            "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", +            "dev": true, +            "requires": { +                "callsites": "^3.0.0" +            } +        }, +        "path-is-absolute": { +            "version": "1.0.1", +            "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", +            "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", +            "dev": true +        }, +        "path-key": { +            "version": "2.0.1", +            "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", +            "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", +            "dev": true +        }, +        "prelude-ls": { +            "version": "1.1.2", +            "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", +            "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", +            "dev": true +        }, +        "progress": { +            "version": "2.0.3", +            "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", +            "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", +            "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 +        }, +        "realistic-structured-clone": { +            "version": "2.0.2", +            "resolved": "https://registry.npmjs.org/realistic-structured-clone/-/realistic-structured-clone-2.0.2.tgz", +            "integrity": "sha512-5IEvyfuMJ4tjQOuKKTFNvd+H9GSbE87IcendSBannE28PTrbolgaVg5DdEApRKhtze794iXqVUFKV60GLCNKEg==", +            "dev": true, +            "requires": { +                "core-js": "^2.5.3", +                "domexception": "^1.0.1", +                "typeson": "^5.8.2", +                "typeson-registry": "^1.0.0-alpha.20" +            } +        }, +        "regexpp": { +            "version": "2.0.1", +            "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", +            "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", +            "dev": true +        }, +        "resolve-from": { +            "version": "4.0.0", +            "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", +            "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", +            "dev": true +        }, +        "restore-cursor": { +            "version": "3.1.0", +            "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", +            "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", +            "dev": true, +            "requires": { +                "onetime": "^5.1.0", +                "signal-exit": "^3.0.2" +            } +        }, +        "rimraf": { +            "version": "2.6.3", +            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", +            "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", +            "dev": true, +            "requires": { +                "glob": "^7.1.3" +            } +        }, +        "run-async": { +            "version": "2.3.0", +            "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", +            "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", +            "dev": true, +            "requires": { +                "is-promise": "^2.1.0" +            } +        }, +        "rxjs": { +            "version": "6.5.4", +            "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz", +            "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==", +            "dev": true, +            "requires": { +                "tslib": "^1.9.0" +            } +        }, +        "safer-buffer": { +            "version": "2.1.2", +            "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", +            "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", +            "dev": true +        }, +        "semver": { +            "version": "6.3.0", +            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", +            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", +            "dev": true +        }, +        "setimmediate": { +            "version": "1.0.5", +            "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", +            "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", +            "dev": true +        }, +        "shebang-command": { +            "version": "1.2.0", +            "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", +            "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", +            "dev": true, +            "requires": { +                "shebang-regex": "^1.0.0" +            } +        }, +        "shebang-regex": { +            "version": "1.0.0", +            "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", +            "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", +            "dev": true +        }, +        "signal-exit": { +            "version": "3.0.2", +            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", +            "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", +            "dev": true +        }, +        "slice-ansi": { +            "version": "2.1.0", +            "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", +            "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", +            "dev": true, +            "requires": { +                "ansi-styles": "^3.2.0", +                "astral-regex": "^1.0.0", +                "is-fullwidth-code-point": "^2.0.0" +            }, +            "dependencies": { +                "is-fullwidth-code-point": { +                    "version": "2.0.0", +                    "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", +                    "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", +                    "dev": 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 +        }, +        "string-width": { +            "version": "4.2.0", +            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", +            "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", +            "dev": true, +            "requires": { +                "emoji-regex": "^8.0.0", +                "is-fullwidth-code-point": "^3.0.0", +                "strip-ansi": "^6.0.0" +            }, +            "dependencies": { +                "strip-ansi": { +                    "version": "6.0.0", +                    "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", +                    "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", +                    "dev": true, +                    "requires": { +                        "ansi-regex": "^5.0.0" +                    } +                } +            } +        }, +        "strip-ansi": { +            "version": "5.2.0", +            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", +            "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", +            "dev": true, +            "requires": { +                "ansi-regex": "^4.1.0" +            }, +            "dependencies": { +                "ansi-regex": { +                    "version": "4.1.0", +                    "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", +                    "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", +                    "dev": true +                } +            } +        }, +        "strip-json-comments": { +            "version": "3.0.1", +            "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.0.1.tgz", +            "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", +            "dev": true +        }, +        "supports-color": { +            "version": "5.5.0", +            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", +            "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", +            "dev": true, +            "requires": { +                "has-flag": "^3.0.0" +            } +        }, +        "table": { +            "version": "5.4.6", +            "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", +            "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==", +            "dev": true, +            "requires": { +                "ajv": "^6.10.2", +                "lodash": "^4.17.14", +                "slice-ansi": "^2.1.0", +                "string-width": "^3.0.0" +            }, +            "dependencies": { +                "emoji-regex": { +                    "version": "7.0.3", +                    "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", +                    "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", +                    "dev": true +                }, +                "is-fullwidth-code-point": { +                    "version": "2.0.0", +                    "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", +                    "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", +                    "dev": true +                }, +                "string-width": { +                    "version": "3.1.0", +                    "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", +                    "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", +                    "dev": true, +                    "requires": { +                        "emoji-regex": "^7.0.1", +                        "is-fullwidth-code-point": "^2.0.0", +                        "strip-ansi": "^5.1.0" +                    } +                } +            } +        }, +        "text-table": { +            "version": "0.2.0", +            "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", +            "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", +            "dev": true +        }, +        "through": { +            "version": "2.3.8", +            "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", +            "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", +            "dev": true +        }, +        "tmp": { +            "version": "0.0.33", +            "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", +            "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", +            "dev": true, +            "requires": { +                "os-tmpdir": "~1.0.2" +            } +        }, +        "tr46": { +            "version": "1.0.1", +            "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", +            "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", +            "dev": true, +            "requires": { +                "punycode": "^2.1.0" +            } +        }, +        "tslib": { +            "version": "1.10.0", +            "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", +            "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==", +            "dev": true +        }, +        "type-check": { +            "version": "0.3.2", +            "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", +            "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", +            "dev": true, +            "requires": { +                "prelude-ls": "~1.1.2" +            } +        }, +        "type-fest": { +            "version": "0.8.1", +            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", +            "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", +            "dev": true +        }, +        "typeson": { +            "version": "5.18.2", +            "resolved": "https://registry.npmjs.org/typeson/-/typeson-5.18.2.tgz", +            "integrity": "sha512-Vetd+OGX05P4qHyHiSLdHZ5Z5GuQDrHHwSdjkqho9NSCYVSLSfRMjklD/unpHH8tXBR9Z/R05rwJSuMpMFrdsw==", +            "dev": true +        }, +        "typeson-registry": { +            "version": "1.0.0-alpha.34", +            "resolved": "https://registry.npmjs.org/typeson-registry/-/typeson-registry-1.0.0-alpha.34.tgz", +            "integrity": "sha512-2U0R5eFGJPaqha8HBAICJv6rW2x/cAVHizURHbcAo61Mpd47s+MDn67Ktxoyl9jWgsqCAibZsrldG8v/2ZuCaw==", +            "dev": true, +            "requires": { +                "base64-arraybuffer-es6": "0.5.0", +                "typeson": "5.18.2", +                "whatwg-url": "7.1.0" +            } +        }, +        "uri-js": { +            "version": "4.2.2", +            "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", +            "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", +            "dev": true, +            "requires": { +                "punycode": "^2.1.0" +            } +        }, +        "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 +        }, +        "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-url": { +            "version": "7.1.0", +            "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", +            "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", +            "dev": true, +            "requires": { +                "lodash.sortby": "^4.7.0", +                "tr46": "^1.0.1", +                "webidl-conversions": "^4.0.2" +            } +        }, +        "which": { +            "version": "1.3.1", +            "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", +            "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", +            "dev": true, +            "requires": { +                "isexe": "^2.0.0" +            } +        }, +        "word-wrap": { +            "version": "1.2.3", +            "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", +            "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", +            "dev": true +        }, +        "wrappy": { +            "version": "1.0.2", +            "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", +            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", +            "dev": true +        }, +        "write": { +            "version": "1.0.3", +            "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", +            "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", +            "dev": true, +            "requires": { +                "mkdirp": "^0.5.1" +            } +        } +    } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..17fdfa82 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ +    "name": "yomichan", +    "version": "0.0.0", +    "description": "Japanese pop-up dictionary extension for Chrome and Firefox.", +    "directories": { +        "test": "test" +    }, +    "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" +    }, +    "repository": { +        "type": "git", +        "url": "git+https://github.com/FooSoft/yomichan.git" +    }, +    "author": "FooSoft", +    "license": "GPL-3.0-or-later", +    "licenses": [ +        { +            "type": "GPL-3.0-or-later", +            "url": "https://www.gnu.org/licenses/gpl-3.0.html" +        } +    ], +    "bugs": { +        "url": "https://github.com/FooSoft/yomichan/issues" +    }, +    "homepage": "https://foosoft.net/projects/yomichan/", +    "devDependencies": { +        "eslint": "^6.8.0", +        "eslint-plugin-no-unsanitized": "^3.0.2", +        "fake-indexeddb": "^3.0.0" +    } +} diff --git a/test/data/dictionaries/invalid-dictionary1/index.json b/test/data/dictionaries/invalid-dictionary1/index.json new file mode 100644 index 00000000..1be3b360 --- /dev/null +++ b/test/data/dictionaries/invalid-dictionary1/index.json @@ -0,0 +1,7 @@ +{ +    "title": "Invalid Dictionary 1", +    "format": 0, +    "revision": "test", +    "sequenced": true, +    "description": "Invalid format number" +}
\ No newline at end of file diff --git a/test/data/dictionaries/invalid-dictionary2/index.json b/test/data/dictionaries/invalid-dictionary2/index.json new file mode 100644 index 00000000..ba2cc669 --- /dev/null +++ b/test/data/dictionaries/invalid-dictionary2/index.json @@ -0,0 +1,7 @@ +{ +    "title": "Invalid Dictionary 2", +    "format": 3, +    "revision": "test", +    "sequenced": true, +    "description": "Empty entry in kanji bank" +}
\ No newline at end of file diff --git a/test/data/dictionaries/invalid-dictionary2/kanji_bank_1.json b/test/data/dictionaries/invalid-dictionary2/kanji_bank_1.json new file mode 100644 index 00000000..5825bcac --- /dev/null +++ b/test/data/dictionaries/invalid-dictionary2/kanji_bank_1.json @@ -0,0 +1,3 @@ +[ +    [] +]
\ No newline at end of file diff --git a/test/data/dictionaries/invalid-dictionary3/index.json b/test/data/dictionaries/invalid-dictionary3/index.json new file mode 100644 index 00000000..f23fa3f0 --- /dev/null +++ b/test/data/dictionaries/invalid-dictionary3/index.json @@ -0,0 +1,7 @@ +{ +    "title": "Invalid Dictionary 3", +    "format": 3, +    "revision": "test", +    "sequenced": true, +    "description": "Invalid type entry in kanji meta bank" +}
\ No newline at end of file diff --git a/test/data/dictionaries/invalid-dictionary3/kanji_meta_bank_1.json b/test/data/dictionaries/invalid-dictionary3/kanji_meta_bank_1.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/test/data/dictionaries/invalid-dictionary3/kanji_meta_bank_1.json @@ -0,0 +1 @@ +{}
\ No newline at end of file diff --git a/test/data/dictionaries/invalid-dictionary4/index.json b/test/data/dictionaries/invalid-dictionary4/index.json new file mode 100644 index 00000000..542791d7 --- /dev/null +++ b/test/data/dictionaries/invalid-dictionary4/index.json @@ -0,0 +1,7 @@ +{ +    "title": "Invalid Dictionary 4", +    "format": 3, +    "revision": "test", +    "sequenced": true, +    "description": "Invalid value as part of a tag bank entry" +}
\ No newline at end of file diff --git a/test/data/dictionaries/invalid-dictionary4/tag_bank_1.json b/test/data/dictionaries/invalid-dictionary4/tag_bank_1.json new file mode 100644 index 00000000..4f19b476 --- /dev/null +++ b/test/data/dictionaries/invalid-dictionary4/tag_bank_1.json @@ -0,0 +1,3 @@ +[ +    [{"invalid": true}, "category1", 0, "tag1 notes", 0] +]
\ No newline at end of file diff --git a/test/data/dictionaries/invalid-dictionary5/index.json b/test/data/dictionaries/invalid-dictionary5/index.json new file mode 100644 index 00000000..e0d0f00e --- /dev/null +++ b/test/data/dictionaries/invalid-dictionary5/index.json @@ -0,0 +1,7 @@ +{ +    "title": "Invalid Dictionary 5", +    "format": 3, +    "revision": "test", +    "sequenced": true, +    "description": "Invalid type as part of a term bank entry" +}
\ No newline at end of file diff --git a/test/data/dictionaries/invalid-dictionary5/term_bank_1.json b/test/data/dictionaries/invalid-dictionary5/term_bank_1.json new file mode 100644 index 00000000..7288a996 --- /dev/null +++ b/test/data/dictionaries/invalid-dictionary5/term_bank_1.json @@ -0,0 +1,3 @@ +[ +    ["打", "だ", "tag1 tag2", "", 2, false, 1, "tag3 tag4 tag5"] +]
\ No newline at end of file diff --git a/test/data/dictionaries/invalid-dictionary6/index.json b/test/data/dictionaries/invalid-dictionary6/index.json new file mode 100644 index 00000000..b91acca3 --- /dev/null +++ b/test/data/dictionaries/invalid-dictionary6/index.json @@ -0,0 +1,7 @@ +{ +    "title": "Invalid Dictionary 6", +    "format": 3, +    "revision": "test", +    "sequenced": true, +    "description": "Invalid root type for term meta bank" +}
\ No newline at end of file diff --git a/test/data/dictionaries/invalid-dictionary6/term_meta_bank_1.json b/test/data/dictionaries/invalid-dictionary6/term_meta_bank_1.json new file mode 100644 index 00000000..02e4a84d --- /dev/null +++ b/test/data/dictionaries/invalid-dictionary6/term_meta_bank_1.json @@ -0,0 +1 @@ +false
\ No newline at end of file diff --git a/test/data/dictionaries/valid-dictionary1/index.json b/test/data/dictionaries/valid-dictionary1/index.json new file mode 100644 index 00000000..3034bf38 --- /dev/null +++ b/test/data/dictionaries/valid-dictionary1/index.json @@ -0,0 +1,6 @@ +{ +    "title": "Test Dictionary", +    "format": 3, +    "revision": "test", +    "sequenced": true +}
\ No newline at end of file diff --git a/test/data/dictionaries/valid-dictionary1/kanji_bank_1.json b/test/data/dictionaries/valid-dictionary1/kanji_bank_1.json new file mode 100644 index 00000000..264f94c1 --- /dev/null +++ b/test/data/dictionaries/valid-dictionary1/kanji_bank_1.json @@ -0,0 +1,42 @@ +[ +    [ +        "打", +        "ダ ダアス", +        "う.つ う.ち- ぶ.つ", +        "ktag1 ktag2", +        [ +            "meaning1", +            "meaning2", +            "meaning3", +            "meaning4", +            "meaning5" +        ], +        { +            "kstat1": "1", +            "kstat2": "2", +            "kstat3": "3", +            "kstat4": "4", +            "kstat5": "5" +        } +    ], +    [ +        "込", +        "", +        "-こ.む こ.む こ.み -こ.み こ.める", +        "ktag1 ktag2", +        [ +            "meaning1", +            "meaning2", +            "meaning3", +            "meaning4", +            "meaning5" +        ], +        { +            "kstat1": "1", +            "kstat2": "2", +            "kstat3": "3", +            "kstat4": "4", +            "kstat5": "5" +        } +    ] +]
\ No newline at end of file diff --git a/test/data/dictionaries/valid-dictionary1/kanji_meta_bank_1.json b/test/data/dictionaries/valid-dictionary1/kanji_meta_bank_1.json new file mode 100644 index 00000000..73e75b8a --- /dev/null +++ b/test/data/dictionaries/valid-dictionary1/kanji_meta_bank_1.json @@ -0,0 +1,4 @@ +[ +    ["打", "freq", 1], +    ["込", "freq", 2] +]
\ No newline at end of file diff --git a/test/data/dictionaries/valid-dictionary1/tag_bank_1.json b/test/data/dictionaries/valid-dictionary1/tag_bank_1.json new file mode 100644 index 00000000..109ad395 --- /dev/null +++ b/test/data/dictionaries/valid-dictionary1/tag_bank_1.json @@ -0,0 +1,7 @@ +[ +    ["tag1", "category1", 0, "tag1 notes", 0], +    ["tag2", "category2", 0, "tag2 notes", 0], +    ["tag3", "category3", 0, "tag3 notes", 0], +    ["tag4", "category4", 0, "tag4 notes", 0], +    ["tag5", "category5", 0, "tag5 notes", 0] +]
\ No newline at end of file diff --git a/test/data/dictionaries/valid-dictionary1/tag_bank_2.json b/test/data/dictionaries/valid-dictionary1/tag_bank_2.json new file mode 100644 index 00000000..5e7936b3 --- /dev/null +++ b/test/data/dictionaries/valid-dictionary1/tag_bank_2.json @@ -0,0 +1,9 @@ +[ +    ["ktag1", "kcategory1", 0, "ktag1 notes", 0], +    ["ktag2", "kcategory2", 0, "ktag2 notes", 0], +    ["kstat1", "kcategory3", 0, "kstat1 notes", 0], +    ["kstat2", "kcategory4", 0, "kstat2 notes", 0], +    ["kstat3", "kcategory5", 0, "kstat3 notes", 0], +    ["kstat4", "kcategory6", 0, "kstat4 notes", 0], +    ["kstat5", "kcategory7", 0, "kstat5 notes", 0] +]
\ No newline at end of file diff --git a/test/data/dictionaries/valid-dictionary1/term_bank_1.json b/test/data/dictionaries/valid-dictionary1/term_bank_1.json new file mode 100644 index 00000000..755d9f6a --- /dev/null +++ b/test/data/dictionaries/valid-dictionary1/term_bank_1.json @@ -0,0 +1,34 @@ +[ +    ["打", "だ", "tag1 tag2", "", 2, ["definition1a (打, だ)", "definition1b (打, だ)"], 1, "tag3 tag4 tag5"], +    ["打", "ダース", "tag1 tag2", "", 1, ["definition1a (打, ダース)", "definition1b (打, ダース)"], 2, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 3, ["definition1a (打つ, うつ)", "definition1b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 4, ["definition2a (打つ, うつ)", "definition2b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 5, ["definition3a (打つ, うつ)", "definition3b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 6, ["definition4a (打つ, うつ)", "definition4b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 7, ["definition5a (打つ, うつ)", "definition5b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 8, ["definition6a (打つ, うつ)", "definition6b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 9, ["definition7a (打つ, うつ)", "definition7b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 10, ["definition8a (打つ, うつ)", "definition8b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 11, ["definition9a (打つ, うつ)", "definition9b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 12, ["definition10a (打つ, うつ)", "definition10b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 13, ["definition11a (打つ, うつ)", "definition11b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 14, ["definition12a (打つ, うつ)", "definition12b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 15, ["definition13a (打つ, うつ)", "definition13b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 16, ["definition14a (打つ, うつ)", "definition14b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "うつ", "tag1 tag2", "v5", 17, ["definition15a (打つ, うつ)", "definition15b (打つ, うつ)"], 3, "tag3 tag4 tag5"], +    ["打つ", "ぶつ", "tag1 tag2", "v5", 18, ["definition1a (打つ, ぶつ)", "definition1b (打つ, ぶつ)"], 4, "tag3 tag4 tag5"], +    ["打つ", "ぶつ", "tag1 tag2", "v5", 19, ["definition2a (打つ, ぶつ)", "definition2b (打つ, ぶつ)"], 4, "tag3 tag4 tag5"], +    ["打ち込む", "うちこむ", "tag1 tag2", "v5", 20, ["definition1a (打ち込む, うちこむ)", "definition1b (打ち込む, うちこむ)"], 5, "tag3 tag4 tag5"], +    ["打ち込む", "うちこむ", "tag1 tag2", "v5", 21, ["definition2a (打ち込む, うちこむ)", "definition2b (打ち込む, うちこむ)"], 5, "tag5 tag6 tag7"], +    ["打ち込む", "うちこむ", "tag1 tag2", "v5", 22, ["definition3a (打ち込む, うちこむ)", "definition3b (打ち込む, うちこむ)"], 5, "tag5 tag6 tag7"], +    ["打ち込む", "うちこむ", "tag1 tag2", "v5", 23, ["definition4a (打ち込む, うちこむ)", "definition4b (打ち込む, うちこむ)"], 5, "tag5 tag6 tag7"], +    ["打ち込む", "うちこむ", "tag1 tag2", "v5", 24, ["definition5a (打ち込む, うちこむ)", "definition5b (打ち込む, うちこむ)"], 5, "tag5 tag6 tag7"], +    ["打ち込む", "うちこむ", "tag1 tag2", "v5", 25, ["definition6a (打ち込む, うちこむ)", "definition6b (打ち込む, うちこむ)"], 5, "tag5 tag6 tag7"], +    ["打ち込む", "うちこむ", "tag1 tag2", "v5", 26, ["definition7a (打ち込む, うちこむ)", "definition7b (打ち込む, うちこむ)"], 5, "tag5 tag6 tag7"], +    ["打ち込む", "うちこむ", "tag1 tag2", "v5", 27, ["definition8a (打ち込む, うちこむ)", "definition8b (打ち込む, うちこむ)"], 5, "tag5 tag6 tag7"], +    ["打ち込む", "うちこむ", "tag1 tag2", "v5", 28, ["definition9a (打ち込む, うちこむ)", "definition9b (打ち込む, うちこむ)"], 5, "tag5 tag6 tag7"], +    ["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 29, ["definition1a (打ち込む, ぶちこむ)", "definition1b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"], +    ["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 30, ["definition2a (打ち込む, ぶちこむ)", "definition2b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"], +    ["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 31, ["definition3a (打ち込む, ぶちこむ)", "definition3b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"], +    ["打ち込む", "ぶちこむ", "tag1 tag2", "v5", 32, ["definition4a (打ち込む, ぶちこむ)", "definition4b (打ち込む, ぶちこむ)"], 6, "tag3 tag4 tag5"] +]
\ No newline at end of file diff --git a/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json b/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json new file mode 100644 index 00000000..78096502 --- /dev/null +++ b/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json @@ -0,0 +1,5 @@ +[ +    ["打", "freq", 1], +    ["打つ", "freq", 2], +    ["打ち込む", "freq", 3] +]
\ No newline at end of file diff --git a/test/dictionary-validate.js b/test/dictionary-validate.js new file mode 100644 index 00000000..14eee2ed --- /dev/null +++ b/test/dictionary-validate.js @@ -0,0 +1,113 @@ +/* + * 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 yomichanTest = require('./yomichan-test'); + +const JSZip = yomichanTest.JSZip; +const {JsonSchema} = yomichanTest.requireScript('ext/bg/js/json-schema.js', ['JsonSchema']); + + +function readSchema(relativeFileName) { +    const fileName = path.join(__dirname, relativeFileName); +    const source = fs.readFileSync(fileName, {encoding: 'utf8'}); +    return JSON.parse(source); +} + + +async function validateDictionaryBanks(zip, fileNameFormat, schema) { +    let index = 1; +    while (true) { +        const fileName = fileNameFormat.replace(/\?/, index); + +        const file = zip.files[fileName]; +        if (!file) { break; } + +        const data = JSON.parse(await file.async('string')); +        JsonSchema.validate(data, schema); + +        ++index; +    } +} + +async function validateDictionary(archive, schemas) { +    const indexFile = archive.files['index.json']; +    if (!indexFile) { +        throw new Error('No dictionary index found in archive'); +    } + +    const index = JSON.parse(await indexFile.async('string')); +    const version = index.format || index.version; + +    JsonSchema.validate(index, schemas.index); + +    await validateDictionaryBanks(archive, 'term_bank_?.json', version === 1 ? schemas.termBankV1 : schemas.termBankV3); +    await validateDictionaryBanks(archive, 'term_meta_bank_?.json', schemas.termMetaBankV3); +    await validateDictionaryBanks(archive, 'kanji_bank_?.json', version === 1 ? schemas.kanjiBankV1 : schemas.kanjiBankV3); +    await validateDictionaryBanks(archive, 'kanji_meta_bank_?.json', schemas.kanjiMetaBankV3); +    await validateDictionaryBanks(archive, 'tag_bank_?.json', schemas.tagBankV3); +} + +function getSchemas() { +    return { +        index: readSchema('../ext/bg/data/dictionary-index-schema.json'), +        kanjiBankV1: readSchema('../ext/bg/data/dictionary-kanji-bank-v1-schema.json'), +        kanjiBankV3: readSchema('../ext/bg/data/dictionary-kanji-bank-v3-schema.json'), +        kanjiMetaBankV3: readSchema('../ext/bg/data/dictionary-kanji-meta-bank-v3-schema.json'), +        tagBankV3: readSchema('../ext/bg/data/dictionary-tag-bank-v3-schema.json'), +        termBankV1: readSchema('../ext/bg/data/dictionary-term-bank-v1-schema.json'), +        termBankV3: readSchema('../ext/bg/data/dictionary-term-bank-v3-schema.json'), +        termMetaBankV3: readSchema('../ext/bg/data/dictionary-term-meta-bank-v3-schema.json') +    }; +} + + +async function main() { +    const dictionaryFileNames = process.argv.slice(2); +    if (dictionaryFileNames.length === 0) { +        console.log([ +            'Usage:', +            '  node dictionary-validate <dictionary-file-names>...' +        ].join('\n')); +        return; +    } + +    const schemas = getSchemas(); + +    for (const dictionaryFileName of dictionaryFileNames) { +        try { +            console.log(`Validating ${dictionaryFileName}...`); +            const source = fs.readFileSync(dictionaryFileName); +            const archive = await JSZip.loadAsync(source); +            await validateDictionary(archive, schemas); +            console.log('No issues found'); +        } catch (e) { +            console.warn(e); +        } +    } +} + + +if (require.main === module) { main(); } + + +module.exports = { +    getSchemas, +    validateDictionary +}; diff --git a/test/schema-validate.js b/test/schema-validate.js new file mode 100644 index 00000000..a4f2d94c --- /dev/null +++ b/test/schema-validate.js @@ -0,0 +1,52 @@ +/* + * 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 yomichanTest = require('./yomichan-test'); + +const {JsonSchema} = yomichanTest.requireScript('ext/bg/js/json-schema.js', ['JsonSchema']); + + +function main() { +    const args = process.argv.slice(2); +    if (args.length < 2) { +        console.log([ +            'Usage:', +            '  node schema-validate <schema-file-name> <data-file-names>...' +        ].join('\n')); +        return; +    } + +    const schemaSource = fs.readFileSync(args[0], {encoding: 'utf8'}); +    const schema = JSON.parse(schemaSource); + +    for (const dataFileName of args.slice(1)) { +        try { +            console.log(`Validating ${dataFileName}...`); +            const dataSource = fs.readFileSync(dataFileName, {encoding: 'utf8'}); +            const data = JSON.parse(dataSource); +            JsonSchema.validate(data, schema); +            console.log('No issues found'); +        } catch (e) { +            console.warn(e); +        } +    } +} + + +if (require.main === module) { main(); } diff --git a/test/test-database.js b/test/test-database.js new file mode 100644 index 00000000..c2317881 --- /dev/null +++ b/test/test-database.js @@ -0,0 +1,935 @@ +/* + * 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 url = require('url'); +const path = require('path'); +const assert = require('assert'); +const yomichanTest = require('./yomichan-test'); +require('fake-indexeddb/auto'); + +const chrome = { +    runtime: { +        onMessage: { +            addListener() { /* NOP */ } +        }, +        getURL(path2) { +            return url.pathToFileURL(path.join(__dirname, '..', 'ext', path2.replace(/^\//, ''))); +        } +    } +}; + +class XMLHttpRequest { +    constructor() { +        this._eventCallbacks = new Map(); +        this._url = ''; +        this._responseText = null; +    } + +    overrideMimeType() { +        // NOP +    } + +    addEventListener(eventName, callback) { +        let callbacks = this._eventCallbacks.get(eventName); +        if (typeof callbacks === 'undefined') { +            callbacks = []; +            this._eventCallbacks.set(eventName, callbacks); +        } +        callbacks.push(callback); +    } + +    open(action, url2) { +        this._url = url2; +    } + +    send() { +        const filePath = url.fileURLToPath(this._url); +        Promise.resolve() +            .then(() => { +                let source; +                try { +                    source = fs.readFileSync(filePath, {encoding: 'utf8'}); +                } catch (e) { +                    this._trigger('error'); +                    return; +                } +                this._responseText = source; +                this._trigger('load'); +            }); +    } + +    get responseText() { +        return this._responseText; +    } + +    _trigger(eventName, ...args) { +        const callbacks = this._eventCallbacks.get(eventName); +        if (typeof callbacks === 'undefined') { return; } + +        for (let i = 0, ii = callbacks.length; i < ii; ++i) { +            callbacks[i](...args); +        } +    } +} + +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 = { +    chrome, +    JsonSchema, +    requestJson, +    stringReverse, +    hasOwn, +    dictFieldSplit, +    dictTagSanitize, +    indexedDB: global.indexedDB, +    JSZip: yomichanTest.JSZip +}; +databaseGlobals.window = databaseGlobals; +const {Database} = yomichanTest.requireScript('ext/bg/js/database.js', ['Database'], databaseGlobals); + + +function countTermsWithExpression(terms, expression) { +    return terms.reduce((i, v) => (i + (v.expression === expression ? 1 : 0)), 0); +} + +function countTermsWithReading(terms, reading) { +    return terms.reduce((i, v) => (i + (v.reading === reading ? 1 : 0)), 0); +} + +function countMetasWithMode(metas, mode) { +    return metas.reduce((i, v) => (i + (v.mode === mode ? 1 : 0)), 0); +} + +function countKanjiWithCharacter(kanji, character) { +    return kanji.reduce((i, v) => (i + (v.character === character ? 1 : 0)), 0); +} + + +function clearDatabase(timeout) { +    return new Promise((resolve, reject) => { +        let timer = setTimeout(() => { +            timer = null; +            reject(new Error(`clearDatabase failed to resolve after ${timeout}ms`)); +        }, timeout); + +        (async () => { +            const indexedDB = global.indexedDB; +            for (const {name} of await indexedDB.databases()) { +                await new Promise((resolve2, reject2) => { +                    const request = indexedDB.deleteDatabase(name); +                    request.onerror = (e) => reject2(e); +                    request.onsuccess = () => resolve2(); +                }); +            } +            if (timer !== null) { +                clearTimeout(timer); +            } +            resolve(); +        })(); +    }); +} + + +async function testDatabase1() { +    // Load dictionary data +    const testDictionary = yomichanTest.createTestDictionaryArchive('valid-dictionary1'); +    const testDictionarySource = await testDictionary.generateAsync({type: 'string'}); +    const testDictionaryIndex = JSON.parse(await testDictionary.files['index.json'].async('string')); + +    const title = testDictionaryIndex.title; +    const titles = new Map([ +        [title, {priority: 0, allowSecondarySearches: false}] +    ]); + +    // Setup iteration data +    const iterations = [ +        { +            cleanup: async () => { +                // Test purge +                await database.purge(); +                await testDatabaseEmpty1(database); +            } +        }, +        { +            cleanup: async () => { +                // Test deleteDictionary +                let progressEvent = false; +                await database.deleteDictionary( +                    title, +                    () => { +                        progressEvent = true; +                    }, +                    {rate: 1000} +                ); +                assert.ok(progressEvent); + +                await testDatabaseEmpty1(database); +            } +        }, +        { +            cleanup: async () => {} +        } +    ]; + +    // Setup database +    const database = new Database(); +    await database.prepare(); + +    for (const {cleanup} of iterations) { +        const expectedSummary = { +            title, +            revision: 'test', +            sequenced: true, +            version: 3, +            prefixWildcardsSupported: true +        }; + +        // Import data +        let progressEvent = false; +        const {result, errors} = await database.importDictionary( +            testDictionarySource, +            () => { +                progressEvent = true; +            }, +            {prefixWildcardsSupported: true} +        ); +        assert.deepStrictEqual(errors, []); +        assert.deepStrictEqual(result, expectedSummary); +        assert.ok(progressEvent); + +        // Get info summary +        const info = await database.getDictionaryInfo(); +        assert.deepStrictEqual(info, [expectedSummary]); + +        // Get counts +        const counts = await database.getDictionaryCounts( +            info.map((v) => v.title), +            true +        ); +        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} +        }); + +        // Test find* functions +        await testFindTermsBulkTest1(database, titles); +        await testTindTermsExactBulk1(database, titles); +        await testFindTermsBySequenceBulk1(database, title); +        await testFindTermMetaBulk1(database, titles); +        await testFindKanjiBulk1(database, titles); +        await testFindKanjiMetaBulk1(database, titles); +        await testFindTagForTitle1(database, title); + +        // Cleanup +        await cleanup(); +    } + +    await database.close(); +} + +async function testDatabaseEmpty1(database) { +    const info = await database.getDictionaryInfo(); +    assert.deepStrictEqual(info, []); + +    const counts = await database.getDictionaryCounts([], true); +    assert.deepStrictEqual(counts, { +        counts: [], +        total: {kanji: 0, kanjiMeta: 0, terms: 0, termMeta: 0, tagMeta: 0} +    }); +} + +async function testFindTermsBulkTest1(database, titles) { +    const data = [ +        { +            inputs: [ +                { +                    wildcard: null, +                    termList: ['打', '打つ', '打ち込む'] +                }, +                { +                    wildcard: null, +                    termList: ['だ', 'ダース', 'うつ', 'ぶつ', 'うちこむ', 'ぶちこむ'] +                }, +                { +                    wildcard: 'suffix', +                    termList: ['打'] +                } +            ], +            expectedResults: { +                total: 32, +                expressions: [ +                    ['打', 2], +                    ['打つ', 17], +                    ['打ち込む', 13] +                ], +                readings: [ +                    ['だ', 1], +                    ['ダース', 1], +                    ['うつ', 15], +                    ['ぶつ', 2], +                    ['うちこむ', 9], +                    ['ぶちこむ', 4] +                ] +            } +        }, +        { +            inputs: [ +                { +                    wildcard: null, +                    termList: ['込む'] +                } +            ], +            expectedResults: { +                total: 0, +                expressions: [], +                readings: [] +            } +        }, +        { +            inputs: [ +                { +                    wildcard: 'prefix', +                    termList: ['込む'] +                } +            ], +            expectedResults: { +                total: 13, +                expressions: [ +                    ['打ち込む', 13] +                ], +                readings: [ +                    ['うちこむ', 9], +                    ['ぶちこむ', 4] +                ] +            } +        }, +        { +            inputs: [ +                { +                    wildcard: null, +                    termList: [] +                } +            ], +            expectedResults: { +                total: 0, +                expressions: [], +                readings: [] +            } +        } +    ]; + +    for (const {inputs, expectedResults} of data) { +        for (const {termList, wildcard} of inputs) { +            const results = await database.findTermsBulk(termList, titles, wildcard); +            assert.strictEqual(results.length, expectedResults.total); +            for (const [expression, count] of expectedResults.expressions) { +                assert.strictEqual(countTermsWithExpression(results, expression), count); +            } +            for (const [reading, count] of expectedResults.readings) { +                assert.strictEqual(countTermsWithReading(results, reading), count); +            } +        } +    } +} + +async function testTindTermsExactBulk1(database, titles) { +    const data = [ +        { +            inputs: [ +                { +                    termList: ['打', '打つ', '打ち込む'], +                    readingList: ['だ', 'うつ', 'うちこむ'] +                } +            ], +            expectedResults: { +                total: 25, +                expressions: [ +                    ['打', 1], +                    ['打つ', 15], +                    ['打ち込む', 9] +                ], +                readings: [ +                    ['だ', 1], +                    ['うつ', 15], +                    ['うちこむ', 9] +                ] +            } +        }, +        { +            inputs: [ +                { +                    termList: ['打', '打つ', '打ち込む'], +                    readingList: ['だ?', 'うつ?', 'うちこむ?'] +                } +            ], +            expectedResults: { +                total: 0, +                expressions: [], +                readings: [] +            } +        }, +        { +            inputs: [ +                { +                    termList: ['打つ', '打つ'], +                    readingList: ['うつ', 'ぶつ'] +                } +            ], +            expectedResults: { +                total: 17, +                expressions: [ +                    ['打つ', 17] +                ], +                readings: [ +                    ['うつ', 15], +                    ['ぶつ', 2] +                ] +            } +        }, +        { +            inputs: [ +                { +                    termList: ['打つ'], +                    readingList: ['うちこむ'] +                } +            ], +            expectedResults: { +                total: 0, +                expressions: [], +                readings: [] +            } +        }, +        { +            inputs: [ +                { +                    termList: [], +                    readingList: [] +                } +            ], +            expectedResults: { +                total: 0, +                expressions: [], +                readings: [] +            } +        } +    ]; + +    for (const {inputs, expectedResults} of data) { +        for (const {termList, readingList} of inputs) { +            const results = await database.findTermsExactBulk(termList, readingList, titles); +            assert.strictEqual(results.length, expectedResults.total); +            for (const [expression, count] of expectedResults.expressions) { +                assert.strictEqual(countTermsWithExpression(results, expression), count); +            } +            for (const [reading, count] of expectedResults.readings) { +                assert.strictEqual(countTermsWithReading(results, reading), count); +            } +        } +    } +} + +async function testFindTermsBySequenceBulk1(database, mainDictionary) { +    const data = [ +        { +            inputs: [ +                { +                    sequenceList: [1, 2, 3, 4, 5, 6] +                } +            ], +            expectedResults: { +                total: 32, +                expressions: [ +                    ['打', 2], +                    ['打つ', 17], +                    ['打ち込む', 13] +                ], +                readings: [ +                    ['だ', 1], +                    ['ダース', 1], +                    ['うつ', 15], +                    ['ぶつ', 2], +                    ['うちこむ', 9], +                    ['ぶちこむ', 4] +                ] +            } +        }, +        { +            inputs: [ +                { +                    sequenceList: [1] +                } +            ], +            expectedResults: { +                total: 1, +                expressions: [ +                    ['打', 1] +                ], +                readings: [ +                    ['だ', 1] +                ] +            } +        }, +        { +            inputs: [ +                { +                    sequenceList: [2] +                } +            ], +            expectedResults: { +                total: 1, +                expressions: [ +                    ['打', 1] +                ], +                readings: [ +                    ['ダース', 1] +                ] +            } +        }, +        { +            inputs: [ +                { +                    sequenceList: [3] +                } +            ], +            expectedResults: { +                total: 15, +                expressions: [ +                    ['打つ', 15] +                ], +                readings: [ +                    ['うつ', 15] +                ] +            } +        }, +        { +            inputs: [ +                { +                    sequenceList: [4] +                } +            ], +            expectedResults: { +                total: 2, +                expressions: [ +                    ['打つ', 2] +                ], +                readings: [ +                    ['ぶつ', 2] +                ] +            } +        }, +        { +            inputs: [ +                { +                    sequenceList: [5] +                } +            ], +            expectedResults: { +                total: 9, +                expressions: [ +                    ['打ち込む', 9] +                ], +                readings: [ +                    ['うちこむ', 9] +                ] +            } +        }, +        { +            inputs: [ +                { +                    sequenceList: [6] +                } +            ], +            expectedResults: { +                total: 4, +                expressions: [ +                    ['打ち込む', 4] +                ], +                readings: [ +                    ['ぶちこむ', 4] +                ] +            } +        }, +        { +            inputs: [ +                { +                    sequenceList: [-1] +                } +            ], +            expectedResults: { +                total: 0, +                expressions: [], +                readings: [] +            } +        }, +        { +            inputs: [ +                { +                    sequenceList: [] +                } +            ], +            expectedResults: { +                total: 0, +                expressions: [], +                readings: [] +            } +        } +    ]; + +    for (const {inputs, expectedResults} of data) { +        for (const {sequenceList} of inputs) { +            const results = await database.findTermsBySequenceBulk(sequenceList, mainDictionary); +            assert.strictEqual(results.length, expectedResults.total); +            for (const [expression, count] of expectedResults.expressions) { +                assert.strictEqual(countTermsWithExpression(results, expression), count); +            } +            for (const [reading, count] of expectedResults.readings) { +                assert.strictEqual(countTermsWithReading(results, reading), count); +            } +        } +    } +} + +async function testFindTermMetaBulk1(database, titles) { +    const data = [ +        { +            inputs: [ +                { +                    termList: ['打'] +                } +            ], +            expectedResults: { +                total: 1, +                modes: [ +                    ['freq', 1] +                ] +            } +        }, +        { +            inputs: [ +                { +                    termList: ['打つ'] +                } +            ], +            expectedResults: { +                total: 1, +                modes: [ +                    ['freq', 1] +                ] +            } +        }, +        { +            inputs: [ +                { +                    termList: ['打ち込む'] +                } +            ], +            expectedResults: { +                total: 1, +                modes: [ +                    ['freq', 1] +                ] +            } +        }, +        { +            inputs: [ +                { +                    termList: ['?'] +                } +            ], +            expectedResults: { +                total: 0, +                modes: [] +            } +        } +    ]; + +    for (const {inputs, expectedResults} of data) { +        for (const {termList} of inputs) { +            const results = await database.findTermMetaBulk(termList, titles); +            assert.strictEqual(results.length, expectedResults.total); +            for (const [mode, count] of expectedResults.modes) { +                assert.strictEqual(countMetasWithMode(results, mode), count); +            } +        } +    } +} + +async function testFindKanjiBulk1(database, titles) { +    const data = [ +        { +            inputs: [ +                { +                    kanjiList: ['打'] +                } +            ], +            expectedResults: { +                total: 1, +                kanji: [ +                    ['打', 1] +                ] +            } +        }, +        { +            inputs: [ +                { +                    kanjiList: ['込'] +                } +            ], +            expectedResults: { +                total: 1, +                kanji: [ +                    ['込', 1] +                ] +            } +        }, +        { +            inputs: [ +                { +                    kanjiList: ['?'] +                } +            ], +            expectedResults: { +                total: 0, +                kanji: [] +            } +        } +    ]; + +    for (const {inputs, expectedResults} of data) { +        for (const {kanjiList} of inputs) { +            const results = await database.findKanjiBulk(kanjiList, titles); +            assert.strictEqual(results.length, expectedResults.total); +            for (const [kanji, count] of expectedResults.kanji) { +                assert.strictEqual(countKanjiWithCharacter(results, kanji), count); +            } +        } +    } +} + +async function testFindKanjiMetaBulk1(database, titles) { +    const data = [ +        { +            inputs: [ +                { +                    kanjiList: ['打'] +                } +            ], +            expectedResults: { +                total: 1, +                modes: [ +                    ['freq', 1] +                ] +            } +        }, +        { +            inputs: [ +                { +                    kanjiList: ['込'] +                } +            ], +            expectedResults: { +                total: 1, +                modes: [ +                    ['freq', 1] +                ] +            } +        }, +        { +            inputs: [ +                { +                    kanjiList: ['?'] +                } +            ], +            expectedResults: { +                total: 0, +                modes: [] +            } +        } +    ]; + +    for (const {inputs, expectedResults} of data) { +        for (const {kanjiList} of inputs) { +            const results = await database.findKanjiMetaBulk(kanjiList, titles); +            assert.strictEqual(results.length, expectedResults.total); +            for (const [mode, count] of expectedResults.modes) { +                assert.strictEqual(countMetasWithMode(results, mode), count); +            } +        } +    } +} + +async function testFindTagForTitle1(database, title) { +    const data = [ +        { +            inputs: [ +                { +                    name: 'tag1' +                } +            ], +            expectedResults: { +                value: {category: 'category1', dictionary: title, name: 'tag1', notes: 'tag1 notes', order: 0, score: 0} +            } +        }, +        { +            inputs: [ +                { +                    name: 'ktag1' +                } +            ], +            expectedResults: { +                value: {category: 'kcategory1', dictionary: title, name: 'ktag1', notes: 'ktag1 notes', order: 0, score: 0} +            } +        }, +        { +            inputs: [ +                { +                    name: 'kstat1' +                } +            ], +            expectedResults: { +                value: {category: 'kcategory3', dictionary: title, name: 'kstat1', notes: 'kstat1 notes', order: 0, score: 0} +            } +        }, +        { +            inputs: [ +                { +                    name: 'invalid' +                } +            ], +            expectedResults: { +                value: null +            } +        } +    ]; + +    for (const {inputs, expectedResults} of data) { +        for (const {name} of inputs) { +            const result = await database.findTagForTitle(name, title); +            assert.deepStrictEqual(result, expectedResults.value); +        } +    } +} + + +async function testDatabase2() { +    // Load dictionary data +    const testDictionary = yomichanTest.createTestDictionaryArchive('valid-dictionary1'); +    const testDictionarySource = await testDictionary.generateAsync({type: 'string'}); +    const testDictionaryIndex = JSON.parse(await testDictionary.files['index.json'].async('string')); + +    const title = testDictionaryIndex.title; +    const titles = new Map([ +        [title, {priority: 0, allowSecondarySearches: false}] +    ]); + +    // Setup database +    const database = new Database(); + +    // Error: not prepared +    await assert.rejects(async () => await database.purge()); +    await assert.rejects(async () => await database.deleteDictionary(title, () => {}, {})); +    await assert.rejects(async () => await database.findTermsBulk(['?'], titles, null)); +    await assert.rejects(async () => await database.findTermsExactBulk(['?'], ['?'], titles)); +    await assert.rejects(async () => await database.findTermsBySequenceBulk([1], title)); +    await assert.rejects(async () => await database.findTermMetaBulk(['?'], titles)); +    await assert.rejects(async () => await database.findTermMetaBulk(['?'], titles)); +    await assert.rejects(async () => await database.findKanjiBulk(['?'], titles)); +    await assert.rejects(async () => await database.findKanjiMetaBulk(['?'], titles)); +    await assert.rejects(async () => await database.findTagForTitle('tag', title)); +    await assert.rejects(async () => await database.getDictionaryInfo()); +    await assert.rejects(async () => await database.getDictionaryCounts(titles, true)); +    await assert.rejects(async () => await database.importDictionary(testDictionarySource, () => {}, {})); + +    await database.prepare(); + +    // Error: already prepared +    await assert.rejects(async () => await database.prepare()); + +    await database.importDictionary(testDictionarySource, () => {}, {}); + +    // Error: dictionary already imported +    await assert.rejects(async () => await database.importDictionary(testDictionarySource, () => {}, {})); + +    await database.close(); +} + + +async function testDatabase3() { +    const invalidDictionaries = [ +        'invalid-dictionary1', +        'invalid-dictionary2', +        'invalid-dictionary3', +        'invalid-dictionary4', +        'invalid-dictionary5', +        'invalid-dictionary6' +    ]; + +    // Setup database +    const database = new Database(); +    await database.prepare(); + +    for (const invalidDictionary of invalidDictionaries) { +        const testDictionary = yomichanTest.createTestDictionaryArchive(invalidDictionary); +        const testDictionarySource = await testDictionary.generateAsync({type: 'string'}); + +        let error = null; +        try { +            await database.importDictionary(testDictionarySource, () => {}, {}); +        } catch (e) { +            error = e; +        } + +        if (error === null) { +            assert.ok(false, `Expected an error while importing ${invalidDictionary}`); +        } else { +            const prefix = 'Dictionary has invalid data'; +            const message = error.message; +            assert.ok(typeof message, 'string'); +            assert.ok(message.startsWith(prefix), `Expected error message to start with '${prefix}': ${message}`); +        } +    } + +    await database.close(); +} + + +async function main() { +    const clearTimeout = 5000; +    try { +        await testDatabase1(); +        await clearDatabase(clearTimeout); + +        await testDatabase2(); +        await clearDatabase(clearTimeout); + +        await testDatabase3(); +        await clearDatabase(clearTimeout); +    } catch (e) { +        console.log(e); +        process.exit(-1); +        throw e; +    } +} + + +if (require.main === module) { main(); } diff --git a/test/test-dictionary.js b/test/test-dictionary.js new file mode 100644 index 00000000..74f9e62b --- /dev/null +++ b/test/test-dictionary.js @@ -0,0 +1,59 @@ +/* + * 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 yomichanTest = require('./yomichan-test'); +const dictionaryValidate = require('./dictionary-validate'); + + +async function main() { +    const dictionaries = [ +        {name: 'valid-dictionary1', valid: true}, +        {name: 'invalid-dictionary1', valid: false}, +        {name: 'invalid-dictionary2', valid: false}, +        {name: 'invalid-dictionary3', valid: false}, +        {name: 'invalid-dictionary4', valid: false}, +        {name: 'invalid-dictionary5', valid: false}, +        {name: 'invalid-dictionary6', valid: false} +    ]; + +    const schemas = dictionaryValidate.getSchemas(); + +    for (const {name, valid} of dictionaries) { +        const archive = yomichanTest.createTestDictionaryArchive(name); + +        let error = null; +        try { +            await dictionaryValidate.validateDictionary(archive, schemas); +        } catch (e) { +            error = e; +        } + +        if (valid) { +            if (error !== null) { +                throw error; +            } +        } else { +            if (error === null) { +                throw new Error(`Expected dictionary ${name} to be invalid`); +            } +        } +    } +} + + +if (require.main === module) { main(); } diff --git a/test/test-schema.js b/test/test-schema.js new file mode 100644 index 00000000..f4612f86 --- /dev/null +++ b/test/test-schema.js @@ -0,0 +1,251 @@ +/* + * 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 assert = require('assert'); +const yomichanTest = require('./yomichan-test'); + +const {JsonSchema} = yomichanTest.requireScript('ext/bg/js/json-schema.js', ['JsonSchema']); + + +function testValidate1() { +    const schema = { +        allOf: [ +            { +                type: 'number' +            }, +            { +                anyOf: [ +                    {minimum: 10, maximum: 100}, +                    {minimum: -100, maximum: -10} +                ] +            }, +            { +                oneOf: [ +                    {multipleOf: 3}, +                    {multipleOf: 5} +                ] +            }, +            { +                not: [ +                    {multipleOf: 20} +                ] +            } +        ] +    }; + +    const schemaValidate = (value) => { +        try { +            JsonSchema.validate(value, schema); +            return true; +        } catch (e) { +            return false; +        } +    }; + +    const jsValidate = (value) => { +        return ( +            typeof value === 'number' && +            ( +                (value >= 10 && value <= 100) || +                (value >= -100 && value <= -10) +            ) && +            ( +                ( +                    (value % 3) === 0 || +                    (value % 5) === 0 +                ) && +                (value % 15) !== 0 +            ) && +            (value % 20) !== 0 +        ); +    }; + +    for (let i = -111; i <= 111; i++) { +        const actual = schemaValidate(i, schema); +        const expected = jsValidate(i); +        assert.strictEqual(actual, expected); +    } +} + + +function testGetValidValueOrDefault1() { +    // Test value defaulting on objects with additionalProperties=false +    const schema = { +        type: 'object', +        required: ['test'], +        properties: { +            test: { +                type: 'string', +                default: 'default' +            } +        }, +        additionalProperties: false +    }; + +    const testData = [ +        [ +            void 0, +            {test: 'default'} +        ], +        [ +            null, +            {test: 'default'} +        ], +        [ +            0, +            {test: 'default'} +        ], +        [ +            '', +            {test: 'default'} +        ], +        [ +            [], +            {test: 'default'} +        ], +        [ +            {}, +            {test: 'default'} +        ], +        [ +            {test: 'value'}, +            {test: 'value'} +        ], +        [ +            {test2: 'value2'}, +            {test: 'default'} +        ], +        [ +            {test: 'value', test2: 'value2'}, +            {test: 'value'} +        ] +    ]; + +    for (const [value, expected] of testData) { +        const actual = JsonSchema.getValidValueOrDefault(schema, value); +        assert.deepStrictEqual(actual, expected); +    } +} + +function testGetValidValueOrDefault2() { +    // Test value defaulting on objects with additionalProperties=true +    const schema = { +        type: 'object', +        required: ['test'], +        properties: { +            test: { +                type: 'string', +                default: 'default' +            } +        }, +        additionalProperties: true +    }; + +    const testData = [ +        [ +            {}, +            {test: 'default'} +        ], +        [ +            {test: 'value'}, +            {test: 'value'} +        ], +        [ +            {test2: 'value2'}, +            {test: 'default', test2: 'value2'} +        ], +        [ +            {test: 'value', test2: 'value2'}, +            {test: 'value', test2: 'value2'} +        ] +    ]; + +    for (const [value, expected] of testData) { +        const actual = JsonSchema.getValidValueOrDefault(schema, value); +        assert.deepStrictEqual(actual, expected); +    } +} + +function testGetValidValueOrDefault3() { +    // Test value defaulting on objects with additionalProperties={schema} +    const schema = { +        type: 'object', +        required: ['test'], +        properties: { +            test: { +                type: 'string', +                default: 'default' +            } +        }, +        additionalProperties: { +            type: 'number', +            default: 10 +        } +    }; + +    const testData = [ +        [ +            {}, +            {test: 'default'} +        ], +        [ +            {test: 'value'}, +            {test: 'value'} +        ], +        [ +            {test2: 'value2'}, +            {test: 'default', test2: 10} +        ], +        [ +            {test: 'value', test2: 'value2'}, +            {test: 'value', test2: 10} +        ], +        [ +            {test2: 2}, +            {test: 'default', test2: 2} +        ], +        [ +            {test: 'value', test2: 2}, +            {test: 'value', test2: 2} +        ], +        [ +            {test: 'value', test2: 2, test3: null}, +            {test: 'value', test2: 2, test3: 10} +        ], +        [ +            {test: 'value', test2: 2, test3: void 0}, +            {test: 'value', test2: 2, test3: 10} +        ] +    ]; + +    for (const [value, expected] of testData) { +        const actual = JsonSchema.getValidValueOrDefault(schema, value); +        assert.deepStrictEqual(actual, expected); +    } +} + + +function main() { +    testValidate1(); +    testGetValidValueOrDefault1(); +    testGetValidValueOrDefault2(); +    testGetValidValueOrDefault3(); +} + + +if (require.main === module) { main(); } diff --git a/test/yomichan-test.js b/test/yomichan-test.js new file mode 100644 index 00000000..78bfb9c6 --- /dev/null +++ b/test/yomichan-test.js @@ -0,0 +1,70 @@ +/* + * 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'); + + +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) { +        process.noDeprecation = true; // Suppress a warning about JSZip +        JSZip = require(path.join(__dirname, '../ext/mixed/lib/jszip.min.js')); +        process.noDeprecation = false; +    } +    return JSZip; +} + +function createTestDictionaryArchive(dictionary, dictionaryName) { +    const dictionaryDirectory = path.join(__dirname, 'data', 'dictionaries', dictionary); +    const fileNames = fs.readdirSync(dictionaryDirectory); + +    const archive = new (getJSZip())(); + +    for (const fileName of fileNames) { +        const source = fs.readFileSync(path.join(dictionaryDirectory, fileName), {encoding: 'utf8'}); +        const json = JSON.parse(source); +        if (fileName === 'index.json' && typeof dictionaryName === 'string') { +            json.title = dictionaryName; +        } +        archive.file(fileName, JSON.stringify(json, null, 0)); +    } + +    return archive; +} + + +module.exports = { +    requireScript, +    createTestDictionaryArchive, +    get JSZip() { return getJSZip(); } +}; diff --git a/tmpl/query-parser.html b/tmpl/query-parser.html deleted file mode 100644 index db98b5ff..00000000 --- a/tmpl/query-parser.html +++ /dev/null @@ -1,27 +0,0 @@ -{{~#*inline "term"~}} -{{~#if preview~}} -<span class="query-parser-term-preview"> -{{~else~}} -<span class="query-parser-term"> -{{~/if~}} -{{~#each this~}} -{{> part }} -{{~/each~}} -</span> -{{~/inline~}} - -{{~#*inline "part"~}} -{{~#if raw~}} -{{~#each text~}} -<span class="query-parser-char">{{this}}</span> -{{~/each~}} -{{~else~}} -<ruby>{{~#each text~}} -<span class="query-parser-char">{{this}}</span> -{{~/each~}}<rt>{{reading}}</rt></ruby> -{{~/if~}} -{{~/inline~}} - -{{~#each terms~}} -{{> term preview=../preview }} -{{~/each~}}  |