initial commit

This commit is contained in:
2026-02-09 19:04:19 -08:00
commit f92b57c7b6
531 changed files with 196294 additions and 0 deletions

775
vendor/yomitan/js/comm/anki-connect.js vendored Normal file
View File

@@ -0,0 +1,775 @@
/*
* Copyright (C) 2023-2025 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 <https://www.gnu.org/licenses/>.
*/
import {ExtensionError} from '../core/extension-error.js';
import {parseJson} from '../core/json.js';
import {isObjectNotArray} from '../core/object-utilities.js';
import {getRootDeckName} from '../data/anki-util.js';
/**
* This class controls communication with Anki via the AnkiConnect plugin.
*/
export class AnkiConnect {
/**
* Creates a new instance.
*/
constructor() {
/** @type {boolean} */
this._enabled = false;
/** @type {?string} */
this._server = null;
/** @type {number} */
this._localVersion = 2;
/** @type {number} */
this._remoteVersion = 0;
/** @type {?Promise<number>} */
this._versionCheckPromise = null;
/** @type {?string} */
this._apiKey = null;
}
/**
* Gets the URL of the AnkiConnect server.
* @type {?string}
*/
get server() {
return this._server;
}
/**
* Assigns the URL of the AnkiConnect server.
* @param {string} value The new server URL to assign.
*/
set server(value) {
this._server = value;
}
/**
* Gets whether or not server communication is enabled.
* @type {boolean}
*/
get enabled() {
return this._enabled;
}
/**
* Sets whether or not server communication is enabled.
* @param {boolean} value The enabled state.
*/
set enabled(value) {
this._enabled = value;
}
/**
* Gets the API key used when connecting to AnkiConnect.
* The value will be `null` if no API key is used.
* @type {?string}
*/
get apiKey() {
return this._apiKey;
}
/**
* Sets the API key used when connecting to AnkiConnect.
* @param {?string} value The API key to use, or `null` if no API key should be used.
*/
set apiKey(value) {
this._apiKey = value;
}
/**
* Checks whether a connection to AnkiConnect can be established.
* @returns {Promise<boolean>} `true` if the connection was made, `false` otherwise.
*/
async isConnected() {
try {
await this._getVersion();
return true;
} catch (e) {
return false;
}
}
/**
* Gets the AnkiConnect API version number.
* @returns {Promise<?number>} The version number
*/
async getVersion() {
if (!this._enabled) { return null; }
await this._checkVersion();
return await this._getVersion();
}
/**
* @param {import('anki').Note} note
* @returns {Promise<?import('anki').NoteId>}
*/
async addNote(note) {
if (!this._enabled) { return null; }
await this._checkVersion();
const result = await this._invoke('addNote', {note});
if (result !== null && typeof result !== 'number') {
throw this._createUnexpectedResultError('number|null', result);
}
return result;
}
/**
* @param {import('anki').Note[]} notes
* @returns {Promise<?((number | null)[] | null)>}
*/
async addNotes(notes) {
if (!this._enabled) { return null; }
await this._checkVersion();
const result = await this._invoke('addNotes', {notes});
if (result !== null && !Array.isArray(result)) {
throw this._createUnexpectedResultError('(number | null)[] | null', result);
}
return result;
}
/**
* @param {import('anki').Note} noteWithId
* @returns {Promise<null>}
*/
async updateNoteFields(noteWithId) {
if (!this._enabled) { return null; }
await this._checkVersion();
const result = await this._invoke('updateNoteFields', {note: noteWithId});
if (result !== null) {
throw this._createUnexpectedResultError('null', result);
}
return result;
}
/**
* @param {import('anki').Note[]} notes
* @returns {Promise<boolean[]>}
*/
async canAddNotes(notes) {
if (!this._enabled) { return new Array(notes.length).fill(false); }
await this._checkVersion();
const result = await this._invoke('canAddNotes', {notes});
return this._normalizeArray(result, notes.length, 'boolean');
}
/**
* @param {import('anki').Note[]} notes
* @returns {Promise<import('anki').CanAddNotesDetail[]>}
*/
async canAddNotesWithErrorDetail(notes) {
if (!this._enabled) { return notes.map(() => ({canAdd: false, error: null})); }
await this._checkVersion();
const result = await this._invoke('canAddNotesWithErrorDetail', {notes});
return this._normalizeCanAddNotesWithErrorDetailArray(result, notes.length);
}
/**
* @param {import('anki').NoteId[]} noteIds
* @returns {Promise<(?import('anki').NoteInfo)[]>}
*/
async notesInfo(noteIds) {
if (!this._enabled) { return []; }
await this._checkVersion();
const result = await this._invoke('notesInfo', {notes: noteIds});
return this._normalizeNoteInfoArray(result);
}
/**
* @param {import('anki').CardId[]} cardIds
* @returns {Promise<(?import('anki').CardInfo)[]>}
*/
async cardsInfo(cardIds) {
if (!this._enabled) { return []; }
await this._checkVersion();
const result = await this._invoke('cardsInfo', {cards: cardIds});
return this._normalizeCardInfoArray(result);
}
/**
* @returns {Promise<string[]>}
*/
async getDeckNames() {
if (!this._enabled) { return []; }
await this._checkVersion();
const result = await this._invoke('deckNames', {});
return this._normalizeArray(result, -1, 'string');
}
/**
* @returns {Promise<string[]>}
*/
async getModelNames() {
if (!this._enabled) { return []; }
await this._checkVersion();
const result = await this._invoke('modelNames', {});
return this._normalizeArray(result, -1, 'string');
}
/**
* @param {string} modelName
* @returns {Promise<string[]>}
*/
async getModelFieldNames(modelName) {
if (!this._enabled) { return []; }
await this._checkVersion();
const result = await this._invoke('modelFieldNames', {modelName});
return this._normalizeArray(result, -1, 'string');
}
/**
* @param {string} query
* @returns {Promise<import('anki').CardId[]>}
*/
async guiBrowse(query) {
if (!this._enabled) { return []; }
await this._checkVersion();
const result = await this._invoke('guiBrowse', {query});
return this._normalizeArray(result, -1, 'number');
}
/**
* @param {import('anki').NoteId} noteId
* @returns {Promise<import('anki').CardId[]>}
*/
async guiBrowseNote(noteId) {
return await this.guiBrowse(`nid:${noteId}`);
}
/**
* @param {import('anki').NoteId[]} noteIds
* @returns {Promise<import('anki').CardId[]>}
*/
async guiBrowseNotes(noteIds) {
return await this.guiBrowse(`nid:${noteIds.join(',')}`);
}
/**
* Opens the note editor GUI.
* @param {import('anki').NoteId} noteId The ID of the note.
* @returns {Promise<void>} Nothing is returned.
*/
async guiEditNote(noteId) {
await this._invoke('guiEditNote', {note: noteId});
}
/**
* Stores a file with the specified base64-encoded content inside Anki's media folder.
* @param {string} fileName The name of the file.
* @param {string} content The base64-encoded content of the file.
* @returns {Promise<?string>} The actual file name used to store the file, which may be different; or `null` if the file was not stored.
* @throws {Error} An error is thrown is this object is not enabled.
*/
async storeMediaFile(fileName, content) {
if (!this._enabled) {
throw new Error('AnkiConnect not enabled');
}
await this._checkVersion();
const result = await this._invoke('storeMediaFile', {filename: fileName, data: content});
if (result !== null && typeof result !== 'string') {
throw this._createUnexpectedResultError('string|null', result);
}
return result;
}
/**
* Finds notes matching a query.
* @param {string} query Searches for notes matching a query.
* @returns {Promise<import('anki').NoteId[]>} An array of note IDs.
* @see https://docs.ankiweb.net/searching.html
*/
async findNotes(query) {
if (!this._enabled) { return []; }
await this._checkVersion();
const result = await this._invoke('findNotes', {query});
return this._normalizeArray(result, -1, 'number');
}
/**
* @param {import('anki').Note[]} notes
* @returns {Promise<import('anki').NoteId[][]>}
*/
async findNoteIds(notes) {
if (!this._enabled) { return []; }
await this._checkVersion();
const actions = [];
const actionsTargetsList = [];
/** @type {Map<string, import('anki').NoteId[][]>} */
const actionsTargetsMap = new Map();
/** @type {import('anki').NoteId[][]} */
const allNoteIds = [];
for (const note of notes) {
const query = this._getNoteQuery(note);
let actionsTargets = actionsTargetsMap.get(query);
if (typeof actionsTargets === 'undefined') {
actionsTargets = [];
actionsTargetsList.push(actionsTargets);
actionsTargetsMap.set(query, actionsTargets);
actions.push({action: 'findNotes', params: {query}});
}
/** @type {import('anki').NoteId[]} */
const noteIds = [];
allNoteIds.push(noteIds);
actionsTargets.push(noteIds);
}
const result = await this._invokeMulti(actions);
for (let i = 0, ii = Math.min(result.length, actionsTargetsList.length); i < ii; ++i) {
const noteIds = /** @type {number[]} */ (this._normalizeArray(result[i], -1, 'number'));
for (const actionsTargets of actionsTargetsList[i]) {
for (const noteId of noteIds) {
actionsTargets.push(noteId);
}
}
}
return allNoteIds;
}
/**
* @param {import('anki').CardId[]} cardIds
* @returns {Promise<boolean>}
*/
async suspendCards(cardIds) {
if (!this._enabled) { return false; }
await this._checkVersion();
const result = await this._invoke('suspend', {cards: cardIds});
return typeof result === 'boolean' && result;
}
/**
* @param {string} query
* @returns {Promise<import('anki').CardId[]>}
*/
async findCards(query) {
if (!this._enabled) { return []; }
await this._checkVersion();
const result = await this._invoke('findCards', {query});
return this._normalizeArray(result, -1, 'number');
}
/**
* @param {import('anki').NoteId} noteId
* @returns {Promise<import('anki').CardId[]>}
*/
async findCardsForNote(noteId) {
return await this.findCards(`nid:${noteId}`);
}
/**
* Gets information about the AnkiConnect APIs available.
* @param {string[]} scopes A list of scopes to get information about.
* @param {?string[]} actions A list of actions to check for
* @returns {Promise<import('anki').ApiReflectResult>} Information about the APIs.
*/
async apiReflect(scopes, actions = null) {
const result = await this._invoke('apiReflect', {scopes, actions});
if (!(typeof result === 'object' && result !== null)) {
throw this._createUnexpectedResultError('object', result);
}
const {scopes: resultScopes, actions: resultActions} = /** @type {import('core').SerializableObject} */ (result);
const resultScopes2 = /** @type {string[]} */ (this._normalizeArray(resultScopes, -1, 'string', ', field scopes'));
const resultActions2 = /** @type {string[]} */ (this._normalizeArray(resultActions, -1, 'string', ', field scopes'));
return {
scopes: resultScopes2,
actions: resultActions2,
};
}
/**
* Checks whether a specific API action exists.
* @param {string} action The action to check for.
* @returns {Promise<boolean>} Whether or not the action exists.
*/
async apiExists(action) {
const {actions} = await this.apiReflect(['actions'], [action]);
return actions.includes(action);
}
/**
* Checks if a specific error object corresponds to an unsupported action.
* @param {Error} error An error object generated by an API call.
* @returns {boolean} Whether or not the error indicates the action is not supported.
*/
isErrorUnsupportedAction(error) {
if (error instanceof ExtensionError) {
const {data} = error;
if (typeof data === 'object' && data !== null && /** @type {import('core').SerializableObject} */ (data).apiError === 'unsupported action') {
return true;
}
}
return false;
}
/**
* Makes Anki sync.
* @returns {Promise<?unknown>}
*/
async makeAnkiSync() {
if (!this._enabled) { return null; }
const version = await this._checkVersion();
const result = await this._invoke('sync', {version});
return result === null;
}
// Private
/**
* @returns {Promise<void>}
*/
async _checkVersion() {
if (this._remoteVersion < this._localVersion) {
if (this._versionCheckPromise === null) {
const promise = this._getVersion();
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');
}
}
}
/**
* @param {string} action
* @param {import('core').SerializableObject} params
* @returns {Promise<unknown>}
*/
async _invoke(action, params) {
/** @type {import('anki').MessageBody} */
const body = {action, params, version: this._localVersion};
if (this._apiKey !== null) { body.key = this._apiKey; }
let response;
try {
if (this._server === null) { throw new Error('Server URL is null'); }
response = await fetch(this._server, {
method: 'POST',
mode: 'cors',
cache: 'default',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
body: JSON.stringify(body),
});
} catch (e) {
const error = new ExtensionError('Anki connection failure');
error.data = {action, params, originalError: e};
throw error;
}
if (!response.ok) {
const error = new ExtensionError(`Anki connection error: ${response.status}`);
error.data = {action, params, status: response.status};
throw error;
}
let responseText = null;
/** @type {unknown} */
let result;
try {
responseText = await response.text();
result = parseJson(responseText);
} catch (e) {
const error = new ExtensionError('Invalid Anki response');
error.data = {action, params, status: response.status, responseText, originalError: e};
throw error;
}
if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
const apiError = /** @type {import('core').SerializableObject} */ (result).error;
if (typeof apiError !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const error = new ExtensionError(`Anki error: ${apiError}`);
// eslint-disable-next-line @typescript-eslint/no-base-to-string
error.data = {action, params, status: response.status, apiError: typeof apiError === 'string' ? apiError : `${apiError}`};
throw error;
}
}
return result;
}
/**
* @param {{action: string, params: import('core').SerializableObject}[]} actions
* @returns {Promise<unknown[]>}
*/
async _invokeMulti(actions) {
const result = await this._invoke('multi', {actions});
if (!Array.isArray(result)) {
throw this._createUnexpectedResultError('array', result);
}
return result;
}
/**
* @param {string} text
* @returns {string}
*/
_escapeQuery(text) {
return text.replace(/"/g, '');
}
/**
* @param {import('anki').NoteFields} fields
* @returns {string}
*/
_fieldsToQuery(fields) {
const fieldNames = Object.keys(fields);
if (fieldNames.length === 0) {
return '';
}
const key = fieldNames[0];
return `"${key.toLowerCase()}:${this._escapeQuery(fields[key])}"`;
}
/**
* @param {import('anki').Note} note
* @returns {?('collection'|'deck'|'deck-root')}
*/
_getDuplicateScopeFromNote(note) {
const {options} = note;
if (typeof options === 'object' && options !== null) {
const {duplicateScope} = options;
if (typeof duplicateScope !== 'undefined') {
return duplicateScope;
}
}
return null;
}
/**
* @param {import('anki').Note} note
* @returns {string}
*/
_getNoteQuery(note) {
let query = '';
switch (this._getDuplicateScopeFromNote(note)) {
case 'deck':
query = `"deck:${this._escapeQuery(note.deckName)}" `;
break;
case 'deck-root':
query = `"deck:${this._escapeQuery(getRootDeckName(note.deckName))}" `;
break;
}
query += this._fieldsToQuery(note.fields);
return query;
}
/**
* @returns {Promise<number>}
*/
async _getVersion() {
const version = await this._invoke('version', {});
return typeof version === 'number' ? version : 0;
}
/**
* @param {string} message
* @param {unknown} data
* @returns {ExtensionError}
*/
_createError(message, data) {
const error = new ExtensionError(message);
error.data = data;
return error;
}
/**
* @param {string} expectedType
* @param {unknown} result
* @param {string} [context]
* @returns {ExtensionError}
*/
_createUnexpectedResultError(expectedType, result, context) {
return this._createError(`Unexpected type${typeof context === 'string' ? context : ''}: expected ${expectedType}, received ${this._getTypeName(result)}`, result);
}
/**
* @param {unknown} value
* @returns {string}
*/
_getTypeName(value) {
if (value === null) { return 'null'; }
return Array.isArray(value) ? 'array' : typeof value;
}
/**
* @template [T=unknown]
* @param {unknown} result
* @param {number} expectedCount
* @param {'boolean'|'string'|'number'} type
* @param {string} [context]
* @returns {T[]}
* @throws {Error}
*/
_normalizeArray(result, expectedCount, type, context) {
if (!Array.isArray(result)) {
throw this._createUnexpectedResultError(`${type}[]`, result, context);
}
if (expectedCount < 0) {
expectedCount = result.length;
} else if (expectedCount !== result.length) {
throw this._createError(`Unexpected result array size${context}: expected ${expectedCount}, received ${result.length}`, result);
}
for (let i = 0; i < expectedCount; ++i) {
const item = /** @type {unknown} */ (result[i]);
if (typeof item !== type) {
throw this._createError(`Unexpected result type at index ${i}${context}: expected ${type}, received ${this._getTypeName(item)}`, result);
}
}
return /** @type {T[]} */ (result);
}
/**
* @param {unknown} result
* @returns {(?import('anki').NoteInfo)[]}
* @throws {Error}
*/
_normalizeNoteInfoArray(result) {
if (!Array.isArray(result)) {
throw this._createUnexpectedResultError('array', result, '');
}
/** @type {(?import('anki').NoteInfo)[]} */
const result2 = [];
for (let i = 0, ii = result.length; i < ii; ++i) {
const item = /** @type {unknown} */ (result[i]);
if (item === null || typeof item !== 'object') {
throw this._createError(`Unexpected result type at index ${i}: expected Notes.NoteInfo, received ${this._getTypeName(item)}`, result);
}
const {noteId} = /** @type {{[key: string]: unknown}} */ (item);
if (typeof noteId !== 'number') {
result2.push(null);
continue;
}
const {tags, fields, modelName, cards} = /** @type {{[key: string]: unknown}} */ (item);
if (typeof modelName !== 'string') {
throw this._createError(`Unexpected result type at index ${i}, field modelName: expected string, received ${this._getTypeName(modelName)}`, result);
}
if (!isObjectNotArray(fields)) {
throw this._createError(`Unexpected result type at index ${i}, field fields: expected object, received ${this._getTypeName(fields)}`, result);
}
const tags2 = /** @type {string[]} */ (this._normalizeArray(tags, -1, 'string', ', field tags'));
const cards2 = /** @type {number[]} */ (this._normalizeArray(cards, -1, 'number', ', field cards'));
/** @type {{[key: string]: import('anki').NoteFieldInfo}} */
const fields2 = {};
for (const [key, fieldInfo] of Object.entries(fields)) {
if (!isObjectNotArray(fieldInfo)) { continue; }
const {value, order} = fieldInfo;
if (typeof value !== 'string' || typeof order !== 'number') { continue; }
fields2[key] = {value, order};
}
/** @type {import('anki').NoteInfo} */
const item2 = {
noteId,
tags: tags2,
fields: fields2,
modelName,
cards: cards2,
cardsInfo: [],
};
result2.push(item2);
}
return result2;
}
/**
* Transforms raw AnkiConnect data into the CardInfo type.
* @param {unknown} result
* @returns {(?import('anki').CardInfo)[]}
* @throws {Error}
*/
_normalizeCardInfoArray(result) {
if (!Array.isArray(result)) {
throw this._createUnexpectedResultError('array', result, '');
}
/** @type {(?import('anki').CardInfo)[]} */
const result2 = [];
for (let i = 0, ii = result.length; i < ii; ++i) {
const item = /** @type {unknown} */ (result[i]);
if (item === null || typeof item !== 'object') {
throw this._createError(`Unexpected result type at index ${i}: expected Cards.CardInfo, received ${this._getTypeName(item)}`, result);
}
const {cardId} = /** @type {{[key: string]: unknown}} */ (item);
if (typeof cardId !== 'number') {
result2.push(null);
continue;
}
const {note, flags, queue} = /** @type {{[key: string]: unknown}} */ (item);
if (typeof note !== 'number') {
result2.push(null);
continue;
}
/** @type {import('anki').CardInfo} */
const item2 = {
noteId: note,
cardId,
flags: typeof flags === 'number' ? flags : 0,
cardState: typeof queue === 'number' ? queue : 0,
};
result2.push(item2);
}
return result2;
}
/**
* @param {unknown} result
* @param {number} expectedCount
* @returns {import('anki').CanAddNotesDetail[]}
* @throws {Error}
*/
_normalizeCanAddNotesWithErrorDetailArray(result, expectedCount) {
if (!Array.isArray(result)) {
throw this._createUnexpectedResultError('array', result, '');
}
if (expectedCount !== result.length) {
throw this._createError(`Unexpected result array size: expected ${expectedCount}, received ${result.length}`, result);
}
/** @type {import('anki').CanAddNotesDetail[]} */
const result2 = [];
for (let i = 0; i < expectedCount; ++i) {
const item = /** @type {unknown} */ (result[i]);
if (item === null || typeof item !== 'object') {
throw this._createError(`Unexpected result type at index ${i}: expected object, received ${this._getTypeName(item)}`, result);
}
const {canAdd, error} = /** @type {{[key: string]: unknown}} */ (item);
if (typeof canAdd !== 'boolean') {
throw this._createError(`Unexpected result type at index ${i}, field canAdd: expected boolean, received ${this._getTypeName(canAdd)}`, result);
}
const item2 = {
canAdd: canAdd,
error: typeof error === 'string' ? error : null,
};
result2.push(item2);
}
return result2;
}
}

