diff options
Diffstat (limited to 'ext/bg/js')
35 files changed, 1905 insertions, 810 deletions
diff --git a/ext/bg/js/anki.js b/ext/bg/js/anki.js index 17b93620..10a07061 100644 --- a/ext/bg/js/anki.js +++ b/ext/bg/js/anki.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 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/>. */ diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js index b489b8d2..906aaa30 100644 --- a/ext/bg/js/api.js +++ b/ext/bg/js/api.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016-2017 Alex Yatskov <alex@foosoft.net> + * 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 @@ -13,491 +13,39 @@ * 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/>. */ -function apiOptionsGet(optionsContext) { - return utilBackend().getOptions(optionsContext); +function apiTemplateRender(template, data, dynamic) { + return _apiInvoke('templateRender', {data, template, dynamic}); } -async function apiOptionsSet(changedOptions, optionsContext, source) { - const options = await apiOptionsGet(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 apiOptionsSave(source); -} - -function apiOptionsGetFull() { - return utilBackend().getFullOptions(); -} - -async function apiOptionsSave(source) { - const backend = utilBackend(); - const options = await apiOptionsGetFull(); - await optionsSave(options); - backend.onOptionsUpdated(source); -} - -async function apiTermsFind(text, details, optionsContext) { - const options = await apiOptionsGet(optionsContext); - const [definitions, length] = await utilBackend().translator.findTerms(text, details, options); - definitions.splice(options.general.maxResults); - return {length, definitions}; +function apiAudioGetUrl(definition, source, optionsContext) { + return _apiInvoke('audioGetUrl', {definition, source, optionsContext}); } -async function apiTextParse(text, optionsContext) { - const options = await apiOptionsGet(optionsContext); - const translator = utilBackend().translator; - - const results = []; - while (text.length > 0) { - const term = []; - const [definitions, sourceLength] = await translator.findTermsInternal( - text.slice(0, options.scanning.length), - dictEnabledSet(options), - options.scanning.alphanumeric, - {} - ); - if (definitions.length > 0) { - dictTermsSort(definitions); - const {expression, reading} = definitions[0]; - const source = text.slice(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.slice(source.length); - } else { - const reading = jpConvertReading(text[0], null, options.parsing.readingMode); - term.push({text: text[0], reading}); - text = text.slice(1); - } - results.push(term); - } - return results; -} - -async function apiTextParseMecab(text, optionsContext) { - const options = await apiOptionsGet(optionsContext); - const mecab = utilBackend().mecab; - - const results = {}; - const rawResults = await 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}); +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 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 function apiKanjiFind(text, optionsContext) { - const options = await apiOptionsGet(optionsContext); - const definitions = await utilBackend().translator.findKanji(text, options); - definitions.splice(options.general.maxResults); - return definitions; -} - -async function apiDefinitionAdd(definition, mode, context, optionsContext) { - const options = await apiOptionsGet(optionsContext); - - if (mode !== 'kanji') { - await audioInject( - definition, - options.anki.terms.fields, - options.audio.sources, - optionsContext - ); - } - - if (context && context.screenshot) { - await apiInjectScreenshot( - definition, - options.anki.terms.fields, - context.screenshot - ); - } - - const note = await dictNoteFormat(definition, mode, options); - return utilBackend().anki.addNote(note); -} - -async function apiDefinitionsAddable(definitions, modes, optionsContext) { - const options = await apiOptionsGet(optionsContext); - const states = []; - - try { - const notes = []; - for (const definition of definitions) { - for (const mode of modes) { - const note = await dictNoteFormat(definition, mode, options); - notes.push(note); - } - } - - const cannotAdd = []; - const anki = utilBackend().anki; - const results = await 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 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]; + const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`; + reject(new Error(`${message} (${JSON.stringify(data)})`)); } - } - } - } catch (e) { - // NOP - } - - return states; -} - -async function apiNoteView(noteId) { - return utilBackend().anki.guiBrowse(`nid:${noteId}`); -} - -async function apiTemplateRender(template, data, dynamic) { - if (dynamic) { - return handlebarsRenderDynamic(template, data); - } else { - return handlebarsRenderStatic(template, data); - } -} - -async function apiCommandExec(command, params) { - const handlers = apiCommandExec.handlers; - if (hasOwn(handlers, command)) { - const handler = handlers[command]; - handler(params); - } -} -apiCommandExec.handlers = { - search: async (params) => { - const url = chrome.runtime.getURL('/bg/search.html'); - if (!(params && params.newTab)) { - try { - const tab = await apiFindTab(1000, (url2) => ( - url2 !== null && - url2.startsWith(url) && - (url2.length === url.length || url2[url.length] === '?' || url2[url.length] === '#') - )); - if (tab !== null) { - await apiFocusTab(tab); - return; - } - } catch (e) { - // NOP - } - } - chrome.tabs.create({url}); - }, - - help: () => { - chrome.tabs.create({url: 'https://foosoft.net/projects/yomichan/'}); - }, - - options: (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}); - } - }, - - toggle: async () => { - const optionsContext = { - depth: 0, - url: window.location.href - }; - const options = await apiOptionsGet(optionsContext); - options.general.enable = !options.general.enable; - await apiOptionsSave('popup'); - } -}; - -async function apiAudioGetUrl(definition, source, optionsContext) { - return audioGetUrl(definition, source, optionsContext); -} - -async function apiInjectScreenshot(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 utilBackend().anki.storeMediaFile(filename, data); - } catch (e) { - return; - } - - definition.screenshotFileName = filename; -} - -function apiScreenshotGet(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)); - }); -} - -function apiForward(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)); - }); -} - -function apiFrameInformationGet(sender) { - const frameId = sender.frameId; - return Promise.resolve({frameId}); -} - -function apiInjectStylesheet(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 function apiGetEnvironmentInfo() { - const browser = await apiGetBrowser(); - const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve)); - return { - browser, - platform: { - os: platform.os - } - }; -} - -async function apiGetBrowser() { - 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'; - } + }; + const backend = window.yomichanBackend; + backend.onMessage({action, params}, null, callback); } catch (e) { - // NOP + reject(e); + yomichan.triggerOrphaned(e); } - return 'firefox'; - } else { - return 'chrome'; - } -} - -function apiGetTabUrl(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}); - }); }); } - -async function apiFindTab(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 = apiGetTabUrl(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); -} - -async function apiFocusTab(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. - } -} - -async function apiClipboardGet() { - const clipboardPasteTarget = utilBackend().clipboardPasteTarget; - clipboardPasteTarget.innerText = ''; - clipboardPasteTarget.focus(); - document.execCommand('paste'); - return clipboardPasteTarget.innerText; -} diff --git a/ext/bg/js/audio.js b/ext/bg/js/audio.js index dc0ba5eb..36ac413b 100644 --- a/ext/bg/js/audio.js +++ b/ext/bg/js/audio.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 Alex Yatskov <alex@foosoft.net> + * Copyright (C) 2017-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,12 +13,12 @@ * 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/>. */ -const audioUrlBuilders = { - 'jpod101': async (definition) => { +const audioUrlBuilders = new Map([ + ['jpod101', async (definition) => { let kana = definition.reading; let kanji = definition.expression; @@ -36,8 +36,8 @@ const audioUrlBuilders = { } return `https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?${params.join('&')}`; - }, - 'jpod101-alternate': async (definition) => { + }], + ['jpod101-alternate', async (definition) => { const response = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'); @@ -61,8 +61,8 @@ const audioUrlBuilders = { } throw new Error('Failed to find audio URL'); - }, - 'jisho': async (definition) => { + }], + ['jisho', async (definition) => { const response = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', `https://jisho.org/search/${definition.expression}`); @@ -85,37 +85,34 @@ const audioUrlBuilders = { } throw new Error('Failed to find audio URL'); - }, - 'text-to-speech': async (definition, optionsContext) => { - const options = await apiOptionsGet(optionsContext); + }], + ['text-to-speech', async (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, optionsContext) => { - const options = await apiOptionsGet(optionsContext); + }], + ['text-to-speech-reading', async (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, optionsContext) => { - const options = await apiOptionsGet(optionsContext); + }], + ['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, optionsContext, download) { - if (hasOwn(audioUrlBuilders, mode)) { - const handler = audioUrlBuilders[mode]; +async function audioGetUrl(definition, mode, options, download) { + const handler = audioUrlBuilders.get(mode); + if (typeof handler === 'function') { try { - return await handler(definition, optionsContext, download); + return await handler(definition, options, download); } catch (e) { // NOP } diff --git a/ext/bg/js/backend-api-forwarder.js b/ext/bg/js/backend-api-forwarder.js index db4d30b9..170a6b32 100644 --- a/ext/bg/js/backend-api-forwarder.js +++ b/ext/bg/js/backend-api-forwarder.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ 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(); diff --git a/ext/bg/js/conditions.js b/ext/bg/js/conditions.js index c0f0f301..d4d1c0e0 100644 --- a/ext/bg/js/conditions.js +++ b/ext/bg/js/conditions.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js index 0b21f662..834174bf 100644 --- a/ext/bg/js/context.js +++ b/ext/bg/js/context.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 Alex Yatskov <alex@foosoft.net> + * Copyright (C) 2017-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/>. */ diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js index a20d5f15..42a143f3 100644 --- a/ext/bg/js/database.js +++ b/ext/bg/js/database.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/>. */ @@ -28,7 +28,7 @@ class Database { } try { - this.db = await Database.open('dict', 4, (db, transaction, oldVersion) => { + this.db = await Database.open('dict', 5, (db, transaction, oldVersion) => { Database.upgrade(db, transaction, oldVersion, [ { version: 2, @@ -76,6 +76,15 @@ class Database { indices: ['dictionary', 'expression', 'reading', 'sequence'] } } + }, + { + version: 5, + stores: { + terms: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse'] + } + } } ]); }); @@ -143,14 +152,17 @@ class Database { } }; + const useWildcard = !!wildcard; + const prefixWildcard = wildcard === 'prefix'; + const dbTransaction = this.db.transaction(['terms'], 'readonly'); const dbTerms = dbTransaction.objectStore('terms'); - const dbIndex1 = dbTerms.index('expression'); - const dbIndex2 = dbTerms.index('reading'); + const dbIndex1 = dbTerms.index(prefixWildcard ? 'expressionReverse' : 'expression'); + const dbIndex2 = dbTerms.index(prefixWildcard ? 'readingReverse' : 'reading'); for (let i = 0; i < termList.length; ++i) { - const term = termList[i]; - const query = wildcard ? IDBKeyRange.bound(term, `${term}\uffff`, false, false) : IDBKeyRange.only(term); + const term = prefixWildcard ? stringReverse(termList[i]) : termList[i]; + const query = useWildcard ? IDBKeyRange.bound(term, `${term}\uffff`, false, false) : IDBKeyRange.only(term); promises.push( Database.getAll(dbIndex1, query, i, processRow), Database.getAll(dbIndex2, query, i, processRow) @@ -320,9 +332,12 @@ class Database { return result; } - async importDictionary(archive, progressCallback, exceptions) { + async importDictionary(archive, progressCallback, details) { this.validate(); + const errors = []; + const prefixWildcardsSupported = details.prefixWildcardsSupported; + const maxTransactionLength = 1000; const bulkAdd = async (objectStoreName, items, total, current) => { const db = this.db; @@ -337,11 +352,7 @@ class Database { const objectStore = transaction.objectStore(objectStoreName); await Database.bulkAdd(objectStore, items, i, count); } catch (e) { - if (exceptions) { - exceptions.push(e); - } else { - throw e; - } + errors.push(e); } } }; @@ -396,6 +407,13 @@ class Database { } } + if (prefixWildcardsSupported) { + for (const row of rows) { + row.expressionReverse = stringReverse(row.expression); + row.readingReverse = stringReverse(row.reading); + } + } + await bulkAdd('terms', rows, total, current); }; @@ -475,15 +493,18 @@ class Database { await bulkAdd('tagMeta', rows, total, current); }; - return await Database.importDictionaryZip( + const result = await Database.importDictionaryZip( archive, indexDataLoaded, termDataLoaded, termMetaDataLoaded, kanjiDataLoaded, kanjiMetaDataLoaded, - tagDataLoaded + tagDataLoaded, + details ); + + return {result, errors}; } validate() { @@ -499,7 +520,8 @@ class Database { termMetaDataLoaded, kanjiDataLoaded, kanjiMetaDataLoaded, - tagDataLoaded + tagDataLoaded, + details ) { const zip = await JSZip.loadAsync(archive); @@ -517,7 +539,8 @@ class Database { title: index.title, revision: index.revision, sequenced: index.sequenced, - version: index.format || index.version + version: index.format || index.version, + prefixWildcardsSupported: !!details.prefixWildcardsSupported }; await indexDataLoaded(summary); diff --git a/ext/bg/js/deinflector.js b/ext/bg/js/deinflector.js index 51f4723c..33b2a8b3 100644 --- a/ext/bg/js/deinflector.js +++ b/ext/bg/js/deinflector.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/>. */ diff --git a/ext/bg/js/dictionary.js b/ext/bg/js/dictionary.js index 0b35e32e..92adc532 100644 --- a/ext/bg/js/dictionary.js +++ b/ext/bg/js/dictionary.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/>. */ @@ -310,7 +310,7 @@ function dictFieldSplit(field) { return field.length === 0 ? [] : field.split(' '); } -async function dictFieldFormat(field, definition, mode, options, exceptions) { +async function dictFieldFormat(field, definition, mode, options, templates, exceptions) { const data = { marker: null, definition, @@ -329,7 +329,7 @@ async function dictFieldFormat(field, definition, mode, options, exceptions) { } data.marker = marker; try { - return await apiTemplateRender(options.anki.fieldTemplates, data, true); + return await apiTemplateRender(templates, data, true); } catch (e) { if (exceptions) { exceptions.push(e); } return `{${marker}-render-error}`; @@ -357,7 +357,7 @@ dictFieldFormat.markers = new Set([ 'url' ]); -async function dictNoteFormat(definition, mode, options) { +async function dictNoteFormat(definition, mode, options, templates) { const note = {fields: {}, tags: options.anki.tags}; let fields = []; @@ -391,7 +391,7 @@ async function dictNoteFormat(definition, mode, options) { } for (const name in fields) { - note.fields[name] = await dictFieldFormat(fields[name], definition, mode, options); + 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 8f43cf9a..6d1581be 100644 --- a/ext/bg/js/handlebars.js +++ b/ext/bg/js/handlebars.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/>. */ @@ -141,12 +141,13 @@ function handlebarsRenderStatic(name, data) { function handlebarsRenderDynamic(template, data) { handlebarsRegisterHelpers(); - - Handlebars.yomichan_cache = Handlebars.yomichan_cache || {}; - let instance = Handlebars.yomichan_cache[template]; - if (!instance) { - instance = Handlebars.yomichan_cache[template] = Handlebars.compile(template); + const cache = handlebarsRenderDynamic._cache; + let instance = cache.get(template); + if (typeof instance === 'undefined') { + instance = Handlebars.compile(template); + cache.set(template, instance); } return instance(data).trim(); } +handlebarsRenderDynamic._cache = new Map(); diff --git a/ext/bg/js/json-schema.js b/ext/bg/js/json-schema.js new file mode 100644 index 00000000..5d596a8b --- /dev/null +++ b/ext/bg/js/json-schema.js @@ -0,0 +1,428 @@ +/* + * 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/>. + */ + + +class JsonSchemaProxyHandler { + constructor(schema) { + this._schema = schema; + } + + getPrototypeOf(target) { + return Object.getPrototypeOf(target); + } + + setPrototypeOf() { + throw new Error('setPrototypeOf not supported'); + } + + isExtensible(target) { + return Object.isExtensible(target); + } + + preventExtensions(target) { + Object.preventExtensions(target); + return true; + } + + getOwnPropertyDescriptor(target, property) { + return Object.getOwnPropertyDescriptor(target, property); + } + + defineProperty() { + throw new Error('defineProperty not supported'); + } + + has(target, property) { + return property in target; + } + + get(target, property) { + if (typeof property === 'symbol') { + return target[property]; + } + + if (Array.isArray(target)) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + property = parseInt(property, 10); + } else if (typeof property === 'string') { + return target[property]; + } + } + + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property); + if (propertySchema === null) { + return; + } + + const value = target[property]; + return value !== null && typeof value === 'object' ? JsonSchema.createProxy(value, propertySchema) : value; + } + + set(target, property, value) { + if (Array.isArray(target)) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + property = parseInt(property, 10); + if (property > target.length) { + throw new Error('Array index out of range'); + } + } else if (typeof property === 'string') { + target[property] = value; + return true; + } + } + + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property); + if (propertySchema === null) { + throw new Error(`Property ${property} not supported`); + } + + value = JsonSchema.isolate(value); + + const error = JsonSchemaProxyHandler.validate(value, propertySchema); + if (error !== null) { + throw new Error(`Invalid value: ${error}`); + } + + target[property] = value; + return true; + } + + deleteProperty(target, property) { + const required = this._schema.required; + if (Array.isArray(required) && required.includes(property)) { + throw new Error(`${property} cannot be deleted`); + } + return Reflect.deleteProperty(target, property); + } + + ownKeys(target) { + return Reflect.ownKeys(target); + } + + apply() { + throw new Error('apply not supported'); + } + + construct() { + throw new Error('construct not supported'); + } + + static getPropertySchema(schema, property) { + const type = schema.type; + if (Array.isArray(type)) { + throw new Error(`Ambiguous property type for ${property}`); + } + switch (type) { + case 'object': + { + const properties = schema.properties; + if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) { + if (Object.prototype.hasOwnProperty.call(properties, property)) { + return properties[property]; + } + } + + const additionalProperties = schema.additionalProperties; + return (additionalProperties !== null && typeof additionalProperties === 'object' && !Array.isArray(additionalProperties)) ? additionalProperties : null; + } + case 'array': + { + const items = schema.items; + return (items !== null && typeof items === 'object' && !Array.isArray(items)) ? items : null; + } + default: + return null; + } + } + + static validate(value, schema) { + const type = JsonSchemaProxyHandler.getValueType(value); + const schemaType = schema.type; + if (!JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType)) { + return `Value type ${type} does not match schema type ${schemaType}`; + } + + const schemaEnum = schema.enum; + if (Array.isArray(schemaEnum) && !JsonSchemaProxyHandler.valuesAreEqualAny(value, schemaEnum)) { + return 'Invalid enum value'; + } + + switch (type) { + case 'number': + return JsonSchemaProxyHandler.validateNumber(value, schema); + case 'string': + return JsonSchemaProxyHandler.validateString(value, schema); + case 'array': + return JsonSchemaProxyHandler.validateArray(value, schema); + case 'object': + return JsonSchemaProxyHandler.validateObject(value, schema); + default: + return null; + } + } + + static validateNumber(value, schema) { + const multipleOf = schema.multipleOf; + if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { + return `Number is not a multiple of ${multipleOf}`; + } + + const minimum = schema.minimum; + if (typeof minimum === 'number' && value < minimum) { + return `Number is less than ${minimum}`; + } + + const exclusiveMinimum = schema.exclusiveMinimum; + if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) { + return `Number is less than or equal to ${exclusiveMinimum}`; + } + + const maximum = schema.maximum; + if (typeof maximum === 'number' && value > maximum) { + return `Number is greater than ${maximum}`; + } + + const exclusiveMaximum = schema.exclusiveMaximum; + if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) { + return `Number is greater than or equal to ${exclusiveMaximum}`; + } + + return null; + } + + static validateString(value, schema) { + const minLength = schema.minLength; + if (typeof minLength === 'number' && value.length < minLength) { + return 'String length too short'; + } + + const maxLength = schema.minLength; + if (typeof maxLength === 'number' && value.length > maxLength) { + return 'String length too long'; + } + + return null; + } + + static validateArray(value, schema) { + const minItems = schema.minItems; + if (typeof minItems === 'number' && value.length < minItems) { + return 'Array length too short'; + } + + const maxItems = schema.maxItems; + if (typeof maxItems === 'number' && value.length > maxItems) { + return 'Array length too long'; + } + + return null; + } + + static validateObject(value, schema) { + const properties = new Set(Object.getOwnPropertyNames(value)); + + const required = schema.required; + if (Array.isArray(required)) { + for (const property of required) { + if (!properties.has(property)) { + return `Missing property ${property}`; + } + } + } + + const minProperties = schema.minProperties; + if (typeof minProperties === 'number' && properties.length < minProperties) { + return 'Not enough object properties'; + } + + const maxProperties = schema.maxProperties; + if (typeof maxProperties === 'number' && properties.length > maxProperties) { + return 'Too many object properties'; + } + + for (const property of properties) { + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + if (propertySchema === null) { + return `No schema found for ${property}`; + } + const error = JsonSchemaProxyHandler.validate(value[property], propertySchema); + if (error !== null) { + return error; + } + } + + return null; + } + + static isValueTypeAny(value, type, schemaTypes) { + if (typeof schemaTypes === 'string') { + return JsonSchemaProxyHandler.isValueType(value, type, schemaTypes); + } else if (Array.isArray(schemaTypes)) { + for (const schemaType of schemaTypes) { + if (JsonSchemaProxyHandler.isValueType(value, type, schemaType)) { + return true; + } + } + return false; + } + return true; + } + + static isValueType(value, type, schemaType) { + return ( + type === schemaType || + (schemaType === 'integer' && Math.floor(value) === value) + ); + } + + static getValueType(value) { + const type = typeof value; + if (type === 'object') { + if (value === null) { return 'null'; } + if (Array.isArray(value)) { return 'array'; } + } + return type; + } + + static valuesAreEqualAny(value1, valueList) { + for (const value2 of valueList) { + if (JsonSchemaProxyHandler.valuesAreEqual(value1, value2)) { + return true; + } + } + return false; + } + + static valuesAreEqual(value1, value2) { + return value1 === value2; + } + + static getDefaultTypeValue(type) { + if (typeof type === 'string') { + switch (type) { + case 'null': + return null; + case 'boolean': + return false; + case 'number': + case 'integer': + return 0; + case 'string': + return ''; + case 'array': + return []; + case 'object': + return {}; + } + } + return null; + } + + static getValidValueOrDefault(schema, value) { + let type = JsonSchemaProxyHandler.getValueType(value); + const schemaType = schema.type; + if (!JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType)) { + let assignDefault = true; + + const schemaDefault = schema.default; + if (typeof schemaDefault !== 'undefined') { + value = JsonSchema.isolate(schemaDefault); + type = JsonSchemaProxyHandler.getValueType(value); + assignDefault = !JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType); + } + + if (assignDefault) { + value = JsonSchemaProxyHandler.getDefaultTypeValue(schemaType); + type = JsonSchemaProxyHandler.getValueType(value); + } + } + + switch (type) { + case 'object': + value = JsonSchemaProxyHandler.populateObjectDefaults(value, schema); + break; + case 'array': + value = JsonSchemaProxyHandler.populateArrayDefaults(value, schema); + break; + } + + return value; + } + + static populateObjectDefaults(value, schema) { + const properties = new Set(Object.getOwnPropertyNames(value)); + + const required = schema.required; + if (Array.isArray(required)) { + for (const property of required) { + properties.delete(property); + + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + if (propertySchema === null) { continue; } + value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]); + } + } + + for (const property of properties) { + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + if (propertySchema === null) { + Reflect.deleteProperty(value, property); + } else { + value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]); + } + } + + return value; + } + + static populateArrayDefaults(value, schema) { + for (let i = 0, ii = value.length; i < ii; ++i) { + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i); + if (propertySchema === null) { continue; } + value[i] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[i]); + } + + return value; + } +} + +class JsonSchema { + static createProxy(target, schema) { + return new Proxy(target, new JsonSchemaProxyHandler(schema)); + } + + static getValidValueOrDefault(schema, value) { + return JsonSchemaProxyHandler.getValidValueOrDefault(schema, value); + } + + static isolate(value) { + if (value === null) { return null; } + + switch (typeof value) { + case 'boolean': + case 'number': + case 'string': + case 'bigint': + case 'symbol': + return value; + } + + const stringValue = JSON.stringify(value); + return typeof stringValue === 'string' ? JSON.parse(stringValue) : null; + } +} diff --git a/ext/bg/js/mecab.js b/ext/bg/js/mecab.js index 62111f73..8bcbb91c 100644 --- a/ext/bg/js/mecab.js +++ b/ext/bg/js/mecab.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index e53a8a13..8021672b 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 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/>. */ @@ -86,6 +86,13 @@ const profileOptionsVersionUpdates = [ delete options.general.audioSource; delete options.general.audioVolume; delete options.general.autoPlayAudio; + }, + (options) => { + // Version 12 changes: + // The preferred default value of options.anki.fieldTemplates has been changed to null. + if (utilStringHashCode(options.anki.fieldTemplates) === 1444379824) { + options.anki.fieldTemplates = null; + } } ]; @@ -326,7 +333,7 @@ function profileOptionsCreateDefaults() { screenshot: {format: 'png', quality: 92}, terms: {deck: '', model: '', fields: {}}, kanji: {deck: '', model: '', fields: {}}, - fieldTemplates: profileOptionsGetDefaultFieldTemplates() + fieldTemplates: null } }; } @@ -378,7 +385,15 @@ function profileOptionsUpdateVersion(options) { * ] */ -const optionsVersionUpdates = []; +const optionsVersionUpdates = [ + (options) => { + options.global = { + database: { + prefixWildcardsSupported: false + } + }; + } +]; function optionsUpdateVersion(options, defaultProfileOptions) { // Ensure profiles is an array @@ -423,6 +438,11 @@ function optionsUpdateVersion(options, defaultProfileOptions) { profile.options = profileOptionsUpdateVersion(profile.options); } + // Version + if (typeof options.version !== 'number') { + options.version = 0; + } + // Generic updates return optionsGenericApplyUpdates(options, optionsVersionUpdates); } @@ -468,3 +488,7 @@ function optionsSave(options) { }); }); } + +function optionsGetDefault() { + return optionsUpdateVersion({}, {}); +} diff --git a/ext/bg/js/page-exit-prevention.js b/ext/bg/js/page-exit-prevention.js index aee4e3c2..3a320db3 100644 --- a/ext/bg/js/page-exit-prevention.js +++ b/ext/bg/js/page-exit-prevention.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ diff --git a/ext/bg/js/profile-conditions.js b/ext/bg/js/profile-conditions.js index ebc6680a..1fd78e5d 100644 --- a/ext/bg/js/profile-conditions.js +++ b/ext/bg/js/profile-conditions.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ diff --git a/ext/bg/js/request.js b/ext/bg/js/request.js index 7d73d49b..b584c9a9 100644 --- a/ext/bg/js/request.js +++ b/ext/bg/js/request.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 Alex Yatskov <alex@foosoft.net> + * Copyright (C) 2017-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/>. */ diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js index 6ba8467e..2fe50a13 100644 --- a/ext/bg/js/search-frontend.js +++ b/ext/bg/js/search-frontend.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ @@ -33,6 +33,7 @@ async function searchFrontendSetup() { window.frontendInitializationData = {depth: 1, ignoreNodes, proxy: false}; const scriptSrcs = [ + '/mixed/js/text-scanner.js', '/fg/js/frontend-api-receiver.js', '/fg/js/popup.js', '/fg/js/popup-proxy-host.js', @@ -40,6 +41,9 @@ async function searchFrontendSetup() { '/fg/js/frontend-initialize.js' ]; for (const src of scriptSrcs) { + const node = document.querySelector(`script[src='${src}']`); + if (node !== null) { continue; } + const script = document.createElement('script'); script.async = false; script.src = src; diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js index 8dc2e30a..0b3eccbd 100644 --- a/ext/bg/js/search-query-parser.js +++ b/ext/bg/js/search-query-parser.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ @@ -62,7 +62,7 @@ class QueryParser { const scanningOptions = this.search.options.scanning; const scanningModifier = scanningOptions.modifier; if (!( - Frontend.isScanningModifierPressed(scanningModifier, e) || + TextScanner.isScanningModifierPressed(scanningModifier, e) || (scanningOptions.middleMouse && DOM.isMouseButtonDown(e, 'auxiliary')) )) { return; @@ -148,10 +148,9 @@ class QueryParser { async setPreview(text) { const previewTerms = []; - while (text.length > 0) { - const tempText = text.slice(0, 2); - previewTerms.push([{text: Array.from(tempText)}]); - text = text.slice(2); + for (let i = 0, ii = text.length; i < ii; i += 2) { + const tempText = text.substring(i, i + 2); + previewTerms.push([{text: tempText.split('')}]); } this.queryParser.innerHTML = await apiTemplateRender('query-parser.html', { terms: previewTerms, @@ -218,7 +217,7 @@ class QueryParser { return result.map((term) => { return term.filter((part) => part.text.trim()).map((part) => { return { - text: Array.from(part.text), + text: part.text.split(''), reading: part.reading, raw: !part.reading || !part.reading.trim() }; diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index fe48773f..a4103ef2 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.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,16 +13,9 @@ * 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/>. */ - -let IS_FIREFOX = null; -(async () => { - const {browser} = await apiGetEnvironmentInfo(); - IS_FIREFOX = ['firefox', 'firefox-mobile'].includes(browser); -})(); - class DisplaySearch extends Display { constructor() { super(document.querySelector('#spinner'), document.querySelector('#content')); @@ -43,8 +36,12 @@ class DisplaySearch extends Display { this.introVisible = true; this.introAnimationTimer = null; - this.clipboardMonitorIntervalId = null; - this.clipboardPrevText = null; + this.isFirefox = false; + + this.clipboardMonitorTimerId = null; + this.clipboardMonitorTimerToken = null; + this.clipboardInterval = 250; + this.clipboardPreviousText = null; } static create() { @@ -56,6 +53,7 @@ class DisplaySearch extends Display { async prepare() { try { await this.initialize(); + this.isFirefox = await DisplaySearch._isFirefox(); if (this.search !== null) { this.search.addEventListener('click', (e) => this.onSearch(e), false); @@ -207,10 +205,14 @@ class DisplaySearch extends Display { async onSearchQueryUpdated(query, animate) { try { const details = {}; - const match = /[*\uff0a]+$/.exec(query); + const match = /^([*\uff0a]*)([\w\W]*?)([*\uff0a]*)$/.exec(query); if (match !== null) { - details.wildcard = true; - query = query.substring(0, query.length - match[0].length); + if (match[1]) { + details.wildcard = 'prefix'; + } else if (match[3]) { + details.wildcard = 'suffix'; + } + query = match[2]; } const valid = (query.length > 0); @@ -224,63 +226,81 @@ class DisplaySearch extends Display { sentence: {text: query, offset: 0}, url: window.location.href }); - this.setTitleText(query); } else { this.container.textContent = ''; } + this.setTitleText(query); window.parent.postMessage('popupClose', '*'); } catch (e) { this.onError(e); } } - onRuntimeMessage({action, params}, sender, callback) { - const handlers = DisplaySearch.runtimeMessageHandlers; - if (hasOwn(handlers, action)) { - const handler = handlers[action]; - const result = handler(this, params); - callback(result); - } else { - return super.onRuntimeMessage({action, params}, sender, callback); - } - } - initClipboardMonitor() { // ignore copy from search page window.addEventListener('copy', () => { - this.clipboardPrevText = document.getSelection().toString().trim(); + this.clipboardPreviousText = document.getSelection().toString().trim(); }); } startClipboardMonitor() { - this.clipboardMonitorIntervalId = setInterval(async () => { - let curText = null; - // TODO get rid of this and figure out why apiClipboardGet doesn't work on Firefox - if (IS_FIREFOX) { - curText = (await navigator.clipboard.readText()).trim(); - } else if (IS_FIREFOX === false) { - curText = (await apiClipboardGet()).trim(); - } - if (curText && (curText !== this.clipboardPrevText) && jpIsJapaneseText(curText)) { - if (this.isWanakanaEnabled()) { - this.setQuery(window.wanakana.toKana(curText)); - } else { - this.setQuery(curText); + // 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 this.getClipboardText() + // call will exit early if the reference has changed. + const token = {}; + const intervalCallback = async () => { + this.clipboardMonitorTimerId = null; + + let text = await this.getClipboardText(); + if (this.clipboardMonitorTimerToken !== token) { return; } + + if ( + typeof text === 'string' && + (text = text.trim()).length > 0 && + text !== this.clipboardPreviousText + ) { + this.clipboardPreviousText = text; + if (jpIsJapaneseText(text)) { + this.setQuery(this.isWanakanaEnabled() ? window.wanakana.toKana(text) : text); + window.history.pushState(null, '', `${window.location.pathname}?query=${encodeURIComponent(text)}`); + this.onSearchQueryUpdated(this.query.value, true); } + } - const queryString = curText.length > 0 ? `?query=${encodeURIComponent(curText)}` : ''; - window.history.pushState(null, '', `${window.location.pathname}${queryString}`); - this.onSearchQueryUpdated(this.query.value, true); + this.clipboardMonitorTimerId = setTimeout(intervalCallback, this.clipboardInterval); + }; - this.clipboardPrevText = curText; - } - }, 100); + this.clipboardMonitorTimerToken = token; + + intervalCallback(); } stopClipboardMonitor() { - if (this.clipboardMonitorIntervalId) { - clearInterval(this.clipboardMonitorIntervalId); - this.clipboardMonitorIntervalId = null; + this.clipboardMonitorTimerToken = null; + if (this.clipboardMonitorTimerId !== null) { + clearTimeout(this.clipboardMonitorTimerId); + this.clipboardMonitorTimerId = null; + } + } + + async getClipboardText() { + /* + Notes: + apiClipboardGet doesn't work on Firefox because document.execCommand('paste') + results in an empty string on the web extension background page. + This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985 + Therefore, navigator.clipboard.readText() is used on Firefox. + + navigator.clipboard.readText() can't be used in Chrome for two reasons: + * Requires page to be focused, else it rejects with an exception. + * When the page is focused, Chrome will request clipboard permission, despite already + being an extension with clipboard permissions. It effectively asks for the + non-extension permission for clipboard access. + */ + try { + return this.isFirefox ? await navigator.clipboard.readText() : await apiClipboardGet(); + } catch (e) { + return null; } } @@ -360,22 +380,32 @@ class DisplaySearch extends Display { setTitleText(text) { // Chrome limits title to 1024 characters if (text.length > 1000) { - text = text.slice(0, 1000) + '...'; + text = text.substring(0, 1000) + '...'; + } + + if (text.length === 0) { + document.title = 'Yomichan Search'; + } else { + document.title = `${text} - Yomichan Search`; } - document.title = `${text} - Yomichan Search`; } static getSearchQueryFromLocation(url) { const match = /^[^?#]*\?(?:[^&#]*&)?query=([^&#]*)/.exec(url); return match !== null ? decodeURIComponent(match[1]) : null; } -} -DisplaySearch.runtimeMessageHandlers = { - getUrl: () => { - return {url: window.location.href}; + static async _isFirefox() { + const {browser} = await apiGetEnvironmentInfo(); + switch (browser) { + case 'firefox': + case 'firefox-mobile': + return true; + default: + return false; + } } -}; +} DisplaySearch.onKeyDownIgnoreKeys = { 'ANY_MOD': [ @@ -392,4 +422,4 @@ DisplaySearch.onKeyDownIgnoreKeys = { 'Shift': [] }; -window.yomichan_search = DisplaySearch.create(); +DisplaySearch.instance = DisplaySearch.create(); diff --git a/ext/bg/js/settings/anki-templates.js b/ext/bg/js/settings/anki-templates.js index 9cdfc134..5e74358f 100644 --- a/ext/bg/js/settings/anki-templates.js +++ b/ext/bg/js/settings/anki-templates.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ @@ -42,10 +42,22 @@ function ankiTemplatesInitialize() { node.addEventListener('click', onAnkiTemplateMarkerClicked, false); } - $('#field-templates').on('change', (e) => onAnkiTemplatesValidateCompile(e)); + $('#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)); + + ankiTemplatesUpdateValue(); +} + +async function ankiTemplatesUpdateValue() { + const optionsContext = getOptionsContext(); + const options = await apiOptionsGet(optionsContext); + let templates = options.anki.fieldTemplates; + if (typeof templates !== 'string') { templates = profileOptionsGetDefaultFieldTemplates(); } + $('#field-templates').val(templates); + + onAnkiTemplatesValidateCompile(); } const ankiTemplatesValidateGetDefinition = (() => { @@ -73,7 +85,9 @@ async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, i const definition = await ankiTemplatesValidateGetDefinition(text, optionsContext); if (definition !== null) { const options = await apiOptionsGet(optionsContext); - result = await dictFieldFormat(field, definition, mode, options, exceptions); + let templates = options.anki.fieldTemplates; + if (typeof templates !== 'string') { templates = profileOptionsGetDefaultFieldTemplates(); } + result = await dictFieldFormat(field, definition, mode, options, templates, exceptions); } } catch (e) { exceptions.push(e); @@ -89,6 +103,24 @@ async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, i } } +async function onAnkiFieldTemplatesChanged(e) { + // Get value + let templates = e.currentTarget.value; + if (templates === profileOptionsGetDefaultFieldTemplates()) { + // Default + templates = null; + } + + // Overwrite + const optionsContext = getOptionsContext(); + const options = await getOptionsMutable(optionsContext); + options.anki.fieldTemplates = templates; + await settingsSaveOptions(); + + // Compile + onAnkiTemplatesValidateCompile(); +} + function onAnkiTemplatesValidateCompile() { const infoNode = document.querySelector('#field-template-compile-result'); ankiTemplatesValidate(infoNode, '{expression}', 'term-kanji', false, true); diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js index e1aabbaf..5f7989b8 100644 --- a/ext/bg/js/settings/anki.js +++ b/ext/bg/js/settings/anki.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ @@ -154,7 +154,7 @@ async function _onAnkiModelChanged(e) { } const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); + const options = await getOptionsMutable(optionsContext); options.anki[tabId].fields = utilBackgroundIsolate(fields); await settingsSaveOptions(); diff --git a/ext/bg/js/audio-ui.js b/ext/bg/js/settings/audio-ui.js index 381129ac..711c2291 100644 --- a/ext/bg/js/audio-ui.js +++ b/ext/bg/js/settings/audio-ui.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ @@ -21,7 +21,7 @@ class AudioSourceUI { static instantiateTemplate(templateSelector) { const template = document.querySelector(templateSelector); const content = document.importNode(template.content, true); - return $(content.firstChild); + return content.firstChild; } } @@ -32,13 +32,14 @@ AudioSourceUI.Container = class Container { this.addButton = addButton; this.children = []; - this.container.empty(); + this.container.textContent = ''; for (const audioSource of toIterable(audioSources)) { this.children.push(new AudioSourceUI.AudioSource(this, audioSource, this.children.length)); } - this.addButton.on('click', () => this.onAddAudioSource()); + this._clickListener = () => this.onAddAudioSource(); + this.addButton.addEventListener('click', this._clickListener, false); } cleanup() { @@ -46,8 +47,9 @@ AudioSourceUI.Container = class Container { child.cleanup(); } - this.addButton.off('click'); - this.container.empty(); + this.addButton.removeEventListener('click', this._clickListener, false); + this.container.textContent = ''; + this._clickListener = null; } save() { @@ -98,20 +100,28 @@ AudioSourceUI.AudioSource = class AudioSource { this.audioSource = audioSource; this.index = index; - this.container = AudioSourceUI.instantiateTemplate('#audio-source-template').appendTo(parent.container); - this.select = this.container.find('.audio-source-select'); - this.removeButton = this.container.find('.audio-source-remove'); + this.container = AudioSourceUI.instantiateTemplate('#audio-source-template'); + this.select = this.container.querySelector('.audio-source-select'); + this.removeButton = this.container.querySelector('.audio-source-remove'); - this.select.val(audioSource); + this.select.value = audioSource; - this.select.on('change', () => this.onSelectChanged()); - this.removeButton.on('click', () => this.onRemoveClicked()); + this._selectChangeListener = () => this.onSelectChanged(); + this._removeClickListener = () => this.onRemoveClicked(); + + this.select.addEventListener('change', this._selectChangeListener, false); + this.removeButton.addEventListener('click', this._removeClickListener, false); + + parent.container.appendChild(this.container); } cleanup() { - this.select.off('change'); - this.removeButton.off('click'); - this.container.remove(); + this.select.removeEventListener('change', this._selectChangeListener, false); + this.removeButton.removeEventListener('click', this._removeClickListener, false); + + if (this.container.parentNode !== null) { + this.container.parentNode.removeChild(this.container); + } } save() { @@ -119,7 +129,7 @@ AudioSourceUI.AudioSource = class AudioSource { } onSelectChanged() { - this.audioSource = this.select.val(); + this.audioSource = this.select.value; this.parent.audioSources[this.index] = this.audioSource; this.save(); } diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js index f63551ed..cff3f521 100644 --- a/ext/bg/js/settings/audio.js +++ b/ext/bg/js/settings/audio.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ @@ -21,8 +21,12 @@ let audioSourceUI = null; async function audioSettingsInitialize() { const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); - audioSourceUI = new AudioSourceUI.Container(options.audio.sources, $('.audio-source-list'), $('.audio-source-add')); + const options = await getOptionsMutable(optionsContext); + audioSourceUI = new AudioSourceUI.Container( + options.audio.sources, + document.querySelector('.audio-source-list'), + document.querySelector('.audio-source-add') + ); audioSourceUI.save = () => settingsSaveOptions(); textToSpeechInitialize(); @@ -34,24 +38,34 @@ function textToSpeechInitialize() { speechSynthesis.addEventListener('voiceschanged', () => updateTextToSpeechVoices(), false); updateTextToSpeechVoices(); - $('#text-to-speech-voice-test').on('click', () => textToSpeechTest()); + document.querySelector('#text-to-speech-voice').addEventListener('change', (e) => onTextToSpeechVoiceChange(e), false); + document.querySelector('#text-to-speech-voice-test').addEventListener('click', () => textToSpeechTest(), false); } function updateTextToSpeechVoices() { const voices = Array.prototype.map.call(speechSynthesis.getVoices(), (voice, index) => ({voice, index})); voices.sort(textToSpeechVoiceCompare); - if (voices.length > 0) { - $('#text-to-speech-voice-container').css('display', ''); - } - const select = $('#text-to-speech-voice'); - select.empty(); - select.append($('<option>').val('').text('None')); + document.querySelector('#text-to-speech-voice-container').hidden = (voices.length === 0); + + const fragment = document.createDocumentFragment(); + + let option = document.createElement('option'); + option.value = ''; + option.textContent = 'None'; + fragment.appendChild(option); + for (const {voice} of voices) { - select.append($('<option>').val(voice.voiceURI).text(`${voice.name} (${voice.lang})`)); + option = document.createElement('option'); + option.value = voice.voiceURI; + option.textContent = `${voice.name} (${voice.lang})`; + fragment.appendChild(option); } - select.val(select.attr('data-value')); + const select = document.querySelector('#text-to-speech-voice'); + select.textContent = ''; + select.appendChild(fragment); + select.value = select.dataset.value; } function languageTagIsJapanese(languageTag) { @@ -78,15 +92,13 @@ function textToSpeechVoiceCompare(a, b) { if (bIsDefault) { return 1; } } - if (a.index < b.index) { return -1; } - if (a.index > b.index) { return 1; } - return 0; + return a.index - b.index; } function textToSpeechTest() { try { - const text = $('#text-to-speech-voice-test').attr('data-speech-text') || ''; - const voiceURI = $('#text-to-speech-voice').val(); + 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; } @@ -100,3 +112,7 @@ function textToSpeechTest() { // NOP } } + +function onTextToSpeechVoiceChange(e) { + e.currentTarget.dataset.value = e.currentTarget.value; +} diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js new file mode 100644 index 00000000..becdc568 --- /dev/null +++ b/ext/bg/js/settings/backup.js @@ -0,0 +1,370 @@ +/* + * 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. 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/>. + */ + + +// Exporting + +let _settingsExportToken = null; +let _settingsExportRevoke = null; +const SETTINGS_EXPORT_CURRENT_VERSION = 0; + +function _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) { + const values = [ + date.getUTCFullYear().toString(), + dateSeparator, + (date.getUTCMonth() + 1).toString().padStart(2, '0'), + dateSeparator, + date.getUTCDate().toString().padStart(2, '0'), + dateTimeSeparator, + date.getUTCHours().toString().padStart(2, '0'), + timeSeparator, + date.getUTCMinutes().toString().padStart(2, '0'), + timeSeparator, + date.getUTCSeconds().toString().padStart(2, '0') + ]; + return values.slice(0, resolution * 2 - 1).join(''); +} + +async function _getSettingsExportData(date) { + const optionsFull = await apiOptionsGetFull(); + const environment = await apiGetEnvironmentInfo(); + + const fieldTemplatesDefault = profileOptionsGetDefaultFieldTemplates(); + + // Format options + for (const {options} of optionsFull.profiles) { + if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) { + delete options.anki.fieldTemplates; // Default + } + } + + const data = { + version: SETTINGS_EXPORT_CURRENT_VERSION, + date: _getSettingsExportDateString(date, '-', ' ', ':', 6), + url: chrome.runtime.getURL('/'), + manifest: chrome.runtime.getManifest(), + environment, + userAgent: navigator.userAgent, + options: optionsFull + }; + + return data; +} + +function _saveBlob(blob, fileName) { + if (typeof navigator === 'object' && typeof navigator.msSaveBlob === 'function') { + if (navigator.msSaveBlob(blob)) { + return; + } + } + + const blobUrl = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = blobUrl; + a.download = fileName; + a.rel = 'noopener'; + a.target = '_blank'; + + const revoke = () => { + URL.revokeObjectURL(blobUrl); + a.href = ''; + _settingsExportRevoke = null; + }; + _settingsExportRevoke = revoke; + + a.dispatchEvent(new MouseEvent('click')); + setTimeout(revoke, 60000); +} + +async function _onSettingsExportClick() { + if (_settingsExportRevoke !== null) { + _settingsExportRevoke(); + _settingsExportRevoke = null; + } + + const date = new Date(Date.now()); + + const token = {}; + _settingsExportToken = token; + const data = await _getSettingsExportData(date); + if (_settingsExportToken !== token) { + // A new export has been started + return; + } + _settingsExportToken = null; + + const fileName = `yomichan-settings-${_getSettingsExportDateString(date, '-', '-', '-', 6)}.json`; + const blob = new Blob([JSON.stringify(data, null, 4)], {type: 'application/json'}); + _saveBlob(blob, fileName); +} + + +// Importing + +async function _settingsImportSetOptionsFull(optionsFull) { + return utilIsolate(await utilBackend().setFullOptions( + utilBackgroundIsolate(optionsFull) + )); +} + +function _showSettingsImportError(error) { + logError(error); + document.querySelector('#settings-import-error-modal-message').textContent = `${error}`; + $('#settings-import-error-modal').modal('show'); +} + +async function _showSettingsImportWarnings(warnings) { + const modalNode = $('#settings-import-warning-modal'); + const buttons = document.querySelectorAll('.settings-import-warning-modal-import-button'); + const messageContainer = document.querySelector('#settings-import-warning-modal-message'); + if (modalNode.length === 0 || buttons.length === 0 || messageContainer === null) { + return {result: false}; + } + + // Set message + const fragment = document.createDocumentFragment(); + for (const warning of warnings) { + const node = document.createElement('li'); + node.textContent = `${warning}`; + fragment.appendChild(node); + } + messageContainer.textContent = ''; + messageContainer.appendChild(fragment); + + // Show modal + modalNode.modal('show'); + + // Wait for modal to close + return new Promise((resolve) => { + const onButtonClick = (e) => { + e.preventDefault(); + complete({ + result: true, + sanitize: e.currentTarget.dataset.importSanitize === 'true' + }); + modalNode.modal('hide'); + + }; + const onModalHide = () => { + complete({result: false}); + }; + + let completed = false; + const complete = (result) => { + if (completed) { return; } + completed = true; + + modalNode.off('hide.bs.modal', onModalHide); + for (const button of buttons) { + button.removeEventListener('click', onButtonClick, false); + } + + resolve(result); + }; + + // Hook events + modalNode.on('hide.bs.modal', onModalHide); + for (const button of buttons) { + button.addEventListener('click', onButtonClick, false); + } + }); +} + +function _isLocalhostUrl(urlString) { + try { + const url = new URL(urlString); + switch (url.hostname.toLowerCase()) { + case 'localhost': + case '127.0.0.1': + case '[::1]': + switch (url.protocol.toLowerCase()) { + case 'http:': + case 'https:': + return true; + } + break; + } + } catch (e) { + // NOP + } + return false; +} + +function _settingsImportSanitizeProfileOptions(options, dryRun) { + const warnings = []; + + const anki = options.anki; + if (isObject(anki)) { + const fieldTemplates = anki.fieldTemplates; + if (typeof fieldTemplates === 'string') { + warnings.push('anki.fieldTemplates contains a non-default value'); + if (!dryRun) { + delete anki.fieldTemplates; + } + } + const server = anki.server; + if (typeof server === 'string' && server.length > 0 && !_isLocalhostUrl(server)) { + warnings.push('anki.server uses a non-localhost URL'); + if (!dryRun) { + delete anki.server; + } + } + } + + const audio = options.audio; + if (isObject(audio)) { + const customSourceUrl = audio.customSourceUrl; + if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !_isLocalhostUrl(customSourceUrl)) { + warnings.push('audio.customSourceUrl uses a non-localhost URL'); + if (!dryRun) { + delete audio.customSourceUrl; + } + } + } + + return warnings; +} + +function _settingsImportSanitizeOptions(optionsFull, dryRun) { + const warnings = new Set(); + + const profiles = optionsFull.profiles; + if (Array.isArray(profiles)) { + for (const profile of profiles) { + if (!isObject(profile)) { continue; } + const options = profile.options; + if (!isObject(options)) { continue; } + + const warnings2 = _settingsImportSanitizeProfileOptions(options, dryRun); + for (const warning of warnings2) { + warnings.add(warning); + } + } + } + + return warnings; +} + +function _utf8Decode(arrayBuffer) { + try { + return new TextDecoder('utf-8').decode(arrayBuffer); + } catch (e) { + const binaryString = String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)); + return decodeURIComponent(escape(binaryString)); + } +} + +async function _importSettingsFile(file) { + const dataString = _utf8Decode(await utilReadFileArrayBuffer(file)); + const data = JSON.parse(dataString); + + // Type check + if (!isObject(data)) { + throw new Error(`Invalid data type: ${typeof data}`); + } + + // Version check + const version = data.version; + if (!( + typeof version === 'number' && + Number.isFinite(version) && + version === Math.floor(version) + )) { + throw new Error(`Invalid version: ${version}`); + } + + if (!( + version >= 0 && + version <= SETTINGS_EXPORT_CURRENT_VERSION + )) { + throw new Error(`Unsupported version: ${version}`); + } + + // Verify options exists + let optionsFull = data.options; + if (!isObject(optionsFull)) { + throw new Error(`Invalid options type: ${typeof optionsFull}`); + } + + // Upgrade options + optionsFull = optionsUpdateVersion(optionsFull, {}); + + // Check for warnings + const sanitizationWarnings = _settingsImportSanitizeOptions(optionsFull, true); + + // Show sanitization warnings + if (sanitizationWarnings.size > 0) { + const {result, sanitize} = await _showSettingsImportWarnings(sanitizationWarnings); + if (!result) { return; } + + if (sanitize !== false) { + _settingsImportSanitizeOptions(optionsFull, false); + } + } + + // Assign options + await _settingsImportSetOptionsFull(optionsFull); + + // Reload settings page + window.location.reload(); +} + +function _onSettingsImportClick() { + document.querySelector('#settings-import-file').click(); +} + +function _onSettingsImportFileChange(e) { + const files = e.target.files; + if (files.length === 0) { return; } + + const file = files[0]; + e.target.value = null; + _importSettingsFile(file).catch(_showSettingsImportError); +} + + +// Resetting + +function _onSettingsResetClick() { + $('#settings-reset-modal').modal('show'); +} + +async function _onSettingsResetConfirmClick() { + $('#settings-reset-modal').modal('hide'); + + // Get default options + const optionsFull = optionsGetDefault(); + + // Assign options + await _settingsImportSetOptionsFull(optionsFull); + + // Reload settings page + window.location.reload(); +} + + +// Setup + +window.addEventListener('DOMContentLoaded', () => { + 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/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js index cc9db087..4d041451 100644 --- a/ext/bg/js/conditions-ui.js +++ b/ext/bg/js/settings/conditions-ui.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js index 065a8abc..ed171ae9 100644 --- a/ext/bg/js/settings/dictionaries.js +++ b/ext/bg/js/settings/dictionaries.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ @@ -189,6 +189,7 @@ class SettingsDictionaryEntryUI { this.content.querySelector('.dict-title').textContent = this.dictionaryInfo.title; this.content.querySelector('.dict-revision').textContent = `rev.${this.dictionaryInfo.revision}`; + this.content.querySelector('.dict-prefix-wildcard-searches-supported').checked = !!this.dictionaryInfo.prefixWildcardsSupported; this.applyValues(); @@ -272,7 +273,7 @@ class SettingsDictionaryEntryUI { progress.hidden = true; const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); + const options = await getOptionsMutable(optionsContext); onDatabaseUpdated(options); } } @@ -356,9 +357,10 @@ async function dictSettingsInitialize() { 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); const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); + const options = await getOptionsMutable(optionsContext); onDictionaryOptionsChanged(options); onDatabaseUpdated(options); } @@ -366,6 +368,9 @@ async function dictSettingsInitialize() { async function onDictionaryOptionsChanged(options) { if (dictionaryUI === null) { return; } dictionaryUI.setOptionsDictionaries(options.dictionaries); + + const optionsFull = await apiOptionsGetFull(); + document.querySelector('#database-enable-prefix-wildcard-searches').checked = optionsFull.global.database.prefixWildcardsSupported; } async function onDatabaseUpdated(options) { @@ -420,7 +425,7 @@ async function updateMainDictionarySelect(options, dictionaries) { async function onDictionaryMainChanged(e) { const value = e.target.value; const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); + const options = await getOptionsMutable(optionsContext); options.general.mainDictionary = value; settingsSaveOptions(); } @@ -526,14 +531,14 @@ async function onDictionaryPurge(e) { dictionarySpinnerShow(true); await utilDatabasePurge(); - for (const options of toIterable(await getOptionsArray())) { + for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) { options.dictionaries = utilBackgroundIsolate({}); options.general.mainDictionary = ''; } await settingsSaveOptions(); const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); + const options = await getOptionsMutable(optionsContext); onDatabaseUpdated(options); } catch (err) { dictionaryErrorsShow([err]); @@ -552,6 +557,9 @@ async function onDictionaryPurge(e) { } async function onDictionaryImport(e) { + const files = [...e.target.files]; + e.target.value = null; + const dictFile = $('#dict-file'); const dictControls = $('#dict-importer').hide(); const dictProgress = $('#dict-import-progress').show(); @@ -572,8 +580,11 @@ async function onDictionaryImport(e) { } }; - const exceptions = []; - const files = [...e.target.files]; + const optionsFull = await apiOptionsGetFull(); + + const importDetails = { + prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported + }; for (let i = 0, ii = files.length; i < ii; ++i) { setProgress(0.0); @@ -582,25 +593,26 @@ async function onDictionaryImport(e) { dictImportInfo.textContent = `(${i + 1} of ${ii})`; } - const summary = await utilDatabaseImport(files[i], updateProgress, exceptions); - for (const options of toIterable(await getOptionsArray())) { + const {result, errors} = await utilDatabaseImport(files[i], updateProgress, importDetails); + for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) { const dictionaryOptions = SettingsDictionaryListUI.createDictionaryOptions(); dictionaryOptions.enabled = true; - options.dictionaries[summary.title] = dictionaryOptions; - if (summary.sequenced && options.general.mainDictionary === '') { - options.general.mainDictionary = summary.title; + options.dictionaries[result.title] = dictionaryOptions; + if (result.sequenced && options.general.mainDictionary === '') { + options.general.mainDictionary = result.title; } } await settingsSaveOptions(); - if (exceptions.length > 0) { - exceptions.push(`Dictionary may not have been imported properly: ${exceptions.length} error${exceptions.length === 1 ? '' : 's'} reported.`); - dictionaryErrorsShow(exceptions); + if (errors.length > 0) { + errors.push(...errors); + errors.push(`Dictionary may not have been imported properly: ${errors.length} error${errors.length === 1 ? '' : 's'} reported.`); + dictionaryErrorsShow(errors); } const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); + const options = await getOptionsMutable(optionsContext); onDatabaseUpdated(options); } } catch (err) { @@ -616,3 +628,12 @@ async function onDictionaryImport(e) { dictProgress.hide(); } } + + +async function onDatabaseEnablePrefixWildcardSearchesChanged(e) { + const optionsFull = await getOptionsFullMutable(); + const v = !!e.target.checked; + if (optionsFull.global.database.prefixWildcardsSupported === v) { return; } + optionsFull.global.database.prefixWildcardsSupported = !!e.target.checked; + await settingsSaveOptions(); +} diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js index 7456e7a4..56828a15 100644 --- a/ext/bg/js/settings/main.js +++ b/ext/bg/js/settings/main.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,12 +13,17 @@ * 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/>. */ -async function getOptionsArray() { - const optionsFull = await apiOptionsGetFull(); - return optionsFull.profiles.map((profile) => profile.options); +function getOptionsMutable(optionsContext) { + return utilBackend().getOptions( + utilBackgroundIsolate(optionsContext) + ); +} + +function getOptionsFullMutable() { + return utilBackend().getFullOptions(); } async function formRead(options) { @@ -75,7 +80,6 @@ async function formRead(options) { options.anki.server = $('#interface-server').val(); options.anki.screenshot.format = $('#screenshot-format').val(); options.anki.screenshot.quality = parseInt($('#screenshot-quality').val(), 10); - options.anki.fieldTemplates = $('#field-templates').val(); if (optionsAnkiEnableOld && !ankiErrorShown()) { options.anki.terms.deck = $('#anki-terms-deck').val(); @@ -140,9 +144,8 @@ async function formWrite(options) { $('#interface-server').val(options.anki.server); $('#screenshot-format').val(options.anki.screenshot.format); $('#screenshot-quality').val(options.anki.screenshot.quality); - $('#field-templates').val(options.anki.fieldTemplates); - onAnkiTemplatesValidateCompile(); + await ankiTemplatesUpdateValue(); await onAnkiOptionsChanged(options); await onDictionaryOptionsChanged(options); @@ -161,7 +164,9 @@ function formUpdateVisibility(options) { if (options.general.debugInfo) { const temp = utilIsolate(options); - temp.anki.fieldTemplates = '...'; + if (typeof temp.anki.fieldTemplates === 'string') { + temp.anki.fieldTemplates = '...'; + } const text = JSON.stringify(temp, null, 4); $('#debug').text(text); } @@ -169,7 +174,7 @@ function formUpdateVisibility(options) { async function onFormOptionsChanged() { const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); + const options = await getOptionsMutable(optionsContext); await formRead(options); await settingsSaveOptions(); @@ -195,21 +200,10 @@ async function onOptionsUpdate({source}) { if (source === thisSource) { return; } const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); + const options = await getOptionsMutable(optionsContext); await formWrite(options); } -function onMessage({action, params}, sender, callback) { - switch (action) { - case 'optionsUpdate': - onOptionsUpdate(params); - break; - case 'getUrl': - callback({url: window.location.href}); - break; - } -} - function showExtensionInformation() { const node = document.getElementById('extension-info'); @@ -233,7 +227,7 @@ async function onReady() { storageInfoInitialize(); - chrome.runtime.onMessage.addListener(onMessage); + yomichan.on('optionsUpdate', onOptionsUpdate); } $(document).ready(() => onReady()); diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js index 49409968..2b727cbd 100644 --- a/ext/bg/js/settings/popup-preview-frame.js +++ b/ext/bg/js/settings/popup-preview-frame.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ @@ -24,6 +24,7 @@ class SettingsPopupPreview { this.popupInjectOuterStylesheetOld = Popup.injectOuterStylesheet; this.popupShown = false; this.themeChangeTimeout = null; + this.textSource = null; } static create() { @@ -46,16 +47,18 @@ class SettingsPopupPreview { window.apiOptionsGet = (...args) => this.apiOptionsGet(...args); // Overwrite frontend - this.frontend = Frontend.create(); - window.yomichan_frontend = this.frontend; + const popupHost = new PopupProxyHost(); + await popupHost.prepare(); + + const popup = popupHost.createPopup(null, 0); + popup.setChildrenSupported(false); + + this.frontend = new Frontend(popup); this.frontend.setEnabled = function () {}; this.frontend.searchClear = function () {}; - this.frontend.popup.childrenSupported = false; - this.frontend.popup.interactive = false; - - await this.frontend.isPrepared(); + await this.frontend.prepare(); // Overwrite popup Popup.injectOuterStylesheet = (...args) => this.popupInjectOuterStylesheet(...args); @@ -95,7 +98,7 @@ class SettingsPopupPreview { onWindowResize() { if (this.frontend === null) { return; } - const textSource = this.frontend.textSourceLast; + const textSource = this.textSource; if (textSource === null) { return; } const elementRect = textSource.getRect(); @@ -105,11 +108,10 @@ class SettingsPopupPreview { onMessage(e) { const {action, params} = e.data; - const handlers = SettingsPopupPreview.messageHandlers; - if (hasOwn(handlers, action)) { - const handler = handlers[action]; - handler(this, params); - } + const handler = SettingsPopupPreview._messageHandlers.get(action); + if (typeof handler !== 'function') { return; } + + handler(this, params); } onThemeDarkCheckboxChanged(node) { @@ -160,13 +162,14 @@ class SettingsPopupPreview { const source = new TextSourceRange(range, range.toString(), null); try { - await this.frontend.searchSource(source, 'script'); + await this.frontend.onSearchSource(source, 'script'); } finally { source.cleanup(); } - await this.frontend.lastShowPromise; + this.textSource = source; + await this.frontend.showContentCompleted(); - if (this.frontend.popup.isVisible()) { + if (this.frontend.popup.isVisibleSync()) { this.popupShown = true; } @@ -174,11 +177,11 @@ class SettingsPopupPreview { } } -SettingsPopupPreview.messageHandlers = { - setText: (self, {text}) => self.setText(text), - setCustomCss: (self, {css}) => self.setCustomCss(css), - setCustomOuterCss: (self, {css}) => self.setCustomOuterCss(css) -}; +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/popup-preview.js b/ext/bg/js/settings/popup-preview.js index d8579eb1..0d20471e 100644 --- a/ext/bg/js/settings/popup-preview.js +++ b/ext/bg/js/settings/popup-preview.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js index 8c218e97..c4e68b53 100644 --- a/ext/bg/js/settings/profiles.js +++ b/ext/bg/js/settings/profiles.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ let currentProfileIndex = 0; @@ -27,7 +27,7 @@ function getOptionsContext() { async function profileOptionsSetup() { - const optionsFull = await apiOptionsGetFull(); + const optionsFull = await getOptionsFullMutable(); currentProfileIndex = optionsFull.profileCurrent; profileOptionsSetupEventListeners(); @@ -120,7 +120,7 @@ async function profileOptionsUpdateTarget(optionsFull) { profileFormWrite(optionsFull); const optionsContext = getOptionsContext(); - const options = await apiOptionsGet(optionsContext); + const options = await getOptionsMutable(optionsContext); await formWrite(options); } @@ -164,13 +164,13 @@ async function onProfileOptionsChanged(e) { return; } - const optionsFull = await apiOptionsGetFull(); + const optionsFull = await getOptionsFullMutable(); await profileFormRead(optionsFull); await settingsSaveOptions(); } async function onTargetProfileChanged() { - const optionsFull = await apiOptionsGetFull(); + const optionsFull = await getOptionsFullMutable(); const index = tryGetIntegerValue('#profile-target', 0, optionsFull.profiles.length); if (index === null || currentProfileIndex === index) { return; @@ -182,7 +182,7 @@ async function onTargetProfileChanged() { } async function onProfileAdd() { - const optionsFull = await apiOptionsGetFull(); + const optionsFull = await getOptionsFullMutable(); const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]); profile.name = profileOptionsCreateCopyName(profile.name, optionsFull.profiles, 100); optionsFull.profiles.push(profile); @@ -210,7 +210,7 @@ async function onProfileRemove(e) { async function onProfileRemoveConfirm() { $('#profile-remove-modal').modal('hide'); - const optionsFull = await apiOptionsGetFull(); + const optionsFull = await getOptionsFullMutable(); if (optionsFull.profiles.length <= 1) { return; } @@ -234,7 +234,7 @@ function onProfileNameChanged() { } async function onProfileMove(offset) { - const optionsFull = await apiOptionsGetFull(); + const optionsFull = await getOptionsFullMutable(); const index = currentProfileIndex + offset; if (index < 0 || index >= optionsFull.profiles.length) { return; @@ -267,7 +267,7 @@ async function onProfileCopy() { async function onProfileCopyConfirm() { $('#profile-copy-modal').modal('hide'); - const optionsFull = await apiOptionsGetFull(); + const optionsFull = await getOptionsFullMutable(); const index = tryGetIntegerValue('#profile-copy-source', 0, optionsFull.profiles.length); if (index === null || index === currentProfileIndex) { return; diff --git a/ext/bg/js/settings/storage.js b/ext/bg/js/settings/storage.js index 51ca6855..6c10f665 100644 --- a/ext/bg/js/settings/storage.js +++ b/ext/bg/js/settings/storage.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * 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 @@ -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/>. */ diff --git a/ext/bg/js/templates.js b/ext/bg/js/templates.js index 9320477f..eae4e014 100644 --- a/ext/bg/js/templates.js +++ b/ext/bg/js/templates.js @@ -143,11 +143,11 @@ templates['kanji.html'] = template({"1":function(container,depth0,helpers,partia },"33":function(container,depth0,helpers,partials,data) { return "class=\"source-term\""; },"35":function(container,depth0,helpers,partials,data) { - return "class=\"source-term term-button-fade\""; + return "class=\"source-term invisible\""; },"37":function(container,depth0,helpers,partials,data) { return "class=\"next-term\""; },"39":function(container,depth0,helpers,partials,data) { - return "class=\"next-term term-button-fade\""; + return "class=\"next-term invisible\""; },"41":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1; @@ -491,11 +491,11 @@ templates['terms.html'] = template({"1":function(container,depth0,helpers,partia },"67":function(container,depth0,helpers,partials,data) { return "class=\"source-term\""; },"69":function(container,depth0,helpers,partials,data) { - return "class=\"source-term term-button-fade\""; + return "class=\"source-term invisible\""; },"71":function(container,depth0,helpers,partials,data) { return "class=\"next-term\""; },"73":function(container,depth0,helpers,partials,data) { - return "class=\"next-term term-button-fade\""; + return "class=\"next-term invisible\""; },"75":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1; diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js index 202014c9..7473c6ad 100644 --- a/ext/bg/js/translator.js +++ b/ext/bg/js/translator.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 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/>. */ @@ -230,7 +230,7 @@ class Translator { const titles = Object.keys(dictionaries); const deinflections = ( details.wildcard ? - await this.findTermWildcard(text, titles) : + await this.findTermWildcard(text, titles, details.wildcard) : await this.findTermDeinflections(text, titles) ); @@ -268,8 +268,8 @@ class Translator { return [definitions, length]; } - async findTermWildcard(text, titles) { - const definitions = await this.database.findTermsBulk([text], titles, true); + async findTermWildcard(text, titles, wildcard) { + const definitions = await this.database.findTermsBulk([text], titles, wildcard); if (definitions.length === 0) { return []; } @@ -308,7 +308,7 @@ class Translator { deinflectionArray.push(deinflection); } - const definitions = await this.database.findTermsBulk(uniqueDeinflectionTerms, titles, false); + const definitions = await this.database.findTermsBulk(uniqueDeinflectionTerms, titles, null); for (const definition of definitions) { const definitionRules = Deinflector.rulesToRuleFlags(definition.rules); diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js index 3dd5fd55..333e814b 100644 --- a/ext/bg/js/util.js +++ b/ext/bg/js/util.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,11 +13,40 @@ * 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/>. */ -function utilIsolate(data) { - return JSON.parse(JSON.stringify(data)); +function utilIsolate(value) { + if (value === null) { return null; } + + switch (typeof value) { + case 'boolean': + case 'number': + case 'string': + case 'bigint': + case 'symbol': + return value; + } + + const stringValue = JSON.stringify(value); + return typeof stringValue === 'string' ? JSON.parse(stringValue) : null; +} + +function utilFunctionIsolate(func) { + return function (...args) { + try { + args = args.map((v) => utilIsolate(v)); + return func.call(this, ...args); + } catch (e) { + try { + String(func); + } catch (e2) { + // Dead object + return; + } + throw e; + } + }; } function utilBackgroundIsolate(data) { @@ -25,6 +54,11 @@ function utilBackgroundIsolate(data) { return backgroundPage.utilIsolate(data); } +function utilBackgroundFunctionIsolate(func) { + const backgroundPage = chrome.extension.getBackgroundPage(); + return backgroundPage.utilFunctionIsolate(func); +} + function utilSetEqual(setA, setB) { if (setA.size !== setB.size) { return false; @@ -54,6 +88,8 @@ function utilSetDifference(setA, setB) { function utilStringHashCode(string) { let hashCode = 0; + if (typeof string !== 'string') { return hashCode; } + for (let i = 0, charCode = string.charCodeAt(i); i < string.length; charCode = string.charCodeAt(++i)) { hashCode = ((hashCode << 5) - hashCode) + charCode; hashCode |= 0; @@ -63,44 +99,52 @@ function utilStringHashCode(string) { } function utilBackend() { - return chrome.extension.getBackgroundPage().yomichan_backend; + return chrome.extension.getBackgroundPage().yomichanBackend; } -function utilAnkiGetModelNames() { - return utilBackend().anki.getModelNames(); +async function utilAnkiGetModelNames() { + return utilIsolate(await utilBackend().anki.getModelNames()); } -function utilAnkiGetDeckNames() { - return utilBackend().anki.getDeckNames(); +async function utilAnkiGetDeckNames() { + return utilIsolate(await utilBackend().anki.getDeckNames()); } -function utilDatabaseGetDictionaryInfo() { - return utilBackend().translator.database.getDictionaryInfo(); +async function utilDatabaseGetDictionaryInfo() { + return utilIsolate(await utilBackend().translator.database.getDictionaryInfo()); } -function utilDatabaseGetDictionaryCounts(dictionaryNames, getTotal) { - return utilBackend().translator.database.getDictionaryCounts(dictionaryNames, getTotal); +async function utilDatabaseGetDictionaryCounts(dictionaryNames, getTotal) { + return utilIsolate(await utilBackend().translator.database.getDictionaryCounts( + utilBackgroundIsolate(dictionaryNames), + utilBackgroundIsolate(getTotal) + )); } -function utilAnkiGetModelFieldNames(modelName) { - return utilBackend().anki.getModelFieldNames(modelName); +async function utilAnkiGetModelFieldNames(modelName) { + return utilIsolate(await utilBackend().anki.getModelFieldNames( + utilBackgroundIsolate(modelName) + )); } -function utilDatabasePurge() { - return utilBackend().translator.purgeDatabase(); +async function utilDatabasePurge() { + return utilIsolate(await utilBackend().translator.purgeDatabase()); } -function utilDatabaseDeleteDictionary(dictionaryName, onProgress) { - return utilBackend().translator.database.deleteDictionary(dictionaryName, onProgress); +async function utilDatabaseDeleteDictionary(dictionaryName, onProgress) { + return utilIsolate(await utilBackend().translator.database.deleteDictionary( + utilBackgroundIsolate(dictionaryName), + utilBackgroundFunctionIsolate(onProgress) + )); } -async function utilDatabaseImport(data, progress, exceptions) { - // Edge cannot read data on the background page due to the File object - // being created from a different window. Read on the same page instead. - if (EXTENSION_IS_BROWSER_EDGE) { - data = await utilReadFile(data); - } - return utilBackend().translator.database.importDictionary(data, progress, exceptions); +async function utilDatabaseImport(data, onProgress, details) { + data = await utilReadFile(data); + return utilIsolate(await utilBackend().translator.database.importDictionary( + utilBackgroundIsolate(data), + utilBackgroundFunctionIsolate(onProgress), + utilBackgroundIsolate(details) + )); } function utilReadFile(file) { @@ -111,3 +155,12 @@ function utilReadFile(file) { reader.readAsBinaryString(file); }); } + +function utilReadFileArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(file); + }); +} |