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(); |