498
vendor/yomitan/js/comm/api.js vendored Normal file
View File

@@ -0,0 +1,498 @@
/*
* Copyright (C) 2023-2025 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 <https://www.gnu.org/licenses/>.
*/
import {ExtensionError} from '../core/extension-error.js';
import {log} from '../core/log.js';
export class API {
/**
* @param {import('../extension/web-extension.js').WebExtension} webExtension
* @param {Worker?} mediaDrawingWorker
* @param {MessagePort?} backendPort
*/
constructor(webExtension, mediaDrawingWorker = null, backendPort = null) {
/** @type {import('../extension/web-extension.js').WebExtension} */
this._webExtension = webExtension;
/** @type {Worker?} */
this._mediaDrawingWorker = mediaDrawingWorker;
/** @type {MessagePort?} */
this._backendPort = backendPort;
}
/**
* @param {import('api').ApiParam<'optionsGet', 'optionsContext'>} optionsContext
* @returns {Promise<import('api').ApiReturn<'optionsGet'>>}
*/
optionsGet(optionsContext) {
return this._invoke('optionsGet', {optionsContext});
}
/**
* @returns {Promise<import('api').ApiReturn<'optionsGetFull'>>}
*/
optionsGetFull() {
return this._invoke('optionsGetFull', void 0);
}
/**
* @param {import('api').ApiParam<'termsFind', 'text'>} text
* @param {import('api').ApiParam<'termsFind', 'details'>} details
* @param {import('api').ApiParam<'termsFind', 'optionsContext'>} optionsContext
* @returns {Promise<import('api').ApiReturn<'termsFind'>>}
*/
termsFind(text, details, optionsContext) {
return this._invoke('termsFind', {text, details, optionsContext});
}
/**
* @param {import('api').ApiParam<'parseText', 'text'>} text
* @param {import('api').ApiParam<'parseText', 'optionsContext'>} optionsContext
* @param {import('api').ApiParam<'parseText', 'scanLength'>} scanLength
* @param {import('api').ApiParam<'parseText', 'useInternalParser'>} useInternalParser
* @param {import('api').ApiParam<'parseText', 'useMecabParser'>} useMecabParser
* @returns {Promise<import('api').ApiReturn<'parseText'>>}
*/
parseText(text, optionsContext, scanLength, useInternalParser, useMecabParser) {
return this._invoke('parseText', {text, optionsContext, scanLength, useInternalParser, useMecabParser});
}
/**
* @param {import('api').ApiParam<'kanjiFind', 'text'>} text
* @param {import('api').ApiParam<'kanjiFind', 'optionsContext'>} optionsContext
* @returns {Promise<import('api').ApiReturn<'kanjiFind'>>}
*/
kanjiFind(text, optionsContext) {
return this._invoke('kanjiFind', {text, optionsContext});
}
/**
* @returns {Promise<import('api').ApiReturn<'isAnkiConnected'>>}
*/
isAnkiConnected() {
return this._invoke('isAnkiConnected', void 0);
}
/**
* @returns {Promise<import('api').ApiReturn<'getAnkiConnectVersion'>>}
*/
getAnkiConnectVersion() {
return this._invoke('getAnkiConnectVersion', void 0);
}
/**
* @param {import('api').ApiParam<'addAnkiNote', 'note'>} note
* @returns {Promise<import('api').ApiReturn<'addAnkiNote'>>}
*/
addAnkiNote(note) {
return this._invoke('addAnkiNote', {note});
}
/**
* @param {import('api').ApiParam<'updateAnkiNote', 'noteWithId'>} noteWithId
* @returns {Promise<import('api').ApiReturn<'updateAnkiNote'>>}
*/
updateAnkiNote(noteWithId) {
return this._invoke('updateAnkiNote', {noteWithId});
}
/**
* @param {import('api').ApiParam<'getAnkiNoteInfo', 'notes'>} notes
* @param {import('api').ApiParam<'getAnkiNoteInfo', 'fetchAdditionalInfo'>} fetchAdditionalInfo
* @returns {Promise<import('api').ApiReturn<'getAnkiNoteInfo'>>}
*/
getAnkiNoteInfo(notes, fetchAdditionalInfo) {
return this._invoke('getAnkiNoteInfo', {notes, fetchAdditionalInfo});
}
/**
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'timestamp'>} timestamp
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'definitionDetails'>} definitionDetails
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'audioDetails'>} audioDetails
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'screenshotDetails'>} screenshotDetails
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'clipboardDetails'>} clipboardDetails
* @param {import('api').ApiParam<'injectAnkiNoteMedia', 'dictionaryMediaDetails'>} dictionaryMediaDetails
* @returns {Promise<import('api').ApiReturn<'injectAnkiNoteMedia'>>}
*/
injectAnkiNoteMedia(timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails) {
return this._invoke('injectAnkiNoteMedia', {timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails});
}
/**
* @param {import('api').ApiParam<'viewNotes', 'noteIds'>} noteIds
* @param {import('api').ApiParam<'viewNotes', 'mode'>} mode
* @param {import('api').ApiParam<'viewNotes', 'allowFallback'>} allowFallback
* @returns {Promise<import('api').ApiReturn<'viewNotes'>>}
*/
viewNotes(noteIds, mode, allowFallback) {
return this._invoke('viewNotes', {noteIds, mode, allowFallback});
}
/**
* @param {import('api').ApiParam<'suspendAnkiCardsForNote', 'noteId'>} noteId
* @returns {Promise<import('api').ApiReturn<'suspendAnkiCardsForNote'>>}
*/
suspendAnkiCardsForNote(noteId) {
return this._invoke('suspendAnkiCardsForNote', {noteId});
}
/**
* @param {import('api').ApiParam<'getTermAudioInfoList', 'source'>} source
* @param {import('api').ApiParam<'getTermAudioInfoList', 'term'>} term
* @param {import('api').ApiParam<'getTermAudioInfoList', 'reading'>} reading
* @param {import('api').ApiParam<'getTermAudioInfoList', 'languageSummary'>} languageSummary
* @returns {Promise<import('api').ApiReturn<'getTermAudioInfoList'>>}
*/
getTermAudioInfoList(source, term, reading, languageSummary) {
return this._invoke('getTermAudioInfoList', {source, term, reading, languageSummary});
}
/**
* @param {import('api').ApiParam<'commandExec', 'command'>} command
* @param {import('api').ApiParam<'commandExec', 'params'>} [params]
* @returns {Promise<import('api').ApiReturn<'commandExec'>>}
*/
commandExec(command, params) {
return this._invoke('commandExec', {command, params});
}
/**
* @param {import('api').ApiParam<'sendMessageToFrame', 'frameId'>} frameId
* @param {import('api').ApiParam<'sendMessageToFrame', 'message'>} message
* @returns {Promise<import('api').ApiReturn<'sendMessageToFrame'>>}
*/
sendMessageToFrame(frameId, message) {
return this._invoke('sendMessageToFrame', {frameId, message});
}
/**
* @param {import('api').ApiParam<'broadcastTab', 'message'>} message
* @returns {Promise<import('api').ApiReturn<'broadcastTab'>>}
*/
broadcastTab(message) {
return this._invoke('broadcastTab', {message});
}
/**
* @returns {Promise<import('api').ApiReturn<'frameInformationGet'>>}
*/
frameInformationGet() {
return this._invoke('frameInformationGet', void 0);
}
/**
* @param {import('api').ApiParam<'injectStylesheet', 'type'>} type
* @param {import('api').ApiParam<'injectStylesheet', 'value'>} value
* @returns {Promise<import('api').ApiReturn<'injectStylesheet'>>}
*/
injectStylesheet(type, value) {
return this._invoke('injectStylesheet', {type, value});
}
/**
* @param {import('api').ApiParam<'getStylesheetContent', 'url'>} url
* @returns {Promise<import('api').ApiReturn<'getStylesheetContent'>>}
*/
getStylesheetContent(url) {
return this._invoke('getStylesheetContent', {url});
}
/**
* @returns {Promise<import('api').ApiReturn<'getEnvironmentInfo'>>}
*/
getEnvironmentInfo() {
return this._invoke('getEnvironmentInfo', void 0);
}
/**
* @returns {Promise<import('api').ApiReturn<'clipboardGet'>>}
*/
clipboardGet() {
return this._invoke('clipboardGet', void 0);
}
/**
* @returns {Promise<import('api').ApiReturn<'getZoom'>>}
*/
getZoom() {
return this._invoke('getZoom', void 0);
}
/**
* @returns {Promise<import('api').ApiReturn<'getDefaultAnkiFieldTemplates'>>}
*/
getDefaultAnkiFieldTemplates() {
return this._invoke('getDefaultAnkiFieldTemplates', void 0);
}
/**
* @returns {Promise<import('api').ApiReturn<'getDictionaryInfo'>>}
*/
getDictionaryInfo() {
return this._invoke('getDictionaryInfo', void 0);
}
/**
* @returns {Promise<import('api').ApiReturn<'purgeDatabase'>>}
*/
purgeDatabase() {
return this._invoke('purgeDatabase', void 0);
}
/**
* @param {import('api').ApiParam<'getMedia', 'targets'>} targets
* @returns {Promise<import('api').ApiReturn<'getMedia'>>}
*/
getMedia(targets) {
return this._invoke('getMedia', {targets});
}
/**
* @param {import('api').PmApiParam<'drawMedia', 'requests'>} requests
* @param {Transferable[]} transferables
*/
drawMedia(requests, transferables) {
this._mediaDrawingWorker?.postMessage({action: 'drawMedia', params: {requests}}, transferables);
}
/**
* @param {import('api').ApiParam<'logGenericErrorBackend', 'error'>} error
* @param {import('api').ApiParam<'logGenericErrorBackend', 'level'>} level
* @param {import('api').ApiParam<'logGenericErrorBackend', 'context'>} context
* @returns {Promise<import('api').ApiReturn<'logGenericErrorBackend'>>}
*/
logGenericErrorBackend(error, level, context) {
return this._invoke('logGenericErrorBackend', {error, level, context});
}
/**
* @returns {Promise<import('api').ApiReturn<'logIndicatorClear'>>}
*/
logIndicatorClear() {
return this._invoke('logIndicatorClear', void 0);
}
/**
* @param {import('api').ApiParam<'modifySettings', 'targets'>} targets
* @param {import('api').ApiParam<'modifySettings', 'source'>} source
* @returns {Promise<import('api').ApiReturn<'modifySettings'>>}
*/
modifySettings(targets, source) {
return this._invoke('modifySettings', {targets, source});
}
/**
* @param {import('api').ApiParam<'getSettings', 'targets'>} targets
* @returns {Promise<import('api').ApiReturn<'getSettings'>>}
*/
getSettings(targets) {
return this._invoke('getSettings', {targets});
}
/**
* @param {import('api').ApiParam<'setAllSettings', 'value'>} value
* @param {import('api').ApiParam<'setAllSettings', 'source'>} source
* @returns {Promise<import('api').ApiReturn<'setAllSettings'>>}
*/
setAllSettings(value, source) {
return this._invoke('setAllSettings', {value, source});
}
/**
* @param {import('api').ApiParams<'getOrCreateSearchPopup'>} details
* @returns {Promise<import('api').ApiReturn<'getOrCreateSearchPopup'>>}
*/
getOrCreateSearchPopup(details) {
return this._invoke('getOrCreateSearchPopup', details);
}
/**
* @param {import('api').ApiParam<'isTabSearchPopup', 'tabId'>} tabId
* @returns {Promise<import('api').ApiReturn<'isTabSearchPopup'>>}
*/
isTabSearchPopup(tabId) {
return this._invoke('isTabSearchPopup', {tabId});
}
/**
* @param {import('api').ApiParam<'triggerDatabaseUpdated', 'type'>} type
* @param {import('api').ApiParam<'triggerDatabaseUpdated', 'cause'>} cause
* @returns {Promise<import('api').ApiReturn<'triggerDatabaseUpdated'>>}
*/
triggerDatabaseUpdated(type, cause) {
return this._invoke('triggerDatabaseUpdated', {type, cause});
}
/**
* @returns {Promise<import('api').ApiReturn<'testMecab'>>}
*/
testMecab() {
return this._invoke('testMecab', void 0);
}
/**
* @param {string} url
* @returns {Promise<import('api').ApiReturn<'testYomitanApi'>>}
*/
testYomitanApi(url) {
return this._invoke('testYomitanApi', {url});
}
/**
* @param {import('api').ApiParam<'isTextLookupWorthy', 'text'>} text
* @param {import('api').ApiParam<'isTextLookupWorthy', 'language'>} language
* @returns {Promise<import('api').ApiReturn<'isTextLookupWorthy'>>}
*/
isTextLookupWorthy(text, language) {
return this._invoke('isTextLookupWorthy', {text, language});
}
/**
* @param {import('api').ApiParam<'getTermFrequencies', 'termReadingList'>} termReadingList
* @param {import('api').ApiParam<'getTermFrequencies', 'dictionaries'>} dictionaries
* @returns {Promise<import('api').ApiReturn<'getTermFrequencies'>>}
*/
getTermFrequencies(termReadingList, dictionaries) {
return this._invoke('getTermFrequencies', {termReadingList, dictionaries});
}
/**
* @param {import('api').ApiParam<'findAnkiNotes', 'query'>} query
* @returns {Promise<import('api').ApiReturn<'findAnkiNotes'>>}
*/
findAnkiNotes(query) {
return this._invoke('findAnkiNotes', {query});
}
/**
* @param {import('api').ApiParam<'openCrossFramePort', 'targetTabId'>} targetTabId
* @param {import('api').ApiParam<'openCrossFramePort', 'targetFrameId'>} targetFrameId
* @returns {Promise<import('api').ApiReturn<'openCrossFramePort'>>}
*/
openCrossFramePort(targetTabId, targetFrameId) {
return this._invoke('openCrossFramePort', {targetTabId, targetFrameId});
}
/**
* This is used to keep the background page alive on Firefox MV3, as it does not support offscreen.
* The reason that backend persistency is required on FF is actually different from the reason it's required on Chromium --
* on Chromium, persistency (which we achieve via the offscreen page, not via this heartbeat) is required because the load time
* for the IndexedDB is incredibly long, which makes the first lookup after the extension sleeps take one minute+, which is
* not acceptable. However, on Firefox, the database is backed by sqlite and starts very fast. Instead, the problem is that the
* media-drawing-worker on the frontend holds a MessagePort to the database-worker on the backend, which closes when the extension
* sleeps, because the database-worker is killed and currently there is no way to detect a closed port due to
* https://github.com/whatwg/html/issues/1766 / https://github.com/whatwg/html/issues/10201
*
* So this is our only choice. We can remove this once there is a way to gracefully detect the closed MessagePort and rebuild it.
* @returns {Promise<import('api').ApiReturn<'heartbeat'>>}
*/
heartbeat() {
return this._invoke('heartbeat', void 0);
}
/**
* @param {Transferable[]} transferables
*/
registerOffscreenPort(transferables) {
this._pmInvoke('registerOffscreenPort', void 0, transferables);
}
/**
* @param {MessagePort} port
*/
connectToDatabaseWorker(port) {
this._pmInvoke('connectToDatabaseWorker', void 0, [port]);
}
/**
* @returns {Promise<import('api').ApiReturn<'getLanguageSummaries'>>}
*/
getLanguageSummaries() {
return this._invoke('getLanguageSummaries', void 0);
}
/**
* @returns {Promise<import('api').ApiReturn<'forceSync'>>}
*/
forceSync() {
return this._invoke('forceSync', void 0);
}
// Utilities
/**
* @template {import('api').ApiNames} TAction
* @template {import('api').ApiParams<TAction>} TParams
* @param {TAction} action
* @param {TParams} params
* @returns {Promise<import('api').ApiReturn<TAction>>}
*/
_invoke(action, params) {
/** @type {import('api').ApiMessage<TAction>} */
const data = {action, params};
return new Promise((resolve, reject) => {
try {
this._webExtension.sendMessage(data, (response) => {
this._webExtension.getLastError();
if (response !== null && typeof response === 'object') {
const {error} = /** @type {import('core').UnknownObject} */ (response);
if (typeof error !== 'undefined') {
reject(ExtensionError.deserialize(/** @type {import('core').SerializedError} */(error)));
} else {
const {result} = /** @type {import('core').UnknownObject} */ (response);
resolve(/** @type {import('api').ApiReturn<TAction>} */(result));
}
} else {
const message = response === null ? 'Unexpected null response. You may need to refresh the page.' : `Unexpected response of type ${typeof response}. You may need to refresh the page.`;
reject(new Error(`${message} (${JSON.stringify(data)})`));
}
});
} catch (e) {
reject(e);
}
});
}
/**
* @template {import('api').PmApiNames} TAction
* @template {import('api').PmApiParams<TAction>} TParams
* @param {TAction} action
* @param {TParams} params
* @param {Transferable[]} transferables
*/
_pmInvoke(action, params, transferables) {
// on firefox, there is no service worker, so we instead use a MessageChannel which is established
// via a handshake via a SharedWorker
if (!('serviceWorker' in navigator)) {
if (this._backendPort === null) {
log.error('no backend port available');
return;
}
this._backendPort.postMessage({action, params}, transferables);
} else {
void navigator.serviceWorker.ready.then((serviceWorkerRegistration) => {
if (serviceWorkerRegistration.active !== null) {
serviceWorkerRegistration.active.postMessage({action, params}, transferables);
} else {
log.error(`[${self.constructor.name}] no active service worker`);
}
});
}
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-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 <https://www.gnu.org/licenses/>.
*/
import {EventDispatcher} from '../core/event-dispatcher.js';
/**
* @augments EventDispatcher<import('clipboard-monitor').Events>
*/
export class ClipboardMonitor extends EventDispatcher {
/**
* @param {import('clipboard-monitor').ClipboardReaderLike} clipboardReader
*/
constructor(clipboardReader) {
super();
/** @type {import('clipboard-monitor').ClipboardReaderLike} */
this._clipboardReader = clipboardReader;
/** @type {?import('core').Timeout} */
this._timerId = null;
/** @type {?import('core').TokenObject} */
this._timerToken = null;
/** @type {number} */
this._interval = 250;
/** @type {?string} */
this._previousText = null;
}
/**
* @returns {void}
*/
start() {
this.stop();
let canChange = false;
/**
* This token 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.
* @type {?import('core').TokenObject}
*/
const token = {};
const intervalCallback = async () => {
this._timerId = null;
let text = null;
try {
text = await this._clipboardReader.getText(false);
} 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.trigger('change', {text});
}
}
canChange = true;
this._timerId = setTimeout(intervalCallback, this._interval);
};
this._timerToken = token;
void intervalCallback();
}
/**
* @returns {void}
*/
stop() {
this._timerToken = null;
this._previousText = null;
if (this._timerId !== null) {
clearTimeout(this._timerId);
this._timerId = null;
}
}
/**
* @param {?string} text
*/
setPreviousText(text) {
this._previousText = text;
}
}

