diff options
Diffstat (limited to 'ext/js/comm')
-rw-r--r-- | ext/js/comm/anki.js | 235 | ||||
-rw-r--r-- | ext/js/comm/clipboard-monitor.js | 80 | ||||
-rw-r--r-- | ext/js/comm/clipboard-reader.js | 169 | ||||
-rw-r--r-- | ext/js/comm/mecab.js | 230 |
4 files changed, 714 insertions, 0 deletions
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; + } +} |