/*
* Copyright (C) 2023 Yomitan Authors
* Copyright (C) 2016-2022 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 .
*/
import {deferPromise} from '../core.js';
import {ExtensionError} from '../core/extension-error.js';
export class API {
/**
* @param {import('../yomitan.js').Yomitan} yomitan
*/
constructor(yomitan) {
/** @type {import('../yomitan.js').Yomitan} */
this._yomitan = yomitan;
}
/**
* @param {import('api').OptionsGetDetails['optionsContext']} optionsContext
* @returns {Promise}
*/
optionsGet(optionsContext) {
/** @type {import('api').OptionsGetDetails} */
const details = {optionsContext};
return this._invoke('optionsGet', details);
}
/**
* @returns {Promise}
*/
optionsGetFull() {
return this._invoke('optionsGetFull');
}
/**
* @param {import('api').TermsFindDetails['text']} text
* @param {import('api').TermsFindDetails['details']} details
* @param {import('api').TermsFindDetails['optionsContext']} optionsContext
* @returns {Promise}
*/
termsFind(text, details, optionsContext) {
/** @type {import('api').TermsFindDetails} */
const details2 = {text, details, optionsContext};
return this._invoke('termsFind', details2);
}
/**
* @param {import('api').ParseTextDetails['text']} text
* @param {import('api').ParseTextDetails['optionsContext']} optionsContext
* @param {import('api').ParseTextDetails['scanLength']} scanLength
* @param {import('api').ParseTextDetails['useInternalParser']} useInternalParser
* @param {import('api').ParseTextDetails['useMecabParser']} useMecabParser
* @returns {Promise}
*/
parseText(text, optionsContext, scanLength, useInternalParser, useMecabParser) {
/** @type {import('api').ParseTextDetails} */
const details = {text, optionsContext, scanLength, useInternalParser, useMecabParser};
return this._invoke('parseText', details);
}
/**
* @param {import('api').KanjiFindDetails['text']} text
* @param {import('api').KanjiFindDetails['optionsContext']} optionsContext
* @returns {Promise}
*/
kanjiFind(text, optionsContext) {
/** @type {import('api').KanjiFindDetails} */
const details = {text, optionsContext};
return this._invoke('kanjiFind', details);
}
/**
* @returns {Promise}
*/
isAnkiConnected() {
return this._invoke('isAnkiConnected');
}
/**
* @returns {Promise}
*/
getAnkiConnectVersion() {
return this._invoke('getAnkiConnectVersion');
}
/**
* @param {import('api').AddAnkiNoteDetails['note']} note
* @returns {Promise}
*/
addAnkiNote(note) {
/** @type {import('api').AddAnkiNoteDetails} */
const details = {note};
return this._invoke('addAnkiNote', details);
}
/**
* @param {import('api').GetAnkiNoteInfoDetails['notes']} notes
* @param {import('api').GetAnkiNoteInfoDetails['fetchAdditionalInfo']} fetchAdditionalInfo
* @returns {Promise}
*/
getAnkiNoteInfo(notes, fetchAdditionalInfo) {
/** @type {import('api').GetAnkiNoteInfoDetails} */
const details = {notes, fetchAdditionalInfo};
return this._invoke('getAnkiNoteInfo', details);
}
/**
* @param {import('api').InjectAnkiNoteMediaDetails['timestamp']} timestamp
* @param {import('api').InjectAnkiNoteMediaDetails['definitionDetails']} definitionDetails
* @param {import('api').InjectAnkiNoteMediaDetails['audioDetails']} audioDetails
* @param {import('api').InjectAnkiNoteMediaDetails['screenshotDetails']} screenshotDetails
* @param {import('api').InjectAnkiNoteMediaDetails['clipboardDetails']} clipboardDetails
* @param {import('api').InjectAnkiNoteMediaDetails['dictionaryMediaDetails']} dictionaryMediaDetails
* @returns {Promise}
*/
injectAnkiNoteMedia(timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails) {
/** @type {import('api').InjectAnkiNoteMediaDetails} */
const details = {timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails};
return this._invoke('injectAnkiNoteMedia', details);
}
/**
* @param {import('api').NoteViewDetails['noteId']} noteId
* @param {import('api').NoteViewDetails['mode']} mode
* @param {import('api').NoteViewDetails['allowFallback']} allowFallback
* @returns {Promise}
*/
noteView(noteId, mode, allowFallback) {
/** @type {import('api').NoteViewDetails} */
const details = {noteId, mode, allowFallback};
return this._invoke('noteView', details);
}
/**
* @param {import('api').SuspendAnkiCardsForNoteDetails['noteId']} noteId
* @returns {Promise}
*/
suspendAnkiCardsForNote(noteId) {
/** @type {import('api').SuspendAnkiCardsForNoteDetails} */
const details = {noteId};
return this._invoke('suspendAnkiCardsForNote', details);
}
/**
* @param {import('api').GetTermAudioInfoListDetails['source']} source
* @param {import('api').GetTermAudioInfoListDetails['term']} term
* @param {import('api').GetTermAudioInfoListDetails['reading']} reading
* @returns {Promise}
*/
getTermAudioInfoList(source, term, reading) {
/** @type {import('api').GetTermAudioInfoListDetails} */
const details = {source, term, reading};
return this._invoke('getTermAudioInfoList', details);
}
/**
* @param {import('api').CommandExecDetails['command']} command
* @param {import('api').CommandExecDetails['params']} [params]
* @returns {Promise}
*/
commandExec(command, params) {
/** @type {import('api').CommandExecDetails} */
const details = {command, params};
return this._invoke('commandExec', details);
}
/**
* @param {import('api').SendMessageToFrameDetails['frameId']} frameId
* @param {import('api').SendMessageToFrameDetails['action']} action
* @param {import('api').SendMessageToFrameDetails['params']} [params]
* @returns {Promise}
*/
sendMessageToFrame(frameId, action, params) {
/** @type {import('api').SendMessageToFrameDetails} */
const details = {frameId, action, params};
return this._invoke('sendMessageToFrame', details);
}
/**
* @param {import('api').BroadcastTabDetails['action']} action
* @param {import('api').BroadcastTabDetails['params']} params
* @returns {Promise}
*/
broadcastTab(action, params) {
/** @type {import('api').BroadcastTabDetails} */
const details = {action, params};
return this._invoke('broadcastTab', details);
}
/**
* @returns {Promise}
*/
frameInformationGet() {
return this._invoke('frameInformationGet');
}
/**
* @param {import('api').InjectStylesheetDetails['type']} type
* @param {import('api').InjectStylesheetDetails['value']} value
* @returns {Promise}
*/
injectStylesheet(type, value) {
/** @type {import('api').InjectStylesheetDetails} */
const details = {type, value};
return this._invoke('injectStylesheet', details);
}
/**
* @param {import('api').GetStylesheetContentDetails['url']} url
* @returns {Promise}
*/
getStylesheetContent(url) {
/** @type {import('api').GetStylesheetContentDetails} */
const details = {url};
return this._invoke('getStylesheetContent', details);
}
/**
* @returns {Promise}
*/
getEnvironmentInfo() {
return this._invoke('getEnvironmentInfo');
}
/**
* @returns {Promise}
*/
clipboardGet() {
return this._invoke('clipboardGet');
}
/**
* @returns {Promise}
*/
getDisplayTemplatesHtml() {
return this._invoke('getDisplayTemplatesHtml');
}
/**
* @returns {Promise}
*/
getZoom() {
return this._invoke('getZoom');
}
/**
* @returns {Promise}
*/
getDefaultAnkiFieldTemplates() {
return this._invoke('getDefaultAnkiFieldTemplates');
}
/**
* @returns {Promise}
*/
getDictionaryInfo() {
return this._invoke('getDictionaryInfo');
}
/**
* @returns {Promise}
*/
purgeDatabase() {
return this._invoke('purgeDatabase');
}
/**
* @param {import('api').GetMediaDetails['targets']} targets
* @returns {Promise}
*/
getMedia(targets) {
/** @type {import('api').GetMediaDetails} */
const details = {targets};
return this._invoke('getMedia', details);
}
/**
* @param {import('api').LogDetails['error']} error
* @param {import('api').LogDetails['level']} level
* @param {import('api').LogDetails['context']} context
* @returns {Promise}
*/
log(error, level, context) {
/** @type {import('api').LogDetails} */
const details = {error, level, context};
return this._invoke('log', details);
}
/**
* @returns {Promise}
*/
logIndicatorClear() {
return this._invoke('logIndicatorClear');
}
/**
* @param {import('api').ModifySettingsDetails['targets']} targets
* @param {import('api').ModifySettingsDetails['source']} source
* @returns {Promise}
*/
modifySettings(targets, source) {
const details = {targets, source};
return this._invoke('modifySettings', details);
}
/**
* @param {import('api').GetSettingsDetails['targets']} targets
* @returns {Promise}
*/
getSettings(targets) {
/** @type {import('api').GetSettingsDetails} */
const details = {targets};
return this._invoke('getSettings', details);
}
/**
* @param {import('api').SetAllSettingsDetails['value']} value
* @param {import('api').SetAllSettingsDetails['source']} source
* @returns {Promise}
*/
setAllSettings(value, source) {
/** @type {import('api').SetAllSettingsDetails} */
const details = {value, source};
return this._invoke('setAllSettings', details);
}
/**
* @param {import('api').GetOrCreateSearchPopupDetails} details
* @returns {Promise}
*/
getOrCreateSearchPopup(details) {
return this._invoke('getOrCreateSearchPopup', details);
}
/**
* @param {import('api').IsTabSearchPopupDetails['tabId']} tabId
* @returns {Promise}
*/
isTabSearchPopup(tabId) {
/** @type {import('api').IsTabSearchPopupDetails} */
const details = {tabId};
return this._invoke('isTabSearchPopup', details);
}
/**
* @param {import('api').TriggerDatabaseUpdatedDetails['type']} type
* @param {import('api').TriggerDatabaseUpdatedDetails['cause']} cause
* @returns {Promise}
*/
triggerDatabaseUpdated(type, cause) {
/** @type {import('api').TriggerDatabaseUpdatedDetails} */
const details = {type, cause};
return this._invoke('triggerDatabaseUpdated', details);
}
/**
* @returns {Promise}
*/
testMecab() {
return this._invoke('testMecab');
}
/**
* @param {import('api').TextHasJapaneseCharactersDetails['text']} text
* @returns {Promise}
*/
textHasJapaneseCharacters(text) {
/** @type {import('api').TextHasJapaneseCharactersDetails} */
const details = {text};
return this._invoke('textHasJapaneseCharacters', details);
}
/**
* @param {import('api').GetTermFrequenciesDetails['termReadingList']} termReadingList
* @param {import('api').GetTermFrequenciesDetails['dictionaries']} dictionaries
* @returns {Promise}
*/
getTermFrequencies(termReadingList, dictionaries) {
/** @type {import('api').GetTermFrequenciesDetails} */
const details = {termReadingList, dictionaries};
return this._invoke('getTermFrequencies', details);
}
/**
* @param {import('api').FindAnkiNotesDetails['query']} query
* @returns {Promise}
*/
findAnkiNotes(query) {
/** @type {import('api').FindAnkiNotesDetails} */
const details = {query};
return this._invoke('findAnkiNotes', details);
}
/**
* @param {import('api').LoadExtensionScriptsDetails['files']} files
* @returns {Promise}
*/
loadExtensionScripts(files) {
/** @type {import('api').LoadExtensionScriptsDetails} */
const details = {files};
return this._invoke('loadExtensionScripts', details);
}
/**
* @param {import('api').OpenCrossFramePortDetails['targetTabId']} targetTabId
* @param {import('api').OpenCrossFramePortDetails['targetFrameId']} targetFrameId
* @returns {Promise}
*/
openCrossFramePort(targetTabId, targetFrameId) {
return this._invoke('openCrossFramePort', {targetTabId, targetFrameId});
}
// Utilities
/**
* @param {number} timeout
* @returns {Promise}
*/
_createActionPort(timeout) {
return new Promise((resolve, reject) => {
/** @type {?number} */
let timer = null;
const portDetails = deferPromise();
/**
* @param {chrome.runtime.Port} port
*/
const onConnect = async (port) => {
try {
const {name: expectedName, id: expectedId} = await portDetails.promise;
const {name, id} = JSON.parse(port.name);
if (name !== expectedName || id !== expectedId || timer === null) { return; }
} catch (e) {
return;
}
clearTimeout(timer);
timer = null;
chrome.runtime.onConnect.removeListener(onConnect);
resolve(port);
};
/**
* @param {Error} e
*/
const onError = (e) => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
chrome.runtime.onConnect.removeListener(onConnect);
portDetails.reject(e);
reject(e);
};
timer = setTimeout(() => onError(new Error('Timeout')), timeout);
chrome.runtime.onConnect.addListener(onConnect);
this._invoke('createActionPort').then(portDetails.resolve, onError);
});
}
/**
* @template [TReturn=unknown]
* @param {string} action
* @param {import('core').SerializableObject} params
* @param {?(...args: unknown[]) => void} onProgress0
* @param {number} [timeout]
* @returns {Promise}
*/
_invokeWithProgress(action, params, onProgress0, timeout=5000) {
return new Promise((resolve, reject) => {
/** @type {?chrome.runtime.Port} */
let port = null;
const onProgress = typeof onProgress0 === 'function' ? onProgress0 : () => {};
/**
* @param {import('backend').InvokeWithProgressResponseMessage} message
*/
const onMessage = (message) => {
switch (message.type) {
case 'progress':
try {
onProgress(...message.data);
} catch (e) {
// NOP
}
break;
case 'complete':
cleanup();
resolve(message.data);
break;
case 'error':
cleanup();
reject(ExtensionError.deserialize(message.data));
break;
}
};
const onDisconnect = () => {
cleanup();
reject(new Error('Disconnected'));
};
const cleanup = () => {
if (port !== null) {
port.onMessage.removeListener(onMessage);
port.onDisconnect.removeListener(onDisconnect);
port.disconnect();
port = null;
}
};
(async () => {
try {
port = await this._createActionPort(timeout);
port.onMessage.addListener(onMessage);
port.onDisconnect.addListener(onDisconnect);
// Chrome has a maximum message size that can be sent, so longer messages must be fragmented.
const messageString = JSON.stringify({action, params});
const fragmentSize = 1e7; // 10 MB
for (let i = 0, ii = messageString.length; i < ii; i += fragmentSize) {
const data = messageString.substring(i, i + fragmentSize);
port.postMessage(/** @type {import('backend').InvokeWithProgressRequestFragmentMessage} */ ({action: 'fragment', data}));
}
port.postMessage(/** @type {import('backend').InvokeWithProgressRequestInvokeMessage} */ ({action: 'invoke'}));
} catch (e) {
cleanup();
reject(e);
}
})();
});
}
/**
* @template [TReturn=unknown]
* @param {string} action
* @param {import('core').SerializableObject} [params]
* @returns {Promise}
*/
_invoke(action, params={}) {
const data = {action, params};
return new Promise((resolve, reject) => {
try {
this._yomitan.sendMessage(data, (response) => {
this._checkLastError(chrome.runtime.lastError);
if (response !== null && typeof response === 'object') {
if (typeof response.error !== 'undefined') {
reject(ExtensionError.deserialize(response.error));
} else {
resolve(response.result);
}
} else {
const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`;
reject(new Error(`${message} (${JSON.stringify(data)})`));
}
});
} catch (e) {
reject(e);
}
});
}
/**
* @param {chrome.runtime.LastError|undefined} _ignore
*/
_checkLastError(_ignore) {
// NOP
}
}