View File

@@ -0,0 +1,225 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-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 <https://www.gnu.org/licenses/>.
*/
import {getFileExtensionFromImageMediaType} from '../media/media-util.js';
/**
* Class which can read text and images from the clipboard.
*/
export class ClipboardReader {
/**
* @param {?Document} document
* @param {?string} pasteTargetSelector
* @param {?string} richContentPasteTargetSelector
*/
constructor(document, pasteTargetSelector, richContentPasteTargetSelector) {
/** @type {?Document} */
this._document = document;
/** @type {?import('environment').Browser} */
this._browser = null;
/** @type {?HTMLTextAreaElement} */
this._pasteTarget = null;
/** @type {?string} */
this._pasteTargetSelector = pasteTargetSelector;
/** @type {?HTMLElement} */
this._richContentPasteTarget = null;
/** @type {?string} */
this._richContentPasteTargetSelector = richContentPasteTargetSelector;
}
/**
* Gets the browser being used.
* @type {?import('environment').Browser}
*/
get browser() {
return this._browser;
}
/**
* Assigns the browser being used.
*/
set browser(value) {
this._browser = value;
}
/**
* Gets the text in the clipboard.
* @param {boolean} useRichText Whether or not to use rich text for pasting, when possible.
* @returns {Promise<string>} A string containing the clipboard text.
* @throws {Error} Error if not supported.
*/
async getText(useRichText) {
/*
Notes:
document.execCommand('paste') sometimes 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() && !useRichText) {
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');
}
if (useRichText) {
const target = this._getRichContentPasteTarget();
target.focus();
document.execCommand('paste');
const result = /** @type {string} */ (target.textContent);
this._clearRichContent(target);
return result;
} else {
const target = this._getPasteTarget();
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 {Promise<?string>} A string containing a data URL of the image file, or null if no image was found.
* @throws {Error} Error if not supported.
*/
async getImage() {
// See browser-specific notes in getText
if (
this._isFirefox() &&
typeof navigator.clipboard !== 'undefined' &&
typeof navigator.clipboard.read === 'function'
) {
// This function is behind the Firefox flag: dom.events.asyncClipboard.read
// See: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/read#browser_compatibility
let items;
try {
items = await navigator.clipboard.read();
} catch (e) {
return null;
}
for (const item of items) {
for (const type of item.types) {
if (!getFileExtensionFromImageMediaType(type)) { continue; }
try {
const blob = await item.getType(type);
return await this._readFileAsDataURL(blob);
} catch (e) {
// NOP
}
}
}
return null;
}
const document = this._document;
if (document === null) {
throw new Error('Clipboard reading not supported in this context');
}
const target = this._getRichContentPasteTarget();
target.focus();
document.execCommand('paste');
const image = target.querySelector('img[src^="data:"]');
const result = (image !== null ? image.getAttribute('src') : null);
this._clearRichContent(target);
return result;
}
// Private
/**
* @returns {boolean}
*/
_isFirefox() {
return (this._browser === 'firefox' || this._browser === 'firefox-mobile');
}
/**
* @param {Blob} file
* @returns {Promise<string>}
*/
_readFileAsDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(/** @type {string} */ (reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
/**
* @returns {HTMLTextAreaElement}
*/
_getPasteTarget() {
if (this._pasteTarget === null) {
this._pasteTarget = /** @type {HTMLTextAreaElement} */ (this._findPasteTarget(this._pasteTargetSelector));
}
return this._pasteTarget;
}
/**
* @returns {HTMLElement}
*/
_getRichContentPasteTarget() {
if (this._richContentPasteTarget === null) {
this._richContentPasteTarget = /** @type {HTMLElement} */ (this._findPasteTarget(this._richContentPasteTargetSelector));
}
return this._richContentPasteTarget;
}
/**
* @template {Element} T
* @param {?string} selector
* @returns {T}
* @throws {Error}
*/
_findPasteTarget(selector) {
if (selector === null) { throw new Error('Invalid selector'); }
const target = this._document !== null ? this._document.querySelector(selector) : null;
if (target === null) { throw new Error('Clipboard paste target does not exist'); }
return /** @type {T} */ (target);
}
/**
* @param {HTMLElement} element
*/
_clearRichContent(element) {
for (const image of element.querySelectorAll('img')) {
image.removeAttribute('src');
image.removeAttribute('srcset');
}
element.textContent = '';
}
}

View File

@@ -0,0 +1,487 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-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 <https://www.gnu.org/licenses/>.
*/
import {extendApiMap, invokeApiMapHandler} from '../core/api-map.js';
import {EventDispatcher} from '../core/event-dispatcher.js';
import {EventListenerCollection} from '../core/event-listener-collection.js';
import {ExtensionError} from '../core/extension-error.js';
import {parseJson} from '../core/json.js';
import {log} from '../core/log.js';
import {safePerformance} from '../core/safe-performance.js';
/**
* @augments EventDispatcher<import('cross-frame-api').CrossFrameAPIPortEvents>
*/
export class CrossFrameAPIPort extends EventDispatcher {
/**
* @param {number} otherTabId
* @param {number} otherFrameId
* @param {chrome.runtime.Port} port
* @param {import('cross-frame-api').ApiMap} apiMap
*/
constructor(otherTabId, otherFrameId, port, apiMap) {
super();
/** @type {number} */
this._otherTabId = otherTabId;
/** @type {number} */
this._otherFrameId = otherFrameId;
/** @type {?chrome.runtime.Port} */
this._port = port;
/** @type {import('cross-frame-api').ApiMap} */
this._apiMap = apiMap;
/** @type {Map<number, import('cross-frame-api').Invocation>} */
this._activeInvocations = new Map();
/** @type {number} */
this._invocationId = 0;
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
}
/** @type {number} */
get otherTabId() {
return this._otherTabId;
}
/** @type {number} */
get otherFrameId() {
return this._otherFrameId;
}
/**
* @throws {Error}
*/
prepare() {
if (this._port === null) { throw new Error('Invalid state'); }
this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this));
this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this));
this._eventListeners.addEventListener(window, 'pageshow', this._onPageShow.bind(this));
this._eventListeners.addEventListener(document, 'resume', this._onResume.bind(this));
}
/**
* @template {import('cross-frame-api').ApiNames} TName
* @param {TName} action
* @param {import('cross-frame-api').ApiParams<TName>} params
* @param {number} ackTimeout
* @param {number} responseTimeout
* @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
*/
invoke(action, params, ackTimeout, responseTimeout) {
return new Promise((resolve, reject) => {
if (this._port === null) {
reject(new Error(`Port is disconnected (${action})`));
return;
}
const id = this._invocationId++;
/** @type {import('cross-frame-api').Invocation} */
const invocation = {
id,
resolve,
reject,
responseTimeout,
action,
ack: false,
timer: null,
};
this._activeInvocations.set(id, invocation);
if (ackTimeout !== null) {
try {
invocation.timer = setTimeout(() => this._onError(id, 'Acknowledgement timeout'), ackTimeout);
} catch (e) {
this._onError(id, 'Failed to set timeout');
return;
}
}
safePerformance.mark(`cross-frame-api:invoke:${action}`);
try {
this._port.postMessage(/** @type {import('cross-frame-api').InvokeMessage} */ ({type: 'invoke', id, data: {action, params}}));
} catch (e) {
this._onError(id, e);
}
});
}
/** */
disconnect() {
this._onDisconnect();
}
// Private
/**
* @param {Event} e
*/
_onResume(e) {
// Page Resumed after being frozen
log.log('Yomitan cross frame reset. Resuming after page frozen.', e);
this._onDisconnect();
}
/**
* @param {PageTransitionEvent} e
*/
_onPageShow(e) {
// Page restored from BFCache
if (e.persisted) {
log.log('Yomitan cross frame reset. Page restored from BFCache.', e);
this._onDisconnect();
}
}
/** */
_onDisconnect() {
if (this._port === null) { return; }
this._eventListeners.removeAllEventListeners();
this._port = null;
for (const id of this._activeInvocations.keys()) {
this._onError(id, 'Disconnected');
}
this.trigger('disconnect', this);
}
/**
* @param {import('cross-frame-api').Message} details
*/
_onMessage(details) {
const {type, id} = details;
switch (type) {
case 'invoke':
this._onInvoke(id, details.data);
break;
case 'ack':
this._onAck(id);
break;
case 'result':
this._onResult(id, details.data);
break;
}
}
// Response handlers
/**
* @param {number} id
*/
_onAck(id) {
const invocation = this._activeInvocations.get(id);
if (typeof invocation === 'undefined') {
log.warn(new Error(`Request ${id} not found for acknowledgement`));
return;
}
if (invocation.ack) {
this._onError(id, `Request ${id} already acknowledged`);
return;
}
invocation.ack = true;
if (invocation.timer !== null) {
clearTimeout(invocation.timer);
invocation.timer = null;
}
const responseTimeout = invocation.responseTimeout;
if (responseTimeout !== null) {
try {
invocation.timer = setTimeout(() => this._onError(id, 'Response timeout'), responseTimeout);
} catch (e) {
this._onError(id, 'Failed to set timeout');
}
}
}
/**
* @param {number} id
* @param {import('core').Response<import('cross-frame-api').ApiReturnAny>} data
*/
_onResult(id, data) {
const invocation = this._activeInvocations.get(id);
if (typeof invocation === 'undefined') {
log.warn(new Error(`Request ${id} not found`));
return;
}
if (!invocation.ack) {
this._onError(id, `Request ${id} not acknowledged`);
return;
}
this._activeInvocations.delete(id);
if (invocation.timer !== null) {
clearTimeout(invocation.timer);
invocation.timer = null;
}
const error = data.error;
if (typeof error !== 'undefined') {
invocation.reject(ExtensionError.deserialize(error));
} else {
invocation.resolve(data.result);
}
}
/**
* @param {number} id
* @param {unknown} errorOrMessage
*/
_onError(id, errorOrMessage) {
const invocation = this._activeInvocations.get(id);
if (typeof invocation === 'undefined') { return; }
const error = errorOrMessage instanceof Error ? errorOrMessage : new Error(`${errorOrMessage} (${invocation.action})`);
this._activeInvocations.delete(id);
if (invocation.timer !== null) {
clearTimeout(invocation.timer);
invocation.timer = null;
}
invocation.reject(error);
}
// Invocation
/**
* @param {number} id
* @param {import('cross-frame-api').ApiMessageAny} details
*/
_onInvoke(id, {action, params}) {
this._sendAck(id);
invokeApiMapHandler(
this._apiMap,
action,
params,
[],
(data) => this._sendResult(id, data),
() => this._sendError(id, new Error(`Unknown action: ${action}`)),
);
}
/**
* @param {import('cross-frame-api').Message} data
*/
_sendResponse(data) {
if (this._port === null) { return; }
try {
this._port.postMessage(data);
} catch (e) {
// NOP
}
}
/**
* @param {number} id
*/
_sendAck(id) {
this._sendResponse({type: 'ack', id});
}
/**
* @param {number} id
* @param {import('core').Response<import('cross-frame-api').ApiReturnAny>} data
*/
_sendResult(id, data) {
this._sendResponse({type: 'result', id, data});
}
/**
* @param {number} id
* @param {Error} error
*/
_sendError(id, error) {
this._sendResponse({type: 'result', id, data: {error: ExtensionError.serialize(error)}});
}
}
export class CrossFrameAPI {
/**
* @param {import('../comm/api.js').API} api
* @param {?number} tabId
* @param {?number} frameId
*/
constructor(api, tabId, frameId) {
/** @type {import('../comm/api.js').API} */
this._api = api;
/** @type {number} */
this._ackTimeout = 3000; // 3 seconds
/** @type {number} */
this._responseTimeout = 10000; // 10 seconds
/** @type {Map<number, Map<number, CrossFrameAPIPort>>} */
this._commPorts = new Map();
/** @type {import('cross-frame-api').ApiMap} */
this._apiMap = new Map();
/** @type {(port: CrossFrameAPIPort) => void} */
this._onDisconnectBind = this._onDisconnect.bind(this);
/** @type {?number} */
this._tabId = tabId;
/** @type {?number} */
this._frameId = frameId;
}
/**
* @type {?number}
*/
get tabId() {
return this._tabId;
}
/**
* @type {?number}
*/
get frameId() {
return this._frameId;
}
/** */
prepare() {
chrome.runtime.onConnect.addListener(this._onConnect.bind(this));
}
/**
* @template {import('cross-frame-api').ApiNames} TName
* @param {number} targetFrameId
* @param {TName} action
* @param {import('cross-frame-api').ApiParams<TName>} params
* @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
*/
invoke(targetFrameId, action, params) {
return this.invokeTab(null, targetFrameId, action, params);
}
/**
* @template {import('cross-frame-api').ApiNames} TName
* @param {?number} targetTabId
* @param {number} targetFrameId
* @param {TName} action
* @param {import('cross-frame-api').ApiParams<TName>} params
* @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
*/
async invokeTab(targetTabId, targetFrameId, action, params) {
if (typeof targetTabId !== 'number') {
targetTabId = this._tabId;
if (typeof targetTabId !== 'number') {
throw new Error('Unknown target tab id for invocation');
}
}
const commPort = await this._getOrCreateCommPort(targetTabId, targetFrameId);
return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout);
}
/**
* @param {import('cross-frame-api').ApiMapInit} handlers
*/
registerHandlers(handlers) {
extendApiMap(this._apiMap, handlers);
}
// Private
/**
* @param {chrome.runtime.Port} port
*/
_onConnect(port) {
try {
/** @type {import('cross-frame-api').PortDetails} */
let details;
try {
details = parseJson(port.name);
} catch (e) {
return;
}
if (details.name !== 'cross-frame-communication-port') { return; }
const otherTabId = details.otherTabId;
const otherFrameId = details.otherFrameId;
this._setupCommPort(otherTabId, otherFrameId, port);
} catch (e) {
port.disconnect();
log.error(e);
}
}
/**
* @param {CrossFrameAPIPort} commPort
*/
_onDisconnect(commPort) {
commPort.off('disconnect', this._onDisconnectBind);
const {otherTabId, otherFrameId} = commPort;
const tabPorts = this._commPorts.get(otherTabId);
if (typeof tabPorts !== 'undefined') {
tabPorts.delete(otherFrameId);
if (tabPorts.size === 0) {
this._commPorts.delete(otherTabId);
}
}
}
/**
* @param {number} otherTabId
* @param {number} otherFrameId
* @returns {Promise<CrossFrameAPIPort>}
*/
async _getOrCreateCommPort(otherTabId, otherFrameId) {
const tabPorts = this._commPorts.get(otherTabId);
if (typeof tabPorts !== 'undefined') {
const commPort = tabPorts.get(otherFrameId);
if (typeof commPort !== 'undefined') {
return commPort;
}
}
return await this._createCommPort(otherTabId, otherFrameId);
}
/**
* @param {number} otherTabId
* @param {number} otherFrameId
* @returns {Promise<CrossFrameAPIPort>}
*/
async _createCommPort(otherTabId, otherFrameId) {
await this._api.openCrossFramePort(otherTabId, otherFrameId);
const tabPorts = this._commPorts.get(otherTabId);
if (typeof tabPorts !== 'undefined') {
const commPort = tabPorts.get(otherFrameId);
if (typeof commPort !== 'undefined') {
return commPort;
}
}
throw new Error('Comm port didn\'t open');
}
/**
* @param {number} otherTabId
* @param {number} otherFrameId
* @param {chrome.runtime.Port} port
* @returns {CrossFrameAPIPort}
*/
_setupCommPort(otherTabId, otherFrameId, port) {
const commPort = new CrossFrameAPIPort(otherTabId, otherFrameId, port, this._apiMap);
let tabPorts = this._commPorts.get(otherTabId);
if (typeof tabPorts === 'undefined') {
tabPorts = new Map();
this._commPorts.set(otherTabId, tabPorts);
}
tabPorts.set(otherFrameId, commPort);
commPort.prepare();
commPort.on('disconnect', this._onDisconnectBind);
return commPort;
}
}

