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/js | |
parent | 19197a9a5d6a1f54a179d894577dfac513b97401 (diff) | |
parent | 0a6c08d0f53090a4ad48663bc5846ddae5723d52 (diff) |
Merge branch 'master' into testing
Diffstat (limited to 'ext/bg/js')
32 files changed, 3294 insertions, 2834 deletions
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); - }); -} |