diff options
Diffstat (limited to 'ext/js/background')
-rw-r--r-- | ext/js/background/backend.js | 2053 | ||||
-rw-r--r-- | ext/js/background/background-main.js | 25 | ||||
-rw-r--r-- | ext/js/background/profile-conditions.js | 276 | ||||
-rw-r--r-- | ext/js/background/request-builder.js | 266 |
4 files changed, 2620 insertions, 0 deletions
diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js new file mode 100644 index 00000000..3bb23310 --- /dev/null +++ b/ext/js/background/backend.js @@ -0,0 +1,2053 @@ +/* + * Copyright (C) 2016-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * AnkiConnect + * AudioDownloader + * ClipboardMonitor + * ClipboardReader + * DictionaryDatabase + * Environment + * JapaneseUtil + * JsonSchemaValidator + * Mecab + * MediaUtility + * ObjectPropertyAccessor + * OptionsUtil + * PermissionsUtil + * ProfileConditions + * RequestBuilder + * Translator + * wanakana + */ + +class Backend { + constructor() { + this._japaneseUtil = new JapaneseUtil(wanakana); + this._environment = new Environment(); + this._dictionaryDatabase = new DictionaryDatabase(); + this._translator = new Translator({ + japaneseUtil: this._japaneseUtil, + database: this._dictionaryDatabase + }); + this._anki = new AnkiConnect(); + this._mecab = new Mecab(); + this._mediaUtility = new MediaUtility(); + this._clipboardReader = new ClipboardReader({ + // eslint-disable-next-line no-undef + document: (typeof document === 'object' && document !== null ? document : null), + pasteTargetSelector: '#clipboard-paste-target', + imagePasteTargetSelector: '#clipboard-image-paste-target', + mediaUtility: this._mediaUtility + }); + this._clipboardMonitor = new ClipboardMonitor({ + japaneseUtil: this._japaneseUtil, + clipboardReader: this._clipboardReader + }); + this._options = null; + this._profileConditionsSchemaValidator = new JsonSchemaValidator(); + this._profileConditionsSchemaCache = []; + this._profileConditionsUtil = new ProfileConditions(); + this._defaultAnkiFieldTemplates = null; + this._requestBuilder = new RequestBuilder(); + this._audioDownloader = new AudioDownloader({ + japaneseUtil: this._japaneseUtil, + requestBuilder: this._requestBuilder + }); + this._optionsUtil = new OptionsUtil(); + + this._searchPopupTabId = null; + this._searchPopupTabCreatePromise = null; + + this._isPrepared = false; + this._prepareError = false; + this._preparePromise = null; + const {promise, resolve, reject} = deferPromise(); + this._prepareCompletePromise = promise; + this._prepareCompleteResolve = resolve; + this._prepareCompleteReject = reject; + + this._defaultBrowserActionTitle = null; + this._badgePrepareDelayTimer = null; + this._logErrorLevel = null; + this._permissions = null; + this._permissionsUtil = new PermissionsUtil(); + + this._messageHandlers = new Map([ + ['requestBackendReadySignal', {async: false, contentScript: true, handler: this._onApiRequestBackendReadySignal.bind(this)}], + ['optionsGet', {async: false, contentScript: true, handler: this._onApiOptionsGet.bind(this)}], + ['optionsGetFull', {async: false, contentScript: true, handler: this._onApiOptionsGetFull.bind(this)}], + ['kanjiFind', {async: true, contentScript: true, handler: this._onApiKanjiFind.bind(this)}], + ['termsFind', {async: true, contentScript: true, handler: this._onApiTermsFind.bind(this)}], + ['textParse', {async: true, contentScript: true, handler: this._onApiTextParse.bind(this)}], + ['getAnkiConnectVersion', {async: true, contentScript: true, handler: this._onApGetAnkiConnectVersion.bind(this)}], + ['isAnkiConnected', {async: true, contentScript: true, handler: this._onApiIsAnkiConnected.bind(this)}], + ['addAnkiNote', {async: true, contentScript: true, handler: this._onApiAddAnkiNote.bind(this)}], + ['getAnkiNoteInfo', {async: true, contentScript: true, handler: this._onApiGetAnkiNoteInfo.bind(this)}], + ['injectAnkiNoteMedia', {async: true, contentScript: true, handler: this._onApiInjectAnkiNoteMedia.bind(this)}], + ['noteView', {async: true, contentScript: true, handler: this._onApiNoteView.bind(this)}], + ['suspendAnkiCardsForNote', {async: true, contentScript: true, handler: this._onApiSuspendAnkiCardsForNote.bind(this)}], + ['commandExec', {async: false, contentScript: true, handler: this._onApiCommandExec.bind(this)}], + ['getExpressionAudioInfoList', {async: true, contentScript: true, handler: this._onApiGetExpressionAudioInfoList.bind(this)}], + ['sendMessageToFrame', {async: false, contentScript: true, handler: this._onApiSendMessageToFrame.bind(this)}], + ['broadcastTab', {async: false, contentScript: true, handler: this._onApiBroadcastTab.bind(this)}], + ['frameInformationGet', {async: true, contentScript: true, handler: this._onApiFrameInformationGet.bind(this)}], + ['injectStylesheet', {async: true, contentScript: true, handler: this._onApiInjectStylesheet.bind(this)}], + ['getStylesheetContent', {async: true, contentScript: true, handler: this._onApiGetStylesheetContent.bind(this)}], + ['getEnvironmentInfo', {async: false, contentScript: true, handler: this._onApiGetEnvironmentInfo.bind(this)}], + ['clipboardGet', {async: true, contentScript: true, handler: this._onApiClipboardGet.bind(this)}], + ['getDisplayTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetDisplayTemplatesHtml.bind(this)}], + ['getZoom', {async: true, contentScript: true, handler: this._onApiGetZoom.bind(this)}], + ['getDefaultAnkiFieldTemplates', {async: false, contentScript: true, handler: this._onApiGetDefaultAnkiFieldTemplates.bind(this)}], + ['getDictionaryInfo', {async: true, contentScript: false, handler: this._onApiGetDictionaryInfo.bind(this)}], + ['getDictionaryCounts', {async: true, contentScript: false, handler: this._onApiGetDictionaryCounts.bind(this)}], + ['purgeDatabase', {async: true, contentScript: false, handler: this._onApiPurgeDatabase.bind(this)}], + ['getMedia', {async: true, contentScript: true, handler: this._onApiGetMedia.bind(this)}], + ['log', {async: false, contentScript: true, handler: this._onApiLog.bind(this)}], + ['logIndicatorClear', {async: false, contentScript: true, handler: this._onApiLogIndicatorClear.bind(this)}], + ['createActionPort', {async: false, contentScript: true, handler: this._onApiCreateActionPort.bind(this)}], + ['modifySettings', {async: true, contentScript: true, handler: this._onApiModifySettings.bind(this)}], + ['getSettings', {async: false, contentScript: true, handler: this._onApiGetSettings.bind(this)}], + ['setAllSettings', {async: true, contentScript: false, handler: this._onApiSetAllSettings.bind(this)}], + ['getOrCreateSearchPopup', {async: true, contentScript: true, handler: this._onApiGetOrCreateSearchPopup.bind(this)}], + ['isTabSearchPopup', {async: true, contentScript: true, handler: this._onApiIsTabSearchPopup.bind(this)}], + ['triggerDatabaseUpdated', {async: false, contentScript: true, handler: this._onApiTriggerDatabaseUpdated.bind(this)}], + ['testMecab', {async: true, contentScript: true, handler: this._onApiTestMecab.bind(this)}] + ]); + this._messageHandlersWithProgress = new Map([ + ]); + + this._commandHandlers = new Map([ + ['toggleTextScanning', this._onCommandToggleTextScanning.bind(this)], + ['openInfoPage', this._onCommandOpenInfoPage.bind(this)], + ['openSettingsPage', this._onCommandOpenSettingsPage.bind(this)], + ['openSearchPage', this._onCommandOpenSearchPage.bind(this)], + ['openPopupWindow', this._onCommandOpenPopupWindow.bind(this)] + ]); + } + + prepare() { + if (this._preparePromise === null) { + const promise = this._prepareInternal(); + promise.then( + (value) => { + this._isPrepared = true; + this._prepareCompleteResolve(value); + }, + (error) => { + this._prepareError = true; + this._prepareCompleteReject(error); + } + ); + promise.finally(() => this._updateBadge()); + this._preparePromise = promise; + } + return this._prepareCompletePromise; + } + + // Private + + _prepareInternalSync() { + if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) { + const onCommand = this._onWebExtensionEventWrapper(this._onCommand.bind(this)); + chrome.commands.onCommand.addListener(onCommand); + } + + if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) { + const onZoomChange = this._onWebExtensionEventWrapper(this._onZoomChange.bind(this)); + chrome.tabs.onZoomChange.addListener(onZoomChange); + } + + const onConnect = this._onWebExtensionEventWrapper(this._onConnect.bind(this)); + chrome.runtime.onConnect.addListener(onConnect); + + const onMessage = this._onMessageWrapper.bind(this); + chrome.runtime.onMessage.addListener(onMessage); + + const onPermissionsChanged = this._onWebExtensionEventWrapper(this._onPermissionsChanged.bind(this)); + chrome.permissions.onAdded.addListener(onPermissionsChanged); + chrome.permissions.onRemoved.addListener(onPermissionsChanged); + } + + async _prepareInternal() { + try { + this._prepareInternalSync(); + + this._permissions = await this._permissionsUtil.getAllPermissions(); + this._defaultBrowserActionTitle = await this._getBrowserIconTitle(); + this._badgePrepareDelayTimer = setTimeout(() => { + this._badgePrepareDelayTimer = null; + this._updateBadge(); + }, 1000); + this._updateBadge(); + + yomichan.on('log', this._onLog.bind(this)); + + await this._requestBuilder.prepare(); + await this._environment.prepare(); + this._clipboardReader.browser = this._environment.getInfo().browser; + + try { + await this._dictionaryDatabase.prepare(); + } catch (e) { + yomichan.logError(e); + } + + const deinflectionReasions = await this._fetchAsset('/data/deinflect.json', true); + this._translator.prepare(deinflectionReasions); + + await this._optionsUtil.prepare(); + this._defaultAnkiFieldTemplates = (await this._fetchAsset('/data/templates/default-anki-field-templates.handlebars')).trim(); + this._options = await this._optionsUtil.load(); + + this._applyOptions('background'); + + const options = this._getProfileOptions({current: true}); + if (options.general.showGuide) { + this._openWelcomeGuidePage(); + } + + this._clipboardMonitor.on('change', this._onClipboardTextChange.bind(this)); + + this._sendMessageAllTabsIgnoreResponse('backendReady', {}); + this._sendMessageIgnoreResponse({action: 'backendReady', params: {}}); + } catch (e) { + yomichan.logError(e); + throw e; + } finally { + if (this._badgePrepareDelayTimer !== null) { + clearTimeout(this._badgePrepareDelayTimer); + this._badgePrepareDelayTimer = null; + } + } + } + + // Event handlers + + async _onClipboardTextChange({text}) { + const {clipboard: {maximumSearchLength}} = this._getProfileOptions({current: true}); + if (text.length > maximumSearchLength) { + text = text.substring(0, maximumSearchLength); + } + try { + const {tab, created} = await this._getOrCreateSearchPopup(); + await this._focusTab(tab); + await this._updateSearchQuery(tab.id, text, !created); + } catch (e) { + // NOP + } + } + + _onLog({level}) { + const levelValue = this._getErrorLevelValue(level); + if (levelValue <= this._getErrorLevelValue(this._logErrorLevel)) { return; } + + this._logErrorLevel = level; + this._updateBadge(); + } + + // WebExtension event handlers (with prepared checks) + + _onWebExtensionEventWrapper(handler) { + return (...args) => { + if (this._isPrepared) { + handler(...args); + return; + } + + this._prepareCompletePromise.then( + () => { handler(...args); }, + () => {} // NOP + ); + }; + } + + _onMessageWrapper(message, sender, sendResponse) { + if (this._isPrepared) { + return this._onMessage(message, sender, sendResponse); + } + + this._prepareCompletePromise.then( + () => { this._onMessage(message, sender, sendResponse); }, + () => { sendResponse(); } + ); + return true; + } + + // WebExtension event handlers + + _onCommand(command) { + this._runCommand(command); + } + + _onMessage({action, params}, sender, callback) { + const messageHandler = this._messageHandlers.get(action); + if (typeof messageHandler === 'undefined') { return false; } + + if (!messageHandler.contentScript) { + try { + this._validatePrivilegedMessageSender(sender); + } catch (error) { + callback({error: serializeError(error)}); + return false; + } + } + + return yomichan.invokeMessageHandler(messageHandler, params, callback, sender); + } + + _onConnect(port) { + try { + let details; + try { + details = JSON.parse(port.name); + } catch (e) { + return; + } + if (details.name !== 'background-cross-frame-communication-port') { return; } + + const senderTabId = (port.sender && port.sender.tab ? port.sender.tab.id : null); + if (typeof senderTabId !== 'number') { + throw new Error('Port does not have an associated tab ID'); + } + const senderFrameId = port.sender.frameId; + if (typeof senderFrameId !== 'number') { + throw new Error('Port does not have an associated frame ID'); + } + let {targetTabId, targetFrameId} = details; + if (typeof targetTabId !== 'number') { + targetTabId = senderTabId; + } + + const details2 = { + name: 'cross-frame-communication-port', + sourceTabId: senderTabId, + sourceFrameId: senderFrameId + }; + let forwardPort = chrome.tabs.connect(targetTabId, {frameId: targetFrameId, name: JSON.stringify(details2)}); + + const cleanup = () => { + this._checkLastError(chrome.runtime.lastError); + if (forwardPort !== null) { + forwardPort.disconnect(); + forwardPort = null; + } + if (port !== null) { + port.disconnect(); + port = null; + } + }; + + port.onMessage.addListener((message) => { forwardPort.postMessage(message); }); + forwardPort.onMessage.addListener((message) => { port.postMessage(message); }); + port.onDisconnect.addListener(cleanup); + forwardPort.onDisconnect.addListener(cleanup); + } catch (e) { + port.disconnect(); + yomichan.logError(e); + } + } + + _onZoomChange({tabId, oldZoomFactor, newZoomFactor}) { + this._sendMessageTabIgnoreResponse(tabId, {action: 'zoomChanged', params: {oldZoomFactor, newZoomFactor}}); + } + + _onPermissionsChanged() { + this._checkPermissions(); + } + + // Message handlers + + _onApiRequestBackendReadySignal(_params, sender) { + // tab ID isn't set in background (e.g. browser_action) + const data = {action: 'backendReady', params: {}}; + if (typeof sender.tab === 'undefined') { + this._sendMessageIgnoreResponse(data); + return false; + } else { + this._sendMessageTabIgnoreResponse(sender.tab.id, data); + return true; + } + } + + _onApiOptionsGet({optionsContext}) { + return this._getProfileOptions(optionsContext); + } + + _onApiOptionsGetFull() { + return this._getOptionsFull(); + } + + async _onApiKanjiFind({text, optionsContext}) { + const options = this._getProfileOptions(optionsContext); + const {general: {maxResults}} = options; + const findKanjiOptions = this._getTranslatorFindKanjiOptions(options); + const definitions = await this._translator.findKanji(text, findKanjiOptions); + definitions.splice(maxResults); + return definitions; + } + + async _onApiTermsFind({text, details, optionsContext}) { + const options = this._getProfileOptions(optionsContext); + const {general: {resultOutputMode: mode, maxResults}} = options; + const findTermsOptions = this._getTranslatorFindTermsOptions(details, options); + const [definitions, length] = await this._translator.findTerms(mode, text, findTermsOptions); + definitions.splice(maxResults); + return {length, definitions}; + } + + async _onApiTextParse({text, optionsContext}) { + const options = this._getProfileOptions(optionsContext); + const results = []; + + if (options.parsing.enableScanningParser) { + results.push({ + source: 'scanning-parser', + id: 'scan', + content: await this._textParseScanning(text, options) + }); + } + + if (options.parsing.enableMecabParser) { + const mecabResults = await this._textParseMecab(text, options); + for (const [mecabDictName, mecabDictResults] of mecabResults) { + results.push({ + source: 'mecab', + dictionary: mecabDictName, + id: `mecab-${mecabDictName}`, + content: mecabDictResults + }); + } + } + + return results; + } + + async _onApGetAnkiConnectVersion() { + return await this._anki.getVersion(); + } + + async _onApiIsAnkiConnected() { + return await this._anki.isConnected(); + } + + async _onApiAddAnkiNote({note}) { + return await this._anki.addNote(note); + } + + async _onApiGetAnkiNoteInfo({notes}) { + const results = []; + const cannotAdd = []; + const canAddArray = await this._anki.canAddNotes(notes); + + for (let i = 0; i < notes.length; ++i) { + const note = notes[i]; + const canAdd = canAddArray[i]; + const info = {canAdd, noteIds: null}; + results.push(info); + if (!canAdd) { + cannotAdd.push({note, info}); + } + } + + if (cannotAdd.length > 0) { + const cannotAddNotes = cannotAdd.map(({note}) => note); + const noteIdsArray = await this._anki.findNoteIds(cannotAddNotes); + for (let i = 0, ii = Math.min(cannotAdd.length, noteIdsArray.length); i < ii; ++i) { + const noteIds = noteIdsArray[i]; + if (noteIds.length > 0) { + cannotAdd[i].info.noteIds = noteIds; + } + } + } + + return results; + } + + async _onApiInjectAnkiNoteMedia({timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails}) { + return await this._injectAnkNoteMedia( + this._anki, + timestamp, + definitionDetails, + audioDetails, + screenshotDetails, + clipboardDetails + ); + } + + async _onApiNoteView({noteId}) { + return await this._anki.guiBrowseNote(noteId); + } + + async _onApiSuspendAnkiCardsForNote({noteId}) { + const cardIds = await this._anki.findCardsForNote(noteId); + const count = cardIds.length; + if (count > 0) { + const okay = await this._anki.suspendCards(cardIds); + if (!okay) { return 0; } + } + return count; + } + + _onApiCommandExec({command, params}) { + return this._runCommand(command, params); + } + + async _onApiGetExpressionAudioInfoList({source, expression, reading, details}) { + return await this._audioDownloader.getExpressionAudioInfoList(source, expression, reading, details); + } + + _onApiSendMessageToFrame({frameId: targetFrameId, action, params}, sender) { + if (!(sender && sender.tab)) { + return false; + } + + const tabId = sender.tab.id; + const frameId = sender.frameId; + this._sendMessageTabIgnoreResponse(tabId, {action, params, frameId}, {frameId: targetFrameId}); + return true; + } + + _onApiBroadcastTab({action, params}, sender) { + if (!(sender && sender.tab)) { + return false; + } + + const tabId = sender.tab.id; + const frameId = sender.frameId; + this._sendMessageTabIgnoreResponse(tabId, {action, params, frameId}); + return true; + } + + _onApiFrameInformationGet(params, sender) { + const tab = sender.tab; + const tabId = tab ? tab.id : void 0; + const frameId = sender.frameId; + return Promise.resolve({tabId, frameId}); + } + + _onApiInjectStylesheet({type, value}, sender) { + return this._injectStylesheet(type, value, sender); + } + + async _onApiGetStylesheetContent({url}) { + if (!url.startsWith('/') || url.startsWith('//') || !url.endsWith('.css')) { + throw new Error('Invalid URL'); + } + return await this._fetchAsset(url); + } + + _onApiGetEnvironmentInfo() { + return this._environment.getInfo(); + } + + async _onApiClipboardGet() { + return this._clipboardReader.getText(); + } + + async _onApiGetDisplayTemplatesHtml() { + return await this._fetchAsset('/display-templates.html'); + } + + _onApiGetZoom(params, sender) { + if (!sender || !sender.tab) { + return Promise.reject(new Error('Invalid tab')); + } + + return new Promise((resolve, reject) => { + const tabId = sender.tab.id; + if (!( + chrome.tabs !== null && + typeof chrome.tabs === 'object' && + typeof chrome.tabs.getZoom === 'function' + )) { + // Not supported + resolve({zoomFactor: 1.0}); + return; + } + chrome.tabs.getZoom(tabId, (zoomFactor) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve({zoomFactor}); + } + }); + }); + } + + _onApiGetDefaultAnkiFieldTemplates() { + return this._defaultAnkiFieldTemplates; + } + + async _onApiGetDictionaryInfo() { + return await this._dictionaryDatabase.getDictionaryInfo(); + } + + async _onApiGetDictionaryCounts({dictionaryNames, getTotal}) { + return await this._dictionaryDatabase.getDictionaryCounts(dictionaryNames, getTotal); + } + + async _onApiPurgeDatabase() { + await this._dictionaryDatabase.purge(); + this._triggerDatabaseUpdated('dictionary', 'purge'); + } + + async _onApiGetMedia({targets}) { + return await this._dictionaryDatabase.getMedia(targets); + } + + _onApiLog({error, level, context}) { + yomichan.log(deserializeError(error), level, context); + } + + _onApiLogIndicatorClear() { + if (this._logErrorLevel === null) { return; } + this._logErrorLevel = null; + this._updateBadge(); + } + + _onApiCreateActionPort(params, sender) { + if (!sender || !sender.tab) { throw new Error('Invalid sender'); } + const tabId = sender.tab.id; + if (typeof tabId !== 'number') { throw new Error('Sender has invalid tab ID'); } + + const frameId = sender.frameId; + const id = generateId(16); + const details = { + name: 'action-port', + id + }; + + const port = chrome.tabs.connect(tabId, {name: JSON.stringify(details), frameId}); + try { + this._createActionListenerPort(port, sender, this._messageHandlersWithProgress); + } catch (e) { + port.disconnect(); + throw e; + } + + return details; + } + + _onApiModifySettings({targets, source}) { + return this._modifySettings(targets, source); + } + + _onApiGetSettings({targets}) { + const results = []; + for (const target of targets) { + try { + const result = this._getSetting(target); + results.push({result: clone(result)}); + } catch (e) { + results.push({error: serializeError(e)}); + } + } + return results; + } + + async _onApiSetAllSettings({value, source}) { + this._optionsUtil.validate(value); + this._options = clone(value); + await this._saveOptions(source); + } + + async _onApiGetOrCreateSearchPopup({focus=false, text=null}) { + const {tab, created} = await this._getOrCreateSearchPopup(); + if (focus === true || (focus === 'ifCreated' && created)) { + await this._focusTab(tab); + } + if (typeof text === 'string') { + await this._updateSearchQuery(tab.id, text, !created); + } + return {tabId: tab.id, windowId: tab.windowId}; + } + + async _onApiIsTabSearchPopup({tabId}) { + const baseUrl = chrome.runtime.getURL('/search.html'); + const tab = typeof tabId === 'number' ? await this._checkTabUrl(tabId, (url) => url.startsWith(baseUrl)) : null; + return (tab !== null); + } + + _onApiTriggerDatabaseUpdated({type, cause}) { + this._triggerDatabaseUpdated(type, cause); + } + + async _onApiTestMecab() { + if (!this._mecab.isEnabled()) { + throw new Error('MeCab not enabled'); + } + + let permissionsOkay = false; + try { + permissionsOkay = await this._permissionsUtil.hasPermissions({permissions: ['nativeMessaging']}); + } catch (e) { + // NOP + } + if (!permissionsOkay) { + throw new Error('Insufficient permissions'); + } + + const disconnect = !this._mecab.isConnected(); + try { + const version = await this._mecab.getVersion(); + if (version === null) { + throw new Error('Could not connect to native MeCab component'); + } + + const localVersion = this._mecab.getLocalVersion(); + if (version !== localVersion) { + throw new Error(`MeCab component version not supported: ${version}`); + } + } finally { + // Disconnect if the connection was previously disconnected + if (disconnect && this._mecab.isEnabled() && this._mecab.isActive()) { + this._mecab.disconnect(); + } + } + + return true; + } + + // Command handlers + + async _onCommandOpenSearchPage(params) { + const {mode='existingOrNewTab', query} = params || {}; + + const baseUrl = chrome.runtime.getURL('/search.html'); + const queryParams = {}; + if (query && query.length > 0) { queryParams.query = query; } + const queryString = new URLSearchParams(queryParams).toString(); + let url = baseUrl; + if (queryString.length > 0) { + url += `?${queryString}`; + } + + const predicate = ({url: url2}) => { + if (url2 === null || !url2.startsWith(baseUrl)) { return false; } + const parsedUrl = new URL(url2); + const baseUrl2 = `${parsedUrl.origin}${parsedUrl.pathname}`; + const mode2 = parsedUrl.searchParams.get('mode'); + return baseUrl2 === baseUrl && (mode2 === mode || (!mode2 && mode === 'existingOrNewTab')); + }; + + const openInTab = async () => { + const tab = await this._findTabs(1000, false, predicate, false); + if (tab !== null) { + await this._focusTab(tab); + if (queryParams.query) { + await this._updateSearchQuery(tab.id, queryParams.query, true); + } + return true; + } + }; + + switch (mode) { + case 'existingOrNewTab': + try { + if (await openInTab()) { return; } + } catch (e) { + // NOP + } + await this._createTab(url); + return; + case 'newTab': + await this._createTab(url); + return; + } + } + + async _onCommandOpenInfoPage() { + await this._openInfoPage(); + } + + async _onCommandOpenSettingsPage(params) { + const {mode='existingOrNewTab'} = params || {}; + await this._openSettingsPage(mode); + } + + async _onCommandToggleTextScanning() { + const options = this._getProfileOptions({current: true}); + await this._modifySettings([{ + action: 'set', + path: 'general.enable', + value: !options.general.enable, + scope: 'profile', + optionsContext: {current: true} + }], 'backend'); + } + + async _onCommandOpenPopupWindow() { + await this._onApiGetOrCreateSearchPopup({focus: true}); + } + + // Utilities + + async _modifySettings(targets, source) { + const results = []; + for (const target of targets) { + try { + const result = this._modifySetting(target); + results.push({result: clone(result)}); + } catch (e) { + results.push({error: serializeError(e)}); + } + } + await this._saveOptions(source); + return results; + } + + _getOrCreateSearchPopup() { + if (this._searchPopupTabCreatePromise === null) { + const promise = this._getOrCreateSearchPopup2(); + this._searchPopupTabCreatePromise = promise; + promise.then(() => { this._searchPopupTabCreatePromise = null; }); + } + return this._searchPopupTabCreatePromise; + } + + async _getOrCreateSearchPopup2() { + // Use existing tab + const baseUrl = chrome.runtime.getURL('/search.html'); + const urlPredicate = (url) => url !== null && url.startsWith(baseUrl); + if (this._searchPopupTabId !== null) { + const tab = await this._checkTabUrl(this._searchPopupTabId, urlPredicate); + if (tab !== null) { + return {tab, created: false}; + } + this._searchPopupTabId = null; + } + + // Find existing tab + const existingTabInfo = await this._findSearchPopupTab(urlPredicate); + if (existingTabInfo !== null) { + const existingTab = existingTabInfo.tab; + this._searchPopupTabId = existingTab.id; + return {tab: existingTab, created: false}; + } + + // chrome.windows not supported (e.g. on Firefox mobile) + if (!isObject(chrome.windows)) { + throw new Error('Window creation not supported'); + } + + // Create a new window + const options = this._getProfileOptions({current: true}); + const createData = this._getSearchPopupWindowCreateData(baseUrl, options); + const {popupWindow: {windowState}} = options; + const popupWindow = await this._createWindow(createData); + if (windowState !== 'normal') { + await this._updateWindow(popupWindow.id, {state: windowState}); + } + + const {tabs} = popupWindow; + if (tabs.length === 0) { + throw new Error('Created window did not contain a tab'); + } + + const tab = tabs[0]; + await this._waitUntilTabFrameIsReady(tab.id, 0, 2000); + + await this._sendMessageTabPromise( + tab.id, + {action: 'setMode', params: {mode: 'popup'}}, + {frameId: 0} + ); + + this._searchPopupTabId = tab.id; + return {tab, created: true}; + } + + async _findSearchPopupTab(urlPredicate) { + const predicate = async ({url, tab}) => { + if (!urlPredicate(url)) { return false; } + try { + const mode = await this._sendMessageTabPromise( + tab.id, + {action: 'getMode', params: {}}, + {frameId: 0} + ); + return mode === 'popup'; + } catch (e) { + return false; + } + }; + return await this._findTabs(1000, false, predicate, true); + } + + _getSearchPopupWindowCreateData(url, options) { + const {popupWindow: {width, height, left, top, useLeft, useTop, windowType}} = options; + return { + url, + width, + height, + left: useLeft ? left : void 0, + top: useTop ? top : void 0, + type: windowType, + state: 'normal' + }; + } + + _createWindow(createData) { + return new Promise((resolve, reject) => { + chrome.windows.create( + createData, + (result) => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message)); + } else { + resolve(result); + } + } + ); + }); + } + + _updateWindow(windowId, updateInfo) { + return new Promise((resolve, reject) => { + chrome.windows.update( + windowId, + updateInfo, + (result) => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message)); + } else { + resolve(result); + } + } + ); + }); + } + + _updateSearchQuery(tabId, text, animate) { + return this._sendMessageTabPromise( + tabId, + {action: 'updateSearchQuery', params: {text, animate}}, + {frameId: 0} + ); + } + + _applyOptions(source) { + const options = this._getProfileOptions({current: true}); + this._updateBadge(); + + this._anki.server = options.anki.server; + this._anki.enabled = options.anki.enable; + + this._mecab.setEnabled(options.parsing.enableMecabParser); + + if (options.clipboard.enableBackgroundMonitor) { + this._clipboardMonitor.start(); + } else { + this._clipboardMonitor.stop(); + } + + this._sendMessageAllTabsIgnoreResponse('optionsUpdated', {source}); + } + + _getOptionsFull(useSchema=false) { + const options = this._options; + return useSchema ? this._optionsUtil.createValidatingProxy(options) : options; + } + + _getProfileOptions(optionsContext, useSchema=false) { + return this._getProfile(optionsContext, useSchema).options; + } + + _getProfile(optionsContext, useSchema=false) { + const options = this._getOptionsFull(useSchema); + const profiles = options.profiles; + if (optionsContext.current) { + return profiles[options.profileCurrent]; + } + if (typeof optionsContext.index === 'number') { + return profiles[optionsContext.index]; + } + const profile = this._getProfileFromContext(options, optionsContext); + return profile !== null ? profile : profiles[options.profileCurrent]; + } + + _getProfileFromContext(options, optionsContext) { + optionsContext = this._profileConditionsUtil.normalizeContext(optionsContext); + + let index = 0; + for (const profile of options.profiles) { + const conditionGroups = profile.conditionGroups; + + let schema; + if (index < this._profileConditionsSchemaCache.length) { + schema = this._profileConditionsSchemaCache[index]; + } else { + schema = this._profileConditionsUtil.createSchema(conditionGroups); + this._profileConditionsSchemaCache.push(schema); + } + + if (conditionGroups.length > 0 && this._profileConditionsSchemaValidator.isValid(optionsContext, schema)) { + return profile; + } + ++index; + } + + return null; + } + + _clearProfileConditionsSchemaCache() { + this._profileConditionsSchemaCache = []; + this._profileConditionsSchemaValidator.clearCache(); + } + + _checkLastError() { + // NOP + } + + _runCommand(command, params) { + const handler = this._commandHandlers.get(command); + if (typeof handler !== 'function') { return false; } + + handler(params); + return true; + } + + async _textParseScanning(text, options) { + const jp = this._japaneseUtil; + const {scanning: {length: scanningLength}, parsing: {readingMode}} = options; + const findTermsOptions = this._getTranslatorFindTermsOptions({wildcard: null}, options); + const results = []; + while (text.length > 0) { + const term = []; + const [definitions, sourceLength] = await this._translator.findTerms( + 'simple', + text.substring(0, scanningLength), + findTermsOptions + ); + if (definitions.length > 0 && sourceLength > 0) { + const {expression, reading} = definitions[0]; + const source = text.substring(0, sourceLength); + for (const {text: text2, furigana} of jp.distributeFuriganaInflected(expression, reading, source)) { + const reading2 = jp.convertReading(text2, furigana, readingMode); + term.push({text: text2, reading: reading2}); + } + text = text.substring(source.length); + } else { + const reading = jp.convertReading(text[0], '', readingMode); + term.push({text: text[0], reading}); + text = text.substring(1); + } + results.push(term); + } + return results; + } + + async _textParseMecab(text, options) { + const jp = this._japaneseUtil; + const {parsing: {readingMode}} = options; + + let parseTextResults; + try { + parseTextResults = await this._mecab.parseText(text); + } catch (e) { + return []; + } + + const results = []; + for (const {name, lines} of parseTextResults) { + const result = []; + for (const line of lines) { + for (const {expression, reading, source} of line) { + const term = []; + for (const {text: text2, furigana} of jp.distributeFuriganaInflected( + expression.length > 0 ? expression : source, + jp.convertKatakanaToHiragana(reading), + source + )) { + const reading2 = jp.convertReading(text2, furigana, readingMode); + term.push({text: text2, reading: reading2}); + } + result.push(term); + } + result.push([{text: '\n', reading: ''}]); + } + results.push([name, result]); + } + return results; + } + + _createActionListenerPort(port, sender, handlers) { + let hasStarted = false; + let messageString = ''; + + const onProgress = (...data) => { + try { + if (port === null) { return; } + port.postMessage({type: 'progress', data}); + } catch (e) { + // NOP + } + }; + + const onMessage = (message) => { + if (hasStarted) { return; } + + try { + const {action, data} = message; + switch (action) { + case 'fragment': + messageString += data; + break; + case 'invoke': + { + hasStarted = true; + port.onMessage.removeListener(onMessage); + + const messageData = JSON.parse(messageString); + messageString = null; + onMessageComplete(messageData); + } + break; + } + } catch (e) { + cleanup(e); + } + }; + + const onMessageComplete = async (message) => { + try { + const {action, params} = message; + port.postMessage({type: 'ack'}); + + const messageHandler = handlers.get(action); + if (typeof messageHandler === 'undefined') { + throw new Error('Invalid action'); + } + const {handler, async, contentScript} = messageHandler; + + if (!contentScript) { + this._validatePrivilegedMessageSender(sender); + } + + const promiseOrResult = handler(params, sender, onProgress); + const result = async ? await promiseOrResult : promiseOrResult; + port.postMessage({type: 'complete', data: result}); + } catch (e) { + cleanup(e); + } + }; + + const onDisconnect = () => { + cleanup(null); + }; + + const cleanup = (error) => { + if (port === null) { return; } + if (error !== null) { + port.postMessage({type: 'error', data: serializeError(error)}); + } + if (!hasStarted) { + port.onMessage.removeListener(onMessage); + } + port.onDisconnect.removeListener(onDisconnect); + port = null; + handlers = null; + }; + + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(onDisconnect); + } + + _getErrorLevelValue(errorLevel) { + switch (errorLevel) { + case 'info': return 0; + case 'debug': return 0; + case 'warn': return 1; + case 'error': return 2; + default: return 0; + } + } + + _getModifySettingObject(target) { + const scope = target.scope; + switch (scope) { + case 'profile': + if (!isObject(target.optionsContext)) { throw new Error('Invalid optionsContext'); } + return this._getProfileOptions(target.optionsContext, true); + case 'global': + return this._getOptionsFull(true); + default: + throw new Error(`Invalid scope: ${scope}`); + } + } + + _getSetting(target) { + const options = this._getModifySettingObject(target); + const accessor = new ObjectPropertyAccessor(options); + const {path} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + return accessor.get(ObjectPropertyAccessor.getPathArray(path)); + } + + _modifySetting(target) { + const options = this._getModifySettingObject(target); + const accessor = new ObjectPropertyAccessor(options); + const action = target.action; + switch (action) { + case 'set': + { + const {path, value} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + const pathArray = ObjectPropertyAccessor.getPathArray(path); + accessor.set(pathArray, value); + return accessor.get(pathArray); + } + case 'delete': + { + const {path} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + accessor.delete(ObjectPropertyAccessor.getPathArray(path)); + return true; + } + case 'swap': + { + const {path1, path2} = target; + if (typeof path1 !== 'string') { throw new Error('Invalid path1'); } + if (typeof path2 !== 'string') { throw new Error('Invalid path2'); } + accessor.swap(ObjectPropertyAccessor.getPathArray(path1), ObjectPropertyAccessor.getPathArray(path2)); + return true; + } + case 'splice': + { + const {path, start, deleteCount, items} = target; + if (typeof path !== 'string') { throw new Error('Invalid path'); } + if (typeof start !== 'number' || Math.floor(start) !== start) { throw new Error('Invalid start'); } + if (typeof deleteCount !== 'number' || Math.floor(deleteCount) !== deleteCount) { throw new Error('Invalid deleteCount'); } + if (!Array.isArray(items)) { throw new Error('Invalid items'); } + const array = accessor.get(ObjectPropertyAccessor.getPathArray(path)); + if (!Array.isArray(array)) { throw new Error('Invalid target type'); } + return array.splice(start, deleteCount, ...items); + } + default: + throw new Error(`Unknown action: ${action}`); + } + } + + _validatePrivilegedMessageSender(sender) { + const url = sender.url; + if (!(typeof url === 'string' && yomichan.isExtensionUrl(url))) { + throw new Error('Invalid message sender'); + } + } + + _getBrowserIconTitle() { + return ( + isObject(chrome.browserAction) && + typeof chrome.browserAction.getTitle === 'function' ? + new Promise((resolve) => chrome.browserAction.getTitle({}, resolve)) : + Promise.resolve('') + ); + } + + _updateBadge() { + let title = this._defaultBrowserActionTitle; + if (title === null || !isObject(chrome.browserAction)) { + // Not ready or invalid + return; + } + + let text = ''; + let color = null; + let status = null; + + if (this._logErrorLevel !== null) { + switch (this._logErrorLevel) { + case 'error': + text = '!!'; + color = '#f04e4e'; + status = 'Error'; + break; + default: // 'warn' + text = '!'; + color = '#f0ad4e'; + status = 'Warning'; + break; + } + } else if (!this._isPrepared) { + if (this._prepareError) { + text = '!!'; + color = '#f04e4e'; + status = 'Error'; + } else if (this._badgePrepareDelayTimer === null) { + text = '...'; + color = '#f0ad4e'; + status = 'Loading'; + } + } else { + const options = this._getProfileOptions({current: true}); + if (!options.general.enable) { + text = 'off'; + color = '#555555'; + status = 'Disabled'; + } else if (!this._hasRequiredPermissionsForSettings(options)) { + text = '!'; + color = '#f0ad4e'; + status = 'Some settings require additional permissions'; + } else if (!this._isAnyDictionaryEnabled(options)) { + text = '!'; + color = '#f0ad4e'; + status = 'No dictionaries installed'; + } + } + + if (color !== null && typeof chrome.browserAction.setBadgeBackgroundColor === 'function') { + chrome.browserAction.setBadgeBackgroundColor({color}); + } + if (text !== null && typeof chrome.browserAction.setBadgeText === 'function') { + chrome.browserAction.setBadgeText({text}); + } + if (typeof chrome.browserAction.setTitle === 'function') { + if (status !== null) { + title = `${title} - ${status}`; + } + chrome.browserAction.setTitle({title}); + } + } + + _isAnyDictionaryEnabled(options) { + for (const {enabled} of Object.values(options.dictionaries)) { + if (enabled) { + return true; + } + } + return false; + } + + _anyOptionsMatches(predicate) { + for (const {options} of this._options.profiles) { + const value = predicate(options); + if (value) { return value; } + } + return false; + } + + async _getTabUrl(tabId) { + try { + const {url} = await this._sendMessageTabPromise( + tabId, + {action: 'getUrl', params: {}}, + {frameId: 0} + ); + if (typeof url === 'string') { + return url; + } + } catch (e) { + // NOP + } + return null; + } + + _getAllTabs() { + return new Promise((resolve, reject) => { + chrome.tabs.query({}, (tabs) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(tabs); + } + }); + }); + } + + async _findTabs(timeout, multiple, predicate, predicateIsAsync) { + // This function works around the need to have the "tabs" permission to access tab.url. + const tabs = await this._getAllTabs(); + + let done = false; + const checkTab = async (tab, add) => { + const url = await this._getTabUrl(tab.id); + + if (done) { return; } + + let okay = false; + const item = {tab, url}; + try { + okay = predicate(item); + if (predicateIsAsync) { okay = await okay; } + } catch (e) { + // NOP + } + + if (okay && !done) { + if (add(item)) { + done = true; + } + } + }; + + if (multiple) { + const results = []; + const add = (value) => { + results.push(value); + return false; + }; + const checkTabPromises = tabs.map((tab) => checkTab(tab, add)); + await Promise.race([ + Promise.all(checkTabPromises), + promiseTimeout(timeout) + ]); + return results; + } else { + const {promise, resolve} = deferPromise(); + let result = null; + const add = (value) => { + result = value; + resolve(); + return true; + }; + const checkTabPromises = tabs.map((tab) => checkTab(tab, add)); + await Promise.race([ + promise, + Promise.all(checkTabPromises), + promiseTimeout(timeout) + ]); + resolve(); + return result; + } + } + + async _focusTab(tab) { + await new Promise((resolve, reject) => { + chrome.tabs.update(tab.id, {active: true}, () => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } 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, reject) => { + chrome.windows.get(tab.windowId, {}, (value) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(value); + } + }); + }); + if (!tabWindow.focused) { + await new Promise((resolve, reject) => { + chrome.windows.update(tab.windowId, {focused: true}, () => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(); + } + }); + }); + } + } catch (e) { + // Edge throws exception for no reason here. + } + } + + _waitUntilTabFrameIsReady(tabId, frameId, timeout=null) { + return new Promise((resolve, reject) => { + let timer = null; + let onMessage = (message, sender) => { + if ( + !sender.tab || + sender.tab.id !== tabId || + sender.frameId !== frameId || + !isObject(message) || + message.action !== 'yomichanReady' + ) { + return; + } + + cleanup(); + resolve(); + }; + const cleanup = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + if (onMessage !== null) { + chrome.runtime.onMessage.removeListener(onMessage); + onMessage = null; + } + }; + + chrome.runtime.onMessage.addListener(onMessage); + + this._sendMessageTabPromise(tabId, {action: 'isReady'}, {frameId}) + .then( + (value) => { + if (!value) { return; } + cleanup(); + resolve(); + }, + () => {} // NOP + ); + + if (timeout !== null) { + timer = setTimeout(() => { + timer = null; + cleanup(); + reject(new Error('Timeout')); + }, timeout); + } + }); + } + + async _fetchAsset(url, json=false) { + const response = await fetch(chrome.runtime.getURL(url), { + method: 'GET', + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' + }); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status}`); + } + return await (json ? response.json() : response.text()); + } + + _sendMessageIgnoreResponse(...args) { + const callback = () => this._checkLastError(chrome.runtime.lastError); + chrome.runtime.sendMessage(...args, callback); + } + + _sendMessageTabIgnoreResponse(...args) { + const callback = () => this._checkLastError(chrome.runtime.lastError); + chrome.tabs.sendMessage(...args, callback); + } + + _sendMessageAllTabsIgnoreResponse(action, params) { + const callback = () => this._checkLastError(chrome.runtime.lastError); + chrome.tabs.query({}, (tabs) => { + for (const tab of tabs) { + chrome.tabs.sendMessage(tab.id, {action, params}, callback); + } + }); + } + + _sendMessageTabPromise(...args) { + return new Promise((resolve, reject) => { + const callback = (response) => { + try { + resolve(yomichan.getMessageResponseResult(response)); + } catch (error) { + reject(error); + } + }; + + chrome.tabs.sendMessage(...args, callback); + }); + } + + async _checkTabUrl(tabId, urlPredicate) { + let tab; + try { + tab = await this._getTabById(tabId); + } catch (e) { + return null; + } + + const url = await this._getTabUrl(tabId); + const isValidTab = urlPredicate(url); + return isValidTab ? tab : null; + } + + async _getScreenshot(tabId, frameId, format, quality) { + const tab = await this._getTabById(tabId); + const {windowId} = tab; + + let token = null; + try { + if (typeof tabId === 'number' && typeof frameId === 'number') { + const action = 'setAllVisibleOverride'; + const params = {value: false, priority: 0, awaitFrame: true}; + token = await this._sendMessageTabPromise(tabId, {action, params}, {frameId}); + } + + return await new Promise((resolve, reject) => { + chrome.tabs.captureVisibleTab(windowId, {format, quality}, (result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + }); + }); + } finally { + if (token !== null) { + const action = 'clearAllVisibleOverride'; + const params = {token}; + try { + await this._sendMessageTabPromise(tabId, {action, params}, {frameId}); + } catch (e) { + // NOP + } + } + } + } + + async _downloadDefinitionAudio(sources, expression, reading, details) { + return await this._audioDownloader.downloadExpressionAudio(sources, expression, reading, details); + } + + async _injectAnkNoteMedia(ankiConnect, timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails) { + let screenshotFileName = null; + let clipboardImageFileName = null; + let clipboardText = null; + let audioFileName = null; + const errors = []; + + try { + if (screenshotDetails !== null) { + screenshotFileName = await this._injectAnkNoteScreenshot(ankiConnect, timestamp, definitionDetails, screenshotDetails); + } + } catch (e) { + errors.push(serializeError(e)); + } + + try { + if (clipboardDetails !== null && clipboardDetails.image) { + clipboardImageFileName = await this._injectAnkNoteClipboardImage(ankiConnect, timestamp, definitionDetails); + } + } catch (e) { + errors.push(serializeError(e)); + } + + try { + if (clipboardDetails !== null && clipboardDetails.text) { + clipboardText = await this._clipboardReader.getText(); + } + } catch (e) { + errors.push(serializeError(e)); + } + + try { + if (audioDetails !== null) { + audioFileName = await this._injectAnkNoteAudio(ankiConnect, timestamp, definitionDetails, audioDetails); + } + } catch (e) { + errors.push(serializeError(e)); + } + + return { + result: { + screenshotFileName, + clipboardImageFileName, + clipboardText, + audioFileName + }, + errors + }; + } + + async _injectAnkNoteAudio(ankiConnect, timestamp, definitionDetails, details) { + const {type, expression, reading} = definitionDetails; + if ( + type === 'kanji' || + typeof expression !== 'string' || + typeof reading !== 'string' || + (expression.length === 0 && reading.length === 0) + ) { + return null; + } + + const {sources, customSourceUrl, customSourceType} = details; + let data; + let contentType; + try { + ({data, contentType} = await this._downloadDefinitionAudio( + sources, + expression, + reading, + { + textToSpeechVoice: null, + customSourceUrl, + customSourceType, + binary: true, + disableCache: true + } + )); + } catch (e) { + // No audio + return null; + } + + let extension = this._mediaUtility.getFileExtensionFromAudioMediaType(contentType); + if (extension === null) { extension = '.mp3'; } + let fileName = this._generateAnkiNoteMediaFileName('yomichan_audio', extension, timestamp, definitionDetails); + fileName = fileName.replace(/\]/g, ''); + await ankiConnect.storeMediaFile(fileName, data); + + return fileName; + } + + async _injectAnkNoteScreenshot(ankiConnect, timestamp, definitionDetails, details) { + const {tabId, frameId, format, quality} = details; + const dataUrl = await this._getScreenshot(tabId, frameId, format, quality); + + const {mediaType, data} = this._getDataUrlInfo(dataUrl); + const extension = this._mediaUtility.getFileExtensionFromImageMediaType(mediaType); + if (extension === null) { + throw new Error('Unknown media type for screenshot image'); + } + + const fileName = this._generateAnkiNoteMediaFileName('yomichan_browser_screenshot', extension, timestamp, definitionDetails); + await ankiConnect.storeMediaFile(fileName, data); + + return fileName; + } + + async _injectAnkNoteClipboardImage(ankiConnect, timestamp, definitionDetails) { + const dataUrl = await this._clipboardReader.getImage(); + if (dataUrl === null) { + return null; + } + + const {mediaType, data} = this._getDataUrlInfo(dataUrl); + const extension = this._mediaUtility.getFileExtensionFromImageMediaType(mediaType); + if (extension === null) { + throw new Error('Unknown media type for clipboard image'); + } + + const fileName = this._generateAnkiNoteMediaFileName('yomichan_clipboard_image', extension, timestamp, definitionDetails); + await ankiConnect.storeMediaFile(fileName, data); + + return fileName; + } + + _generateAnkiNoteMediaFileName(prefix, extension, timestamp, definitionDetails) { + let fileName = prefix; + + switch (definitionDetails.type) { + case 'kanji': + { + const {character} = definitionDetails; + if (character) { fileName += `_${character}`; } + } + break; + default: + { + const {reading, expression} = definitionDetails; + if (reading) { fileName += `_${reading}`; } + if (expression) { fileName += `_${expression}`; } + } + break; + } + + fileName += `_${this._ankNoteDateToString(new Date(timestamp))}`; + fileName += extension; + + fileName = this._replaceInvalidFileNameCharacters(fileName); + + return fileName; + } + + _replaceInvalidFileNameCharacters(fileName) { + // eslint-disable-next-line no-control-regex + return fileName.replace(/[<>:"/\\|?*\x00-\x1F]/g, '-'); + } + + _ankNoteDateToString(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}`; + } + + _getDataUrlInfo(dataUrl) { + const match = /^data:([^,]*?)(;base64)?,/.exec(dataUrl); + if (match === null) { + throw new Error('Invalid data URL'); + } + + let mediaType = match[1]; + if (mediaType.length === 0) { mediaType = 'text/plain'; } + + let data = dataUrl.substring(match[0].length); + if (typeof match[2] === 'undefined') { data = btoa(data); } + + return {mediaType, data}; + } + + _triggerDatabaseUpdated(type, cause) { + this._translator.clearDatabaseCaches(); + this._sendMessageAllTabsIgnoreResponse('databaseUpdated', {type, cause}); + } + + async _saveOptions(source) { + this._clearProfileConditionsSchemaCache(); + const options = this._getOptionsFull(); + await this._optionsUtil.save(options); + this._applyOptions(source); + } + + _getTranslatorFindTermsOptions(details, options) { + const {wildcard} = details; + const enabledDictionaryMap = this._getTranslatorEnabledDictionaryMap(options); + const { + general: {mainDictionary}, + scanning: {alphanumeric}, + translation: { + convertHalfWidthCharacters, + convertNumericCharacters, + convertAlphabeticCharacters, + convertHiraganaToKatakana, + convertKatakanaToHiragana, + collapseEmphaticSequences, + textReplacements: textReplacementsOptions + } + } = options; + const textReplacements = this._getTranslatorTextReplacements(textReplacementsOptions); + return { + wildcard, + mainDictionary, + alphanumeric, + convertHalfWidthCharacters, + convertNumericCharacters, + convertAlphabeticCharacters, + convertHiraganaToKatakana, + convertKatakanaToHiragana, + collapseEmphaticSequences, + textReplacements, + enabledDictionaryMap + }; + } + + _getTranslatorFindKanjiOptions(options) { + const enabledDictionaryMap = this._getTranslatorEnabledDictionaryMap(options); + return {enabledDictionaryMap}; + } + + _getTranslatorEnabledDictionaryMap(options) { + const enabledDictionaryMap = new Map(); + for (const [title, {enabled, priority, allowSecondarySearches}] of Object.entries(options.dictionaries)) { + if (!enabled) { continue; } + enabledDictionaryMap.set(title, {priority, allowSecondarySearches}); + } + return enabledDictionaryMap; + } + + _getTranslatorTextReplacements(textReplacementsOptions) { + const textReplacements = []; + for (const group of textReplacementsOptions.groups) { + const textReplacementsEntries = []; + for (let {pattern, ignoreCase, replacement} of group) { + try { + pattern = new RegExp(pattern, ignoreCase ? 'gi' : 'g'); + } catch (e) { + // Invalid pattern + continue; + } + textReplacementsEntries.push({pattern, replacement}); + } + if (textReplacementsEntries.length > 0) { + textReplacements.push(textReplacementsEntries); + } + } + if (textReplacements.length === 0 || textReplacementsOptions.searchOriginal) { + textReplacements.unshift(null); + } + return textReplacements; + } + + async _openWelcomeGuidePage() { + await this._createTab(chrome.runtime.getURL('/welcome.html')); + } + + async _openInfoPage() { + await this._createTab(chrome.runtime.getURL('/info.html')); + } + + async _openSettingsPage(mode) { + const {useSettingsV2} = this._options.global; + const manifest = chrome.runtime.getManifest(); + const url = chrome.runtime.getURL(useSettingsV2 ? manifest.options_ui.page : '/settings-old.html'); + switch (mode) { + case 'existingOrNewTab': + if (useSettingsV2) { + const predicate = ({url: url2}) => (url2 !== null && url2.startsWith(url)); + const tab = await this._findTabs(1000, false, predicate, false); + if (tab !== null) { + await this._focusTab(tab); + } else { + await this._createTab(url); + } + } else { + await new Promise((resolve, reject) => { + chrome.runtime.openOptionsPage(() => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(); + } + }); + }); + } + break; + case 'newTab': + await this._createTab(url); + break; + } + } + + _createTab(url) { + return new Promise((resolve, reject) => { + chrome.tabs.create({url}, (tab) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(tab); + } + }); + }); + } + + _injectStylesheet(type, value, target) { + if (isObject(chrome.tabs) && typeof chrome.tabs.insertCSS === 'function') { + return this._injectStylesheetMV2(type, value, target); + } else if (isObject(chrome.scripting) && typeof chrome.scripting.insertCSS === 'function') { + return this._injectStylesheetMV3(type, value, target); + } else { + return Promise.reject(new Error('insertCSS function not available')); + } + } + + _injectStylesheetMV2(type, value, target) { + return new Promise((resolve, reject) => { + if (!target.tab) { + reject(new Error('Invalid tab')); + return; + } + + const tabId = target.tab.id; + const frameId = target.frameId; + const details = ( + type === 'file' ? + { + file: value, + runAt: 'document_start', + cssOrigin: 'author', + allFrames: false, + matchAboutBlank: true + } : + { + code: value, + runAt: 'document_start', + cssOrigin: 'user', + allFrames: false, + matchAboutBlank: true + } + ); + if (typeof frameId === 'number') { + details.frameId = frameId; + } + + chrome.tabs.insertCSS(tabId, details, () => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(); + } + }); + }); + } + + _injectStylesheetMV3(type, value, target) { + return new Promise((resolve, reject) => { + if (!target.tab) { + reject(new Error('Invalid tab')); + return; + } + + const tabId = target.tab.id; + const frameId = target.frameId; + const details = ( + type === 'file' ? + {origin: chrome.scripting.StyleOrigin.AUTHOR, files: [value]} : + {origin: chrome.scripting.StyleOrigin.USER, css: value} + ); + details.target = { + tabId, + allFrames: false + }; + if (typeof frameId === 'number') { + details.target.frameIds = [frameId]; + } + + chrome.scripting.insertCSS(details, () => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(); + } + }); + }); + } + + _getTabById(tabId) { + return new Promise((resolve, reject) => { + chrome.tabs.get( + tabId, + (result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + } + ); + }); + } + + async _checkPermissions() { + this._permissions = await this._permissionsUtil.getAllPermissions(); + this._updateBadge(); + } + + _hasRequiredPermissionsForSettings(options) { + return this._permissions === null || this._permissionsUtil.hasRequiredPermissionsForOptions(this._permissions, options); + } +} diff --git a/ext/js/background/background-main.js b/ext/js/background/background-main.js new file mode 100644 index 00000000..01e57d0f --- /dev/null +++ b/ext/js/background/background-main.js @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * Backend + */ + +(() => { + const backend = new Backend(); + backend.prepare(); +})(); diff --git a/ext/js/background/profile-conditions.js b/ext/js/background/profile-conditions.js new file mode 100644 index 00000000..8e6c7163 --- /dev/null +++ b/ext/js/background/profile-conditions.js @@ -0,0 +1,276 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/** + * Utility class to help processing profile conditions. + */ +class ProfileConditions { + /** + * Creates a new instance. + */ + constructor() { + this._splitPattern = /[,;\s]+/; + this._descriptors = new Map([ + [ + 'popupLevel', + { + operators: new Map([ + ['equal', this._createSchemaPopupLevelEqual.bind(this)], + ['notEqual', this._createSchemaPopupLevelNotEqual.bind(this)], + ['lessThan', this._createSchemaPopupLevelLessThan.bind(this)], + ['greaterThan', this._createSchemaPopupLevelGreaterThan.bind(this)], + ['lessThanOrEqual', this._createSchemaPopupLevelLessThanOrEqual.bind(this)], + ['greaterThanOrEqual', this._createSchemaPopupLevelGreaterThanOrEqual.bind(this)] + ]) + } + ], + [ + 'url', + { + operators: new Map([ + ['matchDomain', this._createSchemaUrlMatchDomain.bind(this)], + ['matchRegExp', this._createSchemaUrlMatchRegExp.bind(this)] + ]) + } + ], + [ + 'modifierKeys', + { + operators: new Map([ + ['are', this._createSchemaModifierKeysAre.bind(this)], + ['areNot', this._createSchemaModifierKeysAreNot.bind(this)], + ['include', this._createSchemaModifierKeysInclude.bind(this)], + ['notInclude', this._createSchemaModifierKeysNotInclude.bind(this)] + ]) + } + ] + ]); + } + + /** + * Creates a new JSON schema descriptor for the given set of condition groups. + * @param conditionGroups An array of condition groups in the following format: + * conditionGroups = [ + * { + * conditions: [ + * { + * type: (condition type: string), + * operator: (condition sub-type: string), + * value: (value to compare against: string) + * }, + * ... + * ] + * }, + * ... + * ] + */ + createSchema(conditionGroups) { + const anyOf = []; + for (const {conditions} of conditionGroups) { + const allOf = []; + for (const {type, operator, value} of conditions) { + const conditionDescriptor = this._descriptors.get(type); + if (typeof conditionDescriptor === 'undefined') { continue; } + + const createSchema = conditionDescriptor.operators.get(operator); + if (typeof createSchema === 'undefined') { continue; } + + const schema = createSchema(value); + allOf.push(schema); + } + switch (allOf.length) { + case 0: break; + case 1: anyOf.push(allOf[0]); break; + default: anyOf.push({allOf}); break; + } + } + switch (anyOf.length) { + case 0: return {}; + case 1: return anyOf[0]; + default: return {anyOf}; + } + } + + /** + * Creates a normalized version of the context object to test, + * assigning dependent fields as needed. + * @param context A context object which is used during schema validation. + * @returns A normalized context object. + */ + normalizeContext(context) { + const normalizedContext = Object.assign({}, context); + const {url} = normalizedContext; + if (typeof url === 'string') { + try { + normalizedContext.domain = new URL(url).hostname; + } catch (e) { + // NOP + } + } + return normalizedContext; + } + + // Private + + _split(value) { + return value.split(this._splitPattern); + } + + _stringToNumber(value) { + const number = Number.parseFloat(value); + return Number.isFinite(number) ? number : 0; + } + + // popupLevel schema creation functions + + _createSchemaPopupLevelEqual(value) { + value = this._stringToNumber(value); + return { + required: ['depth'], + properties: { + depth: {const: value} + } + }; + } + + _createSchemaPopupLevelNotEqual(value) { + return { + not: [this._createSchemaPopupLevelEqual(value)] + }; + } + + _createSchemaPopupLevelLessThan(value) { + value = this._stringToNumber(value); + return { + required: ['depth'], + properties: { + depth: {type: 'number', exclusiveMaximum: value} + } + }; + } + + _createSchemaPopupLevelGreaterThan(value) { + value = this._stringToNumber(value); + return { + required: ['depth'], + properties: { + depth: {type: 'number', exclusiveMinimum: value} + } + }; + } + + _createSchemaPopupLevelLessThanOrEqual(value) { + value = this._stringToNumber(value); + return { + required: ['depth'], + properties: { + depth: {type: 'number', maximum: value} + } + }; + } + + _createSchemaPopupLevelGreaterThanOrEqual(value) { + value = this._stringToNumber(value); + return { + required: ['depth'], + properties: { + depth: {type: 'number', minimum: value} + } + }; + } + + // url schema creation functions + + _createSchemaUrlMatchDomain(value) { + const oneOf = []; + for (let domain of this._split(value)) { + if (domain.length === 0) { continue; } + domain = domain.toLowerCase(); + oneOf.push({const: domain}); + } + return { + required: ['domain'], + properties: { + domain: {oneOf} + } + }; + } + + _createSchemaUrlMatchRegExp(value) { + return { + required: ['url'], + properties: { + url: {type: 'string', pattern: value, patternFlags: 'i'} + } + }; + } + + // modifierKeys schema creation functions + + _createSchemaModifierKeysAre(value) { + return this._createSchemaModifierKeysGeneric(value, true, false); + } + + _createSchemaModifierKeysAreNot(value) { + return { + not: [this._createSchemaModifierKeysGeneric(value, true, false)] + }; + } + + _createSchemaModifierKeysInclude(value) { + return this._createSchemaModifierKeysGeneric(value, false, false); + } + + _createSchemaModifierKeysNotInclude(value) { + return this._createSchemaModifierKeysGeneric(value, false, true); + } + + _createSchemaModifierKeysGeneric(value, exact, none) { + const containsList = []; + for (const modifierKey of this._split(value)) { + if (modifierKey.length === 0) { continue; } + containsList.push({ + contains: { + const: modifierKey + } + }); + } + const containsListCount = containsList.length; + const modifierKeysSchema = { + type: 'array' + }; + if (exact) { + modifierKeysSchema.maxItems = containsListCount; + } + if (none) { + if (containsListCount > 0) { + modifierKeysSchema.not = containsList; + } + } else { + modifierKeysSchema.minItems = containsListCount; + if (containsListCount > 0) { + modifierKeysSchema.allOf = containsList; + } + } + return { + required: ['modifierKeys'], + properties: { + modifierKeys: modifierKeysSchema + } + }; + } +} diff --git a/ext/js/background/request-builder.js b/ext/js/background/request-builder.js new file mode 100644 index 00000000..dda5825d --- /dev/null +++ b/ext/js/background/request-builder.js @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class RequestBuilder { + constructor() { + this._extraHeadersSupported = null; + this._onBeforeSendHeadersExtraInfoSpec = ['blocking', 'requestHeaders', 'extraHeaders']; + this._textEncoder = new TextEncoder(); + this._ruleIds = new Set(); + } + + async prepare() { + try { + await this._clearDynamicRules(); + } catch (e) { + // NOP + } + } + + async fetchAnonymous(url, init) { + if (isObject(chrome.declarativeNetRequest)) { + return await this._fetchAnonymousDeclarative(url, init); + } + const originURL = this._getOriginURL(url); + const modifications = [ + ['cookie', null], + ['origin', {name: 'Origin', value: originURL}] + ]; + return await this._fetchModifyHeaders(url, init, modifications); + } + + // Private + + async _fetchModifyHeaders(url, init, modifications) { + const matchURL = this._getMatchURL(url); + + let done = false; + const callback = (details) => { + if (done || details.url !== url) { return {}; } + done = true; + + const requestHeaders = details.requestHeaders; + this._modifyHeaders(requestHeaders, modifications); + return {requestHeaders}; + }; + const filter = { + urls: [matchURL], + types: ['xmlhttprequest'] + }; + + let needsCleanup = false; + try { + this._onBeforeSendHeadersAddListener(callback, filter); + needsCleanup = true; + } catch (e) { + // NOP + } + + try { + return await fetch(url, init); + } finally { + if (needsCleanup) { + try { + chrome.webRequest.onBeforeSendHeaders.removeListener(callback); + } catch (e) { + // NOP + } + } + } + } + + _onBeforeSendHeadersAddListener(callback, filter) { + const extraInfoSpec = this._onBeforeSendHeadersExtraInfoSpec; + for (let i = 0; i < 2; ++i) { + try { + chrome.webRequest.onBeforeSendHeaders.addListener(callback, filter, extraInfoSpec); + if (this._extraHeadersSupported === null) { + this._extraHeadersSupported = true; + } + break; + } catch (e) { + // Firefox doesn't support the 'extraHeaders' option and will throw the following error: + // Type error for parameter extraInfoSpec (Error processing 2: Invalid enumeration value "extraHeaders") for webRequest.onBeforeSendHeaders. + if (this._extraHeadersSupported !== null || !`${e.message}`.includes('extraHeaders')) { + throw e; + } + } + + // addListener failed; remove 'extraHeaders' from extraInfoSpec. + this._extraHeadersSupported = false; + const index = extraInfoSpec.indexOf('extraHeaders'); + if (index >= 0) { extraInfoSpec.splice(index, 1); } + } + } + + _getMatchURL(url) { + const url2 = new URL(url); + return `${url2.protocol}//${url2.host}${url2.pathname}`; + } + + _getOriginURL(url) { + const url2 = new URL(url); + return `${url2.protocol}//${url2.host}`; + } + + _modifyHeaders(headers, modifications) { + modifications = new Map(modifications); + + for (let i = 0, ii = headers.length; i < ii; ++i) { + const header = headers[i]; + const name = header.name.toLowerCase(); + const modification = modifications.get(name); + if (typeof modification === 'undefined') { continue; } + + modifications.delete(name); + + if (modification === null) { + headers.splice(i, 1); + --i; + --ii; + } else { + headers[i] = modification; + } + } + + for (const header of modifications.values()) { + if (header !== null) { + headers.push(header); + } + } + } + + async _clearDynamicRules() { + if (!isObject(chrome.declarativeNetRequest)) { return; } + + const rules = this._getDynamicRules(); + + if (rules.length === 0) { return; } + + const removeRuleIds = []; + for (const {id} of rules) { + removeRuleIds.push(id); + } + + await this._updateDynamicRules({removeRuleIds}); + } + + async _fetchAnonymousDeclarative(url, init) { + const id = this._getNewRuleId(); + const originUrl = this._getOriginURL(url); + url = encodeURI(decodeURI(url)); + + this._ruleIds.add(id); + try { + const addRules = [{ + id, + priority: 1, + condition: { + urlFilter: `|${this._escapeDnrUrl(url)}|`, + resourceTypes: ['xmlhttprequest'] + }, + action: { + type: 'modifyHeaders', + requestHeaders: [ + { + operation: 'remove', + header: 'Cookie' + }, + { + operation: 'set', + header: 'Origin', + value: originUrl + } + ], + responseHeaders: [ + { + operation: 'remove', + header: 'Set-Cookie' + } + ] + } + }]; + + await this._updateDynamicRules({addRules}); + try { + return await fetch(url, init); + } finally { + await this._tryUpdateDynamicRules({removeRuleIds: [id]}); + } + } finally { + this._ruleIds.delete(id); + } + } + + _getDynamicRules() { + return new Promise((resolve, reject) => { + chrome.declarativeNetRequest.getDynamicRules((result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + }); + }); + } + + _updateDynamicRules(options) { + return new Promise((resolve, reject) => { + chrome.declarativeNetRequest.updateDynamicRules(options, () => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(); + } + }); + }); + } + + async _tryUpdateDynamicRules(options) { + try { + await this._updateDynamicRules(options); + return true; + } catch (e) { + return false; + } + } + + _getNewRuleId() { + let id = 1; + while (this._ruleIds.has(id)) { + const pre = id; + ++id; + if (id === pre) { throw new Error('Could not generate an id'); } + } + return id; + } + + _escapeDnrUrl(url) { + return url.replace(/[|*^]/g, (char) => this._urlEncodeUtf8(char)); + } + + _urlEncodeUtf8(text) { + const array = this._textEncoder.encode(text); + let result = ''; + for (const byte of array) { + result += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; + } + return result; + } +} |