View File

@@ -0,0 +1,329 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-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 <https://www.gnu.org/licenses/>.
*/
import {generateId} from '../core/utilities.js';
/**
* This class is used to return the ancestor frame IDs for the current frame.
* This is a workaround to using the `webNavigation.getAllFrames` API, which
* would require an additional permission that is otherwise unnecessary.
* It is also used to track the correlation between child frame elements and their IDs.
*/
export class FrameAncestryHandler {
/**
* Creates a new instance.
* @param {import('../comm/cross-frame-api.js').CrossFrameAPI} crossFrameApi
*/
constructor(crossFrameApi) {
/** @type {import('../comm/cross-frame-api.js').CrossFrameAPI} */
this._crossFrameApi = crossFrameApi;
/** @type {boolean} */
this._isPrepared = false;
/** @type {string} */
this._requestMessageId = 'FrameAncestryHandler.requestFrameInfo';
/** @type {?Promise<number[]>} */
this._getFrameAncestryInfoPromise = null;
/** @type {Map<number, {window: Window, frameElement: ?(undefined|Element)}>} */
this._childFrameMap = new Map();
/** @type {Map<string, import('frame-ancestry-handler').ResponseHandler>} */
this._responseHandlers = new Map();
}
/**
* Initializes event event listening.
*/
prepare() {
if (this._isPrepared) { return; }
window.addEventListener('message', this._onWindowMessage.bind(this), false);
this._crossFrameApi.registerHandlers([
['frameAncestryHandlerRequestFrameInfoResponse', this._onFrameAncestryHandlerRequestFrameInfoResponse.bind(this)],
]);
this._isPrepared = true;
}
/**
* Returns whether or not this frame is the root frame in the tab.
* @returns {boolean} `true` if it is the root, otherwise `false`.
*/
isRootFrame() {
return (window === window.parent);
}
/**
* Gets the frame ancestry information for the current frame. If the frame is the
* root frame, an empty array is returned. Otherwise, an array of frame IDs is returned,
* starting from the nearest ancestor.
* @returns {Promise<number[]>} An array of frame IDs corresponding to the ancestors of the current frame.
*/
async getFrameAncestryInfo() {
if (this._getFrameAncestryInfoPromise === null) {
this._getFrameAncestryInfoPromise = this._getFrameAncestryInfo(5000);
}
return await this._getFrameAncestryInfoPromise;
}
/**
* Gets the frame element of a child frame given a frame ID.
* For this function to work, the `getFrameAncestryInfo` function needs to have
* been invoked previously.
* @param {number} frameId The frame ID of the child frame to get.
* @returns {?Element} The element corresponding to the frame with ID `frameId`, otherwise `null`.
*/
getChildFrameElement(frameId) {
const frameInfo = this._childFrameMap.get(frameId);
if (typeof frameInfo === 'undefined') { return null; }
let {frameElement} = frameInfo;
if (typeof frameElement === 'undefined') {
frameElement = this._findFrameElementWithContentWindow(frameInfo.window);
frameInfo.frameElement = frameElement;
}
return frameElement;
}
// Private
/**
* @param {number} [timeout]
* @returns {Promise<number[]>}
*/
_getFrameAncestryInfo(timeout = 5000) {
return new Promise((resolve, reject) => {
const {frameId} = this._crossFrameApi;
const targetWindow = window.parent;
if (frameId === null || window === targetWindow) {
resolve([]);
return;
}
const uniqueId = generateId(16);
let nonce = generateId(16);
/** @type {number[]} */
const results = [];
/** @type {?import('core').Timeout} */
let timer = null;
const cleanup = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
this._removeResponseHandler(uniqueId);
};
/** @type {import('frame-ancestry-handler').ResponseHandler} */
const onMessage = (params) => {
if (params.nonce !== nonce) { return null; }
// Add result
results.push(params.frameId);
nonce = generateId(16);
if (!params.more) {
// Cleanup
cleanup();
// Finish
resolve(results);
}
return {nonce};
};
const onTimeout = () => {
timer = null;
cleanup();
reject(new Error(`Request for parent frame ID timed out after ${timeout}ms`));
};
const resetTimeout = () => {
if (timer !== null) { clearTimeout(timer); }
timer = setTimeout(onTimeout, timeout);
};
// Start
this._addResponseHandler(uniqueId, onMessage);
resetTimeout();
this._requestFrameInfo(targetWindow, frameId, frameId, uniqueId, nonce);
});
}
/**
* @param {MessageEvent<unknown>} event
*/
_onWindowMessage(event) {
const source = /** @type {?Window} */ (event.source);
if (source === null || source === window || source.parent !== window) { return; }
const {data} = event;
if (typeof data !== 'object' || data === null) { return; }
const {action} = /** @type {import('core').SerializableObject} */ (data);
if (action !== this._requestMessageId) { return; }
const {params} = /** @type {import('core').SerializableObject} */ (data);
if (typeof params !== 'object' || params === null) { return; }
void this._onRequestFrameInfo(/** @type {import('core').SerializableObject} */ (params), source);
}
/**
* @param {import('core').SerializableObject} params
* @param {Window} source
*/
async _onRequestFrameInfo(params, source) {
try {
let {originFrameId, childFrameId, uniqueId, nonce} = params;
if (
typeof originFrameId !== 'number' ||
typeof childFrameId !== 'number' ||
!this._isNonNegativeInteger(originFrameId) ||
typeof uniqueId !== 'string' ||
typeof nonce !== 'string'
) {
return;
}
const {frameId} = this._crossFrameApi;
if (frameId === null) { return; }
const {parent} = window;
const more = (window !== parent);
try {
const response = await this._crossFrameApi.invoke(originFrameId, 'frameAncestryHandlerRequestFrameInfoResponse', {uniqueId, frameId, nonce, more});
if (response === null) { return; }
const nonce2 = response.nonce;
if (typeof nonce2 !== 'string') { return; }
nonce = nonce2;
} catch (e) {
return;
}
if (!this._childFrameMap.has(childFrameId)) {
this._childFrameMap.set(childFrameId, {window: source, frameElement: void 0});
}
if (more) {
this._requestFrameInfo(parent, originFrameId, frameId, uniqueId, /** @type {string} */ (nonce));
}
} catch (e) {
// NOP
}
}
/**
* @param {Window} targetWindow
* @param {number} originFrameId
* @param {number} childFrameId
* @param {string} uniqueId
* @param {string} nonce
*/
_requestFrameInfo(targetWindow, originFrameId, childFrameId, uniqueId, nonce) {
targetWindow.postMessage({
action: this._requestMessageId,
params: {originFrameId, childFrameId, uniqueId, nonce},
}, '*');
}
/**
* @param {number} value
* @returns {boolean}
*/
_isNonNegativeInteger(value) {
return (
Number.isFinite(value) &&
value >= 0 &&
Math.floor(value) === value
);
}
/**
* @param {Window} contentWindow
* @returns {?Element}
*/
_findFrameElementWithContentWindow(contentWindow) {
// Check frameElement, for non-null same-origin frames
try {
const {frameElement} = contentWindow;
if (frameElement !== null) { return frameElement; }
} catch (e) {
// NOP
}
// Check frames
const frameTypes = ['iframe', 'frame', 'object'];
for (const frameType of frameTypes) {
for (const frame of /** @type {HTMLCollectionOf<import('extension').HtmlElementWithContentWindow>} */ (document.getElementsByTagName(frameType))) {
if (frame.contentWindow === contentWindow) {
return frame;
}
}
}
// Check for shadow roots
/** @type {Node[]} */
const rootElements = [document.documentElement];
while (rootElements.length > 0) {
const rootElement = /** @type {Node} */ (rootElements.shift());
const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode()) {
const element = /** @type {Element} */ (walker.currentNode);
// @ts-expect-error - this is more simple to elide any type checks or casting
if (element.contentWindow === contentWindow) {
return element;
}
/** @type {?ShadowRoot|undefined} */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const shadowRoot = (
element.shadowRoot ||
// @ts-expect-error - openOrClosedShadowRoot is available to Firefox 63+ for WebExtensions
element.openOrClosedShadowRoot
);
if (shadowRoot) {
rootElements.push(shadowRoot);
}
}
}
// Not found
return null;
}
/**
* @param {string} id
* @param {import('frame-ancestry-handler').ResponseHandler} handler
* @throws {Error}
*/
_addResponseHandler(id, handler) {
if (this._responseHandlers.has(id)) { throw new Error('Identifier already used'); }
this._responseHandlers.set(id, handler);
}
/**
* @param {string} id
*/
_removeResponseHandler(id) {
this._responseHandlers.delete(id);
}
/** @type {import('cross-frame-api').ApiHandler<'frameAncestryHandlerRequestFrameInfoResponse'>} */
_onFrameAncestryHandlerRequestFrameInfoResponse(params) {
const handler = this._responseHandlers.get(params.uniqueId);
return typeof handler !== 'undefined' ? handler(params) : null;
}
}

