diff options
Diffstat (limited to 'ext/js')
36 files changed, 10859 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; + } +} diff --git a/ext/js/comm/anki.js b/ext/js/comm/anki.js new file mode 100644 index 00000000..251e0e0c --- /dev/null +++ b/ext/js/comm/anki.js @@ -0,0 +1,235 @@ +/* + * 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/>. + */ + +class AnkiConnect { + constructor() { + this._enabled = false; + this._server = null; + this._localVersion = 2; + this._remoteVersion = 0; + this._versionCheckPromise = null; + } + + get server() { + return this._server; + } + + set server(value) { + this._server = value; + } + + get enabled() { + return this._enabled; + } + + set enabled(value) { + this._enabled = value; + } + + async isConnected() { + try { + await this._invoke('version'); + return true; + } catch (e) { + return false; + } + } + + async getVersion() { + if (!this._enabled) { return null; } + await this._checkVersion(); + return await this._invoke('version', {}); + } + + async addNote(note) { + if (!this._enabled) { return null; } + await this._checkVersion(); + return await this._invoke('addNote', {note}); + } + + async canAddNotes(notes) { + if (!this._enabled) { return []; } + await this._checkVersion(); + return await this._invoke('canAddNotes', {notes}); + } + + async getDeckNames() { + if (!this._enabled) { return []; } + await this._checkVersion(); + return await this._invoke('deckNames'); + } + + async getModelNames() { + if (!this._enabled) { return []; } + await this._checkVersion(); + return await this._invoke('modelNames'); + } + + async getModelFieldNames(modelName) { + if (!this._enabled) { return []; } + await this._checkVersion(); + return await this._invoke('modelFieldNames', {modelName}); + } + + async guiBrowse(query) { + if (!this._enabled) { return []; } + await this._checkVersion(); + return await this._invoke('guiBrowse', {query}); + } + + async guiBrowseNote(noteId) { + return await this.guiBrowse(`nid:${noteId}`); + } + + async storeMediaFile(fileName, dataBase64) { + if (!this._enabled) { + throw new Error('AnkiConnect not enabled'); + } + await this._checkVersion(); + return await this._invoke('storeMediaFile', {filename: fileName, data: dataBase64}); + } + + async findNoteIds(notes) { + if (!this._enabled) { return []; } + await this._checkVersion(); + const actions = notes.map((note) => { + let query = ''; + switch (this._getDuplicateScopeFromNote(note)) { + case 'deck': + query = `"deck:${this._escapeQuery(note.deckName)}" `; + break; + case 'deck-root': + query = `"deck:${this._escapeQuery(this.getRootDeckName(note.deckName))}" `; + break; + } + query += this._fieldsToQuery(note.fields); + return {action: 'findNotes', params: {query}}; + }); + return await this._invoke('multi', {actions}); + } + + async suspendCards(cardIds) { + if (!this._enabled) { return false; } + await this._checkVersion(); + return await this._invoke('suspend', {cards: cardIds}); + } + + async findCards(query) { + if (!this._enabled) { return []; } + await this._checkVersion(); + return await this._invoke('findCards', {query}); + } + + async findCardsForNote(noteId) { + return await this.findCards(`nid:${noteId}`); + } + + getRootDeckName(deckName) { + const index = deckName.indexOf('::'); + return index >= 0 ? deckName.substring(0, index) : deckName; + } + + // Private + + async _checkVersion() { + if (this._remoteVersion < this._localVersion) { + if (this._versionCheckPromise === null) { + const promise = this._invoke('version'); + promise + .catch(() => {}) + .finally(() => { this._versionCheckPromise = null; }); + this._versionCheckPromise = promise; + } + this._remoteVersion = await this._versionCheckPromise; + if (this._remoteVersion < this._localVersion) { + throw new Error('Extension and plugin versions incompatible'); + } + } + } + + async _invoke(action, params) { + let response; + try { + response = await fetch(this._server, { + method: 'POST', + mode: 'cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({action, params, version: this._localVersion}) + }); + } catch (e) { + const error = new Error('Anki connection failure'); + error.data = {action, params}; + throw error; + } + + if (!response.ok) { + const error = new Error(`Anki connection error: ${response.status}`); + error.data = {action, params, status: response.status}; + throw error; + } + + let responseText = null; + let result; + try { + responseText = await response.text(); + result = JSON.parse(responseText); + } catch (e) { + const error = new Error('Invalid Anki response'); + error.data = {action, params, status: response.status, responseText}; + throw error; + } + + if (isObject(result)) { + const apiError = result.error; + if (typeof apiError !== 'undefined') { + const error = new Error(`Anki error: ${apiError}`); + error.data = {action, params, status: response.status, apiError}; + throw error; + } + } + + return result; + } + + _escapeQuery(text) { + return text.replace(/"/g, ''); + } + + _fieldsToQuery(fields) { + const fieldNames = Object.keys(fields); + if (fieldNames.length === 0) { + return ''; + } + + const key = fieldNames[0]; + return `"${key.toLowerCase()}:${this._escapeQuery(fields[key])}"`; + } + + _getDuplicateScopeFromNote(note) { + const {options} = note; + if (typeof options === 'object' && options !== null) { + const {duplicateScope} = options; + if (typeof duplicateScope !== 'undefined') { + return duplicateScope; + } + } + return null; + } +} diff --git a/ext/js/comm/clipboard-monitor.js b/ext/js/comm/clipboard-monitor.js new file mode 100644 index 00000000..7379d7ad --- /dev/null +++ b/ext/js/comm/clipboard-monitor.js @@ -0,0 +1,80 @@ +/* + * 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 ClipboardMonitor extends EventDispatcher { + constructor({japaneseUtil, clipboardReader}) { + super(); + this._japaneseUtil = japaneseUtil; + this._clipboardReader = clipboardReader; + this._timerId = null; + this._timerToken = null; + this._interval = 250; + this._previousText = null; + } + + start() { + this.stop(); + + // The token below is used as a unique identifier to ensure that a new clipboard monitor + // hasn't been started during the await call. The check below the await call + // will exit early if the reference has changed. + let canChange = false; + const token = {}; + const intervalCallback = async () => { + this._timerId = null; + + let text = null; + try { + text = await this._clipboardReader.getText(); + } catch (e) { + // NOP + } + if (this._timerToken !== token) { return; } + + if ( + typeof text === 'string' && + (text = text.trim()).length > 0 && + text !== this._previousText + ) { + this._previousText = text; + if (canChange && this._japaneseUtil.isStringPartiallyJapanese(text)) { + this.trigger('change', {text}); + } + } + + canChange = true; + this._timerId = setTimeout(intervalCallback, this._interval); + }; + + this._timerToken = token; + + intervalCallback(); + } + + stop() { + this._timerToken = null; + this._previousText = null; + if (this._timerId !== null) { + clearTimeout(this._timerId); + this._timerId = null; + } + } + + setPreviousText(text) { + this._previousText = text; + } +} diff --git a/ext/js/comm/clipboard-reader.js b/ext/js/comm/clipboard-reader.js new file mode 100644 index 00000000..275c2d60 --- /dev/null +++ b/ext/js/comm/clipboard-reader.js @@ -0,0 +1,169 @@ +/* + * 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 which can read text and images from the clipboard. + */ +class ClipboardReader { + /** + * Creates a new instances of a clipboard reader. + * @param document The Document object to be used, or null for no support. + * @param pasteTargetSelector The selector for the paste target element. + * @param imagePasteTargetSelector The selector for the image paste target element. + */ + constructor({document=null, pasteTargetSelector=null, imagePasteTargetSelector=null, mediaUtility=null}) { + this._document = document; + this._browser = null; + this._pasteTarget = null; + this._pasteTargetSelector = pasteTargetSelector; + this._imagePasteTarget = null; + this._imagePasteTargetSelector = imagePasteTargetSelector; + this._mediaUtility = mediaUtility; + } + + /** + * Gets the browser being used. + */ + get browser() { + return this._browser; + } + + /** + * Assigns the browser being used. + */ + set browser(value) { + this._browser = value; + } + + /** + * Gets the text in the clipboard. + * @returns A string containing the clipboard text. + * @throws Error if not supported. + */ + async getText() { + /* + Notes: + document.execCommand('paste') doesn't work on Firefox. + See: 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. + */ + if (this._isFirefox()) { + try { + return await navigator.clipboard.readText(); + } catch (e) { + // Error is undefined, due to permissions + throw new Error('Cannot read clipboard text; check extension permissions'); + } + } + + const document = this._document; + if (document === null) { + throw new Error('Clipboard reading not supported in this context'); + } + + let target = this._pasteTarget; + if (target === null) { + target = document.querySelector(this._pasteTargetSelector); + if (target === null) { + throw new Error('Clipboard paste target does not exist'); + } + this._pasteTarget = target; + } + + target.value = ''; + target.focus(); + document.execCommand('paste'); + const result = target.value; + target.value = ''; + return (typeof result === 'string' ? result : ''); + } + + /** + * Gets the first image in the clipboard. + * @returns A string containing a data URL of the image file, or null if no image was found. + * @throws Error if not supported. + */ + async getImage() { + // See browser-specific notes in getText + if ( + this._isFirefox() && + this._mediaUtility !== null && + typeof navigator.clipboard !== 'undefined' && + typeof navigator.clipboard.read === 'function' + ) { + // This function is behind the Firefox flag: dom.events.asyncClipboard.dataTransfer + let files; + try { + ({files} = await navigator.clipboard.read()); + } catch (e) { + return null; + } + + for (const file of files) { + if (this._mediaUtility.getFileExtensionFromImageMediaType(file.type) !== null) { + return await this._readFileAsDataURL(file); + } + } + return null; + } + + const document = this._document; + if (document === null) { + throw new Error('Clipboard reading not supported in this context'); + } + + let target = this._imagePasteTarget; + if (target === null) { + target = document.querySelector(this._imagePasteTargetSelector); + if (target === null) { + throw new Error('Clipboard paste target does not exist'); + } + this._imagePasteTarget = target; + } + + target.focus(); + document.execCommand('paste'); + const image = target.querySelector('img[src^="data:"]'); + const result = (image !== null ? image.getAttribute('src') : null); + for (const image2 of target.querySelectorAll('img')) { + image2.removeAttribute('src'); + } + target.textContent = ''; + return result; + } + + // Private + + _isFirefox() { + return (this._browser === 'firefox' || this._browser === 'firefox-mobile'); + } + + _readFileAsDataURL(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); + } +} diff --git a/ext/js/comm/mecab.js b/ext/js/comm/mecab.js new file mode 100644 index 00000000..4eff2927 --- /dev/null +++ b/ext/js/comm/mecab.js @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2019-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/>. + */ + +/** + * This class is used to connect Yomichan to a native component that is + * used to parse text into individual terms. + */ +class Mecab { + /** + * Creates a new instance of the class. + */ + constructor() { + this._port = null; + this._sequence = 0; + this._invocations = new Map(); + this._eventListeners = new EventListenerCollection(); + this._timeout = 5000; + this._version = 1; + this._remoteVersion = null; + this._enabled = false; + this._setupPortPromise = null; + } + + /** + * Returns whether or not the component is enabled. + */ + isEnabled() { + return this._enabled; + } + + /** + * Changes whether or not the component connection is enabled. + * @param enabled A boolean indicating whether or not the component should be enabled. + */ + setEnabled(enabled) { + this._enabled = !!enabled; + if (!this._enabled && this._port !== null) { + this._clearPort(); + } + } + + /** + * Disconnects the current port, but does not disable future connections. + */ + disconnect() { + if (this._port !== null) { + this._clearPort(); + } + } + + /** + * Returns whether or not the connection to the native application is active. + * @returns `true` if the connection is active, `false` otherwise. + */ + isConnected() { + return (this._port !== null); + } + + /** + * Returns whether or not any invocation is currently active. + * @returns `true` if an invocation is active, `false` otherwise. + */ + isActive() { + return (this._invocations.size > 0); + } + + /** + * Gets the local API version being used. + * @returns An integer representing the API version that Yomichan uses. + */ + getLocalVersion() { + return this._version; + } + + /** + * Gets the version of the MeCab component. + * @returns The version of the MeCab component, or `null` if the component was not found. + */ + async getVersion() { + try { + await this._setupPort(); + } catch (e) { + // NOP + } + return this._remoteVersion; + } + + /** + * Parses a string of Japanese text into arrays of lines and terms. + * + * Return value format: + * ```js + * [ + * { + * name: (string), + * lines: [ + * {expression: (string), reading: (string), source: (string)}, + * ... + * ] + * }, + * ... + * ] + * ``` + * @param text The string to parse. + * @returns A collection of parsing results of the text. + */ + async parseText(text) { + await this._setupPort(); + const rawResults = await this._invoke('parse_text', {text}); + return this._convertParseTextResults(rawResults); + } + + // Private + + _onMessage({sequence, data}) { + const invocation = this._invocations.get(sequence); + if (typeof invocation === 'undefined') { return; } + + const {resolve, timer} = invocation; + clearTimeout(timer); + resolve(data); + this._invocations.delete(sequence); + } + + _onDisconnect() { + if (this._port === null) { return; } + const e = chrome.runtime.lastError; + const error = new Error(e ? e.message : 'MeCab disconnected'); + for (const {reject, timer} of this._invocations.values()) { + clearTimeout(timer); + reject(error); + } + this._clearPort(); + } + + _invoke(action, params) { + return new Promise((resolve, reject) => { + if (this._port === null) { + reject(new Error('Port disconnected')); + } + + const sequence = this._sequence++; + + const timer = setTimeout(() => { + this._invocations.delete(sequence); + reject(new Error(`MeCab invoke timed out after ${this._timeout}ms`)); + }, this._timeout); + + this._invocations.set(sequence, {resolve, reject, timer}, this._timeout); + + this._port.postMessage({action, params, sequence}); + }); + } + + _convertParseTextResults(rawResults) { + const results = []; + for (const [name, rawLines] of Object.entries(rawResults)) { + const lines = []; + for (const rawLine of rawLines) { + const line = []; + for (let {expression, reading, source} of rawLine) { + if (typeof expression !== 'string') { expression = ''; } + if (typeof reading !== 'string') { reading = ''; } + if (typeof source !== 'string') { source = ''; } + line.push({expression, reading, source}); + } + lines.push(line); + } + results.push({name, lines}); + } + return results; + } + + async _setupPort() { + if (!this._enabled) { + throw new Error('MeCab not enabled'); + } + if (this._setupPortPromise === null) { + this._setupPortPromise = this._setupPort2(); + } + try { + await this._setupPortPromise; + } catch (e) { + throw new Error(e.message); + } + } + + async _setupPort2() { + const port = chrome.runtime.connectNative('yomichan_mecab'); + this._eventListeners.addListener(port.onMessage, this._onMessage.bind(this)); + this._eventListeners.addListener(port.onDisconnect, this._onDisconnect.bind(this)); + this._port = port; + + try { + const {version} = await this._invoke('get_version', {}); + this._remoteVersion = version; + if (version !== this._version) { + throw new Error(`Unsupported MeCab native messenger version ${version}. Yomichan supports version ${this._version}.`); + } + } catch (e) { + if (this._port === port) { + this._clearPort(); + } + throw e; + } + } + + _clearPort() { + this._port.disconnect(); + this._port = null; + this._invocations.clear(); + this._eventListeners.removeAllEventListeners(); + this._sequence = 0; + this._setupPortPromise = null; + } +} diff --git a/ext/js/data/anki-note-builder.js b/ext/js/data/anki-note-builder.js new file mode 100644 index 00000000..e1399f66 --- /dev/null +++ b/ext/js/data/anki-note-builder.js @@ -0,0 +1,148 @@ +/* + * 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 + * TemplateRendererProxy + */ + +class AnkiNoteBuilder { + constructor(enabled) { + this._markerPattern = /\{([\w-]+)\}/g; + this._templateRenderer = enabled ? new TemplateRendererProxy() : null; + } + + async createNote({ + definition, + mode, + context, + templates, + deckName, + modelName, + fields, + tags=[], + injectedMedia=null, + checkForDuplicates=true, + duplicateScope='collection', + resultOutputMode='split', + glossaryLayoutMode='default', + compactTags=false, + errors=null + }) { + let duplicateScopeDeckName = null; + let duplicateScopeCheckChildren = false; + if (duplicateScope === 'deck-root') { + duplicateScope = 'deck'; + duplicateScopeDeckName = this.getRootDeckName(deckName); + duplicateScopeCheckChildren = true; + } + + const data = { + definition, + mode, + context, + resultOutputMode, + glossaryLayoutMode, + compactTags, + injectedMedia + }; + const formattedFieldValuePromises = []; + for (const [, fieldValue] of fields) { + const formattedFieldValuePromise = this._formatField(fieldValue, data, templates, errors); + formattedFieldValuePromises.push(formattedFieldValuePromise); + } + + const formattedFieldValues = await Promise.all(formattedFieldValuePromises); + const noteFields = {}; + for (let i = 0, ii = fields.length; i < ii; ++i) { + const fieldName = fields[i][0]; + const formattedFieldValue = formattedFieldValues[i]; + noteFields[fieldName] = formattedFieldValue; + } + + return { + fields: noteFields, + tags, + deckName, + modelName, + options: { + allowDuplicate: !checkForDuplicates, + duplicateScope, + duplicateScopeOptions: { + deckName: duplicateScopeDeckName, + checkChildren: duplicateScopeCheckChildren + } + } + }; + } + + containsMarker(fields, marker) { + marker = `{${marker}}`; + for (const [, fieldValue] of fields) { + if (fieldValue.includes(marker)) { + return true; + } + } + return false; + } + + containsAnyMarker(field) { + const result = this._markerPattern.test(field); + this._markerPattern.lastIndex = 0; + return result; + } + + getRootDeckName(deckName) { + const index = deckName.indexOf('::'); + return index >= 0 ? deckName.substring(0, index) : deckName; + } + + // Private + + async _formatField(field, data, templates, errors=null) { + return await this._stringReplaceAsync(field, this._markerPattern, async (g0, marker) => { + try { + return await this._renderTemplate(templates, data, marker); + } catch (e) { + if (errors) { + const error = new Error(`Template render error for {${marker}}`); + error.data = {error: e}; + errors.push(error); + } + return `{${marker}-render-error}`; + } + }); + } + + async _stringReplaceAsync(str, regex, replacer) { + let match; + let index = 0; + const parts = []; + while ((match = regex.exec(str)) !== null) { + parts.push(str.substring(index, match.index), replacer(...match, match.index, str)); + index = regex.lastIndex; + } + if (parts.length === 0) { + return str; + } + parts.push(str.substring(index)); + return (await Promise.all(parts)).join(''); + } + + async _renderTemplate(template, data, marker) { + return await this._templateRenderer.render(template, {data, marker}, 'ankiNote'); + } +} diff --git a/ext/js/data/anki-note-data.js b/ext/js/data/anki-note-data.js new file mode 100644 index 00000000..a7d0f9f6 --- /dev/null +++ b/ext/js/data/anki-note-data.js @@ -0,0 +1,240 @@ +/* + * Copyright (C) 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 + * DictionaryDataUtil + */ + +/** + * This class represents the data that is exposed to the Anki template renderer. + * The public properties and data should be backwards compatible. + */ +class AnkiNoteData { + constructor({ + definition, + resultOutputMode, + mode, + glossaryLayoutMode, + compactTags, + context, + injectedMedia=null + }, marker) { + this._definition = definition; + this._resultOutputMode = resultOutputMode; + this._mode = mode; + this._glossaryLayoutMode = glossaryLayoutMode; + this._compactTags = compactTags; + this._context = context; + this._marker = marker; + this._injectedMedia = injectedMedia; + this._pitches = null; + this._pitchCount = null; + this._uniqueExpressions = null; + this._uniqueReadings = null; + this._publicContext = null; + this._cloze = null; + + this._prepareDefinition(definition, injectedMedia, context); + } + + get marker() { + return this._marker; + } + + set marker(value) { + this._marker = value; + } + + get definition() { + return this._definition; + } + + get uniqueExpressions() { + if (this._uniqueExpressions === null) { + this._uniqueExpressions = this._getUniqueExpressions(); + } + return this._uniqueExpressions; + } + + get uniqueReadings() { + if (this._uniqueReadings === null) { + this._uniqueReadings = this._getUniqueReadings(); + } + return this._uniqueReadings; + } + + get pitches() { + if (this._pitches === null) { + this._pitches = DictionaryDataUtil.getPitchAccentInfos(this._definition); + } + return this._pitches; + } + + get pitchCount() { + if (this._pitchCount === null) { + this._pitchCount = this.pitches.reduce((i, v) => i + v.pitches.length, 0); + } + return this._pitchCount; + } + + get group() { + return this._resultOutputMode === 'group'; + } + + get merge() { + return this._resultOutputMode === 'merge'; + } + + get modeTermKanji() { + return this._mode === 'term-kanji'; + } + + get modeTermKana() { + return this._mode === 'term-kana'; + } + + get modeKanji() { + return this._mode === 'kanji'; + } + + get compactGlossaries() { + return this._glossaryLayoutMode === 'compact'; + } + + get glossaryLayoutMode() { + return this._glossaryLayoutMode; + } + + get compactTags() { + return this._compactTags; + } + + get context() { + if (this._publicContext === null) { + this._publicContext = this._getPublicContext(); + } + return this._publicContext; + } + + createPublic() { + const self = this; + return { + get marker() { return self.marker; }, + set marker(value) { self.marker = value; }, + get definition() { return self.definition; }, + get glossaryLayoutMode() { return self.glossaryLayoutMode; }, + get compactTags() { return self.compactTags; }, + get group() { return self.group; }, + get merge() { return self.merge; }, + get modeTermKanji() { return self.modeTermKanji; }, + get modeTermKana() { return self.modeTermKana; }, + get modeKanji() { return self.modeKanji; }, + get compactGlossaries() { return self.compactGlossaries; }, + get uniqueExpressions() { return self.uniqueExpressions; }, + get uniqueReadings() { return self.uniqueReadings; }, + get pitches() { return self.pitches; }, + get pitchCount() { return self.pitchCount; }, + get context() { return self.context; } + }; + } + + // Private + + _asObject(value) { + return (typeof value === 'object' && value !== null ? value : {}); + } + + _getUniqueExpressions() { + const results = new Set(); + const definition = this._definition; + if (definition.type !== 'kanji') { + for (const {expression} of definition.expressions) { + results.add(expression); + } + } + return [...results]; + } + + _getUniqueReadings() { + const results = new Set(); + const definition = this._definition; + if (definition.type !== 'kanji') { + for (const {reading} of definition.expressions) { + results.add(reading); + } + } + return [...results]; + } + + _getPublicContext() { + let {documentTitle} = this._asObject(this._context); + if (typeof documentTitle !== 'string') { documentTitle = ''; } + + return { + document: { + title: documentTitle + } + }; + } + + _getCloze() { + const {sentence} = this._asObject(this._context); + let {text, offset} = this._asObject(sentence); + if (typeof text !== 'string') { text = ''; } + if (typeof offset !== 'number') { offset = 0; } + + const definition = this._definition; + const source = definition.type === 'kanji' ? definition.character : definition.rawSource; + + return { + sentence: text, + prefix: text.substring(0, offset), + body: text.substring(offset, offset + source.length), + suffix: text.substring(offset + source.length) + }; + } + + _getClozeCached() { + if (this._cloze === null) { + this._cloze = this._getCloze(); + } + return this._cloze; + } + + _prepareDefinition(definition, injectedMedia, context) { + const { + screenshotFileName=null, + clipboardImageFileName=null, + clipboardText=null, + audioFileName=null + } = this._asObject(injectedMedia); + + let {url} = this._asObject(context); + if (typeof url !== 'string') { url = ''; } + + definition.screenshotFileName = screenshotFileName; + definition.clipboardImageFileName = clipboardImageFileName; + definition.clipboardText = clipboardText; + definition.audioFileName = audioFileName; + definition.url = url; + Object.defineProperty(definition, 'cloze', { + configurable: true, + enumerable: true, + get: this._getClozeCached.bind(this) + }); + } +} diff --git a/ext/js/data/database.js b/ext/js/data/database.js new file mode 100644 index 00000000..068f4a5f --- /dev/null +++ b/ext/js/data/database.js @@ -0,0 +1,327 @@ +/* + * 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 Database { + constructor() { + this._db = null; + this._isOpening = false; + } + + // Public + + async open(databaseName, version, structure) { + if (this._db !== null) { + throw new Error('Database already open'); + } + if (this._isOpening) { + throw new Error('Already opening'); + } + + try { + this._isOpening = true; + this._db = await this._open(databaseName, version, (db, transaction, oldVersion) => { + this._upgrade(db, transaction, oldVersion, structure); + }); + } finally { + this._isOpening = false; + } + } + + close() { + if (this._db === null) { + throw new Error('Database is not open'); + } + + this._db.close(); + this._db = null; + } + + isOpening() { + return this._isOpening; + } + + isOpen() { + return this._db !== null; + } + + transaction(storeNames, mode) { + if (this._db === null) { + throw new Error(this._isOpening ? 'Database not ready' : 'Database not open'); + } + return this._db.transaction(storeNames, mode); + } + + bulkAdd(objectStoreName, items, start, count) { + return new Promise((resolve, reject) => { + if (start + count > items.length) { + count = items.length - start; + } + + if (count <= 0) { + resolve(); + return; + } + + const end = start + count; + let completedCount = 0; + const onError = (e) => reject(e.target.error); + const onSuccess = () => { + if (++completedCount >= count) { + resolve(); + } + }; + + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + for (let i = start; i < end; ++i) { + const request = objectStore.add(items[i]); + request.onerror = onError; + request.onsuccess = onSuccess; + } + }); + } + + getAll(objectStoreOrIndex, query, resolve, reject) { + if (typeof objectStoreOrIndex.getAll === 'function') { + this._getAllFast(objectStoreOrIndex, query, resolve, reject); + } else { + this._getAllUsingCursor(objectStoreOrIndex, query, resolve, reject); + } + } + + getAllKeys(objectStoreOrIndex, query, resolve, reject) { + if (typeof objectStoreOrIndex.getAll === 'function') { + this._getAllKeysFast(objectStoreOrIndex, query, resolve, reject); + } else { + this._getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject); + } + } + + find(objectStoreName, indexName, query, predicate=null, defaultValue) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readonly'); + const objectStore = transaction.objectStore(objectStoreName); + const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; + const request = objectStoreOrIndex.openCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + const value = cursor.value; + if (typeof predicate !== 'function' || predicate(value)) { + resolve(value); + } else { + cursor.continue(); + } + } else { + resolve(defaultValue); + } + }; + }); + } + + bulkCount(targets, resolve, reject) { + const targetCount = targets.length; + if (targetCount <= 0) { + resolve(); + return; + } + + let completedCount = 0; + const results = new Array(targetCount).fill(null); + + const onError = (e) => reject(e.target.error); + const onSuccess = (e, index) => { + const count = e.target.result; + results[index] = count; + if (++completedCount >= targetCount) { + resolve(results); + } + }; + + for (let i = 0; i < targetCount; ++i) { + const index = i; + const [objectStoreOrIndex, query] = targets[i]; + const request = objectStoreOrIndex.count(query); + request.onerror = onError; + request.onsuccess = (e) => onSuccess(e, index); + } + } + + delete(objectStoreName, key) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + const request = objectStore.delete(key); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(); + }); + } + + bulkDelete(objectStoreName, indexName, query, filterKeys=null, onProgress=null) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; + + const onGetKeys = (keys) => { + try { + if (typeof filterKeys === 'function') { + keys = filterKeys(keys); + } + this._bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject); + } catch (e) { + reject(e); + } + }; + + this.getAllKeys(objectStoreOrIndex, query, onGetKeys, reject); + }); + } + + static deleteDatabase(databaseName) { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(databaseName); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(); + request.onblocked = () => reject(new Error('Database deletion blocked')); + }); + } + + // Private + + _open(name, version, onUpgradeNeeded) { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, version); + + request.onupgradeneeded = (event) => { + try { + request.transaction.onerror = (e) => reject(e.target.error); + onUpgradeNeeded(request.result, request.transaction, event.oldVersion, event.newVersion); + } catch (e) { + reject(e); + } + }; + + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(request.result); + }); + } + + _upgrade(db, transaction, oldVersion, upgrades) { + for (const {version, stores} of upgrades) { + if (oldVersion >= version) { continue; } + + for (const [objectStoreName, {primaryKey, indices}] of Object.entries(stores)) { + const existingObjectStoreNames = transaction.objectStoreNames || db.objectStoreNames; + const objectStore = ( + this._listContains(existingObjectStoreNames, objectStoreName) ? + transaction.objectStore(objectStoreName) : + db.createObjectStore(objectStoreName, primaryKey) + ); + const existingIndexNames = objectStore.indexNames; + + for (const indexName of indices) { + if (this._listContains(existingIndexNames, indexName)) { continue; } + + objectStore.createIndex(indexName, indexName, {}); + } + } + } + } + + _listContains(list, value) { + for (let i = 0, ii = list.length; i < ii; ++i) { + if (list[i] === value) { return true; } + } + return false; + } + + _getAllFast(objectStoreOrIndex, query, resolve, reject) { + const request = objectStoreOrIndex.getAll(query); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => resolve(e.target.result); + } + + _getAllUsingCursor(objectStoreOrIndex, query, resolve, reject) { + const results = []; + const request = objectStoreOrIndex.openCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + results.push(cursor.value); + cursor.continue(); + } else { + resolve(results); + } + }; + } + + _getAllKeysFast(objectStoreOrIndex, query, resolve, reject) { + const request = objectStoreOrIndex.getAllKeys(query); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => resolve(e.target.result); + } + + _getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject) { + const results = []; + const request = objectStoreOrIndex.openKeyCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + results.push(cursor.primaryKey); + cursor.continue(); + } else { + resolve(results); + } + }; + } + + _bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject) { + const count = keys.length; + if (count === 0) { + resolve(); + return; + } + + let completedCount = 0; + const hasProgress = (typeof onProgress === 'function'); + + const onError = (e) => reject(e.target.error); + const onSuccess = () => { + ++completedCount; + if (hasProgress) { + try { + onProgress(completedCount, count); + } catch (e) { + // NOP + } + } + if (completedCount >= count) { + resolve(); + } + }; + + for (const key of keys) { + const request = objectStore.delete(key); + request.onerror = onError; + request.onsuccess = onSuccess; + } + } +} diff --git a/ext/js/data/json-schema.js b/ext/js/data/json-schema.js new file mode 100644 index 00000000..7b6b9c53 --- /dev/null +++ b/ext/js/data/json-schema.js @@ -0,0 +1,757 @@ +/* + * Copyright (C) 2019-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 + * CacheMap + */ + +class JsonSchemaProxyHandler { + constructor(schema, jsonSchemaValidator) { + this._schema = schema; + this._jsonSchemaValidator = jsonSchemaValidator; + } + + 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 = this._jsonSchemaValidator.getPropertySchema(this._schema, property, target); + if (propertySchema === null) { + return; + } + + const value = target[property]; + return value !== null && typeof value === 'object' ? this._jsonSchemaValidator.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 = this._jsonSchemaValidator.getPropertySchema(this._schema, property, target); + if (propertySchema === null) { + throw new Error(`Property ${property} not supported`); + } + + value = clone(value); + + this._jsonSchemaValidator.validate(value, propertySchema); + + 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'); + } +} + +class JsonSchemaValidator { + constructor() { + this._regexCache = new CacheMap(100); + } + + createProxy(target, schema) { + return new Proxy(target, new JsonSchemaProxyHandler(schema, this)); + } + + isValid(value, schema) { + try { + this.validate(value, schema); + return true; + } catch (e) { + return false; + } + } + + validate(value, schema) { + const info = new JsonSchemaTraversalInfo(value, schema); + this._validate(value, schema, info); + } + + getValidValueOrDefault(schema, value) { + const info = new JsonSchemaTraversalInfo(value, schema); + return this._getValidValueOrDefault(schema, value, info); + } + + getPropertySchema(schema, property, value) { + return this._getPropertySchema(schema, property, value, null); + } + + clearCache() { + this._regexCache.clear(); + } + + // Private + + _getPropertySchema(schema, property, value, path) { + const type = this._getSchemaOrValueType(schema, value); + switch (type) { + case 'object': + { + const properties = schema.properties; + if (this._isObject(properties)) { + const propertySchema = properties[property]; + if (this._isObject(propertySchema)) { + if (path !== null) { path.push(['properties', properties], [property, propertySchema]); } + return propertySchema; + } + } + + const additionalProperties = schema.additionalProperties; + if (additionalProperties === false) { + return null; + } else if (this._isObject(additionalProperties)) { + if (path !== null) { path.push(['additionalProperties', additionalProperties]); } + return additionalProperties; + } else { + const result = JsonSchemaValidator.unconstrainedSchema; + if (path !== null) { path.push([null, result]); } + return result; + } + } + case 'array': + { + const items = schema.items; + if (this._isObject(items)) { + return items; + } + if (Array.isArray(items)) { + if (property >= 0 && property < items.length) { + const propertySchema = items[property]; + if (this._isObject(propertySchema)) { + if (path !== null) { path.push(['items', items], [property, propertySchema]); } + return propertySchema; + } + } + } + + const additionalItems = schema.additionalItems; + if (additionalItems === false) { + return null; + } else if (this._isObject(additionalItems)) { + if (path !== null) { path.push(['additionalItems', additionalItems]); } + return additionalItems; + } else { + const result = JsonSchemaValidator.unconstrainedSchema; + if (path !== null) { path.push([null, result]); } + return result; + } + } + default: + return null; + } + } + + _getSchemaOrValueType(schema, value) { + const type = schema.type; + + if (Array.isArray(type)) { + if (typeof value !== 'undefined') { + const valueType = this._getValueType(value); + if (type.indexOf(valueType) >= 0) { + return valueType; + } + } + return null; + } + + if (typeof type === 'undefined') { + if (typeof value !== 'undefined') { + return this._getValueType(value); + } + return null; + } + + return type; + } + + _validate(value, schema, info) { + this._validateSingleSchema(value, schema, info); + this._validateConditional(value, schema, info); + this._validateAllOf(value, schema, info); + this._validateAnyOf(value, schema, info); + this._validateOneOf(value, schema, info); + this._validateNoneOf(value, schema, info); + } + + _validateConditional(value, schema, info) { + const ifSchema = schema.if; + if (!this._isObject(ifSchema)) { return; } + + let okay = true; + info.schemaPush('if', ifSchema); + try { + this._validate(value, ifSchema, info); + } catch (e) { + okay = false; + } + info.schemaPop(); + + const nextSchema = okay ? schema.then : schema.else; + if (this._isObject(nextSchema)) { + info.schemaPush(okay ? 'then' : 'else', nextSchema); + this._validate(value, nextSchema, info); + info.schemaPop(); + } + } + + _validateAllOf(value, schema, info) { + const subSchemas = schema.allOf; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('allOf', subSchemas); + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + this._validate(value, subSchema, info); + info.schemaPop(); + } + info.schemaPop(); + } + + _validateAnyOf(value, schema, info) { + const subSchemas = schema.anyOf; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('anyOf', subSchemas); + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + try { + this._validate(value, subSchema, info); + return; + } catch (e) { + // NOP + } + info.schemaPop(); + } + + throw new JsonSchemaValidationError('0 anyOf schemas matched', value, schema, info); + // info.schemaPop(); // Unreachable + } + + _validateOneOf(value, schema, info) { + const subSchemas = schema.oneOf; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('oneOf', subSchemas); + let count = 0; + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + try { + this._validate(value, subSchema, info); + ++count; + } catch (e) { + // NOP + } + info.schemaPop(); + } + + if (count !== 1) { + throw new JsonSchemaValidationError(`${count} oneOf schemas matched`, value, schema, info); + } + + info.schemaPop(); + } + + _validateNoneOf(value, schema, info) { + const subSchemas = schema.not; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('not', subSchemas); + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + try { + this._validate(value, subSchema, info); + } catch (e) { + info.schemaPop(); + continue; + } + throw new JsonSchemaValidationError(`not[${i}] schema matched`, value, schema, info); + } + info.schemaPop(); + } + + _validateSingleSchema(value, schema, info) { + const type = this._getValueType(value); + const schemaType = schema.type; + if (!this._isValueTypeAny(value, type, schemaType)) { + throw new JsonSchemaValidationError(`Value type ${type} does not match schema type ${schemaType}`, value, schema, info); + } + + const schemaConst = schema.const; + if (typeof schemaConst !== 'undefined' && !this._valuesAreEqual(value, schemaConst)) { + throw new JsonSchemaValidationError('Invalid constant value', value, schema, info); + } + + const schemaEnum = schema.enum; + if (Array.isArray(schemaEnum) && !this._valuesAreEqualAny(value, schemaEnum)) { + throw new JsonSchemaValidationError('Invalid enum value', value, schema, info); + } + + switch (type) { + case 'number': + this._validateNumber(value, schema, info); + break; + case 'string': + this._validateString(value, schema, info); + break; + case 'array': + this._validateArray(value, schema, info); + break; + case 'object': + this._validateObject(value, schema, info); + break; + } + } + + _validateNumber(value, schema, info) { + const multipleOf = schema.multipleOf; + if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { + throw new JsonSchemaValidationError(`Number is not a multiple of ${multipleOf}`, value, schema, info); + } + + const minimum = schema.minimum; + if (typeof minimum === 'number' && value < minimum) { + throw new JsonSchemaValidationError(`Number is less than ${minimum}`, value, schema, info); + } + + const exclusiveMinimum = schema.exclusiveMinimum; + if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) { + throw new JsonSchemaValidationError(`Number is less than or equal to ${exclusiveMinimum}`, value, schema, info); + } + + const maximum = schema.maximum; + if (typeof maximum === 'number' && value > maximum) { + throw new JsonSchemaValidationError(`Number is greater than ${maximum}`, value, schema, info); + } + + const exclusiveMaximum = schema.exclusiveMaximum; + if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) { + throw new JsonSchemaValidationError(`Number is greater than or equal to ${exclusiveMaximum}`, value, schema, info); + } + } + + _validateString(value, schema, info) { + const minLength = schema.minLength; + if (typeof minLength === 'number' && value.length < minLength) { + throw new JsonSchemaValidationError('String length too short', value, schema, info); + } + + const maxLength = schema.maxLength; + if (typeof maxLength === 'number' && value.length > maxLength) { + throw new JsonSchemaValidationError('String length too long', value, schema, info); + } + + const pattern = schema.pattern; + if (typeof pattern === 'string') { + let patternFlags = schema.patternFlags; + if (typeof patternFlags !== 'string') { patternFlags = ''; } + + let regex; + try { + regex = this._getRegex(pattern, patternFlags); + } catch (e) { + throw new JsonSchemaValidationError(`Pattern is invalid (${e.message})`, value, schema, info); + } + + if (!regex.test(value)) { + throw new JsonSchemaValidationError('Pattern match failed', value, schema, info); + } + } + } + + _validateArray(value, schema, info) { + const minItems = schema.minItems; + if (typeof minItems === 'number' && value.length < minItems) { + throw new JsonSchemaValidationError('Array length too short', value, schema, info); + } + + const maxItems = schema.maxItems; + if (typeof maxItems === 'number' && value.length > maxItems) { + throw new JsonSchemaValidationError('Array length too long', value, schema, info); + } + + this._validateArrayContains(value, schema, info); + + for (let i = 0, ii = value.length; i < ii; ++i) { + const schemaPath = []; + const propertySchema = this._getPropertySchema(schema, i, value, schemaPath); + if (propertySchema === null) { + throw new JsonSchemaValidationError(`No schema found for array[${i}]`, value, schema, info); + } + + const propertyValue = value[i]; + + for (const [p, s] of schemaPath) { info.schemaPush(p, s); } + info.valuePush(i, propertyValue); + this._validate(propertyValue, propertySchema, info); + info.valuePop(); + for (let j = 0, jj = schemaPath.length; j < jj; ++j) { info.schemaPop(); } + } + } + + _validateArrayContains(value, schema, info) { + const containsSchema = schema.contains; + if (!this._isObject(containsSchema)) { return; } + + info.schemaPush('contains', containsSchema); + for (let i = 0, ii = value.length; i < ii; ++i) { + const propertyValue = value[i]; + info.valuePush(i, propertyValue); + try { + this._validate(propertyValue, containsSchema, info); + info.schemaPop(); + return; + } catch (e) { + // NOP + } + info.valuePop(); + } + throw new JsonSchemaValidationError('contains schema didn\'t match', value, schema, info); + } + + _validateObject(value, schema, info) { + const properties = new Set(Object.getOwnPropertyNames(value)); + + const required = schema.required; + if (Array.isArray(required)) { + for (const property of required) { + if (!properties.has(property)) { + throw new JsonSchemaValidationError(`Missing property ${property}`, value, schema, info); + } + } + } + + const minProperties = schema.minProperties; + if (typeof minProperties === 'number' && properties.length < minProperties) { + throw new JsonSchemaValidationError('Not enough object properties', value, schema, info); + } + + const maxProperties = schema.maxProperties; + if (typeof maxProperties === 'number' && properties.length > maxProperties) { + throw new JsonSchemaValidationError('Too many object properties', value, schema, info); + } + + for (const property of properties) { + const schemaPath = []; + const propertySchema = this._getPropertySchema(schema, property, value, schemaPath); + if (propertySchema === null) { + throw new JsonSchemaValidationError(`No schema found for ${property}`, value, schema, info); + } + + const propertyValue = value[property]; + + for (const [p, s] of schemaPath) { info.schemaPush(p, s); } + info.valuePush(property, propertyValue); + this._validate(propertyValue, propertySchema, info); + info.valuePop(); + for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); } + } + } + + _isValueTypeAny(value, type, schemaTypes) { + if (typeof schemaTypes === 'string') { + return this._isValueType(value, type, schemaTypes); + } else if (Array.isArray(schemaTypes)) { + for (const schemaType of schemaTypes) { + if (this._isValueType(value, type, schemaType)) { + return true; + } + } + return false; + } + return true; + } + + _isValueType(value, type, schemaType) { + return ( + type === schemaType || + (schemaType === 'integer' && Math.floor(value) === value) + ); + } + + _getValueType(value) { + const type = typeof value; + if (type === 'object') { + if (value === null) { return 'null'; } + if (Array.isArray(value)) { return 'array'; } + } + return type; + } + + _valuesAreEqualAny(value1, valueList) { + for (const value2 of valueList) { + if (this._valuesAreEqual(value1, value2)) { + return true; + } + } + return false; + } + + _valuesAreEqual(value1, value2) { + return value1 === value2; + } + + _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; + } + + _getDefaultSchemaValue(schema) { + const schemaType = schema.type; + const schemaDefault = schema.default; + return ( + typeof schemaDefault !== 'undefined' && + this._isValueTypeAny(schemaDefault, this._getValueType(schemaDefault), schemaType) ? + clone(schemaDefault) : + this._getDefaultTypeValue(schemaType) + ); + } + + _getValidValueOrDefault(schema, value, info) { + let type = this._getValueType(value); + if (typeof value === 'undefined' || !this._isValueTypeAny(value, type, schema.type)) { + value = this._getDefaultSchemaValue(schema); + type = this._getValueType(value); + } + + switch (type) { + case 'object': + value = this._populateObjectDefaults(value, schema, info); + break; + case 'array': + value = this._populateArrayDefaults(value, schema, info); + break; + default: + if (!this.isValid(value, schema)) { + const schemaDefault = this._getDefaultSchemaValue(schema); + if (this.isValid(schemaDefault, schema)) { + value = schemaDefault; + } + } + break; + } + + return value; + } + + _populateObjectDefaults(value, schema, info) { + 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 = this._getPropertySchema(schema, property, value, null); + if (propertySchema === null) { continue; } + info.valuePush(property, value); + info.schemaPush(property, propertySchema); + const hasValue = Object.prototype.hasOwnProperty.call(value, property); + value[property] = this._getValidValueOrDefault(propertySchema, hasValue ? value[property] : void 0, info); + info.schemaPop(); + info.valuePop(); + } + } + + for (const property of properties) { + const propertySchema = this._getPropertySchema(schema, property, value, null); + if (propertySchema === null) { + Reflect.deleteProperty(value, property); + } else { + info.valuePush(property, value); + info.schemaPush(property, propertySchema); + value[property] = this._getValidValueOrDefault(propertySchema, value[property], info); + info.schemaPop(); + info.valuePop(); + } + } + + return value; + } + + _populateArrayDefaults(value, schema, info) { + for (let i = 0, ii = value.length; i < ii; ++i) { + const propertySchema = this._getPropertySchema(schema, i, value, null); + if (propertySchema === null) { continue; } + info.valuePush(i, value); + info.schemaPush(i, propertySchema); + value[i] = this._getValidValueOrDefault(propertySchema, value[i], info); + info.schemaPop(); + info.valuePop(); + } + + const minItems = schema.minItems; + if (typeof minItems === 'number' && value.length < minItems) { + for (let i = value.length; i < minItems; ++i) { + const propertySchema = this._getPropertySchema(schema, i, value, null); + if (propertySchema === null) { break; } + info.valuePush(i, value); + info.schemaPush(i, propertySchema); + const item = this._getValidValueOrDefault(propertySchema, void 0, info); + info.schemaPop(); + info.valuePop(); + value.push(item); + } + } + + const maxItems = schema.maxItems; + if (typeof maxItems === 'number' && value.length > maxItems) { + value.splice(maxItems, value.length - maxItems); + } + + return value; + } + + _isObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + _getRegex(pattern, flags) { + const key = `${flags}:${pattern}`; + let regex = this._regexCache.get(key); + if (typeof regex === 'undefined') { + regex = new RegExp(pattern, flags); + this._regexCache.set(key, regex); + } + return regex; + } +} + +Object.defineProperty(JsonSchemaValidator, 'unconstrainedSchema', { + value: Object.freeze({}), + configurable: false, + enumerable: true, + writable: false +}); + +class JsonSchemaTraversalInfo { + constructor(value, schema) { + this.valuePath = []; + this.schemaPath = []; + this.valuePush(null, value); + this.schemaPush(null, schema); + } + + valuePush(path, value) { + this.valuePath.push([path, value]); + } + + valuePop() { + this.valuePath.pop(); + } + + schemaPush(path, schema) { + this.schemaPath.push([path, schema]); + } + + schemaPop() { + this.schemaPath.pop(); + } +} + +class JsonSchemaValidationError extends Error { + constructor(message, value, schema, info) { + super(message); + this.value = value; + this.schema = schema; + this.info = info; + } +} diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js new file mode 100644 index 00000000..1105dfed --- /dev/null +++ b/ext/js/data/options-util.js @@ -0,0 +1,739 @@ +/* + * 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 + * JsonSchemaValidator + * TemplatePatcher + */ + +class OptionsUtil { + constructor() { + this._schemaValidator = new JsonSchemaValidator(); + this._templatePatcher = null; + this._optionsSchema = null; + } + + async prepare() { + this._optionsSchema = await this._fetchAsset('/data/schemas/options-schema.json', true); + } + + async update(options) { + // Invalid options + if (!isObject(options)) { + options = {}; + } + + // Check for legacy options + let defaultProfileOptions = {}; + if (!Array.isArray(options.profiles)) { + defaultProfileOptions = options; + options = {}; + } + + // Ensure profiles is an array + if (!Array.isArray(options.profiles)) { + options.profiles = []; + } + + // Remove invalid profiles + const profiles = options.profiles; + for (let i = profiles.length - 1; i >= 0; --i) { + if (!isObject(profiles[i])) { + profiles.splice(i, 1); + } + } + + // Require at least one profile + if (profiles.length === 0) { + profiles.push({ + name: 'Default', + options: defaultProfileOptions, + conditionGroups: [] + }); + } + + // Ensure profileCurrent is valid + const profileCurrent = options.profileCurrent; + if (!( + typeof profileCurrent === 'number' && + Number.isFinite(profileCurrent) && + Math.floor(profileCurrent) === profileCurrent && + profileCurrent >= 0 && + profileCurrent < profiles.length + )) { + options.profileCurrent = 0; + } + + // Version + if (typeof options.version !== 'number') { + options.version = 0; + } + + // Generic updates + options = await this._applyUpdates(options, this._getVersionUpdates()); + + // Validation + options = this._schemaValidator.getValidValueOrDefault(this._optionsSchema, options); + + // Result + return options; + } + + async load() { + let options; + try { + const optionsStr = await new Promise((resolve, reject) => { + chrome.storage.local.get(['options'], (store) => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message)); + } else { + resolve(store.options); + } + }); + }); + options = JSON.parse(optionsStr); + } catch (e) { + // NOP + } + + if (typeof options !== 'undefined') { + options = await this.update(options); + } else { + options = this.getDefault(); + } + + return options; + } + + save(options) { + return new Promise((resolve, reject) => { + chrome.storage.local.set({options: JSON.stringify(options)}, () => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message)); + } else { + resolve(); + } + }); + }); + } + + getDefault() { + const optionsVersion = this._getVersionUpdates().length; + const options = this._schemaValidator.getValidValueOrDefault(this._optionsSchema); + options.version = optionsVersion; + return options; + } + + createValidatingProxy(options) { + return this._schemaValidator.createProxy(options, this._optionsSchema); + } + + validate(options) { + return this._schemaValidator.validate(options, this._optionsSchema); + } + + // Legacy profile updating + + _legacyProfileUpdateGetUpdates() { + return [ + null, + null, + null, + null, + (options) => { + options.general.audioSource = options.general.audioPlayback ? 'jpod101' : 'disabled'; + }, + (options) => { + options.general.showGuide = false; + }, + (options) => { + options.scanning.modifier = options.scanning.requireShift ? 'shift' : 'none'; + }, + (options) => { + options.general.resultOutputMode = options.general.groupResults ? 'group' : 'split'; + options.anki.fieldTemplates = null; + }, + (options) => { + if (this._getStringHashCode(options.anki.fieldTemplates) === 1285806040) { + options.anki.fieldTemplates = null; + } + }, + (options) => { + if (this._getStringHashCode(options.anki.fieldTemplates) === -250091611) { + options.anki.fieldTemplates = null; + } + }, + (options) => { + const oldAudioSource = options.general.audioSource; + const disabled = oldAudioSource === 'disabled'; + options.audio.enabled = !disabled; + options.audio.volume = options.general.audioVolume; + options.audio.autoPlay = options.general.autoPlayAudio; + options.audio.sources = [disabled ? 'jpod101' : oldAudioSource]; + + 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 (this._getStringHashCode(options.anki.fieldTemplates) === 1444379824) { + options.anki.fieldTemplates = null; + } + }, + (options) => { + // Version 13 changes: + // Default anki field tempaltes updated to include {document-title}. + let fieldTemplates = options.anki.fieldTemplates; + if (typeof fieldTemplates === 'string') { + fieldTemplates += '\n\n{{#*inline "document-title"}}\n {{~context.document.title~}}\n{{/inline}}'; + options.anki.fieldTemplates = fieldTemplates; + } + }, + (options) => { + // Version 14 changes: + // Changed template for Anki audio and tags. + let fieldTemplates = options.anki.fieldTemplates; + if (typeof fieldTemplates !== 'string') { return; } + + const replacements = [ + [ + '{{#*inline "audio"}}{{/inline}}', + '{{#*inline "audio"}}\n {{~#if definition.audioFileName~}}\n [sound:{{definition.audioFileName}}]\n {{~/if~}}\n{{/inline}}' + ], + [ + '{{#*inline "tags"}}\n {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}}\n{{/inline}}', + '{{#*inline "tags"}}\n {{~#mergeTags definition group merge}}{{this}}{{/mergeTags~}}\n{{/inline}}' + ] + ]; + + for (const [pattern, replacement] of replacements) { + let replaced = false; + fieldTemplates = fieldTemplates.replace(new RegExp(escapeRegExp(pattern), 'g'), () => { + replaced = true; + return replacement; + }); + + if (!replaced) { + fieldTemplates += '\n\n' + replacement; + } + } + + options.anki.fieldTemplates = fieldTemplates; + } + ]; + } + + _legacyProfileUpdateGetDefaults() { + return { + general: { + enable: true, + enableClipboardPopups: false, + resultOutputMode: 'group', + debugInfo: false, + maxResults: 32, + showAdvanced: false, + popupDisplayMode: 'default', + popupWidth: 400, + popupHeight: 250, + popupHorizontalOffset: 0, + popupVerticalOffset: 10, + popupHorizontalOffset2: 10, + popupVerticalOffset2: 0, + popupHorizontalTextPosition: 'below', + popupVerticalTextPosition: 'before', + popupScalingFactor: 1, + popupScaleRelativeToPageZoom: false, + popupScaleRelativeToVisualViewport: true, + showGuide: true, + compactTags: false, + compactGlossaries: false, + mainDictionary: '', + popupTheme: 'default', + popupOuterTheme: 'default', + customPopupCss: '', + customPopupOuterCss: '', + enableWanakana: true, + enableClipboardMonitor: false, + showPitchAccentDownstepNotation: true, + showPitchAccentPositionNotation: true, + showPitchAccentGraph: false, + showIframePopupsInRootFrame: false, + useSecurePopupFrameUrl: true, + usePopupShadowDom: true + }, + + audio: { + enabled: true, + sources: ['jpod101'], + volume: 100, + autoPlay: false, + customSourceUrl: '', + textToSpeechVoice: '' + }, + + scanning: { + middleMouse: true, + touchInputEnabled: true, + selectText: true, + alphanumeric: true, + autoHideResults: false, + delay: 20, + length: 10, + modifier: 'shift', + deepDomScan: false, + popupNestingMaxDepth: 0, + enablePopupSearch: false, + enableOnPopupExpressions: false, + enableOnSearchPage: true, + enableSearchTags: false, + layoutAwareScan: false + }, + + translation: { + convertHalfWidthCharacters: 'false', + convertNumericCharacters: 'false', + convertAlphabeticCharacters: 'false', + convertHiraganaToKatakana: 'false', + convertKatakanaToHiragana: 'variant', + collapseEmphaticSequences: 'false' + }, + + dictionaries: {}, + + parsing: { + enableScanningParser: true, + enableMecabParser: false, + selectedParser: null, + termSpacing: true, + readingMode: 'hiragana' + }, + + anki: { + enable: false, + server: 'http://127.0.0.1:8765', + tags: ['yomichan'], + sentenceExt: 200, + screenshot: {format: 'png', quality: 92}, + terms: {deck: '', model: '', fields: {}}, + kanji: {deck: '', model: '', fields: {}}, + duplicateScope: 'collection', + fieldTemplates: null + } + }; + } + + _legacyProfileUpdateAssignDefaults(options) { + const defaults = this._legacyProfileUpdateGetDefaults(); + + const combine = (target, source) => { + for (const key in source) { + if (!Object.prototype.hasOwnProperty.call(target, key)) { + target[key] = source[key]; + } + } + }; + + combine(options, defaults); + combine(options.general, defaults.general); + combine(options.scanning, defaults.scanning); + combine(options.anki, defaults.anki); + combine(options.anki.terms, defaults.anki.terms); + combine(options.anki.kanji, defaults.anki.kanji); + + return options; + } + + _legacyProfileUpdateUpdateVersion(options) { + const updates = this._legacyProfileUpdateGetUpdates(); + this._legacyProfileUpdateAssignDefaults(options); + + const targetVersion = updates.length; + const currentVersion = options.version; + + if (typeof currentVersion === 'number' && Number.isFinite(currentVersion)) { + for (let i = Math.max(0, Math.floor(currentVersion)); i < targetVersion; ++i) { + const update = updates[i]; + if (update !== null) { + update(options); + } + } + } + + options.version = targetVersion; + return options; + } + + // Private + + async _applyAnkiFieldTemplatesPatch(options, modificationsUrl) { + let patch = null; + for (const {options: profileOptions} of options.profiles) { + const fieldTemplates = profileOptions.anki.fieldTemplates; + if (fieldTemplates === null) { continue; } + + if (patch === null) { + const content = await this._fetchAsset(modificationsUrl); + if (this._templatePatcher === null) { + this._templatePatcher = new TemplatePatcher(); + } + patch = this._templatePatcher.parsePatch(content); + } + + profileOptions.anki.fieldTemplates = this._templatePatcher.applyPatch(fieldTemplates, patch); + } + } + + async _fetchAsset(url, json=false) { + url = chrome.runtime.getURL(url); + const response = await fetch(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()); + } + + _getStringHashCode(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; + } + + return hashCode; + } + + async _applyUpdates(options, updates) { + const targetVersion = updates.length; + let currentVersion = options.version; + + if (typeof currentVersion !== 'number' || !Number.isFinite(currentVersion)) { + currentVersion = 0; + } + + for (let i = Math.max(0, Math.floor(currentVersion)); i < targetVersion; ++i) { + const {update, async} = updates[i]; + const result = update(options); + options = (async ? await result : result); + } + + options.version = targetVersion; + return options; + } + + _getVersionUpdates() { + return [ + {async: false, update: this._updateVersion1.bind(this)}, + {async: false, update: this._updateVersion2.bind(this)}, + {async: true, update: this._updateVersion3.bind(this)}, + {async: true, update: this._updateVersion4.bind(this)}, + {async: false, update: this._updateVersion5.bind(this)}, + {async: true, update: this._updateVersion6.bind(this)}, + {async: false, update: this._updateVersion7.bind(this)}, + {async: true, update: this._updateVersion8.bind(this)} + ]; + } + + _updateVersion1(options) { + // Version 1 changes: + // Added options.global.database.prefixWildcardsSupported = false. + options.global = { + database: { + prefixWildcardsSupported: false + } + }; + return options; + } + + _updateVersion2(options) { + // Version 2 changes: + // Legacy profile update process moved into this upgrade function. + for (const profile of options.profiles) { + if (!Array.isArray(profile.conditionGroups)) { + profile.conditionGroups = []; + } + profile.options = this._legacyProfileUpdateUpdateVersion(profile.options); + } + return options; + } + + async _updateVersion3(options) { + // Version 3 changes: + // Pitch accent Anki field templates added. + await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v2.handlebars'); + return options; + } + + async _updateVersion4(options) { + // Version 4 changes: + // Options conditions converted to string representations. + // Added usePopupWindow. + // Updated handlebars templates to include "clipboard-image" definition. + // Updated handlebars templates to include "clipboard-text" definition. + // Added hideDelay. + // Added inputs to profileOptions.scanning. + // Added pointerEventsEnabled to profileOptions.scanning. + // Added preventMiddleMouse to profileOptions.scanning. + for (const {conditionGroups} of options.profiles) { + for (const {conditions} of conditionGroups) { + for (const condition of conditions) { + const value = condition.value; + condition.value = ( + Array.isArray(value) ? + value.join(', ') : + `${value}` + ); + } + } + } + const createInputDefaultOptions = () => ({ + showAdvanced: false, + searchTerms: true, + searchKanji: true, + scanOnTouchMove: true, + scanOnPenHover: true, + scanOnPenPress: true, + scanOnPenRelease: false, + preventTouchScrolling: true + }); + for (const {options: profileOptions} of options.profiles) { + profileOptions.general.usePopupWindow = false; + profileOptions.scanning.hideDelay = 0; + profileOptions.scanning.pointerEventsEnabled = false; + profileOptions.scanning.preventMiddleMouse = { + onWebPages: false, + onPopupPages: false, + onSearchPages: false, + onSearchQuery: false + }; + + const {modifier, middleMouse} = profileOptions.scanning; + delete profileOptions.scanning.modifier; + delete profileOptions.scanning.middleMouse; + const scanningInputs = []; + let modifierInput = ''; + switch (modifier) { + case 'alt': + case 'ctrl': + case 'shift': + case 'meta': + modifierInput = modifier; + break; + case 'none': + modifierInput = ''; + break; + } + scanningInputs.push({ + include: modifierInput, + exclude: 'mouse0', + types: {mouse: true, touch: false, pen: false}, + options: createInputDefaultOptions() + }); + if (middleMouse) { + scanningInputs.push({ + include: 'mouse2', + exclude: '', + types: {mouse: true, touch: false, pen: false}, + options: createInputDefaultOptions() + }); + } + scanningInputs.push({ + include: '', + exclude: '', + types: {mouse: false, touch: true, pen: true}, + options: createInputDefaultOptions() + }); + profileOptions.scanning.inputs = scanningInputs; + } + await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v4.handlebars'); + return options; + } + + _updateVersion5(options) { + // Version 5 changes: + // Removed legacy version number from profile options. + for (const profile of options.profiles) { + delete profile.options.version; + } + return options; + } + + async _updateVersion6(options) { + // Version 6 changes: + // Updated handlebars templates to include "conjugation" definition. + // Added global option showPopupPreview. + // Added global option useSettingsV2. + // Added anki.checkForDuplicates. + // Added general.glossaryLayoutMode; removed general.compactGlossaries. + await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v6.handlebars'); + options.global.showPopupPreview = false; + options.global.useSettingsV2 = false; + for (const profile of options.profiles) { + profile.options.anki.checkForDuplicates = true; + profile.options.general.glossaryLayoutMode = (profile.options.general.compactGlossaries ? 'compact' : 'default'); + delete profile.options.general.compactGlossaries; + const fieldTemplates = profile.options.anki.fieldTemplates; + if (typeof fieldTemplates === 'string') { + profile.options.anki.fieldTemplates = this._updateVersion6AnkiTemplatesCompactTags(fieldTemplates); + } + } + return options; + } + + _updateVersion6AnkiTemplatesCompactTags(templates) { + const rawPattern1 = '{{~#if definitionTags~}}<i>({{#each definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each}})</i> {{/if~}}'; + const pattern1 = new RegExp(`((\r?\n)?[ \t]*)${escapeRegExp(rawPattern1)}`, 'g'); + const replacement1 = ( + // eslint-disable-next-line indent +`{{~#scope~}} + {{~#set "any" false}}{{/set~}} + {{~#if definitionTags~}}{{#each definitionTags~}} + {{~#if (op "||" (op "!" ../data.compactTags) (op "!" redundant))~}} + {{~#if (get "any")}}, {{else}}<i>({{/if~}} + {{name}} + {{~#set "any" true}}{{/set~}} + {{~/if~}} + {{~/each~}} + {{~#if (get "any")}})</i> {{/if~}} + {{~/if~}} +{{~/scope~}}` + ); + const simpleNewline = /\n/g; + templates = templates.replace(pattern1, (g0, space) => (space + replacement1.replace(simpleNewline, space))); + templates = templates.replace(/\bcompactGlossaries=((?:\.*\/)*)compactGlossaries\b/g, (g0, g1) => `${g0} data=${g1}.`); + return templates; + } + + _updateVersion7(options) { + // Version 7 changes: + // Added general.maximumClipboardSearchLength. + // Added general.popupCurrentIndicatorMode. + // Added general.popupActionBarVisibility. + // Added general.popupActionBarLocation. + // Removed global option showPopupPreview. + delete options.global.showPopupPreview; + for (const profile of options.profiles) { + profile.options.general.maximumClipboardSearchLength = 1000; + profile.options.general.popupCurrentIndicatorMode = 'triangle'; + profile.options.general.popupActionBarVisibility = 'auto'; + profile.options.general.popupActionBarLocation = 'right'; + } + return options; + } + + async _updateVersion8(options) { + // Version 8 changes: + // Added translation.textReplacements. + // Moved anki.sentenceExt to sentenceParsing.scanExtent. + // Added sentenceParsing.enableTerminationCharacters. + // Added sentenceParsing.terminationCharacters. + // Changed general.popupActionBarLocation. + // Added inputs.hotkeys. + // Added anki.suspendNewCards. + // Added popupWindow. + // Updated handlebars templates to include "stroke-count" definition. + // Updated global.useSettingsV2 to be true (opt-out). + // Added audio.customSourceType. + // Moved general.enableClipboardPopups => clipboard.enableBackgroundMonitor. + // Moved general.enableClipboardMonitor => clipboard.enableSearchPageMonitor. Forced value to false due to a bug which caused its value to not be read. + // Moved general.maximumClipboardSearchLength => clipboard.maximumSearchLength. + // Added clipboard.autoSearchContent. + await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v8.handlebars'); + options.global.useSettingsV2 = true; + for (const profile of options.profiles) { + profile.options.translation.textReplacements = { + searchOriginal: true, + groups: [] + }; + profile.options.sentenceParsing = { + scanExtent: profile.options.anki.sentenceExt, + enableTerminationCharacters: true, + terminationCharacters: [ + {enabled: true, character1: '「', character2: '」', includeCharacterAtStart: false, includeCharacterAtEnd: false}, + {enabled: true, character1: '『', character2: '』', includeCharacterAtStart: false, includeCharacterAtEnd: false}, + {enabled: true, character1: '"', character2: '"', includeCharacterAtStart: false, includeCharacterAtEnd: false}, + {enabled: true, character1: '\'', character2: '\'', includeCharacterAtStart: false, includeCharacterAtEnd: false}, + {enabled: true, character1: '.', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '!', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '?', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '.', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '。', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '!', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '?', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '…', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true} + ] + }; + delete profile.options.anki.sentenceExt; + profile.options.general.popupActionBarLocation = 'top'; + profile.options.inputs = { + hotkeys: [ + {action: 'close', key: 'Escape', modifiers: [], scopes: ['popup'], enabled: true}, + {action: 'focusSearchBox', key: 'Escape', modifiers: [], scopes: ['search'], enabled: true}, + {action: 'previousEntry3', key: 'PageUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'nextEntry3', key: 'PageDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'lastEntry', key: 'End', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'firstEntry', key: 'Home', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'previousEntry', key: 'ArrowUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'nextEntry', key: 'ArrowDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'historyBackward', key: 'KeyB', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'historyForward', key: 'KeyF', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'addNoteKanji', key: 'KeyK', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'addNoteTermKanji', key: 'KeyE', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'addNoteTermKana', key: 'KeyR', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'playAudio', key: 'KeyP', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'viewNote', key: 'KeyV', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'copyHostSelection', key: 'KeyC', modifiers: ['ctrl'], scopes: ['popup'], enabled: true} + ] + }; + profile.options.anki.suspendNewCards = false; + profile.options.popupWindow = { + width: profile.options.general.popupWidth, + height: profile.options.general.popupHeight, + left: 0, + top: 0, + useLeft: false, + useTop: false, + windowType: 'popup', + windowState: 'normal' + }; + profile.options.audio.customSourceType = 'audio'; + profile.options.clipboard = { + enableBackgroundMonitor: profile.options.general.enableClipboardPopups, + enableSearchPageMonitor: false, + autoSearchContent: true, + maximumSearchLength: profile.options.general.maximumClipboardSearchLength + }; + delete profile.options.general.enableClipboardPopups; + delete profile.options.general.enableClipboardMonitor; + delete profile.options.general.maximumClipboardSearchLength; + } + return options; + } +} diff --git a/ext/js/data/permissions-util.js b/ext/js/data/permissions-util.js new file mode 100644 index 00000000..bd3a18ce --- /dev/null +++ b/ext/js/data/permissions-util.js @@ -0,0 +1,126 @@ +/* + * Copyright (C) 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 PermissionsUtil { + constructor() { + this._ankiFieldMarkersRequiringClipboardPermission = new Set([ + 'clipboard-image', + 'clipboard-text' + ]); + this._ankiMarkerPattern = /\{([\w-]+)\}/g; + } + + hasPermissions(permissions) { + return new Promise((resolve, reject) => chrome.permissions.contains(permissions, (result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + })); + } + + setPermissionsGranted(permissions, shouldHave) { + return ( + shouldHave ? + new Promise((resolve, reject) => chrome.permissions.request(permissions, (result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + })) : + new Promise((resolve, reject) => chrome.permissions.remove(permissions, (result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(!result); + } + })) + ); + } + + getAllPermissions() { + return new Promise((resolve, reject) => chrome.permissions.getAll((result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + })); + } + + getRequiredPermissionsForAnkiFieldValue(fieldValue) { + const markers = this._getAnkiFieldMarkers(fieldValue); + const markerPermissions = this._ankiFieldMarkersRequiringClipboardPermission; + for (const marker of markers) { + if (markerPermissions.has(marker)) { + return ['clipboardRead']; + } + } + return []; + } + + hasRequiredPermissionsForOptions(permissions, options) { + const permissionsSet = new Set(permissions.permissions); + + if (!permissionsSet.has('nativeMessaging')) { + if (options.parsing.enableMecabParser) { + return false; + } + } + + if (!permissionsSet.has('clipboardRead')) { + if (options.clipboard.enableBackgroundMonitor || options.clipboard.enableSearchPageMonitor) { + return false; + } + const fieldMarkersRequiringClipboardPermission = this._ankiFieldMarkersRequiringClipboardPermission; + const fieldsList = [ + options.anki.terms.fields, + options.anki.kanji.fields + ]; + for (const fields of fieldsList) { + for (const fieldValue of Object.values(fields)) { + const markers = this._getAnkiFieldMarkers(fieldValue); + for (const marker of markers) { + if (fieldMarkersRequiringClipboardPermission.has(marker)) { + return false; + } + } + } + } + } + + return true; + } + + // Private + + _getAnkiFieldMarkers(fieldValue) { + const pattern = this._ankiMarkerPattern; + const markers = []; + let match; + while ((match = pattern.exec(fieldValue)) !== null) { + markers.push(match[1]); + } + return markers; + } +} diff --git a/ext/js/display/query-parser.js b/ext/js/display/query-parser.js new file mode 100644 index 00000000..05ebfa27 --- /dev/null +++ b/ext/js/display/query-parser.js @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2019-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 + * TextScanner + * api + */ + +class QueryParser extends EventDispatcher { + constructor({getSearchContext, documentUtil}) { + super(); + this._getSearchContext = getSearchContext; + this._documentUtil = documentUtil; + this._text = ''; + this._setTextToken = null; + this._selectedParser = null; + this._parseResults = []; + this._queryParser = document.querySelector('#query-parser-content'); + this._queryParserModeContainer = document.querySelector('#query-parser-mode-container'); + this._queryParserModeSelect = document.querySelector('#query-parser-mode-select'); + this._textScanner = new TextScanner({ + node: this._queryParser, + getSearchContext, + documentUtil, + searchTerms: true, + searchKanji: false, + searchOnClick: true + }); + } + + get text() { + return this._text; + } + + prepare() { + this._textScanner.prepare(); + this._textScanner.on('searched', this._onSearched.bind(this)); + this._queryParserModeSelect.addEventListener('change', this._onParserChange.bind(this), false); + } + + setOptions({selectedParser, termSpacing, scanning}) { + let selectedParserChanged = false; + if (selectedParser === null || typeof selectedParser === 'string') { + selectedParserChanged = (this._selectedParser !== selectedParser); + this._selectedParser = selectedParser; + } + if (typeof termSpacing === 'boolean') { + this._queryParser.dataset.termSpacing = `${termSpacing}`; + } + if (scanning !== null && typeof scanning === 'object') { + this._textScanner.setOptions(scanning); + } + this._textScanner.setEnabled(true); + if (selectedParserChanged && this._parseResults.length > 0) { + this._renderParseResult(); + } + } + + async setText(text) { + this._text = text; + this._setPreview(text); + + const token = {}; + this._setTextToken = token; + this._parseResults = await api.textParse(text, this._getOptionsContext()); + if (this._setTextToken !== token) { return; } + + this._refreshSelectedParser(); + + this._renderParserSelect(); + this._renderParseResult(); + } + + // Private + + _onSearched(e) { + const {error} = e; + if (error !== null) { + yomichan.logError(error); + return; + } + if (e.type === null) { return; } + + this.trigger('searched', e); + } + + _onParserChange(e) { + const value = e.currentTarget.value; + this._setSelectedParser(value); + } + + _getOptionsContext() { + return this._getSearchContext().optionsContext; + } + + _refreshSelectedParser() { + if (this._parseResults.length > 0 && !this._getParseResult()) { + const value = this._parseResults[0].id; + this._setSelectedParser(value); + } + } + + _setSelectedParser(value) { + const optionsContext = this._getOptionsContext(); + api.modifySettings([{ + action: 'set', + path: 'parsing.selectedParser', + value, + scope: 'profile', + optionsContext + }], 'search'); + } + + _getParseResult() { + const selectedParser = this._selectedParser; + return this._parseResults.find((r) => r.id === selectedParser); + } + + _setPreview(text) { + const terms = [[{text, reading: ''}]]; + this._queryParser.textContent = ''; + this._queryParser.appendChild(this._createParseResult(terms, true)); + } + + _renderParserSelect() { + const visible = (this._parseResults.length > 1); + if (visible) { + this._updateParserModeSelect(this._queryParserModeSelect, this._parseResults, this._selectedParser); + } + this._queryParserModeContainer.hidden = !visible; + } + + _renderParseResult() { + const parseResult = this._getParseResult(); + this._queryParser.textContent = ''; + if (!parseResult) { return; } + this._queryParser.appendChild(this._createParseResult(parseResult.content, false)); + } + + _updateParserModeSelect(select, parseResults, selectedParser) { + const fragment = document.createDocumentFragment(); + + let index = 0; + let selectedIndex = -1; + for (const parseResult of parseResults) { + const option = document.createElement('option'); + option.value = parseResult.id; + switch (parseResult.source) { + case 'scanning-parser': + option.textContent = 'Scanning parser'; + break; + case 'mecab': + option.textContent = `MeCab: ${parseResult.dictionary}`; + break; + default: + option.textContent = `Unknown source: ${parseResult.source}`; + break; + } + fragment.appendChild(option); + + if (selectedParser === parseResult.id) { + selectedIndex = index; + } + ++index; + } + + select.textContent = ''; + select.appendChild(fragment); + select.selectedIndex = selectedIndex; + } + + _createParseResult(terms, preview) { + const type = preview ? 'preview' : 'normal'; + const fragment = document.createDocumentFragment(); + for (const term of terms) { + const termNode = document.createElement('span'); + termNode.className = 'query-parser-term'; + termNode.dataset.type = type; + for (const segment of term) { + if (segment.reading.trim().length === 0) { + this._addSegmentText(segment.text, termNode); + } else { + termNode.appendChild(this._createSegment(segment)); + } + } + fragment.appendChild(termNode); + } + return fragment; + } + + _createSegment(segment) { + const segmentNode = document.createElement('ruby'); + segmentNode.className = 'query-parser-segment'; + + const textNode = document.createElement('span'); + textNode.className = 'query-parser-segment-text'; + + const readingNode = document.createElement('rt'); + readingNode.className = 'query-parser-segment-reading'; + + segmentNode.appendChild(textNode); + segmentNode.appendChild(readingNode); + + this._addSegmentText(segment.text, textNode); + readingNode.textContent = segment.reading; + + return segmentNode; + } + + _addSegmentText(text, container) { + for (const character of text) { + const node = document.createElement('span'); + node.className = 'query-parser-char'; + node.textContent = character; + container.appendChild(node); + } + } +} diff --git a/ext/js/display/search-display-controller.js b/ext/js/display/search-display-controller.js new file mode 100644 index 00000000..a295346d --- /dev/null +++ b/ext/js/display/search-display-controller.js @@ -0,0 +1,422 @@ +/* + * 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 + * ClipboardMonitor + * api + * wanakana + */ + +class SearchDisplayController { + constructor(tabId, frameId, display, japaneseUtil) { + this._tabId = tabId; + this._frameId = frameId; + this._display = display; + this._searchButton = document.querySelector('#search-button'); + this._queryInput = document.querySelector('#search-textbox'); + this._introElement = document.querySelector('#intro'); + this._clipboardMonitorEnableCheckbox = document.querySelector('#clipboard-monitor-enable'); + this._wanakanaEnableCheckbox = document.querySelector('#wanakana-enable'); + this._queryInputEvents = new EventListenerCollection(); + this._queryInputEventsSetup = false; + this._wanakanaEnabled = false; + this._introVisible = true; + this._introAnimationTimer = null; + this._clipboardMonitorEnabled = false; + this._clipboardMonitor = new ClipboardMonitor({ + japaneseUtil, + clipboardReader: { + getText: async () => (await api.clipboardGet()) + } + }); + this._messageHandlers = new Map(); + this._mode = null; + } + + async prepare() { + this._updateMode(); + + await this._display.updateOptions(); + + chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); + yomichan.on('optionsUpdated', this._onOptionsUpdated.bind(this)); + + this._display.on('optionsUpdated', this._onDisplayOptionsUpdated.bind(this)); + this._display.on('contentUpdating', this._onContentUpdating.bind(this)); + + this._display.hotkeyHandler.registerActions([ + ['focusSearchBox', this._onActionFocusSearchBox.bind(this)] + ]); + this._registerMessageHandlers([ + ['getMode', {async: false, handler: this._onMessageGetMode.bind(this)}], + ['setMode', {async: false, handler: this._onMessageSetMode.bind(this)}], + ['updateSearchQuery', {async: false, handler: this._onExternalSearchUpdate.bind(this)}] + ]); + + this._display.autoPlayAudioDelay = 0; + this._display.queryParserVisible = true; + this._display.setHistorySettings({useBrowserHistory: true}); + this._display.setQueryPostProcessor(this._postProcessQuery.bind(this)); + + this._searchButton.addEventListener('click', this._onSearch.bind(this), false); + this._wanakanaEnableCheckbox.addEventListener('change', this._onWanakanaEnableChange.bind(this)); + window.addEventListener('copy', this._onCopy.bind(this)); + this._clipboardMonitor.on('change', this._onExternalSearchUpdate.bind(this)); + this._clipboardMonitorEnableCheckbox.addEventListener('change', this._onClipboardMonitorEnableChange.bind(this)); + this._display.hotkeyHandler.on('keydownNonHotkey', this._onKeyDown.bind(this)); + + this._onDisplayOptionsUpdated({options: this._display.getOptions()}); + } + + // Actions + + _onActionFocusSearchBox() { + if (this._queryInput === null) { return; } + this._queryInput.focus(); + this._queryInput.select(); + } + + // Messages + + _onMessageSetMode({mode}) { + this._setMode(mode, true); + } + + _onMessageGetMode() { + return this._mode; + } + + // Private + + _onMessage({action, params}, sender, callback) { + const messageHandler = this._messageHandlers.get(action); + if (typeof messageHandler === 'undefined') { return false; } + return yomichan.invokeMessageHandler(messageHandler, params, callback, sender); + } + + _onKeyDown(e) { + if ( + document.activeElement !== this._queryInput && + !e.ctrlKey && + !e.metaKey && + !e.altKey && + e.key.length === 1 + ) { + this._queryInput.focus({preventScroll: true}); + } + } + + async _onOptionsUpdated() { + await this._display.updateOptions(); + const query = this._queryInput.value; + if (query) { + this._display.searchLast(); + } + } + + _onDisplayOptionsUpdated({options}) { + this._clipboardMonitorEnabled = options.clipboard.enableSearchPageMonitor; + this._updateClipboardMonitorEnabled(); + + const enableWanakana = !!this._display.getOptions().general.enableWanakana; + this._wanakanaEnableCheckbox.checked = enableWanakana; + this._setWanakanaEnabled(enableWanakana); + } + + _onContentUpdating({type, content, source}) { + let animate = false; + let valid = false; + switch (type) { + case 'terms': + case 'kanji': + animate = !!content.animate; + valid = (typeof source === 'string' && source.length > 0); + this._display.blurElement(this._queryInput); + break; + case 'clear': + valid = false; + animate = true; + source = ''; + break; + } + + if (typeof source !== 'string') { source = ''; } + + if (this._queryInput.value !== source) { + this._queryInput.value = source; + this._updateSearchHeight(true); + } + this._setIntroVisible(!valid, animate); + } + + _onSearchInput() { + this._updateSearchHeight(false); + } + + _onSearchKeydown(e) { + const {code} = e; + if (!((code === 'Enter' || code === 'NumpadEnter') && !e.shiftKey)) { return; } + + // Search + e.preventDefault(); + e.stopImmediatePropagation(); + this._display.blurElement(e.currentTarget); + this._search(true, true, true); + } + + _onSearch(e) { + e.preventDefault(); + this._search(true, true, true); + } + + _onCopy() { + // ignore copy from search page + this._clipboardMonitor.setPreviousText(window.getSelection().toString().trim()); + } + + _onExternalSearchUpdate({text, animate=true}) { + const {clipboard: {autoSearchContent, maximumSearchLength}} = this._display.getOptions(); + if (text.length > maximumSearchLength) { + text = text.substring(0, maximumSearchLength); + } + this._queryInput.value = text; + this._updateSearchHeight(true); + this._search(animate, false, autoSearchContent); + } + + _onWanakanaEnableChange(e) { + const value = e.target.checked; + this._setWanakanaEnabled(value); + api.modifySettings([{ + action: 'set', + path: 'general.enableWanakana', + value, + scope: 'profile', + optionsContext: this._display.getOptionsContext() + }], 'search'); + } + + _onClipboardMonitorEnableChange(e) { + const enabled = e.target.checked; + this._setClipboardMonitorEnabled(enabled); + } + + _setWanakanaEnabled(enabled) { + if (this._queryInputEventsSetup && this._wanakanaEnabled === enabled) { return; } + + const input = this._queryInput; + this._queryInputEvents.removeAllEventListeners(); + this._queryInputEvents.addEventListener(input, 'keydown', this._onSearchKeydown.bind(this), false); + + this._wanakanaEnabled = enabled; + if (enabled) { + wanakana.bind(input); + } else { + wanakana.unbind(input); + } + + this._queryInputEvents.addEventListener(input, 'input', this._onSearchInput.bind(this), false); + this._queryInputEventsSetup = true; + } + + _setIntroVisible(visible, animate) { + if (this._introVisible === visible) { + return; + } + + this._introVisible = visible; + + if (this._introElement === null) { + return; + } + + if (this._introAnimationTimer !== null) { + clearTimeout(this._introAnimationTimer); + this._introAnimationTimer = null; + } + + if (visible) { + this._showIntro(animate); + } else { + this._hideIntro(animate); + } + } + + _showIntro(animate) { + if (animate) { + const duration = 0.4; + this._introElement.style.transition = ''; + this._introElement.style.height = ''; + const size = this._introElement.getBoundingClientRect(); + this._introElement.style.height = '0px'; + this._introElement.style.transition = `height ${duration}s ease-in-out 0s`; + window.getComputedStyle(this._introElement).getPropertyValue('height'); // Commits height so next line can start animation + this._introElement.style.height = `${size.height}px`; + this._introAnimationTimer = setTimeout(() => { + this._introElement.style.height = ''; + this._introAnimationTimer = null; + }, duration * 1000); + } else { + this._introElement.style.transition = ''; + this._introElement.style.height = ''; + } + } + + _hideIntro(animate) { + if (animate) { + const duration = 0.4; + const size = this._introElement.getBoundingClientRect(); + this._introElement.style.height = `${size.height}px`; + this._introElement.style.transition = `height ${duration}s ease-in-out 0s`; + window.getComputedStyle(this._introElement).getPropertyValue('height'); // Commits height so next line can start animation + } else { + this._introElement.style.transition = ''; + } + this._introElement.style.height = '0'; + } + + async _setClipboardMonitorEnabled(value) { + let modify = true; + if (value) { + value = await this._requestPermissions(['clipboardRead']); + modify = value; + } + + this._clipboardMonitorEnabled = value; + this._updateClipboardMonitorEnabled(); + + if (!modify) { return; } + + await api.modifySettings([{ + action: 'set', + path: 'clipboard.enableSearchPageMonitor', + value, + scope: 'profile', + optionsContext: this._display.getOptionsContext() + }], 'search'); + } + + _updateClipboardMonitorEnabled() { + const enabled = this._clipboardMonitorEnabled; + this._clipboardMonitorEnableCheckbox.checked = enabled; + if (enabled && this._mode !== 'popup') { + this._clipboardMonitor.start(); + } else { + this._clipboardMonitor.stop(); + } + } + + _requestPermissions(permissions) { + return new Promise((resolve) => { + chrome.permissions.request( + {permissions}, + (granted) => { + const e = chrome.runtime.lastError; + resolve(!e && granted); + } + ); + }); + } + + _search(animate, history, lookup) { + const query = this._queryInput.value; + const depth = this._display.depth; + const url = window.location.href; + const documentTitle = document.title; + const details = { + focus: false, + history, + params: { + query + }, + state: { + focusEntry: 0, + optionsContext: {depth, url}, + url, + sentence: {text: query, offset: 0}, + documentTitle + }, + content: { + definitions: null, + animate, + contentOrigin: { + tabId: this.tabId, + frameId: this.frameId + } + } + }; + if (!lookup) { details.params.lookup = 'false'; } + this._display.setContent(details); + } + + _updateSearchHeight(shrink) { + const node = this._queryInput; + if (shrink) { + node.style.height = '0'; + } + const {scrollHeight} = node; + const currentHeight = node.getBoundingClientRect().height; + if (shrink || scrollHeight >= currentHeight - 1) { + node.style.height = `${scrollHeight}px`; + } + } + + _postProcessQuery(query) { + if (this._wanakanaEnabled) { + try { + query = this._japaneseUtil.convertToKana(query); + } catch (e) { + // NOP + } + } + return query; + } + + _registerMessageHandlers(handlers) { + for (const [name, handlerInfo] of handlers) { + this._messageHandlers.set(name, handlerInfo); + } + } + + _updateMode() { + let mode = null; + try { + mode = sessionStorage.getItem('mode'); + } catch (e) { + // Browsers can throw a SecurityError when cookie blocking is enabled. + } + this._setMode(mode, false); + } + + _setMode(mode, save) { + if (mode === this._mode) { return; } + if (save) { + try { + if (mode === null) { + sessionStorage.removeItem('mode'); + } else { + sessionStorage.setItem('mode', mode); + } + } catch (e) { + // Browsers can throw a SecurityError when cookie blocking is enabled. + } + } + this._mode = mode; + document.documentElement.dataset.searchMode = (mode !== null ? mode : ''); + this._updateClipboardMonitorEnabled(); + } +} diff --git a/ext/js/display/search-main.js b/ext/js/display/search-main.js new file mode 100644 index 00000000..c7ec595a --- /dev/null +++ b/ext/js/display/search-main.js @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2019-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 + * Display + * DocumentFocusController + * HotkeyHandler + * JapaneseUtil + * SearchDisplayController + * api + * wanakana + */ + +(async () => { + try { + const documentFocusController = new DocumentFocusController(); + documentFocusController.prepare(); + + api.forwardLogsToBackend(); + await yomichan.backendReady(); + + const {tabId, frameId} = await api.frameInformationGet(); + + const japaneseUtil = new JapaneseUtil(wanakana); + + const hotkeyHandler = new HotkeyHandler(); + hotkeyHandler.prepare(); + + const display = new Display(tabId, frameId, 'search', japaneseUtil, documentFocusController, hotkeyHandler); + await display.prepare(); + + const searchDisplayController = new SearchDisplayController(tabId, frameId, display, japaneseUtil); + await searchDisplayController.prepare(); + + display.initializeState(); + + document.documentElement.dataset.loaded = 'true'; + + yomichan.ready(); + } catch (e) { + yomichan.logError(e); + } +})(); diff --git a/ext/js/dom/native-simple-dom-parser.js b/ext/js/dom/native-simple-dom-parser.js new file mode 100644 index 00000000..27dadec0 --- /dev/null +++ b/ext/js/dom/native-simple-dom-parser.js @@ -0,0 +1,50 @@ +/* + * 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 NativeSimpleDOMParser { + constructor(content) { + this._document = new DOMParser().parseFromString(content, 'text/html'); + } + + getElementById(id, root=null) { + return (root || this._document).querySelector(`[id='${id}']`); + } + + getElementByTagName(tagName, root=null) { + return (root || this._document).querySelector(tagName); + } + + getElementsByTagName(tagName, root=null) { + return [...(root || this._document).querySelectorAll(tagName)]; + } + + getElementsByClassName(className, root=null) { + return [...(root || this._document).querySelectorAll(`.${className}`)]; + } + + getAttribute(element, attribute) { + return element.hasAttribute(attribute) ? element.getAttribute(attribute) : null; + } + + getTextContent(element) { + return element.textContent; + } + + static isSupported() { + return typeof DOMParser !== 'undefined'; + } +} diff --git a/ext/js/dom/simple-dom-parser.js b/ext/js/dom/simple-dom-parser.js new file mode 100644 index 00000000..7c57ca98 --- /dev/null +++ b/ext/js/dom/simple-dom-parser.js @@ -0,0 +1,117 @@ +/* + * 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/>. + */ + +/* globals + * parse5 + */ + +class SimpleDOMParser { + constructor(content) { + this._document = parse5.parse(content); + } + + getElementById(id, root=null) { + for (const node of this._allNodes(root)) { + if (typeof node.tagName === 'string' && this.getAttribute(node, 'id') === id) { + return node; + } + } + return null; + } + + getElementByTagName(tagName, root=null) { + for (const node of this._allNodes(root)) { + if (node.tagName === tagName) { + return node; + } + } + return null; + } + + getElementsByTagName(tagName, root=null) { + const results = []; + for (const node of this._allNodes(root)) { + if (node.tagName === tagName) { + results.push(node); + } + } + return results; + } + + getElementsByClassName(className, root=null) { + const results = []; + const classNamePattern = new RegExp(`(^|\\s)${escapeRegExp(className)}(\\s|$)`); + for (const node of this._allNodes(root)) { + if (typeof node.tagName === 'string') { + const nodeClassName = this.getAttribute(node, 'class'); + if (nodeClassName !== null && classNamePattern.test(nodeClassName)) { + results.push(node); + } + } + } + return results; + } + + getAttribute(element, attribute) { + for (const attr of element.attrs) { + if ( + attr.name === attribute && + typeof attr.namespace === 'undefined' + ) { + return attr.value; + } + } + return null; + } + + getTextContent(element) { + let source = ''; + for (const node of this._allNodes(element)) { + if (node.nodeName === '#text') { + source += node.value; + } + } + return source; + } + + static isSupported() { + return typeof parse5 !== 'undefined'; + } + + // Private + + *_allNodes(root) { + if (root === null) { + root = this._document; + } + + // Depth-first pre-order traversal + const nodeQueue = [root]; + while (nodeQueue.length > 0) { + const node = nodeQueue.pop(); + + yield node; + + const childNodes = node.childNodes; + if (typeof childNodes !== 'undefined') { + for (let i = childNodes.length - 1; i >= 0; --i) { + nodeQueue.push(childNodes[i]); + } + } + } + } +} diff --git a/ext/js/general/text-source-map.js b/ext/js/general/text-source-map.js new file mode 100644 index 00000000..49b6d99f --- /dev/null +++ b/ext/js/general/text-source-map.js @@ -0,0 +1,118 @@ +/* + * 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 TextSourceMap { + constructor(source, mapping=null) { + this._source = source; + this._mapping = (mapping !== null ? TextSourceMap.normalizeMapping(mapping) : null); + } + + get source() { + return this._source; + } + + equals(other) { + if (this === other) { + return true; + } + + const source = this._source; + if (!(other instanceof TextSourceMap && source === other.source)) { + return false; + } + + let mapping = this._mapping; + let otherMapping = other.getMappingCopy(); + if (mapping === null) { + if (otherMapping === null) { + return true; + } + mapping = TextSourceMap.createMapping(source); + } else if (otherMapping === null) { + otherMapping = TextSourceMap.createMapping(source); + } + + const mappingLength = mapping.length; + if (mappingLength !== otherMapping.length) { + return false; + } + + for (let i = 0; i < mappingLength; ++i) { + if (mapping[i] !== otherMapping[i]) { + return false; + } + } + + return true; + } + + getSourceLength(finalLength) { + const mapping = this._mapping; + if (mapping === null) { + return finalLength; + } + + let sourceLength = 0; + for (let i = 0; i < finalLength; ++i) { + sourceLength += mapping[i]; + } + return sourceLength; + } + + combine(index, count) { + if (count <= 0) { return; } + + if (this._mapping === null) { + this._mapping = TextSourceMap.createMapping(this._source); + } + + let sum = this._mapping[index]; + const parts = this._mapping.splice(index + 1, count); + for (const part of parts) { + sum += part; + } + this._mapping[index] = sum; + } + + insert(index, ...items) { + if (this._mapping === null) { + this._mapping = TextSourceMap.createMapping(this._source); + } + + this._mapping.splice(index, 0, ...items); + } + + getMappingCopy() { + return this._mapping !== null ? [...this._mapping] : null; + } + + static createMapping(text) { + return new Array(text.length).fill(1); + } + + static normalizeMapping(mapping) { + const result = []; + for (const value of mapping) { + result.push( + (typeof value === 'number' && Number.isFinite(value)) ? + Math.floor(value) : + 0 + ); + } + return result; + } +} diff --git a/ext/js/language/deinflector.js b/ext/js/language/deinflector.js new file mode 100644 index 00000000..8fee3f01 --- /dev/null +++ b/ext/js/language/deinflector.js @@ -0,0 +1,96 @@ +/* + * 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/>. + */ + + +class Deinflector { + constructor(reasons) { + this.reasons = Deinflector.normalizeReasons(reasons); + } + + deinflect(source, rawSource) { + const results = [{ + source, + rawSource, + term: source, + rules: 0, + reasons: [], + databaseDefinitions: [] + }]; + for (let i = 0; i < results.length; ++i) { + const {rules, term, reasons} = results[i]; + for (const [reason, variants] of this.reasons) { + for (const [kanaIn, kanaOut, rulesIn, rulesOut] of variants) { + if ( + (rules !== 0 && (rules & rulesIn) === 0) || + !term.endsWith(kanaIn) || + (term.length - kanaIn.length + kanaOut.length) <= 0 + ) { + continue; + } + + results.push({ + source, + rawSource, + term: term.substring(0, term.length - kanaIn.length) + kanaOut, + rules: rulesOut, + reasons: [reason, ...reasons], + databaseDefinitions: [] + }); + } + } + } + return results; + } + + static normalizeReasons(reasons) { + const normalizedReasons = []; + for (const [reason, reasonInfo] of Object.entries(reasons)) { + const variants = []; + for (const {kanaIn, kanaOut, rulesIn, rulesOut} of reasonInfo) { + variants.push([ + kanaIn, + kanaOut, + Deinflector.rulesToRuleFlags(rulesIn), + Deinflector.rulesToRuleFlags(rulesOut) + ]); + } + normalizedReasons.push([reason, variants]); + } + return normalizedReasons; + } + + static rulesToRuleFlags(rules) { + const ruleTypes = Deinflector.ruleTypes; + let value = 0; + for (const rule of rules) { + const ruleBits = ruleTypes.get(rule); + if (typeof ruleBits === 'undefined') { continue; } + value |= ruleBits; + } + return value; + } +} + +Deinflector.ruleTypes = new Map([ + ['v1', 0b00000001], // Verb ichidan + ['v5', 0b00000010], // Verb godan + ['vs', 0b00000100], // Verb suru + ['vk', 0b00001000], // Verb kuru + ['vz', 0b00010000], // Verb zuru + ['adj-i', 0b00100000], // Adjective i + ['iru', 0b01000000] // Intermediate -iru endings for progressive or perfect tense +]); diff --git a/ext/js/language/dictionary-database.js b/ext/js/language/dictionary-database.js new file mode 100644 index 00000000..b363ed25 --- /dev/null +++ b/ext/js/language/dictionary-database.js @@ -0,0 +1,484 @@ +/* + * 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 + * Database + */ + +class DictionaryDatabase { + constructor() { + this._db = new Database(); + this._dbName = 'dict'; + this._schemas = new Map(); + } + + // Public + + async prepare() { + await this._db.open( + this._dbName, + 60, + [ + { + version: 20, + stores: { + terms: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'expression', 'reading'] + }, + kanji: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'character'] + }, + tagMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary'] + }, + dictionaries: { + primaryKey: {autoIncrement: true}, + indices: ['title', 'version'] + } + } + }, + { + version: 30, + stores: { + termMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'expression'] + }, + kanjiMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'character'] + }, + tagMeta: { + primaryKey: {autoIncrement: true}, + indices: ['dictionary', 'name'] + } + } + }, + { + version: 40, + stores: { + terms: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'expression', 'reading', 'sequence'] + } + } + }, + { + version: 50, + stores: { + terms: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse'] + } + } + }, + { + version: 60, + stores: { + media: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'path'] + } + } + } + ] + ); + } + + async close() { + this._db.close(); + } + + isPrepared() { + return this._db.isOpen(); + } + + async purge() { + if (this._db.isOpening()) { + throw new Error('Cannot purge database while opening'); + } + if (this._db.isOpen()) { + this._db.close(); + } + let result = false; + try { + await Database.deleteDatabase(this._dbName); + result = true; + } catch (e) { + yomichan.logError(e); + } + await this.prepare(); + return result; + } + + async deleteDictionary(dictionaryName, progressSettings, onProgress) { + const targets = [ + ['dictionaries', 'title'], + ['kanji', 'dictionary'], + ['kanjiMeta', 'dictionary'], + ['terms', 'dictionary'], + ['termMeta', 'dictionary'], + ['tagMeta', 'dictionary'], + ['media', 'dictionary'] + ]; + + const {rate} = progressSettings; + const progressData = { + count: 0, + processed: 0, + storeCount: targets.length, + storesProcesed: 0 + }; + + const filterKeys = (keys) => { + ++progressData.storesProcesed; + progressData.count += keys.length; + onProgress(progressData); + return keys; + }; + const onProgress2 = () => { + const processed = progressData.processed + 1; + progressData.processed = processed; + if ((processed % rate) === 0 || processed === progressData.count) { + onProgress(progressData); + } + }; + + const promises = []; + for (const [objectStoreName, indexName] of targets) { + const query = IDBKeyRange.only(dictionaryName); + const promise = this._db.bulkDelete(objectStoreName, indexName, query, filterKeys, onProgress2); + promises.push(promise); + } + await Promise.all(promises); + } + + findTermsBulk(termList, dictionaries, wildcard) { + return new Promise((resolve, reject) => { + const results = []; + const count = termList.length; + if (count === 0) { + resolve(results); + return; + } + + const visited = new Set(); + const useWildcard = !!wildcard; + const prefixWildcard = wildcard === 'prefix'; + + const transaction = this._db.transaction(['terms'], 'readonly'); + const terms = transaction.objectStore('terms'); + const index1 = terms.index(prefixWildcard ? 'expressionReverse' : 'expression'); + const index2 = terms.index(prefixWildcard ? 'readingReverse' : 'reading'); + + const count2 = count * 2; + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const term = prefixWildcard ? stringReverse(termList[i]) : termList[i]; + const query = useWildcard ? IDBKeyRange.bound(term, `${term}\uffff`, false, false) : IDBKeyRange.only(term); + + const onGetAll = (rows) => { + for (const row of rows) { + if (dictionaries.has(row.dictionary) && !visited.has(row.id)) { + visited.add(row.id); + results.push(this._createTerm(row, inputIndex)); + } + } + if (++completeCount >= count2) { + resolve(results); + } + }; + + this._db.getAll(index1, query, onGetAll, reject); + this._db.getAll(index2, query, onGetAll, reject); + } + }); + } + + findTermsExactBulk(termList, readingList, dictionaries) { + return new Promise((resolve, reject) => { + const results = []; + const count = termList.length; + if (count === 0) { + resolve(results); + return; + } + + const transaction = this._db.transaction(['terms'], 'readonly'); + const terms = transaction.objectStore('terms'); + const index = terms.index('expression'); + + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const reading = readingList[i]; + const query = IDBKeyRange.only(termList[i]); + + const onGetAll = (rows) => { + for (const row of rows) { + if (row.reading === reading && dictionaries.has(row.dictionary)) { + results.push(this._createTerm(row, inputIndex)); + } + } + if (++completeCount >= count) { + resolve(results); + } + }; + + this._db.getAll(index, query, onGetAll, reject); + } + }); + } + + findTermsBySequenceBulk(sequenceList, mainDictionary) { + return new Promise((resolve, reject) => { + const results = []; + const count = sequenceList.length; + if (count === 0) { + resolve(results); + return; + } + + const transaction = this._db.transaction(['terms'], 'readonly'); + const terms = transaction.objectStore('terms'); + const index = terms.index('sequence'); + + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const query = IDBKeyRange.only(sequenceList[i]); + + const onGetAll = (rows) => { + for (const row of rows) { + if (row.dictionary === mainDictionary) { + results.push(this._createTerm(row, inputIndex)); + } + } + if (++completeCount >= count) { + resolve(results); + } + }; + + this._db.getAll(index, query, onGetAll, reject); + } + }); + } + + findTermMetaBulk(termList, dictionaries) { + return this._findGenericBulk('termMeta', 'expression', termList, dictionaries, this._createTermMeta.bind(this)); + } + + findKanjiBulk(kanjiList, dictionaries) { + return this._findGenericBulk('kanji', 'character', kanjiList, dictionaries, this._createKanji.bind(this)); + } + + findKanjiMetaBulk(kanjiList, dictionaries) { + return this._findGenericBulk('kanjiMeta', 'character', kanjiList, dictionaries, this._createKanjiMeta.bind(this)); + } + + findTagForTitle(name, title) { + const query = IDBKeyRange.only(name); + return this._db.find('tagMeta', 'name', query, (row) => (row.dictionary === title), null); + } + + getMedia(targets) { + return new Promise((resolve, reject) => { + const count = targets.length; + const results = new Array(count).fill(null); + if (count === 0) { + resolve(results); + return; + } + + let completeCount = 0; + const transaction = this._db.transaction(['media'], 'readonly'); + const objectStore = transaction.objectStore('media'); + const index = objectStore.index('path'); + + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const {path, dictionaryName} = targets[i]; + const query = IDBKeyRange.only(path); + + const onGetAll = (rows) => { + for (const row of rows) { + if (row.dictionary !== dictionaryName) { continue; } + results[inputIndex] = this._createMedia(row, inputIndex); + } + if (++completeCount >= count) { + resolve(results); + } + }; + + this._db.getAll(index, query, onGetAll, reject); + } + }); + } + + getDictionaryInfo() { + return new Promise((resolve, reject) => { + const transaction = this._db.transaction(['dictionaries'], 'readonly'); + const objectStore = transaction.objectStore('dictionaries'); + this._db.getAll(objectStore, null, resolve, reject); + }); + } + + getDictionaryCounts(dictionaryNames, getTotal) { + return new Promise((resolve, reject) => { + const targets = [ + ['kanji', 'dictionary'], + ['kanjiMeta', 'dictionary'], + ['terms', 'dictionary'], + ['termMeta', 'dictionary'], + ['tagMeta', 'dictionary'], + ['media', 'dictionary'] + ]; + const objectStoreNames = targets.map(([objectStoreName]) => objectStoreName); + const transaction = this._db.transaction(objectStoreNames, 'readonly'); + const databaseTargets = targets.map(([objectStoreName, indexName]) => { + const objectStore = transaction.objectStore(objectStoreName); + const index = objectStore.index(indexName); + return {objectStore, index}; + }); + + const countTargets = []; + if (getTotal) { + for (const {objectStore} of databaseTargets) { + countTargets.push([objectStore, null]); + } + } + for (const dictionaryName of dictionaryNames) { + const query = IDBKeyRange.only(dictionaryName); + for (const {index} of databaseTargets) { + countTargets.push([index, query]); + } + } + + const onCountComplete = (results) => { + const resultCount = results.length; + const targetCount = targets.length; + const counts = []; + for (let i = 0; i < resultCount; i += targetCount) { + const countGroup = {}; + for (let j = 0; j < targetCount; ++j) { + countGroup[targets[j][0]] = results[i + j]; + } + counts.push(countGroup); + } + const total = getTotal ? counts.shift() : null; + resolve({total, counts}); + }; + + this._db.bulkCount(countTargets, onCountComplete, reject); + }); + } + + async dictionaryExists(title) { + const query = IDBKeyRange.only(title); + const result = await this._db.find('dictionaries', 'title', query); + return typeof result !== 'undefined'; + } + + bulkAdd(objectStoreName, items, start, count) { + return this._db.bulkAdd(objectStoreName, items, start, count); + } + + // Private + + async _findGenericBulk(objectStoreName, indexName, indexValueList, dictionaries, createResult) { + return new Promise((resolve, reject) => { + const results = []; + const count = indexValueList.length; + if (count === 0) { + resolve(results); + return; + } + + const transaction = this._db.transaction([objectStoreName], 'readonly'); + const terms = transaction.objectStore(objectStoreName); + const index = terms.index(indexName); + + let completeCount = 0; + for (let i = 0; i < count; ++i) { + const inputIndex = i; + const query = IDBKeyRange.only(indexValueList[i]); + + const onGetAll = (rows) => { + for (const row of rows) { + if (dictionaries.has(row.dictionary)) { + results.push(createResult(row, inputIndex)); + } + } + if (++completeCount >= count) { + resolve(results); + } + }; + + this._db.getAll(index, query, onGetAll, reject); + } + }); + } + + _createTerm(row, index) { + return { + index, + expression: row.expression, + reading: row.reading, + definitionTags: this._splitField(row.definitionTags || row.tags || ''), + termTags: this._splitField(row.termTags || ''), + rules: this._splitField(row.rules), + glossary: row.glossary, + score: row.score, + dictionary: row.dictionary, + id: row.id, + sequence: typeof row.sequence === 'undefined' ? -1 : row.sequence + }; + } + + _createKanji(row, index) { + return { + index, + character: row.character, + onyomi: this._splitField(row.onyomi), + kunyomi: this._splitField(row.kunyomi), + tags: this._splitField(row.tags), + glossary: row.meanings, + stats: row.stats, + dictionary: row.dictionary + }; + } + + _createTermMeta({expression, mode, data, dictionary}, index) { + return {expression, mode, data, dictionary, index}; + } + + _createKanjiMeta({character, mode, data, dictionary}, index) { + return {character, mode, data, dictionary, index}; + } + + _createMedia(row, index) { + return Object.assign({}, row, {index}); + } + + _splitField(field) { + return field.length === 0 ? [] : field.split(' '); + } +} diff --git a/ext/js/language/dictionary-importer.js b/ext/js/language/dictionary-importer.js new file mode 100644 index 00000000..4cb608db --- /dev/null +++ b/ext/js/language/dictionary-importer.js @@ -0,0 +1,407 @@ +/* + * 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 + * JSZip + * JsonSchemaValidator + * MediaUtility + */ + +class DictionaryImporter { + constructor() { + this._schemas = new Map(); + this._jsonSchemaValidator = new JsonSchemaValidator(); + this._mediaUtility = new MediaUtility(); + } + + async importDictionary(dictionaryDatabase, archiveSource, details, onProgress) { + if (!dictionaryDatabase) { + throw new Error('Invalid database'); + } + if (!dictionaryDatabase.isPrepared()) { + throw new Error('Database is not ready'); + } + + const hasOnProgress = (typeof onProgress === 'function'); + + // Read archive + const archive = await JSZip.loadAsync(archiveSource); + + // Read and validate index + const indexFileName = 'index.json'; + const indexFile = archive.files[indexFileName]; + if (!indexFile) { + throw new Error('No dictionary index found in archive'); + } + + const index = JSON.parse(await indexFile.async('string')); + + const indexSchema = await this._getSchema('/data/schemas/dictionary-index-schema.json'); + this._validateJsonSchema(index, indexSchema, indexFileName); + + const dictionaryTitle = index.title; + const version = index.format || index.version; + + if (!dictionaryTitle || !index.revision) { + throw new Error('Unrecognized dictionary format'); + } + + // Verify database is not already imported + if (await dictionaryDatabase.dictionaryExists(dictionaryTitle)) { + throw new Error('Dictionary is already imported'); + } + + // Data format converters + const convertTermBankEntry = (entry) => { + if (version === 1) { + const [expression, reading, definitionTags, rules, score, ...glossary] = entry; + return {expression, reading, definitionTags, rules, score, glossary}; + } else { + const [expression, reading, definitionTags, rules, score, glossary, sequence, termTags] = entry; + return {expression, reading, definitionTags, rules, score, glossary, sequence, termTags}; + } + }; + + const convertTermMetaBankEntry = (entry) => { + const [expression, mode, data] = entry; + return {expression, mode, data}; + }; + + const convertKanjiBankEntry = (entry) => { + if (version === 1) { + const [character, onyomi, kunyomi, tags, ...meanings] = entry; + return {character, onyomi, kunyomi, tags, meanings}; + } else { + const [character, onyomi, kunyomi, tags, meanings, stats] = entry; + return {character, onyomi, kunyomi, tags, meanings, stats}; + } + }; + + const convertKanjiMetaBankEntry = (entry) => { + const [character, mode, data] = entry; + return {character, mode, data}; + }; + + const convertTagBankEntry = (entry) => { + const [name, category, order, notes, score] = entry; + return {name, category, order, notes, score}; + }; + + // Archive file reading + const readFileSequence = async (fileNameFormat, convertEntry, schema) => { + const results = []; + for (let i = 1; true; ++i) { + const fileName = fileNameFormat.replace(/\?/, `${i}`); + const file = archive.files[fileName]; + if (!file) { break; } + + const entries = JSON.parse(await file.async('string')); + this._validateJsonSchema(entries, schema, fileName); + + for (let entry of entries) { + entry = convertEntry(entry); + entry.dictionary = dictionaryTitle; + results.push(entry); + } + } + return results; + }; + + // Load schemas + const dataBankSchemaPaths = this._getDataBankSchemaPaths(version); + const dataBankSchemas = await Promise.all(dataBankSchemaPaths.map((path) => this._getSchema(path))); + + // Load data + const termList = await readFileSequence('term_bank_?.json', convertTermBankEntry, dataBankSchemas[0]); + const termMetaList = await readFileSequence('term_meta_bank_?.json', convertTermMetaBankEntry, dataBankSchemas[1]); + const kanjiList = await readFileSequence('kanji_bank_?.json', convertKanjiBankEntry, dataBankSchemas[2]); + const kanjiMetaList = await readFileSequence('kanji_meta_bank_?.json', convertKanjiMetaBankEntry, dataBankSchemas[3]); + const tagList = await readFileSequence('tag_bank_?.json', convertTagBankEntry, dataBankSchemas[4]); + + // Old tags + const indexTagMeta = index.tagMeta; + if (typeof indexTagMeta === 'object' && indexTagMeta !== null) { + for (const name of Object.keys(indexTagMeta)) { + const {category, order, notes, score} = indexTagMeta[name]; + tagList.push({name, category, order, notes, score}); + } + } + + // Prefix wildcard support + const prefixWildcardsSupported = !!details.prefixWildcardsSupported; + if (prefixWildcardsSupported) { + for (const entry of termList) { + entry.expressionReverse = stringReverse(entry.expression); + entry.readingReverse = stringReverse(entry.reading); + } + } + + // Extended data support + const extendedDataContext = { + archive, + media: new Map() + }; + for (const entry of termList) { + const glossaryList = entry.glossary; + for (let i = 0, ii = glossaryList.length; i < ii; ++i) { + const glossary = glossaryList[i]; + if (typeof glossary !== 'object' || glossary === null) { continue; } + glossaryList[i] = await this._formatDictionaryTermGlossaryObject(glossary, extendedDataContext, entry); + } + } + + const media = [...extendedDataContext.media.values()]; + + // Add dictionary + const summary = this._createSummary(dictionaryTitle, version, index, {prefixWildcardsSupported}); + + dictionaryDatabase.bulkAdd('dictionaries', [summary], 0, 1); + + // Add data + const errors = []; + const total = ( + termList.length + + termMetaList.length + + kanjiList.length + + kanjiMetaList.length + + tagList.length + ); + let loadedCount = 0; + const maxTransactionLength = 1000; + + const bulkAdd = async (objectStoreName, entries) => { + const ii = entries.length; + for (let i = 0; i < ii; i += maxTransactionLength) { + const count = Math.min(maxTransactionLength, ii - i); + + try { + await dictionaryDatabase.bulkAdd(objectStoreName, entries, i, count); + } catch (e) { + errors.push(e); + } + + loadedCount += count; + if (hasOnProgress) { + onProgress(total, loadedCount); + } + } + }; + + await bulkAdd('terms', termList); + await bulkAdd('termMeta', termMetaList); + await bulkAdd('kanji', kanjiList); + await bulkAdd('kanjiMeta', kanjiMetaList); + await bulkAdd('tagMeta', tagList); + await bulkAdd('media', media); + + return {result: summary, errors}; + } + + _createSummary(dictionaryTitle, version, index, details) { + const summary = { + title: dictionaryTitle, + revision: index.revision, + sequenced: index.sequenced, + version + }; + + const {author, url, description, attribution} = index; + if (typeof author === 'string') { summary.author = author; } + if (typeof url === 'string') { summary.url = url; } + if (typeof description === 'string') { summary.description = description; } + if (typeof attribution === 'string') { summary.attribution = attribution; } + + Object.assign(summary, details); + + return summary; + } + + async _getSchema(fileName) { + let schemaPromise = this._schemas.get(fileName); + if (typeof schemaPromise !== 'undefined') { + return schemaPromise; + } + + schemaPromise = this._fetchJsonAsset(fileName); + this._schemas.set(fileName, schemaPromise); + return schemaPromise; + } + + _validateJsonSchema(value, schema, fileName) { + try { + this._jsonSchemaValidator.validate(value, schema); + } catch (e) { + throw this._formatSchemaError(e, fileName); + } + } + + _formatSchemaError(e, fileName) { + const valuePathString = this._getSchemaErrorPathString(e.info.valuePath, 'dictionary'); + const schemaPathString = this._getSchemaErrorPathString(e.info.schemaPath, 'schema'); + + const e2 = new Error(`Dictionary has invalid data in '${fileName}' for value '${valuePathString}', validated against '${schemaPathString}': ${e.message}`); + e2.data = e; + + return e2; + } + + _getSchemaErrorPathString(infoList, base='') { + let result = base; + for (const [part] of infoList) { + switch (typeof part) { + case 'string': + if (result.length > 0) { + result += '.'; + } + result += part; + break; + case 'number': + result += `[${part}]`; + break; + } + } + return result; + } + + _getDataBankSchemaPaths(version) { + const termBank = ( + version === 1 ? + '/data/schemas/dictionary-term-bank-v1-schema.json' : + '/data/schemas/dictionary-term-bank-v3-schema.json' + ); + const termMetaBank = '/data/schemas/dictionary-term-meta-bank-v3-schema.json'; + const kanjiBank = ( + version === 1 ? + '/data/schemas/dictionary-kanji-bank-v1-schema.json' : + '/data/schemas/dictionary-kanji-bank-v3-schema.json' + ); + const kanjiMetaBank = '/data/schemas/dictionary-kanji-meta-bank-v3-schema.json'; + const tagBank = '/data/schemas/dictionary-tag-bank-v3-schema.json'; + + return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank]; + } + + async _formatDictionaryTermGlossaryObject(data, context, entry) { + switch (data.type) { + case 'text': + return data.text; + case 'image': + return await this._formatDictionaryTermGlossaryImage(data, context, entry); + default: + throw new Error(`Unhandled data type: ${data.type}`); + } + } + + async _formatDictionaryTermGlossaryImage(data, context, entry) { + const dictionary = entry.dictionary; + const {path, width: preferredWidth, height: preferredHeight, title, description, pixelated} = data; + if (context.media.has(path)) { + // Already exists + return data; + } + + let errorSource = entry.expression; + if (entry.reading.length > 0) { + errorSource += ` (${entry.reading});`; + } + + const file = context.archive.file(path); + if (file === null) { + throw new Error(`Could not find image at path ${JSON.stringify(path)} for ${errorSource}`); + } + + const content = await file.async('base64'); + const mediaType = this._mediaUtility.getImageMediaTypeFromFileName(path); + if (mediaType === null) { + throw new Error(`Could not determine media type for image at path ${JSON.stringify(path)} for ${errorSource}`); + } + + let image; + try { + image = await this._loadImageBase64(mediaType, content); + } catch (e) { + throw new Error(`Could not load image at path ${JSON.stringify(path)} for ${errorSource}`); + } + + const width = image.naturalWidth; + const height = image.naturalHeight; + + // Create image data + const mediaData = { + dictionary, + path, + mediaType, + width, + height, + content + }; + context.media.set(path, mediaData); + + // Create new data + const newData = { + type: 'image', + path, + width, + height + }; + if (typeof preferredWidth === 'number') { newData.preferredWidth = preferredWidth; } + if (typeof preferredHeight === 'number') { newData.preferredHeight = preferredHeight; } + if (typeof title === 'string') { newData.title = title; } + if (typeof description === 'string') { newData.description = description; } + if (typeof pixelated === 'boolean') { newData.pixelated = pixelated; } + + return newData; + } + + async _fetchJsonAsset(url) { + 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 response.json(); + } + + /** + * Attempts to load an image using a base64 encoded content and a media type. + * @param mediaType The media type for the image content. + * @param content The binary content for the image, encoded in base64. + * @returns A Promise which resolves with an HTMLImageElement instance on + * successful load, otherwise an error is thrown. + */ + _loadImageBase64(mediaType, content) { + return new Promise((resolve, reject) => { + const image = new Image(); + const eventListeners = new EventListenerCollection(); + eventListeners.addEventListener(image, 'load', () => { + eventListeners.removeAllEventListeners(); + resolve(image); + }, false); + eventListeners.addEventListener(image, 'error', () => { + eventListeners.removeAllEventListeners(); + reject(new Error('Image failed to load')); + }, false); + image.src = `data:${mediaType};base64,${content}`; + }); + } +} diff --git a/ext/js/language/translator.js b/ext/js/language/translator.js new file mode 100644 index 00000000..729c8294 --- /dev/null +++ b/ext/js/language/translator.js @@ -0,0 +1,1397 @@ +/* + * 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 + * Deinflector + * TextSourceMap + */ + +/** + * Class which finds term and kanji definitions for text. + */ +class Translator { + /** + * Creates a new Translator instance. + * @param database An instance of DictionaryDatabase. + */ + constructor({japaneseUtil, database}) { + this._japaneseUtil = japaneseUtil; + this._database = database; + this._deinflector = null; + this._tagCache = new Map(); + this._stringComparer = new Intl.Collator('en-US'); // Invariant locale + } + + /** + * Initializes the instance for use. The public API should not be used until + * this function has been called. + * @param deinflectionReasons The raw deinflections reasons data that the Deinflector uses. + */ + prepare(deinflectionReasons) { + this._deinflector = new Deinflector(deinflectionReasons); + } + + /** + * Clears the database tag cache. This should be executed if the database is changed. + */ + clearDatabaseCaches() { + this._tagCache.clear(); + } + + /** + * Finds term definitions for the given text. + * @param mode The mode to use for finding terms, which determines the format of the resulting array. + * One of: 'group', 'merge', 'split', 'simple' + * @param text The text to find terms for. + * @param options An object using the following structure: + * { + * wildcard: (enum: null, 'prefix', 'suffix'), + * mainDictionary: (string), + * alphanumeric: (boolean), + * convertHalfWidthCharacters: (enum: 'false', 'true', 'variant'), + * convertNumericCharacters: (enum: 'false', 'true', 'variant'), + * convertAlphabeticCharacters: (enum: 'false', 'true', 'variant'), + * convertHiraganaToKatakana: (enum: 'false', 'true', 'variant'), + * convertKatakanaToHiragana: (enum: 'false', 'true', 'variant'), + * collapseEmphaticSequences: (enum: 'false', 'true', 'full'), + * textReplacements: [ + * (null or [ + * {pattern: (RegExp), replacement: (string)} + * ... + * ]) + * ... + * ], + * enabledDictionaryMap: (Map of [ + * (string), + * { + * priority: (number), + * allowSecondarySearches: (boolean) + * } + * ]) + * } + * @returns An array of [definitions, textLength]. The structure of each definition depends on the + * mode parameter, see the _create?TermDefinition?() functions for structure details. + */ + async findTerms(mode, text, options) { + switch (mode) { + case 'group': + return await this._findTermsGrouped(text, options); + case 'merge': + return await this._findTermsMerged(text, options); + case 'split': + return await this._findTermsSplit(text, options); + case 'simple': + return await this._findTermsSimple(text, options); + default: + return [[], 0]; + } + } + + /** + * Finds kanji definitions for the given text. + * @param text The text to find kanji definitions for. This string can be of any length, + * but is typically just one character, which is a single kanji. If the string is multiple + * characters long, each character will be searched in the database. + * @param options An object using the following structure: + * { + * enabledDictionaryMap: (Map of [ + * (string), + * { + * priority: (number) + * } + * ]) + * } + * @returns An array of definitions. See the _createKanjiDefinition() function for structure details. + */ + async findKanji(text, options) { + const {enabledDictionaryMap} = options; + const kanjiUnique = new Set(); + for (const c of text) { + kanjiUnique.add(c); + } + + const databaseDefinitions = await this._database.findKanjiBulk([...kanjiUnique], enabledDictionaryMap); + if (databaseDefinitions.length === 0) { return []; } + + this._sortDatabaseDefinitionsByIndex(databaseDefinitions); + + const definitions = []; + for (const {character, onyomi, kunyomi, tags, glossary, stats, dictionary} of databaseDefinitions) { + const expandedStats = await this._expandStats(stats, dictionary); + const expandedTags = await this._expandTags(tags, dictionary); + this._sortTags(expandedTags); + + const definition = this._createKanjiDefinition(character, dictionary, onyomi, kunyomi, glossary, expandedTags, expandedStats); + definitions.push(definition); + } + + await this._buildKanjiMeta(definitions, enabledDictionaryMap); + + return definitions; + } + + // Find terms core functions + + async _findTermsSimple(text, options) { + const {enabledDictionaryMap} = options; + const [definitions, length] = await this._findTermsInternal(text, enabledDictionaryMap, options); + this._sortDefinitions(definitions, false); + return [definitions, length]; + } + + async _findTermsSplit(text, options) { + const {enabledDictionaryMap} = options; + const [definitions, length] = await this._findTermsInternal(text, enabledDictionaryMap, options); + await this._buildTermMeta(definitions, enabledDictionaryMap); + this._sortDefinitions(definitions, true); + return [definitions, length]; + } + + async _findTermsGrouped(text, options) { + const {enabledDictionaryMap} = options; + const [definitions, length] = await this._findTermsInternal(text, enabledDictionaryMap, options); + + const groupedDefinitions = this._groupTerms(definitions, enabledDictionaryMap); + await this._buildTermMeta(groupedDefinitions, enabledDictionaryMap); + this._sortDefinitions(groupedDefinitions, false); + + for (const definition of groupedDefinitions) { + this._flagRedundantDefinitionTags(definition.definitions); + } + + return [groupedDefinitions, length]; + } + + async _findTermsMerged(text, options) { + const {mainDictionary, enabledDictionaryMap} = options; + const secondarySearchDictionaryMap = this._getSecondarySearchDictionaryMap(enabledDictionaryMap); + + const [definitions, length] = await this._findTermsInternal(text, enabledDictionaryMap, options); + const {sequencedDefinitions, unsequencedDefinitions} = await this._getSequencedDefinitions(definitions, mainDictionary, enabledDictionaryMap); + const definitionsMerged = []; + const usedDefinitions = new Set(); + + for (const {sourceDefinitions, relatedDefinitions} of sequencedDefinitions) { + const result = await this._getMergedDefinition( + sourceDefinitions, + relatedDefinitions, + unsequencedDefinitions, + secondarySearchDictionaryMap, + usedDefinitions + ); + definitionsMerged.push(result); + } + + const unusedDefinitions = unsequencedDefinitions.filter((definition) => !usedDefinitions.has(definition)); + for (const groupedDefinition of this._groupTerms(unusedDefinitions, enabledDictionaryMap)) { + const {reasons, score, expression, reading, source, rawSource, sourceTerm, furiganaSegments, termTags, definitions: definitions2} = groupedDefinition; + const termDetailsList = [this._createTermDetails(sourceTerm, expression, reading, furiganaSegments, termTags)]; + const compatibilityDefinition = this._createMergedTermDefinition( + source, + rawSource, + this._convertTermDefinitionsToMergedGlossaryTermDefinitions(definitions2), + [expression], + [reading], + termDetailsList, + reasons, + score + ); + definitionsMerged.push(compatibilityDefinition); + } + + await this._buildTermMeta(definitionsMerged, enabledDictionaryMap); + this._sortDefinitions(definitionsMerged, false); + + for (const definition of definitionsMerged) { + this._flagRedundantDefinitionTags(definition.definitions); + } + + return [definitionsMerged, length]; + } + + // Find terms internal implementation + + async _findTermsInternal(text, enabledDictionaryMap, options) { + const {alphanumeric, wildcard} = options; + text = this._getSearchableText(text, alphanumeric); + if (text.length === 0) { + return [[], 0]; + } + + const deinflections = ( + wildcard ? + await this._findTermWildcard(text, enabledDictionaryMap, wildcard) : + await this._findTermDeinflections(text, enabledDictionaryMap, options) + ); + + let maxLength = 0; + const definitions = []; + for (const {databaseDefinitions, source, rawSource, term, reasons} of deinflections) { + if (databaseDefinitions.length === 0) { continue; } + maxLength = Math.max(maxLength, rawSource.length); + for (const databaseDefinition of databaseDefinitions) { + const definition = await this._createTermDefinitionFromDatabaseDefinition(databaseDefinition, source, rawSource, term, reasons, enabledDictionaryMap); + definitions.push(definition); + } + } + + this._removeDuplicateDefinitions(definitions); + return [definitions, maxLength]; + } + + async _findTermWildcard(text, enabledDictionaryMap, wildcard) { + const databaseDefinitions = await this._database.findTermsBulk([text], enabledDictionaryMap, wildcard); + if (databaseDefinitions.length === 0) { + return []; + } + + return [{ + source: text, + rawSource: text, + term: text, + rules: 0, + reasons: [], + databaseDefinitions + }]; + } + + async _findTermDeinflections(text, enabledDictionaryMap, options) { + const deinflections = this._getAllDeinflections(text, options); + + if (deinflections.length === 0) { + return []; + } + + const uniqueDeinflectionTerms = []; + const uniqueDeinflectionArrays = []; + const uniqueDeinflectionsMap = new Map(); + for (const deinflection of deinflections) { + const term = deinflection.term; + let deinflectionArray = uniqueDeinflectionsMap.get(term); + if (typeof deinflectionArray === 'undefined') { + deinflectionArray = []; + uniqueDeinflectionTerms.push(term); + uniqueDeinflectionArrays.push(deinflectionArray); + uniqueDeinflectionsMap.set(term, deinflectionArray); + } + deinflectionArray.push(deinflection); + } + + const databaseDefinitions = await this._database.findTermsBulk(uniqueDeinflectionTerms, enabledDictionaryMap, null); + + for (const databaseDefinition of databaseDefinitions) { + const definitionRules = Deinflector.rulesToRuleFlags(databaseDefinition.rules); + for (const deinflection of uniqueDeinflectionArrays[databaseDefinition.index]) { + const deinflectionRules = deinflection.rules; + if (deinflectionRules === 0 || (definitionRules & deinflectionRules) !== 0) { + deinflection.databaseDefinitions.push(databaseDefinition); + } + } + } + + return deinflections; + } + + _getAllDeinflections(text, options) { + const textOptionVariantArray = [ + this._getTextReplacementsVariants(options), + this._getTextOptionEntryVariants(options.convertHalfWidthCharacters), + this._getTextOptionEntryVariants(options.convertNumericCharacters), + this._getTextOptionEntryVariants(options.convertAlphabeticCharacters), + this._getTextOptionEntryVariants(options.convertHiraganaToKatakana), + this._getTextOptionEntryVariants(options.convertKatakanaToHiragana), + this._getCollapseEmphaticOptions(options) + ]; + + const jp = this._japaneseUtil; + const deinflections = []; + const used = new Set(); + for (const [textReplacements, halfWidth, numeric, alphabetic, katakana, hiragana, [collapseEmphatic, collapseEmphaticFull]] of this._getArrayVariants(textOptionVariantArray)) { + let text2 = text; + const sourceMap = new TextSourceMap(text2); + if (textReplacements !== null) { + text2 = this._applyTextReplacements(text2, sourceMap, textReplacements); + } + if (halfWidth) { + text2 = jp.convertHalfWidthKanaToFullWidth(text2, sourceMap); + } + if (numeric) { + text2 = jp.convertNumericToFullWidth(text2); + } + if (alphabetic) { + text2 = jp.convertAlphabeticToKana(text2, sourceMap); + } + if (katakana) { + text2 = jp.convertHiraganaToKatakana(text2); + } + if (hiragana) { + text2 = jp.convertKatakanaToHiragana(text2); + } + if (collapseEmphatic) { + text2 = jp.collapseEmphaticSequences(text2, collapseEmphaticFull, sourceMap); + } + + for (let i = text2.length; i > 0; --i) { + const text2Substring = text2.substring(0, i); + if (used.has(text2Substring)) { break; } + used.add(text2Substring); + const rawSource = sourceMap.source.substring(0, sourceMap.getSourceLength(i)); + for (const deinflection of this._deinflector.deinflect(text2Substring, rawSource)) { + deinflections.push(deinflection); + } + } + } + return deinflections; + } + + async _getSequencedDefinitions(definitions, mainDictionary, enabledDictionaryMap) { + const sequenceList = []; + const sequencedDefinitionMap = new Map(); + const sequencedDefinitions = []; + const unsequencedDefinitions = []; + for (const definition of definitions) { + const {sequence, dictionary} = definition; + if (mainDictionary === dictionary && sequence >= 0) { + let sequencedDefinition = sequencedDefinitionMap.get(sequence); + if (typeof sequencedDefinition === 'undefined') { + sequencedDefinition = { + sourceDefinitions: [], + relatedDefinitions: [], + relatedDefinitionIds: new Set() + }; + sequencedDefinitionMap.set(sequence, sequencedDefinition); + sequencedDefinitions.push(sequencedDefinition); + sequenceList.push(sequence); + } + sequencedDefinition.sourceDefinitions.push(definition); + sequencedDefinition.relatedDefinitions.push(definition); + sequencedDefinition.relatedDefinitionIds.add(definition.id); + } else { + unsequencedDefinitions.push(definition); + } + } + + if (sequenceList.length > 0) { + const databaseDefinitions = await this._database.findTermsBySequenceBulk(sequenceList, mainDictionary); + for (const databaseDefinition of databaseDefinitions) { + const {relatedDefinitions, relatedDefinitionIds} = sequencedDefinitions[databaseDefinition.index]; + const {id} = databaseDefinition; + if (relatedDefinitionIds.has(id)) { continue; } + + const {source, rawSource, sourceTerm} = relatedDefinitions[0]; + const definition = await this._createTermDefinitionFromDatabaseDefinition(databaseDefinition, source, rawSource, sourceTerm, [], enabledDictionaryMap); + relatedDefinitions.push(definition); + } + } + + for (const {relatedDefinitions} of sequencedDefinitions) { + this._sortDefinitionsById(relatedDefinitions); + } + + return {sequencedDefinitions, unsequencedDefinitions}; + } + + async _getMergedSecondarySearchResults(expressionsMap, secondarySearchDictionaryMap) { + if (secondarySearchDictionaryMap.size === 0) { + return []; + } + + const expressionList = []; + const readingList = []; + for (const [expression, readingMap] of expressionsMap.entries()) { + for (const reading of readingMap.keys()) { + expressionList.push(expression); + readingList.push(reading); + } + } + + const databaseDefinitions = await this._database.findTermsExactBulk(expressionList, readingList, secondarySearchDictionaryMap); + this._sortDatabaseDefinitionsByIndex(databaseDefinitions); + + const definitions = []; + for (const databaseDefinition of databaseDefinitions) { + const source = expressionList[databaseDefinition.index]; + const definition = await this._createTermDefinitionFromDatabaseDefinition(databaseDefinition, source, source, source, [], secondarySearchDictionaryMap); + definitions.push(definition); + } + + return definitions; + } + + async _getMergedDefinition(sourceDefinitions, relatedDefinitions, unsequencedDefinitions, secondarySearchDictionaryMap, usedDefinitions) { + const {reasons, source, rawSource} = sourceDefinitions[0]; + const score = this._getMaxDefinitionScore(sourceDefinitions); + const termInfoMap = new Map(); + const glossaryDefinitions = []; + const glossaryDefinitionGroupMap = new Map(); + + this._mergeByGlossary(relatedDefinitions, glossaryDefinitionGroupMap); + this._addUniqueTermInfos(relatedDefinitions, termInfoMap); + + let secondaryDefinitions = await this._getMergedSecondarySearchResults(termInfoMap, secondarySearchDictionaryMap); + secondaryDefinitions = [...unsequencedDefinitions, ...secondaryDefinitions]; + + this._removeUsedDefinitions(secondaryDefinitions, termInfoMap, usedDefinitions); + this._removeDuplicateDefinitions(secondaryDefinitions); + + this._mergeByGlossary(secondaryDefinitions, glossaryDefinitionGroupMap); + + const allExpressions = new Set(); + const allReadings = new Set(); + for (const {expressions, readings} of glossaryDefinitionGroupMap.values()) { + for (const expression of expressions) { allExpressions.add(expression); } + for (const reading of readings) { allReadings.add(reading); } + } + + for (const {expressions, readings, definitions} of glossaryDefinitionGroupMap.values()) { + const glossaryDefinition = this._createMergedGlossaryTermDefinition( + source, + rawSource, + definitions, + expressions, + readings, + allExpressions, + allReadings + ); + glossaryDefinitions.push(glossaryDefinition); + } + + this._sortDefinitions(glossaryDefinitions, true); + + const termDetailsList = this._createTermDetailsListFromTermInfoMap(termInfoMap); + + return this._createMergedTermDefinition( + source, + rawSource, + glossaryDefinitions, + [...allExpressions], + [...allReadings], + termDetailsList, + reasons, + score + ); + } + + _removeUsedDefinitions(definitions, termInfoMap, usedDefinitions) { + for (let i = 0, ii = definitions.length; i < ii; ++i) { + const definition = definitions[i]; + const {expression, reading} = definition; + const expressionMap = termInfoMap.get(expression); + if ( + typeof expressionMap !== 'undefined' && + typeof expressionMap.get(reading) !== 'undefined' + ) { + usedDefinitions.add(definition); + } else { + definitions.splice(i, 1); + --i; + --ii; + } + } + } + + _getUniqueDefinitionTags(definitions) { + const definitionTagsMap = new Map(); + for (const {definitionTags} of definitions) { + for (const tag of definitionTags) { + const {name} = tag; + if (definitionTagsMap.has(name)) { continue; } + definitionTagsMap.set(name, this._cloneTag(tag)); + } + } + return [...definitionTagsMap.values()]; + } + + _removeDuplicateDefinitions(definitions) { + const definitionGroups = new Map(); + for (let i = 0, ii = definitions.length; i < ii; ++i) { + const definition = definitions[i]; + const {id} = definition; + const existing = definitionGroups.get(id); + if (typeof existing === 'undefined') { + definitionGroups.set(id, [i, definition]); + continue; + } + + let removeIndex = i; + if (definition.source.length > existing[1].source.length) { + definitionGroups.set(id, [i, definition]); + removeIndex = existing[0]; + } + + definitions.splice(removeIndex, 1); + --i; + --ii; + } + } + + _flagRedundantDefinitionTags(definitions) { + let lastDictionary = null; + let lastPartOfSpeech = ''; + const removeCategoriesSet = new Set(); + + for (const {dictionary, definitionTags} of definitions) { + const partOfSpeech = this._createMapKey(this._getTagNamesWithCategory(definitionTags, 'partOfSpeech')); + + if (lastDictionary !== dictionary) { + lastDictionary = dictionary; + lastPartOfSpeech = ''; + } + + if (lastPartOfSpeech === partOfSpeech) { + removeCategoriesSet.add('partOfSpeech'); + } else { + lastPartOfSpeech = partOfSpeech; + } + + if (removeCategoriesSet.size > 0) { + this._flagTagsWithCategoryAsRedundant(definitionTags, removeCategoriesSet); + removeCategoriesSet.clear(); + } + } + } + + _groupTerms(definitions) { + const groups = new Map(); + for (const definition of definitions) { + const key = this._createMapKey([definition.source, definition.expression, definition.reading, ...definition.reasons]); + let groupDefinitions = groups.get(key); + if (typeof groupDefinitions === 'undefined') { + groupDefinitions = []; + groups.set(key, groupDefinitions); + } + + groupDefinitions.push(definition); + } + + const results = []; + for (const groupDefinitions of groups.values()) { + this._sortDefinitions(groupDefinitions, true); + const definition = this._createGroupedTermDefinition(groupDefinitions); + results.push(definition); + } + + return results; + } + + _mergeByGlossary(definitions, glossaryDefinitionGroupMap) { + for (const definition of definitions) { + const {expression, reading, dictionary, glossary, id} = definition; + + const key = this._createMapKey([dictionary, ...glossary]); + let group = glossaryDefinitionGroupMap.get(key); + if (typeof group === 'undefined') { + group = { + expressions: new Set(), + readings: new Set(), + definitions: [], + definitionIds: new Set() + }; + glossaryDefinitionGroupMap.set(key, group); + } + + const {definitionIds} = group; + if (definitionIds.has(id)) { continue; } + definitionIds.add(id); + group.expressions.add(expression); + group.readings.add(reading); + group.definitions.push(definition); + } + } + + _addUniqueTermInfos(definitions, termInfoMap) { + for (const {expression, reading, sourceTerm, furiganaSegments, termTags} of definitions) { + let readingMap = termInfoMap.get(expression); + if (typeof readingMap === 'undefined') { + readingMap = new Map(); + termInfoMap.set(expression, readingMap); + } + + let termInfo = readingMap.get(reading); + if (typeof termInfo === 'undefined') { + termInfo = { + sourceTerm, + furiganaSegments, + termTagsMap: new Map() + }; + readingMap.set(reading, termInfo); + } + + const {termTagsMap} = termInfo; + for (const tag of termTags) { + const {name} = tag; + if (termTagsMap.has(name)) { continue; } + termTagsMap.set(name, this._cloneTag(tag)); + } + } + } + + _convertTermDefinitionsToMergedGlossaryTermDefinitions(definitions) { + const convertedDefinitions = []; + for (const definition of definitions) { + const {source, rawSource, expression, reading} = definition; + const expressions = new Set([expression]); + const readings = new Set([reading]); + const convertedDefinition = this._createMergedGlossaryTermDefinition(source, rawSource, [definition], expressions, readings, expressions, readings); + convertedDefinitions.push(convertedDefinition); + } + return convertedDefinitions; + } + + // Metadata building + + async _buildTermMeta(definitions, enabledDictionaryMap) { + const addMetadataTargetInfo = (targetMap1, target, parents) => { + let {expression, reading} = target; + if (!reading) { reading = expression; } + + let targetMap2 = targetMap1.get(expression); + if (typeof targetMap2 === 'undefined') { + targetMap2 = new Map(); + targetMap1.set(expression, targetMap2); + } + + let targets = targetMap2.get(reading); + if (typeof targets === 'undefined') { + targets = new Set([target, ...parents]); + targetMap2.set(reading, targets); + } else { + targets.add(target); + for (const parent of parents) { + targets.add(parent); + } + } + }; + + const targetMap = new Map(); + const definitionsQueue = definitions.map((definition) => ({definition, parents: []})); + while (definitionsQueue.length > 0) { + const {definition, parents} = definitionsQueue.shift(); + const childDefinitions = definition.definitions; + if (Array.isArray(childDefinitions)) { + for (const definition2 of childDefinitions) { + definitionsQueue.push({definition: definition2, parents: [...parents, definition]}); + } + } else { + addMetadataTargetInfo(targetMap, definition, parents); + } + + for (const target of definition.expressions) { + addMetadataTargetInfo(targetMap, target, []); + } + } + const targetMapEntries = [...targetMap.entries()]; + const uniqueExpressions = targetMapEntries.map(([expression]) => expression); + + const metas = await this._database.findTermMetaBulk(uniqueExpressions, enabledDictionaryMap); + for (const {expression, mode, data, dictionary, index} of metas) { + const targetMap2 = targetMapEntries[index][1]; + for (const [reading, targets] of targetMap2) { + switch (mode) { + case 'freq': + { + const frequencyData = this._getTermFrequencyData(expression, reading, dictionary, data); + if (frequencyData === null) { continue; } + for (const {frequencies} of targets) { frequencies.push(frequencyData); } + } + break; + case 'pitch': + { + const pitchData = await this._getPitchData(expression, reading, dictionary, data); + if (pitchData === null) { continue; } + for (const {pitches} of targets) { pitches.push(pitchData); } + } + break; + } + } + } + } + + async _buildKanjiMeta(definitions, enabledDictionaryMap) { + const kanjiList = []; + for (const {character} of definitions) { + kanjiList.push(character); + } + + const metas = await this._database.findKanjiMetaBulk(kanjiList, enabledDictionaryMap); + for (const {character, mode, data, dictionary, index} of metas) { + switch (mode) { + case 'freq': + { + const frequencyData = this._getKanjiFrequencyData(character, dictionary, data); + definitions[index].frequencies.push(frequencyData); + } + break; + } + } + } + + async _expandTags(names, dictionary) { + const tagMetaList = await this._getTagMetaList(names, dictionary); + const results = []; + for (let i = 0, ii = tagMetaList.length; i < ii; ++i) { + const meta = tagMetaList[i]; + const name = names[i]; + const {category, notes, order, score} = (meta !== null ? meta : {}); + const tag = this._createTag(name, category, notes, order, score, dictionary, false); + results.push(tag); + } + return results; + } + + async _expandStats(items, dictionary) { + const names = Object.keys(items); + const tagMetaList = await this._getTagMetaList(names, dictionary); + + const statsGroups = new Map(); + for (let i = 0; i < names.length; ++i) { + const name = names[i]; + const meta = tagMetaList[i]; + if (meta === null) { continue; } + + const {category, notes, order, score} = meta; + let group = statsGroups.get(category); + if (typeof group === 'undefined') { + group = []; + statsGroups.set(category, group); + } + + const value = items[name]; + const stat = this._createKanjiStat(name, category, notes, order, score, dictionary, value); + group.push(stat); + } + + const stats = {}; + for (const [category, group] of statsGroups.entries()) { + this._sortKanjiStats(group); + stats[category] = group; + } + return stats; + } + + async _getTagMetaList(names, dictionary) { + const tagMetaList = []; + let cache = this._tagCache.get(dictionary); + if (typeof cache === 'undefined') { + cache = new Map(); + this._tagCache.set(dictionary, cache); + } + + for (const name of names) { + const base = this._getNameBase(name); + + let tagMeta = cache.get(base); + if (typeof tagMeta === 'undefined') { + tagMeta = await this._database.findTagForTitle(base, dictionary); + cache.set(base, tagMeta); + } + + tagMetaList.push(tagMeta); + } + + return tagMetaList; + } + + _getTermFrequencyData(expression, reading, dictionary, data) { + let frequency = data; + const hasReading = (data !== null && typeof data === 'object'); + if (hasReading) { + if (data.reading !== reading) { return null; } + frequency = data.frequency; + } + return {dictionary, expression, reading, hasReading, frequency}; + } + + _getKanjiFrequencyData(character, dictionary, data) { + return {dictionary, character, frequency: data}; + } + + async _getPitchData(expression, reading, dictionary, data) { + if (data.reading !== reading) { return null; } + + const pitches = []; + for (let {position, tags} of data.pitches) { + tags = Array.isArray(tags) ? await this._expandTags(tags, dictionary) : []; + pitches.push({position, tags}); + } + + return {expression, reading, dictionary, pitches}; + } + + // Simple helpers + + _scoreToTermFrequency(score) { + if (score > 0) { + return 'popular'; + } else if (score < 0) { + return 'rare'; + } else { + return 'normal'; + } + } + + _getNameBase(name) { + const pos = name.indexOf(':'); + return (pos >= 0 ? name.substring(0, pos) : name); + } + + _getSearchableText(text, allowAlphanumericCharacters) { + if (allowAlphanumericCharacters) { + return text; + } + + const jp = this._japaneseUtil; + let newText = ''; + for (const c of text) { + if (!jp.isCodePointJapanese(c.codePointAt(0))) { + break; + } + newText += c; + } + return newText; + } + + _getTextOptionEntryVariants(value) { + switch (value) { + case 'true': return [true]; + case 'variant': return [false, true]; + default: return [false]; + } + } + + _getCollapseEmphaticOptions(options) { + const collapseEmphaticOptions = [[false, false]]; + switch (options.collapseEmphaticSequences) { + case 'true': + collapseEmphaticOptions.push([true, false]); + break; + case 'full': + collapseEmphaticOptions.push([true, false], [true, true]); + break; + } + return collapseEmphaticOptions; + } + + _getTextReplacementsVariants(options) { + return options.textReplacements; + } + + _getSecondarySearchDictionaryMap(enabledDictionaryMap) { + const secondarySearchDictionaryMap = new Map(); + for (const [dictionary, details] of enabledDictionaryMap.entries()) { + if (!details.allowSecondarySearches) { continue; } + secondarySearchDictionaryMap.set(dictionary, details); + } + return secondarySearchDictionaryMap; + } + + _getDictionaryPriority(dictionary, enabledDictionaryMap) { + const info = enabledDictionaryMap.get(dictionary); + return typeof info !== 'undefined' ? info.priority : 0; + } + + _getTagNamesWithCategory(tags, category) { + const results = []; + for (const tag of tags) { + if (tag.category !== category) { continue; } + results.push(tag.name); + } + results.sort(); + return results; + } + + _flagTagsWithCategoryAsRedundant(tags, removeCategoriesSet) { + for (const tag of tags) { + if (removeCategoriesSet.has(tag.category)) { + tag.redundant = true; + } + } + } + + _getUniqueDictionaryNames(definitions) { + const uniqueDictionaryNames = new Set(); + for (const {dictionaryNames} of definitions) { + for (const dictionaryName of dictionaryNames) { + uniqueDictionaryNames.add(dictionaryName); + } + } + return [...uniqueDictionaryNames]; + } + + _getUniqueTermTags(definitions) { + const newTermTags = []; + if (definitions.length <= 1) { + for (const {termTags} of definitions) { + for (const tag of termTags) { + newTermTags.push(this._cloneTag(tag)); + } + } + } else { + const tagsSet = new Set(); + let checkTagsMap = false; + for (const {termTags} of definitions) { + for (const tag of termTags) { + const key = this._getTagMapKey(tag); + if (checkTagsMap && tagsSet.has(key)) { continue; } + tagsSet.add(key); + newTermTags.push(this._cloneTag(tag)); + } + checkTagsMap = true; + } + } + return newTermTags; + } + + *_getArrayVariants(arrayVariants) { + const ii = arrayVariants.length; + + let total = 1; + for (let i = 0; i < ii; ++i) { + total *= arrayVariants[i].length; + } + + for (let a = 0; a < total; ++a) { + const variant = []; + let index = a; + for (let i = 0; i < ii; ++i) { + const entryVariants = arrayVariants[i]; + variant.push(entryVariants[index % entryVariants.length]); + index = Math.floor(index / entryVariants.length); + } + yield variant; + } + } + + _areSetsEqual(set1, set2) { + if (set1.size !== set2.size) { + return false; + } + + for (const value of set1) { + if (!set2.has(value)) { + return false; + } + } + + return true; + } + + _getSetIntersection(set1, set2) { + const result = []; + for (const value of set1) { + if (set2.has(value)) { + result.push(value); + } + } + return result; + } + + // Reduction functions + + _getTermTagsScoreSum(termTags) { + let result = 0; + for (const {score} of termTags) { + result += score; + } + return result; + } + + _getSourceTermMatchCountSum(definitions) { + let result = 0; + for (const {sourceTermExactMatchCount} of definitions) { + result += sourceTermExactMatchCount; + } + return result; + } + + _getMaxDefinitionScore(definitions) { + let result = Number.MIN_SAFE_INTEGER; + for (const {score} of definitions) { + if (score > result) { result = score; } + } + return result; + } + + _getMaxDictionaryPriority(definitions) { + let result = Number.MIN_SAFE_INTEGER; + for (const {dictionaryPriority} of definitions) { + if (dictionaryPriority > result) { result = dictionaryPriority; } + } + return result; + } + + // Common data creation and cloning functions + + _cloneTag(tag) { + const {name, category, notes, order, score, dictionary, redundant} = tag; + return this._createTag(name, category, notes, order, score, dictionary, redundant); + } + + _getTagMapKey(tag) { + const {name, category, notes} = tag; + return this._createMapKey([name, category, notes]); + } + + _createMapKey(array) { + return JSON.stringify(array); + } + + _createTag(name, category, notes, order, score, dictionary, redundant) { + return { + name, + category: (typeof category === 'string' && category.length > 0 ? category : 'default'), + notes: (typeof notes === 'string' ? notes : ''), + order: (typeof order === 'number' ? order : 0), + score: (typeof score === 'number' ? score : 0), + dictionary: (typeof dictionary === 'string' ? dictionary : null), + redundant + }; + } + + _createKanjiStat(name, category, notes, order, score, dictionary, value) { + return { + name, + category: (typeof category === 'string' && category.length > 0 ? category : 'default'), + notes: (typeof notes === 'string' ? notes : ''), + order: (typeof order === 'number' ? order : 0), + score: (typeof score === 'number' ? score : 0), + dictionary: (typeof dictionary === 'string' ? dictionary : null), + value + }; + } + + _createKanjiDefinition(character, dictionary, onyomi, kunyomi, glossary, tags, stats) { + return { + type: 'kanji', + character, + dictionary, + onyomi, + kunyomi, + glossary, + tags, + stats, + frequencies: [] + }; + } + + async _createTermDefinitionFromDatabaseDefinition(databaseDefinition, source, rawSource, sourceTerm, reasons, enabledDictionaryMap) { + const {expression, reading, definitionTags, termTags, glossary, score, dictionary, id, sequence} = databaseDefinition; + const dictionaryPriority = this._getDictionaryPriority(dictionary, enabledDictionaryMap); + const termTagsExpanded = await this._expandTags(termTags, dictionary); + const definitionTagsExpanded = await this._expandTags(definitionTags, dictionary); + + this._sortTags(definitionTagsExpanded); + this._sortTags(termTagsExpanded); + + const furiganaSegments = this._japaneseUtil.distributeFurigana(expression, reading); + const termDetailsList = [this._createTermDetails(sourceTerm, expression, reading, furiganaSegments, termTagsExpanded)]; + const sourceTermExactMatchCount = (sourceTerm === expression ? 1 : 0); + + return { + type: 'term', + id, + source, + rawSource, + sourceTerm, + reasons, + score, + sequence, + dictionary, + dictionaryPriority, + dictionaryNames: [dictionary], + expression, + reading, + expressions: termDetailsList, + furiganaSegments, + glossary, + definitionTags: definitionTagsExpanded, + termTags: termTagsExpanded, + // definitions + frequencies: [], + pitches: [], + // only + sourceTermExactMatchCount + }; + } + + _createGroupedTermDefinition(definitions) { + const {expression, reading, furiganaSegments, reasons, source, rawSource, sourceTerm} = definitions[0]; + const score = this._getMaxDefinitionScore(definitions); + const dictionaryPriority = this._getMaxDictionaryPriority(definitions); + const dictionaryNames = this._getUniqueDictionaryNames(definitions); + const termTags = this._getUniqueTermTags(definitions); + const termDetailsList = [this._createTermDetails(sourceTerm, expression, reading, furiganaSegments, termTags)]; + const sourceTermExactMatchCount = (sourceTerm === expression ? 1 : 0); + return { + type: 'termGrouped', + // id + source, + rawSource, + sourceTerm, + reasons: [...reasons], + score, + // sequence + dictionary: dictionaryNames[0], + dictionaryPriority, + dictionaryNames, + expression, + reading, + expressions: termDetailsList, + furiganaSegments, // Contains duplicate data + // glossary + // definitionTags + termTags, + definitions, // type: 'term' + frequencies: [], + pitches: [], + // only + sourceTermExactMatchCount + }; + } + + _createMergedTermDefinition(source, rawSource, definitions, expressions, readings, termDetailsList, reasons, score) { + const dictionaryPriority = this._getMaxDictionaryPriority(definitions); + const sourceTermExactMatchCount = this._getSourceTermMatchCountSum(definitions); + const dictionaryNames = this._getUniqueDictionaryNames(definitions); + return { + type: 'termMerged', + // id + source, + rawSource, + // sourceTerm + reasons, + score, + // sequence + dictionary: dictionaryNames[0], + dictionaryPriority, + dictionaryNames, + expression: expressions, + reading: readings, + expressions: termDetailsList, + // furiganaSegments + // glossary + // definitionTags + // termTags + definitions, // type: 'termMergedByGlossary' + frequencies: [], + pitches: [], + // only + sourceTermExactMatchCount + }; + } + + _createMergedGlossaryTermDefinition(source, rawSource, definitions, expressions, readings, allExpressions, allReadings) { + const only = []; + if (!this._areSetsEqual(expressions, allExpressions)) { + only.push(...this._getSetIntersection(expressions, allExpressions)); + } + if (!this._areSetsEqual(readings, allReadings)) { + only.push(...this._getSetIntersection(readings, allReadings)); + } + + const sourceTermExactMatchCount = this._getSourceTermMatchCountSum(definitions); + const dictionaryNames = this._getUniqueDictionaryNames(definitions); + + const termInfoMap = new Map(); + this._addUniqueTermInfos(definitions, termInfoMap); + const termDetailsList = this._createTermDetailsListFromTermInfoMap(termInfoMap); + + const definitionTags = this._getUniqueDefinitionTags(definitions); + this._sortTags(definitionTags); + + const {glossary} = definitions[0]; + const score = this._getMaxDefinitionScore(definitions); + const dictionaryPriority = this._getMaxDictionaryPriority(definitions); + return { + type: 'termMergedByGlossary', + // id + source, + rawSource, + // sourceTerm + reasons: [], + score, + // sequence + dictionary: dictionaryNames[0], + dictionaryPriority, + dictionaryNames, + expression: [...expressions], + reading: [...readings], + expressions: termDetailsList, + // furiganaSegments + glossary: [...glossary], + definitionTags, + // termTags + definitions, // type: 'term'; contains duplicate data + frequencies: [], + pitches: [], + only, + sourceTermExactMatchCount + }; + } + + _createTermDetailsListFromTermInfoMap(termInfoMap) { + const termDetailsList = []; + for (const [expression, readingMap] of termInfoMap.entries()) { + for (const [reading, {termTagsMap, sourceTerm, furiganaSegments}] of readingMap.entries()) { + const termTags = [...termTagsMap.values()]; + this._sortTags(termTags); + termDetailsList.push(this._createTermDetails(sourceTerm, expression, reading, furiganaSegments, termTags)); + } + } + return termDetailsList; + } + + _createTermDetails(sourceTerm, expression, reading, furiganaSegments, termTags) { + const termFrequency = this._scoreToTermFrequency(this._getTermTagsScoreSum(termTags)); + return { + sourceTerm, + expression, + reading, + furiganaSegments, // Contains duplicate data + termTags, + termFrequency, + frequencies: [], + pitches: [] + }; + } + + // Sorting functions + + _sortTags(tags) { + if (tags.length <= 1) { return; } + const stringComparer = this._stringComparer; + tags.sort((v1, v2) => { + const i = v1.order - v2.order; + if (i !== 0) { return i; } + + return stringComparer.compare(v1.name, v2.name); + }); + } + + _sortDefinitions(definitions, useDictionaryPriority) { + if (definitions.length <= 1) { return; } + const stringComparer = this._stringComparer; + const compareFunction1 = (v1, v2) => { + let i = v2.source.length - v1.source.length; + if (i !== 0) { return i; } + + i = v1.reasons.length - v2.reasons.length; + if (i !== 0) { return i; } + + i = v2.sourceTermExactMatchCount - v1.sourceTermExactMatchCount; + if (i !== 0) { return i; } + + i = v2.score - v1.score; + if (i !== 0) { return i; } + + const expression1 = v1.expression; + const expression2 = v2.expression; + if (typeof expression1 !== 'string' || typeof expression2 !== 'string') { return 0; } // Skip if either is not a string (array) + + i = expression2.length - expression1.length; + if (i !== 0) { return i; } + + return stringComparer.compare(expression1, expression2); + }; + const compareFunction2 = (v1, v2) => { + const i = v2.dictionaryPriority - v1.dictionaryPriority; + return (i !== 0) ? i : compareFunction1(v1, v2); + }; + definitions.sort(useDictionaryPriority ? compareFunction2 : compareFunction1); + } + + _sortDatabaseDefinitionsByIndex(definitions) { + if (definitions.length <= 1) { return; } + definitions.sort((a, b) => a.index - b.index); + } + + _sortDefinitionsById(definitions) { + if (definitions.length <= 1) { return; } + definitions.sort((a, b) => a.id - b.id); + } + + _sortKanjiStats(stats) { + if (stats.length <= 1) { return; } + const stringComparer = this._stringComparer; + stats.sort((v1, v2) => { + const i = v1.order - v2.order; + if (i !== 0) { return i; } + + return stringComparer.compare(v1.notes, v2.notes); + }); + } + + // Regex functions + + _applyTextReplacements(text, sourceMap, replacements) { + for (const {pattern, replacement} of replacements) { + text = this._applyTextReplacement(text, sourceMap, pattern, replacement); + } + return text; + } + + _applyTextReplacement(text, sourceMap, pattern, replacement) { + const isGlobal = pattern.global; + if (isGlobal) { pattern.lastIndex = 0; } + for (let loop = true; loop; loop = isGlobal) { + const match = pattern.exec(text); + if (match === null) { break; } + + const matchText = match[0]; + const index = match.index; + const actualReplacement = this._applyMatchReplacement(replacement, match); + const actualReplacementLength = actualReplacement.length; + const delta = actualReplacementLength - (matchText.length > 0 ? matchText.length : -1); + + text = `${text.substring(0, index)}${actualReplacement}${text.substring(index + matchText.length)}`; + pattern.lastIndex += delta; + + if (actualReplacementLength > 0) { + sourceMap.combine(Math.max(0, index - 1), matchText.length); + sourceMap.insert(index, ...(new Array(actualReplacementLength).fill(0))); + } else { + sourceMap.combine(index, matchText.length); + } + } + return text; + } + + _applyMatchReplacement(replacement, match) { + const pattern = /\$(?:\$|&|`|'|(\d\d?)|<([^>]*)>)/g; + return replacement.replace(pattern, (g0, g1, g2) => { + if (typeof g1 !== 'undefined') { + const matchIndex = Number.parseInt(g1, 10); + if (matchIndex >= 1 && matchIndex <= match.length) { + return match[matchIndex]; + } + } else if (typeof g2 !== 'undefined') { + const {groups} = match; + if (typeof groups === 'object' && groups !== null && Object.prototype.hasOwnProperty.call(groups, g2)) { + return groups[g2]; + } + } else { + switch (g0) { + case '$': return '$'; + case '&': return match[0]; + case '`': return replacement.substring(0, match.index); + case '\'': return replacement.substring(match.index + g0.length); + } + } + return g0; + }); + } +} diff --git a/ext/js/media/audio-downloader.js b/ext/js/media/audio-downloader.js new file mode 100644 index 00000000..4e77419b --- /dev/null +++ b/ext/js/media/audio-downloader.js @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2017-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 + * JsonSchemaValidator + * NativeSimpleDOMParser + * SimpleDOMParser + */ + +class AudioDownloader { + constructor({japaneseUtil, requestBuilder}) { + this._japaneseUtil = japaneseUtil; + this._requestBuilder = requestBuilder; + this._customAudioListSchema = null; + this._schemaValidator = null; + this._getInfoHandlers = new Map([ + ['jpod101', this._getInfoJpod101.bind(this)], + ['jpod101-alternate', this._getInfoJpod101Alternate.bind(this)], + ['jisho', this._getInfoJisho.bind(this)], + ['text-to-speech', this._getInfoTextToSpeech.bind(this)], + ['text-to-speech-reading', this._getInfoTextToSpeechReading.bind(this)], + ['custom', this._getInfoCustom.bind(this)] + ]); + } + + async getExpressionAudioInfoList(source, expression, reading, details) { + const handler = this._getInfoHandlers.get(source); + if (typeof handler === 'function') { + try { + return await handler(expression, reading, details); + } catch (e) { + // NOP + } + } + return []; + } + + async downloadExpressionAudio(sources, expression, reading, details) { + for (const source of sources) { + const infoList = await this.getExpressionAudioInfoList(source, expression, reading, details); + for (const info of infoList) { + switch (info.type) { + case 'url': + try { + return await this._downloadAudioFromUrl(info.url, source); + } catch (e) { + // NOP + } + break; + } + } + } + + throw new Error('Could not download audio'); + } + + // Private + + _normalizeUrl(url, base) { + return new URL(url, base).href; + } + + async _getInfoJpod101(expression, reading) { + let kana = reading; + let kanji = expression; + + if (!kana && this._japaneseUtil.isStringEntirelyKana(kanji)) { + kana = kanji; + kanji = null; + } + + const params = []; + if (kanji) { + params.push(`kanji=${encodeURIComponent(kanji)}`); + } + if (kana) { + params.push(`kana=${encodeURIComponent(kana)}`); + } + + const url = `https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?${params.join('&')}`; + return [{type: 'url', url}]; + } + + async _getInfoJpod101Alternate(expression, reading) { + const fetchUrl = 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'; + const data = `post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(expression)}&vulgar=true`; + const response = await this._requestBuilder.fetchAnonymous(fetchUrl, { + method: 'POST', + mode: 'cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: data + }); + const responseText = await response.text(); + + const dom = this._createSimpleDOMParser(responseText); + for (const row of dom.getElementsByClassName('dc-result-row')) { + try { + const audio = dom.getElementByTagName('audio', row); + if (audio === null) { continue; } + + const source = dom.getElementByTagName('source', audio); + if (source === null) { continue; } + + let url = dom.getAttribute(source, 'src'); + if (url === null) { continue; } + + const htmlReadings = dom.getElementsByClassName('dc-vocab_kana'); + if (htmlReadings.length === 0) { continue; } + + const htmlReading = dom.getTextContent(htmlReadings[0]); + if (htmlReading && (!reading || reading === htmlReading)) { + url = this._normalizeUrl(url, response.url); + return [{type: 'url', url}]; + } + } catch (e) { + // NOP + } + } + + throw new Error('Failed to find audio URL'); + } + + async _getInfoJisho(expression, reading) { + const fetchUrl = `https://jisho.org/search/${expression}`; + const response = await this._requestBuilder.fetchAnonymous(fetchUrl, { + method: 'GET', + mode: 'cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' + }); + const responseText = await response.text(); + + const dom = this._createSimpleDOMParser(responseText); + try { + const audio = dom.getElementById(`audio_${expression}:${reading}`); + if (audio !== null) { + const source = dom.getElementByTagName('source', audio); + if (source !== null) { + let url = dom.getAttribute(source, 'src'); + if (url !== null) { + url = this._normalizeUrl(url, response.url); + return [{type: 'url', url}]; + } + } + } + } catch (e) { + // NOP + } + + throw new Error('Failed to find audio URL'); + } + + async _getInfoTextToSpeech(expression, reading, {textToSpeechVoice}) { + if (!textToSpeechVoice) { + throw new Error('No voice'); + } + return [{type: 'tts', text: expression, voice: textToSpeechVoice}]; + } + + async _getInfoTextToSpeechReading(expression, reading, {textToSpeechVoice}) { + if (!textToSpeechVoice) { + throw new Error('No voice'); + } + return [{type: 'tts', text: reading || expression, voice: textToSpeechVoice}]; + } + + async _getInfoCustom(expression, reading, {customSourceUrl, customSourceType}) { + if (typeof customSourceUrl !== 'string') { + throw new Error('No custom URL defined'); + } + const data = {expression, reading}; + const url = customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (Object.prototype.hasOwnProperty.call(data, m1) ? `${data[m1]}` : m0)); + + switch (customSourceType) { + case 'json': + return await this._getInfoCustomJson(url); + default: + return [{type: 'url', url}]; + } + } + + async _getInfoCustomJson(url) { + const response = await this._requestBuilder.fetchAnonymous(url, { + method: 'GET', + mode: 'cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' + }); + + if (!response.ok) { + throw new Error(`Invalid response: ${response.status}`); + } + + const responseJson = await response.json(); + + const schema = await this._getCustomAudioListSchema(); + if (this._schemaValidator === null) { + this._schemaValidator = new JsonSchemaValidator(); + } + this._schemaValidator.validate(responseJson, schema); + + const results = []; + for (const {url: url2, name} of responseJson.audioSources) { + const info = {type: 'url', url: url2}; + if (typeof name === 'string') { info.name = name; } + results.push(info); + } + return results; + } + + async _downloadAudioFromUrl(url, source) { + const response = await this._requestBuilder.fetchAnonymous(url, { + method: 'GET', + mode: 'cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' + }); + + if (!response.ok) { + throw new Error(`Invalid response: ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + + if (!await this._isAudioBinaryValid(arrayBuffer, source)) { + throw new Error('Could not retrieve audio'); + } + + const data = this._arrayBufferToBase64(arrayBuffer); + const contentType = response.headers.get('Content-Type'); + return {data, contentType}; + } + + async _isAudioBinaryValid(arrayBuffer, source) { + switch (source) { + case 'jpod101': + { + const digest = await this._arrayBufferDigest(arrayBuffer); + switch (digest) { + case 'ae6398b5a27bc8c0a771df6c907ade794be15518174773c58c7c7ddd17098906': // Invalid audio + return false; + default: + return true; + } + } + default: + return true; + } + } + + async _arrayBufferDigest(arrayBuffer) { + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(arrayBuffer))); + let digest = ''; + for (const byte of hash) { + digest += byte.toString(16).padStart(2, '0'); + } + return digest; + } + + _arrayBufferToBase64(arrayBuffer) { + return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); + } + + _createSimpleDOMParser(content) { + if (typeof NativeSimpleDOMParser !== 'undefined' && NativeSimpleDOMParser.isSupported()) { + return new NativeSimpleDOMParser(content); + } else if (typeof SimpleDOMParser !== 'undefined' && SimpleDOMParser.isSupported()) { + return new SimpleDOMParser(content); + } else { + throw new Error('DOM parsing not supported'); + } + } + + async _getCustomAudioListSchema() { + let schema = this._customAudioListSchema; + if (schema === null) { + const url = chrome.runtime.getURL('/data/schemas/custom-audio-list-schema.json'); + const response = await fetch(url, { + method: 'GET', + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' + }); + schema = await response.json(); + this._customAudioListSchema = schema; + } + return schema; + } +} diff --git a/ext/js/media/media-utility.js b/ext/js/media/media-utility.js new file mode 100644 index 00000000..b4fbe04d --- /dev/null +++ b/ext/js/media/media-utility.js @@ -0,0 +1,132 @@ +/* + * 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/>. + */ + +/** + * MediaUtility is a class containing helper methods related to media processing. + */ +class MediaUtility { + /** + * Gets the file extension of a file path. URL search queries and hash + * fragments are not handled. + * @param path The path to the file. + * @returns The file extension, including the '.', or an empty string + * if there is no file extension. + */ + getFileNameExtension(path) { + const match = /\.[^./\\]*$/.exec(path); + return match !== null ? match[0] : ''; + } + + /** + * Gets an image file's media type using a file path. + * @param path The path to the file. + * @returns The media type string if it can be determined from the file path, + * otherwise null. + */ + getImageMediaTypeFromFileName(path) { + switch (this.getFileNameExtension(path).toLowerCase()) { + case '.apng': + return 'image/apng'; + case '.bmp': + return 'image/bmp'; + case '.gif': + return 'image/gif'; + case '.ico': + case '.cur': + return 'image/x-icon'; + case '.jpg': + case '.jpeg': + case '.jfif': + case '.pjpeg': + case '.pjp': + return 'image/jpeg'; + case '.png': + return 'image/png'; + case '.svg': + return 'image/svg+xml'; + case '.tif': + case '.tiff': + return 'image/tiff'; + case '.webp': + return 'image/webp'; + default: + return null; + } + } + + /** + * Gets the file extension for a corresponding media type. + * @param mediaType The media type to use. + * @returns A file extension including the dot for the media type, + * otherwise null. + */ + getFileExtensionFromImageMediaType(mediaType) { + switch (mediaType) { + case 'image/apng': + return '.apng'; + case 'image/bmp': + return '.bmp'; + case 'image/gif': + return '.gif'; + case 'image/x-icon': + return '.ico'; + case 'image/jpeg': + return '.jpeg'; + case 'image/png': + return '.png'; + case 'image/svg+xml': + return '.svg'; + case 'image/tiff': + return '.tiff'; + case 'image/webp': + return '.webp'; + default: + return null; + } + } + + /** + * Gets the file extension for a corresponding media type. + * @param mediaType The media type to use. + * @returns A file extension including the dot for the media type, + * otherwise null. + */ + getFileExtensionFromAudioMediaType(mediaType) { + switch (mediaType) { + case 'audio/mpeg': + case 'audio/mp3': + return '.mp3'; + case 'audio/mp4': + return '.mp4'; + case 'audio/ogg': + case 'audio/vorbis': + return '.ogg'; + case 'audio/vnd.wav': + case 'audio/wave': + case 'audio/wav': + case 'audio/x-wav': + case 'audio/x-pn-wav': + return '.wav'; + case 'audio/flac': + return '.flac'; + case 'audio/webm': + return '.webm'; + default: + return null; + } + } +} diff --git a/ext/js/pages/action-popup-main.js b/ext/js/pages/action-popup-main.js new file mode 100644 index 00000000..5cc56745 --- /dev/null +++ b/ext/js/pages/action-popup-main.js @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2017-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 + * HotkeyHelpController + * PermissionsUtil + * api + */ + +class DisplayController { + constructor() { + this._optionsFull = null; + this._permissionsUtil = new PermissionsUtil(); + } + + async prepare() { + const manifest = chrome.runtime.getManifest(); + + this._showExtensionInfo(manifest); + this._setupEnvironment(); + this._setupButtonEvents('.action-open-search', 'openSearchPage', chrome.runtime.getURL('/search.html')); + this._setupButtonEvents('.action-open-info', 'openInfoPage', chrome.runtime.getURL('/info.html')); + + const optionsFull = await api.optionsGetFull(); + this._optionsFull = optionsFull; + + this._setupHotkeys(); + + const optionsPageUrl = optionsFull.global.useSettingsV2 ? manifest.options_ui.page : '/settings-old.html'; + this._setupButtonEvents('.action-open-settings', 'openSettingsPage', chrome.runtime.getURL(optionsPageUrl)); + this._setupButtonEvents('.action-open-permissions', null, chrome.runtime.getURL('/permissions.html')); + + const {profiles, profileCurrent} = optionsFull; + const primaryProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null; + if (primaryProfile !== null) { + this._setupOptions(primaryProfile); + } + + document.querySelector('.action-select-profile').hidden = (profiles.length <= 1); + + this._updateProfileSelect(profiles, profileCurrent); + + setTimeout(() => { + document.body.dataset.loaded = 'true'; + }, 10); + } + + // Private + + _showExtensionInfo(manifest) { + const node = document.getElementById('extension-info'); + if (node === null) { return; } + + node.textContent = `${manifest.name} v${manifest.version}`; + } + + _setupButtonEvents(selector, command, url) { + const nodes = document.querySelectorAll(selector); + for (const node of nodes) { + if (typeof command === 'string') { + node.addEventListener('click', (e) => { + if (e.button !== 0) { return; } + api.commandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'}); + e.preventDefault(); + }, false); + node.addEventListener('auxclick', (e) => { + if (e.button !== 1) { return; } + api.commandExec(command, {mode: 'newTab'}); + e.preventDefault(); + }, false); + } + + if (typeof url === 'string') { + node.href = url; + node.target = '_blank'; + node.rel = 'noopener'; + } + } + } + + async _setupEnvironment() { + const urlSearchParams = new URLSearchParams(location.search); + let mode = urlSearchParams.get('mode'); + switch (mode) { + case 'full': + case 'mini': + break; + default: + { + let tab; + try { + tab = await this._getCurrentTab(); + } catch (e) { + // NOP + } + mode = (tab ? 'full' : 'mini'); + } + break; + } + + document.documentElement.dataset.mode = mode; + } + + _getCurrentTab() { + return new Promise((resolve, reject) => { + chrome.tabs.getCurrent((result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + }); + }); + } + + _setupOptions({options}) { + const extensionEnabled = options.general.enable; + const onToggleChanged = () => api.commandExec('toggleTextScanning'); + for (const toggle of document.querySelectorAll('#enable-search,#enable-search2')) { + toggle.checked = extensionEnabled; + toggle.addEventListener('change', onToggleChanged, false); + } + this._updateDictionariesEnabledWarnings(options); + this._updatePermissionsWarnings(options); + } + + async _setupHotkeys() { + const hotkeyHelpController = new HotkeyHelpController(); + await hotkeyHelpController.prepare(); + + const {profiles, profileCurrent} = this._optionsFull; + const primaryProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null; + if (primaryProfile !== null) { + hotkeyHelpController.setOptions(primaryProfile.options); + } + + hotkeyHelpController.setupNode(document.documentElement); + } + + _updateProfileSelect(profiles, profileCurrent) { + const select = document.querySelector('#profile-select'); + const optionGroup = document.querySelector('#profile-select-option-group'); + const fragment = document.createDocumentFragment(); + for (let i = 0, ii = profiles.length; i < ii; ++i) { + const {name} = profiles[i]; + const option = document.createElement('option'); + option.textContent = name; + option.value = `${i}`; + fragment.appendChild(option); + } + optionGroup.textContent = ''; + optionGroup.appendChild(fragment); + select.value = `${profileCurrent}`; + + select.addEventListener('change', this._onProfileSelectChange.bind(this), false); + } + + _onProfileSelectChange(e) { + const value = parseInt(e.currentTarget.value, 10); + if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= this._optionsFull.profiles.length) { + this._setPrimaryProfileIndex(value); + } + } + + async _setPrimaryProfileIndex(value) { + return await api.modifySettings( + [{ + action: 'set', + path: 'profileCurrent', + value, + scope: 'global' + }] + ); + } + + async _updateDictionariesEnabledWarnings(options) { + const noDictionariesEnabledWarnings = document.querySelectorAll('.no-dictionaries-enabled-warning'); + const dictionaries = await api.getDictionaryInfo(); + + let enabledCount = 0; + for (const {title} of dictionaries) { + if ( + Object.prototype.hasOwnProperty.call(options.dictionaries, title) && + options.dictionaries[title].enabled + ) { + ++enabledCount; + } + } + + const hasEnabledDictionary = (enabledCount > 0); + for (const node of noDictionariesEnabledWarnings) { + node.hidden = hasEnabledDictionary; + } + } + + async _updatePermissionsWarnings(options) { + const permissions = await this._permissionsUtil.getAllPermissions(); + if (this._permissionsUtil.hasRequiredPermissionsForOptions(permissions, options)) { return; } + + const warnings = document.querySelectorAll('.action-open-permissions,.permissions-required-warning'); + for (const node of warnings) { + console.log(node); + node.hidden = false; + } + } +} + +(async () => { + api.forwardLogsToBackend(); + await yomichan.backendReady(); + + api.logIndicatorClear(); + + const displayController = new DisplayController(); + displayController.prepare(); + + yomichan.ready(); +})(); diff --git a/ext/js/pages/generic-page-main.js b/ext/js/pages/generic-page-main.js new file mode 100644 index 00000000..db1a770a --- /dev/null +++ b/ext/js/pages/generic-page-main.js @@ -0,0 +1,32 @@ +/* + * 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 + * DocumentFocusController + */ + +function setupEnvironmentInfo() { + const {manifest_version: manifestVersion} = chrome.runtime.getManifest(); + document.documentElement.dataset.manifestVersion = `${manifestVersion}`; +} + +(() => { + const documentFocusController = new DocumentFocusController(); + documentFocusController.prepare(); + document.documentElement.dataset.loaded = 'true'; + setupEnvironmentInfo(); +})(); diff --git a/ext/js/pages/info-main.js b/ext/js/pages/info-main.js new file mode 100644 index 00000000..6cf82595 --- /dev/null +++ b/ext/js/pages/info-main.js @@ -0,0 +1,127 @@ +/* + * 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 + * BackupController + * DocumentFocusController + * SettingsController + * api + */ + +function getBrowserDisplayName(browser) { + switch (browser) { + case 'chrome': return 'Chrome'; + case 'firefox': return 'Firefox'; + case 'firefox-mobile': return 'Firefox for Android'; + case 'edge': return 'Edge'; + case 'edge-legacy': return 'Edge Legacy'; + default: return `${browser}`; + } +} + +function getOperatingSystemDisplayName(os) { + switch (os) { + case 'mac': return 'Mac OS'; + case 'win': return 'Windows'; + case 'android': return 'Android'; + case 'cros': return 'Chrome OS'; + case 'linux': return 'Linux'; + case 'openbsd': return 'Open BSD'; + case 'unknown': return 'Unknown'; + default: return `${os}`; + } +} + +(async () => { + try { + const documentFocusController = new DocumentFocusController(); + documentFocusController.prepare(); + + const manifest = chrome.runtime.getManifest(); + const language = chrome.i18n.getUILanguage(); + + api.forwardLogsToBackend(); + await yomichan.prepare(); + + const {userAgent} = navigator; + const {name, version} = manifest; + const {browser, platform: {os}} = await api.getEnvironmentInfo(); + + const thisVersionLink = document.querySelector('#release-notes-this-version-link'); + thisVersionLink.href = thisVersionLink.dataset.hrefFormat.replace(/\{version\}/g, version); + + document.querySelector('#version').textContent = `${name} ${version}`; + document.querySelector('#browser').textContent = getBrowserDisplayName(browser); + document.querySelector('#platform').textContent = getOperatingSystemDisplayName(os); + document.querySelector('#language').textContent = `${language}`; + document.querySelector('#user-agent').textContent = userAgent; + + (async () => { + let ankiConnectVersion = null; + try { + ankiConnectVersion = await api.getAnkiConnectVersion(); + } catch (e) { + // NOP + } + + document.querySelector('#anki-connect-version').textContent = (ankiConnectVersion !== null ? `${ankiConnectVersion}` : 'Unknown'); + document.querySelector('#anki-connect-version-container').hasError = `${ankiConnectVersion === null}`; + document.querySelector('#anki-connect-version-unknown-message').hidden = (ankiConnectVersion !== null); + })(); + + (async () => { + let dictionaryInfos; + try { + dictionaryInfos = await api.getDictionaryInfo(); + } catch (e) { + return; + } + + const fragment = document.createDocumentFragment(); + let first = true; + for (const {title} of dictionaryInfos) { + if (first) { + first = false; + } else { + fragment.appendChild(document.createTextNode(', ')); + } + + const node = document.createElement('span'); + node.className = 'installed-dictionary'; + node.textContent = title; + fragment.appendChild(node); + } + + document.querySelector('#installed-dictionaries-none').hidden = (dictionaryInfos.length !== 0); + const container = document.querySelector('#installed-dictionaries'); + container.textContent = ''; + container.appendChild(fragment); + })(); + + const settingsController = new SettingsController(); + settingsController.prepare(); + + const backupController = new BackupController(settingsController, null); + await backupController.prepare(); + + await promiseTimeout(100); + + document.documentElement.dataset.loaded = 'true'; + } catch (e) { + yomichan.logError(e); + } +})(); diff --git a/ext/js/pages/permissions-main.js b/ext/js/pages/permissions-main.js new file mode 100644 index 00000000..5b17a5dd --- /dev/null +++ b/ext/js/pages/permissions-main.js @@ -0,0 +1,103 @@ +/* + * 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 + * DocumentFocusController + * PermissionsToggleController + * SettingsController + * api + */ + +async function setupEnvironmentInfo() { + const {manifest_version: manifestVersion} = chrome.runtime.getManifest(); + const {browser, platform} = await api.getEnvironmentInfo(); + document.documentElement.dataset.browser = browser; + document.documentElement.dataset.os = platform.os; + document.documentElement.dataset.manifestVersion = `${manifestVersion}`; +} + +async function isAllowedIncognitoAccess() { + return await new Promise((resolve) => chrome.extension.isAllowedIncognitoAccess(resolve)); +} + +async function isAllowedFileSchemeAccess() { + return await new Promise((resolve) => chrome.extension.isAllowedFileSchemeAccess(resolve)); +} + +function setupPermissionsToggles() { + const manifest = chrome.runtime.getManifest(); + let optionalPermissions = manifest.optional_permissions; + if (!Array.isArray(optionalPermissions)) { optionalPermissions = []; } + optionalPermissions = new Set(optionalPermissions); + + const hasAllPermisions = (set, values) => { + for (const value of values) { + if (!set.has(value)) { return false; } + } + return true; + }; + + for (const toggle of document.querySelectorAll('.permissions-toggle')) { + let permissions = toggle.dataset.requiredPermissions; + permissions = (typeof permissions === 'string' && permissions.length > 0 ? permissions.split(' ') : []); + toggle.disabled = !hasAllPermisions(optionalPermissions, permissions); + } +} + +(async () => { + try { + const documentFocusController = new DocumentFocusController(); + documentFocusController.prepare(); + + setupPermissionsToggles(); + + for (const node of document.querySelectorAll('.extension-id-example')) { + node.textContent = chrome.runtime.getURL('/'); + } + + api.forwardLogsToBackend(); + await yomichan.prepare(); + + setupEnvironmentInfo(); + + const permissionsCheckboxes = [ + document.querySelector('#permission-checkbox-allow-in-private-windows'), + document.querySelector('#permission-checkbox-allow-file-url-access') + ]; + + const permissions = await Promise.all([ + isAllowedIncognitoAccess(), + isAllowedFileSchemeAccess() + ]); + + for (let i = 0, ii = permissions.length; i < ii; ++i) { + permissionsCheckboxes[i].checked = permissions[i]; + } + + const settingsController = new SettingsController(0); + settingsController.prepare(); + + const permissionsToggleController = new PermissionsToggleController(settingsController); + permissionsToggleController.prepare(); + + await promiseTimeout(100); + + document.documentElement.dataset.loaded = 'true'; + } catch (e) { + yomichan.logError(e); + } +})(); diff --git a/ext/js/pages/welcome-main.js b/ext/js/pages/welcome-main.js new file mode 100644 index 00000000..57b265dc --- /dev/null +++ b/ext/js/pages/welcome-main.js @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2019-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 + * DictionaryController + * DictionaryImportController + * DocumentFocusController + * GenericSettingController + * ModalController + * ScanInputsSimpleController + * SettingsController + * SettingsDisplayController + * StatusFooter + * api + */ + +async function setupEnvironmentInfo() { + const {manifest_version: manifestVersion} = chrome.runtime.getManifest(); + const {browser, platform} = await api.getEnvironmentInfo(); + document.documentElement.dataset.browser = browser; + document.documentElement.dataset.os = platform.os; + document.documentElement.dataset.manifestVersion = `${manifestVersion}`; +} + +async function setupGenericSettingsController(genericSettingController) { + await genericSettingController.prepare(); + await genericSettingController.refresh(); +} + +(async () => { + try { + const documentFocusController = new DocumentFocusController(); + documentFocusController.prepare(); + + const statusFooter = new StatusFooter(document.querySelector('.status-footer-container')); + statusFooter.prepare(); + + api.forwardLogsToBackend(); + await yomichan.prepare(); + + setupEnvironmentInfo(); + + const optionsFull = await api.optionsGetFull(); + + const preparePromises = []; + + const modalController = new ModalController(); + modalController.prepare(); + + const settingsController = new SettingsController(optionsFull.profileCurrent); + settingsController.prepare(); + + const dictionaryController = new DictionaryController(settingsController, modalController, null, statusFooter); + dictionaryController.prepare(); + + const dictionaryImportController = new DictionaryImportController(settingsController, modalController, null, statusFooter); + dictionaryImportController.prepare(); + + const genericSettingController = new GenericSettingController(settingsController); + preparePromises.push(setupGenericSettingsController(genericSettingController)); + + const simpleScanningInputController = new ScanInputsSimpleController(settingsController); + simpleScanningInputController.prepare(); + + await Promise.all(preparePromises); + + document.documentElement.dataset.loaded = 'true'; + + const settingsDisplayController = new SettingsDisplayController(settingsController, modalController); + settingsDisplayController.prepare(); + } catch (e) { + yomichan.logError(e); + } +})(); diff --git a/ext/js/templates/template-patcher.js b/ext/js/templates/template-patcher.js new file mode 100644 index 00000000..57178957 --- /dev/null +++ b/ext/js/templates/template-patcher.js @@ -0,0 +1,92 @@ +/* + * Copyright (C) 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 TemplatePatcher { + constructor() { + this._diffPattern1 = /\n?\{\{<<<<<<<\}\}\n/g; + this._diffPattern2 = /\n\{\{=======\}\}\n/g; + this._diffPattern3 = /\n\{\{>>>>>>>\}\}\n*/g; + this._lookupMarkerPattern = /[ \t]*\{\{~?>\s*\(\s*lookup\s*\.\s*"marker"\s*\)\s*~?\}\}/g; + } + + parsePatch(content) { + const diffPattern1 = this._diffPattern1; + const diffPattern2 = this._diffPattern2; + const diffPattern3 = this._diffPattern3; + const modifications = []; + let index = 0; + + while (true) { + // Find modification boundaries + diffPattern1.lastIndex = index; + const m1 = diffPattern1.exec(content); + if (m1 === null) { break; } + + diffPattern2.lastIndex = m1.index + m1[0].length; + const m2 = diffPattern2.exec(content); + if (m2 === null) { break; } + + diffPattern3.lastIndex = m2.index + m2[0].length; + const m3 = diffPattern3.exec(content); + if (m3 === null) { break; } + + // Construct + const current = content.substring(m1.index + m1[0].length, m2.index); + const replacement = content.substring(m2.index + m2[0].length, m3.index); + + if (current.length > 0) { + modifications.push({current, replacement}); + } + + // Update + content = content.substring(0, m1.index) + content.substring(m3.index + m3[0].length); + index = m1.index; + } + + return {addition: content, modifications}; + } + + applyPatch(template, patch) { + for (const {current, replacement} of patch.modifications) { + let fromIndex = 0; + while (true) { + const index = template.indexOf(current, fromIndex); + if (index < 0) { break; } + template = template.substring(0, index) + replacement + template.substring(index + current.length); + fromIndex = index + replacement.length; + } + } + template = this._addFieldTemplatesBeforeEnd(template, patch.addition); + return template; + } + + // Private + + _addFieldTemplatesBeforeEnd(template, addition) { + const newline = '\n'; + let replaced = false; + template = template.replace(this._lookupMarkerPattern, (g0) => { + replaced = true; + return `${addition}${newline}${g0}`; + }); + if (!replaced) { + template += newline; + template += addition; + } + return template; + } +} diff --git a/ext/js/templates/template-renderer-frame-api.js b/ext/js/templates/template-renderer-frame-api.js new file mode 100644 index 00000000..4936a2af --- /dev/null +++ b/ext/js/templates/template-renderer-frame-api.js @@ -0,0 +1,78 @@ +/* + * 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 TemplateRendererFrameApi { + constructor(templateRenderer) { + this._templateRenderer = templateRenderer; + this._windowMessageHandlers = new Map([ + ['render', {async: true, handler: this._onRender.bind(this)}] + ]); + } + + prepare() { + window.addEventListener('message', this._onWindowMessage.bind(this), false); + } + + // Private + + _onWindowMessage(e) { + const {source, data: {action, params, id}} = e; + const messageHandler = this._windowMessageHandlers.get(action); + if (typeof messageHandler === 'undefined') { return; } + + this._onWindowMessageInner(messageHandler, action, params, source, id); + } + + async _onWindowMessageInner({handler, async}, action, params, source, id) { + let response; + try { + let result = handler(params); + if (async) { + result = await result; + } + response = {result}; + } catch (error) { + response = {error: this._errorToJson(error)}; + } + + if (typeof id === 'undefined') { return; } + source.postMessage({action: `${action}.response`, params: response, id}, '*'); + } + + async _onRender({template, data, type}) { + return await this._templateRenderer.render(template, data, type); + } + + _errorToJson(error) { + try { + if (error !== null && typeof error === 'object') { + return { + name: error.name, + message: error.message, + stack: error.stack, + data: error.data + }; + } + } catch (e) { + // NOP + } + return { + value: error, + hasValue: true + }; + } +} diff --git a/ext/js/templates/template-renderer-frame-main.js b/ext/js/templates/template-renderer-frame-main.js new file mode 100644 index 00000000..d25eb56d --- /dev/null +++ b/ext/js/templates/template-renderer-frame-main.js @@ -0,0 +1,33 @@ +/* + * 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/>. + */ + +/* globals + * AnkiNoteData + * JapaneseUtil + * TemplateRenderer + * TemplateRendererFrameApi + */ + +(() => { + const japaneseUtil = new JapaneseUtil(null); + const templateRenderer = new TemplateRenderer(japaneseUtil); + templateRenderer.registerDataType('ankiNote', { + modifier: ({data, marker}) => new AnkiNoteData(data, marker).createPublic() + }); + const api = new TemplateRendererFrameApi(templateRenderer); + api.prepare(); +})(); diff --git a/ext/js/templates/template-renderer-proxy.js b/ext/js/templates/template-renderer-proxy.js new file mode 100644 index 00000000..6a49832b --- /dev/null +++ b/ext/js/templates/template-renderer-proxy.js @@ -0,0 +1,157 @@ +/* + * 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 TemplateRendererProxy { + constructor() { + this._frame = null; + this._frameNeedsLoad = true; + this._frameLoading = false; + this._frameLoadPromise = null; + this._frameUrl = chrome.runtime.getURL('/template-renderer.html'); + this._invocations = new Set(); + } + + async render(template, data, type) { + await this._prepareFrame(); + return await this._invoke('render', {template, data, type}); + } + + // Private + + async _prepareFrame() { + if (this._frame === null) { + this._frame = document.createElement('iframe'); + this._frame.addEventListener('load', this._onFrameLoad.bind(this), false); + const style = this._frame.style; + style.opacity = '0'; + style.width = '0'; + style.height = '0'; + style.position = 'absolute'; + style.border = '0'; + style.margin = '0'; + style.padding = '0'; + style.pointerEvents = 'none'; + } + if (this._frameNeedsLoad) { + this._frameNeedsLoad = false; + this._frameLoading = true; + this._frameLoadPromise = this._loadFrame(this._frame, this._frameUrl) + .finally(() => { this._frameLoading = false; }); + } + await this._frameLoadPromise; + } + + _loadFrame(frame, url, timeout=5000) { + return new Promise((resolve, reject) => { + let ready = false; + const cleanup = () => { + frame.removeEventListener('load', onLoad, false); + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + const onLoad = () => { + if (!ready) { return; } + cleanup(); + resolve(); + }; + + let timer = setTimeout(() => { + cleanup(); + reject(new Error('Timeout')); + }, timeout); + + frame.removeAttribute('src'); + frame.removeAttribute('srcdoc'); + frame.addEventListener('load', onLoad, false); + try { + document.body.appendChild(frame); + ready = true; + frame.contentDocument.location.href = url; + } catch (e) { + cleanup(); + reject(e); + } + }); + } + + _invoke(action, params, timeout=null) { + return new Promise((resolve, reject) => { + const frameWindow = (this._frame !== null ? this._frame.contentWindow : null); + if (frameWindow === null) { + reject(new Error('Frame not set up')); + return; + } + + const id = generateId(16); + const invocation = { + cancel: () => { + cleanup(); + reject(new Error('Terminated')); + } + }; + + const cleanup = () => { + this._invocations.delete(invocation); + window.removeEventListener('message', onMessage, false); + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + const onMessage = (e) => { + if ( + e.source !== frameWindow || + e.data.id !== id || + e.data.action !== `${action}.response` + ) { + return; + } + + const response = e.data.params; + cleanup(); + const {error} = response; + if (error) { + reject(deserializeError(error)); + } else { + resolve(response.result); + } + }; + + let timer = (typeof timeout === 'number' ? setTimeout(() => { + cleanup(); + reject(new Error('Timeout')); + }, timeout) : null); + + this._invocations.add(invocation); + + window.addEventListener('message', onMessage, false); + frameWindow.postMessage({action, params, id}, '*'); + }); + } + + _onFrameLoad() { + if (this._frameLoading) { return; } + this._frameNeedsLoad = true; + + for (const invocation of this._invocations) { + invocation.cancel(); + } + this._invocations.clear(); + } +} diff --git a/ext/js/templates/template-renderer.js b/ext/js/templates/template-renderer.js new file mode 100644 index 00000000..ae39e478 --- /dev/null +++ b/ext/js/templates/template-renderer.js @@ -0,0 +1,416 @@ +/* + * 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 + * Handlebars + */ + +class TemplateRenderer { + constructor(japaneseUtil) { + this._japaneseUtil = japaneseUtil; + this._cache = new Map(); + this._cacheMaxSize = 5; + this._helpersRegistered = false; + this._stateStack = null; + this._dataTypes = new Map(); + } + + registerDataType(name, {modifier=null, modifierPost=null}) { + this._dataTypes.set(name, {modifier, modifierPost}); + } + + async render(template, data, type) { + if (!this._helpersRegistered) { + this._registerHelpers(); + this._helpersRegistered = true; + } + + const cache = this._cache; + let instance = cache.get(template); + if (typeof instance === 'undefined') { + this._updateCacheSize(this._cacheMaxSize - 1); + instance = Handlebars.compile(template); + cache.set(template, instance); + } + + let modifier = null; + let modifierPost = null; + if (typeof type === 'string') { + const typeInfo = this._dataTypes.get(type); + if (typeof typeInfo !== 'undefined') { + ({modifier, modifierPost} = typeInfo); + } + } + + try { + if (typeof modifier === 'function') { + data = modifier(data); + } + + this._stateStack = [new Map()]; + return instance(data).trim(); + } finally { + this._stateStack = null; + + if (typeof modifierPost === 'function') { + modifierPost(data); + } + } + } + + // Private + + _updateCacheSize(maxSize) { + const cache = this._cache; + let removeCount = cache.size - maxSize; + if (removeCount <= 0) { return; } + + for (const key of cache.keys()) { + cache.delete(key); + if (--removeCount <= 0) { break; } + } + } + + _registerHelpers() { + Handlebars.partials = Handlebars.templates; + + const helpers = [ + ['dumpObject', this._dumpObject.bind(this)], + ['furigana', this._furigana.bind(this)], + ['furiganaPlain', this._furiganaPlain.bind(this)], + ['kanjiLinks', this._kanjiLinks.bind(this)], + ['multiLine', this._multiLine.bind(this)], + ['sanitizeCssClass', this._sanitizeCssClass.bind(this)], + ['regexReplace', this._regexReplace.bind(this)], + ['regexMatch', this._regexMatch.bind(this)], + ['mergeTags', this._mergeTags.bind(this)], + ['eachUpTo', this._eachUpTo.bind(this)], + ['spread', this._spread.bind(this)], + ['op', this._op.bind(this)], + ['get', this._get.bind(this)], + ['set', this._set.bind(this)], + ['scope', this._scope.bind(this)], + ['property', this._property.bind(this)], + ['noop', this._noop.bind(this)], + ['isMoraPitchHigh', this._isMoraPitchHigh.bind(this)], + ['getKanaMorae', this._getKanaMorae.bind(this)], + ['typeof', this._getTypeof.bind(this)] + ]; + + for (const [name, helper] of helpers) { + this._registerHelper(name, helper); + } + } + + _registerHelper(name, helper) { + function wrapper(...args) { + return helper(this, ...args); + } + Handlebars.registerHelper(name, wrapper); + } + + _escape(text) { + return Handlebars.Utils.escapeExpression(text); + } + + _dumpObject(context, options) { + const dump = JSON.stringify(options.fn(context), null, 4); + return this._escape(dump); + } + + _furigana(context, ...args) { + const {expression, reading} = this._getFuriganaExpressionAndReading(context, ...args); + const segs = this._japaneseUtil.distributeFurigana(expression, reading); + + let result = ''; + for (const seg of segs) { + if (seg.furigana.length > 0) { + result += `<ruby>${seg.text}<rt>${seg.furigana}</rt></ruby>`; + } else { + result += seg.text; + } + } + + return result; + } + + _furiganaPlain(context, ...args) { + const {expression, reading} = this._getFuriganaExpressionAndReading(context, ...args); + const segs = this._japaneseUtil.distributeFurigana(expression, reading); + + let result = ''; + for (const seg of segs) { + if (seg.furigana.length > 0) { + if (result.length > 0) { result += ' '; } + result += `${seg.text}[${seg.furigana}]`; + } else { + result += seg.text; + } + } + + return result; + } + + _getFuriganaExpressionAndReading(context, ...args) { + const options = args[args.length - 1]; + if (args.length >= 3) { + return {expression: args[0], reading: args[1]}; + } else { + const {expression, reading} = options.fn(context); + return {expression, reading}; + } + } + + _kanjiLinks(context, options) { + const jp = this._japaneseUtil; + let result = ''; + for (const c of options.fn(context)) { + if (jp.isCodePointKanji(c.codePointAt(0))) { + result += `<a href="#" class="kanji-link">${c}</a>`; + } else { + result += c; + } + } + + return result; + } + + _multiLine(context, options) { + return options.fn(context).split('\n').join('<br>'); + } + + _sanitizeCssClass(context, options) { + return options.fn(context).replace(/[^_a-z0-9\u00a0-\uffff]/ig, '_'); + } + + _regexReplace(context, ...args) { + // Usage: + // {{#regexReplace regex string [flags]}}content{{/regexReplace}} + // regex: regular expression string + // string: string to replace + // flags: optional flags for regular expression + // e.g. "i" for case-insensitive, "g" for replace all + let value = args[args.length - 1].fn(context); + if (args.length >= 3) { + try { + const flags = args.length > 3 ? args[2] : 'g'; + const regex = new RegExp(args[0], flags); + value = value.replace(regex, args[1]); + } catch (e) { + return `${e}`; + } + } + return value; + } + + _regexMatch(context, ...args) { + // Usage: + // {{#regexMatch regex [flags]}}content{{/regexMatch}} + // regex: regular expression string + // flags: optional flags for regular expression + // e.g. "i" for case-insensitive, "g" for match all + let value = args[args.length - 1].fn(context); + if (args.length >= 2) { + try { + const flags = args.length > 2 ? args[1] : ''; + const regex = new RegExp(args[0], flags); + const parts = []; + value.replace(regex, (g0) => parts.push(g0)); + value = parts.join(''); + } catch (e) { + return `${e}`; + } + } + return value; + } + + _mergeTags(context, object, isGroupMode, isMergeMode) { + const tagSources = []; + if (isGroupMode || isMergeMode) { + for (const definition of object.definitions) { + tagSources.push(definition.definitionTags); + } + } else { + tagSources.push(object.definitionTags); + } + + const tags = new Set(); + for (const tagSource of tagSources) { + for (const tag of tagSource) { + tags.add(tag.name); + } + } + + return [...tags].join(', '); + } + + _eachUpTo(context, iterable, maxCount, options) { + if (iterable) { + const results = []; + let any = false; + for (const entry of iterable) { + any = true; + if (results.length >= maxCount) { break; } + const processedEntry = options.fn(entry); + results.push(processedEntry); + } + if (any) { + return results.join(''); + } + } + return options.inverse(context); + } + + _spread(context, ...args) { + const result = []; + for (let i = 0, ii = args.length - 1; i < ii; ++i) { + try { + result.push(...args[i]); + } catch (e) { + // NOP + } + } + return result; + } + + _op(context, ...args) { + switch (args.length) { + case 3: return this._evaluateUnaryExpression(args[0], args[1]); + case 4: return this._evaluateBinaryExpression(args[0], args[1], args[2]); + case 5: return this._evaluateTernaryExpression(args[0], args[1], args[2], args[3]); + default: return void 0; + } + } + + _evaluateUnaryExpression(operator, operand1) { + switch (operator) { + case '+': return +operand1; + case '-': return -operand1; + case '~': return ~operand1; + case '!': return !operand1; + default: return void 0; + } + } + + _evaluateBinaryExpression(operator, operand1, operand2) { + switch (operator) { + case '+': return operand1 + operand2; + case '-': return operand1 - operand2; + case '/': return operand1 / operand2; + case '*': return operand1 * operand2; + case '%': return operand1 % operand2; + case '**': return operand1 ** operand2; + case '==': return operand1 == operand2; // eslint-disable-line eqeqeq + case '!=': return operand1 != operand2; // eslint-disable-line eqeqeq + case '===': return operand1 === operand2; + case '!==': return operand1 !== operand2; + case '<': return operand1 < operand2; + case '<=': return operand1 <= operand2; + case '>': return operand1 > operand2; + case '>=': return operand1 >= operand2; + case '<<': return operand1 << operand2; + case '>>': return operand1 >> operand2; + case '>>>': return operand1 >>> operand2; + case '&': return operand1 & operand2; + case '|': return operand1 | operand2; + case '^': return operand1 ^ operand2; + case '&&': return operand1 && operand2; + case '||': return operand1 || operand2; + default: return void 0; + } + } + + _evaluateTernaryExpression(operator, operand1, operand2, operand3) { + switch (operator) { + case '?:': return operand1 ? operand2 : operand3; + default: return void 0; + } + } + + _get(context, key) { + for (let i = this._stateStack.length; --i >= 0;) { + const map = this._stateStack[i]; + if (map.has(key)) { + return map.get(key); + } + } + return void 0; + } + + _set(context, ...args) { + switch (args.length) { + case 2: + { + const [key, options] = args; + const value = options.fn(context); + this._stateStack[this._stateStack.length - 1].set(key, value); + } + break; + case 3: + { + const [key, value] = args; + this._stateStack[this._stateStack.length - 1].set(key, value); + } + break; + } + return ''; + } + + _scope(context, options) { + try { + this._stateStack.push(new Map()); + return options.fn(context); + } finally { + if (this._stateStack.length > 1) { + this._stateStack.pop(); + } + } + } + + _property(context, ...args) { + const ii = args.length - 1; + if (ii <= 0) { return void 0; } + + try { + let value = args[0]; + for (let i = 1; i < ii; ++i) { + value = value[args[i]]; + } + return value; + } catch (e) { + return void 0; + } + } + + _noop(context, options) { + return options.fn(context); + } + + _isMoraPitchHigh(context, index, position) { + return this._japaneseUtil.isMoraPitchHigh(index, position); + } + + _getKanaMorae(context, text) { + return this._japaneseUtil.getKanaMorae(`${text}`); + } + + _getTypeof(context, ...args) { + const ii = args.length - 1; + const value = (ii > 0 ? args[0] : args[ii].fn(context)); + return typeof value; + } +} |