diff options
Diffstat (limited to 'ext/bg/js/backend.js')
-rw-r--r-- | ext/bg/js/backend.js | 614 |
1 files changed, 578 insertions, 36 deletions
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index d9f9b586..28b0201e 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net> + * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net> * Author: Alex Yatskov <alex@foosoft.net> * * This program is free software: you can redistribute it and/or modify @@ -13,7 +13,7 @@ * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * along with this program. If not, see <https://www.gnu.org/licenses/>. */ @@ -23,6 +23,7 @@ class Backend { this.anki = new AnkiNull(); this.mecab = new Mecab(); this.options = null; + this.optionsSchema = null; this.optionsContext = { depth: 0, url: window.location.href @@ -38,11 +39,20 @@ class Backend { async prepare() { await this.translator.prepare(); + + this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET'); this.options = await optionsLoad(); + try { + this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, this.options); + } catch (e) { + // This shouldn't happen, but catch errors just in case of bugs + logError(e); + } + this.onOptionsUpdated('background'); if (chrome.commands !== null && typeof chrome.commands === 'object') { - chrome.commands.onCommand.addListener(this.onCommand.bind(this)); + chrome.commands.onCommand.addListener((command) => this._runCommand(command)); } chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); @@ -67,22 +77,21 @@ class Backend { }); } - onCommand(command) { - apiCommandExec(command); - } - onMessage({action, params}, sender, callback) { - const handlers = Backend.messageHandlers; - if (hasOwn(handlers, action)) { - const handler = handlers[action]; - const promise = handler(params, sender); + const handler = Backend._messageHandlers.get(action); + if (typeof handler !== 'function') { return false; } + + try { + const promise = handler(this, params, sender); promise.then( (result) => callback({result}), (error) => callback({error: errorToJson(error)}) ); + return true; + } catch (error) { + callback({error: errorToJson(error)}); + return false; } - - return true; } applyOptions() { @@ -106,6 +115,13 @@ class Backend { } } + async getOptionsSchema() { + if (this.isPreparedPromise !== null) { + await this.isPreparedPromise; + } + return this.optionsSchema; + } + async getFullOptions() { if (this.isPreparedPromise !== null) { await this.isPreparedPromise; @@ -113,6 +129,18 @@ class Backend { return this.options; } + async setFullOptions(options) { + if (this.isPreparedPromise !== null) { + await this.isPreparedPromise; + } + try { + this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, utilIsolate(options)); + } catch (e) { + // This shouldn't happen, but catch errors just in case of bugs + logError(e); + } + } + async getOptions(optionsContext) { if (this.isPreparedPromise !== null) { await this.isPreparedPromise; @@ -180,28 +208,542 @@ class Backend { checkLastError() { // NOP } + + _runCommand(command, params) { + const handler = Backend._commandHandlers.get(command); + if (typeof handler !== 'function') { return false; } + + handler(this, params); + return true; + } + + // Message handlers + + _onApiOptionsSchemaGet() { + return this.getOptionsSchema(); + } + + _onApiOptionsGet({optionsContext}) { + return this.getOptions(optionsContext); + } + + _onApiOptionsGetFull() { + return this.getFullOptions(); + } + + async _onApiOptionsSet({changedOptions, optionsContext, source}) { + const options = await this.getOptions(optionsContext); + + function getValuePaths(obj) { + const valuePaths = []; + const nodes = [{obj, path: []}]; + while (nodes.length > 0) { + const node = nodes.pop(); + for (const key of Object.keys(node.obj)) { + const path = node.path.concat(key); + const obj = node.obj[key]; + if (obj !== null && typeof obj === 'object') { + nodes.unshift({obj, path}); + } else { + valuePaths.push([obj, path]); + } + } + } + return valuePaths; + } + + function modifyOption(path, value, options) { + let pivot = options; + for (const key of path.slice(0, -1)) { + if (!hasOwn(pivot, key)) { + return false; + } + pivot = pivot[key]; + } + pivot[path[path.length - 1]] = value; + return true; + } + + for (const [value, path] of getValuePaths(changedOptions)) { + modifyOption(path, value, options); + } + + await this._onApiOptionsSave({source}); + } + + async _onApiOptionsSave({source}) { + const options = await this.getFullOptions(); + await optionsSave(options); + this.onOptionsUpdated(source); + } + + async _onApiKanjiFind({text, optionsContext}) { + const options = await 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 [definitions, length] = await this.translator.findTerms(text, details, options); + definitions.splice(options.general.maxResults); + return {length, definitions}; + } + + async _onApiTextParse({text, optionsContext}) { + const options = await this.getOptions(optionsContext); + const results = []; + while (text.length > 0) { + const term = []; + const [definitions, sourceLength] = await this.translator.findTermsInternal( + text.substring(0, options.scanning.length), + dictEnabledSet(options), + options.scanning.alphanumeric, + {} + ); + if (definitions.length > 0) { + dictTermsSort(definitions); + const {expression, reading} = definitions[0]; + const source = text.substring(0, sourceLength); + for (const {text, furigana} of jpDistributeFuriganaInflected(expression, reading, source)) { + const reading = jpConvertReading(text, furigana, options.parsing.readingMode); + term.push({text, reading}); + } + text = text.substring(source.length); + } else { + const reading = jpConvertReading(text[0], null, options.parsing.readingMode); + term.push({text: text[0], reading}); + text = text.substring(1); + } + results.push(term); + } + return results; + } + + async _onApiTextParseMecab({text, optionsContext}) { + const options = await this.getOptions(optionsContext); + const results = {}; + const rawResults = await this.mecab.parseText(text); + for (const mecabName in rawResults) { + const result = []; + for (const parsedLine of rawResults[mecabName]) { + for (const {expression, reading, source} of parsedLine) { + const term = []; + if (expression !== null && reading !== null) { + for (const {text, furigana} of jpDistributeFuriganaInflected( + expression, + jpKatakanaToHiragana(reading), + source + )) { + const reading = jpConvertReading(text, furigana, options.parsing.readingMode); + term.push({text, reading}); + } + } else { + const reading = jpConvertReading(source, null, options.parsing.readingMode); + term.push({text: source, reading}); + } + result.push(term); + } + result.push([{text: '\n'}]); + } + results[mecabName] = result; + } + return results; + } + + async _onApiDefinitionAdd({definition, mode, context, optionsContext}) { + const options = await this.getOptions(optionsContext); + const templates = Backend._getTemplates(options); + + if (mode !== 'kanji') { + await audioInject( + definition, + options.anki.terms.fields, + options.audio.sources, + optionsContext + ); + } + + if (context && context.screenshot) { + await this._injectScreenshot( + definition, + options.anki.terms.fields, + context.screenshot + ); + } + + const note = await dictNoteFormat(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 states = []; + + try { + const notes = []; + for (const definition of definitions) { + for (const mode of modes) { + const note = await dictNoteFormat(definition, mode, options, templates); + notes.push(note); + } + } + + const cannotAdd = []; + const results = await this.anki.canAddNotes(notes); + for (let resultBase = 0; resultBase < results.length; resultBase += modes.length) { + const state = {}; + for (let modeOffset = 0; modeOffset < modes.length; ++modeOffset) { + const index = resultBase + modeOffset; + const result = results[index]; + const info = {canAdd: result}; + state[modes[modeOffset]] = info; + if (!result) { + cannotAdd.push([notes[index], info]); + } + } + + states.push(state); + } + + if (cannotAdd.length > 0) { + const noteIdsArray = await this.anki.findNoteIds(cannotAdd.map((e) => e[0])); + for (let i = 0, ii = Math.min(cannotAdd.length, noteIdsArray.length); i < ii; ++i) { + const noteIds = noteIdsArray[i]; + if (noteIds.length > 0) { + cannotAdd[i][1].noteId = noteIds[0]; + } + } + } + } catch (e) { + // NOP + } + + return states; + } + + async _onApiNoteView({noteId}) { + return this.anki.guiBrowse(`nid:${noteId}`); + } + + async _onApiTemplateRender({template, data, dynamic}) { + return ( + dynamic ? + handlebarsRenderDynamic(template, data) : + handlebarsRenderStatic(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); + } + + _onApiScreenshotGet({options}, sender) { + if (!(sender && sender.tab)) { + return Promise.resolve(); + } + + const windowId = sender.tab.windowId; + return new Promise((resolve) => { + chrome.tabs.captureVisibleTab(windowId, options, (dataUrl) => resolve(dataUrl)); + }); + } + + _onApiForward({action, params}, sender) { + if (!(sender && sender.tab)) { + return Promise.resolve(); + } + + const tabId = sender.tab.id; + return new Promise((resolve) => { + chrome.tabs.sendMessage(tabId, {action, params}, (response) => resolve(response)); + }); + } + + _onApiFrameInformationGet(params, sender) { + const frameId = sender.frameId; + return Promise.resolve({frameId}); + } + + _onApiInjectStylesheet({css}, sender) { + if (!sender.tab) { + return Promise.reject(new Error('Invalid tab')); + } + + const tabId = sender.tab.id; + const frameId = sender.frameId; + const details = { + code: css, + runAt: 'document_start', + cssOrigin: 'user', + allFrames: false + }; + if (typeof frameId === 'number') { + details.frameId = frameId; + } + + return new Promise((resolve, reject) => { + chrome.tabs.insertCSS(tabId, details, () => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(); + } + }); + }); + } + + async _onApiGetEnvironmentInfo() { + const browser = await Backend._getBrowser(); + const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve)); + return { + browser, + platform: { + os: platform.os + } + }; + } + + async _onApiClipboardGet() { + const clipboardPasteTarget = this.clipboardPasteTarget; + clipboardPasteTarget.value = ''; + clipboardPasteTarget.focus(); + document.execCommand('paste'); + const result = clipboardPasteTarget.value; + clipboardPasteTarget.value = ''; + return result; + } + + // Command handlers + + async _onCommandSearch(params) { + const url = chrome.runtime.getURL('/bg/search.html'); + if (!(params && params.newTab)) { + try { + const tab = await Backend._findTab(1000, (url2) => ( + url2 !== null && + url2.startsWith(url) && + (url2.length === url.length || url2[url.length] === '?' || url2[url.length] === '#') + )); + if (tab !== null) { + await Backend._focusTab(tab); + return; + } + } catch (e) { + // NOP + } + } + chrome.tabs.create({url}); + } + + _onCommandHelp() { + chrome.tabs.create({url: 'https://foosoft.net/projects/yomichan/'}); + } + + _onCommandOptions(params) { + if (!(params && params.newTab)) { + chrome.runtime.openOptionsPage(); + } else { + const manifest = chrome.runtime.getManifest(); + const url = chrome.runtime.getURL(manifest.options_ui.page); + chrome.tabs.create({url}); + } + } + + async _onCommandToggle() { + const optionsContext = { + depth: 0, + url: window.location.href + }; + const source = 'popup'; + + const options = await this.getOptions(optionsContext); + options.general.enable = !options.general.enable; + await this._onApiOptionsSave({source}); + } + + // Utilities + + async _injectScreenshot(definition, fields, screenshot) { + let usesScreenshot = false; + for (const name in fields) { + if (fields[name].includes('{screenshot}')) { + usesScreenshot = true; + break; + } + } + + if (!usesScreenshot) { + return; + } + + const dateToString = (date) => { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth().toString().padStart(2, '0'); + const day = date.getUTCDate().toString().padStart(2, '0'); + const hours = date.getUTCHours().toString().padStart(2, '0'); + const minutes = date.getUTCMinutes().toString().padStart(2, '0'); + const seconds = date.getUTCSeconds().toString().padStart(2, '0'); + return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`; + }; + + const now = new Date(Date.now()); + const filename = `yomichan_browser_screenshot_${definition.reading}_${dateToString(now)}.${screenshot.format}`; + const data = screenshot.dataUrl.replace(/^data:[\w\W]*?,/, ''); + + try { + await this.anki.storeMediaFile(filename, data); + } catch (e) { + return; + } + + definition.screenshotFileName = filename; + } + + static _getTabUrl(tab) { + return new Promise((resolve) => { + chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => { + let url = null; + if (!chrome.runtime.lastError) { + url = (response !== null && typeof response === 'object' && !Array.isArray(response) ? response.url : null); + if (url !== null && typeof url !== 'string') { + url = null; + } + } + resolve({tab, url}); + }); + }); + } + + static async _findTab(timeout, checkUrl) { + // This function works around the need to have the "tabs" permission to access tab.url. + const tabs = await new Promise((resolve) => chrome.tabs.query({}, resolve)); + let matchPromiseResolve = null; + const matchPromise = new Promise((resolve) => { matchPromiseResolve = resolve; }); + + const checkTabUrl = ({tab, url}) => { + if (checkUrl(url, tab)) { + matchPromiseResolve(tab); + } + }; + + const promises = []; + for (const tab of tabs) { + const promise = Backend._getTabUrl(tab); + promise.then(checkTabUrl); + promises.push(promise); + } + + const racePromises = [ + matchPromise, + Promise.all(promises).then(() => null) + ]; + if (typeof timeout === 'number') { + racePromises.push(new Promise((resolve) => setTimeout(() => resolve(null), timeout))); + } + + return await Promise.race(racePromises); + } + + static async _focusTab(tab) { + await new Promise((resolve, reject) => { + chrome.tabs.update(tab.id, {active: true}, () => { + const e = chrome.runtime.lastError; + if (e) { reject(e); } + else { resolve(); } + }); + }); + + if (!(typeof chrome.windows === 'object' && chrome.windows !== null)) { + // Windows not supported (e.g. on Firefox mobile) + return; + } + + try { + const tabWindow = await new Promise((resolve) => { + chrome.windows.get(tab.windowId, {}, (tabWindow) => { + const e = chrome.runtime.lastError; + if (e) { reject(e); } + else { resolve(tabWindow); } + }); + }); + if (!tabWindow.focused) { + await new Promise((resolve, reject) => { + chrome.windows.update(tab.windowId, {focused: true}, () => { + const e = chrome.runtime.lastError; + if (e) { reject(e); } + else { resolve(); } + }); + }); + } + } catch (e) { + // Edge throws exception for no reason here. + } + } + + static async _getBrowser() { + if (EXTENSION_IS_BROWSER_EDGE) { + return 'edge'; + } + if (typeof browser !== 'undefined') { + try { + const info = await browser.runtime.getBrowserInfo(); + if (info.name === 'Fennec') { + return 'firefox-mobile'; + } + } catch (e) { + // NOP + } + return 'firefox'; + } else { + return 'chrome'; + } + } + + static _getTemplates(options) { + const templates = options.anki.fieldTemplates; + return typeof templates === 'string' ? templates : profileOptionsGetDefaultFieldTemplates(); + } } -Backend.messageHandlers = { - optionsGet: ({optionsContext}) => apiOptionsGet(optionsContext), - optionsSet: ({changedOptions, optionsContext, source}) => apiOptionsSet(changedOptions, optionsContext, source), - kanjiFind: ({text, optionsContext}) => apiKanjiFind(text, optionsContext), - termsFind: ({text, details, optionsContext}) => apiTermsFind(text, details, optionsContext), - textParse: ({text, optionsContext}) => apiTextParse(text, optionsContext), - textParseMecab: ({text, optionsContext}) => apiTextParseMecab(text, optionsContext), - definitionAdd: ({definition, mode, context, optionsContext}) => apiDefinitionAdd(definition, mode, context, optionsContext), - definitionsAddable: ({definitions, modes, optionsContext}) => apiDefinitionsAddable(definitions, modes, optionsContext), - noteView: ({noteId}) => apiNoteView(noteId), - templateRender: ({template, data, dynamic}) => apiTemplateRender(template, data, dynamic), - commandExec: ({command, params}) => apiCommandExec(command, params), - audioGetUrl: ({definition, source, optionsContext}) => apiAudioGetUrl(definition, source, optionsContext), - screenshotGet: ({options}, sender) => apiScreenshotGet(options, sender), - forward: ({action, params}, sender) => apiForward(action, params, sender), - frameInformationGet: (params, sender) => apiFrameInformationGet(sender), - injectStylesheet: ({css}, sender) => apiInjectStylesheet(css, sender), - getEnvironmentInfo: () => apiGetEnvironmentInfo(), - clipboardGet: () => apiClipboardGet() -}; - -window.yomichan_backend = new Backend(); -window.yomichan_backend.prepare(); +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)] +]); + +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(); |