225
vendor/yomitan/js/comm/frame-client.js vendored Normal file
View File

@@ -0,0 +1,225 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-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 <https://www.gnu.org/licenses/>.
*/
import {isObjectNotArray} from '../core/object-utilities.js';
import {deferPromise, generateId} from '../core/utilities.js';
export class FrameClient {
constructor() {
/** @type {?string} */
this._secret = null;
/** @type {?string} */
this._token = null;
/** @type {?number} */
this._frameId = null;
}
/** @type {number} */
get frameId() {
if (this._frameId === null) { throw new Error('Not connected'); }
return this._frameId;
}
/**
* @param {import('extension').HtmlElementWithContentWindow} frame
* @param {string} targetOrigin
* @param {number} hostFrameId
* @param {import('frame-client').SetupFrameFunction} setupFrame
* @param {number} [timeout]
*/
async connect(frame, targetOrigin, hostFrameId, setupFrame, timeout = 10000) {
const {secret, token, frameId} = await this._connectInternal(frame, targetOrigin, hostFrameId, setupFrame, timeout);
this._secret = secret;
this._token = token;
this._frameId = frameId;
}
/**
* @returns {boolean}
*/
isConnected() {
return (this._secret !== null);
}
/**
* @template [T=unknown]
* @param {T} data
* @returns {import('frame-client').Message<T>}
* @throws {Error}
*/
createMessage(data) {
if (!this.isConnected()) {
throw new Error('Not connected');
}
return {
token: /** @type {string} */ (this._token),
secret: /** @type {string} */ (this._secret),
data,
};
}
/**
* @param {import('extension').HtmlElementWithContentWindow} frame
* @param {string} targetOrigin
* @param {number} hostFrameId
* @param {(frame: import('extension').HtmlElementWithContentWindow) => void} setupFrame
* @param {number} timeout
* @returns {Promise<{secret: string, token: string, frameId: number}>}
*/
_connectInternal(frame, targetOrigin, hostFrameId, setupFrame, timeout) {
return new Promise((resolve, reject) => {
/** @type {Map<string, string>} */
const tokenMap = new Map();
/** @type {?import('core').Timeout} */
let timer = null;
const deferPromiseDetails = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise());
const frameLoadedPromise = deferPromiseDetails.promise;
let frameLoadedResolve = /** @type {?() => void} */ (deferPromiseDetails.resolve);
let frameLoadedReject = /** @type {?(reason?: import('core').RejectionReason) => void} */ (deferPromiseDetails.reject);
/**
* @param {string} action
* @param {import('core').SerializableObject} params
* @throws {Error}
*/
const postMessage = (action, params) => {
const contentWindow = frame.contentWindow;
if (contentWindow === null) { throw new Error('Frame missing content window'); }
let validOrigin = true;
try {
validOrigin = (contentWindow.location.origin === targetOrigin);
} catch (e) {
// NOP
}
if (!validOrigin) { throw new Error('Unexpected frame origin'); }
contentWindow.postMessage({action, params}, targetOrigin);
};
/** @type {import('extension').ChromeRuntimeOnMessageCallback<import('application').ApiMessageAny>} */
const onMessage = (message) => {
void onMessageInner(message);
return false;
};
/**
* @param {import('application').ApiMessageAny} message
*/
const onMessageInner = async (message) => {
try {
if (!isObjectNotArray(message)) { return; }
const {action, params} = message;
if (!isObjectNotArray(params)) { return; }
await frameLoadedPromise;
if (timer === null) { return; } // Done
switch (action) {
case 'frameEndpointReady':
{
const {secret} = params;
const token = generateId(16);
tokenMap.set(secret, token);
postMessage('frameEndpointConnect', {secret, token, hostFrameId});
}
break;
case 'frameEndpointConnected':
{
const {secret, token} = params;
const frameId = message.frameId;
const token2 = tokenMap.get(secret);
if (typeof token2 !== 'undefined' && token === token2 && typeof frameId === 'number') {
cleanup();
resolve({secret, token, frameId});
}
}
break;
}
} catch (e) {
cleanup();
reject(e);
}
};
const onLoad = () => {
if (frameLoadedResolve === null) {
cleanup();
reject(new Error('Unexpected load event'));
return;
}
if (FrameClient.isFrameAboutBlank(frame)) {
return;
}
frameLoadedResolve();
frameLoadedResolve = null;
frameLoadedReject = null;
};
const cleanup = () => {
if (timer === null) { return; } // Done
clearTimeout(timer);
timer = null;
frameLoadedResolve = null;
if (frameLoadedReject !== null) {
frameLoadedReject(new Error('Terminated'));
frameLoadedReject = null;
}
chrome.runtime.onMessage.removeListener(onMessage);
frame.removeEventListener('load', onLoad);
};
// Start
timer = setTimeout(() => {
cleanup();
reject(new Error('Timeout'));
}, timeout);
chrome.runtime.onMessage.addListener(onMessage);
frame.addEventListener('load', onLoad);
// Prevent unhandled rejections
frameLoadedPromise.catch(() => {}); // NOP
try {
setupFrame(frame);
} catch (e) {
cleanup();
reject(e);
}
});
}
/**
* @param {import('extension').HtmlElementWithContentWindow} frame
* @returns {boolean}
*/
static isFrameAboutBlank(frame) {
try {
const contentDocument = frame.contentDocument;
if (contentDocument === null) { return false; }
const url = contentDocument.location.href;
return /^about:blank(?:[#?]|$)/.test(url);
} catch (e) {
return false;
}
}
}

109
vendor/yomitan/js/comm/frame-endpoint.js vendored Normal file
View File

@@ -0,0 +1,109 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-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 <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../core/event-listener-collection.js';
import {log} from '../core/log.js';
import {generateId} from '../core/utilities.js';
export class FrameEndpoint {
/**
* @param {import('../comm/api.js').API} api
*/
constructor(api) {
/** @type {import('../comm/api.js').API} */
this._api = api;
/** @type {string} */
this._secret = generateId(16);
/** @type {?string} */
this._token = null;
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {boolean} */
this._eventListenersSetup = false;
}
/**
* @returns {void}
*/
signal() {
if (!this._eventListenersSetup) {
this._eventListeners.addEventListener(window, 'message', this._onMessage.bind(this), false);
this._eventListenersSetup = true;
}
/** @type {import('frame-client').FrameEndpointReadyDetails} */
const details = {secret: this._secret};
void this._api.broadcastTab({action: 'frameEndpointReady', params: details});
}
/**
* @param {unknown} message
* @returns {boolean}
*/
authenticate(message) {
return (
this._token !== null &&
typeof message === 'object' && message !== null &&
this._token === /** @type {import('core').SerializableObject} */ (message).token &&
this._secret === /** @type {import('core').SerializableObject} */ (message).secret
);
}
/**
* @param {MessageEvent<unknown>} event
*/
_onMessage(event) {
if (this._token !== null) { return; } // Already initialized
const {data} = event;
if (typeof data !== 'object' || data === null) {
log.error('Invalid message');
return;
}
const {action} = /** @type {import('core').SerializableObject} */ (data);
if (action !== 'frameEndpointConnect') {
log.error('Invalid action');
return;
}
const {params} = /** @type {import('core').SerializableObject} */ (data);
if (typeof params !== 'object' || params === null) {
log.error('Invalid data');
return;
}
const {secret} = /** @type {import('core').SerializableObject} */ (params);
if (secret !== this._secret) {
log.error('Invalid authentication');
return;
}
const {token, hostFrameId} = /** @type {import('core').SerializableObject} */ (params);
if (typeof token !== 'string' || typeof hostFrameId !== 'number') {
log.error('Invalid target');
return;
}
this._token = token;
this._eventListeners.removeAllEventListeners();
/** @type {import('frame-client').FrameEndpointConnectedDetails} */
const details = {secret, token};
void this._api.sendMessageToFrame(hostFrameId, {action: 'frameEndpointConnected', params: details});
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-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 <https://www.gnu.org/licenses/>.
*/
import {FrameAncestryHandler} from './frame-ancestry-handler.js';
export class FrameOffsetForwarder {
/**
* @param {import('../comm/cross-frame-api.js').CrossFrameAPI} crossFrameApi
*/
constructor(crossFrameApi) {
/** @type {import('../comm/cross-frame-api.js').CrossFrameAPI} */
this._crossFrameApi = crossFrameApi;
/** @type {FrameAncestryHandler} */
this._frameAncestryHandler = new FrameAncestryHandler(crossFrameApi);
}
/**
* @returns {void}
*/
prepare() {
this._frameAncestryHandler.prepare();
this._crossFrameApi.registerHandlers([
['frameOffsetForwarderGetChildFrameRect', this._onMessageGetChildFrameRect.bind(this)],
]);
}
/**
* @returns {Promise<?[x: number, y: number]>}
*/
async getOffset() {
if (this._frameAncestryHandler.isRootFrame()) {
return [0, 0];
}
const {frameId} = this._crossFrameApi;
if (frameId === null) { return null; }
try {
const ancestorFrameIds = await this._frameAncestryHandler.getFrameAncestryInfo();
let childFrameId = frameId;
/** @type {Promise<?import('frame-offset-forwarder').ChildFrameRect>[]} */
const promises = [];
for (const ancestorFrameId of ancestorFrameIds) {
promises.push(this._crossFrameApi.invoke(ancestorFrameId, 'frameOffsetForwarderGetChildFrameRect', {frameId: childFrameId}));
childFrameId = ancestorFrameId;
}
const results = await Promise.all(promises);
let x = 0;
let y = 0;
for (const result of results) {
if (result === null) { return null; }
x += result.x;
y += result.y;
}
return [x, y];
} catch (e) {
return null;
}
}
// Private
/** @type {import('cross-frame-api').ApiHandler<'frameOffsetForwarderGetChildFrameRect'>} */
_onMessageGetChildFrameRect({frameId}) {
const frameElement = this._frameAncestryHandler.getChildFrameElement(frameId);
if (frameElement === null) { return null; }
const {left, top, width, height} = frameElement.getBoundingClientRect();
return {x: left, y: top, width, height};
}
}

286
vendor/yomitan/js/comm/mecab.js vendored Normal file
View File

@@ -0,0 +1,286 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2019-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 <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../core/event-listener-collection.js';
import {toError} from '../core/to-error.js';
/**
* This class is used to connect Yomitan to a native component that is
* used to parse text into individual terms.
*/
export class Mecab {
/**
* Creates a new instance of the class.
*/
constructor() {
/** @type {?chrome.runtime.Port} */
this._port = null;
/** @type {number} */
this._sequence = 0;
/** @type {Map<number, {resolve: (value: unknown) => void, reject: (reason?: unknown) => void, timer: import('core').Timeout}>} */
this._invocations = new Map();
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {number} */
this._timeout = 5000;
/** @type {number} */
this._version = 1;
/** @type {?number} */
this._remoteVersion = null;
/** @type {boolean} */
this._enabled = false;
/** @type {?Promise<void>} */
this._setupPortPromise = null;
}
/**
* Returns whether or not the component is enabled.
* @returns {boolean} Whether or not the object is enabled.
*/
isEnabled() {
return this._enabled;
}
/**
* Changes whether or not the component connection is enabled.
* @param {boolean} 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 {boolean} `true` if the connection is active, `false` otherwise.
*/
isConnected() {
return (this._port !== null);
}
/**
* Returns whether or not any invocation is currently active.
* @returns {boolean} `true` if an invocation is active, `false` otherwise.
*/
isActive() {
return (this._invocations.size > 0);
}
/**
* Gets the local API version being used.
* @returns {number} An integer representing the API version that Yomitan uses.
*/
getLocalVersion() {
return this._version;
}
/**
* Gets the version of the MeCab component.
* @returns {Promise<?number>} The version of the MeCab component, or `null` if the component was not found.
*/
async getVersion() {
try {
await this._setupPortWrapper();
} 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: [
* {term: (string), reading: (string), source: (string)},
* ...
* ]
* },
* ...
* ]
* ```
* @param {string} text The string to parse.
* @returns {Promise<import('mecab').ParseResult[]>} A collection of parsing results of the text.
*/
async parseText(text) {
await this._setupPortWrapper();
const rawResults = await this._invoke('parse_text', {text});
// Note: The format of rawResults is not validated
return this._convertParseTextResults(/** @type {import('mecab').ParseResultRaw} */ (rawResults));
}
// Private
/**
* @param {unknown} message
*/
_onMessage(message) {
if (typeof message !== 'object' || message === null) { return; }
const {sequence, data} = /** @type {import('core').SerializableObject} */ (message);
if (typeof sequence !== 'number') { return; }
const invocation = this._invocations.get(sequence);
if (typeof invocation === 'undefined') { return; }
const {resolve, timer} = invocation;
clearTimeout(timer);
resolve(data);
this._invocations.delete(sequence);
}
/**
* @returns {void}
*/
_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();
}
/**
* @param {string} action
* @param {import('core').SerializableObject} params
* @returns {Promise<unknown>}
*/
_invoke(action, params) {
return new Promise((resolve, reject) => {
if (this._port === null) {
reject(new Error('Port disconnected'));
return;
}
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._port.postMessage({action, params, sequence});
});
}
/**
* @param {import('mecab').ParseResultRaw} rawResults
* @returns {import('mecab').ParseResult[]}
*/
_convertParseTextResults(rawResults) {
/** @type {import('mecab').ParseResult[]} */
const results = [];
for (const [name, rawLines] of Object.entries(rawResults)) {
/** @type {import('mecab').ParseFragment[][]} */
const lines = [];
for (const rawLine of rawLines) {
const line = [];
for (let {expression: term, reading, source} of rawLine) {
if (typeof term !== 'string') { term = ''; }
if (typeof reading !== 'string') { reading = ''; }
if (typeof source !== 'string') { source = ''; }
line.push({term, reading, source});
}
lines.push(line);
}
results.push({name, lines});
}
return results;
}
/**
* @returns {Promise<void>}
*/
async _setupPortWrapper() {
if (!this._enabled) {
throw new Error('MeCab not enabled');
}
if (this._setupPortPromise === null) {
this._setupPortPromise = this._setupPort();
}
try {
await this._setupPortPromise;
} catch (e) {
throw toError(e);
}
}
/**
* @returns {Promise<void>}
*/
async _setupPort() {
const port = chrome.runtime.connectNative('yomitan_mecab');
this._eventListeners.addListener(port.onMessage, this._onMessage.bind(this));
this._eventListeners.addListener(port.onDisconnect, this._onDisconnect.bind(this));
this._port = port;
try {
const data = await this._invoke('get_version', {});
if (typeof data !== 'object' || data === null) {
throw new Error('Invalid version');
}
const {version} = /** @type {import('core').SerializableObject} */ (data);
if (typeof version !== 'number') {
throw new Error('Invalid version');
}
this._remoteVersion = version;
if (version !== this._version) {
throw new Error(`Unsupported MeCab native messenger version ${version}. Yomitan supports version ${this._version}.`);
}
} catch (e) {
if (this._port === port) {
this._clearPort();
}
throw e;
}
}
/**
* @returns {void}
*/
_clearPort() {
if (this._port !== null) {
this._port.disconnect();
this._port = null;
}
this._invocations.clear();
this._eventListeners.removeAllEventListeners();
this._sequence = 0;
this._setupPortPromise = null;
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright (C) 2024-2025 Yomitan 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/>.
*/
import {createApiMap, invokeApiMapHandler} from '../core/api-map.js';
import {ExtensionError} from '../core/extension-error.js';
import {log} from '../core/log.js';
/**
* This serves as a bridge between the application and the backend on Firefox
* where we don't have service workers.
*
* It is designed to have extremely short lifetime on the application side,
* as otherwise it will stay alive across extension updates (which only restart
* the backend) which can lead to extremely difficult to debug situations where
* the bridge is running an old version of the code.
*
* All it does is broker a handshake between the application and the backend,
* where they establish a connection between each other with a MessageChannel.
*
* # On backend startup
* backend
* ↓↓<"registerBackendPort" via SharedWorker.port.postMessage>↓↓
* bridge: store the port in state
*
* # On application startup
* application: create a new MessageChannel, bind event listeners to one of the ports, and send the other port to the bridge
* ↓↓<"connectToBackend1" via SharedWorker.port.postMessage>↓↓
* bridge
* ↓↓<"connectToBackend2" via MessageChannel.port.postMessage which is stored in state from backend startup phase>↓↓
* backend: bind event listeners to the other port
*/
export class SharedWorkerBridge {
constructor() {
/** @type {MessagePort?} */
this._backendPort = null;
/** @type {import('shared-worker').ApiMap} */
this._apiMap = createApiMap([
['registerBackendPort', this._onRegisterBackendPort.bind(this)],
['connectToBackend1', this._onConnectToBackend1.bind(this)],
]);
}
/**
*
*/
prepare() {
addEventListener('connect', (connectEvent) => {
const interlocutorPort = (/** @type {MessageEvent} */ (connectEvent)).ports[0];
interlocutorPort.addEventListener('message', (/** @type {MessageEvent<import('shared-worker').ApiMessageAny>} */ event) => {
const {action, params} = event.data;
return invokeApiMapHandler(this._apiMap, action, params, [interlocutorPort, event.ports], () => {});
});
interlocutorPort.addEventListener('messageerror', (/** @type {MessageEvent} */ event) => {
const error = new ExtensionError('SharedWorkerBridge: Error receiving message from interlocutor port when establishing connection');
error.data = event;
log.error(error);
});
interlocutorPort.start();
});
}
/** @type {import('shared-worker').ApiHandler<'registerBackendPort'>} */
_onRegisterBackendPort(_params, interlocutorPort, _ports) {
this._backendPort = interlocutorPort;
}
/** @type {import('shared-worker').ApiHandler<'connectToBackend1'>} */
_onConnectToBackend1(_params, _interlocutorPort, ports) {
if (this._backendPort !== null) {
this._backendPort.postMessage(void 0, [ports[0]]); // connectToBackend2
} else {
log.warn('SharedWorkerBridge: backend port is not registered; this can happen if one of the content scripts loads faster than the backend when extension is reloading');
}
}
}
const bridge = new SharedWorkerBridge();
bridge.prepare();

623
vendor/yomitan/js/comm/yomitan-api.js vendored Normal file
View File

@@ -0,0 +1,623 @@
/*
* Copyright (C) 2025 Yomitan 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/>.
*/
import {parseHTML} from '../../lib/linkedom.js';
import {OffscreenProxy} from '../background/offscreen-proxy.js';
import {RequestBuilder} from '../background/request-builder.js';
import {invokeApiMapHandler} from '../core/api-map.js';
import {EventListenerCollection} from '../core/event-listener-collection.js';
import {ExtensionError} from '../core/extension-error.js';
import {parseJson, readResponseJson} from '../core/json.js';
import {log} from '../core/log.js';
import {toError} from '../core/to-error.js';
import {createFuriganaHtml, createFuriganaPlain} from '../data/anki-note-builder.js';
import {getDynamicTemplates} from '../data/anki-template-util.js';
import {generateAnkiNoteMediaFileName} from '../data/anki-util.js';
import {getLanguageSummaries} from '../language/languages.js';
import {AudioDownloader} from '../media/audio-downloader.js';
import {getFileExtensionFromAudioMediaType, getFileExtensionFromImageMediaType} from '../media/media-util.js';
import {getDictionaryEntryMedia} from '../pages/settings/anki-deck-generator-controller.js';
import {AnkiTemplateRenderer} from '../templates/anki-template-renderer.js';
/** */
export class YomitanApi {
/**
* @param {import('api').ApiMap} apiMap
* @param {OffscreenProxy?} offscreen
*/
constructor(apiMap, offscreen) {
/** @type {?chrome.runtime.Port} */
this._port = null;
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {number} */
this._timeout = 5000;
/** @type {number} */
this._version = 1;
/** @type {?number} */
this._remoteVersion = null;
/** @type {boolean} */
this._enabled = false;
/** @type {?Promise<void>} */
this._setupPortPromise = null;
/** @type {import('api').ApiMap} */
this._apiMap = apiMap;
/** @type {RequestBuilder} */
this._requestBuilder = new RequestBuilder();
/** @type {AudioDownloader} */
this._audioDownloader = new AudioDownloader(this._requestBuilder);
/** @type {OffscreenProxy?} */
this._offscreen = offscreen;
}
/**
* @returns {boolean}
*/
isEnabled() {
return this._enabled;
}
/**
* @param {boolean} enabled
*/
async setEnabled(enabled) {
this._enabled = !!enabled;
if (!this._enabled && this._port !== null) {
this._clearPort();
}
if (this._enabled) {
await this.startApiServer();
}
}
/** */
disconnect() {
if (this._port !== null) {
this._clearPort();
}
}
/**
* @returns {boolean}
*/
isConnected() {
return (this._port !== null);
}
/**
* @returns {number}
*/
getLocalVersion() {
return this._version;
}
/**
* @param {string} url
* @returns {Promise<?number>}
*/
async getRemoteVersion(url) {
if (this._port === null) {
await this.startApiServer();
}
await this._updateRemoteVersion(url);
return this._remoteVersion;
}
/**
* @returns {Promise<boolean>}
*/
async startApiServer() {
try {
await this._setupPortWrapper();
return true;
} catch (e) {
log.error(e);
return false;
}
}
// Private
/**
* @param {unknown} message
*/
async _onMessage(message) {
if (typeof message !== 'object' || message === null) { return; }
if (this._port !== null) {
const {action, params, body} = /** @type {import('core').SerializableObject} */ (message);
if (typeof action !== 'string' || typeof params !== 'object' || typeof body !== 'string') {
this._port.postMessage({action, params, body, data: 'null', responseStatusCode: 400});
return;
}
const optionsFull = await this._invoke('optionsGetFull', void 0);
try {
/** @type {?object} */
const parsedBody = body.length > 0 ? parseJson(body) : {};
if (parsedBody === null) {
throw new Error('Invalid request body');
}
let result = null;
let statusCode = 200;
switch (action) {
case 'yomitanVersion': {
const {version} = chrome.runtime.getManifest();
result = {version: version};
break;
}
case 'termEntries': {
/** @type {import('yomitan-api.js').termEntriesInput} */
// @ts-expect-error - Allow this to error
const {term} = parsedBody;
const invokeParams = {
text: term,
details: {},
optionsContext: {index: optionsFull.profileCurrent},
};
result = await this._invoke(
'termsFind',
invokeParams,
);
break;
}
case 'kanjiEntries': {
/** @type {import('yomitan-api.js').kanjiEntriesInput} */
// @ts-expect-error - Allow this to error
const {character} = parsedBody;
const invokeParams = {
text: character,
details: {},
optionsContext: {index: optionsFull.profileCurrent},
};
result = await this._invoke(
'kanjiFind',
invokeParams,
);
break;
}
case 'ankiFields': {
/** @type {import('yomitan-api.js').ankiFieldsInput} */
// @ts-expect-error - Allow this to error
const {text, type, markers, maxEntries, includeMedia} = parsedBody;
const includeAudioMedia = includeMedia && markers.includes('audio');
const profileOptions = optionsFull.profiles[optionsFull.profileCurrent].options;
const ankiTemplate = await this._getAnkiTemplate(profileOptions);
let dictionaryEntries = await this._getDictionaryEntries(text, type, optionsFull.profileCurrent);
if (maxEntries > 0) {
dictionaryEntries = dictionaryEntries.slice(0, maxEntries);
}
// @ts-expect-error - `parseHTML` can return `null` but this input has been validated to not be `null`
const domlessDocument = parseHTML('').document;
// @ts-expect-error - `parseHTML` can return `null` but this input has been validated to not be `null`
const domlessWindow = parseHTML('').window;
const dictionaryMedia = includeMedia ? await this._fetchDictionaryMedia(dictionaryEntries) : [];
const audioMedia = includeAudioMedia ? await this._fetchAudio(dictionaryEntries, profileOptions) : [];
const commonDatas = await this._createCommonDatas(text, dictionaryEntries, dictionaryMedia, audioMedia, profileOptions, domlessDocument);
const ankiTemplateRenderer = new AnkiTemplateRenderer(domlessDocument, domlessWindow);
await ankiTemplateRenderer.prepare();
const templateRenderer = ankiTemplateRenderer.templateRenderer;
/** @type {Array<Record<string, string>>} */
const ankiFieldsResults = [];
for (const commonData of commonDatas) {
/** @type {Record<string, string>} */
const ankiFieldsResult = {};
for (const marker of markers) {
const templateResult = templateRenderer.render(ankiTemplate, {marker: marker, commonData: commonData}, 'ankiNote');
ankiFieldsResult[marker] = templateResult.result;
}
ankiFieldsResults.push(ankiFieldsResult);
}
result = {
fields: ankiFieldsResults,
dictionaryMedia: dictionaryMedia,
audioMedia: audioMedia,
};
break;
}
case 'tokenize': {
/** @type {import('yomitan-api.js').tokenizeInput} */
// @ts-expect-error - Allow this to error
const {text, scanLength} = parsedBody;
if (typeof text !== 'string' && !Array.isArray(text)) {
throw new Error('Invalid input for tokenize, expected "text" to be a string or a string array but got ' + typeof text);
}
if (typeof scanLength !== 'number') {
throw new Error('Invalid input for tokenize, expected "scanLength" to be a number but got ' + typeof scanLength);
}
const invokeParams = {
text: text,
optionsContext: {index: optionsFull.profileCurrent},
scanLength: scanLength,
useInternalParser: true,
useMecabParser: false,
};
result = await this._invoke('parseText', invokeParams);
break;
}
default:
statusCode = 400;
}
this._port.postMessage({action, params, body, data: result, responseStatusCode: statusCode});
} catch (error) {
log.error(error);
this._port.postMessage({action, params, body, data: JSON.stringify(error), responseStatusCode: 500});
}
}
}
/**
* @param {import('settings').ProfileOptions} options
* @returns {Promise<string>}
*/
async _getAnkiTemplate(options) {
let staticTemplates = options.anki.fieldTemplates;
if (typeof staticTemplates !== 'string') { staticTemplates = await this._invoke('getDefaultAnkiFieldTemplates', void 0); }
const dictionaryInfo = await this._invoke('getDictionaryInfo', void 0);
const dynamicTemplates = getDynamicTemplates(options, dictionaryInfo);
return staticTemplates + '\n' + dynamicTemplates;
}
/**
* @param {string} text
* @param {import('settings.js').AnkiCardFormatType} type
* @param {number} profileIndex
* @returns {Promise<import('dictionary.js').DictionaryEntry[]>}
*/
async _getDictionaryEntries(text, type, profileIndex) {
if (type === 'term') {
const invokeParams = {
text: text,
details: {},
optionsContext: {index: profileIndex},
};
return (await this._invoke('termsFind', invokeParams)).dictionaryEntries;
} else {
const invokeParams = {
text: text,
details: {},
optionsContext: {index: profileIndex},
};
return await this._invoke('kanjiFind', invokeParams);
}
}
/**
* @param {import('dictionary.js').DictionaryEntry[]} dictionaryEntries
* @returns {Promise<import('yomitan-api.js').apiDictionaryMediaDetails[]>}
*/
async _fetchDictionaryMedia(dictionaryEntries) {
/** @type {import('yomitan-api.js').apiDictionaryMediaDetails[]} */
const media = [];
let mediaCount = 0;
for (const dictionaryEntry of dictionaryEntries) {
const dictionaryEntryMedias = getDictionaryEntryMedia(dictionaryEntry);
const mediaRequestTargets = dictionaryEntryMedias.map((x) => { return {path: x.path, dictionary: x.dictionary}; });
const mediaFilesData = await this._invoke('getMedia', {
targets: mediaRequestTargets,
});
for (const mediaFileData of mediaFilesData) {
if (media.some((x) => x.dictionary === mediaFileData.dictionary && x.path === mediaFileData.path)) { continue; }
const timestamp = Date.now();
const ankiFilename = generateAnkiNoteMediaFileName(`yomitan_dictionary_media_${mediaCount}`, getFileExtensionFromImageMediaType(mediaFileData.mediaType) ?? '', timestamp);
media.push({
dictionary: mediaFileData.dictionary,
path: mediaFileData.path,
mediaType: mediaFileData.mediaType,
width: mediaFileData.width,
height: mediaFileData.height,
content: mediaFileData.content,
ankiFilename: ankiFilename,
});
mediaCount += 1;
}
}
return media;
}
/**
*
* @param {import('dictionary.js').DictionaryEntry[]} dictionaryEntries
* @param {import('settings').ProfileOptions} options
* @returns {Promise<import('yomitan-api.js').apiAudioMediaDetails[]>}
*/
async _fetchAudio(dictionaryEntries, options) {
const audioDatas = [];
const idleTimeout = (Number.isFinite(options.anki.downloadTimeout) && options.anki.downloadTimeout > 0 ? options.anki.downloadTimeout : null);
const languageSummary = getLanguageSummaries().find(({iso}) => iso === options.general.language);
if (!languageSummary) { return []; }
for (const dictionaryEntry of dictionaryEntries) {
if (dictionaryEntry.type === 'kanji') { continue; }
const headword = dictionaryEntry.headwords[0]; // Only one headword is accepted for Anki card creation
try {
const audioData = await this._audioDownloader.downloadTermAudio(options.audio.sources, null, headword.term, headword.reading, idleTimeout, languageSummary, options.audio.enableDefaultAudioSources);
const timestamp = Date.now();
const mediaType = audioData.contentType ?? '';
let extension = mediaType !== null ? getFileExtensionFromAudioMediaType(mediaType) : null;
if (extension === null) { extension = '.mp3'; }
const ankiFilename = generateAnkiNoteMediaFileName('yomitan_audio', extension, timestamp);
audioDatas.push({
term: headword.term,
reading: headword.reading,
mediaType: mediaType,
content: audioData.data,
ankiFilename: ankiFilename,
});
} catch (e) {
log.log('Yomitan API failed to download audio ' + toError(e).message);
}
}
return audioDatas;
}
/**
* @param {string} text
* @param {import('dictionary.js').DictionaryEntry[]} dictionaryEntries
* @param {import('yomitan-api.js').apiDictionaryMediaDetails[]} dictionaryMediaDetails
* @param {import('yomitan-api.js').apiAudioMediaDetails[]} audioMediaDetails
* @param {import('settings').ProfileOptions} options
* @param {Document} domlessDocument
* @returns {Promise<import('anki-note-builder.js').CommonData[]>}
*/
async _createCommonDatas(text, dictionaryEntries, dictionaryMediaDetails, audioMediaDetails, options, domlessDocument) {
/** @type {import('anki-note-builder.js').CommonData[]} */
const commonDatas = [];
for (const dictionaryEntry of dictionaryEntries) {
/** @type {import('anki-templates.js').DictionaryMedia} */
const dictionaryMedia = {};
const dictionaryEntryMedias = getDictionaryEntryMedia(dictionaryEntry);
if (dictionaryMediaDetails.length > 0) {
for (const dictionaryEntryMedia of dictionaryEntryMedias) {
const mediaFile = dictionaryMediaDetails.find((x) => x.dictionary === dictionaryEntryMedia.dictionary && x.path === dictionaryEntryMedia.path);
if (!mediaFile) {
log.error('Failed to find media for commonDatas generation');
continue;
}
if (!Object.hasOwn(dictionaryMedia, dictionaryEntryMedia.dictionary)) {
dictionaryMedia[dictionaryEntryMedia.dictionary] = {};
}
dictionaryMedia[dictionaryEntryMedia.dictionary][dictionaryEntryMedia.path] = {value: mediaFile.ankiFilename};
}
}
let audioMediaFile = '';
/** @type {import('api').ParseTextLine[]} */
let furiganaData = [];
if (dictionaryEntry.type === 'term') {
audioMediaFile = audioMediaDetails.find((x) => x.term === dictionaryEntry.headwords[0].term && x.reading === dictionaryEntry.headwords[0].reading)?.ankiFilename ?? '';
furiganaData = [[{
text: dictionaryEntry.headwords[0].term,
reading: dictionaryEntry.headwords[0].reading,
}]];
}
const furiganaReadingMode = options.parsing.readingMode === 'hiragana' || options.parsing.readingMode === 'katakana' ? options.parsing.readingMode : null;
commonDatas.push({
dictionaryEntry: dictionaryEntry,
resultOutputMode: 'group',
cardFormat: {
type: 'term',
name: '',
deck: '',
model: '',
fields: {},
icon: 'big-circle',
},
glossaryLayoutMode: 'default',
compactTags: false,
context: {
url: '',
documentTitle: '',
query: text,
fullQuery: text,
sentence: {
text: '',
offset: 0,
},
},
media: {
audio: audioMediaFile.length > 0 ? {value: audioMediaFile} : void 0,
textFurigana: [{
text: text,
readingMode: furiganaReadingMode,
detailsHtml: {
value: createFuriganaHtml(furiganaData, furiganaReadingMode, null),
},
detailsPlain: {
value: createFuriganaPlain(furiganaData, furiganaReadingMode, null),
},
}],
dictionaryMedia: dictionaryMedia,
},
dictionaryStylesMap: await this._getDictionaryStylesMapDomless(options, domlessDocument),
});
}
return commonDatas;
}
/**
* @param {import('settings').ProfileOptions} options
* @param {Document} domlessDocument
* @returns {Promise<Map<string, string>>}
*/
async _getDictionaryStylesMapDomless(options, domlessDocument) {
const styleMap = new Map();
for (const dictionary of options.dictionaries) {
const {name, styles} = dictionary;
if (typeof styles === 'string') {
// newlines and returns do not get converted into json well, are not required in css, and cause invalid css if not parsed for by the api consumer, just do the work for them
const sanitizedCSS = (await this._sanitizeCSSOffscreen(options, styles, domlessDocument)).replaceAll(/(\r|\n)/g, ' ');
styleMap.set(name, sanitizedCSS);
}
}
return styleMap;
}
/**
* @param {import('settings').ProfileOptions} options
* @param {string} css
* @param {Document} domlessDocument
* @returns {Promise<string>}
*/
async _sanitizeCSSOffscreen(options, css, domlessDocument) {
if (css.length === 0) { return ''; }
try {
if (!this._offscreen) {
throw new Error('Offscreen page not available');
}
const sanitizedCSS = this._offscreen ? await this._offscreen.sendMessagePromise({action: 'sanitizeCSSOffscreen', params: {css}}) : '';
if (sanitizedCSS.length === 0 && css.length > 0) {
throw new Error('CSS parsing failed');
}
return sanitizedCSS;
} catch (e) {
log.log('Offscreen CSS sanitizer failed: ' + toError(e).message);
}
try {
const style = domlessDocument.createElement('style');
// eslint-disable-next-line no-unsanitized/property
style.innerHTML = css;
domlessDocument.appendChild(style);
const styleSheet = style.sheet;
if (!styleSheet) {
throw new Error('CSS parsing failed');
}
return [...styleSheet.cssRules].map((rule) => rule.cssText || '').join('\n');
} catch (e) {
log.log('CSSOM CSS sanitizer failed: ' + toError(e).message);
}
if (options.general.yomitanApiAllowCssSanitizationBypass) {
log.log('Failed to sanitize CSS. Sanitization bypass is enabled, passing through CSS without sanitization: ' + css.replaceAll(/(\r|\n)/g, ' '));
return css;
}
log.log('Failed to sanitize CSS: ' + css.replaceAll(/(\r|\n)/g, ' '));
return '';
}
/**
* @param {string} url
*/
async _updateRemoteVersion(url) {
if (!url) {
throw new Error('Missing Yomitan API URL');
}
try {
const response = await fetch(url + '/serverVersion', {
method: 'POST',
});
/** @type {import('yomitan-api.js').remoteVersionResponse} */
const {version} = await readResponseJson(response);
this._remoteVersion = version;
} catch (e) {
log.error(e);
throw new Error('Failed to fetch. Try again in a moment. The nativemessaging component can take a few seconds to start.');
}
}
/**
* @returns {void}
*/
_onDisconnect() {
if (this._port === null) { return; }
const e = chrome.runtime.lastError;
const error = new Error(e ? e.message : 'Yomitan Api disconnected');
log.error(error);
this._clearPort();
}
/**
* @returns {Promise<void>}
*/
async _setupPortWrapper() {
if (!this._enabled) {
throw new Error('Yomitan Api not enabled');
}
if (this._setupPortPromise === null) {
this._setupPortPromise = this._setupPort();
}
try {
await this._setupPortPromise;
} catch (e) {
throw toError(e);
}
}
/**
* @returns {Promise<void>}
*/
async _setupPort() {
const port = chrome.runtime.connectNative('yomitan_api');
this._eventListeners.addListener(port.onMessage, this._onMessage.bind(this));
this._eventListeners.addListener(port.onDisconnect, this._onDisconnect.bind(this));
this._port = port;
}
/**
* @returns {void}
*/
_clearPort() {
if (this._port !== null) {
this._port.disconnect();
this._port = null;
}
this._eventListeners.removeAllEventListeners();
this._setupPortPromise = null;
}
/**
* @template {import('api').ApiNames} TAction
* @template {import('api').ApiParams<TAction>} TParams
* @param {TAction} action
* @param {TParams} params
* @returns {Promise<import('api').ApiReturn<TAction>>}
*/
_invoke(action, params) {
return new Promise((resolve, reject) => {
try {
invokeApiMapHandler(this._apiMap, action, params, [{}], (response) => {
if (response !== null && typeof response === 'object') {
const {error} = /** @type {import('core').UnknownObject} */ (response);
if (typeof error !== 'undefined') {
reject(ExtensionError.deserialize(/** @type {import('core').SerializedError} */(error)));
} else {
const {result} = /** @type {import('core').UnknownObject} */ (response);
resolve(/** @type {import('api').ApiReturn<TAction>} */(result));
}
} else {
const message = response === null ? 'Unexpected null response. You may need to refresh the page.' : `Unexpected response of type ${typeof response}. You may need to refresh the page.`;
reject(new Error(`${message} (${JSON.stringify(action)})`));
}
});
} catch (e) {
reject(e);
}
});
}
}