diff options
Diffstat (limited to 'ext/bg/js')
30 files changed, 813 insertions, 827 deletions
diff --git a/ext/bg/js/anki-note-builder.js b/ext/bg/js/anki-note-builder.js new file mode 100644 index 00000000..d0ff8205 --- /dev/null +++ b/ext/bg/js/anki-note-builder.js @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2020 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class AnkiNoteBuilder { + constructor({renderTemplate}) { + this._renderTemplate = renderTemplate; + } + + async createNote(definition, mode, options, templates) { + const isKanji = (mode === 'kanji'); + const tags = options.anki.tags; + const modeOptions = isKanji ? options.anki.kanji : options.anki.terms; + const modeOptionsFieldEntries = Object.entries(modeOptions.fields); + + const note = { + fields: {}, + tags, + deckName: modeOptions.deck, + modelName: modeOptions.model + }; + + for (const [fieldName, fieldValue] of modeOptionsFieldEntries) { + note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, options, templates, null); + } + + if (!isKanji && definition.audio) { + const audioFields = []; + + for (const [fieldName, fieldValue] of modeOptionsFieldEntries) { + if (fieldValue.includes('{audio}')) { + audioFields.push(fieldName); + } + } + + if (audioFields.length > 0) { + note.audio = { + url: definition.audio.url, + filename: definition.audio.filename, + skipHash: '7e2c2f954ef6051373ba916f000168dc', // hash of audio data that should be skipped + fields: audioFields + }; + } + } + + return note; + } + + async formatField(field, definition, mode, options, templates, errors=null) { + const data = { + marker: null, + definition, + group: options.general.resultOutputMode === 'group', + merge: options.general.resultOutputMode === 'merge', + modeTermKanji: mode === 'term-kanji', + modeTermKana: mode === 'term-kana', + modeKanji: mode === 'kanji', + compactGlossaries: options.general.compactGlossaries + }; + const pattern = /\{([\w-]+)\}/g; + return await AnkiNoteBuilder.stringReplaceAsync(field, pattern, async (g0, marker) => { + data.marker = marker; + try { + return await this._renderTemplate(templates, data); + } catch (e) { + if (errors) { errors.push(e); } + return `{${marker}-render-error}`; + } + }); + } + + static stringReplaceAsync(str, regex, replacer) { + let match; + let index = 0; + const parts = []; + while ((match = regex.exec(str)) !== null) { + parts.push(str.substring(index, match.index), replacer(...match, match.index, str)); + index = regex.lastIndex; + } + if (parts.length === 0) { + return Promise.resolve(str); + } + parts.push(str.substring(index)); + return Promise.all(parts).then((v) => v.join('')); + } +} diff --git a/ext/bg/js/anki.js b/ext/bg/js/anki.js index 39c6ad51..a70388bd 100644 --- a/ext/bg/js/anki.js +++ b/ext/bg/js/anki.js @@ -16,7 +16,9 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global requestJson*/ +/* global + * requestJson + */ /* * AnkiConnect diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js deleted file mode 100644 index 0c244ffa..00000000 --- a/ext/bg/js/api.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (C) 2019-2020 Alex Yatskov <alex@foosoft.net> - * Author: Alex Yatskov <alex@foosoft.net> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - - -function apiTemplateRender(template, data) { - return _apiInvoke('templateRender', {data, template}); -} - -function apiAudioGetUrl(definition, source, optionsContext) { - return _apiInvoke('audioGetUrl', {definition, source, optionsContext}); -} - -function apiClipboardGet() { - return _apiInvoke('clipboardGet'); -} - -function _apiInvoke(action, params={}) { - const data = {action, params}; - return new Promise((resolve, reject) => { - try { - const callback = (response) => { - if (response !== null && typeof response === 'object') { - if (typeof response.error !== 'undefined') { - reject(jsonToError(response.error)); - } else { - resolve(response.result); - } - } else { - const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`; - reject(new Error(`${message} (${JSON.stringify(data)})`)); - } - }; - const backend = window.yomichanBackend; - backend.onMessage({action, params}, null, callback); - } catch (e) { - reject(e); - yomichan.triggerOrphaned(e); - } - }); -} diff --git a/ext/bg/js/audio.js b/ext/bg/js/audio-uri-builder.js index d300570b..499c3441 100644 --- a/ext/bg/js/audio.js +++ b/ext/bg/js/audio-uri-builder.js @@ -16,10 +16,53 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global jpIsStringEntirelyKana, audioGetFromSources*/ +/* global + * jpIsStringEntirelyKana + */ + +class AudioUriBuilder { + constructor() { + this._getUrlHandlers = new Map([ + ['jpod101', this._getUriJpod101.bind(this)], + ['jpod101-alternate', this._getUriJpod101Alternate.bind(this)], + ['jisho', this._getUriJisho.bind(this)], + ['text-to-speech', this._getUriTextToSpeech.bind(this)], + ['text-to-speech-reading', this._getUriTextToSpeechReading.bind(this)], + ['custom', this._getUriCustom.bind(this)] + ]); + } + + normalizeUrl(url, baseUrl, basePath) { + if (url) { + if (url[0] === '/') { + if (url.length >= 2 && url[1] === '/') { + // Begins with "//" + url = baseUrl.substring(0, baseUrl.indexOf(':') + 1) + url; + } else { + // Begins with "/" + url = baseUrl + url; + } + } else if (!/^[a-z][a-z0-9\-+.]*:/i.test(url)) { + // No URI scheme => relative path + url = baseUrl + basePath + url; + } + } + return url; + } -const audioUrlBuilders = new Map([ - ['jpod101', async (definition) => { + async getUri(definition, source, options) { + const handler = this._getUrlHandlers.get(source); + if (typeof handler === 'function') { + try { + return await handler(definition, options); + } catch (e) { + // NOP + } + } + return null; + } + + async _getUriJpod101(definition) { let kana = definition.reading; let kanji = definition.expression; @@ -37,8 +80,9 @@ const audioUrlBuilders = new Map([ } return `https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?${params.join('&')}`; - }], - ['jpod101-alternate', async (definition) => { + } + + async _getUriJpod101Alternate(definition) { const response = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'); @@ -54,7 +98,7 @@ const audioUrlBuilders = new Map([ const url = row.querySelector('audio>source[src]').getAttribute('src'); const reading = row.getElementsByClassName('dc-vocab_kana').item(0).textContent; if (url && reading && (!definition.reading || definition.reading === reading)) { - return audioUrlNormalize(url, 'https://www.japanesepod101.com', '/learningcenter/reference/'); + return this.normalizeUrl(url, 'https://www.japanesepod101.com', '/learningcenter/reference/'); } } catch (e) { // NOP @@ -62,8 +106,9 @@ const audioUrlBuilders = new Map([ } throw new Error('Failed to find audio URL'); - }], - ['jisho', async (definition) => { + } + + async _getUriJisho(definition) { const response = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', `https://jisho.org/search/${definition.expression}`); @@ -78,7 +123,7 @@ const audioUrlBuilders = new Map([ if (audio !== null) { const url = audio.getElementsByTagName('source').item(0).getAttribute('src'); if (url) { - return audioUrlNormalize(url, 'https://jisho.org', '/search/'); + return this.normalizeUrl(url, 'https://jisho.org', '/search/'); } } } catch (e) { @@ -86,101 +131,28 @@ const audioUrlBuilders = new Map([ } throw new Error('Failed to find audio URL'); - }], - ['text-to-speech', async (definition, options) => { + } + + async _getUriTextToSpeech(definition, options) { const voiceURI = options.audio.textToSpeechVoice; if (!voiceURI) { throw new Error('No voice'); } return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(voiceURI)}`; - }], - ['text-to-speech-reading', async (definition, options) => { + } + + async _getUriTextToSpeechReading(definition, options) { const voiceURI = options.audio.textToSpeechVoice; if (!voiceURI) { throw new Error('No voice'); } return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(voiceURI)}`; - }], - ['custom', async (definition, options) => { - const customSourceUrl = options.audio.customSourceUrl; - return customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(definition, m1) ? `${definition[m1]}` : m0)); - }] -]); - -async function audioGetUrl(definition, mode, options, download) { - const handler = audioUrlBuilders.get(mode); - if (typeof handler === 'function') { - try { - return await handler(definition, options, download); - } catch (e) { - // NOP - } - } - return null; -} - -function audioUrlNormalize(url, baseUrl, basePath) { - if (url) { - if (url[0] === '/') { - if (url.length >= 2 && url[1] === '/') { - // Begins with "//" - url = baseUrl.substring(0, baseUrl.indexOf(':') + 1) + url; - } else { - // Begins with "/" - url = baseUrl + url; - } - } else if (!/^[a-z][a-z0-9\-+.]*:/i.test(url)) { - // No URI scheme => relative path - url = baseUrl + basePath + url; - } - } - return url; -} - -function audioBuildFilename(definition) { - if (definition.reading || definition.expression) { - let filename = 'yomichan'; - if (definition.reading) { - filename += `_${definition.reading}`; - } - if (definition.expression) { - filename += `_${definition.expression}`; - } - - return filename += '.mp3'; - } - return null; -} - -async function audioInject(definition, fields, sources, optionsContext) { - let usesAudio = false; - for (const name in fields) { - if (fields[name].includes('{audio}')) { - usesAudio = true; - break; - } - } - - if (!usesAudio) { - return true; } - try { - const expressions = definition.expressions; - const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition; - - const {url} = await audioGetFromSources(audioSourceDefinition, sources, optionsContext, true); - if (url !== null) { - const filename = audioBuildFilename(audioSourceDefinition); - if (filename !== null) { - definition.audio = {url, filename}; - } - } - - return true; - } catch (e) { - return false; + async _getUriCustom(definition, options) { + const customSourceUrl = options.audio.customSourceUrl; + return customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(definition, m1) ? `${definition[m1]}` : m0)); } } diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index e3bf7bda..978c5a4a 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -16,30 +16,51 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global optionsSave, utilIsolate -conditionsTestValue, profileConditionsDescriptor, profileOptionsGetDefaultFieldTemplates -handlebarsRenderDynamic -requestText, requestJson, optionsLoad -dictConfigured, dictTermsSort, dictEnabledSet, dictNoteFormat -audioGetUrl, audioInject -jpConvertReading, jpDistributeFuriganaInflected, jpKatakanaToHiragana -Translator, AnkiConnect, AnkiNull, Mecab, BackendApiForwarder, JsonSchema, ClipboardMonitor*/ +/* global + * AnkiConnect + * AnkiNoteBuilder + * AnkiNull + * AudioSystem + * AudioUriBuilder + * BackendApiForwarder + * ClipboardMonitor + * JsonSchema + * Mecab + * Translator + * conditionsTestValue + * dictConfigured + * dictEnabledSet + * dictTermsSort + * handlebarsRenderDynamic + * jpConvertReading + * jpDistributeFuriganaInflected + * jpKatakanaToHiragana + * optionsLoad + * optionsSave + * profileConditionsDescriptor + * requestJson + * requestText + * utilIsolate + */ class Backend { constructor() { this.translator = new Translator(); this.anki = new AnkiNull(); this.mecab = new Mecab(); - this.clipboardMonitor = new ClipboardMonitor(); + this.clipboardMonitor = new ClipboardMonitor({getClipboard: this._onApiClipboardGet.bind(this)}); + this.ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: this._renderTemplate.bind(this)}); this.options = null; this.optionsSchema = null; + this.defaultAnkiFieldTemplates = null; + this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)}); + this.audioUriBuilder = new AudioUriBuilder(); this.optionsContext = { depth: 0, url: window.location.href }; - this.isPreparedResolve = null; - this.isPreparedPromise = new Promise((resolve) => (this.isPreparedResolve = resolve)); + this.isPrepared = false; this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target'); @@ -48,12 +69,50 @@ class Backend { this.apiForwarder = new BackendApiForwarder(); this.messageToken = yomichan.generateId(16); + + this._messageHandlers = new Map([ + ['yomichanCoreReady', this._onApiYomichanCoreReady.bind(this)], + ['optionsSchemaGet', this._onApiOptionsSchemaGet.bind(this)], + ['optionsGet', this._onApiOptionsGet.bind(this)], + ['optionsGetFull', this._onApiOptionsGetFull.bind(this)], + ['optionsSet', this._onApiOptionsSet.bind(this)], + ['optionsSave', this._onApiOptionsSave.bind(this)], + ['kanjiFind', this._onApiKanjiFind.bind(this)], + ['termsFind', this._onApiTermsFind.bind(this)], + ['textParse', this._onApiTextParse.bind(this)], + ['textParseMecab', this._onApiTextParseMecab.bind(this)], + ['definitionAdd', this._onApiDefinitionAdd.bind(this)], + ['definitionsAddable', this._onApiDefinitionsAddable.bind(this)], + ['noteView', this._onApiNoteView.bind(this)], + ['templateRender', this._onApiTemplateRender.bind(this)], + ['commandExec', this._onApiCommandExec.bind(this)], + ['audioGetUri', this._onApiAudioGetUri.bind(this)], + ['screenshotGet', this._onApiScreenshotGet.bind(this)], + ['forward', this._onApiForward.bind(this)], + ['frameInformationGet', this._onApiFrameInformationGet.bind(this)], + ['injectStylesheet', this._onApiInjectStylesheet.bind(this)], + ['getEnvironmentInfo', this._onApiGetEnvironmentInfo.bind(this)], + ['clipboardGet', this._onApiClipboardGet.bind(this)], + ['getDisplayTemplatesHtml', this._onApiGetDisplayTemplatesHtml.bind(this)], + ['getQueryParserTemplatesHtml', this._onApiGetQueryParserTemplatesHtml.bind(this)], + ['getZoom', this._onApiGetZoom.bind(this)], + ['getMessageToken', this._onApiGetMessageToken.bind(this)], + ['getDefaultAnkiFieldTemplates', this._onApiGetDefaultAnkiFieldTemplates.bind(this)] + ]); + + this._commandHandlers = new Map([ + ['search', this._onCommandSearch.bind(this)], + ['help', this._onCommandHelp.bind(this)], + ['options', this._onCommandOptions.bind(this)], + ['toggle', this._onCommandToggle.bind(this)] + ]); } async prepare() { await this.translator.prepare(); this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET'); + this.defaultAnkiFieldTemplates = await requestText(chrome.runtime.getURL('/bg/data/default-anki-field-templates.handlebars'), 'GET'); this.options = await optionsLoad(); try { this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, this.options); @@ -65,42 +124,47 @@ class Backend { this.onOptionsUpdated('background'); if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) { - chrome.commands.onCommand.addListener((command) => this._runCommand(command)); + chrome.commands.onCommand.addListener(this._runCommand.bind(this)); } if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) { - chrome.tabs.onZoomChange.addListener((info) => this._onZoomChange(info)); + chrome.tabs.onZoomChange.addListener(this._onZoomChange.bind(this)); } chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); - const options = this.getOptionsSync(this.optionsContext); + this.isPrepared = true; + + const options = this.getOptions(this.optionsContext); if (options.general.showGuide) { chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')}); } - this.isPreparedResolve(); - this.isPreparedResolve = null; - this.isPreparedPromise = null; + this.clipboardMonitor.on('change', this._onClipboardText.bind(this)); - this.clipboardMonitor.onClipboardText = (text) => this._onClipboardText(text); + this._sendMessageAllTabs('backendPrepared'); + const callback = () => this.checkLastError(chrome.runtime.lastError); + chrome.runtime.sendMessage({action: 'backendPrepared'}, callback); } - onOptionsUpdated(source) { - this.applyOptions(); - + _sendMessageAllTabs(action, params={}) { const callback = () => this.checkLastError(chrome.runtime.lastError); chrome.tabs.query({}, (tabs) => { for (const tab of tabs) { - chrome.tabs.sendMessage(tab.id, {action: 'optionsUpdated', params: {source}}, callback); + chrome.tabs.sendMessage(tab.id, {action, params}, callback); } }); } + onOptionsUpdated(source) { + this.applyOptions(); + this._sendMessageAllTabs('optionsUpdated', {source}); + } + onMessage({action, params}, sender, callback) { - const handler = Backend._messageHandlers.get(action); + const handler = this._messageHandlers.get(action); if (typeof handler !== 'function') { return false; } try { - const promise = handler(this, params, sender); + const promise = handler(params, sender); promise.then( (result) => callback({result}), (error) => callback({error: errorToJson(error)}) @@ -112,7 +176,7 @@ class Backend { } } - _onClipboardText(text) { + _onClipboardText({text}) { this._onCommandSearch({mode: 'popup', query: text}); } @@ -122,7 +186,7 @@ class Backend { } applyOptions() { - const options = this.getOptionsSync(this.optionsContext); + const options = this.getOptions(this.optionsContext); if (!options.general.enable) { this.setExtensionBadgeBackgroundColor('#555555'); this.setExtensionBadgeText('off'); @@ -148,24 +212,15 @@ class Backend { } } - async getOptionsSchema() { - if (this.isPreparedPromise !== null) { - await this.isPreparedPromise; - } + getOptionsSchema() { return this.optionsSchema; } - async getFullOptions() { - if (this.isPreparedPromise !== null) { - await this.isPreparedPromise; - } + getFullOptions() { return this.options; } - async setFullOptions(options) { - if (this.isPreparedPromise !== null) { - await this.isPreparedPromise; - } + setFullOptions(options) { try { this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, utilIsolate(options)); } catch (e) { @@ -174,18 +229,11 @@ class Backend { } } - async getOptions(optionsContext) { - if (this.isPreparedPromise !== null) { - await this.isPreparedPromise; - } - return this.getOptionsSync(optionsContext); - } - - getOptionsSync(optionsContext) { - return this.getProfileSync(optionsContext).options; + getOptions(optionsContext) { + return this.getProfile(optionsContext).options; } - getProfileSync(optionsContext) { + getProfile(optionsContext) { const profiles = this.options.profiles; if (typeof optionsContext.index === 'number') { return profiles[optionsContext.index]; @@ -243,29 +291,43 @@ class Backend { } _runCommand(command, params) { - const handler = Backend._commandHandlers.get(command); + const handler = this._commandHandlers.get(command); if (typeof handler !== 'function') { return false; } - handler(this, params); + handler(params); return true; } // Message handlers - _onApiOptionsSchemaGet() { + _onApiYomichanCoreReady(_params, sender) { + // tab ID isn't set in background (e.g. browser_action) + if (typeof sender.tab === 'undefined') { + const callback = () => this.checkLastError(chrome.runtime.lastError); + chrome.runtime.sendMessage({action: 'backendPrepared'}, callback); + return Promise.resolve(); + } + + const tabId = sender.tab.id; + return new Promise((resolve) => { + chrome.tabs.sendMessage(tabId, {action: 'backendPrepared'}, resolve); + }); + } + + async _onApiOptionsSchemaGet() { return this.getOptionsSchema(); } - _onApiOptionsGet({optionsContext}) { + async _onApiOptionsGet({optionsContext}) { return this.getOptions(optionsContext); } - _onApiOptionsGetFull() { + async _onApiOptionsGetFull() { return this.getFullOptions(); } async _onApiOptionsSet({changedOptions, optionsContext, source}) { - const options = await this.getOptions(optionsContext); + const options = this.getOptions(optionsContext); function getValuePaths(obj) { const valuePaths = []; @@ -305,20 +367,20 @@ class Backend { } async _onApiOptionsSave({source}) { - const options = await this.getFullOptions(); + const options = this.getFullOptions(); await optionsSave(options); this.onOptionsUpdated(source); } async _onApiKanjiFind({text, optionsContext}) { - const options = await this.getOptions(optionsContext); + const options = this.getOptions(optionsContext); const definitions = await this.translator.findKanji(text, options); definitions.splice(options.general.maxResults); return definitions; } async _onApiTermsFind({text, details, optionsContext}) { - const options = await this.getOptions(optionsContext); + const options = this.getOptions(optionsContext); const mode = options.general.resultOutputMode; const [definitions, length] = await this.translator.findTerms(mode, text, details, options); definitions.splice(options.general.maxResults); @@ -326,7 +388,7 @@ class Backend { } async _onApiTextParse({text, optionsContext}) { - const options = await this.getOptions(optionsContext); + const options = this.getOptions(optionsContext); const results = []; while (text.length > 0) { const term = []; @@ -356,12 +418,12 @@ class Backend { } async _onApiTextParseMecab({text, optionsContext}) { - const options = await this.getOptions(optionsContext); - const results = {}; + const options = this.getOptions(optionsContext); + const results = []; const rawResults = await this.mecab.parseText(text); - for (const mecabName in rawResults) { + for (const [mecabName, parsedLines] of Object.entries(rawResults)) { const result = []; - for (const parsedLine of rawResults[mecabName]) { + for (const parsedLine of parsedLines) { for (const {expression, reading, source} of parsedLine) { const term = []; if (expression !== null && reading !== null) { @@ -381,17 +443,17 @@ class Backend { } result.push([{text: '\n'}]); } - results[mecabName] = result; + results.push([mecabName, result]); } return results; } async _onApiDefinitionAdd({definition, mode, context, optionsContext}) { - const options = await this.getOptions(optionsContext); - const templates = Backend._getTemplates(options); + const options = this.getOptions(optionsContext); + const templates = this.defaultAnkiFieldTemplates; if (mode !== 'kanji') { - await audioInject( + await this._audioInject( definition, options.anki.terms.fields, options.audio.sources, @@ -407,20 +469,20 @@ class Backend { ); } - const note = await dictNoteFormat(definition, mode, options, templates); + const note = await this.ankiNoteBuilder.createNote(definition, mode, options, templates); return this.anki.addNote(note); } async _onApiDefinitionsAddable({definitions, modes, optionsContext}) { - const options = await this.getOptions(optionsContext); - const templates = Backend._getTemplates(options); + const options = this.getOptions(optionsContext); + const templates = this.defaultAnkiFieldTemplates; const states = []; try { const notes = []; for (const definition of definitions) { for (const mode of modes) { - const note = await dictNoteFormat(definition, mode, options, templates); + const note = await this.ankiNoteBuilder.createNote(definition, mode, options, templates); notes.push(note); } } @@ -459,20 +521,20 @@ class Backend { } async _onApiNoteView({noteId}) { - return this.anki.guiBrowse(`nid:${noteId}`); + return await this.anki.guiBrowse(`nid:${noteId}`); } async _onApiTemplateRender({template, data}) { - return handlebarsRenderDynamic(template, data); + return this._renderTemplate(template, data); } async _onApiCommandExec({command, params}) { return this._runCommand(command, params); } - async _onApiAudioGetUrl({definition, source, optionsContext}) { - const options = await this.getOptions(optionsContext); - return await audioGetUrl(definition, source, options); + async _onApiAudioGetUri({definition, source, optionsContext}) { + const options = this.getOptions(optionsContext); + return await this.audioUriBuilder.getUri(definition, source, options); } _onApiScreenshotGet({options}, sender) { @@ -621,12 +683,16 @@ class Backend { return this.messageToken; } + async _onApiGetDefaultAnkiFieldTemplates() { + return this.defaultAnkiFieldTemplates; + } + // Command handlers async _onCommandSearch(params) { const {mode='existingOrNewTab', query} = params || {}; - const options = await this.getOptions(this.optionsContext); + const options = this.getOptions(this.optionsContext); const {popupWidth, popupHeight} = options.general; const baseUrl = chrome.runtime.getURL('/bg/search.html'); @@ -647,7 +713,7 @@ class Backend { await Backend._focusTab(tab); if (queryParams.query) { await new Promise((resolve) => chrome.tabs.sendMessage( - tab.id, {action: 'searchQueryUpdate', params: {query: queryParams.query}}, resolve + tab.id, {action: 'searchQueryUpdate', params: {text: queryParams.query}}, resolve )); } return true; @@ -693,9 +759,10 @@ class Backend { } _onCommandOptions(params) { - if (!(params && params.newTab)) { + const {mode='existingOrNewTab'} = params || {}; + if (mode === 'existingOrNewTab') { chrome.runtime.openOptionsPage(); - } else { + } else if (mode === 'newTab') { const manifest = chrome.runtime.getManifest(); const url = chrome.runtime.getURL(manifest.options_ui.page); chrome.tabs.create({url}); @@ -709,17 +776,56 @@ class Backend { }; const source = 'popup'; - const options = await this.getOptions(optionsContext); + const options = this.getOptions(optionsContext); options.general.enable = !options.general.enable; await this._onApiOptionsSave({source}); } // Utilities + async _getAudioUri(definition, source, details) { + let optionsContext = (typeof details === 'object' && details !== null ? details.optionsContext : null); + if (!(typeof optionsContext === 'object' && optionsContext !== null)) { + optionsContext = this.optionsContext; + } + + const options = this.getOptions(optionsContext); + return await this.audioUriBuilder.getUri(definition, source, options); + } + + async _audioInject(definition, fields, sources, optionsContext) { + let usesAudio = false; + for (const fieldValue of Object.values(fields)) { + if (fieldValue.includes('{audio}')) { + usesAudio = true; + break; + } + } + + if (!usesAudio) { + return true; + } + + try { + const expressions = definition.expressions; + const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition; + + const {uri} = await this.audioSystem.getDefinitionAudio(audioSourceDefinition, sources, {tts: false, optionsContext}); + const filename = this._createInjectedAudioFileName(audioSourceDefinition); + if (filename !== null) { + definition.audio = {url: uri, filename}; + } + + return true; + } catch (e) { + return false; + } + } + async _injectScreenshot(definition, fields, screenshot) { let usesScreenshot = false; - for (const name in fields) { - if (fields[name].includes('{screenshot}')) { + for (const fieldValue of Object.values(fields)) { + if (fieldValue.includes('{screenshot}')) { usesScreenshot = true; break; } @@ -752,6 +858,21 @@ class Backend { definition.screenshotFileName = filename; } + async _renderTemplate(template, data) { + return handlebarsRenderDynamic(template, data); + } + + _createInjectedAudioFileName(definition) { + const {reading, expression} = definition; + if (!reading && !expression) { return null; } + + let filename = 'yomichan'; + if (reading) { filename += `_${reading}`; } + if (expression) { filename += `_${expression}`; } + filename += '.mp3'; + return filename; + } + static _getTabUrl(tab) { return new Promise((resolve) => { chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => { @@ -860,47 +981,7 @@ class Backend { return 'chrome'; } } - - static _getTemplates(options) { - const templates = options.anki.fieldTemplates; - return typeof templates === 'string' ? templates : profileOptionsGetDefaultFieldTemplates(); - } } -Backend._messageHandlers = new Map([ - ['optionsSchemaGet', (self, ...args) => self._onApiOptionsSchemaGet(...args)], - ['optionsGet', (self, ...args) => self._onApiOptionsGet(...args)], - ['optionsGetFull', (self, ...args) => self._onApiOptionsGetFull(...args)], - ['optionsSet', (self, ...args) => self._onApiOptionsSet(...args)], - ['optionsSave', (self, ...args) => self._onApiOptionsSave(...args)], - ['kanjiFind', (self, ...args) => self._onApiKanjiFind(...args)], - ['termsFind', (self, ...args) => self._onApiTermsFind(...args)], - ['textParse', (self, ...args) => self._onApiTextParse(...args)], - ['textParseMecab', (self, ...args) => self._onApiTextParseMecab(...args)], - ['definitionAdd', (self, ...args) => self._onApiDefinitionAdd(...args)], - ['definitionsAddable', (self, ...args) => self._onApiDefinitionsAddable(...args)], - ['noteView', (self, ...args) => self._onApiNoteView(...args)], - ['templateRender', (self, ...args) => self._onApiTemplateRender(...args)], - ['commandExec', (self, ...args) => self._onApiCommandExec(...args)], - ['audioGetUrl', (self, ...args) => self._onApiAudioGetUrl(...args)], - ['screenshotGet', (self, ...args) => self._onApiScreenshotGet(...args)], - ['forward', (self, ...args) => self._onApiForward(...args)], - ['frameInformationGet', (self, ...args) => self._onApiFrameInformationGet(...args)], - ['injectStylesheet', (self, ...args) => self._onApiInjectStylesheet(...args)], - ['getEnvironmentInfo', (self, ...args) => self._onApiGetEnvironmentInfo(...args)], - ['clipboardGet', (self, ...args) => self._onApiClipboardGet(...args)], - ['getDisplayTemplatesHtml', (self, ...args) => self._onApiGetDisplayTemplatesHtml(...args)], - ['getQueryParserTemplatesHtml', (self, ...args) => self._onApiGetQueryParserTemplatesHtml(...args)], - ['getZoom', (self, ...args) => self._onApiGetZoom(...args)], - ['getMessageToken', (self, ...args) => self._onApiGetMessageToken(...args)] -]); - -Backend._commandHandlers = new Map([ - ['search', (self, ...args) => self._onCommandSearch(...args)], - ['help', (self, ...args) => self._onCommandHelp(...args)], - ['options', (self, ...args) => self._onCommandOptions(...args)], - ['toggle', (self, ...args) => self._onCommandToggle(...args)] -]); - window.yomichanBackend = new Backend(); window.yomichanBackend.prepare(); diff --git a/ext/bg/js/clipboard-monitor.js b/ext/bg/js/clipboard-monitor.js index c2f41385..9a881f57 100644 --- a/ext/bg/js/clipboard-monitor.js +++ b/ext/bg/js/clipboard-monitor.js @@ -16,66 +16,66 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global apiClipboardGet, jpIsStringPartiallyJapanese*/ - -class ClipboardMonitor { - constructor() { - this.timerId = null; - this.timerToken = null; - this.interval = 250; - this.previousText = null; - } +/* global + * jpIsStringPartiallyJapanese + */ - onClipboardText(_text) { - throw new Error('Override me'); +class ClipboardMonitor extends EventDispatcher { + constructor({getClipboard}) { + super(); + this._timerId = null; + this._timerToken = null; + this._interval = 250; + this._previousText = null; + this._getClipboard = getClipboard; } start() { this.stop(); // The token below is used as a unique identifier to ensure that a new clipboard monitor - // hasn't been started during the await call. The check below the await apiClipboardGet() + // hasn't been started during the await call. The check below the await this._getClipboard() // call will exit early if the reference has changed. const token = {}; const intervalCallback = async () => { - this.timerId = null; + this._timerId = null; let text = null; try { - text = await apiClipboardGet(); + text = await this._getClipboard(); } catch (e) { // NOP } - if (this.timerToken !== token) { return; } + if (this._timerToken !== token) { return; } if ( typeof text === 'string' && (text = text.trim()).length > 0 && - text !== this.previousText + text !== this._previousText ) { - this.previousText = text; + this._previousText = text; if (jpIsStringPartiallyJapanese(text)) { - this.onClipboardText(text); + this.trigger('change', {text}); } } - this.timerId = setTimeout(intervalCallback, this.interval); + this._timerId = setTimeout(intervalCallback, this._interval); }; - this.timerToken = token; + this._timerToken = token; intervalCallback(); } stop() { - this.timerToken = null; - if (this.timerId !== null) { - clearTimeout(this.timerId); - this.timerId = null; + this._timerToken = null; + if (this._timerId !== null) { + clearTimeout(this._timerId); + this._timerId = null; } } setPreviousText(text) { - this.previousText = text; + this._previousText = text; } } diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js index bec964fb..c3e74656 100644 --- a/ext/bg/js/context.js +++ b/ext/bg/js/context.js @@ -16,7 +16,11 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global apiCommandExec, apiGetEnvironmentInfo, apiOptionsGet*/ +/* global + * apiCommandExec + * apiGetEnvironmentInfo + * apiOptionsGet + */ function showExtensionInfo() { const node = document.getElementById('extension-info'); @@ -48,7 +52,9 @@ function setupButtonEvents(selector, command, url) { } } -window.addEventListener('DOMContentLoaded', () => { +window.addEventListener('DOMContentLoaded', async () => { + await yomichan.prepare(); + showExtensionInfo(); apiGetEnvironmentInfo().then(({browser}) => { diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js index 558f3ceb..08a2a39f 100644 --- a/ext/bg/js/database.js +++ b/ext/bg/js/database.js @@ -16,7 +16,12 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global dictFieldSplit, requestJson, JsonSchema, JSZip*/ +/* global + * JSZip + * JsonSchema + * dictFieldSplit + * requestJson + */ class Database { constructor() { diff --git a/ext/bg/js/deinflector.js b/ext/bg/js/deinflector.js index e2ced965..d548d271 100644 --- a/ext/bg/js/deinflector.js +++ b/ext/bg/js/deinflector.js @@ -57,9 +57,9 @@ class Deinflector { static normalizeReasons(reasons) { const normalizedReasons = []; - for (const reason in reasons) { + for (const [reason, reasonInfo] of Object.entries(reasons)) { const variants = []; - for (const {kanaIn, kanaOut, rulesIn, rulesOut} of reasons[reason]) { + for (const {kanaIn, kanaOut, rulesIn, rulesOut} of reasonInfo) { variants.push([ kanaIn, kanaOut, diff --git a/ext/bg/js/dictionary.js b/ext/bg/js/dictionary.js index f5c5b21b..3dd1d0c1 100644 --- a/ext/bg/js/dictionary.js +++ b/ext/bg/js/dictionary.js @@ -16,26 +16,18 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global apiTemplateRender*/ - function dictEnabledSet(options) { const enabledDictionaryMap = new Map(); - const optionsDictionaries = options.dictionaries; - for (const title in optionsDictionaries) { - if (!hasOwn(optionsDictionaries, title)) { continue; } - const dictionary = optionsDictionaries[title]; - if (!dictionary.enabled) { continue; } - enabledDictionaryMap.set(title, { - priority: dictionary.priority || 0, - allowSecondarySearches: !!dictionary.allowSecondarySearches - }); + for (const [title, {enabled, priority, allowSecondarySearches}] of Object.entries(options.dictionaries)) { + if (!enabled) { continue; } + enabledDictionaryMap.set(title, {priority, allowSecondarySearches}); } return enabledDictionaryMap; } function dictConfigured(options) { - for (const title in options.dictionaries) { - if (options.dictionaries[title].enabled) { + for (const {enabled} of Object.values(options.dictionaries)) { + if (enabled) { return true; } } @@ -339,90 +331,3 @@ function dictTagsSort(tags) { function dictFieldSplit(field) { return field.length === 0 ? [] : field.split(' '); } - -async function dictFieldFormat(field, definition, mode, options, templates, exceptions) { - const data = { - marker: null, - definition, - group: options.general.resultOutputMode === 'group', - merge: options.general.resultOutputMode === 'merge', - modeTermKanji: mode === 'term-kanji', - modeTermKana: mode === 'term-kana', - modeKanji: mode === 'kanji', - compactGlossaries: options.general.compactGlossaries - }; - const markers = dictFieldFormat.markers; - const pattern = /\{([\w-]+)\}/g; - return await stringReplaceAsync(field, pattern, async (g0, marker) => { - if (!markers.has(marker)) { - return g0; - } - data.marker = marker; - try { - return await apiTemplateRender(templates, data); - } catch (e) { - if (exceptions) { exceptions.push(e); } - return `{${marker}-render-error}`; - } - }); -} -dictFieldFormat.markers = new Set([ - 'audio', - 'character', - 'cloze-body', - 'cloze-prefix', - 'cloze-suffix', - 'dictionary', - 'expression', - 'furigana', - 'furigana-plain', - 'glossary', - 'glossary-brief', - 'kunyomi', - 'onyomi', - 'reading', - 'screenshot', - 'sentence', - 'tags', - 'url' -]); - -async function dictNoteFormat(definition, mode, options, templates) { - const note = {fields: {}, tags: options.anki.tags}; - let fields = []; - - if (mode === 'kanji') { - fields = options.anki.kanji.fields; - note.deckName = options.anki.kanji.deck; - note.modelName = options.anki.kanji.model; - } else { - fields = options.anki.terms.fields; - note.deckName = options.anki.terms.deck; - note.modelName = options.anki.terms.model; - - if (definition.audio) { - const audio = { - url: definition.audio.url, - filename: definition.audio.filename, - skipHash: '7e2c2f954ef6051373ba916f000168dc', - fields: [] - }; - - for (const name in fields) { - if (fields[name].includes('{audio}')) { - audio.fields.push(name); - } - } - - if (audio.fields.length > 0) { - note.audio = audio; - } - } - } - - for (const name in fields) { - note.fields[name] = await dictFieldFormat(fields[name], definition, mode, options, templates); - } - - return note; -} diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js index b1443447..e3ce6bd0 100644 --- a/ext/bg/js/handlebars.js +++ b/ext/bg/js/handlebars.js @@ -16,7 +16,11 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global jpIsCharCodeKanji, jpDistributeFurigana, Handlebars*/ +/* global + * Handlebars + * jpDistributeFurigana + * jpIsCodePointKanji + */ function handlebarsEscape(text) { return Handlebars.Utils.escapeExpression(text); @@ -62,7 +66,7 @@ function handlebarsFuriganaPlain(options) { function handlebarsKanjiLinks(options) { let result = ''; for (const c of options.fn(this)) { - if (jpIsCharCodeKanji(c.charCodeAt(0))) { + if (jpIsCodePointKanji(c.codePointAt(0))) { result += `<a href="#" class="kanji-link">${c}</a>`; } else { result += c; diff --git a/ext/bg/js/japanese.js b/ext/bg/js/japanese.js index abb32da4..3b37754d 100644 --- a/ext/bg/js/japanese.js +++ b/ext/bg/js/japanese.js @@ -16,7 +16,9 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global wanakana*/ +/* global + * wanakana + */ const JP_HALFWIDTH_KATAKANA_MAPPING = new Map([ ['ヲ', 'ヲヺ-'], @@ -115,9 +117,9 @@ const JP_JAPANESE_RANGES = [ // Helper functions -function _jpIsCharCodeInRanges(charCode, ranges) { +function _jpIsCodePointInRanges(codePoint, ranges) { for (const [min, max] of ranges) { - if (charCode >= min && charCode <= max) { + if (codePoint >= min && codePoint <= max) { return true; } } @@ -127,16 +129,16 @@ function _jpIsCharCodeInRanges(charCode, ranges) { // Character code testing functions -function jpIsCharCodeKanji(charCode) { - return _jpIsCharCodeInRanges(charCode, JP_CJK_RANGES); +function jpIsCodePointKanji(codePoint) { + return _jpIsCodePointInRanges(codePoint, JP_CJK_RANGES); } -function jpIsCharCodeKana(charCode) { - return _jpIsCharCodeInRanges(charCode, JP_KANA_RANGES); +function jpIsCodePointKana(codePoint) { + return _jpIsCodePointInRanges(codePoint, JP_KANA_RANGES); } -function jpIsCharCodeJapanese(charCode) { - return _jpIsCharCodeInRanges(charCode, JP_JAPANESE_RANGES); +function jpIsCodePointJapanese(codePoint) { + return _jpIsCodePointInRanges(codePoint, JP_JAPANESE_RANGES); } @@ -144,8 +146,8 @@ function jpIsCharCodeJapanese(charCode) { function jpIsStringEntirelyKana(str) { if (str.length === 0) { return false; } - for (let i = 0, ii = str.length; i < ii; ++i) { - if (!jpIsCharCodeKana(str.charCodeAt(i))) { + for (const c of str) { + if (!jpIsCodePointKana(c.codePointAt(0))) { return false; } } @@ -154,8 +156,8 @@ function jpIsStringEntirelyKana(str) { function jpIsStringPartiallyJapanese(str) { if (str.length === 0) { return false; } - for (let i = 0, ii = str.length; i < ii; ++i) { - if (jpIsCharCodeJapanese(str.charCodeAt(i))) { + for (const c of str) { + if (jpIsCodePointJapanese(c.codePointAt(0))) { return true; } } @@ -264,8 +266,8 @@ function jpDistributeFurigana(expression, reading) { const groups = []; let modePrev = null; for (const c of expression) { - const charCode = c.charCodeAt(0); - const modeCurr = jpIsCharCodeKanji(charCode) || charCode === JP_ITERATION_MARK_CHAR_CODE ? 'kanji' : 'kana'; + const codePoint = c.codePointAt(0); + const modeCurr = jpIsCodePointKanji(codePoint) || codePoint === JP_ITERATION_MARK_CHAR_CODE ? 'kanji' : 'kana'; if (modeCurr === modePrev) { groups[groups.length - 1].text += c; } else { @@ -311,10 +313,11 @@ function jpDistributeFuriganaInflected(expression, reading, source) { function jpConvertHalfWidthKanaToFullWidth(text, sourceMapping) { let result = ''; - const ii = text.length; const hasSourceMapping = Array.isArray(sourceMapping); - for (let i = 0; i < ii; ++i) { + // This function is safe to use charCodeAt instead of codePointAt, since all + // the relevant characters are represented with a single UTF-16 character code. + for (let i = 0, ii = text.length; i < ii; ++i) { const c = text[i]; const mapping = JP_HALFWIDTH_KATAKANA_MAPPING.get(c); if (typeof mapping !== 'string') { @@ -355,13 +358,13 @@ function jpConvertHalfWidthKanaToFullWidth(text, sourceMapping) { function jpConvertNumericTofullWidth(text) { let result = ''; - for (let i = 0, ii = text.length; i < ii; ++i) { - let c = text.charCodeAt(i); + for (const char of text) { + let c = char.codePointAt(0); if (c >= 0x30 && c <= 0x39) { // ['0', '9'] c += 0xff10 - 0x30; // 0xff10 = '0' full width - result += String.fromCharCode(c); + result += String.fromCodePoint(c); } else { - result += text[i]; + result += char; } } return result; @@ -377,9 +380,9 @@ function jpConvertAlphabeticToKana(text, sourceMapping) { sourceMapping.fill(1); } - for (let i = 0; i < ii; ++i) { + for (const char of text) { // Note: 0x61 is the character code for 'a' - let c = text.charCodeAt(i); + let c = char.codePointAt(0); if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z'] c += (0x61 - 0x41); } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z'] @@ -395,10 +398,10 @@ function jpConvertAlphabeticToKana(text, sourceMapping) { result += jpToHiragana(part, sourceMapping, result.length); part = ''; } - result += text[i]; + result += char; continue; } - part += String.fromCharCode(c); + part += String.fromCodePoint(c); } if (part.length > 0) { diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index f9db99a2..bd0bbe0e 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -16,7 +16,9 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global utilStringHashCode*/ +/* global + * utilStringHashCode + */ /* * Generic options functions @@ -58,22 +60,17 @@ const profileOptionsVersionUpdates = [ options.scanning.modifier = options.scanning.requireShift ? 'shift' : 'none'; }, (options) => { - const fieldTemplatesDefault = profileOptionsGetDefaultFieldTemplates(); options.general.resultOutputMode = options.general.groupResults ? 'group' : 'split'; - options.anki.fieldTemplates = ( - (utilStringHashCode(options.anki.fieldTemplates) !== -805327496) ? - `{{#if merge}}${fieldTemplatesDefault}{{else}}${options.anki.fieldTemplates}{{/if}}` : - fieldTemplatesDefault - ); + options.anki.fieldTemplates = null; }, (options) => { if (utilStringHashCode(options.anki.fieldTemplates) === 1285806040) { - options.anki.fieldTemplates = profileOptionsGetDefaultFieldTemplates(); + options.anki.fieldTemplates = null; } }, (options) => { if (utilStringHashCode(options.anki.fieldTemplates) === -250091611) { - options.anki.fieldTemplates = profileOptionsGetDefaultFieldTemplates(); + options.anki.fieldTemplates = null; } }, (options) => { @@ -97,172 +94,6 @@ const profileOptionsVersionUpdates = [ } ]; -function profileOptionsGetDefaultFieldTemplates() { - return ` -{{#*inline "glossary-single"}} - {{~#unless brief~}} - {{~#if definitionTags~}}<i>({{#each definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each}})</i> {{/if~}} - {{~#if only~}}({{#each only}}{{{.}}}{{#unless @last}}, {{/unless}}{{/each}} only) {{/if~}} - {{~/unless~}} - {{~#if glossary.[1]~}} - {{~#if compactGlossaries~}} - {{#each glossary}}{{#multiLine}}{{.}}{{/multiLine}}{{#unless @last}} | {{/unless}}{{/each}} - {{~else~}} - <ul>{{#each glossary}}<li>{{#multiLine}}{{.}}{{/multiLine}}</li>{{/each}}</ul> - {{~/if~}} - {{~else~}} - {{~#multiLine}}{{glossary.[0]}}{{/multiLine~}} - {{~/if~}} -{{/inline}} - -{{#*inline "audio"}}{{/inline}} - -{{#*inline "character"}} - {{~definition.character~}} -{{/inline}} - -{{#*inline "dictionary"}} - {{~definition.dictionary~}} -{{/inline}} - -{{#*inline "expression"}} - {{~#if merge~}} - {{~#if modeTermKana~}} - {{~#each definition.reading~}} - {{{.}}} - {{~#unless @last}}、{{/unless~}} - {{~else~}} - {{~#each definition.expression~}} - {{{.}}} - {{~#unless @last}}、{{/unless~}} - {{~/each~}} - {{~/each~}} - {{~else~}} - {{~#each definition.expression~}} - {{{.}}} - {{~#unless @last}}、{{/unless~}} - {{~/each~}} - {{~/if~}} - {{~else~}} - {{~#if modeTermKana~}} - {{~#if definition.reading~}} - {{definition.reading}} - {{~else~}} - {{definition.expression}} - {{~/if~}} - {{~else~}} - {{definition.expression}} - {{~/if~}} - {{~/if~}} -{{/inline}} - -{{#*inline "furigana"}} - {{~#if merge~}} - {{~#each definition.expressions~}} - <span class="expression-{{termFrequency}}">{{~#furigana}}{{{.}}}{{/furigana~}}</span> - {{~#unless @last}}、{{/unless~}} - {{~/each~}} - {{~else~}} - {{#furigana}}{{{definition}}}{{/furigana}} - {{~/if~}} -{{/inline}} - -{{#*inline "furigana-plain"}} - {{~#if merge~}} - {{~#each definition.expressions~}} - <span class="expression-{{termFrequency}}">{{~#furiganaPlain}}{{{.}}}{{/furiganaPlain~}}</span> - {{~#unless @last}}、{{/unless~}} - {{~/each~}} - {{~else~}} - {{#furiganaPlain}}{{{definition}}}{{/furiganaPlain}} - {{~/if~}} -{{/inline}} - -{{#*inline "glossary"}} - <div style="text-align: left;"> - {{~#if modeKanji~}} - {{~#if definition.glossary.[1]~}} - <ol>{{#each definition.glossary}}<li>{{.}}</li>{{/each}}</ol> - {{~else~}} - {{definition.glossary.[0]}} - {{~/if~}} - {{~else~}} - {{~#if group~}} - {{~#if definition.definitions.[1]~}} - <ol>{{#each definition.definitions}}<li>{{> glossary-single brief=../brief compactGlossaries=../compactGlossaries}}</li>{{/each}}</ol> - {{~else~}} - {{~> glossary-single definition.definitions.[0] brief=brief compactGlossaries=compactGlossaries~}} - {{~/if~}} - {{~else if merge~}} - {{~#if definition.definitions.[1]~}} - <ol>{{#each definition.definitions}}<li>{{> glossary-single brief=../brief compactGlossaries=../compactGlossaries}}</li>{{/each}}</ol> - {{~else~}} - {{~> glossary-single definition.definitions.[0] brief=brief compactGlossaries=compactGlossaries~}} - {{~/if~}} - {{~else~}} - {{~> glossary-single definition brief=brief compactGlossaries=compactGlossaries~}} - {{~/if~}} - {{~/if~}} - </div> -{{/inline}} - -{{#*inline "glossary-brief"}} - {{~> glossary brief=true ~}} -{{/inline}} - -{{#*inline "kunyomi"}} - {{~#each definition.kunyomi}}{{.}}{{#unless @last}}, {{/unless}}{{/each~}} -{{/inline}} - -{{#*inline "onyomi"}} - {{~#each definition.onyomi}}{{.}}{{#unless @last}}, {{/unless}}{{/each~}} -{{/inline}} - -{{#*inline "reading"}} - {{~#unless modeTermKana~}} - {{~#if merge~}} - {{~#each definition.reading~}} - {{{.}}} - {{~#unless @last}}、{{/unless~}} - {{~/each~}} - {{~else~}} - {{~definition.reading~}} - {{~/if~}} - {{~/unless~}} -{{/inline}} - -{{#*inline "sentence"}} - {{~#if definition.cloze}}{{definition.cloze.sentence}}{{/if~}} -{{/inline}} - -{{#*inline "cloze-prefix"}} - {{~#if definition.cloze}}{{definition.cloze.prefix}}{{/if~}} -{{/inline}} - -{{#*inline "cloze-body"}} - {{~#if definition.cloze}}{{definition.cloze.body}}{{/if~}} -{{/inline}} - -{{#*inline "cloze-suffix"}} - {{~#if definition.cloze}}{{definition.cloze.suffix}}{{/if~}} -{{/inline}} - -{{#*inline "tags"}} - {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}} -{{/inline}} - -{{#*inline "url"}} - <a href="{{definition.url}}">{{definition.url}}</a> -{{/inline}} - -{{#*inline "screenshot"}} - <img src="{{definition.screenshotFileName}}" /> -{{/inline}} - -{{~> (lookup . "marker") ~}} -`.trim(); -} - function profileOptionsCreateDefaults() { return { general: { diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js index 509c4009..a470e873 100644 --- a/ext/bg/js/search-frontend.js +++ b/ext/bg/js/search-frontend.js @@ -16,9 +16,13 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global apiOptionsGet*/ +/* global + * apiOptionsGet + */ async function searchFrontendSetup() { + await yomichan.prepare(); + const optionsContext = { depth: 0, url: window.location.href diff --git a/ext/bg/js/search-query-parser-generator.js b/ext/bg/js/search-query-parser-generator.js index 1ab23a82..664858a4 100644 --- a/ext/bg/js/search-query-parser-generator.js +++ b/ext/bg/js/search-query-parser-generator.js @@ -16,7 +16,10 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global apiGetQueryParserTemplatesHtml, TemplateHandler*/ +/* global + * TemplateHandler + * apiGetQueryParserTemplatesHtml + */ class QueryParserGenerator { constructor() { diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js index 0d4aaa50..06316ce2 100644 --- a/ext/bg/js/search-query-parser.js +++ b/ext/bg/js/search-query-parser.js @@ -16,7 +16,15 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global apiTermsFind, apiOptionsSet, apiTextParse, apiTextParseMecab, TextScanner, QueryParserGenerator*/ +/* global + * QueryParserGenerator + * TextScanner + * apiOptionsSet + * apiTermsFind + * apiTextParse + * apiTextParseMecab + * docSentenceExtract + */ class QueryParser extends TextScanner { constructor(search) { @@ -55,12 +63,14 @@ class QueryParser extends TextScanner { const {definitions, length} = await apiTermsFind(searchText, {}, this.search.getOptionsContext()); if (definitions.length === 0) { return null; } + const sentence = docSentenceExtract(textSource, this.search.options.anki.sentenceExt); + textSource.setEndOffset(length); this.search.setContent('terms', {definitions, context: { focus: false, disableHistory: cause === 'mouse', - sentence: {text: searchText, offset: 0}, + sentence, url: window.location.href }}); @@ -142,11 +152,11 @@ class QueryParser extends TextScanner { } if (this.search.options.parsing.enableMecabParser) { const mecabResults = await apiTextParseMecab(text, this.search.getOptionsContext()); - for (const mecabDictName in mecabResults) { + for (const [mecabDictName, mecabDictResults] of mecabResults) { results.push({ name: `MeCab: ${mecabDictName}`, id: `mecab-${mecabDictName}`, - parsedText: mecabResults[mecabDictName] + parsedText: mecabDictResults }); } } diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index 98e167ad..e2bdff73 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -16,7 +16,14 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global apiOptionsSet, apiTermsFind, Display, QueryParser, ClipboardMonitor*/ +/* global + * ClipboardMonitor + * Display + * QueryParser + * apiClipboardGet + * apiOptionsSet + * apiTermsFind + */ class DisplaySearch extends Display { constructor() { @@ -38,7 +45,26 @@ class DisplaySearch extends Display { this.introVisible = true; this.introAnimationTimer = null; - this.clipboardMonitor = new ClipboardMonitor(); + this.clipboardMonitor = new ClipboardMonitor({getClipboard: apiClipboardGet}); + + this._onKeyDownIgnoreKeys = new Map([ + ['ANY_MOD', new Set([ + 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageDown', 'PageUp', 'Home', 'End', + 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', + 'F11', 'F12', 'F13', 'F14', 'F15', 'F16', 'F17', 'F18', 'F19', 'F20', + 'F21', 'F22', 'F23', 'F24' + ])], + ['Control', new Set(['C', 'A', 'Z', 'Y', 'X', 'F', 'G'])], + ['Meta', new Set(['C', 'A', 'Z', 'Y', 'X', 'F', 'G'])], + ['OS', new Set()], + ['Alt', new Set()], + ['AltGraph', new Set()], + ['Shift', new Set()] + ]); + + this._runtimeMessageHandlers = new Map([ + ['searchQueryUpdate', this.onExternalSearchUpdate.bind(this)] + ]); } static create() { @@ -49,76 +75,41 @@ class DisplaySearch extends Display { async prepare() { try { - const superPromise = super.prepare(); - const queryParserPromise = this.queryParser.prepare(); - await Promise.all([superPromise, queryParserPromise]); + await super.prepare(); + await this.queryParser.prepare(); const {queryParams: {query='', mode=''}} = parseUrl(window.location.href); - if (this.search !== null) { - this.search.addEventListener('click', (e) => this.onSearch(e), false); - } - if (this.query !== null) { - document.documentElement.dataset.searchMode = mode; - this.query.addEventListener('input', () => this.onSearchInput(), false); - - if (this.wanakanaEnable !== null) { - if (this.options.general.enableWanakana === true) { - this.wanakanaEnable.checked = true; - window.wanakana.bind(this.query); - } else { - this.wanakanaEnable.checked = false; - } - this.wanakanaEnable.addEventListener('change', (e) => { - const {queryParams: {query: query2=''}} = parseUrl(window.location.href); - if (e.target.checked) { - window.wanakana.bind(this.query); - apiOptionsSet({general: {enableWanakana: true}}, this.getOptionsContext()); - } else { - window.wanakana.unbind(this.query); - apiOptionsSet({general: {enableWanakana: false}}, this.getOptionsContext()); - } - this.setQuery(query2); - this.onSearchQueryUpdated(this.query.value, false); - }); - } + document.documentElement.dataset.searchMode = mode; - this.setQuery(query); - this.onSearchQueryUpdated(this.query.value, false); + if (this.options.general.enableWanakana === true) { + this.wanakanaEnable.checked = true; + window.wanakana.bind(this.query); + } else { + this.wanakanaEnable.checked = false; } - if (this.clipboardMonitorEnable !== null && mode !== 'popup') { + + this.setQuery(query); + this.onSearchQueryUpdated(this.query.value, false); + + if (mode !== 'popup') { if (this.options.general.enableClipboardMonitor === true) { this.clipboardMonitorEnable.checked = true; this.clipboardMonitor.start(); } else { this.clipboardMonitorEnable.checked = false; } - this.clipboardMonitorEnable.addEventListener('change', (e) => { - if (e.target.checked) { - chrome.permissions.request( - {permissions: ['clipboardRead']}, - (granted) => { - if (granted) { - this.clipboardMonitor.start(); - apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext()); - } else { - e.target.checked = false; - } - } - ); - } else { - this.clipboardMonitor.stop(); - apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext()); - } - }); + this.clipboardMonitorEnable.addEventListener('change', this.onClipboardMonitorEnableChange.bind(this)); } chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); - window.addEventListener('popstate', (e) => this.onPopState(e)); - window.addEventListener('copy', (e) => this.onCopy(e)); - - this.clipboardMonitor.onClipboardText = (text) => this.onExternalSearchUpdate(text); + this.search.addEventListener('click', this.onSearch.bind(this), false); + this.query.addEventListener('input', this.onSearchInput.bind(this), false); + this.wanakanaEnable.addEventListener('change', this.onWanakanaEnableChange.bind(this)); + window.addEventListener('popstate', this.onPopState.bind(this)); + window.addEventListener('copy', this.onCopy.bind(this)); + this.clipboardMonitor.on('change', this.onExternalSearchUpdate.bind(this)); this.updateSearchButton(); } catch (e) { @@ -174,28 +165,30 @@ class DisplaySearch extends Display { } onRuntimeMessage({action, params}, sender, callback) { - const handler = DisplaySearch._runtimeMessageHandlers.get(action); + const handler = this._runtimeMessageHandlers.get(action); if (typeof handler !== 'function') { return false; } - const result = handler(this, params, sender); + const result = handler(params, sender); callback(result); return false; } onKeyDown(e) { const key = Display.getKeyFromEvent(e); - const ignoreKeys = DisplaySearch.onKeyDownIgnoreKeys; + const ignoreKeys = this._onKeyDownIgnoreKeys; - const activeModifierMap = { - 'Control': e.ctrlKey, - 'Meta': e.metaKey, - 'ANY_MOD': true - }; + const activeModifierMap = new Map([ + ['Control', e.ctrlKey], + ['Meta', e.metaKey], + ['Shift', e.shiftKey], + ['Alt', e.altKey], + ['ANY_MOD', true] + ]); let preventFocus = false; - for (const [modifier, keys] of Object.entries(ignoreKeys)) { - const modifierActive = activeModifierMap[modifier]; - if (key === modifier || (modifierActive && keys.includes(key))) { + for (const [modifier, keys] of ignoreKeys.entries()) { + const modifierActive = activeModifierMap.get(modifier); + if (key === modifier || (modifierActive && keys.has(key))) { preventFocus = true; break; } @@ -211,7 +204,7 @@ class DisplaySearch extends Display { this.clipboardMonitor.setPreviousText(document.getSelection().toString().trim()); } - onExternalSearchUpdate(text) { + onExternalSearchUpdate({text}) { this.setQuery(text); const url = new URL(window.location.href); url.searchParams.set('query', text); @@ -253,6 +246,38 @@ class DisplaySearch extends Display { } } + onWanakanaEnableChange(e) { + const {queryParams: {query=''}} = parseUrl(window.location.href); + const enableWanakana = e.target.checked; + if (enableWanakana) { + window.wanakana.bind(this.query); + } else { + window.wanakana.unbind(this.query); + } + this.setQuery(query); + this.onSearchQueryUpdated(this.query.value, false); + apiOptionsSet({general: {enableWanakana}}, this.getOptionsContext()); + } + + onClipboardMonitorEnableChange(e) { + if (e.target.checked) { + chrome.permissions.request( + {permissions: ['clipboardRead']}, + (granted) => { + if (granted) { + this.clipboardMonitor.start(); + apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext()); + } else { + e.target.checked = false; + } + } + ); + } else { + this.clipboardMonitor.stop(); + apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext()); + } + } + async updateOptions(options) { await super.updateOptions(options); this.queryParser.setOptions(this.options); @@ -346,23 +371,4 @@ class DisplaySearch extends Display { } } -DisplaySearch.onKeyDownIgnoreKeys = { - 'ANY_MOD': [ - 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageDown', 'PageUp', 'Home', 'End', - 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', - 'F11', 'F12', 'F13', 'F14', 'F15', 'F16', 'F17', 'F18', 'F19', 'F20', - 'F21', 'F22', 'F23', 'F24' - ], - 'Control': ['C', 'A', 'Z', 'Y', 'X', 'F', 'G'], - 'Meta': ['C', 'A', 'Z', 'Y', 'X', 'F', 'G'], - 'OS': [], - 'Alt': [], - 'AltGraph': [], - 'Shift': [] -}; - -DisplaySearch._runtimeMessageHandlers = new Map([ - ['searchQueryUpdate', (self, {query}) => { self.onExternalSearchUpdate(query); }] -]); - DisplaySearch.instance = DisplaySearch.create(); diff --git a/ext/bg/js/settings/anki-templates.js b/ext/bg/js/settings/anki-templates.js index 2e80e334..c5222d30 100644 --- a/ext/bg/js/settings/anki-templates.js +++ b/ext/bg/js/settings/anki-templates.js @@ -16,22 +16,33 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global getOptionsContext, getOptionsMutable, settingsSaveOptions -profileOptionsGetDefaultFieldTemplates, ankiGetFieldMarkers, ankiGetFieldMarkersHtml, dictFieldFormat -apiOptionsGet, apiTermsFind*/ +/* global + * AnkiNoteBuilder + * ankiGetFieldMarkers + * ankiGetFieldMarkersHtml + * apiGetDefaultAnkiFieldTemplates + * apiOptionsGet + * apiTemplateRender + * apiTermsFind + * getOptionsContext + * getOptionsMutable + * settingsSaveOptions + */ function onAnkiFieldTemplatesReset(e) { e.preventDefault(); $('#field-template-reset-modal').modal('show'); } -function onAnkiFieldTemplatesResetConfirm(e) { +async function onAnkiFieldTemplatesResetConfirm(e) { e.preventDefault(); $('#field-template-reset-modal').modal('hide'); + const value = await apiGetDefaultAnkiFieldTemplates(); + const element = document.querySelector('#field-templates'); - element.value = profileOptionsGetDefaultFieldTemplates(); + element.value = value; element.dispatchEvent(new Event('change')); } @@ -45,10 +56,10 @@ function ankiTemplatesInitialize() { node.addEventListener('click', onAnkiTemplateMarkerClicked, false); } - $('#field-templates').on('change', (e) => onAnkiFieldTemplatesChanged(e)); - $('#field-template-render').on('click', (e) => onAnkiTemplateRender(e)); - $('#field-templates-reset').on('click', (e) => onAnkiFieldTemplatesReset(e)); - $('#field-templates-reset-confirm').on('click', (e) => onAnkiFieldTemplatesResetConfirm(e)); + $('#field-templates').on('change', onAnkiFieldTemplatesChanged); + $('#field-template-render').on('click', onAnkiTemplateRender); + $('#field-templates-reset').on('click', onAnkiFieldTemplatesReset); + $('#field-templates-reset-confirm').on('click', onAnkiFieldTemplatesResetConfirm); ankiTemplatesUpdateValue(); } @@ -57,7 +68,7 @@ async function ankiTemplatesUpdateValue() { const optionsContext = getOptionsContext(); const options = await apiOptionsGet(optionsContext); let templates = options.anki.fieldTemplates; - if (typeof templates !== 'string') { templates = profileOptionsGetDefaultFieldTemplates(); } + if (typeof templates !== 'string') { templates = await apiGetDefaultAnkiFieldTemplates(); } $('#field-templates').val(templates); onAnkiTemplatesValidateCompile(); @@ -89,8 +100,9 @@ async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, i if (definition !== null) { const options = await apiOptionsGet(optionsContext); let templates = options.anki.fieldTemplates; - if (typeof templates !== 'string') { templates = profileOptionsGetDefaultFieldTemplates(); } - result = await dictFieldFormat(field, definition, mode, options, templates, exceptions); + if (typeof templates !== 'string') { templates = await apiGetDefaultAnkiFieldTemplates(); } + const ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: apiTemplateRender}); + result = await ankiNoteBuilder.formatField(field, definition, mode, options, templates, exceptions); } } catch (e) { exceptions.push(e); @@ -109,7 +121,7 @@ async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, i async function onAnkiFieldTemplatesChanged(e) { // Get value let templates = e.currentTarget.value; - if (templates === profileOptionsGetDefaultFieldTemplates()) { + if (templates === await apiGetDefaultAnkiFieldTemplates()) { // Default templates = null; } diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js index 4263fc51..b706cd1b 100644 --- a/ext/bg/js/settings/anki.js +++ b/ext/bg/js/settings/anki.js @@ -16,9 +16,16 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global getOptionsContext, getOptionsMutable, settingsSaveOptions -utilBackgroundIsolate, utilAnkiGetDeckNames, utilAnkiGetModelNames, utilAnkiGetModelFieldNames -onFormOptionsChanged*/ +/* global + * getOptionsContext + * getOptionsMutable + * onFormOptionsChanged + * settingsSaveOptions + * utilAnkiGetDeckNames + * utilAnkiGetModelFieldNames + * utilAnkiGetModelNames + * utilBackgroundIsolate + */ // Private @@ -154,10 +161,10 @@ async function _ankiFieldsPopulate(tabId, options) { container.appendChild(fragment); for (const node of container.querySelectorAll('.anki-field-value')) { - node.addEventListener('change', (e) => onFormOptionsChanged(e), false); + node.addEventListener('change', onFormOptionsChanged, false); } for (const node of container.querySelectorAll('.marker-link')) { - node.addEventListener('click', (e) => _onAnkiMarkerClicked(e), false); + node.addEventListener('click', _onAnkiMarkerClicked, false); } } @@ -267,7 +274,7 @@ function ankiGetFieldMarkers(type) { function ankiInitialize() { for (const node of document.querySelectorAll('#anki-terms-model,#anki-kanji-model')) { - node.addEventListener('change', (e) => _onAnkiModelChanged(e), false); + node.addEventListener('change', _onAnkiModelChanged, false); } } diff --git a/ext/bg/js/settings/audio-ui.js b/ext/bg/js/settings/audio-ui.js index 555380b4..206539a4 100644 --- a/ext/bg/js/settings/audio-ui.js +++ b/ext/bg/js/settings/audio-ui.js @@ -37,7 +37,7 @@ AudioSourceUI.Container = class Container { this.children.push(new AudioSourceUI.AudioSource(this, audioSource, this.children.length)); } - this._clickListener = () => this.onAddAudioSource(); + this._clickListener = this.onAddAudioSource.bind(this); this.addButton.addEventListener('click', this._clickListener, false); } @@ -105,8 +105,8 @@ AudioSourceUI.AudioSource = class AudioSource { this.select.value = audioSource; - this._selectChangeListener = () => this.onSelectChanged(); - this._removeClickListener = () => this.onRemoveClicked(); + this._selectChangeListener = this.onSelectChanged.bind(this); + this._removeClickListener = this.onRemoveClicked.bind(this); this.select.addEventListener('change', this._selectChangeListener, false); this.removeButton.addEventListener('click', this._removeClickListener, false); diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js index 588d9a11..38dd6349 100644 --- a/ext/bg/js/settings/audio.js +++ b/ext/bg/js/settings/audio.js @@ -16,12 +16,26 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global getOptionsContext, getOptionsMutable, settingsSaveOptions -AudioSourceUI, audioGetTextToSpeechVoice*/ +/* global + * AudioSourceUI + * AudioSystem + * apiAudioGetUri + * getOptionsContext + * getOptionsMutable + * settingsSaveOptions + */ let audioSourceUI = null; +let audioSystem = null; async function audioSettingsInitialize() { + audioSystem = new AudioSystem({ + getAudioUri: async (definition, source) => { + const optionsContext = getOptionsContext(); + return await apiAudioGetUri(definition, source, optionsContext); + } + }); + const optionsContext = getOptionsContext(); const options = await getOptionsMutable(optionsContext); audioSourceUI = new AudioSourceUI.Container( @@ -29,7 +43,7 @@ async function audioSettingsInitialize() { document.querySelector('.audio-source-list'), document.querySelector('.audio-source-add') ); - audioSourceUI.save = () => settingsSaveOptions(); + audioSourceUI.save = settingsSaveOptions; textToSpeechInitialize(); } @@ -37,11 +51,11 @@ async function audioSettingsInitialize() { function textToSpeechInitialize() { if (typeof speechSynthesis === 'undefined') { return; } - speechSynthesis.addEventListener('voiceschanged', () => updateTextToSpeechVoices(), false); + speechSynthesis.addEventListener('voiceschanged', updateTextToSpeechVoices, false); updateTextToSpeechVoices(); - document.querySelector('#text-to-speech-voice').addEventListener('change', (e) => onTextToSpeechVoiceChange(e), false); - document.querySelector('#text-to-speech-voice-test').addEventListener('click', () => textToSpeechTest(), false); + document.querySelector('#text-to-speech-voice').addEventListener('change', onTextToSpeechVoiceChange, false); + document.querySelector('#text-to-speech-voice-test').addEventListener('click', textToSpeechTest, false); } function updateTextToSpeechVoices() { @@ -100,16 +114,11 @@ function textToSpeechVoiceCompare(a, b) { function textToSpeechTest() { try { const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || ''; - const voiceURI = document.querySelector('#text-to-speech-voice').value; - const voice = audioGetTextToSpeechVoice(voiceURI); - if (voice === null) { return; } - - const utterance = new SpeechSynthesisUtterance(text); - utterance.lang = 'ja-JP'; - utterance.voice = voice; - utterance.volume = 1.0; + const voiceUri = document.querySelector('#text-to-speech-voice').value; - speechSynthesis.speak(utterance); + const audio = audioSystem.createTextToSpeechAudio({text, voiceUri}); + audio.volume = 1.0; + audio.play(); } catch (e) { // NOP } diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js index f4d622a4..21417dfb 100644 --- a/ext/bg/js/settings/backup.js +++ b/ext/bg/js/settings/backup.js @@ -16,10 +16,17 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global apiOptionsGetFull, apiGetEnvironmentInfo -utilBackend, utilIsolate, utilBackgroundIsolate, utilReadFileArrayBuffer -optionsGetDefault, optionsUpdateVersion -profileOptionsGetDefaultFieldTemplates*/ +/* global + * apiGetDefaultAnkiFieldTemplates + * apiGetEnvironmentInfo + * apiOptionsGetFull + * optionsGetDefault + * optionsUpdateVersion + * utilBackend + * utilBackgroundIsolate + * utilIsolate + * utilReadFileArrayBuffer + */ // Exporting @@ -47,8 +54,7 @@ function _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, ti async function _getSettingsExportData(date) { const optionsFull = await apiOptionsGetFull(); const environment = await apiGetEnvironmentInfo(); - - const fieldTemplatesDefault = profileOptionsGetDefaultFieldTemplates(); + const fieldTemplatesDefault = await apiGetDefaultAnkiFieldTemplates(); // Format options for (const {options} of optionsFull.profiles) { @@ -122,7 +128,7 @@ async function _onSettingsExportClick() { // Importing async function _settingsImportSetOptionsFull(optionsFull) { - return utilIsolate(await utilBackend().setFullOptions( + return utilIsolate(utilBackend().setFullOptions( utilBackgroundIsolate(optionsFull) )); } @@ -364,10 +370,10 @@ async function _onSettingsResetConfirmClick() { // Setup -window.addEventListener('DOMContentLoaded', () => { +function backupInitialize() { document.querySelector('#settings-export').addEventListener('click', _onSettingsExportClick, false); document.querySelector('#settings-import').addEventListener('click', _onSettingsImportClick, false); document.querySelector('#settings-import-file').addEventListener('change', _onSettingsImportFileChange, false); document.querySelector('#settings-reset').addEventListener('click', _onSettingsResetClick, false); document.querySelector('#settings-reset-modal-confirm').addEventListener('click', _onSettingsResetConfirmClick, false); -}, false); +} diff --git a/ext/bg/js/settings/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js index 5a271321..9d61d25e 100644 --- a/ext/bg/js/settings/conditions-ui.js +++ b/ext/bg/js/settings/conditions-ui.js @@ -16,7 +16,9 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global conditionsNormalizeOptionValue*/ +/* global + * conditionsNormalizeOptionValue + */ class ConditionsUI { static instantiateTemplate(templateSelector) { @@ -41,7 +43,7 @@ ConditionsUI.Container = class Container { this.children.push(new ConditionsUI.ConditionGroup(this, conditionGroup)); } - this.addButton.on('click', () => this.onAddConditionGroup()); + this.addButton.on('click', this.onAddConditionGroup.bind(this)); } cleanup() { @@ -127,7 +129,7 @@ ConditionsUI.ConditionGroup = class ConditionGroup { this.children.push(new ConditionsUI.Condition(this, condition)); } - this.addButton.on('click', () => this.onAddCondition()); + this.addButton.on('click', this.onAddCondition.bind(this)); } cleanup() { @@ -185,10 +187,10 @@ ConditionsUI.Condition = class Condition { this.updateOperators(); this.updateInput(); - this.input.on('change', () => this.onInputChanged()); - this.typeSelect.on('change', () => this.onConditionTypeChanged()); - this.operatorSelect.on('change', () => this.onConditionOperatorChanged()); - this.removeButton.on('click', () => this.onRemoveClicked()); + this.input.on('change', this.onInputChanged.bind(this)); + this.typeSelect.on('change', this.onConditionTypeChanged.bind(this)); + this.operatorSelect.on('change', this.onConditionOperatorChanged.bind(this)); + this.removeButton.on('click', this.onRemoveClicked.bind(this)); } cleanup() { @@ -235,10 +237,10 @@ ConditionsUI.Condition = class Condition { updateInput() { const conditionDescriptors = this.parent.parent.conditionDescriptors; const {type, operator} = this.condition; - const props = { - placeholder: '', - type: 'text' - }; + const props = new Map([ + ['placeholder', ''], + ['type', 'text'] + ]); const objects = []; if (hasOwn(conditionDescriptors, type)) { @@ -252,20 +254,20 @@ ConditionsUI.Condition = class Condition { for (const object of objects) { if (hasOwn(object, 'placeholder')) { - props.placeholder = object.placeholder; + props.set('placeholder', object.placeholder); } if (object.type === 'number') { - props.type = 'number'; + props.set('type', 'number'); for (const prop of ['step', 'min', 'max']) { if (hasOwn(object, prop)) { - props[prop] = object[prop]; + props.set(prop, object[prop]); } } } } - for (const prop in props) { - this.input.prop(prop, props[prop]); + for (const [prop, value] of props.entries()) { + this.input.prop(prop, value); } const {valid} = this.validateValue(this.condition.value); diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js index 70a22a16..5e59cc3d 100644 --- a/ext/bg/js/settings/dictionaries.js +++ b/ext/bg/js/settings/dictionaries.js @@ -16,11 +16,23 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global getOptionsContext, getOptionsMutable, getOptionsFullMutable, settingsSaveOptions, apiOptionsGetFull, apiOptionsGet -utilBackgroundIsolate, utilDatabaseDeleteDictionary, utilDatabaseGetDictionaryInfo, utilDatabaseGetDictionaryCounts -utilDatabasePurge, utilDatabaseImport -storageUpdateStats, storageEstimate -PageExitPrevention*/ +/* global + * PageExitPrevention + * apiOptionsGet + * apiOptionsGetFull + * getOptionsContext + * getOptionsFullMutable + * getOptionsMutable + * settingsSaveOptions + * storageEstimate + * storageUpdateStats + * utilBackgroundIsolate + * utilDatabaseDeleteDictionary + * utilDatabaseGetDictionaryCounts + * utilDatabaseGetDictionaryInfo + * utilDatabaseImport + * utilDatabasePurge + */ let dictionaryUI = null; @@ -36,7 +48,7 @@ class SettingsDictionaryListUI { this.dictionaryEntries = []; this.extra = null; - document.querySelector('#dict-delete-confirm').addEventListener('click', (e) => this.onDictionaryConfirmDelete(e), false); + document.querySelector('#dict-delete-confirm').addEventListener('click', this.onDictionaryConfirmDelete.bind(this), false); } setOptionsDictionaries(optionsDictionaries) { @@ -198,10 +210,10 @@ class SettingsDictionaryEntryUI { this.applyValues(); - this.eventListeners.addEventListener(this.enabledCheckbox, 'change', (e) => this.onEnabledChanged(e), false); - this.eventListeners.addEventListener(this.allowSecondarySearchesCheckbox, 'change', (e) => this.onAllowSecondarySearchesChanged(e), false); - this.eventListeners.addEventListener(this.priorityInput, 'change', (e) => this.onPriorityChanged(e), false); - this.eventListeners.addEventListener(this.deleteButton, 'click', (e) => this.onDeleteButtonClicked(e), false); + this.eventListeners.addEventListener(this.enabledCheckbox, 'change', this.onEnabledChanged.bind(this), false); + this.eventListeners.addEventListener(this.allowSecondarySearchesCheckbox, 'change', this.onAllowSecondarySearchesChanged.bind(this), false); + this.eventListeners.addEventListener(this.priorityInput, 'change', this.onPriorityChanged.bind(this), false); + this.eventListeners.addEventListener(this.deleteButton, 'click', this.onDeleteButtonClicked.bind(this), false); } cleanup() { @@ -341,14 +353,14 @@ async function dictSettingsInitialize() { document.querySelector('#dict-groups-extra'), document.querySelector('#dict-extra-template') ); - dictionaryUI.save = () => settingsSaveOptions(); - - document.querySelector('#dict-purge-button').addEventListener('click', (e) => onDictionaryPurgeButtonClick(e), false); - document.querySelector('#dict-purge-confirm').addEventListener('click', (e) => onDictionaryPurge(e), false); - document.querySelector('#dict-file-button').addEventListener('click', (e) => onDictionaryImportButtonClick(e), false); - document.querySelector('#dict-file').addEventListener('change', (e) => onDictionaryImport(e), false); - document.querySelector('#dict-main').addEventListener('change', (e) => onDictionaryMainChanged(e), false); - document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', (e) => onDatabaseEnablePrefixWildcardSearchesChanged(e), false); + dictionaryUI.save = settingsSaveOptions; + + document.querySelector('#dict-purge-button').addEventListener('click', onDictionaryPurgeButtonClick, false); + document.querySelector('#dict-purge-confirm').addEventListener('click', onDictionaryPurge, false); + document.querySelector('#dict-file-button').addEventListener('click', onDictionaryImportButtonClick, false); + document.querySelector('#dict-file').addEventListener('change', onDictionaryImport, false); + document.querySelector('#dict-main').addEventListener('change', onDictionaryMainChanged, false); + document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', onDatabaseEnablePrefixWildcardSearchesChanged, false); await onDictionaryOptionsChanged(); await onDatabaseUpdated(); diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js index d1ad2c6b..ebc443df 100644 --- a/ext/bg/js/settings/main.js +++ b/ext/bg/js/settings/main.js @@ -16,13 +16,26 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global getOptionsContext, apiOptionsSave -utilBackend, utilIsolate, utilBackgroundIsolate -ankiErrorShown, ankiFieldsToDict -ankiTemplatesUpdateValue, onAnkiOptionsChanged, onDictionaryOptionsChanged -appearanceInitialize, audioSettingsInitialize, profileOptionsSetup, dictSettingsInitialize -ankiInitialize, ankiTemplatesInitialize, storageInfoInitialize -*/ +/* global + * ankiErrorShown + * ankiFieldsToDict + * ankiInitialize + * ankiTemplatesInitialize + * ankiTemplatesUpdateValue + * apiOptionsSave + * appearanceInitialize + * audioSettingsInitialize + * backupInitialize + * dictSettingsInitialize + * getOptionsContext + * onAnkiOptionsChanged + * onDictionaryOptionsChanged + * profileOptionsSetup + * storageInfoInitialize + * utilBackend + * utilBackgroundIsolate + * utilIsolate + */ function getOptionsMutable(optionsContext) { return utilBackend().getOptions( @@ -200,7 +213,7 @@ async function formWrite(options) { } function formSetupEventListeners() { - $('input, select, textarea').not('.anki-model').not('.ignore-form-changes *').change((e) => onFormOptionsChanged(e)); + $('input, select, textarea').not('.anki-model').not('.ignore-form-changes *').change(onFormOptionsChanged); } function formUpdateVisibility(options) { @@ -262,6 +275,8 @@ function showExtensionInformation() { async function onReady() { + await yomichan.prepare(); + showExtensionInformation(); formSetupEventListeners(); @@ -271,6 +286,7 @@ async function onReady() { await dictSettingsInitialize(); ankiInitialize(); ankiTemplatesInitialize(); + backupInitialize(); storageInfoInitialize(); diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js index aa2b6100..6a149841 100644 --- a/ext/bg/js/settings/popup-preview-frame.js +++ b/ext/bg/js/settings/popup-preview-frame.js @@ -16,7 +16,13 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global apiOptionsGet, Popup, PopupProxyHost, Frontend, TextSourceRange*/ +/* global + * Frontend + * Popup + * PopupProxyHost + * TextSourceRange + * apiOptionsGet + */ class SettingsPopupPreview { constructor() { @@ -28,6 +34,12 @@ class SettingsPopupPreview { this.themeChangeTimeout = null; this.textSource = null; this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); + + this._windowMessageHandlers = new Map([ + ['setText', ({text}) => this.setText(text)], + ['setCustomCss', ({css}) => this.setCustomCss(css)], + ['setCustomOuterCss', ({css}) => this.setCustomOuterCss(css)] + ]); } static create() { @@ -38,15 +50,12 @@ class SettingsPopupPreview { async prepare() { // Setup events - window.addEventListener('message', (e) => this.onMessage(e), false); + window.addEventListener('message', this.onMessage.bind(this), false); - const themeDarkCheckbox = document.querySelector('#theme-dark-checkbox'); - if (themeDarkCheckbox !== null) { - themeDarkCheckbox.addEventListener('change', () => this.onThemeDarkCheckboxChanged(themeDarkCheckbox), false); - } + document.querySelector('#theme-dark-checkbox').addEventListener('change', this.onThemeDarkCheckboxChanged.bind(this), false); // Overwrite API functions - window.apiOptionsGet = (...args) => this.apiOptionsGet(...args); + window.apiOptionsGet = this.apiOptionsGet.bind(this); // Overwrite frontend const popupHost = new PopupProxyHost(); @@ -56,7 +65,7 @@ class SettingsPopupPreview { this.popup.setChildrenSupported(false); this.popupSetCustomOuterCssOld = this.popup.setCustomOuterCss; - this.popup.setCustomOuterCss = (...args) => this.popupSetCustomOuterCss(...args); + this.popup.setCustomOuterCss = this.popupSetCustomOuterCss.bind(this); this.frontend = new Frontend(this.popup); @@ -101,14 +110,14 @@ class SettingsPopupPreview { if (e.origin !== this._targetOrigin) { return; } const {action, params} = e.data; - const handler = SettingsPopupPreview._messageHandlers.get(action); + const handler = this._windowMessageHandlers.get(action); if (typeof handler !== 'function') { return; } - handler(this, params); + handler(params); } - onThemeDarkCheckboxChanged(node) { - document.documentElement.classList.toggle('dark', node.checked); + onThemeDarkCheckboxChanged(e) { + document.documentElement.classList.toggle('dark', e.target.checked); if (this.themeChangeTimeout !== null) { clearTimeout(this.themeChangeTimeout); } @@ -171,12 +180,6 @@ class SettingsPopupPreview { } } -SettingsPopupPreview._messageHandlers = new Map([ - ['setText', (self, {text}) => self.setText(text)], - ['setCustomCss', (self, {css}) => self.setCustomCss(css)], - ['setCustomOuterCss', (self, {css}) => self.setCustomOuterCss(css)] -]); - SettingsPopupPreview.instance = SettingsPopupPreview.create(); diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js index 3e589809..b35b6309 100644 --- a/ext/bg/js/settings/profiles.js +++ b/ext/bg/js/settings/profiles.js @@ -16,9 +16,17 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global getOptionsMutable, getOptionsFullMutable, settingsSaveOptions, apiOptionsGetFull -utilBackgroundIsolate, formWrite -conditionsClearCaches, ConditionsUI, profileConditionsDescriptor*/ +/* global + * ConditionsUI + * apiOptionsGetFull + * conditionsClearCaches + * formWrite + * getOptionsFullMutable + * getOptionsMutable + * profileConditionsDescriptor + * settingsSaveOptions + * utilBackgroundIsolate + */ let currentProfileIndex = 0; let profileConditionsContainer = null; @@ -39,16 +47,16 @@ async function profileOptionsSetup() { } function profileOptionsSetupEventListeners() { - $('#profile-target').change((e) => onTargetProfileChanged(e)); - $('#profile-name').change((e) => onProfileNameChanged(e)); - $('#profile-add').click((e) => onProfileAdd(e)); - $('#profile-remove').click((e) => onProfileRemove(e)); - $('#profile-remove-confirm').click((e) => onProfileRemoveConfirm(e)); - $('#profile-copy').click((e) => onProfileCopy(e)); - $('#profile-copy-confirm').click((e) => onProfileCopyConfirm(e)); + $('#profile-target').change(onTargetProfileChanged); + $('#profile-name').change(onProfileNameChanged); + $('#profile-add').click(onProfileAdd); + $('#profile-remove').click(onProfileRemove); + $('#profile-remove-confirm').click(onProfileRemoveConfirm); + $('#profile-copy').click(onProfileCopy); + $('#profile-copy-confirm').click(onProfileCopyConfirm); $('#profile-move-up').click(() => onProfileMove(-1)); $('#profile-move-down').click(() => onProfileMove(1)); - $('.profile-form').find('input, select, textarea').not('.profile-form-manual').change((e) => onProfileOptionsChanged(e)); + $('.profile-form').find('input, select, textarea').not('.profile-form-manual').change(onProfileOptionsChanged); } function tryGetIntegerValue(selector, min, max) { diff --git a/ext/bg/js/settings/storage.js b/ext/bg/js/settings/storage.js index cbe1bb4d..ae305e22 100644 --- a/ext/bg/js/settings/storage.js +++ b/ext/bg/js/settings/storage.js @@ -16,7 +16,9 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global apiGetEnvironmentInfo*/ +/* global + * apiGetEnvironmentInfo + */ function storageBytesToLabeledString(size) { const base = 1000; @@ -57,7 +59,7 @@ async function storageInfoInitialize() { await storageShowInfo(); - document.querySelector('#storage-refresh').addEventListener('click', () => storageShowInfo(), false); + document.querySelector('#storage-refresh').addEventListener('click', storageShowInfo, false); } async function storageUpdateStats() { diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js index a675a9f7..25da9bf0 100644 --- a/ext/bg/js/translator.js +++ b/ext/bg/js/translator.js @@ -16,12 +16,28 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/*global requestJson -dictTermsMergeBySequence, dictTagBuildSource, dictTermsMergeByGloss, dictTermsSort, dictTagsSort -dictEnabledSet, dictTermsGroup, dictTermsCompressTags, dictTermsUndupe, dictTagSanitize -jpDistributeFurigana, jpConvertHalfWidthKanaToFullWidth, jpConvertNumericTofullWidth -jpConvertAlphabeticToKana, jpHiraganaToKatakana, jpKatakanaToHiragana, jpIsCharCodeJapanese -Database, Deinflector*/ +/* global + * Database + * Deinflector + * dictEnabledSet + * dictTagBuildSource + * dictTagSanitize + * dictTagsSort + * dictTermsCompressTags + * dictTermsGroup + * dictTermsMergeByGloss + * dictTermsMergeBySequence + * dictTermsSort + * dictTermsUndupe + * jpConvertAlphabeticToKana + * jpConvertHalfWidthKanaToFullWidth + * jpConvertNumericTofullWidth + * jpDistributeFurigana + * jpHiraganaToKatakana + * jpIsCodePointJapanese + * jpKatakanaToHiragana + * requestJson + */ class Translator { constructor() { @@ -199,8 +215,19 @@ class Translator { const strayDefinitions = defaultDefinitions.filter((definition, index) => !mergedByTermIndices.has(index)); for (const groupedDefinition of dictTermsGroup(strayDefinitions, dictionaries)) { - groupedDefinition.expressions = [Translator.createExpression(groupedDefinition.expression, groupedDefinition.reading)]; - definitionsMerged.push(groupedDefinition); + // from dictTermsMergeBySequence + const {reasons, score, expression, reading, source, dictionary} = groupedDefinition; + const compatibilityDefinition = { + reasons, + score, + expression: [expression], + reading: [reading], + expressions: [Translator.createExpression(groupedDefinition.expression, groupedDefinition.reading)], + source, + dictionary, + definitions: groupedDefinition.definitions + }; + definitionsMerged.push(compatibilityDefinition); } await this.buildTermMeta(definitionsMerged, dictionaries); @@ -610,13 +637,14 @@ class Translator { static getSearchableText(text, options) { if (!options.scanning.alphanumeric) { - const ii = text.length; - for (let i = 0; i < ii; ++i) { - if (!jpIsCharCodeJapanese(text.charCodeAt(i))) { - text = text.substring(0, i); + let newText = ''; + for (const c of text) { + if (!jpIsCodePointJapanese(c.codePointAt(0))) { break; } + newText += c; } + text = newText; } return text; diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js index 5ce4b08c..79c6af06 100644 --- a/ext/bg/js/util.js +++ b/ext/bg/js/util.js @@ -73,7 +73,11 @@ function utilStringHashCode(string) { } function utilBackend() { - return chrome.extension.getBackgroundPage().yomichanBackend; + const backend = chrome.extension.getBackgroundPage().yomichanBackend; + if (!backend.isPrepared) { + throw new Error('Backend not ready yet'); + } + return backend; } async function utilAnkiGetModelNames() { |