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