diff options
author | Alex Yatskov <alex@foosoft.net> | 2020-06-27 19:04:19 -0700 |
---|---|---|
committer | Alex Yatskov <alex@foosoft.net> | 2020-06-27 19:04:19 -0700 |
commit | 88af95d20bfdbeb59d44bf0f0d46e772a329f839 (patch) | |
tree | d1dfa7268f274fed32061221c0f030e3647f9ae2 /ext/bg | |
parent | 19197a9a5d6a1f54a179d894577dfac513b97401 (diff) | |
parent | 0a6c08d0f53090a4ad48663bc5846ddae5723d52 (diff) |
Merge branch 'master' into testing
Diffstat (limited to 'ext/bg')
42 files changed, 3901 insertions, 3136 deletions
diff --git a/ext/bg/background.html b/ext/bg/background.html index ca35a3c6..55380ae7 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -20,7 +20,6 @@ <script src="/mixed/lib/wanakana.min.js"></script> <script src="/mixed/js/core.js"></script> - <script src="/mixed/js/dom.js"></script> <script src="/mixed/js/environment.js"></script> <script src="/mixed/js/japanese.js"></script> @@ -29,19 +28,19 @@ <script src="/bg/js/backend.js"></script> <script src="/bg/js/mecab.js"></script> <script src="/bg/js/audio-uri-builder.js"></script> - <script src="/bg/js/backend-api-forwarder.js"></script> <script src="/bg/js/clipboard-monitor.js"></script> <script src="/bg/js/conditions.js"></script> + <script src="/bg/js/generic-database.js"></script> <script src="/bg/js/database.js"></script> <script src="/bg/js/dictionary-importer.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/json-schema.js"></script> <script src="/bg/js/media-utility.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/template-renderer.js"></script> <script src="/bg/js/text-source-map.js"></script> <script src="/bg/js/translator.js"></script> <script src="/bg/js/util.js"></script> diff --git a/ext/bg/context.html b/ext/bg/context.html index 93012d70..1e7e6155 100644 --- a/ext/bg/context.html +++ b/ext/bg/context.html @@ -1,191 +1,60 @@ <!DOCTYPE html> <html lang="en"> - <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width,initial-scale=1" /> - <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> - <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> - <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> - <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> - <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> - <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> - <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128"> - <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css"> - <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css"> - <style type="text/css"> - body { - padding: 10px; - } - - h3 { - margin-top: 10px; - } - label { - font-weight: normal; - } - - #mini { - text-align: center; - } - #full { - display: none; - } - .btn-group { - display: flex; - justify-content: center; - margin-top: 10px; - white-space: nowrap; - } - - html:root[data-mode=full] #mini { - display: none; - } - html:root[data-mode=full] #full { - display: initial; - } - - .link-group { - display: block; - line-height: 1.5em; - margin: 0 -10px; - padding: 0.5em 10px; - cursor: pointer; - color: #333; - text-decoration: none; - background-color: transparent; - transition: background-color 0.125s linear 0s; - max-width: none; - } - .link-group:hover, - .link-group:active { - color: #333; - text-decoration: none; - } - .link-group:hover>.link-group-label, - .link-group:active>.link-group-label { - text-decoration: underline; - } - .link-group:hover { - background-color: rgba(0, 0, 0, 0.05); - } - .link-group:active { - background-color: rgba(0, 0, 0, 0.1); - } - .link-group-icon { - width: 16px; - height: 16px; - text-align: center; - vertical-align: middle; - display: inline-block; - margin-right: 0.25em; - } - .link-group-icon>input { - margin: 0; - padding: 0; - } - .link-group-icon>.glyphicon { - top: 0; - } - .link-group-label { - vertical-align: middle; - } - - - .toggle { - width: 60px; - height: 34px; - position: relative; - overflow: hidden; - } - .toggle-group { - position: absolute; - width: 200%; - left: 0; - top: 0; - bottom: 0; - user-select: none; - } - .toggle-group.toggle-group-animated { - transition: transform 0.35s; - } - .toggle-on, - .toggle-off { - position: absolute; - top: 0; - bottom: 0; - margin: 0; - border: 0; - border-radius: 0; - } - .toggle-on { - padding-right: 24px; - left: 0; - right: 50%; - } - .toggle-off { - padding-left: 24px; - left: 50%; - right: 0; - } - .toggle-handle { - position: relative; - margin: 0 auto; - padding-top: 0; - padding-bottom: 0; - height: 100%; - width: 0; - border-width: 0 1px; - } - - .toggle>input[type=checkbox] { - display: none; - } - .toggle>input[type=checkbox]:not(:checked)~.toggle-group { - transform: translateX(-50%); - } - </style> - </head> - <body> - <div id="mini"> - <div> - <label class="btn btn-primary toggle"> - <input type="checkbox" id="enable-search" /> - <div class="toggle-group"> - <span class="btn btn-primary toggle-on">On</span> - <span class="btn btn-default active toggle-off">Off</span> - <span class="btn btn-default toggle-handle"></span> - </div> - </label> - </div> - <div class="btn-group"> - <a title="Search (Alt + Insert) (Middle click to open in new tab)" class="btn btn-default btn-xs action-open-search"><span class="glyphicon glyphicon-search"></span></a> - <a title="Options (Middle click to open in new tab)" class="btn btn-default btn-xs action-open-options"><span class="glyphicon glyphicon-wrench"></span></a> - <a title="Help" class="btn btn-default btn-xs action-open-help"><span class="glyphicon glyphicon-question-sign"></span></a> - </div> - </div> - <div id="full"> - <h3 id="extension-info">Yomichan</h3> - <label class="link-group"> - <span class="link-group-icon"><input type="checkbox" id="enable-search2" /></span><span class="link-group-label">Enable content scanning</span> - </label> - <a class="link-group action-open-options"> - <span class="link-group-icon"><span class="glyphicon glyphicon-chevron-right"></span></span><span class="link-group-label">Options</span> - </a> - <a class="link-group action-open-search"> - <span class="link-group-icon"><span class="glyphicon glyphicon-chevron-right"></span></span><span class="link-group-label">Search</span> - </a> - <a class="link-group action-open-help"> - <span class="link-group-icon"><span class="glyphicon glyphicon-chevron-right"></span></span><span class="link-group-label">Help</span> - </a> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> + <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> + <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> + <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> + <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> + <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> + <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128"> + <link rel="stylesheet" type="text/css" href="/bg/css/context.css"> +</head> +<body> + +<div id="mini"> + <label class="toggle"> + <input type="checkbox" id="enable-search"> + <div class="toggle-group"> + <span class="toggle-on">On</span> + <span class="btnx btn-defaulxt toggle-off">Off</span> + <span class="btnx xbtn-default toggle-handle"></span> </div> - - <script src="/mixed/js/core.js"></script> - <script src="/mixed/js/dom.js"></script> - <script src="/mixed/js/api.js"></script> - - <script src="/bg/js/options.js"></script> - <script src="/bg/js/util.js"></script> - - <script src="/bg/js/context-main.js"></script> - </body> + </label> + <div class="nav-button-container"> + <a class="nav-button action-open-search" data-icon="magnifying-glass" title="Search (Alt + Insert) (Middle click to open in new tab)"></a> + <a class="nav-button action-open-options" data-icon="cog" title="Options (Middle click to open in new tab)"></a> + <a class="nav-button action-open-help" data-icon="question-mark" title="Help"></a> + </div> +</div> + +<div id="full"> + <h3 id="extension-info">Yomichan</h3> + <label class="link-group"> + <span class="link-group-icon"><input type="checkbox" id="enable-search2" /></span><span class="link-group-label">Enable content scanning</span> + </label> + <a class="link-group action-open-options"> + <span class="link-group-icon" data-icon="chevron"></span><span class="link-group-label">Options</span> + </a> + <a class="link-group action-open-search"> + <span class="link-group-icon" data-icon="chevron"></span><span class="link-group-label">Search</span> + </a> + <a class="link-group action-open-help"> + <span class="link-group-icon" data-icon="chevron"></span><span class="link-group-label">Help</span> + </a> +</div> + +<script src="/mixed/js/core.js"></script> +<script src="/mixed/js/comm.js"></script> +<script src="/mixed/js/dom.js"></script> +<script src="/mixed/js/api.js"></script> + +<script src="/bg/js/options.js"></script> +<script src="/bg/js/util.js"></script> + +<script src="/bg/js/context-main.js"></script> + +</body> </html> diff --git a/ext/bg/css/context.css b/ext/bg/css/context.css new file mode 100644 index 00000000..2d42dd16 --- /dev/null +++ b/ext/bg/css/context.css @@ -0,0 +1,282 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +body { + padding: 10px; + margin: 0; + font-family: "Segoe UI", Tahoma, sans-serif; + font-size: 14px; +} + +h3 { + margin: 10px 0; + font-family: inherit; + font-weight: 500; + line-height: 1.1; + font-size: 24px; + color: inherit; +} +label { + font-weight: normal; +} + +#mini { + text-align: center; +} +#full { + display: none; +} +:root[data-mode=full] #mini { + display: none; +} +:root[data-mode=full] #full { + display: initial; +} + + +.link-group { + display: block; + line-height: 1.5em; + margin: 0 -10px; + padding: 0.5em 10px; + cursor: pointer; + color: #333; + text-decoration: none; + background-color: transparent; + transition: background-color 0.125s linear 0s; + max-width: none; +} +.link-group:hover, +.link-group:active { + color: #333; + text-decoration: none; +} +.link-group:hover>.link-group-label, +.link-group:active>.link-group-label { + text-decoration: underline; +} +.link-group:hover { + background-color: rgba(0, 0, 0, 0.05); +} +.link-group:active { + background-color: rgba(0, 0, 0, 0.1); +} +.link-group-icon { + width: 16px; + height: 16px; + text-align: center; + vertical-align: middle; + display: inline-block; + margin-right: 0.25em; +} +.link-group-icon>input { + margin: 0; + padding: 0; +} +.link-group-icon[data-icon=chevron]::after { + content: ""; + display: block; + width: 100%; + height: 100%; + background-image: url(/mixed/img/right-chevron.svg); + background-repeat: no-repeat; + background-position: center center; + background-size: contain; +} +.link-group-label { + vertical-align: middle; +} + +/* Toggle */ +.toggle>input[type=checkbox] { + display: none; +} +.toggle>input[type=checkbox]:not(:checked)~.toggle-group { + transform: translateX(-50%); +} +.toggle { + box-sizing: border-box; + width: 60px; + height: 34px; + position: relative; + overflow: hidden; + border: 1px solid #245580; + border-radius: 4px; + display: inline-block; + padding: 6px 12px; +} +.toggle-group { + position: absolute; + width: 200%; + left: 0; + top: 0; + bottom: 0; + user-select: none; +} +body[data-loaded=true] .toggle-group { + transition: transform 0.35s; +} + +.toggle-on, +.toggle-off, +.toggle-handle { + display: block; + padding: 6px 12px; + font-size: 14px; + font-weight: 400; + line-height: 1.42857143; + text-align: center; + white-space: nowrap; + cursor: pointer; +} +.toggle-on, +.toggle-off { + position: absolute; + top: 0; + bottom: 0; + margin: 0; + border: 0; +} + +.toggle-on { + padding-right: 24px; + left: 0; + right: 50%; + color: #ffffff; + background-color: #337ab7; + border-color: #2e6da4; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), inset 0 3px 5px rgba(0, 0, 0, 0); + background-image: linear-gradient(#337ab7, #265a88); + background-repeat: repeat-x; +} +.toggle-on:focus, +.toggle-on:hover { + background-color: #265a88; + background-image: linear-gradient(#2d65a0, #265a88 50%); +} +.toggle-on:active { + background-color: #204d74; + background-image: none; + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); +} + +.toggle-off { + padding-left: 24px; + left: 50%; + right: 0; +} + +.toggle-handle { + position: relative; + margin: 0 auto; + padding-top: 0; + padding-bottom: 0; + height: 100%; + width: 0; + border-style: solid; + border-width: 0 1px; + border-radius: 4px; + border-color: #cccccc; +} + +.toggle-off, +.toggle-handle { + color: #333333; + text-shadow: 0 1px 0 #ffffff; + background-color: #ffffff; + background-image: linear-gradient(#ffffff, #e0e0e0); + background-repeat: repeat-x; + box-shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075); +} +.toggle-off:focus, +.toggle-off:hover, +.toggle-handle:focus, +.toggle-handle:hover { + background-color: #e6e6e6; + background-image: linear-gradient(#e0e0e0, #e6e6e6 50%); + border-color: #adadad; +} +.toggle-off:active, +.toggle-handle:active { + background-color:#d4d4d4; + background-image: none; + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + border-color: #8c8c8c; +} + + +/* Navigation buttons and icons */ +.nav-button-container { + display: flex; + justify-content: center; + margin-top: 10px; + white-space: nowrap; +} +.nav-button { + background-image: linear-gradient(#f8f8f8, #e0e0e0); + border: 1px solid #cccccc; + margin: 0; + padding: 2px 3px; + margin: 0; + cursor: pointer; +} +.nav-button+.nav-button { + margin-left: -1px; +} +.nav-button::after { + content: ""; + display: block; + width: 16px; + height: 16px; + background-position: center center; + background-size: 16px 16px; + background-repeat: no-repeat; + box-sizing: content-box; +} +.nav-button:hover { + z-index: 1; + border-color: #aaaaaa; + background-image: linear-gradient(#e8e8e8, #d0d0d0); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15); +} +.nav-button:active { + z-index: 1; + border-color: #808080; + background-image: linear-gradient(#c8c8c8, #e0e0e0); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15); +} +.nav-button:focus { + outline: none; +} +.nav-button[data-icon=magnifying-glass]::after { + background-image: url(/mixed/img/magnifying-glass.svg); +} +.nav-button[data-icon=cog]::after { + background-image: url(/mixed/img/cog.svg); +} +.nav-button[data-icon=question-mark]::after { + background-image: url(/mixed/img/question-mark-circle.svg); +} +.nav-button:first-of-type { + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} +.nav-button:last-of-type { + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} diff --git a/ext/bg/css/settings.css b/ext/bg/css/settings.css index f55082e7..7659f6f2 100644 --- a/ext/bg/css/settings.css +++ b/ext/bg/css/settings.css @@ -16,9 +16,8 @@ */ -#anki-spinner, #dict-spinner, #dict-import-progress, -.storage-hidden, #storage-spinner { +.storage-hidden { display: none; } diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json index 656da989..b56017bc 100644 --- a/ext/bg/data/options-schema.json +++ b/ext/bg/data/options-schema.json @@ -109,7 +109,9 @@ "showPitchAccentDownstepNotation", "showPitchAccentPositionNotation", "showPitchAccentGraph", - "showIframePopupsInRootFrame" + "showIframePopupsInRootFrame", + "useSecurePopupFrameUrl", + "usePopupShadowDom" ], "properties": { "enable": { @@ -247,6 +249,14 @@ "showIframePopupsInRootFrame": { "type": "boolean", "default": false + }, + "useSecurePopupFrameUrl": { + "type": "boolean", + "default": true + }, + "usePopupShadowDom": { + "type": "boolean", + "default": true } } }, @@ -316,7 +326,8 @@ "enablePopupSearch", "enableOnPopupExpressions", "enableOnSearchPage", - "enableSearchTags" + "enableSearchTags", + "layoutAwareScan" ], "properties": { "middleMouse": { @@ -378,6 +389,10 @@ "enableSearchTags": { "type": "boolean", "default": false + }, + "layoutAwareScan": { + "type": "boolean", + "default": false } } }, diff --git a/ext/bg/guide.html b/ext/bg/guide.html index cde520d1..d75a9931 100644 --- a/ext/bg/guide.html +++ b/ext/bg/guide.html @@ -27,7 +27,7 @@ <ol> <li>Click on the <img src="/mixed/img/yomichan-icon.svg" alt> icon in the browser toolbar to open the Yomichan actions dialog.</li> - <li>Click on the <em>monkey wrench</em> icon in the middle to open the options page.</li> + <li>Click on the <em>cog</em> icon in the middle to open the options page.</li> <li>Import the dictionaries you wish to use for term and Kanji searches.</li> <li>Hold down <kbd>Shift</kbd> key or the middle mouse button as you move your mouse over text to display definitions.</li> <li>Click on the <img src="/mixed/img/play-audio.svg" alt> icon to hear the term pronounced by a native speaker.</li> diff --git a/ext/bg/js/anki-note-builder.js b/ext/bg/js/anki-note-builder.js index 76199db7..7fe8962a 100644 --- a/ext/bg/js/anki-note-builder.js +++ b/ext/bg/js/anki-note-builder.js @@ -28,8 +28,9 @@ class AnkiNoteBuilder { const modeOptions = isKanji ? options.anki.kanji : options.anki.terms; const modeOptionsFieldEntries = Object.entries(modeOptions.fields); + const fields = {}; const note = { - fields: {}, + fields, tags, deckName: modeOptions.deck, modelName: modeOptions.model, @@ -38,8 +39,17 @@ class AnkiNoteBuilder { } }; - for (const [fieldName, fieldValue] of modeOptionsFieldEntries) { - note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, context, options, templates, null); + const formattedFieldValuePromises = []; + for (const [, fieldValue] of modeOptionsFieldEntries) { + const formattedFieldValuePromise = this.formatField(fieldValue, definition, mode, context, options, templates, null); + formattedFieldValuePromises.push(formattedFieldValuePromise); + } + + const formattedFieldValues = await Promise.all(formattedFieldValuePromises); + for (let i = 0, ii = modeOptionsFieldEntries.length; i < ii; ++i) { + const fieldName = modeOptionsFieldEntries[i][0]; + const formattedFieldValue = formattedFieldValues[i]; + fields[fieldName] = formattedFieldValue; } return note; @@ -155,7 +165,7 @@ class AnkiNoteBuilder { } static arrayBufferToBase64(arrayBuffer) { - return window.btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); + return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); } static stringReplaceAsync(str, regex, replacer) { diff --git a/ext/bg/js/audio-uri-builder.js b/ext/bg/js/audio-uri-builder.js index 27e97680..390e1e4d 100644 --- a/ext/bg/js/audio-uri-builder.js +++ b/ext/bg/js/audio-uri-builder.js @@ -82,16 +82,23 @@ class AudioUriBuilder { } async _getUriJpod101Alternate(definition) { - const response = await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data'))); - xhr.addEventListener('load', () => resolve(xhr.responseText)); - xhr.send(`post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(definition.expression)}`); + const fetchUrl = 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'; + const data = `post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(definition.expression)}`; + const response = await fetch(fetchUrl, { + method: 'POST', + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: data }); + const responseText = await response.text(); - const dom = new DOMParser().parseFromString(response, 'text/html'); + const dom = new DOMParser().parseFromString(responseText, 'text/html'); for (const row of dom.getElementsByClassName('dc-result-row')) { try { const url = row.querySelector('audio>source[src]').getAttribute('src'); @@ -108,15 +115,18 @@ class AudioUriBuilder { } async _getUriJisho(definition) { - const response = await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('GET', `https://jisho.org/search/${definition.expression}`); - xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data'))); - xhr.addEventListener('load', () => resolve(xhr.responseText)); - xhr.send(); + const fetchUrl = `https://jisho.org/search/${definition.expression}`; + const response = await fetch(fetchUrl, { + method: 'GET', + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' }); + const responseText = await response.text(); - const dom = new DOMParser().parseFromString(response, 'text/html'); + const dom = new DOMParser().parseFromString(responseText, 'text/html'); try { const audio = dom.getElementById(`audio_${definition.expression}:${definition.reading}`); if (audio !== null) { diff --git a/ext/bg/js/backend-api-forwarder.js b/ext/bg/js/backend-api-forwarder.js deleted file mode 100644 index 4ac12730..00000000 --- a/ext/bg/js/backend-api-forwarder.js +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (C) 2019-2020 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - - -class BackendApiForwarder { - prepare() { - chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); - } - - _onConnect(port) { - if (port.name !== 'backend-api-forwarder') { return; } - - let tabId; - if (!( - port.sender && - port.sender.tab && - (typeof (tabId = port.sender.tab.id)) === 'number' - )) { - port.disconnect(); - return; - } - - const forwardPort = chrome.tabs.connect(tabId, {name: 'frontend-api-receiver'}); - - port.onMessage.addListener((message) => forwardPort.postMessage(message)); - forwardPort.onMessage.addListener((message) => port.postMessage(message)); - port.onDisconnect.addListener(() => forwardPort.disconnect()); - forwardPort.onDisconnect.addListener(() => port.disconnect()); - } -} diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 20d31efc..344706d1 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -20,7 +20,6 @@ * AnkiNoteBuilder * AudioSystem * AudioUriBuilder - * BackendApiForwarder * ClipboardMonitor * Database * DictionaryImporter @@ -28,10 +27,10 @@ * JsonSchema * Mecab * ObjectPropertyAccessor + * TemplateRenderer * Translator * conditionsTestValue * dictTermsSort - * handlebarsRenderDynamic * jp * optionsLoad * optionsSave @@ -64,22 +63,28 @@ class Backend { audioSystem: this.audioSystem, renderTemplate: this._renderTemplate.bind(this) }); + this._templateRenderer = new TemplateRenderer(); - this.optionsContext = { - depth: 0, - url: window.location.href - }; + const url = (typeof window === 'object' && window !== null ? window.location.href : ''); + this.optionsContext = {depth: 0, url}; - this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target'); + this.clipboardPasteTarget = ( + typeof document === 'object' && document !== null ? + document.querySelector('#clipboard-paste-target') : + null + ); this.popupWindow = null; - const apiForwarder = new BackendApiForwarder(); - apiForwarder.prepare(); - - this._defaultBrowserActionTitle = null; this._isPrepared = false; this._prepareError = false; + this._preparePromise = null; + this._prepareCompletePromise = new Promise((resolve, reject) => { + this._prepareCompleteResolve = resolve; + this._prepareCompleteReject = reject; + }); + + this._defaultBrowserActionTitle = null; this._badgePrepareDelayTimer = null; this._logErrorLevel = null; @@ -103,6 +108,7 @@ class Backend { ['broadcastTab', {async: false, contentScript: true, handler: this._onApiBroadcastTab.bind(this)}], ['frameInformationGet', {async: true, contentScript: true, handler: this._onApiFrameInformationGet.bind(this)}], ['injectStylesheet', {async: true, contentScript: true, handler: this._onApiInjectStylesheet.bind(this)}], + ['getStylesheetContent', {async: true, contentScript: true, handler: this._onApiGetStylesheetContent.bind(this)}], ['getEnvironmentInfo', {async: false, contentScript: true, handler: this._onApiGetEnvironmentInfo.bind(this)}], ['clipboardGet', {async: true, contentScript: true, handler: this._onApiClipboardGet.bind(this)}], ['getDisplayTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetDisplayTemplatesHtml.bind(this)}], @@ -119,7 +125,9 @@ class Backend { ['log', {async: false, contentScript: true, handler: this._onApiLog.bind(this)}], ['logIndicatorClear', {async: false, contentScript: true, handler: this._onApiLogIndicatorClear.bind(this)}], ['createActionPort', {async: false, contentScript: true, handler: this._onApiCreateActionPort.bind(this)}], - ['modifySettings', {async: true, contentScript: true, handler: this._onApiModifySettings.bind(this)}] + ['modifySettings', {async: true, contentScript: true, handler: this._onApiModifySettings.bind(this)}], + ['getSettings', {async: false, contentScript: true, handler: this._onApiGetSettings.bind(this)}], + ['setAllSettings', {async: true, contentScript: false, handler: this._onApiSetAllSettings.bind(this)}] ]); this._messageHandlersWithProgress = new Map([ ['importDictionaryArchive', {async: true, contentScript: false, handler: this._onApiImportDictionaryArchive.bind(this)}], @@ -134,7 +142,26 @@ class Backend { ]); } - async prepare() { + prepare() { + if (this._preparePromise === null) { + const promise = this._prepareInternal(); + promise.then( + (value) => { + this._isPrepared = true; + this._prepareCompleteResolve(value); + }, + (error) => { + this._prepareError = true; + this._prepareCompleteReject(error); + } + ); + promise.finally(() => this._updateBadge()); + this._preparePromise = promise; + } + return this._prepareCompletePromise; + } + + async _prepareInternal() { try { this._defaultBrowserActionTitle = await this._getBrowserIconTitle(); this._badgePrepareDelayTimer = setTimeout(() => { @@ -143,8 +170,14 @@ class Backend { }, 1000); this._updateBadge(); + yomichan.on('log', this._onLog.bind(this)); + await this.environment.prepare(); - await this.database.prepare(); + try { + await this.database.prepare(); + } catch (e) { + yomichan.logError(e); + } await this.translator.prepare(); await profileConditionsDescriptorPromise; @@ -156,14 +189,6 @@ class Backend { this.onOptionsUpdated('background'); - if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) { - chrome.commands.onCommand.addListener(this._runCommand.bind(this)); - } - if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) { - chrome.tabs.onZoomChange.addListener(this._onZoomChange.bind(this)); - } - chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); - const options = this.getOptions(this.optionsContext); if (options.general.showGuide) { chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')}); @@ -174,10 +199,7 @@ class Backend { this._sendMessageAllTabs('backendPrepared'); const callback = () => this.checkLastError(chrome.runtime.lastError); chrome.runtime.sendMessage({action: 'backendPrepared'}, callback); - - this._isPrepared = true; } catch (e) { - this._prepareError = true; yomichan.logError(e); throw e; } finally { @@ -185,15 +207,33 @@ class Backend { clearTimeout(this._badgePrepareDelayTimer); this._badgePrepareDelayTimer = null; } - - this._updateBadge(); } } + prepareComplete() { + return this._prepareCompletePromise; + } + isPrepared() { return this._isPrepared; } + handleCommand(...args) { + return this._runCommand(...args); + } + + handleZoomChange(...args) { + return this._onZoomChange(...args); + } + + handleConnect(...args) { + return this._onConnect(...args); + } + + handleMessage(...args) { + return this.onMessage(...args); + } + _sendMessageAllTabs(action, params={}) { const callback = () => this.checkLastError(chrome.runtime.lastError); chrome.tabs.query({}, (tabs) => { @@ -236,6 +276,45 @@ class Backend { } } + _onConnect(port) { + try { + const match = /^background-cross-frame-communication-port-(\d+)$/.exec(`${port.name}`); + if (match === null) { return; } + + const tabId = (port.sender && port.sender.tab ? port.sender.tab.id : null); + if (typeof tabId !== 'number') { + throw new Error('Port does not have an associated tab ID'); + } + const senderFrameId = port.sender.frameId; + if (typeof tabId !== 'number') { + throw new Error('Port does not have an associated frame ID'); + } + const targetFrameId = parseInt(match[1], 10); + + let forwardPort = chrome.tabs.connect(tabId, {frameId: targetFrameId, name: `cross-frame-communication-port-${senderFrameId}`}); + + const cleanup = () => { + this.checkLastError(chrome.runtime.lastError); + if (forwardPort !== null) { + forwardPort.disconnect(); + forwardPort = null; + } + if (port !== null) { + port.disconnect(); + port = null; + } + }; + + port.onMessage.addListener((message) => { forwardPort.postMessage(message); }); + forwardPort.onMessage.addListener((message) => { port.postMessage(message); }); + port.onDisconnect.addListener(cleanup); + forwardPort.onDisconnect.addListener(cleanup); + } catch (e) { + port.disconnect(); + yomichan.logError(e); + } + } + _onClipboardText({text}) { this._onCommandSearch({mode: 'popup', query: text}); } @@ -245,6 +324,14 @@ class Backend { chrome.tabs.sendMessage(tabId, {action: 'zoomChanged', params: {oldZoomFactor, newZoomFactor}}, callback); } + _onLog({level}) { + const levelValue = this._getErrorLevelValue(level); + if (levelValue <= this._getErrorLevelValue(this._logErrorLevel)) { return; } + + this._logErrorLevel = level; + this._updateBadge(); + } + applyOptions() { const options = this.getOptions(this.optionsContext); this._updateBadge(); @@ -274,15 +361,6 @@ class Backend { return useSchema ? JsonSchema.createProxy(options, this.optionsSchema) : options; } - setFullOptions(options) { - try { - this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, utilIsolate(options)); - } catch (e) { - // This shouldn't happen, but catch errors just in case of bugs - yomichan.logError(e); - } - } - getOptions(optionsContext, useSchema=false) { return this.getProfile(optionsContext, useSchema).options; } @@ -506,13 +584,14 @@ class Backend { const states = []; try { - const notes = []; + const notePromises = []; for (const definition of definitions) { for (const mode of modes) { - const note = await this.ankiNoteBuilder.createNote(definition, mode, context, options, templates); - notes.push(note); + const notePromise = this.ankiNoteBuilder.createNote(definition, mode, context, options, templates); + notePromises.push(notePromise); } } + const notes = await Promise.all(notePromises); const cannotAdd = []; const results = await this.anki.canAddNotes(notes); @@ -641,6 +720,13 @@ class Backend { }); } + async _onApiGetStylesheetContent({url}) { + if (!url.startsWith('/') || url.startsWith('//') || !url.endsWith('.css')) { + throw new Error('Invalid URL'); + } + return await requestText(url, 'GET'); + } + _onApiGetEnvironmentInfo() { return this.environment.getInfo(); } @@ -663,6 +749,9 @@ class Backend { return await navigator.clipboard.readText(); } else { const clipboardPasteTarget = this.clipboardPasteTarget; + if (clipboardPasteTarget === null) { + throw new Error('Reading the clipboard is not supported in this context'); + } clipboardPasteTarget.value = ''; clipboardPasteTarget.focus(); document.execCommand('paste'); @@ -744,12 +833,6 @@ class Backend { _onApiLog({error, level, context}) { yomichan.log(jsonToError(error), level, context); - - const levelValue = this._getErrorLevelValue(level); - if (levelValue <= this._getErrorLevelValue(this._logErrorLevel)) { return; } - - this._logErrorLevel = level; - this._updateBadge(); } _onApiLogIndicatorClear() { @@ -791,8 +874,8 @@ class Backend { const results = []; for (const target of targets) { try { - this._modifySetting(target); - results.push({result: true}); + const result = this._modifySetting(target); + results.push({result: utilIsolate(result)}); } catch (e) { results.push({error: errorToJson(e)}); } @@ -801,10 +884,29 @@ class Backend { return results; } + _onApiGetSettings({targets}) { + const results = []; + for (const target of targets) { + try { + const result = this._getSetting(target); + results.push({result: utilIsolate(result)}); + } catch (e) { + results.push({error: errorToJson(e)}); + } + } + return results; + } + + async _onApiSetAllSettings({value, source}) { + this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, value); + await this._onApiOptionsSave({source}); + } + // Command handlers _createActionListenerPort(port, sender, handlers) { let hasStarted = false; + let messageString = ''; const onProgress = (...data) => { try { @@ -815,12 +917,34 @@ class Backend { } }; - const onMessage = async ({action, params}) => { + const onMessage = (message) => { if (hasStarted) { return; } - hasStarted = true; - port.onMessage.removeListener(onMessage); try { + const {action, data} = message; + switch (action) { + case 'fragment': + messageString += data; + break; + case 'invoke': + { + hasStarted = true; + port.onMessage.removeListener(onMessage); + + const messageData = JSON.parse(messageString); + messageString = null; + onMessageComplete(messageData); + } + break; + } + } catch (e) { + cleanup(e); + } + }; + + const onMessageComplete = async (message) => { + try { + const {action, params} = message; port.postMessage({type: 'ack'}); const messageHandler = handlers.get(action); @@ -837,25 +961,29 @@ class Backend { const result = async ? await promiseOrResult : promiseOrResult; port.postMessage({type: 'complete', data: result}); } catch (e) { - if (port !== null) { - port.postMessage({type: 'error', data: errorToJson(e)}); - } - cleanup(); + cleanup(e); } }; - const cleanup = () => { + const onDisconnect = () => { + cleanup(null); + }; + + const cleanup = (error) => { if (port === null) { return; } + if (error !== null) { + port.postMessage({type: 'error', data: errorToJson(error)}); + } if (!hasStarted) { port.onMessage.removeListener(onMessage); } - port.onDisconnect.removeListener(cleanup); + port.onDisconnect.removeListener(onDisconnect); port = null; handlers = null; }; port.onMessage.addListener(onMessage); - port.onDisconnect.addListener(cleanup); + port.onDisconnect.addListener(onDisconnect); } _getErrorLevelValue(errorLevel) { @@ -951,13 +1079,8 @@ class Backend { } async _onCommandToggle() { - const optionsContext = { - depth: 0, - url: window.location.href - }; const source = 'popup'; - - const options = this.getOptions(optionsContext); + const options = this.getOptions(this.optionsContext); options.general.enable = !options.general.enable; await this._onApiOptionsSave({source}); } @@ -977,45 +1100,53 @@ class Backend { } } - async _modifySetting(target) { + _getSetting(target) { + const options = this._getModifySettingObject(target); + const accessor = new ObjectPropertyAccessor(options); + const {path} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + return accessor.get(ObjectPropertyAccessor.getPathArray(path)); + } + + _modifySetting(target) { const options = this._getModifySettingObject(target); const accessor = new ObjectPropertyAccessor(options); const action = target.action; switch (action) { case 'set': - { - const {path, value} = target; - if (typeof path !== 'string') { throw new Error('Invalid path'); } - accessor.set(ObjectPropertyAccessor.getPathArray(path), value); - } - break; + { + const {path, value} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + const pathArray = ObjectPropertyAccessor.getPathArray(path); + accessor.set(pathArray, value); + return accessor.get(pathArray); + } case 'delete': - { - const {path} = target; - if (typeof path !== 'string') { throw new Error('Invalid path'); } - accessor.delete(ObjectPropertyAccessor.getPathArray(path)); - } - break; + { + const {path} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + accessor.delete(ObjectPropertyAccessor.getPathArray(path)); + return true; + } case 'swap': - { - const {path1, path2} = target; - if (typeof path1 !== 'string') { throw new Error('Invalid path1'); } - if (typeof path2 !== 'string') { throw new Error('Invalid path2'); } - accessor.swap(ObjectPropertyAccessor.getPathArray(path1), ObjectPropertyAccessor.getPathArray(path2)); - } - break; + { + const {path1, path2} = target; + if (typeof path1 !== 'string') { throw new Error('Invalid path1'); } + if (typeof path2 !== 'string') { throw new Error('Invalid path2'); } + accessor.swap(ObjectPropertyAccessor.getPathArray(path1), ObjectPropertyAccessor.getPathArray(path2)); + return true; + } case 'splice': - { - const {path, start, deleteCount, items} = target; - if (typeof path !== 'string') { throw new Error('Invalid path'); } - if (typeof start !== 'number' || Math.floor(start) !== start) { throw new Error('Invalid start'); } - if (typeof deleteCount !== 'number' || Math.floor(deleteCount) !== deleteCount) { throw new Error('Invalid deleteCount'); } - if (!Array.isArray(items)) { throw new Error('Invalid items'); } - const array = accessor.get(ObjectPropertyAccessor.getPathArray(path)); - if (!Array.isArray(array)) { throw new Error('Invalid target type'); } - array.splice(start, deleteCount, ...items); - } - break; + { + const {path, start, deleteCount, items} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + if (typeof start !== 'number' || Math.floor(start) !== start) { throw new Error('Invalid start'); } + if (typeof deleteCount !== 'number' || Math.floor(deleteCount) !== deleteCount) { throw new Error('Invalid deleteCount'); } + if (!Array.isArray(items)) { throw new Error('Invalid items'); } + const array = accessor.get(ObjectPropertyAccessor.getPathArray(path)); + if (!Array.isArray(array)) { throw new Error('Invalid target type'); } + return array.splice(start, deleteCount, ...items); + } default: throw new Error(`Unknown action: ${action}`); } @@ -1113,7 +1244,7 @@ class Backend { } async _renderTemplate(template, data) { - return handlebarsRenderDynamic(template, data); + return await this._templateRenderer.render(template, data); } _getTemplates(options) { @@ -1211,3 +1342,57 @@ class Backend { } } } + +class BackendEventHandler { + constructor(backend) { + this._backend = backend; + } + + prepare() { + if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) { + const onCommand = this._createGenericEventHandler((...args) => this._backend.handleCommand(...args)); + chrome.commands.onCommand.addListener(onCommand); + } + + if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) { + const onZoomChange = this._createGenericEventHandler((...args) => this._backend.handleZoomChange(...args)); + chrome.tabs.onZoomChange.addListener(onZoomChange); + } + + const onConnect = this._createGenericEventHandler((...args) => this._backend.handleConnect(...args)); + chrome.runtime.onConnect.addListener(onConnect); + + const onMessage = this._onMessage.bind(this); + chrome.runtime.onMessage.addListener(onMessage); + } + + // Event handlers + + _createGenericEventHandler(handler) { + return this._onGenericEvent.bind(this, handler); + } + + _onGenericEvent(handler, ...args) { + if (this._backend.isPrepared()) { + handler(...args); + return; + } + + this._backend.prepareComplete().then( + () => { handler(...args); }, + () => {} // NOP + ); + } + + _onMessage(message, sender, sendResponse) { + if (this._backend.isPrepared()) { + return this._backend.handleMessage(message, sender, sendResponse); + } + + this._backend.prepareComplete().then( + () => { this._backend.handleMessage(message, sender, sendResponse); }, + () => { sendResponse(); } // NOP + ); + return true; + } +} diff --git a/ext/bg/js/background-main.js b/ext/bg/js/background-main.js index 24117f4e..9dd447c4 100644 --- a/ext/bg/js/background-main.js +++ b/ext/bg/js/background-main.js @@ -17,9 +17,15 @@ /* global * Backend + * BackendEventHandler */ -(async () => { - window.yomichanBackend = new Backend(); - await window.yomichanBackend.prepare(); +(() => { + const backend = new Backend(); + const backendEventHandler = new BackendEventHandler(backend); + backendEventHandler.prepare(); + if (typeof window === 'object' && window !== null) { + window.yomichanBackend = backend; + } + backend.prepare(); })(); diff --git a/ext/bg/js/context-main.js b/ext/bg/js/context-main.js index dbba0272..4a2ea168 100644 --- a/ext/bg/js/context-main.js +++ b/ext/bg/js/context-main.js @@ -16,11 +16,7 @@ */ /* global - * apiCommandExec - * apiForwardLogsToBackend - * apiGetEnvironmentInfo - * apiLogIndicatorClear - * apiOptionsGet + * api */ function showExtensionInfo() { @@ -36,12 +32,12 @@ function setupButtonEvents(selector, command, url) { for (const node of nodes) { node.addEventListener('click', (e) => { if (e.button !== 0) { return; } - apiCommandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'}); + api.commandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'}); e.preventDefault(); }, false); node.addEventListener('auxclick', (e) => { if (e.button !== 1) { return; } - apiCommandExec(command, {mode: 'newTab'}); + api.commandExec(command, {mode: 'newTab'}); e.preventDefault(); }, false); @@ -54,14 +50,14 @@ function setupButtonEvents(selector, command, url) { } async function mainInner() { - apiForwardLogsToBackend(); + api.forwardLogsToBackend(); await yomichan.prepare(); - await apiLogIndicatorClear(); + await api.logIndicatorClear(); showExtensionInfo(); - apiGetEnvironmentInfo().then(({browser}) => { + api.getEnvironmentInfo().then(({browser}) => { // Firefox mobile opens this page as a full webpage. document.documentElement.dataset.mode = (browser === 'firefox-mobile' ? 'full' : 'mini'); }); @@ -70,25 +66,23 @@ async function mainInner() { setupButtonEvents('.action-open-search', 'search', chrome.runtime.getURL('/bg/search.html')); setupButtonEvents('.action-open-options', 'options', chrome.runtime.getURL(manifest.options_ui.page)); - setupButtonEvents('.action-open-help', 'help'); + setupButtonEvents('.action-open-help', 'help', 'https://foosoft.net/projects/yomichan/'); const optionsContext = { depth: 0, url: window.location.href }; - apiOptionsGet(optionsContext).then((options) => { + api.optionsGet(optionsContext).then((options) => { const toggle = document.querySelector('#enable-search'); toggle.checked = options.general.enable; - toggle.addEventListener('change', () => apiCommandExec('toggle'), false); + toggle.addEventListener('change', () => api.commandExec('toggle'), false); const toggle2 = document.querySelector('#enable-search2'); toggle2.checked = options.general.enable; - toggle2.addEventListener('change', () => apiCommandExec('toggle'), false); + toggle2.addEventListener('change', () => api.commandExec('toggle'), false); setTimeout(() => { - for (const n of document.querySelectorAll('.toggle-group')) { - n.classList.add('toggle-group-animated'); - } + document.body.dataset.loaded = 'true'; }, 10); }); } diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js index 930cd0d0..47f1ebdd 100644 --- a/ext/bg/js/database.js +++ b/ext/bg/js/database.js @@ -16,423 +16,422 @@ */ /* global + * GenericDatabase * dictFieldSplit */ class Database { constructor() { - this.db = null; + this._db = new GenericDatabase(); + this._dbName = 'dict'; this._schemas = new Map(); } // Public async prepare() { - if (this.db !== null) { - throw new Error('Database already initialized'); - } - - try { - this.db = await Database._open('dict', 6, (db, transaction, oldVersion) => { - Database._upgrade(db, transaction, oldVersion, [ - { - version: 2, - stores: { - terms: { - primaryKey: {keyPath: 'id', autoIncrement: true}, - indices: ['dictionary', 'expression', 'reading'] - }, - kanji: { - primaryKey: {autoIncrement: true}, - indices: ['dictionary', 'character'] - }, - tagMeta: { - primaryKey: {autoIncrement: true}, - indices: ['dictionary'] - }, - dictionaries: { - primaryKey: {autoIncrement: true}, - indices: ['title', 'version'] - } + await this._db.open( + this._dbName, + 60, + [ + { + version: 20, + stores: { + terms: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'expression', 'reading'] + }, + kanji: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'character'] + }, + tagMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary'] + }, + dictionaries: { + primaryKey: {autoIncrement: true}, + indices: ['title', 'version'] } - }, - { - version: 3, - stores: { - termMeta: { - primaryKey: {autoIncrement: true}, - indices: ['dictionary', 'expression'] - }, - kanjiMeta: { - primaryKey: {autoIncrement: true}, - indices: ['dictionary', 'character'] - }, - tagMeta: { - primaryKey: {autoIncrement: true}, - indices: ['dictionary', 'name'] - } + } + }, + { + version: 30, + stores: { + termMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'expression'] + }, + kanjiMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'character'] + }, + tagMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'name'] } - }, - { - version: 4, - stores: { - terms: { - primaryKey: {keyPath: 'id', autoIncrement: true}, - indices: ['dictionary', 'expression', 'reading', 'sequence'] - } + } + }, + { + version: 40, + stores: { + terms: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'expression', 'reading', 'sequence'] } - }, - { - version: 5, - stores: { - terms: { - primaryKey: {keyPath: 'id', autoIncrement: true}, - indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse'] - } + } + }, + { + version: 50, + stores: { + terms: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse'] } - }, - { - version: 6, - stores: { - media: { - primaryKey: {keyPath: 'id', autoIncrement: true}, - indices: ['dictionary', 'path'] - } + } + }, + { + version: 60, + stores: { + media: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'path'] } } - ]); - }); - return true; - } catch (e) { - yomichan.logError(e); - return false; - } + } + ] + ); } async close() { - this._validate(); - this.db.close(); - this.db = null; + this._db.close(); } isPrepared() { - return this.db !== null; + return this._db.isOpen(); } async purge() { - this._validate(); - - this.db.close(); - await Database._deleteDatabase(this.db.name); - this.db = null; - + if (this._db.isOpening()) { + throw new Error('Cannot purge database while opening'); + } + if (this._db.isOpen()) { + this._db.close(); + } + await GenericDatabase.deleteDatabase(this._dbName); await this.prepare(); } async deleteDictionary(dictionaryName, progressSettings, onProgress) { - this._validate(); - const targets = [ ['dictionaries', 'title'], ['kanji', 'dictionary'], ['kanjiMeta', 'dictionary'], ['terms', 'dictionary'], ['termMeta', 'dictionary'], - ['tagMeta', 'dictionary'] + ['tagMeta', 'dictionary'], + ['media', 'dictionary'] ]; - const promises = []; + + const {rate} = progressSettings; const progressData = { count: 0, processed: 0, storeCount: targets.length, storesProcesed: 0 }; - let progressRate = (typeof progressSettings === 'object' && progressSettings !== null ? progressSettings.rate : 0); - if (typeof progressRate !== 'number' || progressRate <= 0) { - progressRate = 1000; - } - - for (const [objectStoreName, index] of targets) { - const dbTransaction = this.db.transaction([objectStoreName], 'readwrite'); - 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)); - } - - await Promise.all(promises); - } - - async findTermsBulk(termList, dictionaries, wildcard) { - this._validate(); - const promises = []; - const visited = new Set(); - const results = []; - const processRow = (row, index) => { - if (dictionaries.has(row.dictionary) && !visited.has(row.id)) { - visited.add(row.id); - results.push(Database._createTerm(row, index)); + const filterKeys = (keys) => { + ++progressData.storesProcesed; + progressData.count += keys.length; + onProgress(progressData); + return keys; + }; + const onProgress2 = () => { + const processed = progressData.processed + 1; + progressData.processed = processed; + if ((processed % rate) === 0 || processed === progressData.count) { + onProgress(progressData); } }; - const useWildcard = !!wildcard; - const prefixWildcard = wildcard === 'prefix'; - - const dbTransaction = this.db.transaction(['terms'], 'readonly'); - const dbTerms = dbTransaction.objectStore('terms'); - const dbIndex1 = dbTerms.index(prefixWildcard ? 'expressionReverse' : 'expression'); - const dbIndex2 = dbTerms.index(prefixWildcard ? 'readingReverse' : 'reading'); - - for (let i = 0; i < termList.length; ++i) { - 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) - ); + const promises = []; + for (const [objectStoreName, indexName] of targets) { + const query = IDBKeyRange.only(dictionaryName); + const promise = this._db.bulkDelete(objectStoreName, indexName, query, filterKeys, onProgress2); + promises.push(promise); } - await Promise.all(promises); - - return results; } - async findTermsExactBulk(termList, readingList, dictionaries) { - this._validate(); - - const promises = []; - const results = []; - const processRow = (row, index) => { - if (row.reading === readingList[index] && dictionaries.has(row.dictionary)) { - results.push(Database._createTerm(row, index)); + findTermsBulk(termList, dictionaries, wildcard) { + return new Promise((resolve, reject) => { + const results = []; + const count = termList.length; + if (count === 0) { + resolve(results); + return; } - }; - - const dbTransaction = this.db.transaction(['terms'], 'readonly'); - const dbTerms = dbTransaction.objectStore('terms'); - const dbIndex = dbTerms.index('expression'); - for (let i = 0; i < termList.length; ++i) { - const only = IDBKeyRange.only(termList[i]); - promises.push(Database._getAll(dbIndex, only, i, processRow)); - } - - await Promise.all(promises); + const visited = new Set(); + const useWildcard = !!wildcard; + const prefixWildcard = wildcard === 'prefix'; + + const transaction = this._db.transaction(['terms'], 'readonly'); + const terms = transaction.objectStore('terms'); + const index1 = terms.index(prefixWildcard ? 'expressionReverse' : 'expression'); + const index2 = terms.index(prefixWildcard ? 'readingReverse' : 'reading'); + + const count2 = count * 2; + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const term = prefixWildcard ? stringReverse(termList[i]) : termList[i]; + const query = useWildcard ? IDBKeyRange.bound(term, `${term}\uffff`, false, false) : IDBKeyRange.only(term); + + const onGetAll = (rows) => { + for (const row of rows) { + if (dictionaries.has(row.dictionary) && !visited.has(row.id)) { + visited.add(row.id); + results.push(this._createTerm(row, inputIndex)); + } + } + if (++completeCount >= count2) { + resolve(results); + } + }; - return results; + this._db.getAll(index1, query, onGetAll, reject); + this._db.getAll(index2, query, onGetAll, reject); + } + }); } - async findTermsBySequenceBulk(sequenceList, mainDictionary) { - this._validate(); - - const promises = []; - const results = []; - const processRow = (row, index) => { - if (row.dictionary === mainDictionary) { - results.push(Database._createTerm(row, index)); + findTermsExactBulk(termList, readingList, dictionaries) { + return new Promise((resolve, reject) => { + const results = []; + const count = termList.length; + if (count === 0) { + resolve(results); + return; } - }; - - const dbTransaction = this.db.transaction(['terms'], 'readonly'); - const dbTerms = dbTransaction.objectStore('terms'); - const dbIndex = dbTerms.index('sequence'); - - for (let i = 0; i < sequenceList.length; ++i) { - const only = IDBKeyRange.only(sequenceList[i]); - promises.push(Database._getAll(dbIndex, only, i, processRow)); - } - - await Promise.all(promises); - return results; - } - - async findTermMetaBulk(termList, dictionaries) { - return this._findGenericBulk('termMeta', 'expression', termList, dictionaries, Database._createTermMeta); - } + const transaction = this._db.transaction(['terms'], 'readonly'); + const terms = transaction.objectStore('terms'); + const index = terms.index('expression'); - async findKanjiBulk(kanjiList, dictionaries) { - return this._findGenericBulk('kanji', 'character', kanjiList, dictionaries, Database._createKanji); - } + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const reading = readingList[i]; + const query = IDBKeyRange.only(termList[i]); - async findKanjiMetaBulk(kanjiList, dictionaries) { - return this._findGenericBulk('kanjiMeta', 'character', kanjiList, dictionaries, Database._createKanjiMeta); - } + const onGetAll = (rows) => { + for (const row of rows) { + if (row.reading === reading && dictionaries.has(row.dictionary)) { + results.push(this._createTerm(row, inputIndex)); + } + } + if (++completeCount >= count) { + resolve(results); + } + }; - async findTagForTitle(name, title) { - 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) => { - if (title === row.dictionary) { - result = row; + this._db.getAll(index, query, onGetAll, reject); } }); - - return result; } - async getMedia(targets) { - this._validate(); - - const count = targets.length; - const promises = []; - const results = new Array(count).fill(null); - const createResult = Database._createMedia; - const processRow = (row, [index, dictionaryName]) => { - if (row.dictionary === dictionaryName) { - results[index] = createResult(row, index); + findTermsBySequenceBulk(sequenceList, mainDictionary) { + return new Promise((resolve, reject) => { + const results = []; + const count = sequenceList.length; + if (count === 0) { + resolve(results); + return; } - }; - const transaction = this.db.transaction(['media'], 'readonly'); - const objectStore = transaction.objectStore('media'); - const index = objectStore.index('path'); + const transaction = this._db.transaction(['terms'], 'readonly'); + const terms = transaction.objectStore('terms'); + const index = terms.index('sequence'); - for (let i = 0; i < count; ++i) { - const {path, dictionaryName} = targets[i]; - const only = IDBKeyRange.only(path); - promises.push(Database._getAll(index, only, [i, dictionaryName], processRow)); - } + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const query = IDBKeyRange.only(sequenceList[i]); - await Promise.all(promises); + const onGetAll = (rows) => { + for (const row of rows) { + if (row.dictionary === mainDictionary) { + results.push(this._createTerm(row, inputIndex)); + } + } + if (++completeCount >= count) { + resolve(results); + } + }; - return results; + this._db.getAll(index, query, onGetAll, reject); + } + }); } - async getDictionaryInfo() { - this._validate(); + findTermMetaBulk(termList, dictionaries) { + return this._findGenericBulk('termMeta', 'expression', termList, dictionaries, this._createTermMeta.bind(this)); + } - const results = []; - const dbTransaction = this.db.transaction(['dictionaries'], 'readonly'); - const dbDictionaries = dbTransaction.objectStore('dictionaries'); + findKanjiBulk(kanjiList, dictionaries) { + return this._findGenericBulk('kanji', 'character', kanjiList, dictionaries, this._createKanji.bind(this)); + } - await Database._getAll(dbDictionaries, null, null, (info) => results.push(info)); + findKanjiMetaBulk(kanjiList, dictionaries) { + return this._findGenericBulk('kanjiMeta', 'character', kanjiList, dictionaries, this._createKanjiMeta.bind(this)); + } - return results; + findTagForTitle(name, title) { + const query = IDBKeyRange.only(name); + return this._db.find('tagMeta', 'name', query, (row) => (row.dictionary === title), null); } - async getDictionaryCounts(dictionaryNames, getTotal) { - this._validate(); + getMedia(targets) { + return new Promise((resolve, reject) => { + const count = targets.length; + const results = new Array(count).fill(null); + if (count === 0) { + resolve(results); + return; + } - const objectStoreNames = [ - 'kanji', - 'kanjiMeta', - 'terms', - 'termMeta', - 'tagMeta' - ]; - const dbCountTransaction = this.db.transaction(objectStoreNames, 'readonly'); - - const targets = []; - for (const objectStoreName of objectStoreNames) { - targets.push([ - objectStoreName, - dbCountTransaction.objectStore(objectStoreName).index('dictionary') - ]); - } + let completeCount = 0; + const transaction = this._db.transaction(['media'], 'readonly'); + const objectStore = transaction.objectStore('media'); + const index = objectStore.index('path'); - // 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 counts = []; - const countPromises = []; - for (let i = 0; i < dictionaryNames.length; ++i) { - counts.push(null); - const index = i; - const query2 = IDBKeyRange.only(dictionaryNames[i]); - const countPromise = Database._getCounts(targets, query2).then((v) => counts[index] = v); - countPromises.push(countPromise); - } - await Promise.all(countPromises); + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const {path, dictionaryName} = targets[i]; + const query = IDBKeyRange.only(path); - const result = {counts}; - if (totalPromise !== null) { - result.total = await totalPromise; - } - return result; + const onGetAll = (rows) => { + for (const row of rows) { + if (row.dictionary !== dictionaryName) { continue; } + results[inputIndex] = this._createMedia(row, inputIndex); + } + if (++completeCount >= count) { + resolve(results); + } + }; + + this._db.getAll(index, query, onGetAll, reject); + } + }); } - async dictionaryExists(title) { - this._validate(); - const transaction = this.db.transaction(['dictionaries'], 'readonly'); - const index = transaction.objectStore('dictionaries').index('title'); - const query = IDBKeyRange.only(title); - const count = await Database._getCount(index, query); - return count > 0; + getDictionaryInfo() { + return new Promise((resolve, reject) => { + const transaction = this._db.transaction(['dictionaries'], 'readonly'); + const objectStore = transaction.objectStore('dictionaries'); + this._db.getAll(objectStore, null, resolve, reject); + }); } - bulkAdd(objectStoreName, items, start, count) { + getDictionaryCounts(dictionaryNames, getTotal) { return new Promise((resolve, reject) => { - const transaction = this.db.transaction([objectStoreName], 'readwrite'); - const objectStore = transaction.objectStore(objectStoreName); + const targets = [ + ['kanji', 'dictionary'], + ['kanjiMeta', 'dictionary'], + ['terms', 'dictionary'], + ['termMeta', 'dictionary'], + ['tagMeta', 'dictionary'], + ['media', 'dictionary'] + ]; + const objectStoreNames = targets.map(([objectStoreName]) => objectStoreName); + const transaction = this._db.transaction(objectStoreNames, 'readonly'); + const databaseTargets = targets.map(([objectStoreName, indexName]) => { + const objectStore = transaction.objectStore(objectStoreName); + const index = objectStore.index(indexName); + return {objectStore, index}; + }); - if (start + count > items.length) { - count = items.length - start; + const countTargets = []; + if (getTotal) { + for (const {objectStore} of databaseTargets) { + countTargets.push([objectStore, null]); + } } - - if (count <= 0) { - resolve(); - return; + for (const dictionaryName of dictionaryNames) { + const query = IDBKeyRange.only(dictionaryName); + for (const {index} of databaseTargets) { + countTargets.push([index, query]); + } } - const end = start + count; - let completedCount = 0; - const onError = (e) => reject(e); - const onSuccess = () => { - if (++completedCount >= count) { - resolve(); + const onCountComplete = (results) => { + const resultCount = results.length; + const targetCount = targets.length; + const counts = []; + for (let i = 0; i < resultCount; i += targetCount) { + const countGroup = {}; + for (let j = 0; j < targetCount; ++j) { + countGroup[targets[j][0]] = results[i + j]; + } + counts.push(countGroup); } + const total = getTotal ? counts.shift() : null; + resolve({total, counts}); }; - for (let i = start; i < end; ++i) { - const request = objectStore.add(items[i]); - request.onerror = onError; - request.onsuccess = onSuccess; - } + this._db.bulkCount(countTargets, onCountComplete, reject); }); } - // Private + async dictionaryExists(title) { + const query = IDBKeyRange.only(title); + const result = await this._db.find('dictionaries', 'title', query); + return typeof result !== 'undefined'; + } - _validate() { - if (this.db === null) { - throw new Error('Database not initialized'); - } + bulkAdd(objectStoreName, items, start, count) { + return this._db.bulkAdd(objectStoreName, items, start, count); } - async _findGenericBulk(tableName, indexName, indexValueList, dictionaries, createResult) { - this._validate(); + // Private - const promises = []; - const results = []; - const processRow = (row, index) => { - if (dictionaries.has(row.dictionary)) { - results.push(createResult(row, index)); + async _findGenericBulk(objectStoreName, indexName, indexValueList, dictionaries, createResult) { + return new Promise((resolve, reject) => { + const results = []; + const count = indexValueList.length; + if (count === 0) { + resolve(results); + return; } - }; - const dbTransaction = this.db.transaction([tableName], 'readonly'); - const dbTerms = dbTransaction.objectStore(tableName); - const dbIndex = dbTerms.index(indexName); + const transaction = this._db.transaction([objectStoreName], 'readonly'); + const terms = transaction.objectStore(objectStoreName); + const index = terms.index(indexName); - for (let i = 0; i < indexValueList.length; ++i) { - const only = IDBKeyRange.only(indexValueList[i]); - promises.push(Database._getAll(dbIndex, only, i, processRow)); - } + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const query = IDBKeyRange.only(indexValueList[i]); - await Promise.all(promises); + const onGetAll = (rows) => { + for (const row of rows) { + if (dictionaries.has(row.dictionary)) { + results.push(createResult(row, inputIndex)); + } + } + if (++completeCount >= count) { + resolve(results); + } + }; - return results; + this._db.getAll(index, query, onGetAll, reject); + } + }); } - static _createTerm(row, index) { + _createTerm(row, index) { return { index, expression: row.expression, @@ -448,7 +447,7 @@ class Database { }; } - static _createKanji(row, index) { + _createKanji(row, index) { return { index, character: row.character, @@ -461,193 +460,15 @@ class Database { }; } - static _createTermMeta({expression, mode, data, dictionary}, index) { + _createTermMeta({expression, mode, data, dictionary}, index) { return {expression, mode, data, dictionary, index}; } - static _createKanjiMeta({character, mode, data, dictionary}, index) { + _createKanjiMeta({character, mode, data, dictionary}, index) { return {character, mode, data, dictionary, index}; } - static _createMedia(row, index) { + _createMedia(row, index) { return Object.assign({}, row, {index}); } - - 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) { - return new Promise((resolve, reject) => { - const request = dbIndex.getAll(query); - request.onerror = (e) => reject(e); - request.onsuccess = (e) => { - for (const row of e.target.result) { - processRow(row, context); - } - resolve(); - }; - }); - } - - static _getAllUsingCursor(dbIndex, query, context, processRow) { - return new Promise((resolve, reject) => { - const request = dbIndex.openCursor(query, 'next'); - request.onerror = (e) => reject(e); - request.onsuccess = (e) => { - const cursor = e.target.result; - if (cursor) { - processRow(cursor.value, context); - cursor.continue(); - } else { - resolve(); - } - }; - }); - } - - 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); - countPromises.push(countPromise); - } - return Promise.all(countPromises).then(() => counts); - } - - static _getCount(dbIndex, query) { - return new Promise((resolve, reject) => { - const request = dbIndex.count(query); - request.onerror = (e) => reject(e); - request.onsuccess = (e) => resolve(e.target.result); - }); - } - - static _getAllKeys(dbIndex, query) { - const fn = typeof dbIndex.getAllKeys === 'function' ? Database._getAllKeysFast : Database._getAllKeysUsingCursor; - return fn(dbIndex, query); - } - - static _getAllKeysFast(dbIndex, query) { - return new Promise((resolve, reject) => { - const request = dbIndex.getAllKeys(query); - request.onerror = (e) => reject(e); - request.onsuccess = (e) => resolve(e.target.result); - }); - } - - static _getAllKeysUsingCursor(dbIndex, query) { - return new Promise((resolve, reject) => { - const primaryKeys = []; - const request = dbIndex.openKeyCursor(query, 'next'); - request.onerror = (e) => reject(e); - request.onsuccess = (e) => { - const cursor = e.target.result; - if (cursor) { - primaryKeys.push(cursor.primaryKey); - cursor.continue(); - } else { - resolve(primaryKeys); - } - }; - }); - } - - static async _deleteValues(dbObjectStore, dbIndex, query, onProgress, progressData, progressRate) { - const hasProgress = (typeof onProgress === 'function'); - const count = await Database._getCount(dbIndex, query); - ++progressData.storesProcesed; - progressData.count += count; - if (hasProgress) { - onProgress(progressData); - } - - const onValueDeleted = ( - hasProgress ? - () => { - const p = ++progressData.processed; - if ((p % progressRate) === 0 || p === progressData.count) { - onProgress(progressData); - } - } : - () => {} - ); - - const promises = []; - const primaryKeys = await Database._getAllKeys(dbIndex, query); - for (const key of primaryKeys) { - const promise = Database._deleteValue(dbObjectStore, key).then(onValueDeleted); - promises.push(promise); - } - - await Promise.all(promises); - } - - static _deleteValue(dbObjectStore, key) { - return new Promise((resolve, reject) => { - const request = dbObjectStore.delete(key); - request.onerror = (e) => reject(e); - request.onsuccess = () => resolve(); - }); - } - - static _open(name, version, onUpgradeNeeded) { - return new Promise((resolve, reject) => { - const request = window.indexedDB.open(name, version * 10); - - request.onupgradeneeded = (event) => { - try { - request.transaction.onerror = (e) => reject(e); - onUpgradeNeeded(request.result, request.transaction, event.oldVersion / 10, event.newVersion / 10); - } catch (e) { - reject(e); - } - }; - - request.onerror = (e) => reject(e); - request.onsuccess = () => resolve(request.result); - }); - } - - static _upgrade(db, transaction, oldVersion, upgrades) { - for (const {version, stores} of upgrades) { - if (oldVersion >= version) { continue; } - - const objectStoreNames = Object.keys(stores); - for (const objectStoreName of objectStoreNames) { - const {primaryKey, indices} = stores[objectStoreName]; - - const objectStoreNames2 = transaction.objectStoreNames || db.objectStoreNames; - const objectStore = ( - Database._listContains(objectStoreNames2, objectStoreName) ? - transaction.objectStore(objectStoreName) : - db.createObjectStore(objectStoreName, primaryKey) - ); - - for (const indexName of indices) { - if (Database._listContains(objectStore.indexNames, indexName)) { continue; } - - objectStore.createIndex(indexName, indexName, {}); - } - } - } - } - - static _deleteDatabase(dbName) { - return new Promise((resolve, reject) => { - const request = indexedDB.deleteDatabase(dbName); - request.onerror = (e) => reject(e); - request.onsuccess = () => resolve(); - }); - } - - static _listContains(list, value) { - for (let i = 0, ii = list.length; i < ii; ++i) { - if (list[i] === value) { return true; } - } - return false; - } } diff --git a/ext/bg/js/generic-database.js b/ext/bg/js/generic-database.js new file mode 100644 index 00000000..a82ad650 --- /dev/null +++ b/ext/bg/js/generic-database.js @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class GenericDatabase { + constructor() { + this._db = null; + this._isOpening = false; + } + + // Public + + async open(databaseName, version, structure) { + if (this._db !== null) { + throw new Error('Database already open'); + } + if (this._isOpening) { + throw new Error('Already opening'); + } + + try { + this._isOpening = true; + this._db = await this._open(databaseName, version, (db, transaction, oldVersion) => { + this._upgrade(db, transaction, oldVersion, structure); + }); + } finally { + this._isOpening = false; + } + } + + close() { + if (this._db === null) { + throw new Error('Database is not open'); + } + + this._db.close(); + this._db = null; + } + + isOpening() { + return this._isOpening; + } + + isOpen() { + return this._db !== null; + } + + transaction(storeNames, mode) { + if (this._db === null) { + throw new Error(this._isOpening ? 'Database not ready' : 'Database not open'); + } + return this._db.transaction(storeNames, mode); + } + + bulkAdd(objectStoreName, items, start, count) { + return new Promise((resolve, reject) => { + if (start + count > items.length) { + count = items.length - start; + } + + if (count <= 0) { + resolve(); + return; + } + + const end = start + count; + let completedCount = 0; + const onError = (e) => reject(e.target.error); + const onSuccess = () => { + if (++completedCount >= count) { + resolve(); + } + }; + + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + for (let i = start; i < end; ++i) { + const request = objectStore.add(items[i]); + request.onerror = onError; + request.onsuccess = onSuccess; + } + }); + } + + getAll(objectStoreOrIndex, query, resolve, reject) { + if (typeof objectStoreOrIndex.getAll === 'function') { + this._getAllFast(objectStoreOrIndex, query, resolve, reject); + } else { + this._getAllUsingCursor(objectStoreOrIndex, query, resolve, reject); + } + } + + getAllKeys(objectStoreOrIndex, query, resolve, reject) { + if (typeof objectStoreOrIndex.getAll === 'function') { + this._getAllKeysFast(objectStoreOrIndex, query, resolve, reject); + } else { + this._getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject); + } + } + + find(objectStoreName, indexName, query, predicate=null, defaultValue) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readonly'); + const objectStore = transaction.objectStore(objectStoreName); + const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; + const request = objectStoreOrIndex.openCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + const value = cursor.value; + if (typeof predicate !== 'function' || predicate(value)) { + resolve(value); + } else { + cursor.continue(); + } + } else { + resolve(defaultValue); + } + }; + }); + } + + bulkCount(targets, resolve, reject) { + const targetCount = targets.length; + if (targetCount <= 0) { + resolve(); + return; + } + + let completedCount = 0; + const results = new Array(targetCount).fill(null); + + const onError = (e) => reject(e.target.error); + const onSuccess = (e, index) => { + const count = e.target.result; + results[index] = count; + if (++completedCount >= targetCount) { + resolve(results); + } + }; + + for (let i = 0; i < targetCount; ++i) { + const index = i; + const [objectStoreOrIndex, query] = targets[i]; + const request = objectStoreOrIndex.count(query); + request.onerror = onError; + request.onsuccess = (e) => onSuccess(e, index); + } + } + + delete(objectStoreName, key) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + const request = objectStore.delete(key); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(); + }); + } + + bulkDelete(objectStoreName, indexName, query, filterKeys=null, onProgress=null) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; + + const onGetKeys = (keys) => { + try { + if (typeof filterKeys === 'function') { + keys = filterKeys(keys); + } + this._bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject); + } catch (e) { + reject(e); + } + }; + + this.getAllKeys(objectStoreOrIndex, query, onGetKeys, reject); + }); + } + + static deleteDatabase(databaseName) { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(databaseName); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(); + request.onblocked = () => reject(new Error('Database deletion blocked')); + }); + } + + // Private + + _open(name, version, onUpgradeNeeded) { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, version); + + request.onupgradeneeded = (event) => { + try { + request.transaction.onerror = (e) => reject(e.target.error); + onUpgradeNeeded(request.result, request.transaction, event.oldVersion, event.newVersion); + } catch (e) { + reject(e); + } + }; + + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(request.result); + }); + } + + _upgrade(db, transaction, oldVersion, upgrades) { + for (const {version, stores} of upgrades) { + if (oldVersion >= version) { continue; } + + for (const [objectStoreName, {primaryKey, indices}] of Object.entries(stores)) { + const existingObjectStoreNames = transaction.objectStoreNames || db.objectStoreNames; + const objectStore = ( + this._listContains(existingObjectStoreNames, objectStoreName) ? + transaction.objectStore(objectStoreName) : + db.createObjectStore(objectStoreName, primaryKey) + ); + const existingIndexNames = objectStore.indexNames; + + for (const indexName of indices) { + if (this._listContains(existingIndexNames, indexName)) { continue; } + + objectStore.createIndex(indexName, indexName, {}); + } + } + } + } + + _listContains(list, value) { + for (let i = 0, ii = list.length; i < ii; ++i) { + if (list[i] === value) { return true; } + } + return false; + } + + _getAllFast(objectStoreOrIndex, query, resolve, reject) { + const request = objectStoreOrIndex.getAll(query); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => resolve(e.target.result); + } + + _getAllUsingCursor(objectStoreOrIndex, query, resolve, reject) { + const results = []; + const request = objectStoreOrIndex.openCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + results.push(cursor.value); + cursor.continue(); + } else { + resolve(results); + } + }; + } + + _getAllKeysFast(objectStoreOrIndex, query, resolve, reject) { + const request = objectStoreOrIndex.getAllKeys(query); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => resolve(e.target.result); + } + + _getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject) { + const results = []; + const request = objectStoreOrIndex.openKeyCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + results.push(cursor.primaryKey); + cursor.continue(); + } else { + resolve(results); + } + }; + } + + _bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject) { + const count = keys.length; + if (count === 0) { + resolve(); + return; + } + + let completedCount = 0; + const hasProgress = (typeof onProgress === 'function'); + + const onError = (e) => reject(e.target.error); + const onSuccess = () => { + ++completedCount; + if (hasProgress) { + try { + onProgress(completedCount, count); + } catch (e) { + // NOP + } + } + if (completedCount >= count) { + resolve(); + } + }; + + for (const key of keys) { + const request = objectStore.delete(key); + request.onerror = onError; + request.onsuccess = onSuccess; + } + } +} diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js deleted file mode 100644 index 822174e2..00000000 --- a/ext/bg/js/handlebars.js +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright (C) 2016-2020 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -/* global - * Handlebars - * jp - */ - -function handlebarsEscape(text) { - return Handlebars.Utils.escapeExpression(text); -} - -function handlebarsDumpObject(options) { - const dump = JSON.stringify(options.fn(this), null, 4); - return handlebarsEscape(dump); -} - -function handlebarsFurigana(options) { - const definition = options.fn(this); - const segs = jp.distributeFurigana(definition.expression, definition.reading); - - let result = ''; - for (const seg of segs) { - if (seg.furigana) { - result += `<ruby>${seg.text}<rt>${seg.furigana}</rt></ruby>`; - } else { - result += seg.text; - } - } - - return result; -} - -function handlebarsFuriganaPlain(options) { - const definition = options.fn(this); - const segs = jp.distributeFurigana(definition.expression, definition.reading); - - let result = ''; - for (const seg of segs) { - if (seg.furigana) { - result += ` ${seg.text}[${seg.furigana}]`; - } else { - result += seg.text; - } - } - - return result.trimLeft(); -} - -function handlebarsKanjiLinks(options) { - let result = ''; - for (const c of options.fn(this)) { - if (jp.isCodePointKanji(c.codePointAt(0))) { - result += `<a href="#" class="kanji-link">${c}</a>`; - } else { - result += c; - } - } - - return result; -} - -function handlebarsMultiLine(options) { - return options.fn(this).split('\n').join('<br>'); -} - -function handlebarsSanitizeCssClass(options) { - return options.fn(this).replace(/[^_a-z0-9\u00a0-\uffff]/ig, '_'); -} - -function handlebarsRegexReplace(...args) { - // Usage: - // {{#regexReplace regex string [flags]}}content{{/regexReplace}} - // regex: regular expression string - // string: string to replace - // flags: optional flags for regular expression - // e.g. "i" for case-insensitive, "g" for replace all - let value = args[args.length - 1].fn(this); - if (args.length >= 3) { - try { - const flags = args.length > 3 ? args[2] : 'g'; - const regex = new RegExp(args[0], flags); - value = value.replace(regex, args[1]); - } catch (e) { - return `${e}`; - } - } - return value; -} - -function handlebarsRegexMatch(...args) { - // Usage: - // {{#regexMatch regex [flags]}}content{{/regexMatch}} - // regex: regular expression string - // flags: optional flags for regular expression - // e.g. "i" for case-insensitive, "g" for match all - let value = args[args.length - 1].fn(this); - if (args.length >= 2) { - try { - const flags = args.length > 2 ? args[1] : ''; - const regex = new RegExp(args[0], flags); - const parts = []; - value.replace(regex, (g0) => parts.push(g0)); - value = parts.join(''); - } catch (e) { - return `${e}`; - } - } - return value; -} - -function handlebarsMergeTags(object, isGroupMode, isMergeMode) { - const tagSources = []; - if (isGroupMode || isMergeMode) { - for (const definition of object.definitions) { - tagSources.push(definition.definitionTags); - } - } else { - tagSources.push(object.definitionTags); - } - - const tags = new Set(); - for (const tagSource of tagSources) { - for (const tag of tagSource) { - tags.add(tag.name); - } - } - - return [...tags].join(', '); -} - -function handlebarsRegisterHelpers() { - if (Handlebars.partials !== Handlebars.templates) { - Handlebars.partials = Handlebars.templates; - Handlebars.registerHelper('dumpObject', handlebarsDumpObject); - Handlebars.registerHelper('furigana', handlebarsFurigana); - Handlebars.registerHelper('furiganaPlain', handlebarsFuriganaPlain); - Handlebars.registerHelper('kanjiLinks', handlebarsKanjiLinks); - Handlebars.registerHelper('multiLine', handlebarsMultiLine); - Handlebars.registerHelper('sanitizeCssClass', handlebarsSanitizeCssClass); - Handlebars.registerHelper('regexReplace', handlebarsRegexReplace); - Handlebars.registerHelper('regexMatch', handlebarsRegexMatch); - Handlebars.registerHelper('mergeTags', handlebarsMergeTags); - } -} - -function handlebarsRenderDynamic(template, data) { - handlebarsRegisterHelpers(); - const cache = handlebarsRenderDynamic._cache; - let instance = cache.get(template); - if (typeof instance === 'undefined') { - instance = Handlebars.compile(template); - cache.set(template, instance); - } - - return instance(data).trim(); -} -handlebarsRenderDynamic._cache = new Map(); diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index 35fdde82..ccc56848 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -176,7 +176,9 @@ function profileOptionsCreateDefaults() { showPitchAccentDownstepNotation: true, showPitchAccentPositionNotation: true, showPitchAccentGraph: false, - showIframePopupsInRootFrame: false + showIframePopupsInRootFrame: false, + useSecurePopupFrameUrl: true, + usePopupShadowDom: true }, audio: { @@ -202,7 +204,8 @@ function profileOptionsCreateDefaults() { enablePopupSearch: false, enableOnPopupExpressions: false, enableOnSearchPage: true, - enableSearchTags: false + enableSearchTags: false, + layoutAwareScan: false }, translation: { diff --git a/ext/bg/js/request.js b/ext/bg/js/request.js index 957ac0f5..d1c6ed4e 100644 --- a/ext/bg/js/request.js +++ b/ext/bg/js/request.js @@ -16,28 +16,28 @@ */ -function requestText(url, action, params) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.overrideMimeType('text/plain'); - xhr.addEventListener('load', () => resolve(xhr.responseText)); - xhr.addEventListener('error', () => reject(new Error('Failed to connect'))); - xhr.open(action, url); - if (params) { - xhr.send(JSON.stringify(params)); - } else { - xhr.send(); - } +async function requestText(url, method, data) { + const response = await fetch(url, { + method, + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: (data ? JSON.stringify(data) : void 0) }); + return await response.text(); } -async function requestJson(url, action, params) { - const responseText = await requestText(url, action, params); - try { - return JSON.parse(responseText); - } catch (e) { - const error = new Error(`Invalid response (${e.message || e})`); - error.data = {url, action, params, responseText}; - throw error; - } +async function requestJson(url, method, data) { + const response = await fetch(url, { + method, + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: (data ? JSON.stringify(data) : void 0) + }); + return await response.json(); } diff --git a/ext/bg/js/search-main.js b/ext/bg/js/search-main.js index 54fa549d..13bd8767 100644 --- a/ext/bg/js/search-main.js +++ b/ext/bg/js/search-main.js @@ -17,45 +17,17 @@ /* global * DisplaySearch - * apiForwardLogsToBackend - * apiOptionsGet - * dynamicLoader + * api */ -async function injectSearchFrontend() { - await dynamicLoader.loadScripts([ - '/mixed/js/text-scanner.js', - '/fg/js/frontend-api-receiver.js', - '/fg/js/frame-offset-forwarder.js', - '/fg/js/popup.js', - '/fg/js/popup-factory.js', - '/fg/js/frontend.js', - '/fg/js/content-script-main.js' - ]); -} - (async () => { - apiForwardLogsToBackend(); - await yomichan.prepare(); - - const displaySearch = new DisplaySearch(); - await displaySearch.prepare(); - - let optionsApplied = false; - - const applyOptions = async () => { - const optionsContext = {depth: 0, url: window.location.href}; - const options = await apiOptionsGet(optionsContext); - if (!options.scanning.enableOnSearchPage || optionsApplied) { return; } - - optionsApplied = true; - yomichan.off('optionsUpdated', applyOptions); - - window.frontendInitializationData = {depth: 1, proxy: false, isSearchPage: true}; - await injectSearchFrontend(); - }; - - yomichan.on('optionsUpdated', applyOptions); - - await applyOptions(); + try { + api.forwardLogsToBackend(); + await yomichan.prepare(); + + const displaySearch = new DisplaySearch(); + await displaySearch.prepare(); + } catch (e) { + yomichan.logError(e); + } })(); diff --git a/ext/bg/js/search-query-parser-generator.js b/ext/bg/js/search-query-parser-generator.js index 9e7ff8aa..6989e157 100644 --- a/ext/bg/js/search-query-parser-generator.js +++ b/ext/bg/js/search-query-parser-generator.js @@ -17,7 +17,7 @@ /* global * TemplateHandler - * apiGetQueryParserTemplatesHtml + * api */ class QueryParserGenerator { @@ -26,7 +26,7 @@ class QueryParserGenerator { } async prepare() { - const html = await apiGetQueryParserTemplatesHtml(); + const html = await api.getQueryParserTemplatesHtml(); this._templateHandler = new TemplateHandler(html); } diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js index e1e37d1c..86524b66 100644 --- a/ext/bg/js/search-query-parser.js +++ b/ext/bg/js/search-query-parser.js @@ -18,9 +18,7 @@ /* global * QueryParserGenerator * TextScanner - * apiModifySettings - * apiTermsFind - * apiTextParse + * api * docSentenceExtract */ @@ -44,6 +42,7 @@ class QueryParser { async prepare() { await this._queryParserGenerator.prepare(); + this._textScanner.prepare(); this._queryParser.addEventListener('click', this._onClick.bind(this)); } @@ -59,7 +58,7 @@ class QueryParser { this._setPreview(text); - this._parseResults = await apiTextParse(text, this._getOptionsContext()); + this._parseResults = await api.textParse(text, this._getOptionsContext()); this._refreshSelectedParser(); this._renderParserSelect(); @@ -77,15 +76,17 @@ class QueryParser { async _search(textSource, cause) { if (textSource === null) { return null; } - const searchText = this._textScanner.getTextSourceContent(textSource, this._options.scanning.length); + const {length: scanLength, layoutAwareScan} = this._options.scanning; + const searchText = this._textScanner.getTextSourceContent(textSource, scanLength, layoutAwareScan); if (searchText.length === 0) { return null; } - const {definitions, length} = await apiTermsFind(searchText, {}, this._getOptionsContext()); + const {definitions, length} = await api.termsFind(searchText, {}, this._getOptionsContext()); if (definitions.length === 0) { return null; } - const sentence = docSentenceExtract(textSource, this._options.anki.sentenceExt); + const sentenceExtent = this._options.anki.sentenceExt; + const sentence = docSentenceExtract(textSource, sentenceExtent, layoutAwareScan); - textSource.setEndOffset(length); + textSource.setEndOffset(length, layoutAwareScan); this._setContent('terms', {definitions, context: { focus: false, @@ -99,7 +100,7 @@ class QueryParser { _onParserChange(e) { const value = e.target.value; - apiModifySettings([{ + api.modifySettings([{ action: 'set', path: 'parsing.selectedParser', value, @@ -112,7 +113,7 @@ class QueryParser { if (this._parseResults.length > 0) { if (!this._getParseResult()) { const value = this._parseResults[0].id; - apiModifySettings([{ + api.modifySettings([{ action: 'set', path: 'parsing.selectedParser', value, diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index 96e8a70b..e968e8cf 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -19,10 +19,11 @@ * ClipboardMonitor * DOM * Display + * Frontend + * PopupFactory * QueryParser - * apiClipboardGet - * apiModifySettings - * apiTermsFind + * api + * dynamicLoader * wanakana */ @@ -52,7 +53,7 @@ class DisplaySearch extends Display { this.introVisible = true; this.introAnimationTimer = null; - this.clipboardMonitor = new ClipboardMonitor({getClipboard: apiClipboardGet}); + this.clipboardMonitor = new ClipboardMonitor({getClipboard: api.clipboardGet.bind(api)}); this._onKeyDownIgnoreKeys = new Map([ ['ANY_MOD', new Set([ @@ -75,51 +76,49 @@ class DisplaySearch extends Display { } async prepare() { - try { - await super.prepare(); - await this.updateOptions(); - yomichan.on('optionsUpdated', () => this.updateOptions()); - await this.queryParser.prepare(); + await super.prepare(); + await this.updateOptions(); + yomichan.on('optionsUpdated', () => this.updateOptions()); + await this.queryParser.prepare(); + + const {queryParams: {query='', mode=''}} = parseUrl(window.location.href); + + document.documentElement.dataset.searchMode = mode; - const {queryParams: {query='', mode=''}} = parseUrl(window.location.href); + if (this.options.general.enableWanakana === true) { + this.wanakanaEnable.checked = true; + wanakana.bind(this.query); + } else { + this.wanakanaEnable.checked = false; + } - document.documentElement.dataset.searchMode = mode; + this.setQuery(query); + this.onSearchQueryUpdated(this.query.value, false); - if (this.options.general.enableWanakana === true) { - this.wanakanaEnable.checked = true; - wanakana.bind(this.query); + if (mode !== 'popup') { + if (this.options.general.enableClipboardMonitor === true) { + this.clipboardMonitorEnable.checked = true; + this.clipboardMonitor.start(); } else { - this.wanakanaEnable.checked = false; + this.clipboardMonitorEnable.checked = false; } + this.clipboardMonitorEnable.addEventListener('change', this.onClipboardMonitorEnableChange.bind(this)); + } - this.setQuery(query); - this.onSearchQueryUpdated(this.query.value, false); - - if (mode !== 'popup') { - if (this.options.general.enableClipboardMonitor === true) { - this.clipboardMonitorEnable.checked = true; - this.clipboardMonitor.start(); - } else { - this.clipboardMonitorEnable.checked = false; - } - this.clipboardMonitorEnable.addEventListener('change', this.onClipboardMonitorEnableChange.bind(this)); - } + chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); - chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); + this.search.addEventListener('click', this.onSearch.bind(this), false); + this.query.addEventListener('input', this.onSearchInput.bind(this), false); + this.wanakanaEnable.addEventListener('change', this.onWanakanaEnableChange.bind(this)); + window.addEventListener('popstate', this.onPopState.bind(this)); + window.addEventListener('copy', this.onCopy.bind(this)); + this.clipboardMonitor.on('change', this.onExternalSearchUpdate.bind(this)); - this.search.addEventListener('click', this.onSearch.bind(this), false); - this.query.addEventListener('input', this.onSearchInput.bind(this), false); - this.wanakanaEnable.addEventListener('change', this.onWanakanaEnableChange.bind(this)); - window.addEventListener('popstate', this.onPopState.bind(this)); - window.addEventListener('copy', this.onCopy.bind(this)); - this.clipboardMonitor.on('change', this.onExternalSearchUpdate.bind(this)); + this.updateSearchButton(); - this.updateSearchButton(); + await this._prepareNestedPopups(); - this._isPrepared = true; - } catch (e) { - this.onError(e); - } + this._isPrepared = true; } onError(error) { @@ -234,7 +233,7 @@ class DisplaySearch extends Display { this.setIntroVisible(!valid, animate); this.updateSearchButton(); if (valid) { - const {definitions} = await apiTermsFind(query, details, this.getOptionsContext()); + const {definitions} = await api.termsFind(query, details, this.getOptionsContext()); this.setContent('terms', {definitions, context: { focus: false, disableHistory: true, @@ -258,7 +257,7 @@ class DisplaySearch extends Display { } else { wanakana.unbind(this.query); } - apiModifySettings([{ + api.modifySettings([{ action: 'set', path: 'general.enableWanakana', value, @@ -274,7 +273,7 @@ class DisplaySearch extends Display { (granted) => { if (granted) { this.clipboardMonitor.start(); - apiModifySettings([{ + api.modifySettings([{ action: 'set', path: 'general.enableClipboardMonitor', value: true, @@ -288,7 +287,7 @@ class DisplaySearch extends Display { ); } else { this.clipboardMonitor.stop(); - apiModifySettings([{ + api.modifySettings([{ action: 'set', path: 'general.enableClipboardMonitor', value: false, @@ -314,7 +313,14 @@ class DisplaySearch extends Display { } setQuery(query) { - const interpretedQuery = this.isWanakanaEnabled() ? wanakana.toKana(query) : query; + let interpretedQuery = query; + if (this.isWanakanaEnabled()) { + try { + interpretedQuery = wanakana.toKana(query); + } catch (e) { + // NOP + } + } this.query.value = interpretedQuery; this.queryParser.setText(interpretedQuery); } @@ -396,4 +402,53 @@ class DisplaySearch extends Display { document.title = `${text} - Yomichan Search`; } } + + async _prepareNestedPopups() { + let complete = false; + + const onOptionsUpdated = async () => { + const optionsContext = this.getOptionsContext(); + const options = await api.optionsGet(optionsContext); + if (!options.scanning.enableOnSearchPage || complete) { return; } + + complete = true; + yomichan.off('optionsUpdated', onOptionsUpdated); + + try { + await this._setupNestedPopups(); + } catch (e) { + yomichan.logError(e); + } + }; + + yomichan.on('optionsUpdated', onOptionsUpdated); + + await onOptionsUpdated(); + } + + async _setupNestedPopups() { + await dynamicLoader.loadScripts([ + '/mixed/js/text-scanner.js', + '/fg/js/frame-offset-forwarder.js', + '/fg/js/popup.js', + '/fg/js/popup-factory.js', + '/fg/js/frontend.js' + ]); + + const {frameId} = await api.frameInformationGet(); + + const popupFactory = new PopupFactory(frameId); + popupFactory.prepare(); + + const frontend = new Frontend( + frameId, + popupFactory, + { + depth: 1, + proxy: false, + isSearchPage: true + } + ); + await frontend.prepare(); + } } diff --git a/ext/bg/js/settings/anki-templates.js b/ext/bg/js/settings/anki-templates.js index d5b6e677..88d4fe04 100644 --- a/ext/bg/js/settings/anki-templates.js +++ b/ext/bg/js/settings/anki-templates.js @@ -17,144 +17,146 @@ /* global * AnkiNoteBuilder - * ankiGetFieldMarkers - * ankiGetFieldMarkersHtml - * apiGetDefaultAnkiFieldTemplates - * apiOptionsGet - * apiTemplateRender - * apiTermsFind - * getOptionsContext - * getOptionsMutable - * settingsSaveOptions + * api */ -function onAnkiFieldTemplatesReset(e) { - e.preventDefault(); - $('#field-template-reset-modal').modal('show'); -} +class AnkiTemplatesController { + constructor(settingsController, ankiController) { + this._settingsController = settingsController; + this._ankiController = ankiController; + this._cachedDefinitionValue = null; + this._cachedDefinitionText = null; + this._defaultFieldTemplates = null; + } -async function onAnkiFieldTemplatesResetConfirm(e) { - e.preventDefault(); + async prepare() { + this._defaultFieldTemplates = await api.getDefaultAnkiFieldTemplates(); - $('#field-template-reset-modal').modal('hide'); + const markers = new Set([ + ...this._ankiController.getFieldMarkers('terms'), + ...this._ankiController.getFieldMarkers('kanji') + ]); + const fragment = this._ankiController.getFieldMarkersHtml(markers); - const value = await apiGetDefaultAnkiFieldTemplates(); + const list = document.querySelector('#field-templates-list'); + list.appendChild(fragment); + for (const node of list.querySelectorAll('.marker-link')) { + node.addEventListener('click', this._onMarkerClicked.bind(this), false); + } - const element = document.querySelector('#field-templates'); - element.value = value; - element.dispatchEvent(new Event('change')); -} + document.querySelector('#field-templates').addEventListener('change', this._onChanged.bind(this), false); + document.querySelector('#field-template-render').addEventListener('click', this._onRender.bind(this), false); + document.querySelector('#field-templates-reset').addEventListener('click', this._onReset.bind(this), false); + document.querySelector('#field-templates-reset-confirm').addEventListener('click', this._onResetConfirm.bind(this), false); -function ankiTemplatesInitialize() { - const markers = new Set(ankiGetFieldMarkers('terms').concat(ankiGetFieldMarkers('kanji'))); - const fragment = ankiGetFieldMarkersHtml(markers); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); - const list = document.querySelector('#field-templates-list'); - list.appendChild(fragment); - for (const node of list.querySelectorAll('.marker-link')) { - node.addEventListener('click', onAnkiTemplateMarkerClicked, false); + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); } - $('#field-templates').on('change', onAnkiFieldTemplatesChanged); - $('#field-template-render').on('click', onAnkiTemplateRender); - $('#field-templates-reset').on('click', onAnkiFieldTemplatesReset); - $('#field-templates-reset-confirm').on('click', onAnkiFieldTemplatesResetConfirm); + // Private - ankiTemplatesUpdateValue(); -} + _onOptionsChanged({options}) { + let templates = options.anki.fieldTemplates; + if (typeof templates !== 'string') { templates = this._defaultFieldTemplates; } + document.querySelector('#field-templates').value = templates; -async function ankiTemplatesUpdateValue() { - const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); - let templates = options.anki.fieldTemplates; - if (typeof templates !== 'string') { templates = await apiGetDefaultAnkiFieldTemplates(); } - $('#field-templates').val(templates); + this._onValidateCompile(); + } - onAnkiTemplatesValidateCompile(); -} + _onReset(e) { + e.preventDefault(); + $('#field-template-reset-modal').modal('show'); + } -const ankiTemplatesValidateGetDefinition = (() => { - let cachedValue = null; - let cachedText = null; + _onResetConfirm(e) { + e.preventDefault(); - return async (text, optionsContext) => { - if (cachedText !== text) { - const {definitions} = await apiTermsFind(text, {}, optionsContext); - if (definitions.length === 0) { return null; } + $('#field-template-reset-modal').modal('hide'); - cachedValue = definitions[0]; - cachedText = text; - } - return cachedValue; - }; -})(); - -async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, invalidateInput) { - const text = document.querySelector('#field-templates-preview-text').value || ''; - const exceptions = []; - let result = `No definition found for ${text}`; - try { - const optionsContext = getOptionsContext(); - const definition = await ankiTemplatesValidateGetDefinition(text, optionsContext); - if (definition !== null) { - const options = await apiOptionsGet(optionsContext); - const context = { - document: { - title: document.title - } - }; - let templates = options.anki.fieldTemplates; - if (typeof templates !== 'string') { templates = await apiGetDefaultAnkiFieldTemplates(); } - const ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: apiTemplateRender}); - result = await ankiNoteBuilder.formatField(field, definition, mode, context, options, templates, exceptions); + const value = this._defaultFieldTemplates; + + const element = document.querySelector('#field-templates'); + element.value = value; + element.dispatchEvent(new Event('change')); + } + + async _onChanged(e) { + // Get value + let templates = e.currentTarget.value; + if (templates === this._defaultFieldTemplates) { + // Default + templates = null; } - } catch (e) { - exceptions.push(e); + + // Overwrite + await this._settingsController.setProfileSetting('anki.fieldTemplates', templates); + + // Compile + this._onValidateCompile(); } - const hasException = exceptions.length > 0; - infoNode.hidden = !(showSuccessResult || hasException); - infoNode.textContent = hasException ? exceptions.map((e) => `${e}`).join('\n') : (showSuccessResult ? result : ''); - infoNode.classList.toggle('text-danger', hasException); - if (invalidateInput) { - const input = document.querySelector('#field-templates'); - input.classList.toggle('is-invalid', hasException); + _onValidateCompile() { + const infoNode = document.querySelector('#field-template-compile-result'); + this._validate(infoNode, '{expression}', 'term-kanji', false, true); } -} -async function onAnkiFieldTemplatesChanged(e) { - // Get value - let templates = e.currentTarget.value; - if (templates === await apiGetDefaultAnkiFieldTemplates()) { - // Default - templates = null; + _onMarkerClicked(e) { + e.preventDefault(); + document.querySelector('#field-template-render-text').value = `{${e.target.textContent}}`; } - // Overwrite - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - options.anki.fieldTemplates = templates; - await settingsSaveOptions(); + _onRender(e) { + e.preventDefault(); - // Compile - onAnkiTemplatesValidateCompile(); -} + const field = document.querySelector('#field-template-render-text').value; + const infoNode = document.querySelector('#field-template-render-result'); + infoNode.hidden = true; + this._validate(infoNode, field, 'term-kanji', true, false); + } -function onAnkiTemplatesValidateCompile() { - const infoNode = document.querySelector('#field-template-compile-result'); - ankiTemplatesValidate(infoNode, '{expression}', 'term-kanji', false, true); -} + async _getDefinition(text, optionsContext) { + if (this._cachedDefinitionText !== text) { + const {definitions} = await api.termsFind(text, {}, optionsContext); + if (definitions.length === 0) { return null; } -function onAnkiTemplateMarkerClicked(e) { - e.preventDefault(); - document.querySelector('#field-template-render-text').value = `{${e.target.textContent}}`; -} + this._cachedDefinitionValue = definitions[0]; + this._cachedDefinitionText = text; + } + return this._cachedDefinitionValue; + } -function onAnkiTemplateRender(e) { - e.preventDefault(); + async _validate(infoNode, field, mode, showSuccessResult, invalidateInput) { + const text = document.querySelector('#field-templates-preview-text').value || ''; + const exceptions = []; + let result = `No definition found for ${text}`; + try { + const optionsContext = this._settingsController.getOptionsContext(); + const definition = await this._getDefinition(text, optionsContext); + if (definition !== null) { + const options = await this._settingsController.getOptions(); + const context = { + document: { + title: document.title + } + }; + let templates = options.anki.fieldTemplates; + if (typeof templates !== 'string') { templates = this._defaultFieldTemplates; } + const ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: api.templateRender.bind(api)}); + result = await ankiNoteBuilder.formatField(field, definition, mode, context, options, templates, exceptions); + } + } catch (e) { + exceptions.push(e); + } - const field = document.querySelector('#field-template-render-text').value; - const infoNode = document.querySelector('#field-template-render-result'); - infoNode.hidden = true; - ankiTemplatesValidate(infoNode, field, 'term-kanji', true, false); + const hasException = exceptions.length > 0; + infoNode.hidden = !(showSuccessResult || hasException); + infoNode.textContent = hasException ? exceptions.map((e) => `${e}`).join('\n') : (showSuccessResult ? result : ''); + infoNode.classList.toggle('text-danger', hasException); + if (invalidateInput) { + const input = document.querySelector('#field-templates'); + input.classList.toggle('is-invalid', hasException); + } + } } diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js index ff1277ed..51dabba4 100644 --- a/ext/bg/js/settings/anki.js +++ b/ext/bg/js/settings/anki.js @@ -16,278 +16,282 @@ */ /* global - * apiGetAnkiDeckNames - * apiGetAnkiModelFieldNames - * apiGetAnkiModelNames - * getOptionsContext - * getOptionsMutable - * onFormOptionsChanged - * settingsSaveOptions - * utilBackgroundIsolate + * api */ -// Private - -let _ankiDataPopulated = false; - - -function _ankiSpinnerShow(show) { - const spinner = $('#anki-spinner'); - if (show) { - spinner.show(); - } else { - spinner.hide(); +class AnkiController { + constructor(settingsController) { + this._settingsController = settingsController; } -} -function _ankiSetError(error) { - const node = document.querySelector('#anki-error'); - const node2 = document.querySelector('#anki-invalid-response-error'); - if (error) { - const errorString = `${error}`; - if (node !== null) { - node.hidden = false; - node.textContent = errorString; - _ankiSetErrorData(node, error); + async prepare() { + for (const element of document.querySelectorAll('#anki-fields-container input,#anki-fields-container select')) { + element.addEventListener('change', this._onFieldsChanged.bind(this), false); } - if (node2 !== null) { - node2.hidden = (errorString.indexOf('Invalid response') < 0); - } - } else { - if (node !== null) { - node.hidden = true; - node.textContent = ''; + for (const element of document.querySelectorAll('#anki-terms-model,#anki-kanji-model')) { + element.addEventListener('change', this._onModelChanged.bind(this), false); } - if (node2 !== null) { - node2.hidden = true; - } - } -} + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); -function _ankiSetErrorData(node, error) { - const data = error.data; - let message = ''; - if (typeof data !== 'undefined') { - message += `${JSON.stringify(data, null, 4)}\n\n`; + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); } - message += `${error.stack}`.trimRight(); - const button = document.createElement('a'); - button.className = 'error-data-show-button'; + getFieldMarkers(type) { + switch (type) { + case 'terms': + return [ + 'audio', + 'cloze-body', + 'cloze-prefix', + 'cloze-suffix', + 'dictionary', + 'document-title', + 'expression', + 'furigana', + 'furigana-plain', + 'glossary', + 'glossary-brief', + 'reading', + 'screenshot', + 'sentence', + 'tags', + 'url' + ]; + case 'kanji': + return [ + 'character', + 'dictionary', + 'document-title', + 'glossary', + 'kunyomi', + 'onyomi', + 'screenshot', + 'sentence', + 'tags', + 'url' + ]; + default: + return []; + } + } - const content = document.createElement('div'); - content.className = 'error-data-container'; - content.textContent = message; - content.hidden = true; + getFieldMarkersHtml(markers) { + const template = document.querySelector('#anki-field-marker-template').content; + const fragment = document.createDocumentFragment(); + for (const marker of markers) { + const markerNode = document.importNode(template, true).firstChild; + markerNode.querySelector('.marker-link').textContent = marker; + fragment.appendChild(markerNode); + } + return fragment; + } - button.addEventListener('click', () => content.hidden = !content.hidden, false); + // Private - node.appendChild(button); - node.appendChild(content); -} + async _onOptionsChanged({options}) { + if (!options.anki.enable) { + return; + } -function _ankiSetDropdownOptions(dropdown, optionValues) { - const fragment = document.createDocumentFragment(); - for (const optionValue of optionValues) { - const option = document.createElement('option'); - option.value = optionValue; - option.textContent = optionValue; - fragment.appendChild(option); + await this._deckAndModelPopulate(options); + await Promise.all([ + this._populateFields('terms', options.anki.terms.fields), + this._populateFields('kanji', options.anki.kanji.fields) + ]); } - dropdown.textContent = ''; - dropdown.appendChild(fragment); -} -async function _ankiDeckAndModelPopulate(options) { - const termsDeck = {value: options.anki.terms.deck, selector: '#anki-terms-deck'}; - const kanjiDeck = {value: options.anki.kanji.deck, selector: '#anki-kanji-deck'}; - const termsModel = {value: options.anki.terms.model, selector: '#anki-terms-model'}; - const kanjiModel = {value: options.anki.kanji.model, selector: '#anki-kanji-model'}; - try { - _ankiSpinnerShow(true); - const [deckNames, modelNames] = await Promise.all([apiGetAnkiDeckNames(), apiGetAnkiModelNames()]); - deckNames.sort(); - modelNames.sort(); - termsDeck.values = deckNames; - kanjiDeck.values = deckNames; - termsModel.values = modelNames; - kanjiModel.values = modelNames; - _ankiSetError(null); - } catch (error) { - _ankiSetError(error); - } finally { - _ankiSpinnerShow(false); + _fieldsToDict(elements) { + const result = {}; + for (const element of elements) { + result[element.dataset.field] = element.value; + } + return result; } - for (const {value, values, selector} of [termsDeck, kanjiDeck, termsModel, kanjiModel]) { - const node = document.querySelector(selector); - _ankiSetDropdownOptions(node, Array.isArray(values) ? values : [value]); - node.value = value; + _spinnerShow(show) { + const spinner = document.querySelector('#anki-spinner'); + spinner.hidden = !show; } -} -function _ankiCreateFieldTemplate(name, value, markers) { - const template = document.querySelector('#anki-field-template').content; - const content = document.importNode(template, true).firstChild; + _setError(error) { + const node = document.querySelector('#anki-error'); + const node2 = document.querySelector('#anki-invalid-response-error'); + if (error) { + const errorString = `${error}`; + if (node !== null) { + node.hidden = false; + node.textContent = errorString; + this._setErrorData(node, error); + } + + if (node2 !== null) { + node2.hidden = (errorString.indexOf('Invalid response') < 0); + } + } else { + if (node !== null) { + node.hidden = true; + node.textContent = ''; + } + + if (node2 !== null) { + node2.hidden = true; + } + } + } - content.querySelector('.anki-field-name').textContent = name; + _setErrorData(node, error) { + const data = error.data; + let message = ''; + if (typeof data !== 'undefined') { + message += `${JSON.stringify(data, null, 4)}\n\n`; + } + message += `${error.stack}`.trimRight(); - const field = content.querySelector('.anki-field-value'); - field.dataset.field = name; - field.value = value; + const button = document.createElement('a'); + button.className = 'error-data-show-button'; - content.querySelector('.anki-field-marker-list').appendChild(ankiGetFieldMarkersHtml(markers)); + const content = document.createElement('div'); + content.className = 'error-data-container'; + content.textContent = message; + content.hidden = true; - return content; -} + button.addEventListener('click', () => content.hidden = !content.hidden, false); -async function _ankiFieldsPopulate(tabId, options) { - const tab = document.querySelector(`.tab-pane[data-anki-card-type=${tabId}]`); - const container = tab.querySelector('tbody'); - const markers = ankiGetFieldMarkers(tabId); - - const fragment = document.createDocumentFragment(); - const fields = options.anki[tabId].fields; - for (const name of Object.keys(fields)) { - const value = fields[name]; - const html = _ankiCreateFieldTemplate(name, value, markers); - fragment.appendChild(html); + node.appendChild(button); + node.appendChild(content); } - container.textContent = ''; - container.appendChild(fragment); - - for (const node of container.querySelectorAll('.anki-field-value')) { - node.addEventListener('change', onFormOptionsChanged, false); - } - for (const node of container.querySelectorAll('.marker-link')) { - node.addEventListener('click', _onAnkiMarkerClicked, false); + _setDropdownOptions(dropdown, optionValues) { + const fragment = document.createDocumentFragment(); + for (const optionValue of optionValues) { + const option = document.createElement('option'); + option.value = optionValue; + option.textContent = optionValue; + fragment.appendChild(option); + } + dropdown.textContent = ''; + dropdown.appendChild(fragment); } -} -function _onAnkiMarkerClicked(e) { - e.preventDefault(); - const link = e.currentTarget; - const input = $(link).closest('.input-group').find('.anki-field-value')[0]; - input.value = `{${link.textContent}}`; - input.dispatchEvent(new Event('change')); -} + async _deckAndModelPopulate(options) { + const termsDeck = {value: options.anki.terms.deck, selector: '#anki-terms-deck'}; + const kanjiDeck = {value: options.anki.kanji.deck, selector: '#anki-kanji-deck'}; + const termsModel = {value: options.anki.terms.model, selector: '#anki-terms-model'}; + const kanjiModel = {value: options.anki.kanji.model, selector: '#anki-kanji-model'}; + try { + this._spinnerShow(true); + const [deckNames, modelNames] = await Promise.all([api.getAnkiDeckNames(), api.getAnkiModelNames()]); + deckNames.sort(); + modelNames.sort(); + termsDeck.values = deckNames; + kanjiDeck.values = deckNames; + termsModel.values = modelNames; + kanjiModel.values = modelNames; + this._setError(null); + } catch (error) { + this._setError(error); + } finally { + this._spinnerShow(false); + } -async function _onAnkiModelChanged(e) { - const node = e.currentTarget; - let fieldNames; - try { - const modelName = node.value; - fieldNames = await apiGetAnkiModelFieldNames(modelName); - _ankiSetError(null); - } catch (error) { - _ankiSetError(error); - return; - } finally { - _ankiSpinnerShow(false); + for (const {value, values, selector} of [termsDeck, kanjiDeck, termsModel, kanjiModel]) { + const node = document.querySelector(selector); + this._setDropdownOptions(node, Array.isArray(values) ? values : [value]); + node.value = value; + } } - const tabId = node.dataset.ankiCardType; - if (tabId !== 'terms' && tabId !== 'kanji') { return; } - - const fields = {}; - for (const name of fieldNames) { - fields[name] = ''; - } + _createFieldTemplate(name, value, markers) { + const template = document.querySelector('#anki-field-template').content; + const content = document.importNode(template, true).firstChild; - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - options.anki[tabId].fields = utilBackgroundIsolate(fields); - await settingsSaveOptions(); + content.querySelector('.anki-field-name').textContent = name; - await _ankiFieldsPopulate(tabId, options); -} + const field = content.querySelector('.anki-field-value'); + field.dataset.field = name; + field.value = value; + content.querySelector('.anki-field-marker-list').appendChild(this.getFieldMarkersHtml(markers)); -// Public + return content; + } -function ankiErrorShown() { - const node = document.querySelector('#anki-error'); - return node && !node.hidden; -} + async _populateFields(tabId, fields) { + const tab = document.querySelector(`.tab-pane[data-anki-card-type=${tabId}]`); + const container = tab.querySelector('tbody'); + const markers = this.getFieldMarkers(tabId); -function ankiFieldsToDict(elements) { - const result = {}; - for (const element of elements) { - result[element.dataset.field] = element.value; - } - return result; -} + const fragment = document.createDocumentFragment(); + for (const [name, value] of Object.entries(fields)) { + const html = this._createFieldTemplate(name, value, markers); + fragment.appendChild(html); + } + container.textContent = ''; + container.appendChild(fragment); -function ankiGetFieldMarkersHtml(markers) { - const template = document.querySelector('#anki-field-marker-template').content; - const fragment = document.createDocumentFragment(); - for (const marker of markers) { - const markerNode = document.importNode(template, true).firstChild; - markerNode.querySelector('.marker-link').textContent = marker; - fragment.appendChild(markerNode); + for (const node of container.querySelectorAll('.anki-field-value')) { + node.addEventListener('change', this._onFieldsChanged.bind(this), false); + } + for (const node of container.querySelectorAll('.marker-link')) { + node.addEventListener('click', this._onMarkerClicked.bind(this), false); + } } - return fragment; -} -function ankiGetFieldMarkers(type) { - switch (type) { - case 'terms': - return [ - 'audio', - 'cloze-body', - 'cloze-prefix', - 'cloze-suffix', - 'dictionary', - 'document-title', - 'expression', - 'furigana', - 'furigana-plain', - 'glossary', - 'glossary-brief', - 'reading', - 'screenshot', - 'sentence', - 'tags', - 'url' - ]; - case 'kanji': - return [ - 'character', - 'dictionary', - 'document-title', - 'glossary', - 'kunyomi', - 'onyomi', - 'screenshot', - 'sentence', - 'tags', - 'url' - ]; - default: - return []; + _onMarkerClicked(e) { + e.preventDefault(); + const link = e.currentTarget; + const input = link.closest('.input-group').querySelector('.anki-field-value'); + input.value = `{${link.textContent}}`; + input.dispatchEvent(new Event('change')); } -} + async _onModelChanged(e) { + const node = e.currentTarget; + let fieldNames; + try { + const modelName = node.value; + fieldNames = await api.getAnkiModelFieldNames(modelName); + this._setError(null); + } catch (error) { + this._setError(error); + return; + } finally { + this._spinnerShow(false); + } -function ankiInitialize() { - for (const node of document.querySelectorAll('#anki-terms-model,#anki-kanji-model')) { - node.addEventListener('change', _onAnkiModelChanged, false); - } -} + const tabId = node.dataset.ankiCardType; + if (tabId !== 'terms' && tabId !== 'kanji') { return; } -async function onAnkiOptionsChanged(options) { - if (!options.anki.enable) { - _ankiDataPopulated = false; - return; - } + const fields = {}; + for (const name of fieldNames) { + fields[name] = ''; + } - if (_ankiDataPopulated) { return; } + await this._settingsController.setProfileSetting(`anki["${tabId}"].fields`, fields); + await this._populateFields(tabId, fields); + } - await _ankiDeckAndModelPopulate(options); - _ankiDataPopulated = true; - await Promise.all([_ankiFieldsPopulate('terms', options), _ankiFieldsPopulate('kanji', options)]); + async _onFieldsChanged() { + const termsDeck = document.querySelector('#anki-terms-deck').value; + const termsModel = document.querySelector('#anki-terms-model').value; + const termsFields = this._fieldsToDict(document.querySelectorAll('#terms .anki-field-value')); + const kanjiDeck = document.querySelector('#anki-kanji-deck').value; + const kanjiModel = document.querySelector('#anki-kanji-model').value; + const kanjiFields = this._fieldsToDict(document.querySelectorAll('#kanji .anki-field-value')); + + const targets = [ + {action: 'set', path: 'anki.terms.deck', value: termsDeck}, + {action: 'set', path: 'anki.terms.model', value: termsModel}, + {action: 'set', path: 'anki.terms.fields', value: termsFields}, + {action: 'set', path: 'anki.kanji.deck', value: kanjiDeck}, + {action: 'set', path: 'anki.kanji.model', value: kanjiModel}, + {action: 'set', path: 'anki.kanji.fields', value: kanjiFields} + ]; + + await this._settingsController.modifyProfileSettings(targets); + } } diff --git a/ext/bg/js/settings/audio-ui.js b/ext/bg/js/settings/audio-ui.js deleted file mode 100644 index 73c64227..00000000 --- a/ext/bg/js/settings/audio-ui.js +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (C) 2019-2020 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -class AudioSourceUI { - static instantiateTemplate(templateSelector) { - const template = document.querySelector(templateSelector); - const content = document.importNode(template.content, true); - return content.firstChild; - } -} - -AudioSourceUI.Container = class Container { - constructor(audioSources, container, addButton) { - this.audioSources = audioSources; - this.container = container; - this.addButton = addButton; - this.children = []; - - this.container.textContent = ''; - - for (const audioSource of toIterable(audioSources)) { - this.children.push(new AudioSourceUI.AudioSource(this, audioSource, this.children.length)); - } - - this._clickListener = this.onAddAudioSource.bind(this); - this.addButton.addEventListener('click', this._clickListener, false); - } - - cleanup() { - for (const child of this.children) { - child.cleanup(); - } - - this.addButton.removeEventListener('click', this._clickListener, false); - this.container.textContent = ''; - this._clickListener = null; - } - - save() { - // Override - } - - remove(child) { - const index = this.children.indexOf(child); - if (index < 0) { - return; - } - - child.cleanup(); - this.children.splice(index, 1); - this.audioSources.splice(index, 1); - - for (let i = index; i < this.children.length; ++i) { - this.children[i].index = i; - } - } - - onAddAudioSource() { - const audioSource = this.getUnusedAudioSource(); - this.audioSources.push(audioSource); - this.save(); - this.children.push(new AudioSourceUI.AudioSource(this, audioSource, this.children.length)); - } - - getUnusedAudioSource() { - const audioSourcesAvailable = [ - 'jpod101', - 'jpod101-alternate', - 'jisho', - 'custom' - ]; - for (const source of audioSourcesAvailable) { - if (this.audioSources.indexOf(source) < 0) { - return source; - } - } - return audioSourcesAvailable[0]; - } -}; - -AudioSourceUI.AudioSource = class AudioSource { - constructor(parent, audioSource, index) { - this.parent = parent; - this.audioSource = audioSource; - this.index = index; - - this.container = AudioSourceUI.instantiateTemplate('#audio-source-template'); - this.select = this.container.querySelector('.audio-source-select'); - this.removeButton = this.container.querySelector('.audio-source-remove'); - - this.select.value = audioSource; - - this._selectChangeListener = this.onSelectChanged.bind(this); - this._removeClickListener = this.onRemoveClicked.bind(this); - - this.select.addEventListener('change', this._selectChangeListener, false); - this.removeButton.addEventListener('click', this._removeClickListener, false); - - parent.container.appendChild(this.container); - } - - cleanup() { - this.select.removeEventListener('change', this._selectChangeListener, false); - this.removeButton.removeEventListener('click', this._removeClickListener, false); - - if (this.container.parentNode !== null) { - this.container.parentNode.removeChild(this.container); - } - } - - save() { - this.parent.save(); - } - - onSelectChanged() { - this.audioSource = this.select.value; - this.parent.audioSources[this.index] = this.audioSource; - this.save(); - } - - onRemoveClicked() { - this.parent.remove(this); - this.save(); - } -}; diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js index ac2d82f3..d389acb5 100644 --- a/ext/bg/js/settings/audio.js +++ b/ext/bg/js/settings/audio.js @@ -16,110 +16,219 @@ */ /* global - * AudioSourceUI * AudioSystem - * getOptionsContext - * getOptionsMutable - * settingsSaveOptions */ -let audioSourceUI = null; -let audioSystem = null; - -async function audioSettingsInitialize() { - audioSystem = new AudioSystem({ - audioUriBuilder: null, - useCache: true - }); - - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - audioSourceUI = new AudioSourceUI.Container( - options.audio.sources, - document.querySelector('.audio-source-list'), - document.querySelector('.audio-source-add') - ); - audioSourceUI.save = settingsSaveOptions; - - textToSpeechInitialize(); -} +class AudioController { + constructor(settingsController) { + this._settingsController = settingsController; + this._audioSystem = null; + this._audioSourceContainer = null; + this._audioSourceAddButton = null; + this._audioSourceEntries = []; + } -function textToSpeechInitialize() { - if (typeof speechSynthesis === 'undefined') { return; } + async prepare() { + this._audioSystem = new AudioSystem({ + audioUriBuilder: null, + useCache: true + }); - speechSynthesis.addEventListener('voiceschanged', updateTextToSpeechVoices, false); - updateTextToSpeechVoices(); + this._audioSourceContainer = document.querySelector('.audio-source-list'); + this._audioSourceAddButton = document.querySelector('.audio-source-add'); + this._audioSourceContainer.textContent = ''; - document.querySelector('#text-to-speech-voice').addEventListener('change', onTextToSpeechVoiceChange, false); - document.querySelector('#text-to-speech-voice-test').addEventListener('click', textToSpeechTest, false); -} + this._audioSourceAddButton.addEventListener('click', this._onAddAudioSource.bind(this), false); + + this._prepareTextToSpeech(); + + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); -function updateTextToSpeechVoices() { - const voices = Array.prototype.map.call(speechSynthesis.getVoices(), (voice, index) => ({voice, index})); - voices.sort(textToSpeechVoiceCompare); + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + // Private - document.querySelector('#text-to-speech-voice-container').hidden = (voices.length === 0); + _onOptionsChanged({options}) { + for (let i = this._audioSourceEntries.length - 1; i >= 0; --i) { + this._cleanupAudioSourceEntry(i); + } - const fragment = document.createDocumentFragment(); + for (const audioSource of options.audio.sources) { + this._createAudioSourceEntry(audioSource); + } + } - let option = document.createElement('option'); - option.value = ''; - option.textContent = 'None'; - fragment.appendChild(option); + _prepareTextToSpeech() { + if (typeof speechSynthesis === 'undefined') { return; } - for (const {voice} of voices) { - option = document.createElement('option'); - option.value = voice.voiceURI; - option.textContent = `${voice.name} (${voice.lang})`; + speechSynthesis.addEventListener('voiceschanged', this._updateTextToSpeechVoices.bind(this), false); + this._updateTextToSpeechVoices(); + + document.querySelector('#text-to-speech-voice').addEventListener('change', this._onTextToSpeechVoiceChange.bind(this), false); + document.querySelector('#text-to-speech-voice-test').addEventListener('click', this._testTextToSpeech.bind(this), false); + } + + _updateTextToSpeechVoices() { + const voices = Array.prototype.map.call(speechSynthesis.getVoices(), (voice, index) => ({voice, index})); + voices.sort(this._textToSpeechVoiceCompare.bind(this)); + + document.querySelector('#text-to-speech-voice-container').hidden = (voices.length === 0); + + const fragment = document.createDocumentFragment(); + + let option = document.createElement('option'); + option.value = ''; + option.textContent = 'None'; fragment.appendChild(option); + + for (const {voice} of voices) { + option = document.createElement('option'); + option.value = voice.voiceURI; + option.textContent = `${voice.name} (${voice.lang})`; + fragment.appendChild(option); + } + + const select = document.querySelector('#text-to-speech-voice'); + select.textContent = ''; + select.appendChild(fragment); + select.value = select.dataset.value; } - const select = document.querySelector('#text-to-speech-voice'); - select.textContent = ''; - select.appendChild(fragment); - select.value = select.dataset.value; -} + _textToSpeechVoiceCompare(a, b) { + const aIsJapanese = this._languageTagIsJapanese(a.voice.lang); + const bIsJapanese = this._languageTagIsJapanese(b.voice.lang); + if (aIsJapanese) { + if (!bIsJapanese) { return -1; } + } else { + if (bIsJapanese) { return 1; } + } + + const aIsDefault = a.voice.default; + const bIsDefault = b.voice.default; + if (aIsDefault) { + if (!bIsDefault) { return -1; } + } else { + if (bIsDefault) { return 1; } + } + + return a.index - b.index; + } -function languageTagIsJapanese(languageTag) { - return ( - languageTag.startsWith('ja-') || - languageTag.startsWith('jpn-') - ); -} + _languageTagIsJapanese(languageTag) { + return ( + languageTag.startsWith('ja_') || + languageTag.startsWith('ja-') || + languageTag.startsWith('jpn-') + ); + } -function textToSpeechVoiceCompare(a, b) { - const aIsJapanese = languageTagIsJapanese(a.voice.lang); - const bIsJapanese = languageTagIsJapanese(b.voice.lang); - if (aIsJapanese) { - if (!bIsJapanese) { return -1; } - } else { - if (bIsJapanese) { return 1; } + _testTextToSpeech() { + try { + const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || ''; + const voiceUri = document.querySelector('#text-to-speech-voice').value; + + const audio = this._audioSystem.createTextToSpeechAudio(text, voiceUri); + audio.volume = 1.0; + audio.play(); + } catch (e) { + // NOP + } } - const aIsDefault = a.voice.default; - const bIsDefault = b.voice.default; - if (aIsDefault) { - if (!bIsDefault) { return -1; } - } else { - if (bIsDefault) { return 1; } + _instantiateTemplate(templateSelector) { + const template = document.querySelector(templateSelector); + const content = document.importNode(template.content, true); + return content.firstChild; } - return a.index - b.index; -} + _getUnusedAudioSource() { + const audioSourcesAvailable = [ + 'jpod101', + 'jpod101-alternate', + 'jisho', + 'custom' + ]; + for (const source of audioSourcesAvailable) { + if (!this._audioSourceEntries.some((metadata) => metadata.value === source)) { + return source; + } + } + return audioSourcesAvailable[0]; + } + + _createAudioSourceEntry(value) { + const eventListeners = new EventListenerCollection(); + const container = this._instantiateTemplate('#audio-source-template'); + const select = container.querySelector('.audio-source-select'); + const removeButton = container.querySelector('.audio-source-remove'); + + select.value = value; + + const entry = { + container, + eventListeners, + value + }; -function textToSpeechTest() { - try { - const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || ''; - const voiceUri = document.querySelector('#text-to-speech-voice').value; + eventListeners.addEventListener(select, 'change', this._onAudioSourceSelectChange.bind(this, entry), false); + eventListeners.addEventListener(removeButton, 'click', this._onAudioSourceRemoveClicked.bind(this, entry), false); - const audio = audioSystem.createTextToSpeechAudio(text, voiceUri); - audio.volume = 1.0; - audio.play(); - } catch (e) { - // NOP + this._audioSourceContainer.appendChild(container); + this._audioSourceEntries.push(entry); + } + + async _removeAudioSourceEntry(entry) { + const index = this._audioSourceEntries.indexOf(entry); + if (index < 0) { return; } + + this._cleanupAudioSourceEntry(index); + await this._settingsController.modifyProfileSettings([{ + action: 'splice', + path: 'audio.sources', + start: index, + deleteCount: 1, + items: [] + }]); + } + + _cleanupAudioSourceEntry(index) { + const {container, eventListeners} = this._audioSourceEntries[index]; + if (container.parentNode !== null) { + container.parentNode.removeChild(container); + } + eventListeners.removeAllEventListeners(); + this._audioSourceEntries.splice(index, 1); + } + + _onTextToSpeechVoiceChange(e) { + e.currentTarget.dataset.value = e.currentTarget.value; + } + + async _onAddAudioSource() { + const audioSource = this._getUnusedAudioSource(); + const index = this._audioSourceEntries.length; + this._createAudioSourceEntry(audioSource); + await this._settingsController.modifyProfileSettings([{ + action: 'splice', + path: 'audio.sources', + start: index, + deleteCount: 0, + items: [audioSource] + }]); } -} -function onTextToSpeechVoiceChange(e) { - e.currentTarget.dataset.value = e.currentTarget.value; + async _onAudioSourceSelectChange(entry, event) { + const index = this._audioSourceEntries.indexOf(entry); + if (index < 0) { return; } + + const value = event.currentTarget.value; + entry.value = value; + await this._settingsController.setProfileSetting(`audio.sources[${index}]`, value); + } + + async _onAudioSourceRemoveClicked(entry) { + await this._removeAudioSourceEntry(entry); + } } diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js index faf4e592..13f90886 100644 --- a/ext/bg/js/settings/backup.js +++ b/ext/bg/js/settings/backup.js @@ -16,363 +16,362 @@ */ /* global - * apiGetDefaultAnkiFieldTemplates - * apiGetEnvironmentInfo - * apiOptionsGetFull + * api * optionsGetDefault * optionsUpdateVersion - * utilBackend - * utilBackgroundIsolate - * utilIsolate - * utilReadFileArrayBuffer */ -// Exporting - -let _settingsExportToken = null; -let _settingsExportRevoke = null; -const SETTINGS_EXPORT_CURRENT_VERSION = 0; - -function _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) { - const values = [ - date.getUTCFullYear().toString(), - dateSeparator, - (date.getUTCMonth() + 1).toString().padStart(2, '0'), - dateSeparator, - date.getUTCDate().toString().padStart(2, '0'), - dateTimeSeparator, - date.getUTCHours().toString().padStart(2, '0'), - timeSeparator, - date.getUTCMinutes().toString().padStart(2, '0'), - timeSeparator, - date.getUTCSeconds().toString().padStart(2, '0') - ]; - return values.slice(0, resolution * 2 - 1).join(''); -} +class SettingsBackup { + constructor(settingsController) { + this._settingsController = settingsController; + this._settingsExportToken = null; + this._settingsExportRevoke = null; + this._currentVersion = 0; + } -async function _getSettingsExportData(date) { - const optionsFull = await apiOptionsGetFull(); - const environment = await apiGetEnvironmentInfo(); - const fieldTemplatesDefault = await apiGetDefaultAnkiFieldTemplates(); + prepare() { + document.querySelector('#settings-export').addEventListener('click', this._onSettingsExportClick.bind(this), false); + document.querySelector('#settings-import').addEventListener('click', this._onSettingsImportClick.bind(this), false); + document.querySelector('#settings-import-file').addEventListener('change', this._onSettingsImportFileChange.bind(this), false); + document.querySelector('#settings-reset').addEventListener('click', this._onSettingsResetClick.bind(this), false); + document.querySelector('#settings-reset-modal-confirm').addEventListener('click', this._onSettingsResetConfirmClick.bind(this), false); + } - // Format options - for (const {options} of optionsFull.profiles) { - if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) { - delete options.anki.fieldTemplates; // Default - } + // Private + + _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) { + const values = [ + date.getUTCFullYear().toString(), + dateSeparator, + (date.getUTCMonth() + 1).toString().padStart(2, '0'), + dateSeparator, + date.getUTCDate().toString().padStart(2, '0'), + dateTimeSeparator, + date.getUTCHours().toString().padStart(2, '0'), + timeSeparator, + date.getUTCMinutes().toString().padStart(2, '0'), + timeSeparator, + date.getUTCSeconds().toString().padStart(2, '0') + ]; + return values.slice(0, resolution * 2 - 1).join(''); } - const data = { - version: SETTINGS_EXPORT_CURRENT_VERSION, - date: _getSettingsExportDateString(date, '-', ' ', ':', 6), - url: chrome.runtime.getURL('/'), - manifest: chrome.runtime.getManifest(), - environment, - userAgent: navigator.userAgent, - options: optionsFull - }; - - return data; -} + async _getSettingsExportData(date) { + const optionsFull = await this._settingsController.getOptionsFull(); + const environment = await api.getEnvironmentInfo(); + const fieldTemplatesDefault = await api.getDefaultAnkiFieldTemplates(); -function _saveBlob(blob, fileName) { - if (typeof navigator === 'object' && typeof navigator.msSaveBlob === 'function') { - if (navigator.msSaveBlob(blob)) { - return; + // Format options + for (const {options} of optionsFull.profiles) { + if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) { + delete options.anki.fieldTemplates; // Default + } } - } - const blobUrl = URL.createObjectURL(blob); + const data = { + version: this._currentVersion, + date: this._getSettingsExportDateString(date, '-', ' ', ':', 6), + url: chrome.runtime.getURL('/'), + manifest: chrome.runtime.getManifest(), + environment, + userAgent: navigator.userAgent, + options: optionsFull + }; - const a = document.createElement('a'); - a.href = blobUrl; - a.download = fileName; - a.rel = 'noopener'; - a.target = '_blank'; + return data; + } - const revoke = () => { - URL.revokeObjectURL(blobUrl); - a.href = ''; - _settingsExportRevoke = null; - }; - _settingsExportRevoke = revoke; + _saveBlob(blob, fileName) { + if (typeof navigator === 'object' && typeof navigator.msSaveBlob === 'function') { + if (navigator.msSaveBlob(blob)) { + return; + } + } - a.dispatchEvent(new MouseEvent('click')); - setTimeout(revoke, 60000); -} + const blobUrl = URL.createObjectURL(blob); -async function _onSettingsExportClick() { - if (_settingsExportRevoke !== null) { - _settingsExportRevoke(); - _settingsExportRevoke = null; - } + const a = document.createElement('a'); + a.href = blobUrl; + a.download = fileName; + a.rel = 'noopener'; + a.target = '_blank'; - const date = new Date(Date.now()); + const revoke = () => { + URL.revokeObjectURL(blobUrl); + a.href = ''; + this._settingsExportRevoke = null; + }; + this._settingsExportRevoke = revoke; - const token = {}; - _settingsExportToken = token; - const data = await _getSettingsExportData(date); - if (_settingsExportToken !== token) { - // A new export has been started - return; + a.dispatchEvent(new MouseEvent('click')); + setTimeout(revoke, 60000); } - _settingsExportToken = null; - const fileName = `yomichan-settings-${_getSettingsExportDateString(date, '-', '-', '-', 6)}.json`; - const blob = new Blob([JSON.stringify(data, null, 4)], {type: 'application/json'}); - _saveBlob(blob, fileName); -} - - -// Importing + async _onSettingsExportClick() { + if (this._settingsExportRevoke !== null) { + this._settingsExportRevoke(); + this._settingsExportRevoke = null; + } -async function _settingsImportSetOptionsFull(optionsFull) { - return utilIsolate(utilBackend().setFullOptions( - utilBackgroundIsolate(optionsFull) - )); -} + const date = new Date(Date.now()); -function _showSettingsImportError(error) { - yomichan.logError(error); - document.querySelector('#settings-import-error-modal-message').textContent = `${error}`; - $('#settings-import-error-modal').modal('show'); -} + const token = {}; + this._settingsExportToken = token; + const data = await this._getSettingsExportData(date); + if (this._settingsExportToken !== token) { + // A new export has been started + return; + } + this._settingsExportToken = null; -async function _showSettingsImportWarnings(warnings) { - const modalNode = $('#settings-import-warning-modal'); - const buttons = document.querySelectorAll('.settings-import-warning-modal-import-button'); - const messageContainer = document.querySelector('#settings-import-warning-modal-message'); - if (modalNode.length === 0 || buttons.length === 0 || messageContainer === null) { - return {result: false}; + const fileName = `yomichan-settings-${this._getSettingsExportDateString(date, '-', '-', '-', 6)}.json`; + const blob = new Blob([JSON.stringify(data, null, 4)], {type: 'application/json'}); + this._saveBlob(blob, fileName); } - // Set message - const fragment = document.createDocumentFragment(); - for (const warning of warnings) { - const node = document.createElement('li'); - node.textContent = `${warning}`; - fragment.appendChild(node); + _readFileArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(file); + }); } - messageContainer.textContent = ''; - messageContainer.appendChild(fragment); - - // Show modal - modalNode.modal('show'); - - // Wait for modal to close - return new Promise((resolve) => { - const onButtonClick = (e) => { - e.preventDefault(); - complete({ - result: true, - sanitize: e.currentTarget.dataset.importSanitize === 'true' - }); - modalNode.modal('hide'); - }; - const onModalHide = () => { - complete({result: false}); - }; - let completed = false; - const complete = (result) => { - if (completed) { return; } - completed = true; + // Importing - modalNode.off('hide.bs.modal', onModalHide); - for (const button of buttons) { - button.removeEventListener('click', onButtonClick, false); - } + async _settingsImportSetOptionsFull(optionsFull) { + await this._settingsController.setAllSettings(optionsFull); + } - resolve(result); - }; + _showSettingsImportError(error) { + yomichan.logError(error); + document.querySelector('#settings-import-error-modal-message').textContent = `${error}`; + $('#settings-import-error-modal').modal('show'); + } - // Hook events - modalNode.on('hide.bs.modal', onModalHide); - for (const button of buttons) { - button.addEventListener('click', onButtonClick, false); + async _showSettingsImportWarnings(warnings) { + const modalNode = $('#settings-import-warning-modal'); + const buttons = document.querySelectorAll('.settings-import-warning-modal-import-button'); + const messageContainer = document.querySelector('#settings-import-warning-modal-message'); + if (modalNode.length === 0 || buttons.length === 0 || messageContainer === null) { + return {result: false}; } - }); -} -function _isLocalhostUrl(urlString) { - try { - const url = new URL(urlString); - switch (url.hostname.toLowerCase()) { - case 'localhost': - case '127.0.0.1': - case '[::1]': - switch (url.protocol.toLowerCase()) { - case 'http:': - case 'https:': - return true; - } - break; + // Set message + const fragment = document.createDocumentFragment(); + for (const warning of warnings) { + const node = document.createElement('li'); + node.textContent = `${warning}`; + fragment.appendChild(node); } - } catch (e) { - // NOP - } - return false; -} + messageContainer.textContent = ''; + messageContainer.appendChild(fragment); + + // Show modal + modalNode.modal('show'); + + // Wait for modal to close + return new Promise((resolve) => { + const onButtonClick = (e) => { + e.preventDefault(); + complete({ + result: true, + sanitize: e.currentTarget.dataset.importSanitize === 'true' + }); + modalNode.modal('hide'); + }; + const onModalHide = () => { + complete({result: false}); + }; + + let completed = false; + const complete = (result) => { + if (completed) { return; } + completed = true; + + modalNode.off('hide.bs.modal', onModalHide); + for (const button of buttons) { + button.removeEventListener('click', onButtonClick, false); + } -function _settingsImportSanitizeProfileOptions(options, dryRun) { - const warnings = []; + resolve(result); + }; - const anki = options.anki; - if (isObject(anki)) { - const fieldTemplates = anki.fieldTemplates; - if (typeof fieldTemplates === 'string') { - warnings.push('anki.fieldTemplates contains a non-default value'); - if (!dryRun) { - delete anki.fieldTemplates; - } - } - const server = anki.server; - if (typeof server === 'string' && server.length > 0 && !_isLocalhostUrl(server)) { - warnings.push('anki.server uses a non-localhost URL'); - if (!dryRun) { - delete anki.server; + // Hook events + modalNode.on('hide.bs.modal', onModalHide); + for (const button of buttons) { + button.addEventListener('click', onButtonClick, false); } - } + }); } - const audio = options.audio; - if (isObject(audio)) { - const customSourceUrl = audio.customSourceUrl; - if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !_isLocalhostUrl(customSourceUrl)) { - warnings.push('audio.customSourceUrl uses a non-localhost URL'); - if (!dryRun) { - delete audio.customSourceUrl; + _isLocalhostUrl(urlString) { + try { + const url = new URL(urlString); + switch (url.hostname.toLowerCase()) { + case 'localhost': + case '127.0.0.1': + case '[::1]': + switch (url.protocol.toLowerCase()) { + case 'http:': + case 'https:': + return true; + } + break; } + } catch (e) { + // NOP } + return false; } - return warnings; -} - -function _settingsImportSanitizeOptions(optionsFull, dryRun) { - const warnings = new Set(); + _settingsImportSanitizeProfileOptions(options, dryRun) { + const warnings = []; - const profiles = optionsFull.profiles; - if (Array.isArray(profiles)) { - for (const profile of profiles) { - if (!isObject(profile)) { continue; } - const options = profile.options; - if (!isObject(options)) { continue; } - - const warnings2 = _settingsImportSanitizeProfileOptions(options, dryRun); - for (const warning of warnings2) { - warnings.add(warning); + const anki = options.anki; + if (isObject(anki)) { + const fieldTemplates = anki.fieldTemplates; + if (typeof fieldTemplates === 'string') { + warnings.push('anki.fieldTemplates contains a non-default value'); + if (!dryRun) { + delete anki.fieldTemplates; + } + } + const server = anki.server; + if (typeof server === 'string' && server.length > 0 && !this._isLocalhostUrl(server)) { + warnings.push('anki.server uses a non-localhost URL'); + if (!dryRun) { + delete anki.server; + } } } - } - return warnings; -} + const audio = options.audio; + if (isObject(audio)) { + const customSourceUrl = audio.customSourceUrl; + if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !this._isLocalhostUrl(customSourceUrl)) { + warnings.push('audio.customSourceUrl uses a non-localhost URL'); + if (!dryRun) { + delete audio.customSourceUrl; + } + } + } -function _utf8Decode(arrayBuffer) { - try { - return new TextDecoder('utf-8').decode(arrayBuffer); - } catch (e) { - const binaryString = String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)); - return decodeURIComponent(escape(binaryString)); + return warnings; } -} -async function _importSettingsFile(file) { - const dataString = _utf8Decode(await utilReadFileArrayBuffer(file)); - const data = JSON.parse(dataString); + _settingsImportSanitizeOptions(optionsFull, dryRun) { + const warnings = new Set(); - // Type check - if (!isObject(data)) { - throw new Error(`Invalid data type: ${typeof data}`); - } + const profiles = optionsFull.profiles; + if (Array.isArray(profiles)) { + for (const profile of profiles) { + if (!isObject(profile)) { continue; } + const options = profile.options; + if (!isObject(options)) { continue; } - // Version check - const version = data.version; - if (!( - typeof version === 'number' && - Number.isFinite(version) && - version === Math.floor(version) - )) { - throw new Error(`Invalid version: ${version}`); - } + const warnings2 = this._settingsImportSanitizeProfileOptions(options, dryRun); + for (const warning of warnings2) { + warnings.add(warning); + } + } + } - if (!( - version >= 0 && - version <= SETTINGS_EXPORT_CURRENT_VERSION - )) { - throw new Error(`Unsupported version: ${version}`); + return warnings; } - // Verify options exists - let optionsFull = data.options; - if (!isObject(optionsFull)) { - throw new Error(`Invalid options type: ${typeof optionsFull}`); + _utf8Decode(arrayBuffer) { + try { + return new TextDecoder('utf-8').decode(arrayBuffer); + } catch (e) { + const binaryString = String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)); + return decodeURIComponent(escape(binaryString)); + } } - // Upgrade options - optionsFull = optionsUpdateVersion(optionsFull, {}); + async _importSettingsFile(file) { + const dataString = this._utf8Decode(await this._readFileArrayBuffer(file)); + const data = JSON.parse(dataString); - // Check for warnings - const sanitizationWarnings = _settingsImportSanitizeOptions(optionsFull, true); - - // Show sanitization warnings - if (sanitizationWarnings.size > 0) { - const {result, sanitize} = await _showSettingsImportWarnings(sanitizationWarnings); - if (!result) { return; } + // Type check + if (!isObject(data)) { + throw new Error(`Invalid data type: ${typeof data}`); + } - if (sanitize !== false) { - _settingsImportSanitizeOptions(optionsFull, false); + // Version check + const version = data.version; + if (!( + typeof version === 'number' && + Number.isFinite(version) && + version === Math.floor(version) + )) { + throw new Error(`Invalid version: ${version}`); } - } - // Assign options - await _settingsImportSetOptionsFull(optionsFull); + if (!( + version >= 0 && + version <= this._currentVersion + )) { + throw new Error(`Unsupported version: ${version}`); + } - // Reload settings page - window.location.reload(); -} + // Verify options exists + let optionsFull = data.options; + if (!isObject(optionsFull)) { + throw new Error(`Invalid options type: ${typeof optionsFull}`); + } -function _onSettingsImportClick() { - document.querySelector('#settings-import-file').click(); -} + // Upgrade options + optionsFull = optionsUpdateVersion(optionsFull, {}); -function _onSettingsImportFileChange(e) { - const files = e.target.files; - if (files.length === 0) { return; } + // Check for warnings + const sanitizationWarnings = this._settingsImportSanitizeOptions(optionsFull, true); - const file = files[0]; - e.target.value = null; - _importSettingsFile(file).catch(_showSettingsImportError); -} + // Show sanitization warnings + if (sanitizationWarnings.size > 0) { + const {result, sanitize} = await this._showSettingsImportWarnings(sanitizationWarnings); + if (!result) { return; } + if (sanitize !== false) { + this._settingsImportSanitizeOptions(optionsFull, false); + } + } -// Resetting + // Assign options + await this._settingsImportSetOptionsFull(optionsFull); + } -function _onSettingsResetClick() { - $('#settings-reset-modal').modal('show'); -} + _onSettingsImportClick() { + document.querySelector('#settings-import-file').click(); + } -async function _onSettingsResetConfirmClick() { - $('#settings-reset-modal').modal('hide'); + async _onSettingsImportFileChange(e) { + const files = e.target.files; + if (files.length === 0) { return; } - // Get default options - const optionsFull = optionsGetDefault(); + const file = files[0]; + e.target.value = null; + try { + await this._importSettingsFile(file); + } catch (error) { + this._showSettingsImportError(error); + } + } - // Assign options - await _settingsImportSetOptionsFull(optionsFull); + // Resetting - // Reload settings page - window.location.reload(); -} + _onSettingsResetClick() { + $('#settings-reset-modal').modal('show'); + } + async _onSettingsResetConfirmClick() { + $('#settings-reset-modal').modal('hide'); -// Setup + // Get default options + const optionsFull = optionsGetDefault(); -function backupInitialize() { - document.querySelector('#settings-export').addEventListener('click', _onSettingsExportClick, false); - document.querySelector('#settings-import').addEventListener('click', _onSettingsImportClick, false); - document.querySelector('#settings-import-file').addEventListener('change', _onSettingsImportFileChange, false); - document.querySelector('#settings-reset').addEventListener('click', _onSettingsResetClick, false); - document.querySelector('#settings-reset-modal-confirm').addEventListener('click', _onSettingsResetConfirmClick, false); + // Assign options + await this._settingsImportSetOptionsFull(optionsFull); + } } diff --git a/ext/bg/js/settings/clipboard-popups-controller.js b/ext/bg/js/settings/clipboard-popups-controller.js new file mode 100644 index 00000000..294663f9 --- /dev/null +++ b/ext/bg/js/settings/clipboard-popups-controller.js @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class ClipboardPopupsController { + constructor(settingsController) { + this._settingsController = settingsController; + this._checkbox = document.querySelector('#enable-clipboard-popups'); + } + + async prepare() { + this._checkbox.addEventListener('change', this._onEnableClipboardPopupsChanged.bind(this), false); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + // Private + + _onOptionsChanged({options}) { + this._checkbox.checked = options.general.enableClipboardPopups; + } + + async _onEnableClipboardPopupsChanged(e) { + const checkbox = e.currentTarget; + let value = checkbox.checked; + + if (value) { + value = await new Promise((resolve) => { + chrome.permissions.request({permissions: ['clipboardRead']}, resolve); + }); + checkbox.checked = value; + } + + await this._settingsController.setProfileSetting('general.enableClipboardPopups', value); + } +} diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js index 632c01ea..94a71233 100644 --- a/ext/bg/js/settings/dictionaries.js +++ b/ext/bg/js/settings/dictionaries.js @@ -17,27 +17,13 @@ /* global * PageExitPrevention - * apiDeleteDictionary - * apiGetDictionaryCounts - * apiGetDictionaryInfo - * apiImportDictionaryArchive - * apiOptionsGet - * apiOptionsGetFull - * apiPurgeDatabase - * getOptionsContext - * getOptionsFullMutable - * getOptionsMutable - * settingsSaveOptions - * storageEstimate - * storageUpdateStats + * api * utilBackgroundIsolate */ -let dictionaryUI = null; - - -class SettingsDictionaryListUI { +class SettingsDictionaryListUI extends EventDispatcher { constructor(container, template, extraContainer, extraTemplate) { + super(); this.container = container; this.template = template; this.extraContainer = extraContainer; @@ -312,15 +298,15 @@ class SettingsDictionaryEntryUI { progressBar.style.width = `${percent}%`; }; - await apiDeleteDictionary(this.dictionaryInfo.title, onProgress); + await api.deleteDictionary(this.dictionaryInfo.title, onProgress); } catch (e) { - dictionaryErrorsShow([e]); + this.dictionaryErrorsShow([e]); } finally { prevention.end(); this.isDeleting = false; progress.hidden = true; - onDatabaseUpdated(); + this.parent.trigger('databaseUpdated'); } } @@ -394,340 +380,342 @@ class SettingsDictionaryExtraUI { } } +class DictionaryController { + constructor(settingsController, storageController) { + this._settingsController = settingsController; + this._storageController = storageController; + this._dictionaryUI = null; + this._dictionaryErrorToStringOverrides = [ + [ + 'A mutation operation was attempted on a database that did not allow mutations.', + 'Access to IndexedDB appears to be restricted. Firefox seems to require that the history preference is set to "Remember history" before IndexedDB use of any kind is allowed.' + ], + [ + 'The operation failed for reasons unrelated to the database itself and not covered by any other error code.', + 'Unable to access IndexedDB due to a possibly corrupt user profile. Try using the "Refresh Firefox" feature to reset your user profile.' + ], + [ + 'BulkError', + 'Unable to finish importing dictionary data into IndexedDB. This may indicate that you do not have sufficient disk space available to complete this operation.' + ] + ]; + } -async function dictSettingsInitialize() { - dictionaryUI = new SettingsDictionaryListUI( - document.querySelector('#dict-groups'), - document.querySelector('#dict-template'), - document.querySelector('#dict-groups-extra'), - document.querySelector('#dict-extra-template') - ); - dictionaryUI.save = settingsSaveOptions; - - document.querySelector('#dict-purge-button').addEventListener('click', onDictionaryPurgeButtonClick, false); - document.querySelector('#dict-purge-confirm').addEventListener('click', onDictionaryPurge, false); - document.querySelector('#dict-file-button').addEventListener('click', onDictionaryImportButtonClick, false); - document.querySelector('#dict-file').addEventListener('change', onDictionaryImport, false); - document.querySelector('#dict-main').addEventListener('change', onDictionaryMainChanged, false); - document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', onDatabaseEnablePrefixWildcardSearchesChanged, false); - - await onDictionaryOptionsChanged(); - await onDatabaseUpdated(); -} - -async function onDictionaryOptionsChanged() { - if (dictionaryUI === null) { return; } + async prepare() { + this._dictionaryUI = new SettingsDictionaryListUI( + document.querySelector('#dict-groups'), + document.querySelector('#dict-template'), + document.querySelector('#dict-groups-extra'), + document.querySelector('#dict-extra-template') + ); + this._dictionaryUI.save = () => this._settingsController.save(); + this._dictionaryUI.on('databaseUpdated', this._onDatabaseUpdated.bind(this)); - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); + document.querySelector('#dict-purge-button').addEventListener('click', this._onPurgeButtonClick.bind(this), false); + document.querySelector('#dict-purge-confirm').addEventListener('click', this._onPurgeConfirmButtonClick.bind(this), false); + document.querySelector('#dict-file-button').addEventListener('click', this._onImportButtonClick.bind(this), false); + document.querySelector('#dict-file').addEventListener('change', this._onImportFileChange.bind(this), false); + document.querySelector('#dict-main').addEventListener('change', this._onDictionaryMainChanged.bind(this), false); + document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', this._onDatabaseEnablePrefixWildcardSearchesChanged.bind(this), false); - dictionaryUI.setOptionsDictionaries(options.dictionaries); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); - const optionsFull = await apiOptionsGetFull(); - document.querySelector('#database-enable-prefix-wildcard-searches').checked = optionsFull.global.database.prefixWildcardsSupported; + await this._onOptionsChanged(); + await this._onDatabaseUpdated(); + } - await updateMainDictionarySelectValue(); -} + // Private -async function onDatabaseUpdated() { - try { - const dictionaries = await apiGetDictionaryInfo(); - dictionaryUI.setDictionaries(dictionaries); + async _onOptionsChanged() { + const options = await this._settingsController.getOptionsMutable(); - document.querySelector('#dict-warning').hidden = (dictionaries.length > 0); + this._dictionaryUI.setOptionsDictionaries(options.dictionaries); - updateMainDictionarySelectOptions(dictionaries); - await updateMainDictionarySelectValue(); + const optionsFull = await this._settingsController.getOptionsFull(); + document.querySelector('#database-enable-prefix-wildcard-searches').checked = optionsFull.global.database.prefixWildcardsSupported; - const {counts, total} = await apiGetDictionaryCounts(dictionaries.map((v) => v.title), true); - dictionaryUI.setCounts(counts, total); - } catch (e) { - dictionaryErrorsShow([e]); + await this._updateMainDictionarySelectValue(); } -} -function updateMainDictionarySelectOptions(dictionaries) { - const select = document.querySelector('#dict-main'); - select.textContent = ''; // Empty + _updateMainDictionarySelectOptions(dictionaries) { + const select = document.querySelector('#dict-main'); + select.textContent = ''; // Empty - let option = document.createElement('option'); - option.className = 'text-muted'; - option.value = ''; - option.textContent = 'Not selected'; - select.appendChild(option); + let option = document.createElement('option'); + option.className = 'text-muted'; + option.value = ''; + option.textContent = 'Not selected'; + select.appendChild(option); - for (const {title, sequenced} of toIterable(dictionaries)) { - if (!sequenced) { continue; } + for (const {title, sequenced} of toIterable(dictionaries)) { + if (!sequenced) { continue; } - option = document.createElement('option'); - option.value = title; - option.textContent = title; - select.appendChild(option); + option = document.createElement('option'); + option.value = title; + option.textContent = title; + select.appendChild(option); + } } -} -async function updateMainDictionarySelectValue() { - const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); + async _updateMainDictionarySelectValue() { + const options = await this._settingsController.getOptions(); - const value = options.general.mainDictionary; + 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; + 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; + } } - } - 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); + 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; } - select.value = value; -} + _dictionaryErrorToString(error) { + if (error.toString) { + error = error.toString(); + } else { + error = `${error}`; + } -async function onDictionaryMainChanged(e) { - const select = e.target; - const value = select.value; + for (const [match, subst] of this._dictionaryErrorToStringOverrides) { + if (error.includes(match)) { + error = subst; + break; + } + } - const missingNodeOption = select.querySelector('option[data-not-installed=true]'); - if (missingNodeOption !== null && missingNodeOption.value !== value) { - missingNodeOption.parentNode.removeChild(missingNodeOption); + return error; } - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - options.general.mainDictionary = value; - await settingsSaveOptions(); -} + _dictionaryErrorsShow(errors) { + const dialog = document.querySelector('#dict-error'); + dialog.textContent = ''; + if (errors !== null && errors.length > 0) { + const uniqueErrors = new Map(); + for (let e of errors) { + yomichan.logError(e); + e = this._dictionaryErrorToString(e); + let count = uniqueErrors.get(e); + if (typeof count === 'undefined') { + count = 0; + } + uniqueErrors.set(e, count + 1); + } -function dictionaryErrorToString(error) { - if (error.toString) { - error = error.toString(); - } else { - error = `${error}`; - } + for (const [e, count] of uniqueErrors.entries()) { + const div = document.createElement('p'); + if (count > 1) { + div.textContent = `${e} `; + const em = document.createElement('em'); + em.textContent = `(${count})`; + div.appendChild(em); + } else { + div.textContent = `${e}`; + } + dialog.appendChild(div); + } - for (const [match, subst] of dictionaryErrorToString.overrides) { - if (error.includes(match)) { - error = subst; - break; + dialog.hidden = false; + } else { + dialog.hidden = true; } } - return error; -} -dictionaryErrorToString.overrides = [ - [ - 'A mutation operation was attempted on a database that did not allow mutations.', - 'Access to IndexedDB appears to be restricted. Firefox seems to require that the history preference is set to "Remember history" before IndexedDB use of any kind is allowed.' - ], - [ - 'The operation failed for reasons unrelated to the database itself and not covered by any other error code.', - 'Unable to access IndexedDB due to a possibly corrupt user profile. Try using the "Refresh Firefox" feature to reset your user profile.' - ], - [ - 'BulkError', - 'Unable to finish importing dictionary data into IndexedDB. This may indicate that you do not have sufficient disk space available to complete this operation.' - ] -]; - -function dictionaryErrorsShow(errors) { - const dialog = document.querySelector('#dict-error'); - dialog.textContent = ''; - - if (errors !== null && errors.length > 0) { - const uniqueErrors = new Map(); - for (let e of errors) { - yomichan.logError(e); - e = dictionaryErrorToString(e); - let count = uniqueErrors.get(e); - if (typeof count === 'undefined') { - count = 0; - } - uniqueErrors.set(e, count + 1); - } - - for (const [e, count] of uniqueErrors.entries()) { - const div = document.createElement('p'); - if (count > 1) { - div.textContent = `${e} `; - const em = document.createElement('em'); - em.textContent = `(${count})`; - div.appendChild(em); - } else { - div.textContent = `${e}`; - } - dialog.appendChild(div); + _dictionarySpinnerShow(show) { + const spinner = $('#dict-spinner'); + if (show) { + spinner.show(); + } else { + spinner.hide(); } + } - dialog.hidden = false; - } else { - dialog.hidden = true; + _dictReadFile(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsBinaryString(file); + }); } -} + async _onDatabaseUpdated() { + try { + const dictionaries = await api.getDictionaryInfo(); + this._dictionaryUI.setDictionaries(dictionaries); + + document.querySelector('#dict-warning').hidden = (dictionaries.length > 0); -function dictionarySpinnerShow(show) { - const spinner = $('#dict-spinner'); - if (show) { - spinner.show(); - } else { - spinner.hide(); + this._updateMainDictionarySelectOptions(dictionaries); + await this._updateMainDictionarySelectValue(); + + const {counts, total} = await api.getDictionaryCounts(dictionaries.map((v) => v.title), true); + this._dictionaryUI.setCounts(counts, total); + } catch (e) { + this._dictionaryErrorsShow([e]); + } } -} -function onDictionaryImportButtonClick() { - const dictFile = document.querySelector('#dict-file'); - dictFile.click(); -} + async _onDictionaryMainChanged(e) { + const select = e.target; + const value = select.value; -function onDictionaryPurgeButtonClick(e) { - e.preventDefault(); - $('#dict-purge-modal').modal('show'); -} + const missingNodeOption = select.querySelector('option[data-not-installed=true]'); + if (missingNodeOption !== null && missingNodeOption.value !== value) { + missingNodeOption.parentNode.removeChild(missingNodeOption); + } -async function onDictionaryPurge(e) { - e.preventDefault(); + const options = await this._settingsController.getOptionsMutable(); + options.general.mainDictionary = value; + await this._settingsController.save(); + } - $('#dict-purge-modal').modal('hide'); + _onImportButtonClick() { + const dictFile = document.querySelector('#dict-file'); + dictFile.click(); + } - const dictControls = $('#dict-importer, #dict-groups, #dict-groups-extra, #dict-main-group').hide(); - const dictProgress = document.querySelector('#dict-purge'); - dictProgress.hidden = false; + _onPurgeButtonClick(e) { + e.preventDefault(); + $('#dict-purge-modal').modal('show'); + } - const prevention = new PageExitPrevention(); + async _onPurgeConfirmButtonClick(e) { + e.preventDefault(); - try { - prevention.start(); - dictionaryErrorsShow(null); - dictionarySpinnerShow(true); + $('#dict-purge-modal').modal('hide'); - await apiPurgeDatabase(); - for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) { - options.dictionaries = utilBackgroundIsolate({}); - options.general.mainDictionary = ''; - } - await settingsSaveOptions(); + const dictControls = $('#dict-importer, #dict-groups, #dict-groups-extra, #dict-main-group').hide(); + const dictProgress = document.querySelector('#dict-purge'); + dictProgress.hidden = false; + + const prevention = new PageExitPrevention(); - onDatabaseUpdated(); - } catch (err) { - dictionaryErrorsShow([err]); - } finally { - prevention.end(); + try { + prevention.start(); + this._dictionaryErrorsShow(null); + this._dictionarySpinnerShow(true); + + await api.purgeDatabase(); + const optionsFull = await this._settingsController.getOptionsFullMutable(); + for (const {options} of toIterable(optionsFull.profiles)) { + options.dictionaries = utilBackgroundIsolate({}); + options.general.mainDictionary = ''; + } + await this._settingsController.save(); + + this._onDatabaseUpdated(); + } catch (err) { + this._dictionaryErrorsShow([err]); + } finally { + prevention.end(); - dictionarySpinnerShow(false); + this._dictionarySpinnerShow(false); - dictControls.show(); - dictProgress.hidden = true; + dictControls.show(); + dictProgress.hidden = true; - if (storageEstimate.mostRecent !== null) { - storageUpdateStats(); + this._storageController.updateStats(); } } -} -async function onDictionaryImport(e) { - const files = [...e.target.files]; - e.target.value = null; + async _onImportFileChange(e) { + const files = [...e.target.files]; + e.target.value = null; - const dictFile = $('#dict-file'); - const dictControls = $('#dict-importer').hide(); - const dictProgress = $('#dict-import-progress').show(); - const dictImportInfo = document.querySelector('#dict-import-info'); + const dictFile = $('#dict-file'); + const dictControls = $('#dict-importer').hide(); + const dictProgress = $('#dict-import-progress').show(); + const dictImportInfo = document.querySelector('#dict-import-info'); - const prevention = new PageExitPrevention(); + const prevention = new PageExitPrevention(); - try { - prevention.start(); - dictionaryErrorsShow(null); - dictionarySpinnerShow(true); + try { + prevention.start(); + this._dictionaryErrorsShow(null); + this._dictionarySpinnerShow(true); - const setProgress = (percent) => dictProgress.find('.progress-bar').css('width', `${percent}%`); - const updateProgress = (total, current) => { - setProgress(current / total * 100.0); - if (storageEstimate.mostRecent !== null && !storageUpdateStats.isUpdating) { - storageUpdateStats(); - } - }; + const setProgress = (percent) => dictProgress.find('.progress-bar').css('width', `${percent}%`); + const updateProgress = (total, current) => { + setProgress(current / total * 100.0); + this._storageController.updateStats(); + }; - const optionsFull = await apiOptionsGetFull(); + const optionsFull = await this._settingsController.getOptionsFull(); - const importDetails = { - prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported - }; + const importDetails = { + prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported + }; - for (let i = 0, ii = files.length; i < ii; ++i) { - setProgress(0.0); - if (ii > 1) { - dictImportInfo.hidden = false; - dictImportInfo.textContent = `(${i + 1} of ${ii})`; - } + for (let i = 0, ii = files.length; i < ii; ++i) { + setProgress(0.0); + if (ii > 1) { + dictImportInfo.hidden = false; + dictImportInfo.textContent = `(${i + 1} of ${ii})`; + } - const archiveContent = await dictReadFile(files[i]); - const {result, errors} = await apiImportDictionaryArchive(archiveContent, importDetails, updateProgress); - for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) { - const dictionaryOptions = SettingsDictionaryListUI.createDictionaryOptions(); - dictionaryOptions.enabled = true; - options.dictionaries[result.title] = dictionaryOptions; - if (result.sequenced && options.general.mainDictionary === '') { - options.general.mainDictionary = result.title; + const archiveContent = await this._dictReadFile(files[i]); + const {result, errors} = await api.importDictionaryArchive(archiveContent, importDetails, updateProgress); + const optionsFull2 = await this._settingsController.getOptionsFullMutable(); + for (const {options} of toIterable(optionsFull2.profiles)) { + const dictionaryOptions = SettingsDictionaryListUI.createDictionaryOptions(); + dictionaryOptions.enabled = true; + options.dictionaries[result.title] = dictionaryOptions; + if (result.sequenced && options.general.mainDictionary === '') { + options.general.mainDictionary = result.title; + } } - } - await settingsSaveOptions(); + await this._settingsController.save(); - if (errors.length > 0) { - const errors2 = errors.map((error) => jsonToError(error)); - errors2.push(`Dictionary may not have been imported properly: ${errors2.length} error${errors2.length === 1 ? '' : 's'} reported.`); - dictionaryErrorsShow(errors2); + if (errors.length > 0) { + const errors2 = errors.map((error) => jsonToError(error)); + errors2.push(`Dictionary may not have been imported properly: ${errors2.length} error${errors2.length === 1 ? '' : 's'} reported.`); + this._dictionaryErrorsShow(errors2); + } + + this._onDatabaseUpdated(); } + } catch (err) { + this._dictionaryErrorsShow([err]); + } finally { + prevention.end(); + this._dictionarySpinnerShow(false); - onDatabaseUpdated(); + dictImportInfo.hidden = false; + dictImportInfo.textContent = ''; + dictFile.val(''); + dictControls.show(); + dictProgress.hide(); } - } catch (err) { - dictionaryErrorsShow([err]); - } finally { - prevention.end(); - dictionarySpinnerShow(false); - - dictImportInfo.hidden = false; - dictImportInfo.textContent = ''; - dictFile.val(''); - dictControls.show(); - dictProgress.hide(); } -} - -function dictReadFile(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(reader.error); - reader.readAsBinaryString(file); - }); -} - -async function onDatabaseEnablePrefixWildcardSearchesChanged(e) { - const optionsFull = await getOptionsFullMutable(); - const v = !!e.target.checked; - if (optionsFull.global.database.prefixWildcardsSupported === v) { return; } - optionsFull.global.database.prefixWildcardsSupported = !!e.target.checked; - await settingsSaveOptions(); + async _onDatabaseEnablePrefixWildcardSearchesChanged(e) { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + const v = !!e.target.checked; + if (optionsFull.global.database.prefixWildcardsSupported === v) { return; } + optionsFull.global.database.prefixWildcardsSupported = !!e.target.checked; + await this._settingsController.save(); + } } diff --git a/ext/bg/js/settings/generic-setting-controller.js b/ext/bg/js/settings/generic-setting-controller.js new file mode 100644 index 00000000..bdea8e3d --- /dev/null +++ b/ext/bg/js/settings/generic-setting-controller.js @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* globals + * DOMDataBinder + */ + +class GenericSettingController { + constructor(settingsController) { + this._settingsController = settingsController; + this._defaultScope = 'profile'; + this._dataBinder = new DOMDataBinder({ + selector: '[data-setting]', + createElementMetadata: this._createElementMetadata.bind(this), + compareElementMetadata: this._compareElementMetadata.bind(this), + getValues: this._getValues.bind(this), + setValues: this._setValues.bind(this) + }); + this._transforms = new Map([ + ['setDocumentAttribute', this._setDocumentAttribute.bind(this)], + ['splitTags', this._splitTags.bind(this)], + ['joinTags', this._joinTags.bind(this)] + ]); + } + + async prepare() { + this._dataBinder.observe(document.body); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + } + + // Private + + _onOptionsChanged() { + this._dataBinder.refresh(); + } + + _createElementMetadata(element) { + return { + path: element.dataset.setting, + scope: element.dataset.scope, + transformPre: element.dataset.transformPre, + transformPost: element.dataset.transformPost + }; + } + + _compareElementMetadata(metadata1, metadata2) { + return ( + metadata1.path === metadata2.path && + metadata1.scope === metadata2.scope && + metadata1.transformPre === metadata2.transformPre && + metadata1.transformPost === metadata2.transformPost + ); + } + + async _getValues(targets) { + const defaultScope = this._defaultScope; + const settingsTargets = []; + for (const {metadata: {path, scope}} of targets) { + const target = { + path, + scope: scope || defaultScope + }; + settingsTargets.push(target); + } + return this._transformResults(await this._settingsController.getSettings(settingsTargets), targets); + } + + async _setValues(targets) { + const defaultScope = this._defaultScope; + const settingsTargets = []; + for (const {metadata, value, element} of targets) { + const {path, scope, transformPre} = metadata; + const target = { + path, + scope: scope || defaultScope, + action: 'set', + value: this._transform(value, transformPre, metadata, element) + }; + settingsTargets.push(target); + } + return this._transformResults(await this._settingsController.modifySettings(settingsTargets), targets); + } + + _transformResults(values, targets) { + return values.map((value, i) => { + const error = value.error; + if (error) { return jsonToError(error); } + const {metadata, element} = targets[i]; + const result = this._transform(value.result, metadata.transformPost, metadata, element); + return {result}; + }); + } + + _transform(value, transform, metadata, element) { + if (typeof transform === 'string') { + const transformFunction = this._transforms.get(transform); + if (typeof transformFunction !== 'undefined') { + value = transformFunction(value, metadata, element); + } + } + return value; + } + + // Transforms + + _setDocumentAttribute(value, metadata, element) { + document.documentElement.setAttribute(element.dataset.documentAttribute, `${value}`); + return value; + } + + _splitTags(value) { + return `${value}`.split(/[,; ]+/).filter((v) => !!v); + } + + _joinTags(value) { + return value.join(' '); + } +} diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js index 61395b1c..e22c5e53 100644 --- a/ext/bg/js/settings/main.js +++ b/ext/bg/js/settings/main.js @@ -16,268 +16,20 @@ */ /* global - * ankiErrorShown - * ankiFieldsToDict - * ankiInitialize - * ankiTemplatesInitialize - * ankiTemplatesUpdateValue - * apiForwardLogsToBackend - * apiGetEnvironmentInfo - * apiOptionsSave - * appearanceInitialize - * audioSettingsInitialize - * backupInitialize - * dictSettingsInitialize - * getOptionsContext - * onAnkiOptionsChanged - * onDictionaryOptionsChanged - * profileOptionsSetup - * storageInfoInitialize - * utilBackend - * utilBackgroundIsolate - * utilIsolate + * AnkiController + * AnkiTemplatesController + * AudioController + * ClipboardPopupsController + * DictionaryController + * GenericSettingController + * PopupPreviewController + * ProfileController + * SettingsBackup + * SettingsController + * StorageController + * api */ -function getOptionsMutable(optionsContext) { - return utilBackend().getOptions( - utilBackgroundIsolate(optionsContext) - ); -} - -function getOptionsFullMutable() { - return utilBackend().getFullOptions(); -} - -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'); - options.general.resultOutputMode = $('#result-output-mode').val(); - options.general.debugInfo = $('#show-debug-info').prop('checked'); - options.general.showAdvanced = $('#show-advanced-options').prop('checked'); - options.general.maxResults = parseInt($('#max-displayed-results').val(), 10); - options.general.popupDisplayMode = $('#popup-display-mode').val(); - options.general.popupHorizontalTextPosition = $('#popup-horizontal-text-position').val(); - options.general.popupVerticalTextPosition = $('#popup-vertical-text-position').val(); - options.general.popupWidth = parseInt($('#popup-width').val(), 10); - options.general.popupHeight = parseInt($('#popup-height').val(), 10); - options.general.popupHorizontalOffset = parseInt($('#popup-horizontal-offset').val(), 0); - 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 = 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.showPitchAccentDownstepNotation = $('#show-pitch-accent-downstep-notation').prop('checked'); - options.general.showPitchAccentPositionNotation = $('#show-pitch-accent-position-notation').prop('checked'); - options.general.showPitchAccentGraph = $('#show-pitch-accent-graph').prop('checked'); - options.general.showIframePopupsInRootFrame = $('#show-iframe-popups-in-root-frame').prop('checked'); - options.general.popupTheme = $('#popup-theme').val(); - options.general.popupOuterTheme = $('#popup-outer-theme').val(); - options.general.customPopupCss = $('#custom-popup-css').val(); - options.general.customPopupOuterCss = $('#custom-popup-outer-css').val(); - - options.audio.enabled = $('#audio-playback-enabled').prop('checked'); - options.audio.autoPlay = $('#auto-play-audio').prop('checked'); - options.audio.volume = parseFloat($('#audio-playback-volume').val()); - options.audio.customSourceUrl = $('#audio-custom-source').val(); - options.audio.textToSpeechVoice = $('#text-to-speech-voice').val(); - - options.scanning.middleMouse = $('#middle-mouse-button-scan').prop('checked'); - options.scanning.touchInputEnabled = $('#touch-input-enabled').prop('checked'); - options.scanning.selectText = $('#select-matched-text').prop('checked'); - options.scanning.alphanumeric = $('#search-alphanumeric').prop('checked'); - options.scanning.autoHideResults = $('#auto-hide-results').prop('checked'); - options.scanning.deepDomScan = $('#deep-dom-scan').prop('checked'); - 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(); - options.scanning.popupNestingMaxDepth = parseInt($('#popup-nesting-max-depth').val(), 10); - - options.translation.convertHalfWidthCharacters = $('#translation-convert-half-width-characters').val(); - options.translation.convertNumericCharacters = $('#translation-convert-numeric-characters').val(); - options.translation.convertAlphabeticCharacters = $('#translation-convert-alphabetic-characters').val(); - options.translation.convertHiraganaToKatakana = $('#translation-convert-hiragana-to-katakana').val(); - options.translation.convertKatakanaToHiragana = $('#translation-convert-katakana-to-hiragana').val(); - options.translation.collapseEmphaticSequences = $('#translation-collapse-emphatic-sequences').val(); - - options.parsing.enableScanningParser = $('#parsing-scan-enable').prop('checked'); - options.parsing.enableMecabParser = $('#parsing-mecab-enable').prop('checked'); - options.parsing.termSpacing = $('#parsing-term-spacing').prop('checked'); - options.parsing.readingMode = $('#parsing-reading-mode').val(); - - const optionsAnkiEnableOld = options.anki.enable; - options.anki.enable = $('#anki-enable').prop('checked'); - options.anki.tags = utilBackgroundIsolate($('#card-tags').val().split(/[,; ]+/)); - options.anki.sentenceExt = parseInt($('#sentence-detection-extent').val(), 10); - options.anki.server = $('#interface-server').val(); - options.anki.duplicateScope = $('#duplicate-scope').val(); - options.anki.screenshot.format = $('#screenshot-format').val(); - options.anki.screenshot.quality = parseInt($('#screenshot-quality').val(), 10); - - if (optionsAnkiEnableOld && !ankiErrorShown()) { - options.anki.terms.deck = $('#anki-terms-deck').val(); - options.anki.terms.model = $('#anki-terms-model').val(); - options.anki.terms.fields = utilBackgroundIsolate(ankiFieldsToDict(document.querySelectorAll('#terms .anki-field-value'))); - options.anki.kanji.deck = $('#anki-kanji-deck').val(); - options.anki.kanji.model = $('#anki-kanji-model').val(); - options.anki.kanji.fields = utilBackgroundIsolate(ankiFieldsToDict(document.querySelectorAll('#kanji .anki-field-value'))); - } -} - -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); - $('#result-output-mode').val(options.general.resultOutputMode); - $('#show-debug-info').prop('checked', options.general.debugInfo); - $('#show-advanced-options').prop('checked', options.general.showAdvanced); - $('#max-displayed-results').val(options.general.maxResults); - $('#popup-display-mode').val(options.general.popupDisplayMode); - $('#popup-horizontal-text-position').val(options.general.popupHorizontalTextPosition); - $('#popup-vertical-text-position').val(options.general.popupVerticalTextPosition); - $('#popup-width').val(options.general.popupWidth); - $('#popup-height').val(options.general.popupHeight); - $('#popup-horizontal-offset').val(options.general.popupHorizontalOffset); - $('#popup-vertical-offset').val(options.general.popupVerticalOffset); - $('#popup-horizontal-offset2').val(options.general.popupHorizontalOffset2); - $('#popup-vertical-offset2').val(options.general.popupVerticalOffset2); - $('#popup-scaling-factor').val(options.general.popupScalingFactor); - $('#popup-scale-relative-to-page-zoom').prop('checked', options.general.popupScaleRelativeToPageZoom); - $('#popup-scale-relative-to-visual-viewport').prop('checked', options.general.popupScaleRelativeToVisualViewport); - $('#show-pitch-accent-downstep-notation').prop('checked', options.general.showPitchAccentDownstepNotation); - $('#show-pitch-accent-position-notation').prop('checked', options.general.showPitchAccentPositionNotation); - $('#show-pitch-accent-graph').prop('checked', options.general.showPitchAccentGraph); - $('#show-iframe-popups-in-root-frame').prop('checked', options.general.showIframePopupsInRootFrame); - $('#popup-theme').val(options.general.popupTheme); - $('#popup-outer-theme').val(options.general.popupOuterTheme); - $('#custom-popup-css').val(options.general.customPopupCss); - $('#custom-popup-outer-css').val(options.general.customPopupOuterCss); - - $('#audio-playback-enabled').prop('checked', options.audio.enabled); - $('#auto-play-audio').prop('checked', options.audio.autoPlay); - $('#audio-playback-volume').val(options.audio.volume); - $('#audio-custom-source').val(options.audio.customSourceUrl); - $('#text-to-speech-voice').val(options.audio.textToSpeechVoice).attr('data-value', options.audio.textToSpeechVoice); - - $('#middle-mouse-button-scan').prop('checked', options.scanning.middleMouse); - $('#touch-input-enabled').prop('checked', options.scanning.touchInputEnabled); - $('#select-matched-text').prop('checked', options.scanning.selectText); - $('#search-alphanumeric').prop('checked', options.scanning.alphanumeric); - $('#auto-hide-results').prop('checked', options.scanning.autoHideResults); - $('#deep-dom-scan').prop('checked', options.scanning.deepDomScan); - $('#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); - $('#popup-nesting-max-depth').val(options.scanning.popupNestingMaxDepth); - - $('#translation-convert-half-width-characters').val(options.translation.convertHalfWidthCharacters); - $('#translation-convert-numeric-characters').val(options.translation.convertNumericCharacters); - $('#translation-convert-alphabetic-characters').val(options.translation.convertAlphabeticCharacters); - $('#translation-convert-hiragana-to-katakana').val(options.translation.convertHiraganaToKatakana); - $('#translation-convert-katakana-to-hiragana').val(options.translation.convertKatakanaToHiragana); - $('#translation-collapse-emphatic-sequences').val(options.translation.collapseEmphaticSequences); - - $('#parsing-scan-enable').prop('checked', options.parsing.enableScanningParser); - $('#parsing-mecab-enable').prop('checked', options.parsing.enableMecabParser); - $('#parsing-term-spacing').prop('checked', options.parsing.termSpacing); - $('#parsing-reading-mode').val(options.parsing.readingMode); - - $('#anki-enable').prop('checked', options.anki.enable); - $('#card-tags').val(options.anki.tags.join(' ')); - $('#sentence-detection-extent').val(options.anki.sentenceExt); - $('#interface-server').val(options.anki.server); - $('#duplicate-scope').val(options.anki.duplicateScope); - $('#screenshot-format').val(options.anki.screenshot.format); - $('#screenshot-quality').val(options.anki.screenshot.quality); - - await ankiTemplatesUpdateValue(); - await onAnkiOptionsChanged(options); - await onDictionaryOptionsChanged(); - - formUpdateVisibility(options); -} - -function formSetupEventListeners() { - $('input, select, textarea').not('.anki-model').not('.ignore-form-changes *').change(onFormOptionsChanged); -} - -function formUpdateVisibility(options) { - document.documentElement.dataset.optionsAnkiEnable = `${!!options.anki.enable}`; - document.documentElement.dataset.optionsGeneralDebugInfo = `${!!options.general.debugInfo}`; - document.documentElement.dataset.optionsGeneralShowAdvanced = `${!!options.general.showAdvanced}`; - document.documentElement.dataset.optionsGeneralResultOutputMode = `${options.general.resultOutputMode}`; - - if (options.general.debugInfo) { - const temp = utilIsolate(options); - if (typeof temp.anki.fieldTemplates === 'string') { - temp.anki.fieldTemplates = '...'; - } - const text = JSON.stringify(temp, null, 4); - $('#debug').text(text); - } -} - -async function onFormOptionsChanged() { - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - - await formRead(options); - await settingsSaveOptions(); - formUpdateVisibility(options); - - await onAnkiOptionsChanged(options); -} - - -function settingsGetSource() { - return new Promise((resolve) => { - chrome.tabs.getCurrent((tab) => resolve(`settings${tab ? tab.id : ''}`)); - }); -} - -async function settingsSaveOptions() { - const source = await settingsGetSource(); - await apiOptionsSave(source); -} - -async function onOptionsUpdated({source}) { - const thisSource = await settingsGetSource(); - if (source === thisSource) { return; } - - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - await formWrite(options); -} - - function showExtensionInformation() { const node = document.getElementById('extension-info'); if (node === null) { return; } @@ -290,7 +42,7 @@ async function settingsPopulateModifierKeys() { const scanModifierKeySelect = document.querySelector('#scan-modifier-key'); scanModifierKeySelect.textContent = ''; - const environment = await apiGetEnvironmentInfo(); + const environment = await api.getEnvironmentInfo(); const modifierKeys = [ {value: 'none', name: 'None'}, ...environment.modifiers.keys @@ -303,26 +55,53 @@ async function settingsPopulateModifierKeys() { } } +async function setupEnvironmentInfo() { + const {browser, platform} = await api.getEnvironmentInfo(); + document.documentElement.dataset.browser = browser; + document.documentElement.dataset.operatingSystem = platform.os; +} + -async function onReady() { - apiForwardLogsToBackend(); +(async () => { + api.forwardLogsToBackend(); await yomichan.prepare(); + setupEnvironmentInfo(); showExtensionInformation(); + settingsPopulateModifierKeys(); - await settingsPopulateModifierKeys(); - formSetupEventListeners(); - appearanceInitialize(); - await audioSettingsInitialize(); - await profileOptionsSetup(); - await dictSettingsInitialize(); - ankiInitialize(); - ankiTemplatesInitialize(); - backupInitialize(); + const optionsFull = await api.optionsGetFull(); - storageInfoInitialize(); + const settingsController = new SettingsController(optionsFull.profileCurrent); + settingsController.prepare(); - yomichan.on('optionsUpdated', onOptionsUpdated); -} + const storageController = new StorageController(); + storageController.prepare(); + + const genericSettingController = new GenericSettingController(settingsController); + genericSettingController.prepare(); + + const clipboardPopupsController = new ClipboardPopupsController(settingsController); + clipboardPopupsController.prepare(); + + const popupPreviewController = new PopupPreviewController(settingsController); + popupPreviewController.prepare(); + + const audioController = new AudioController(settingsController); + audioController.prepare(); + + const profileController = new ProfileController(settingsController); + profileController.prepare(); + + const dictionaryController = new DictionaryController(settingsController, storageController); + dictionaryController.prepare(); + + const ankiController = new AnkiController(settingsController); + ankiController.prepare(); + + const ankiTemplatesController = new AnkiTemplatesController(settingsController, ankiController); + ankiTemplatesController.prepare(); -$(document).ready(() => onReady()); + const settingsBackup = new SettingsBackup(settingsController); + settingsBackup.prepare(); +})(); diff --git a/ext/bg/js/settings/popup-preview-frame-main.js b/ext/bg/js/settings/popup-preview-frame-main.js index 8228125f..866b9f57 100644 --- a/ext/bg/js/settings/popup-preview-frame-main.js +++ b/ext/bg/js/settings/popup-preview-frame-main.js @@ -16,11 +16,23 @@ */ /* global - * SettingsPopupPreview - * apiForwardLogsToBackend + * PopupFactory + * PopupPreviewFrame + * api */ -(() => { - apiForwardLogsToBackend(); - new SettingsPopupPreview(); +(async () => { + try { + api.forwardLogsToBackend(); + + const {frameId} = await api.frameInformationGet(); + + const popupFactory = new PopupFactory(frameId); + popupFactory.prepare(); + + const preview = new PopupPreviewFrame(frameId, popupFactory); + await preview.prepare(); + } catch (e) { + yomichan.logError(e); + } })(); diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js index 8901a0c4..98630503 100644 --- a/ext/bg/js/settings/popup-preview-frame.js +++ b/ext/bg/js/settings/popup-preview-frame.js @@ -18,68 +18,78 @@ /* global * Frontend * Popup - * PopupFactory * TextSourceRange - * apiFrameInformationGet - * apiOptionsGet + * api */ -class SettingsPopupPreview { - constructor() { - this.frontend = null; - this.apiOptionsGetOld = apiOptionsGet; - this.popup = null; - this.popupSetCustomOuterCssOld = null; - this.popupShown = false; - this.themeChangeTimeout = null; - this.textSource = null; - this.optionsContext = null; +class PopupPreviewFrame { + constructor(frameId, popupFactory) { + this._frameId = frameId; + this._popupFactory = popupFactory; + this._frontend = null; + this._frontendGetOptionsContextOld = null; + this._apiOptionsGetOld = null; + this._popupSetCustomOuterCssOld = null; + this._popupShown = false; + this._themeChangeTimeout = null; + this._textSource = null; + this._optionsContext = null; this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); this._windowMessageHandlers = new Map([ - ['prepare', ({optionsContext}) => this.prepare(optionsContext)], - ['setText', ({text}) => this.setText(text)], - ['setCustomCss', ({css}) => this.setCustomCss(css)], - ['setCustomOuterCss', ({css}) => this.setCustomOuterCss(css)], - ['updateOptionsContext', ({optionsContext}) => this.updateOptionsContext(optionsContext)] + ['setText', this._setText.bind(this)], + ['setCustomCss', this._setCustomCss.bind(this)], + ['setCustomOuterCss', this._setCustomOuterCss.bind(this)], + ['updateOptionsContext', this._updateOptionsContext.bind(this)] ]); - - window.addEventListener('message', this.onMessage.bind(this), false); } - async prepare(optionsContext) { - this.optionsContext = optionsContext; + async prepare() { + window.addEventListener('message', this._onMessage.bind(this), false); // Setup events - document.querySelector('#theme-dark-checkbox').addEventListener('change', this.onThemeDarkCheckboxChanged.bind(this), false); + document.querySelector('#theme-dark-checkbox').addEventListener('change', this._onThemeDarkCheckboxChanged.bind(this), false); // Overwrite API functions - window.apiOptionsGet = this.apiOptionsGet.bind(this); + this._apiOptionsGetOld = api.optionsGet.bind(api); + api.optionsGet = this._apiOptionsGet.bind(this); // Overwrite frontend - const {frameId} = await apiFrameInformationGet(); - - const popupFactory = new PopupFactory(frameId); - await popupFactory.prepare(); + this._frontend = new Frontend( + this._frameId, + this._popupFactory, + { + allowRootFramePopupProxy: false + } + ); + this._frontendGetOptionsContextOld = this._frontend.getOptionsContext.bind(this._frontend); + this._frontend.getOptionsContext = this._getOptionsContext.bind(this); + await this._frontend.prepare(); + this._frontend.setDisabledOverride(true); + this._frontend.canClearSelection = false; + + const popup = this._frontend.popup; + popup.setChildrenSupported(false); + + this._popupSetCustomOuterCssOld = popup.setCustomOuterCss.bind(popup); + popup.setCustomOuterCss = this._popupSetCustomOuterCss.bind(this); - this.popup = popupFactory.getOrCreatePopup(); - this.popup.setChildrenSupported(false); - - this.popupSetCustomOuterCssOld = this.popup.setCustomOuterCss; - this.popup.setCustomOuterCss = this.popupSetCustomOuterCss.bind(this); + // Update search + this._updateSearch(); + } - this.frontend = new Frontend(this.popup); - this.frontend.getOptionsContext = async () => this.optionsContext; - await this.frontend.prepare(); - this.frontend.setDisabledOverride(true); - this.frontend.canClearSelection = false; + // Private - // Update search - this.updateSearch(); + async _getOptionsContext() { + let optionsContext = this._optionsContext; + if (optionsContext === null) { + optionsContext = this._frontendGetOptionsContextOld(); + } + return optionsContext; } - async apiOptionsGet(...args) { - const options = await this.apiOptionsGetOld(...args); + async _apiOptionsGet(...args) { + const options = await this._apiOptionsGetOld(...args); options.general.enable = true; options.general.debugInfo = false; options.general.popupWidth = 400; @@ -94,9 +104,9 @@ class SettingsPopupPreview { return options; } - async popupSetCustomOuterCss(...args) { + async _popupSetCustomOuterCss(...args) { // This simulates the stylesheet priorities when injecting using the web extension API. - const result = await this.popupSetCustomOuterCssOld.call(this.popup, ...args); + const result = await this._popupSetCustomOuterCssOld(...args); const node = document.querySelector('#client-css'); if (node !== null && result !== null) { @@ -106,7 +116,7 @@ class SettingsPopupPreview { return result; } - onMessage(e) { + _onMessage(e) { if (e.origin !== this._targetOrigin) { return; } const {action, params} = e.data; @@ -116,49 +126,57 @@ class SettingsPopupPreview { handler(params); } - onThemeDarkCheckboxChanged(e) { + _onThemeDarkCheckboxChanged(e) { document.documentElement.classList.toggle('dark', e.target.checked); - if (this.themeChangeTimeout !== null) { - clearTimeout(this.themeChangeTimeout); + if (this._themeChangeTimeout !== null) { + clearTimeout(this._themeChangeTimeout); } - this.themeChangeTimeout = setTimeout(() => { - this.themeChangeTimeout = null; - this.popup.updateTheme(); + this._themeChangeTimeout = setTimeout(() => { + this._themeChangeTimeout = null; + const popup = this._frontend.popup; + if (popup === null) { return; } + popup.updateTheme(); }, 300); } - setText(text) { + _setText({text}) { const exampleText = document.querySelector('#example-text'); if (exampleText === null) { return; } exampleText.textContent = text; - this.updateSearch(); + if (this._frontend === null) { return; } + this._updateSearch(); } - setInfoVisible(visible) { + _setInfoVisible(visible) { const node = document.querySelector('.placeholder-info'); if (node === null) { return; } node.classList.toggle('placeholder-info-visible', visible); } - setCustomCss(css) { - if (this.frontend === null) { return; } - this.popup.setCustomCss(css); + _setCustomCss({css}) { + if (this._frontend === null) { return; } + const popup = this._frontend.popup; + if (popup === null) { return; } + popup.setCustomCss(css); } - setCustomOuterCss(css) { - if (this.frontend === null) { return; } - this.popup.setCustomOuterCss(css, false); + _setCustomOuterCss({css}) { + if (this._frontend === null) { return; } + const popup = this._frontend.popup; + if (popup === null) { return; } + popup.setCustomOuterCss(css, false); } - async updateOptionsContext(optionsContext) { - this.optionsContext = optionsContext; - await this.frontend.updateOptions(); - await this.updateSearch(); + async _updateOptionsContext({optionsContext}) { + this._optionsContext = optionsContext; + if (this._frontend === null) { return; } + await this._frontend.updateOptions(); + await this._updateSearch(); } - async updateSearch() { + async _updateSearch() { const exampleText = document.querySelector('#example-text'); if (exampleText === null) { return; } @@ -170,17 +188,18 @@ class SettingsPopupPreview { const source = new TextSourceRange(range, range.toString(), null, null); try { - await this.frontend.setTextSource(source); + await this._frontend.setTextSource(source); } finally { source.cleanup(); } - this.textSource = source; - await this.frontend.showContentCompleted(); + this._textSource = source; + await this._frontend.showContentCompleted(); - if (this.popup.isVisibleSync()) { - this.popupShown = true; + const popup = this._frontend.popup; + if (popup !== null && popup.isVisibleSync()) { + this._popupShown = true; } - this.setInfoVisible(!this.popupShown); + this._setInfoVisible(!this._popupShown); } } diff --git a/ext/bg/js/settings/popup-preview.js b/ext/bg/js/settings/popup-preview.js index fdc3dd94..d4145b76 100644 --- a/ext/bg/js/settings/popup-preview.js +++ b/ext/bg/js/settings/popup-preview.js @@ -16,69 +16,88 @@ */ /* global - * getOptionsContext * wanakana */ -function appearanceInitialize() { - let previewVisible = false; - $('#settings-popup-preview-button').on('click', () => { - if (previewVisible) { return; } - showAppearancePreview(); - previewVisible = true; - }); -} +class PopupPreviewController { + constructor(settingsController) { + this._settingsController = settingsController; + this._previewVisible = false; + this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); + this._frame = null; + this._previewTextInput = null; + } + + prepare() { + document.querySelector('#settings-popup-preview-button').addEventListener('click', this._onShowPopupPreviewButtonClick.bind(this), false); + } + + // Private + + _onShowPopupPreviewButtonClick() { + if (this._previewVisible) { return; } + this._showAppearancePreview(); + this._previewVisible = true; + } + + _showAppearancePreview() { + const container = document.querySelector('#settings-popup-preview-container'); + const buttonContainer = document.querySelector('#settings-popup-preview-button-container'); + const settings = document.querySelector('#settings-popup-preview-settings'); + const text = document.querySelector('#settings-popup-preview-text'); + const customCss = document.querySelector('#custom-popup-css'); + const customOuterCss = document.querySelector('#custom-popup-outer-css'); + const frame = document.createElement('iframe'); + + this._previewTextInput = text; + this._frame = frame; + + wanakana.bind(text); + + frame.addEventListener('load', this._onFrameLoad.bind(this), false); + text.addEventListener('input', this._onTextChange.bind(this), false); + customCss.addEventListener('input', this._onCustomCssChange.bind(this), false); + customOuterCss.addEventListener('input', this._onCustomOuterCssChange.bind(this), false); + this._settingsController.on('optionsContextChanged', this._onOptionsContextChange.bind(this)); + + frame.src = '/bg/settings-popup-preview.html'; + frame.id = 'settings-popup-preview-frame'; + + container.appendChild(frame); + if (buttonContainer.parentNode !== null) { + buttonContainer.parentNode.removeChild(buttonContainer); + } + settings.style.display = ''; + } + + _onFrameLoad() { + this._onOptionsContextChange(); + this._setText(this._previewTextInput.value); + } + + _onTextChange(e) { + this._setText(e.currentTarget.value); + } + + _onCustomCssChange(e) { + this._invoke('setCustomCss', {css: e.currentTarget.value}); + } + + _onCustomOuterCssChange(e) { + this._invoke('setCustomOuterCss', {css: e.currentTarget.value}); + } + + _onOptionsContextChange() { + const optionsContext = this._settingsController.getOptionsContext(); + this._invoke('updateOptionsContext', {optionsContext}); + } + + _setText(text) { + this._invoke('setText', {text}); + } -function showAppearancePreview() { - const container = $('#settings-popup-preview-container'); - const buttonContainer = $('#settings-popup-preview-button-container'); - const settings = $('#settings-popup-preview-settings'); - const text = $('#settings-popup-preview-text'); - const customCss = $('#custom-popup-css'); - const customOuterCss = $('#custom-popup-outer-css'); - - const frame = document.createElement('iframe'); - frame.src = '/bg/settings-popup-preview.html'; - frame.id = 'settings-popup-preview-frame'; - - 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}, targetOrigin); - }); - customCss.on('input', () => { - const action = 'setCustomCss'; - const params = {css: customCss.val()}; - frame.contentWindow.postMessage({action, params}, targetOrigin); - }); - customOuterCss.on('input', () => { - const action = 'setCustomOuterCss'; - const params = {css: customOuterCss.val()}; - frame.contentWindow.postMessage({action, params}, targetOrigin); - }); - - const updateOptionsContext = () => { - const action = 'updateOptionsContext'; - const params = { - optionsContext: getOptionsContext() - }; - frame.contentWindow.postMessage({action, params}, targetOrigin); - }; - yomichan.on('modifyingProfileChange', updateOptionsContext); - - frame.addEventListener('load', () => { - const action = 'prepare'; - const params = { - optionsContext: getOptionsContext() - }; - frame.contentWindow.postMessage({action, params}, targetOrigin); - }); - - container.append(frame); - buttonContainer.remove(); - settings.css('display', ''); + _invoke(action, params) { + if (this._frame === null || this._frame.contentWindow === null) { return; } + this._frame.contentWindow.postMessage({action, params}, this._targetOrigin); + } } diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js index bdf5a13d..2449ab44 100644 --- a/ext/bg/js/settings/profiles.js +++ b/ext/bg/js/settings/profiles.js @@ -17,288 +17,271 @@ /* global * ConditionsUI - * apiOptionsGetFull * conditionsClearCaches - * formWrite - * getOptionsFullMutable - * getOptionsMutable * profileConditionsDescriptor * profileConditionsDescriptorPromise - * settingsSaveOptions * utilBackgroundIsolate */ -let currentProfileIndex = 0; -let profileConditionsContainer = null; - -function getOptionsContext() { - return { - index: currentProfileIndex - }; -} - - -async function profileOptionsSetup() { - const optionsFull = await getOptionsFullMutable(); - currentProfileIndex = optionsFull.profileCurrent; - - profileOptionsSetupEventListeners(); - await profileOptionsUpdateTarget(optionsFull); -} - -function profileOptionsSetupEventListeners() { - $('#profile-target').change(onTargetProfileChanged); - $('#profile-name').change(onProfileNameChanged); - $('#profile-add').click(onProfileAdd); - $('#profile-remove').click(onProfileRemove); - $('#profile-remove-confirm').click(onProfileRemoveConfirm); - $('#profile-copy').click(onProfileCopy); - $('#profile-copy-confirm').click(onProfileCopyConfirm); - $('#profile-move-up').click(() => onProfileMove(-1)); - $('#profile-move-down').click(() => onProfileMove(1)); - $('.profile-form').find('input, select, textarea').not('.profile-form-manual').change(onProfileOptionsChanged); -} +class ProfileController { + constructor(settingsController) { + this._settingsController = settingsController; + this._conditionsContainer = null; + } -function tryGetIntegerValue(selector, min, max) { - const value = parseInt($(selector).val(), 10); - return ( - typeof value === 'number' && - Number.isFinite(value) && - Math.floor(value) === value && - value >= min && - value < max - ) ? value : null; -} + async prepare() { + $('#profile-target').change(this._onTargetProfileChanged.bind(this)); + $('#profile-name').change(this._onNameChanged.bind(this)); + $('#profile-add').click(this._onAdd.bind(this)); + $('#profile-remove').click(this._onRemove.bind(this)); + $('#profile-remove-confirm').click(this._onRemoveConfirm.bind(this)); + $('#profile-copy').click(this._onCopy.bind(this)); + $('#profile-copy-confirm').click(this._onCopyConfirm.bind(this)); + $('#profile-move-up').click(() => this._onMove(-1)); + $('#profile-move-down').click(() => this._onMove(1)); + $('.profile-form').find('input, select, textarea').not('.profile-form-manual').change(this._onInputChanged.bind(this)); + + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + + this._onOptionsChanged(); + } -async function profileFormRead(optionsFull) { - const profile = optionsFull.profiles[currentProfileIndex]; + // Private - // Current profile - const index = tryGetIntegerValue('#profile-active', 0, optionsFull.profiles.length); - if (index !== null) { - optionsFull.profileCurrent = index; + async _onOptionsChanged() { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + await this._formWrite(optionsFull); } - // Profile name - profile.name = $('#profile-name').val(); -} - -async function profileFormWrite(optionsFull) { - const profile = optionsFull.profiles[currentProfileIndex]; + _tryGetIntegerValue(selector, min, max) { + const value = parseInt($(selector).val(), 10); + return ( + typeof value === 'number' && + Number.isFinite(value) && + Math.floor(value) === value && + value >= min && + value < max + ) ? value : null; + } - profileOptionsPopulateSelect($('#profile-active'), optionsFull.profiles, optionsFull.profileCurrent, null); - profileOptionsPopulateSelect($('#profile-target'), optionsFull.profiles, currentProfileIndex, null); - $('#profile-remove').prop('disabled', optionsFull.profiles.length <= 1); - $('#profile-copy').prop('disabled', optionsFull.profiles.length <= 1); - $('#profile-move-up').prop('disabled', currentProfileIndex <= 0); - $('#profile-move-down').prop('disabled', currentProfileIndex >= optionsFull.profiles.length - 1); + async _formRead(optionsFull) { + const currentProfileIndex = this._settingsController.profileIndex; + const profile = optionsFull.profiles[currentProfileIndex]; - $('#profile-name').val(profile.name); + // Current profile + const index = this._tryGetIntegerValue('#profile-active', 0, optionsFull.profiles.length); + if (index !== null) { + optionsFull.profileCurrent = index; + } - if (profileConditionsContainer !== null) { - profileConditionsContainer.cleanup(); + // Profile name + profile.name = $('#profile-name').val(); } - await profileConditionsDescriptorPromise; - profileConditionsContainer = new ConditionsUI.Container( - profileConditionsDescriptor, - 'popupLevel', - profile.conditionGroups, - $('#profile-condition-groups'), - $('#profile-add-condition-group') - ); - profileConditionsContainer.save = () => { - settingsSaveOptions(); - conditionsClearCaches(profileConditionsDescriptor); - }; - profileConditionsContainer.isolate = utilBackgroundIsolate; -} + async _formWrite(optionsFull) { + const currentProfileIndex = this._settingsController.profileIndex; + const profile = optionsFull.profiles[currentProfileIndex]; -function profileOptionsPopulateSelect(select, profiles, currentValue, ignoreIndices) { - select.empty(); + this._populateSelect($('#profile-active'), optionsFull.profiles, optionsFull.profileCurrent, null); + this._populateSelect($('#profile-target'), optionsFull.profiles, currentProfileIndex, null); + $('#profile-remove').prop('disabled', optionsFull.profiles.length <= 1); + $('#profile-copy').prop('disabled', optionsFull.profiles.length <= 1); + $('#profile-move-up').prop('disabled', currentProfileIndex <= 0); + $('#profile-move-down').prop('disabled', currentProfileIndex >= optionsFull.profiles.length - 1); + $('#profile-name').val(profile.name); - for (let i = 0; i < profiles.length; ++i) { - if (ignoreIndices !== null && ignoreIndices.indexOf(i) >= 0) { - continue; + if (this._conditionsContainer !== null) { + this._conditionsContainer.cleanup(); } - const profile = profiles[i]; - select.append($(`<option value="${i}">${profile.name}</option>`)); - } - select.val(`${currentValue}`); -} + await profileConditionsDescriptorPromise; + this._conditionsContainer = new ConditionsUI.Container( + profileConditionsDescriptor, + 'popupLevel', + profile.conditionGroups, + $('#profile-condition-groups'), + $('#profile-add-condition-group') + ); + this._conditionsContainer.save = () => { + this._settingsController.save(); + conditionsClearCaches(profileConditionsDescriptor); + }; + this._conditionsContainer.isolate = utilBackgroundIsolate; + } -async function profileOptionsUpdateTarget(optionsFull) { - await profileFormWrite(optionsFull); + _populateSelect(select, profiles, currentValue, ignoreIndices) { + select.empty(); - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - await formWrite(options); -} -function profileOptionsCreateCopyName(name, profiles, maxUniqueAttempts) { - let space, index, prefix, suffix; - const match = /^([\w\W]*\(Copy)((\s+)(\d+))?(\)\s*)$/.exec(name); - if (match === null) { - prefix = `${name} (Copy`; - space = ''; - index = ''; - suffix = ')'; - } else { - prefix = match[1]; - suffix = match[5]; - if (typeof match[2] === 'string') { - space = match[3]; - index = parseInt(match[4], 10) + 1; - } else { - space = ' '; - index = 2; + for (let i = 0; i < profiles.length; ++i) { + if (ignoreIndices !== null && ignoreIndices.indexOf(i) >= 0) { + continue; + } + const profile = profiles[i]; + select.append($(`<option value="${i}">${profile.name}</option>`)); } + + select.val(`${currentValue}`); } - let i = 0; - while (true) { - const newName = `${prefix}${space}${index}${suffix}`; - if (i++ >= maxUniqueAttempts || profiles.findIndex((profile) => profile.name === newName) < 0) { - return newName; - } - if (typeof index !== 'number') { - index = 2; - space = ' '; + _createCopyName(name, profiles, maxUniqueAttempts) { + let space, index, prefix, suffix; + const match = /^([\w\W]*\(Copy)((\s+)(\d+))?(\)\s*)$/.exec(name); + if (match === null) { + prefix = `${name} (Copy`; + space = ''; + index = ''; + suffix = ')'; } else { - ++index; + prefix = match[1]; + suffix = match[5]; + if (typeof match[2] === 'string') { + space = match[3]; + index = parseInt(match[4], 10) + 1; + } else { + space = ' '; + index = 2; + } } - } -} -async function onProfileOptionsChanged(e) { - if (!e.originalEvent && !e.isTrigger) { - return; + let i = 0; + while (true) { + const newName = `${prefix}${space}${index}${suffix}`; + if (i++ >= maxUniqueAttempts || profiles.findIndex((profile) => profile.name === newName) < 0) { + return newName; + } + if (typeof index !== 'number') { + index = 2; + space = ' '; + } else { + ++index; + } + } } - const optionsFull = await getOptionsFullMutable(); - await profileFormRead(optionsFull); - await settingsSaveOptions(); -} + async _onInputChanged(e) { + if (!e.originalEvent && !e.isTrigger) { + return; + } -async function onTargetProfileChanged() { - const optionsFull = await getOptionsFullMutable(); - const index = tryGetIntegerValue('#profile-target', 0, optionsFull.profiles.length); - if (index === null || currentProfileIndex === index) { - return; + const optionsFull = await this._settingsController.getOptionsFullMutable(); + await this._formRead(optionsFull); + await this._settingsController.save(); } - currentProfileIndex = index; + async _onTargetProfileChanged() { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + const currentProfileIndex = this._settingsController.profileIndex; + const index = this._tryGetIntegerValue('#profile-target', 0, optionsFull.profiles.length); + if (index === null || currentProfileIndex === index) { + return; + } - await profileOptionsUpdateTarget(optionsFull); + this._settingsController.profileIndex = index; + } - yomichan.trigger('modifyingProfileChange'); -} + async _onAdd() { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + const currentProfileIndex = this._settingsController.profileIndex; + const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]); + profile.name = this._createCopyName(profile.name, optionsFull.profiles, 100); + optionsFull.profiles.push(profile); -async function onProfileAdd() { - const optionsFull = await getOptionsFullMutable(); - const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]); - profile.name = profileOptionsCreateCopyName(profile.name, optionsFull.profiles, 100); - optionsFull.profiles.push(profile); + this._settingsController.profileIndex = optionsFull.profiles.length - 1; - currentProfileIndex = optionsFull.profiles.length - 1; + await this._settingsController.save(); + } - await profileOptionsUpdateTarget(optionsFull); - await settingsSaveOptions(); + async _onRemove(e) { + if (e.shiftKey) { + return await this._onRemoveConfirm(); + } - yomichan.trigger('modifyingProfileChange'); -} + const optionsFull = await this._settingsController.getOptionsFull(); + if (optionsFull.profiles.length <= 1) { + return; + } -async function onProfileRemove(e) { - if (e.shiftKey) { - return await onProfileRemoveConfirm(); - } + const currentProfileIndex = this._settingsController.profileIndex; + const profile = optionsFull.profiles[currentProfileIndex]; - const optionsFull = await apiOptionsGetFull(); - if (optionsFull.profiles.length <= 1) { - return; + $('#profile-remove-modal-profile-name').text(profile.name); + $('#profile-remove-modal').modal('show'); } - const profile = optionsFull.profiles[currentProfileIndex]; + async _onRemoveConfirm() { + $('#profile-remove-modal').modal('hide'); - $('#profile-remove-modal-profile-name').text(profile.name); - $('#profile-remove-modal').modal('show'); -} + const optionsFull = await this._settingsController.getOptionsFullMutable(); + if (optionsFull.profiles.length <= 1) { + return; + } -async function onProfileRemoveConfirm() { - $('#profile-remove-modal').modal('hide'); + const currentProfileIndex = this._settingsController.profileIndex; + optionsFull.profiles.splice(currentProfileIndex, 1); - const optionsFull = await getOptionsFullMutable(); - if (optionsFull.profiles.length <= 1) { - return; - } + if (currentProfileIndex >= optionsFull.profiles.length) { + this._settingsController.profileIndex = optionsFull.profiles.length - 1; + } - optionsFull.profiles.splice(currentProfileIndex, 1); + if (optionsFull.profileCurrent >= optionsFull.profiles.length) { + optionsFull.profileCurrent = optionsFull.profiles.length - 1; + } - if (currentProfileIndex >= optionsFull.profiles.length) { - --currentProfileIndex; + await this._settingsController.save(); } - if (optionsFull.profileCurrent >= optionsFull.profiles.length) { - optionsFull.profileCurrent = optionsFull.profiles.length - 1; + _onNameChanged() { + const currentProfileIndex = this._settingsController.profileIndex; + $('#profile-active, #profile-target').find(`[value="${currentProfileIndex}"]`).text(this.value); } - await profileOptionsUpdateTarget(optionsFull); - await settingsSaveOptions(); - - yomichan.trigger('modifyingProfileChange'); -} + async _onMove(offset) { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + const currentProfileIndex = this._settingsController.profileIndex; + const index = currentProfileIndex + offset; + if (index < 0 || index >= optionsFull.profiles.length) { + return; + } -function onProfileNameChanged() { - $('#profile-active, #profile-target').find(`[value="${currentProfileIndex}"]`).text(this.value); -} + const profile = optionsFull.profiles[currentProfileIndex]; + optionsFull.profiles.splice(currentProfileIndex, 1); + optionsFull.profiles.splice(index, 0, profile); -async function onProfileMove(offset) { - const optionsFull = await getOptionsFullMutable(); - const index = currentProfileIndex + offset; - if (index < 0 || index >= optionsFull.profiles.length) { - return; - } + if (optionsFull.profileCurrent === currentProfileIndex) { + optionsFull.profileCurrent = index; + } - const profile = optionsFull.profiles[currentProfileIndex]; - optionsFull.profiles.splice(currentProfileIndex, 1); - optionsFull.profiles.splice(index, 0, profile); + this._settingsController.profileIndex = index; - if (optionsFull.profileCurrent === currentProfileIndex) { - optionsFull.profileCurrent = index; + await this._settingsController.save(); } - currentProfileIndex = index; - - await profileOptionsUpdateTarget(optionsFull); - await settingsSaveOptions(); - - yomichan.trigger('modifyingProfileChange'); -} + async _onCopy() { + const optionsFull = await this._settingsController.getOptionsFullMutable(); + if (optionsFull.profiles.length <= 1) { + return; + } -async function onProfileCopy() { - const optionsFull = await apiOptionsGetFull(); - if (optionsFull.profiles.length <= 1) { - return; + const currentProfileIndex = this._settingsController.profileIndex; + this._populateSelect($('#profile-copy-source'), optionsFull.profiles, currentProfileIndex === 0 ? 1 : 0, [currentProfileIndex]); + $('#profile-copy-modal').modal('show'); } - profileOptionsPopulateSelect($('#profile-copy-source'), optionsFull.profiles, currentProfileIndex === 0 ? 1 : 0, [currentProfileIndex]); - $('#profile-copy-modal').modal('show'); -} - -async function onProfileCopyConfirm() { - $('#profile-copy-modal').modal('hide'); + async _onCopyConfirm() { + $('#profile-copy-modal').modal('hide'); - const optionsFull = await getOptionsFullMutable(); - const index = tryGetIntegerValue('#profile-copy-source', 0, optionsFull.profiles.length); - if (index === null || index === currentProfileIndex) { - return; - } + const optionsFull = await this._settingsController.getOptionsFullMutable(); + const index = this._tryGetIntegerValue('#profile-copy-source', 0, optionsFull.profiles.length); + const currentProfileIndex = this._settingsController.profileIndex; + if (index === null || index === currentProfileIndex) { + return; + } - const profileOptions = utilBackgroundIsolate(optionsFull.profiles[index].options); - optionsFull.profiles[currentProfileIndex].options = profileOptions; + const profileOptions = utilBackgroundIsolate(optionsFull.profiles[index].options); + optionsFull.profiles[currentProfileIndex].options = profileOptions; - await profileOptionsUpdateTarget(optionsFull); - await settingsSaveOptions(); + await this._settingsController.save(); + } } diff --git a/ext/bg/js/settings/settings-controller.js b/ext/bg/js/settings/settings-controller.js new file mode 100644 index 00000000..87dea408 --- /dev/null +++ b/ext/bg/js/settings/settings-controller.js @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * api + * utilBackend + * utilBackgroundIsolate + */ + +class SettingsController extends EventDispatcher { + constructor(profileIndex=0) { + super(); + this._profileIndex = profileIndex; + this._source = yomichan.generateId(16); + } + + get source() { + return this._source; + } + + get profileIndex() { + return this._profileIndex; + } + + set profileIndex(value) { + if (this._profileIndex === value) { return; } + this._setProfileIndex(value); + } + + prepare() { + yomichan.on('optionsUpdated', this._onOptionsUpdated.bind(this)); + } + + async save() { + await api.optionsSave(this._source); + } + + async getOptions() { + const optionsContext = this.getOptionsContext(); + return await api.optionsGet(optionsContext); + } + + async getOptionsFull() { + return await api.optionsGetFull(); + } + + async getOptionsMutable() { + const optionsContext = this.getOptionsContext(); + return utilBackend().getOptions(utilBackgroundIsolate(optionsContext)); + } + + async getOptionsFullMutable() { + return utilBackend().getFullOptions(); + } + + async setAllSettings(value) { + const profileIndex = value.profileCurrent; + await api.setAllSettings(value, this._source); + this._setProfileIndex(profileIndex); + } + + async getSettings(targets) { + return await this._getSettings(targets, {}); + } + + async getGlobalSettings(targets) { + return await this._getSettings(targets, {scope: 'global'}); + } + + async getProfileSettings(targets) { + return await this._getSettings(targets, {scope: 'profile'}); + } + + async modifySettings(targets) { + return await this._modifySettings(targets, {}); + } + + async modifyGlobalSettings(targets) { + return await this._modifySettings(targets, {scope: 'global'}); + } + + async modifyProfileSettings(targets) { + return await this._modifySettings(targets, {scope: 'profile'}); + } + + async setGlobalSetting(path, value) { + return await this.modifyGlobalSettings([{action: 'set', path, value}]); + } + + async setProfileSetting(path, value) { + return await this.modifyProfileSettings([{action: 'set', path, value}]); + } + + getOptionsContext() { + return {index: this._profileIndex}; + } + + // Private + + _setProfileIndex(value) { + this._profileIndex = value; + this.trigger('optionsContextChanged'); + this._onOptionsUpdatedInternal(); + } + + _onOptionsUpdated({source}) { + if (source === this._source) { return; } + this._onOptionsUpdatedInternal(); + } + + async _onOptionsUpdatedInternal() { + const optionsContext = this.getOptionsContext(); + const options = await this.getOptions(); + this.trigger('optionsChanged', {options, optionsContext}); + } + + _setupTargets(targets, extraFields) { + return targets.map((target) => { + target = Object.assign({}, extraFields, target); + if (target.scope === 'profile') { + target.optionsContext = this.getOptionsContext(); + } + return target; + }); + } + + async _getSettings(targets, extraFields) { + targets = this._setupTargets(targets, extraFields); + return await api.getSettings(targets); + } + + async _modifySettings(targets, extraFields) { + targets = this._setupTargets(targets, extraFields); + return await api.modifySettings(targets, this._source); + } +} diff --git a/ext/bg/js/settings/storage.js b/ext/bg/js/settings/storage.js index d754a109..24c6d7ef 100644 --- a/ext/bg/js/settings/storage.js +++ b/ext/bg/js/settings/storage.js @@ -15,126 +15,117 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/* global - * apiGetEnvironmentInfo - */ - -function storageBytesToLabeledString(size) { - const base = 1000; - const labels = [' bytes', 'KB', 'MB', 'GB']; - let labelIndex = 0; - while (size >= base) { - size /= base; - ++labelIndex; +class StorageController { + constructor() { + this._mostRecentStorageEstimate = null; + this._storageEstimateFailed = false; + this._isUpdating = false; } - const label = labelIndex === 0 ? `${size}` : size.toFixed(1); - return `${label}${labels[labelIndex]}`; -} -async function storageEstimate() { - try { - return (storageEstimate.mostRecent = await navigator.storage.estimate()); - } catch (e) { - // NOP + prepare() { + this._preparePersistentStorage(); + this.updateStats(); + document.querySelector('#storage-refresh').addEventListener('click', this.updateStats.bind(this), false); } - return null; -} -storageEstimate.mostRecent = null; - -async function isStoragePeristent() { - try { - return await navigator.storage.persisted(); - } catch (e) { - // NOP - } - return false; -} - -async function storageInfoInitialize() { - storagePersistInitialize(); - const {browser, platform} = await apiGetEnvironmentInfo(); - document.documentElement.dataset.browser = browser; - document.documentElement.dataset.operatingSystem = platform.os; - - await storageShowInfo(); - document.querySelector('#storage-refresh').addEventListener('click', storageShowInfo, false); -} - -async function storageUpdateStats() { - storageUpdateStats.isUpdating = true; - - const estimate = await storageEstimate(); - const valid = (estimate !== null); - - if (valid) { - // Firefox reports usage as 0 when persistent storage is enabled. - const finite = (estimate.usage > 0 || !(await isStoragePeristent())); - if (finite) { - document.querySelector('#storage-usage').textContent = storageBytesToLabeledString(estimate.usage); - document.querySelector('#storage-quota').textContent = storageBytesToLabeledString(estimate.quota); + async updateStats() { + try { + this._isUpdating = true; + + const estimate = await this._storageEstimate(); + const valid = (estimate !== null); + + if (valid) { + // Firefox reports usage as 0 when persistent storage is enabled. + const finite = (estimate.usage > 0 || !(await this._isStoragePeristent())); + if (finite) { + document.querySelector('#storage-usage').textContent = this._bytesToLabeledString(estimate.usage); + document.querySelector('#storage-quota').textContent = this._bytesToLabeledString(estimate.quota); + } + document.querySelector('#storage-use-finite').classList.toggle('storage-hidden', !finite); + document.querySelector('#storage-use-infinite').classList.toggle('storage-hidden', finite); + } + + document.querySelector('#storage-use').classList.toggle('storage-hidden', !valid); + document.querySelector('#storage-error').classList.toggle('storage-hidden', valid); + + return valid; + } finally { + this._isUpdating = false; } - document.querySelector('#storage-use-finite').classList.toggle('storage-hidden', !finite); - document.querySelector('#storage-use-infinite').classList.toggle('storage-hidden', finite); } - storageUpdateStats.isUpdating = false; - return valid; -} -storageUpdateStats.isUpdating = false; - -async function storageShowInfo() { - storageSpinnerShow(true); - - const valid = await storageUpdateStats(); - document.querySelector('#storage-use').classList.toggle('storage-hidden', !valid); - document.querySelector('#storage-error').classList.toggle('storage-hidden', valid); + // Private - storageSpinnerShow(false); -} + async _preparePersistentStorage() { + if (!(navigator.storage && navigator.storage.persist)) { + // Not supported + return; + } -function storageSpinnerShow(show) { - const spinner = $('#storage-spinner'); - if (show) { - spinner.show(); - } else { - spinner.hide(); + const info = document.querySelector('#storage-persist-info'); + const button = document.querySelector('#storage-persist-button'); + const checkbox = document.querySelector('#storage-persist-button-checkbox'); + + info.classList.remove('storage-hidden'); + button.classList.remove('storage-hidden'); + + let persisted = await this._isStoragePeristent(); + checkbox.checked = persisted; + + button.addEventListener('click', async () => { + if (persisted) { + return; + } + let result = false; + try { + result = await navigator.storage.persist(); + } catch (e) { + // NOP + } + + if (result) { + persisted = true; + checkbox.checked = true; + this.updateStats(); + } else { + document.querySelector('.storage-persist-fail-warning').classList.remove('storage-hidden'); + } + }, false); } -} -async function storagePersistInitialize() { - if (!(navigator.storage && navigator.storage.persist)) { - // Not supported - return; + async _storageEstimate() { + if (this._storageEstimateFailed && this._mostRecentStorageEstimate === null) { + return null; + } + try { + const value = await navigator.storage.estimate(); + this._mostRecentStorageEstimate = value; + return value; + } catch (e) { + this._storageEstimateFailed = true; + } + return null; } - const info = document.querySelector('#storage-persist-info'); - const button = document.querySelector('#storage-persist-button'); - const checkbox = document.querySelector('#storage-persist-button-checkbox'); - - info.classList.remove('storage-hidden'); - button.classList.remove('storage-hidden'); - - let persisted = await isStoragePeristent(); - checkbox.checked = persisted; - - button.addEventListener('click', async () => { - if (persisted) { - return; + _bytesToLabeledString(size) { + const base = 1000; + const labels = [' bytes', 'KB', 'MB', 'GB']; + let labelIndex = 0; + while (size >= base) { + size /= base; + ++labelIndex; } - let result = false; + const label = labelIndex === 0 ? `${size}` : size.toFixed(1); + return `${label}${labels[labelIndex]}`; + } + + async _isStoragePeristent() { try { - result = await navigator.storage.persist(); + return await navigator.storage.persisted(); } catch (e) { // NOP } - - if (result) { - persisted = true; - checkbox.checked = true; - storageShowInfo(); - } else { - $('.storage-persist-fail-warning').removeClass('storage-hidden'); - } - }, false); + return false; + } } diff --git a/ext/bg/js/template-renderer.js b/ext/bg/js/template-renderer.js new file mode 100644 index 00000000..f4b50c3d --- /dev/null +++ b/ext/bg/js/template-renderer.js @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2016-2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * Handlebars + * jp + */ + +class TemplateRenderer { + constructor() { + this._cache = new Map(); + this._cacheMaxSize = 5; + this._helpersRegistered = false; + } + + async render(template, data) { + if (!this._helpersRegistered) { + this._registerHelpers(); + this._helpersRegistered = true; + } + + const cache = this._cache; + let instance = cache.get(template); + if (typeof instance === 'undefined') { + this._updateCacheSize(this._cacheMaxSize - 1); + instance = Handlebars.compile(template); + cache.set(template, instance); + } + + return instance(data).trim(); + } + + // Private + + _updateCacheSize(maxSize) { + const cache = this._cache; + let removeCount = cache.size - maxSize; + if (removeCount <= 0) { return; } + + for (const key of cache.keys()) { + cache.delete(key); + if (--removeCount <= 0) { break; } + } + } + + _registerHelpers() { + Handlebars.partials = Handlebars.templates; + + const helpers = [ + ['dumpObject', this._dumpObject.bind(this)], + ['furigana', this._furigana.bind(this)], + ['furiganaPlain', this._furiganaPlain.bind(this)], + ['kanjiLinks', this._kanjiLinks.bind(this)], + ['multiLine', this._multiLine.bind(this)], + ['sanitizeCssClass', this._sanitizeCssClass.bind(this)], + ['regexReplace', this._regexReplace.bind(this)], + ['regexMatch', this._regexMatch.bind(this)], + ['mergeTags', this._mergeTags.bind(this)] + ]; + + for (const [name, helper] of helpers) { + Handlebars.registerHelper(name, helper); + } + } + + _escape(text) { + return Handlebars.Utils.escapeExpression(text); + } + + _dumpObject(options) { + const dump = JSON.stringify(options.fn(this), null, 4); + return this._escape(dump); + } + + _furigana(options) { + const definition = options.fn(this); + const segs = jp.distributeFurigana(definition.expression, definition.reading); + + let result = ''; + for (const seg of segs) { + if (seg.furigana) { + result += `<ruby>${seg.text}<rt>${seg.furigana}</rt></ruby>`; + } else { + result += seg.text; + } + } + + return result; + } + + _furiganaPlain(options) { + const definition = options.fn(this); + const segs = jp.distributeFurigana(definition.expression, definition.reading); + + let result = ''; + for (const seg of segs) { + if (seg.furigana) { + result += ` ${seg.text}[${seg.furigana}]`; + } else { + result += seg.text; + } + } + + return result.trimLeft(); + } + + _kanjiLinks(options) { + let result = ''; + for (const c of options.fn(this)) { + if (jp.isCodePointKanji(c.codePointAt(0))) { + result += `<a href="#" class="kanji-link">${c}</a>`; + } else { + result += c; + } + } + + return result; + } + + _multiLine(options) { + return options.fn(this).split('\n').join('<br>'); + } + + _sanitizeCssClass(options) { + return options.fn(this).replace(/[^_a-z0-9\u00a0-\uffff]/ig, '_'); + } + + _regexReplace(...args) { + // Usage: + // {{#regexReplace regex string [flags]}}content{{/regexReplace}} + // regex: regular expression string + // string: string to replace + // flags: optional flags for regular expression + // e.g. "i" for case-insensitive, "g" for replace all + let value = args[args.length - 1].fn(this); + if (args.length >= 3) { + try { + const flags = args.length > 3 ? args[2] : 'g'; + const regex = new RegExp(args[0], flags); + value = value.replace(regex, args[1]); + } catch (e) { + return `${e}`; + } + } + return value; + } + + _regexMatch(...args) { + // Usage: + // {{#regexMatch regex [flags]}}content{{/regexMatch}} + // regex: regular expression string + // flags: optional flags for regular expression + // e.g. "i" for case-insensitive, "g" for match all + let value = args[args.length - 1].fn(this); + if (args.length >= 2) { + try { + const flags = args.length > 2 ? args[1] : ''; + const regex = new RegExp(args[0], flags); + const parts = []; + value.replace(regex, (g0) => parts.push(g0)); + value = parts.join(''); + } catch (e) { + return `${e}`; + } + } + return value; + } + + _mergeTags(object, isGroupMode, isMergeMode) { + const tagSources = []; + if (isGroupMode || isMergeMode) { + for (const definition of object.definitions) { + tagSources.push(definition.definitionTags); + } + } else { + tagSources.push(object.definitionTags); + } + + const tags = new Set(); + for (const tagSource of tagSources) { + for (const tag of tagSource) { + tags.add(tag.name); + } + } + + return [...tags].join(', '); + } +} diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js index 8f86e47a..edc19c6e 100644 --- a/ext/bg/js/util.js +++ b/ext/bg/js/util.js @@ -65,12 +65,3 @@ function utilBackend() { } return backend; } - -function utilReadFileArrayBuffer(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(reader.error); - reader.readAsArrayBuffer(file); - }); -} diff --git a/ext/bg/legal.html b/ext/bg/legal.html index 1ee9a28c..8b4fe513 100644 --- a/ext/bg/legal.html +++ b/ext/bg/legal.html @@ -1,22 +1,27 @@ <!DOCTYPE html> <html lang="en"> - <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width,initial-scale=1" /> - <title>Yomichan Legal</title> - <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> - <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> - <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> - <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> - <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> - <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> - <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128"> - <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css"> - <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css"> - </head> - <body> - <div class="container"> - <h3>Yomichan License</h3> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <title>Yomichan Legal</title> + <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> + <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> + <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32"> + <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> + <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> + <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> + <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128"> + <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css"> + <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css"> + <style> +pre { + white-space: pre-line; +} + </style> +</head> +<body><div class="container"> + +<h3>Yomichan License</h3> <pre> Copyright (C) 2016-2020 Yomichan Authors @@ -33,21 +38,138 @@ 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/>. </pre> - <h3>EDRDG License</h3> + +<h3>EDRDG License</h3> <pre> -This package uses the <a href="https://www.edrdg.org/jmdict/edict.html">EDICT</a> and <a href="https://www.edrdg.org/wiki/index.php/KANJIDIC_Project">KANJIDIC</a> dictionary files. These files are -the property of the <a href="https://www.edrdg.org/">Electronic Dictionary Research and Development Group</a>, -and are used in conformance with the Group's <a href="https://www.edrdg.org/edrdg/licence.html">licence</a>. +This package uses the <a href="https://www.edrdg.org/jmdict/edict.html" target="_blank" rel="noopener">EDICT</a> and <a href="https://www.edrdg.org/wiki/index.php/KANJIDIC_Project" target="_blank" rel="noopener">KANJIDIC</a> dictionary files. These files are +the property of the <a href="https://www.edrdg.org/" target="_blank" rel="noopener">Electronic Dictionary Research and Development Group</a>, +and are used in conformance with the Group's <a href="https://www.edrdg.org/edrdg/licence.html" target="_blank" rel="noopener">licence</a>. </pre> - <h3>Third-Party Software Licenses</h3> - <ul> - <li><a href="https://github.com/twbs/bootstrap/blob/v3.3.7/LICENSE" target="_blank" rel="noopener">Bootstrap v3.3.7</a></li> - <li><a href="https://github.com/wycats/handlebars.js/blob/v4.0.6/LICENSE" target="_blank" rel="noopener">Handlebars v4.0.6</a></li> - <li><a href="https://github.com/jquery/jquery/blob/3.2.1/LICENSE.txt" target="_blank" rel="noopener">jQuery v3.2.1</a></li> - <li><a href="https://github.com/Stuk/jszip/blob/v3.1.3/LICENSE.markdown" target="_blank" rel="noopener">JSZip v3.1.3</a></li> - <li><a href="https://github.com/WaniKani/WanaKana/blob/4.0.2/LICENSE" target="_blank" rel="noopener">WanaKana v4.0.2</a></li> - </ul> - </div> - </div> -</body> + +<h3>Third-Party Software Licenses</h3> + +<h4><a href="https://github.com/twbs/bootstrap/blob/v3.3.7/LICENSE" target="_blank" rel="noopener">Bootstrap v3.3.7</a></h4> +<pre> +The MIT License (MIT) + +Copyright (c) 2011-2016 Twitter, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + +<h4><a href="https://github.com/wycats/handlebars.js/blob/v4.0.6/LICENSE" target="_blank" rel="noopener">Handlebars v4.0.6</a></h4> +<pre> +Copyright (C) 2011-2016 by Yehuda Katz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + +<h4><a href="https://github.com/jquery/jquery/blob/3.2.1/LICENSE.txt" target="_blank" rel="noopener">jQuery v3.2.1</a></h4> +<pre> +Copyright JS Foundation and other contributors, https://js.foundation/ + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at https://github.com/jquery/jquery + +The following license applies to all parts of this software except as +documented below: + +==== + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==== + +All files located in the node_modules and external directories are +externally maintained libraries used by this software which have their +own licenses; we recommend you read them, as their terms may differ from +the terms above. +</pre> + +<h4><a href="https://github.com/Stuk/jszip/blob/v3.1.3/LICENSE.markdown" target="_blank" rel="noopener">JSZip v3.1.3</a></h4> +<pre> +Copyright (c) 2009-2016 Stuart Knightley, David Duponchel, Franz Buchinger, António Afonso + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + +<h4><a href="https://github.com/WaniKani/WanaKana/blob/4.0.2/LICENSE" target="_blank" rel="noopener">WanaKana v4.0.2</a></h4> +<pre> +The MIT License (MIT) + +Copyright (c) 2013 WaniKani Community Github + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + +</div></body> </html> diff --git a/ext/bg/search.html b/ext/bg/search.html index f3f156d8..4a28dd88 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -54,8 +54,8 @@ <hr> <div id="navigation-header" class="navigation-header" hidden><div class="navigation-header-actions"> - <button class="action-button action-previous"><img src="/mixed/img/source-term.svg" class="icon-image" title="Source term (Alt + B)" alt></button> - <button class="action-button action-next"><img src="/mixed/img/source-term.svg" class="icon-image" title="Next term (Alt + F)" alt></button> + <button class="action-button action-previous" data-icon="source-term" title="Source term (Alt + B)"></button> + <button class="action-button action-next" data-icon="source-term" title="Next term (Alt + F)"></button> </div></div><div class="navigation-header-spacer"></div> <div id="content"></div> @@ -71,6 +71,7 @@ <script src="/mixed/lib/wanakana.min.js"></script> <script src="/mixed/js/core.js"></script> + <script src="/mixed/js/comm.js"></script> <script src="/mixed/js/dom.js"></script> <script src="/mixed/js/api.js"></script> <script src="/mixed/js/japanese.js"></script> @@ -78,6 +79,7 @@ <script src="/bg/js/dictionary.js"></script> <script src="/bg/js/handlebars.js"></script> <script src="/fg/js/document.js"></script> + <script src="/fg/js/dom-text-scanner.js"></script> <script src="/fg/js/source.js"></script> <script src="/mixed/js/audio-system.js"></script> <script src="/mixed/js/display-context.js"></script> diff --git a/ext/bg/settings-popup-preview.html b/ext/bg/settings-popup-preview.html index 2f0b841b..75bf06c8 100644 --- a/ext/bg/settings-popup-preview.html +++ b/ext/bg/settings-popup-preview.html @@ -119,17 +119,19 @@ </div></div></div> <script src="/mixed/js/core.js"></script> + <script src="/mixed/js/comm.js"></script> <script src="/mixed/js/dom.js"></script> <script src="/mixed/js/api.js"></script> <script src="/mixed/js/dynamic-loader.js"></script> <script src="/mixed/js/text-scanner.js"></script> <script src="/fg/js/document.js"></script> - <script src="/fg/js/frontend-api-receiver.js"></script> + <script src="/fg/js/dom-text-scanner.js"></script> <script src="/fg/js/popup.js"></script> <script src="/fg/js/source.js"></script> <script src="/fg/js/popup-factory.js"></script> <script src="/fg/js/frontend.js"></script> + <script src="/fg/js/frame-offset-forwarder.js"></script> <script src="/bg/js/settings/popup-preview-frame.js"></script> <script src="/bg/js/settings/popup-preview-frame-main.js"></script> diff --git a/ext/bg/settings.html b/ext/bg/settings.html index 3ce91f12..51cb14e7 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -135,60 +135,68 @@ <h3>General Options</h3> <div class="checkbox"> - <label><input type="checkbox" id="enable"> Enable content scanning</label> + <label><input type="checkbox" id="enable" data-setting="general.enable"> Enable content scanning</label> </div> - <div class="checkbox" data-hide-for-browser="firefox-mobile"> + <div class="checkbox ignore-form-changes" 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> + <label><input type="checkbox" id="show-usage-guide" data-setting="general.showGuide"> Show usage guide on startup</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="compact-tags"> Compact tags</label> + <label><input type="checkbox" id="compact-tags" data-setting="general.compactTags"> Compact tags</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="compact-glossaries"> Compact glossaries</label> + <label><input type="checkbox" id="compact-glossaries" data-setting="general.compactGlossaries"> Compact glossaries</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="show-advanced-options"> Show advanced options</label> + <label><input type="checkbox" id="show-advanced-options" data-setting="general.showAdvanced" data-transform-pre="setDocumentAttribute" data-transform-post="setDocumentAttribute" data-document-attribute="data-options-general-show-advanced"> Show advanced options</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="popup-scale-relative-to-page-zoom"> Change popup size relative to page zoom level</label> + <label><input type="checkbox" id="popup-scale-relative-to-page-zoom" data-setting="general.popupScaleRelativeToPageZoom"> Change popup size relative to page zoom level</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="popup-scale-relative-to-visual-viewport"> Change popup size relative to page viewport</label> + <label><input type="checkbox" id="popup-scale-relative-to-visual-viewport" data-setting="general.popupScaleRelativeToVisualViewport"> Change popup size relative to page viewport</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="show-pitch-accent-downstep-notation"> Show downstep notation for pitch accents</label> + <label><input type="checkbox" id="show-pitch-accent-downstep-notation" data-setting="general.showPitchAccentDownstepNotation"> Show downstep notation for pitch accents</label> </div> <div class="checkbox options-position"> - <label><input type="checkbox" id="show-pitch-accent-position-notation"> Show position notation for pitch accents</label> + <label><input type="checkbox" id="show-pitch-accent-position-notation" data-setting="general.showPitchAccentPositionNotation"> Show position notation for pitch accents</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="show-pitch-accent-graph"> Show graph for pitch accents</label> + <label><input type="checkbox" id="show-pitch-accent-graph" data-setting="general.showPitchAccentGraph"> Show graph for pitch accents</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="show-iframe-popups-in-root-frame"> Show iframe popups in root frame</label> + <label><input type="checkbox" id="show-iframe-popups-in-root-frame" data-setting="general.showIframePopupsInRootFrame"> Show iframe popups in root frame</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="show-debug-info"> Show debug information</label> + <label><input type="checkbox" data-setting="general.useSecurePopupFrameUrl"> Use secure popup frame URL</label> + </div> + + <div class="checkbox options-advanced"> + <label><input type="checkbox" data-setting="general.usePopupShadowDom"> Use shadow DOM container for popup</label> + </div> + + <div class="checkbox options-advanced"> + <label><input type="checkbox" id="show-debug-info" data-setting="general.debugInfo" data-transform-pre="setDocumentAttribute" data-transform-post="setDocumentAttribute" data-document-attribute="data-options-general-debug-info"> Show debug information</label> </div> <div class="form-group"> <label for="result-output-mode">Result grouping</label> - <select class="form-control" id="result-output-mode"> + <select class="form-control" id="result-output-mode" data-setting="general.resultOutputMode" data-transform-pre="setDocumentAttribute" data-transform-post="setDocumentAttribute" data-document-attribute="data-options-general-result-output-mode"> <option value="group">Group results by term-reading pairs</option> <option value="merge">Group results by main dictionary entry</option> <option value="split">Split definitions to their own results</option> @@ -197,7 +205,7 @@ <div class="form-group"> <label for="popup-display-mode">Popup display mode</label> - <select class="form-control" id="popup-display-mode"> + <select class="form-control" id="popup-display-mode" data-setting="general.popupDisplayMode"> <option value="default">Default</option> <option value="full-width">Full width</option> </select> @@ -205,26 +213,26 @@ <div class="form-group"> <label for="popup-scaling-factor">Popup size multiplier</label> - <input type="number" min="0" id="popup-scaling-factor" class="form-control"> + <input type="number" min="0" id="popup-scaling-factor" data-setting="general.popupScalingFactor" class="form-control"> </div> <div class="form-group options-advanced"> <label for="max-displayed-results">Maximum displayed results</label> - <input type="number" min="1" id="max-displayed-results" class="form-control"> + <input type="number" min="1" id="max-displayed-results" class="form-control" data-setting="general.maxResults"> </div> <div class="form-group"> <div class="row"> <div class="col-xs-6"> <label for="popup-horizontal-text-position">Popup position for horizontal text</label> - <select class="form-control" id="popup-horizontal-text-position"> + <select class="form-control" id="popup-horizontal-text-position" data-setting="general.popupHorizontalTextPosition"> <option value="below">Below text</option> <option value="above">Above text</option> </select> </div> <div class="col-xs-6"> <label for="popup-vertical-text-position">Popup position for vertical text</label> - <select class="form-control" id="popup-vertical-text-position"> + <select class="form-control" id="popup-vertical-text-position" data-setting="general.popupVerticalTextPosition"> <option value="default">Same as for horizontal text</option> <option value="before">Before text reading direction</option> <option value="after">After text reading direction</option> @@ -239,11 +247,11 @@ <div class="row"> <div class="col-xs-6"> <label for="popup-width">Popup width <span class="label-light">(in pixels)</span></label> - <input type="number" min="1" id="popup-width" class="form-control"> + <input type="number" min="1" id="popup-width" class="form-control" data-setting="general.popupWidth"> </div> <div class="col-xs-6"> <label for="popup-height">Popup height <span class="label-light">(in pixels)</span></label> - <input type="number" min="1" id="popup-height" class="form-control"> + <input type="number" min="1" id="popup-height" class="form-control" data-setting="general.popupHeight"> </div> </div> </div> @@ -252,11 +260,11 @@ <div class="row"> <div class="col-xs-6"> <label for="popup-horizontal-offset">Horizontal popup offset <span class="label-light">(in pixels)</span></label> - <input type="number" min="0" id="popup-horizontal-offset" class="form-control"> + <input type="number" min="0" id="popup-horizontal-offset" class="form-control" data-setting="general.popupHorizontalOffset"> </div> <div class="col-xs-6"> <label for="popup-vertical-offset">Vertical popup offset <span class="label-light">(in pixels)</span></label> - <input type="number" min="0" id="popup-vertical-offset" class="form-control"> + <input type="number" min="0" id="popup-vertical-offset" class="form-control" data-setting="general.popupVerticalOffset"> </div> </div> </div> @@ -265,11 +273,11 @@ <div class="row"> <div class="col-xs-6"> <label for="popup-horizontal-offset2">Horizontal popup offset for vertical text <span class="label-light">(in pixels)</span></label> - <input type="number" min="0" id="popup-horizontal-offset2" class="form-control"> + <input type="number" min="0" id="popup-horizontal-offset2" class="form-control" data-setting="general.popupHorizontalOffset2"> </div> <div class="col-xs-6"> <label for="popup-vertical-offset2">Vertical popup offset for vertical text <span class="label-light">(in pixels)</span></label> - <input type="number" min="0" id="popup-vertical-offset2" class="form-control"> + <input type="number" min="0" id="popup-vertical-offset2" class="form-control" data-setting="general.popupVerticalOffset2"> </div> </div> </div> @@ -278,14 +286,14 @@ <div class="row"> <div class="col-xs-6"> <label for="popup-theme">Popup theme</label> - <select class="form-control" id="popup-theme"> + <select class="form-control" id="popup-theme" data-setting="general.popupTheme"> <option value="default">Light</option> <option value="dark">Dark</option> </select> </div> <div class="col-xs-6"> <label for="popup-outer-theme">Popup shadow theme</label> - <select class="form-control" id="popup-outer-theme"> + <select class="form-control" id="popup-outer-theme" data-setting="general.popupOuterTheme"> <option value="auto">Auto-detect</option> <option value="default">Light</option> <option value="dark">Dark</option> @@ -298,11 +306,11 @@ <div class="row"> <div class="col-xs-6"> <label for="custom-popup-css">Custom popup CSS</label> - <div><textarea autocomplete="off" spellcheck="false" wrap="soft" id="custom-popup-css" class="form-control"></textarea></div> + <div><textarea autocomplete="off" spellcheck="false" wrap="soft" id="custom-popup-css" class="form-control" data-setting="general.customPopupCss"></textarea></div> </div> <div class="col-xs-6"> <label for="custom-popup-outer-css">Custom popup outer CSS</label> - <div><textarea autocomplete="off" spellcheck="false" wrap="soft" id="custom-popup-outer-css" class="form-control" placeholder="iframe.yomichan-float { /*styles*/ }"></textarea></div> + <div><textarea autocomplete="off" spellcheck="false" wrap="soft" id="custom-popup-outer-css" class="form-control" data-setting="general.customPopupOuterCss" placeholder="iframe.yomichan-float { /*styles*/ }"></textarea></div> </div> </div> </div> @@ -324,22 +332,22 @@ <h3>Audio Options</h3> <div class="checkbox"> - <label><input type="checkbox" id="audio-playback-enabled"> Enable audio playback in search results</label> + <label><input type="checkbox" id="audio-playback-enabled" data-setting="audio.enabled"> Enable audio playback in search results</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="auto-play-audio"> Play audio automatically</label> + <label><input type="checkbox" id="auto-play-audio" data-setting="audio.autoPlay"> Play audio automatically</label> </div> <div class="form-group"> <label for="audio-playback-volume">Audio playback volume <span class="label-light">(percent)</span></label> - <input type="number" min="0" max="100" id="audio-playback-volume" class="form-control"> + <input type="number" min="0" max="100" id="audio-playback-volume" class="form-control" data-setting="audio.volume"> </div> <div class="form-group" id="text-to-speech-voice-container" hidden> <label for="text-to-speech-voice">Text-to-speech voice</label> <div class="input-group"> - <select class="form-control" id="text-to-speech-voice"></select> + <select class="form-control" id="text-to-speech-voice" data-setting="audio.textToSpeechVoice"></select> <div class="input-group-btn"> <button class="btn btn-default" id="text-to-speech-voice-test" title="Test voice" data-speech-text="よみちゃん"><span class="glyphicon glyphicon-volume-up"></span></button> </div> @@ -348,7 +356,7 @@ <div class="form-group options-advanced"> <label for="audio-custom-source">Custom audio source <span class="label-light">(URL)</span></label> - <input type="text" id="audio-custom-source" class="form-control" placeholder="Example: http://localhost/audio.mp3?expression={expression}&reading={reading}"> + <input type="text" id="audio-custom-source" class="form-control" data-setting="audio.customSourceUrl" placeholder="Example: http://localhost/audio.mp3?expression={expression}&reading={reading}"> </div> <div class="form-group ignore-form-changes"> @@ -377,42 +385,46 @@ <h3>Scanning Options</h3> <div class="checkbox"> - <label><input type="checkbox" id="middle-mouse-button-scan"> Middle mouse button scans</label> + <label><input type="checkbox" id="middle-mouse-button-scan" data-setting="scanning.middleMouse"> Middle mouse button scans</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="touch-input-enabled"> Touch input enabled</label> + <label><input type="checkbox" id="touch-input-enabled" data-setting="scanning.touchInputEnabled"> Touch input enabled</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="select-matched-text"> Select matched text</label> + <label><input type="checkbox" id="select-matched-text" data-setting="scanning.selectText"> Select matched text</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="search-alphanumeric"> Search alphanumeric text</label> + <label><input type="checkbox" id="search-alphanumeric" data-setting="scanning.alphanumeric"> Search alphanumeric text</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="auto-hide-results"> Automatically hide results</label> + <label><input type="checkbox" id="auto-hide-results" data-setting="scanning.autoHideResults"> Automatically hide results</label> + </div> + + <div class="checkbox"> + <label><input type="checkbox" id="layout-aware-scan" data-setting="scanning.layoutAwareScan"> Layout-aware scan</label> </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="deep-dom-scan"> Deep DOM scan</label> + <label><input type="checkbox" id="deep-dom-scan" data-setting="scanning.deepDomScan"> Deep DOM scan</label> </div> <div class="form-group options-advanced"> <label for="scan-delay">Scan delay <span class="label-light">(in milliseconds)</span></label> - <input type="number" min="0" id="scan-delay" class="form-control"> + <input type="number" min="0" id="scan-delay" class="form-control" data-setting="scanning.delay"> </div> <div class="form-group options-advanced"> <label for="scan-length">Scan length <span class="label-light">(in characters)</span></label> - <input type="number" min="1" step="1" id="scan-length" class="form-control"> + <input type="number" min="1" step="1" id="scan-length" class="form-control" data-setting="scanning.length"> </div> <div class="form-group"> <label for="scan-modifier-key">Scan modifier key</label> - <select class="form-control" id="scan-modifier-key"></select> + <select class="form-control" id="scan-modifier-key" data-setting="scanning.modifier"></select> </div> </div> @@ -447,7 +459,7 @@ <div class="form-group"> <label for="translation-convert-half-width-characters">Convert half width characters to full width <span class="label-light">(ヨミチャン → ヨミチャン)</span></label> - <select class="form-control" id="translation-convert-half-width-characters"> + <select class="form-control" id="translation-convert-half-width-characters" data-setting="translation.convertHalfWidthCharacters"> <option value="false">Disabled</option> <option value="true">Enabled</option> <option value="variant">Use both variants</option> @@ -456,7 +468,7 @@ <div class="form-group"> <label for="translation-convert-numeric-characters">Convert numeric characters to full width <span class="label-light">(1234 → 1234)</span></label> - <select class="form-control" id="translation-convert-numeric-characters"> + <select class="form-control" id="translation-convert-numeric-characters" data-setting="translation.convertNumericCharacters"> <option value="false">Disabled</option> <option value="true">Enabled</option> <option value="variant">Use both variants</option> @@ -465,7 +477,7 @@ <div class="form-group"> <label for="translation-convert-alphabetic-characters">Convert alphabetic characters to hiragana <span class="label-light">(yomichan → よみちゃん)</span></label> - <select class="form-control" id="translation-convert-alphabetic-characters"> + <select class="form-control" id="translation-convert-alphabetic-characters" data-setting="translation.convertAlphabeticCharacters"> <option value="false">Disabled</option> <option value="true">Enabled</option> <option value="variant">Use both variants</option> @@ -474,7 +486,7 @@ <div class="form-group"> <label for="translation-convert-hiragana-to-katakana">Convert hiragana to katakana <span class="label-light">(よみちゃん → ヨミチャン)</span></label> - <select class="form-control" id="translation-convert-hiragana-to-katakana"> + <select class="form-control" id="translation-convert-hiragana-to-katakana" data-setting="translation.convertHiraganaToKatakana"> <option value="false">Disabled</option> <option value="true">Enabled</option> <option value="variant">Use both variants</option> @@ -483,7 +495,7 @@ <div class="form-group"> <label for="translation-convert-katakana-to-hiragana">Convert katakana to hiragana <span class="label-light">(ヨミチャン → よみちゃん)</span></label> - <select class="form-control" id="translation-convert-katakana-to-hiragana"> + <select class="form-control" id="translation-convert-katakana-to-hiragana" data-setting="translation.convertKatakanaToHiragana"> <option value="false">Disabled</option> <option value="true">Enabled</option> <option value="variant">Use both variants</option> @@ -492,7 +504,7 @@ <div class="form-group"> <label for="translation-collapse-emphatic-sequences">Collapse emphatic character sequences <span class="label-light">(すっっごーーい → すっごーい / すごい)</span></label> - <select class="form-control" id="translation-collapse-emphatic-sequences"> + <select class="form-control" id="translation-collapse-emphatic-sequences" data-setting="translation.collapseEmphaticSequences"> <option value="false">Disabled</option> <option value="true">Collapse into single character</option> <option value="full">Remove all characters</option> @@ -509,24 +521,24 @@ </p> <div class="checkbox"> - <label><input type="checkbox" id="enable-search-within-first-popup"> Enable search when clicking glossary entries and tags</label> + <label><input type="checkbox" id="enable-search-within-first-popup" data-setting="scanning.enablePopupSearch"> Enable search when clicking glossary entries and tags</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="enable-scanning-on-search-page"> Enable scanning on search page</label> + <label><input type="checkbox" id="enable-scanning-on-search-page" data-setting="scanning.enableOnSearchPage"> Enable scanning on search page</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="enable-scanning-of-popup-expressions"> Enable scanning of expressions in search results</label> + <label><input type="checkbox" id="enable-scanning-of-popup-expressions" data-setting="scanning.enableOnPopupExpressions"> 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> + <label><input type="checkbox" id="enable-search-tags" data-setting="scanning.enableSearchTags"> 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"> + <input type="number" min="0" step="1" id="popup-nesting-max-depth" class="form-control" data-setting="scanning.popupNestingMaxDepth"> </div> </div> @@ -551,20 +563,20 @@ </p> <div class="checkbox"> - <label><input type="checkbox" id="parsing-scan-enable"> Enable text parsing using installed dictionaries</label> + <label><input type="checkbox" id="parsing-scan-enable" data-setting="parsing.enableScanningParser"> Enable text parsing using installed dictionaries</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="parsing-mecab-enable"> Enable text parsing using MeCab</label> + <label><input type="checkbox" id="parsing-mecab-enable" data-setting="parsing.enableMecabParser"> Enable text parsing using MeCab</label> </div> <div class="checkbox"> - <label><input type="checkbox" id="parsing-term-spacing"> Enable small spaces between parsed words</label> + <label><input type="checkbox" id="parsing-term-spacing" data-setting="parsing.termSpacing"> Enable small spaces between parsed words</label> </div> <div class="form-group"> <label for="parsing-reading-mode">Reading mode</label> - <select class="form-control" id="parsing-reading-mode"> + <select class="form-control" id="parsing-reading-mode" data-setting="parsing.readingMode"> <option value="hiragana">ひらがな</option> <option value="katakana">カタカナ</option> <option value="romaji">Romaji</option> @@ -621,7 +633,7 @@ </div> <div class="checkbox"> - <label><input type="checkbox" id="database-enable-prefix-wildcard-searches"> Enable prefix wildcard searches</label> + <label><input type="checkbox" id="database-enable-prefix-wildcard-searches" data-setting="global.database.prefixWildcardsSupported" data-scope="global"> Enable prefix wildcard searches</label> <p class="help-block"> This option only applies to newly imported dictionaries. Enabling this option will also cause dictionary data to take up slightly more storage space. @@ -711,7 +723,6 @@ <div id="storage-info"> <div> - <img src="/mixed/img/spinner.gif" class="pull-right" id="storage-spinner" /> <h3>Storage</h3> </div> @@ -771,7 +782,7 @@ <div> <div> - <img src="/mixed/img/spinner.gif" class="pull-right" id="anki-spinner" alt> + <img src="/mixed/img/spinner.gif" class="pull-right" id="anki-spinner" alt hidden> <h3>Anki Options</h3> </div> @@ -782,7 +793,7 @@ </p> <div class="checkbox"> - <label><input type="checkbox" id="anki-enable"> Enable Anki integration</label> + <label><input type="checkbox" id="anki-enable" data-setting="anki.enable" data-transform-pre="setDocumentAttribute" data-transform-post="setDocumentAttribute" data-document-attribute="data-options-anki-enable"> Enable Anki integration</label> </div> <div id="anki-general"> @@ -805,22 +816,22 @@ <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"> + <input type="text" id="card-tags" class="form-control" data-setting="anki.tags" data-transform-pre="splitTags" data-transform-post="joinTags"> </div> <div class="form-group options-advanced"> <label for="sentence-detection-extent">Sentence detection extent <span class="label-light">(in characters)</span></label> - <input type="number" min="1" step="1" id="sentence-detection-extent" class="form-control"> + <input type="number" min="1" step="1" id="sentence-detection-extent" class="form-control" data-setting="anki.sentenceExt"> </div> <div class="form-group options-advanced"> <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"> + <input type="text" id="interface-server" class="form-control" data-setting="anki.server"> </div> <div class="form-group options-advanced"> <label for="duplicate-scope">Duplicate scope</label> - <select class="form-control" id="duplicate-scope"> + <select class="form-control" id="duplicate-scope" data-setting="anki.duplicateScope"> <option value="collection">Collection</option> <option value="deck">Deck</option> </select> @@ -828,7 +839,7 @@ <div class="form-group options-advanced"> <label for="screenshot-format">Screenshot format</label> - <select class="form-control" id="screenshot-format"> + <select class="form-control" id="screenshot-format" data-setting="anki.screenshot.format"> <option value="png">PNG</option> <option value="jpeg">JPEG</option> </select> @@ -836,7 +847,7 @@ <div class="form-group options-advanced"> <label for="screenshot-quality">Screenshot quality <span class="label-light">(JPEG only)</span></label> - <input type="number" min="0" max="100" step="1" id="screenshot-quality" class="form-control"> + <input type="number" min="0" max="100" step="1" id="screenshot-quality" class="form-control" data-setting="anki.screenshot.quality"> </div> <div id="anki-format"> @@ -854,7 +865,7 @@ <li><a href="#kanji" data-toggle="tab">Kanji</a></li> </ul> - <div class="tab-content"> + <div class="tab-content ignore-form-changes" id="anki-fields-container"> <div id="terms" class="tab-pane fade in active" data-anki-card-type="terms"> <div class="row"> <div class="form-group col-xs-6"> @@ -1111,8 +1122,6 @@ </p> </div> - <pre id="debug" class="debug"></pre> - <div class="pull-right bottom-links"> <small><span id="extension-info"></span> • <a href="search.html">Search</a> • <a href="https://foosoft.net/projects/yomichan/" target="_blank" rel="noopener">Homepage</a> • <a href="legal.html">Legal</a></small> </div> @@ -1124,6 +1133,7 @@ <script src="/mixed/lib/wanakana.min.js"></script> <script src="/mixed/js/core.js"></script> + <script src="/mixed/js/comm.js"></script> <script src="/mixed/js/dom.js"></script> <script src="/mixed/js/environment.js"></script> <script src="/mixed/js/api.js"></script> @@ -1143,13 +1153,18 @@ <script src="/bg/js/settings/anki.js"></script> <script src="/bg/js/settings/anki-templates.js"></script> <script src="/bg/js/settings/audio.js"></script> - <script src="/bg/js/settings/audio-ui.js"></script> <script src="/bg/js/settings/backup.js"></script> + <script src="/bg/js/settings/clipboard-popups-controller.js"></script> <script src="/bg/js/settings/conditions-ui.js"></script> <script src="/bg/js/settings/dictionaries.js"></script> + <script src="/bg/js/settings/generic-setting-controller.js"></script> <script src="/bg/js/settings/popup-preview.js"></script> <script src="/bg/js/settings/profiles.js"></script> + <script src="/bg/js/settings/settings-controller.js"></script> <script src="/bg/js/settings/storage.js"></script> + <script src="/mixed/js/object-property-accessor.js"></script> + <script src="/mixed/js/task-accumulator.js"></script> + <script src="/mixed/js/dom-data-binder.js"></script> <script src="/bg/js/settings/main.js"></script> </body> |