aboutsummaryrefslogtreecommitdiff
path: root/ext/js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js')
-rw-r--r--ext/js/background/backend.js2053
-rw-r--r--ext/js/background/background-main.js25
-rw-r--r--ext/js/background/profile-conditions.js276
-rw-r--r--ext/js/background/request-builder.js266
-rw-r--r--ext/js/comm/anki.js235
-rw-r--r--ext/js/comm/clipboard-monitor.js80
-rw-r--r--ext/js/comm/clipboard-reader.js169
-rw-r--r--ext/js/comm/mecab.js230
-rw-r--r--ext/js/data/anki-note-builder.js148
-rw-r--r--ext/js/data/anki-note-data.js240
-rw-r--r--ext/js/data/database.js327
-rw-r--r--ext/js/data/json-schema.js757
-rw-r--r--ext/js/data/options-util.js739
-rw-r--r--ext/js/data/permissions-util.js126
-rw-r--r--ext/js/display/query-parser.js232
-rw-r--r--ext/js/display/search-display-controller.js422
-rw-r--r--ext/js/display/search-main.js57
-rw-r--r--ext/js/dom/native-simple-dom-parser.js50
-rw-r--r--ext/js/dom/simple-dom-parser.js117
-rw-r--r--ext/js/general/text-source-map.js118
-rw-r--r--ext/js/language/deinflector.js96
-rw-r--r--ext/js/language/dictionary-database.js484
-rw-r--r--ext/js/language/dictionary-importer.js407
-rw-r--r--ext/js/language/translator.js1397
-rw-r--r--ext/js/media/audio-downloader.js317
-rw-r--r--ext/js/media/media-utility.js132
-rw-r--r--ext/js/pages/action-popup-main.js233
-rw-r--r--ext/js/pages/generic-page-main.js32
-rw-r--r--ext/js/pages/info-main.js127
-rw-r--r--ext/js/pages/permissions-main.js103
-rw-r--r--ext/js/pages/welcome-main.js88
-rw-r--r--ext/js/templates/template-patcher.js92
-rw-r--r--ext/js/templates/template-renderer-frame-api.js78
-rw-r--r--ext/js/templates/template-renderer-frame-main.js33
-rw-r--r--ext/js/templates/template-renderer-proxy.js157
-rw-r--r--ext/js/templates/template-renderer.js416
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;
+ }
+}