/*
* 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 .
*/
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} */
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}
*/
async getRemoteVersion(url) {
if (this._port === null) {
await this.startApiServer();
}
await this._updateRemoteVersion(url);
return this._remoteVersion;
}
/**
* @returns {Promise}
*/
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>} */
const ankiFieldsResults = [];
for (const commonData of commonDatas) {
/** @type {Record} */
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}
*/
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}
*/
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}
*/
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}
*/
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}
*/
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