Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2023-2025 Yomitan Authors
3 * Copyright (C) 2016-2022 Yomichan Authors
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
19import {ExtensionError} from '../core/extension-error.js';
20import {log} from '../core/log.js';
21
22export class API {
23 /**
24 * @param {import('../extension/web-extension.js').WebExtension} webExtension
25 * @param {Worker?} mediaDrawingWorker
26 * @param {MessagePort?} backendPort
27 */
28 constructor(webExtension, mediaDrawingWorker = null, backendPort = null) {
29 /** @type {import('../extension/web-extension.js').WebExtension} */
30 this._webExtension = webExtension;
31
32 /** @type {Worker?} */
33 this._mediaDrawingWorker = mediaDrawingWorker;
34
35 /** @type {MessagePort?} */
36 this._backendPort = backendPort;
37 }
38
39 /**
40 * @param {import('api').ApiParam<'optionsGet', 'optionsContext'>} optionsContext
41 * @returns {Promise<import('api').ApiReturn<'optionsGet'>>}
42 */
43 optionsGet(optionsContext) {
44 return this._invoke('optionsGet', {optionsContext});
45 }
46
47 /**
48 * @returns {Promise<import('api').ApiReturn<'optionsGetFull'>>}
49 */
50 optionsGetFull() {
51 return this._invoke('optionsGetFull', void 0);
52 }
53
54 /**
55 * @param {import('api').ApiParam<'termsFind', 'text'>} text
56 * @param {import('api').ApiParam<'termsFind', 'details'>} details
57 * @param {import('api').ApiParam<'termsFind', 'optionsContext'>} optionsContext
58 * @returns {Promise<import('api').ApiReturn<'termsFind'>>}
59 */
60 termsFind(text, details, optionsContext) {
61 return this._invoke('termsFind', {text, details, optionsContext});
62 }
63
64 /**
65 * @param {import('api').ApiParam<'parseText', 'text'>} text
66 * @param {import('api').ApiParam<'parseText', 'optionsContext'>} optionsContext
67 * @param {import('api').ApiParam<'parseText', 'scanLength'>} scanLength
68 * @param {import('api').ApiParam<'parseText', 'useInternalParser'>} useInternalParser
69 * @param {import('api').ApiParam<'parseText', 'useMecabParser'>} useMecabParser
70 * @returns {Promise<import('api').ApiReturn<'parseText'>>}
71 */
72 parseText(text, optionsContext, scanLength, useInternalParser, useMecabParser) {
73 return this._invoke('parseText', {text, optionsContext, scanLength, useInternalParser, useMecabParser});
74 }
75
76 /**
77 * @param {import('api').ApiParam<'kanjiFind', 'text'>} text
78 * @param {import('api').ApiParam<'kanjiFind', 'optionsContext'>} optionsContext
79 * @returns {Promise<import('api').ApiReturn<'kanjiFind'>>}
80 */
81 kanjiFind(text, optionsContext) {
82 return this._invoke('kanjiFind', {text, optionsContext});
83 }
84
85 /**
86 * @returns {Promise<import('api').ApiReturn<'isAnkiConnected'>>}
87 */
88 isAnkiConnected() {
89 return this._invoke('isAnkiConnected', void 0);
90 }
91
92 /**
93 * @returns {Promise<import('api').ApiReturn<'getAnkiConnectVersion'>>}
94 */
95 getAnkiConnectVersion() {
96 return this._invoke('getAnkiConnectVersion', void 0);
97 }
98
99 /**
100 * @param {import('api').ApiParam<'addAnkiNote', 'note'>} note
101 * @returns {Promise<import('api').ApiReturn<'addAnkiNote'>>}
102 */
103 addAnkiNote(note) {
104 return this._invoke('addAnkiNote', {note});
105 }
106
107 /**
108 * @param {import('api').ApiParam<'updateAnkiNote', 'noteWithId'>} noteWithId
109 * @returns {Promise<import('api').ApiReturn<'updateAnkiNote'>>}
110 */
111 updateAnkiNote(noteWithId) {
112 return this._invoke('updateAnkiNote', {noteWithId});
113 }
114
115 /**
116 * @param {import('api').ApiParam<'getAnkiNoteInfo', 'notes'>} notes
117 * @param {import('api').ApiParam<'getAnkiNoteInfo', 'fetchAdditionalInfo'>} fetchAdditionalInfo
118 * @returns {Promise<import('api').ApiReturn<'getAnkiNoteInfo'>>}
119 */
120 getAnkiNoteInfo(notes, fetchAdditionalInfo) {
121 return this._invoke('getAnkiNoteInfo', {notes, fetchAdditionalInfo});
122 }
123
124 /**
125 * @param {import('api').ApiParam<'injectAnkiNoteMedia', 'timestamp'>} timestamp
126 * @param {import('api').ApiParam<'injectAnkiNoteMedia', 'definitionDetails'>} definitionDetails
127 * @param {import('api').ApiParam<'injectAnkiNoteMedia', 'audioDetails'>} audioDetails
128 * @param {import('api').ApiParam<'injectAnkiNoteMedia', 'screenshotDetails'>} screenshotDetails
129 * @param {import('api').ApiParam<'injectAnkiNoteMedia', 'clipboardDetails'>} clipboardDetails
130 * @param {import('api').ApiParam<'injectAnkiNoteMedia', 'dictionaryMediaDetails'>} dictionaryMediaDetails
131 * @returns {Promise<import('api').ApiReturn<'injectAnkiNoteMedia'>>}
132 */
133 injectAnkiNoteMedia(timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails) {
134 return this._invoke('injectAnkiNoteMedia', {timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails});
135 }
136
137 /**
138 * @param {import('api').ApiParam<'viewNotes', 'noteIds'>} noteIds
139 * @param {import('api').ApiParam<'viewNotes', 'mode'>} mode
140 * @param {import('api').ApiParam<'viewNotes', 'allowFallback'>} allowFallback
141 * @returns {Promise<import('api').ApiReturn<'viewNotes'>>}
142 */
143 viewNotes(noteIds, mode, allowFallback) {
144 return this._invoke('viewNotes', {noteIds, mode, allowFallback});
145 }
146
147 /**
148 * @param {import('api').ApiParam<'suspendAnkiCardsForNote', 'noteId'>} noteId
149 * @returns {Promise<import('api').ApiReturn<'suspendAnkiCardsForNote'>>}
150 */
151 suspendAnkiCardsForNote(noteId) {
152 return this._invoke('suspendAnkiCardsForNote', {noteId});
153 }
154
155 /**
156 * @param {import('api').ApiParam<'getTermAudioInfoList', 'source'>} source
157 * @param {import('api').ApiParam<'getTermAudioInfoList', 'term'>} term
158 * @param {import('api').ApiParam<'getTermAudioInfoList', 'reading'>} reading
159 * @param {import('api').ApiParam<'getTermAudioInfoList', 'languageSummary'>} languageSummary
160 * @returns {Promise<import('api').ApiReturn<'getTermAudioInfoList'>>}
161 */
162 getTermAudioInfoList(source, term, reading, languageSummary) {
163 return this._invoke('getTermAudioInfoList', {source, term, reading, languageSummary});
164 }
165
166 /**
167 * @param {import('api').ApiParam<'commandExec', 'command'>} command
168 * @param {import('api').ApiParam<'commandExec', 'params'>} [params]
169 * @returns {Promise<import('api').ApiReturn<'commandExec'>>}
170 */
171 commandExec(command, params) {
172 return this._invoke('commandExec', {command, params});
173 }
174
175 /**
176 * @param {import('api').ApiParam<'sendMessageToFrame', 'frameId'>} frameId
177 * @param {import('api').ApiParam<'sendMessageToFrame', 'message'>} message
178 * @returns {Promise<import('api').ApiReturn<'sendMessageToFrame'>>}
179 */
180 sendMessageToFrame(frameId, message) {
181 return this._invoke('sendMessageToFrame', {frameId, message});
182 }
183
184 /**
185 * @param {import('api').ApiParam<'broadcastTab', 'message'>} message
186 * @returns {Promise<import('api').ApiReturn<'broadcastTab'>>}
187 */
188 broadcastTab(message) {
189 return this._invoke('broadcastTab', {message});
190 }
191
192 /**
193 * @returns {Promise<import('api').ApiReturn<'frameInformationGet'>>}
194 */
195 frameInformationGet() {
196 return this._invoke('frameInformationGet', void 0);
197 }
198
199 /**
200 * @param {import('api').ApiParam<'injectStylesheet', 'type'>} type
201 * @param {import('api').ApiParam<'injectStylesheet', 'value'>} value
202 * @returns {Promise<import('api').ApiReturn<'injectStylesheet'>>}
203 */
204 injectStylesheet(type, value) {
205 return this._invoke('injectStylesheet', {type, value});
206 }
207
208 /**
209 * @param {import('api').ApiParam<'getStylesheetContent', 'url'>} url
210 * @returns {Promise<import('api').ApiReturn<'getStylesheetContent'>>}
211 */
212 getStylesheetContent(url) {
213 return this._invoke('getStylesheetContent', {url});
214 }
215
216 /**
217 * @returns {Promise<import('api').ApiReturn<'getEnvironmentInfo'>>}
218 */
219 getEnvironmentInfo() {
220 return this._invoke('getEnvironmentInfo', void 0);
221 }
222
223 /**
224 * @returns {Promise<import('api').ApiReturn<'clipboardGet'>>}
225 */
226 clipboardGet() {
227 return this._invoke('clipboardGet', void 0);
228 }
229
230 /**
231 * @returns {Promise<import('api').ApiReturn<'getZoom'>>}
232 */
233 getZoom() {
234 return this._invoke('getZoom', void 0);
235 }
236
237 /**
238 * @returns {Promise<import('api').ApiReturn<'getDefaultAnkiFieldTemplates'>>}
239 */
240 getDefaultAnkiFieldTemplates() {
241 return this._invoke('getDefaultAnkiFieldTemplates', void 0);
242 }
243
244 /**
245 * @returns {Promise<import('api').ApiReturn<'getDictionaryInfo'>>}
246 */
247 getDictionaryInfo() {
248 return this._invoke('getDictionaryInfo', void 0);
249 }
250
251 /**
252 * @returns {Promise<import('api').ApiReturn<'purgeDatabase'>>}
253 */
254 purgeDatabase() {
255 return this._invoke('purgeDatabase', void 0);
256 }
257
258 /**
259 * @param {import('api').ApiParam<'getMedia', 'targets'>} targets
260 * @returns {Promise<import('api').ApiReturn<'getMedia'>>}
261 */
262 getMedia(targets) {
263 return this._invoke('getMedia', {targets});
264 }
265
266 /**
267 * @param {import('api').PmApiParam<'drawMedia', 'requests'>} requests
268 * @param {Transferable[]} transferables
269 */
270 drawMedia(requests, transferables) {
271 this._mediaDrawingWorker?.postMessage({action: 'drawMedia', params: {requests}}, transferables);
272 }
273
274 /**
275 * @param {import('api').ApiParam<'logGenericErrorBackend', 'error'>} error
276 * @param {import('api').ApiParam<'logGenericErrorBackend', 'level'>} level
277 * @param {import('api').ApiParam<'logGenericErrorBackend', 'context'>} context
278 * @returns {Promise<import('api').ApiReturn<'logGenericErrorBackend'>>}
279 */
280 logGenericErrorBackend(error, level, context) {
281 return this._invoke('logGenericErrorBackend', {error, level, context});
282 }
283
284 /**
285 * @returns {Promise<import('api').ApiReturn<'logIndicatorClear'>>}
286 */
287 logIndicatorClear() {
288 return this._invoke('logIndicatorClear', void 0);
289 }
290
291 /**
292 * @param {import('api').ApiParam<'modifySettings', 'targets'>} targets
293 * @param {import('api').ApiParam<'modifySettings', 'source'>} source
294 * @returns {Promise<import('api').ApiReturn<'modifySettings'>>}
295 */
296 modifySettings(targets, source) {
297 return this._invoke('modifySettings', {targets, source});
298 }
299
300 /**
301 * @param {import('api').ApiParam<'getSettings', 'targets'>} targets
302 * @returns {Promise<import('api').ApiReturn<'getSettings'>>}
303 */
304 getSettings(targets) {
305 return this._invoke('getSettings', {targets});
306 }
307
308 /**
309 * @param {import('api').ApiParam<'setAllSettings', 'value'>} value
310 * @param {import('api').ApiParam<'setAllSettings', 'source'>} source
311 * @returns {Promise<import('api').ApiReturn<'setAllSettings'>>}
312 */
313 setAllSettings(value, source) {
314 return this._invoke('setAllSettings', {value, source});
315 }
316
317 /**
318 * @param {import('api').ApiParams<'getOrCreateSearchPopup'>} details
319 * @returns {Promise<import('api').ApiReturn<'getOrCreateSearchPopup'>>}
320 */
321 getOrCreateSearchPopup(details) {
322 return this._invoke('getOrCreateSearchPopup', details);
323 }
324
325 /**
326 * @param {import('api').ApiParam<'isTabSearchPopup', 'tabId'>} tabId
327 * @returns {Promise<import('api').ApiReturn<'isTabSearchPopup'>>}
328 */
329 isTabSearchPopup(tabId) {
330 return this._invoke('isTabSearchPopup', {tabId});
331 }
332
333 /**
334 * @param {import('api').ApiParam<'triggerDatabaseUpdated', 'type'>} type
335 * @param {import('api').ApiParam<'triggerDatabaseUpdated', 'cause'>} cause
336 * @returns {Promise<import('api').ApiReturn<'triggerDatabaseUpdated'>>}
337 */
338 triggerDatabaseUpdated(type, cause) {
339 return this._invoke('triggerDatabaseUpdated', {type, cause});
340 }
341
342 /**
343 * @returns {Promise<import('api').ApiReturn<'testMecab'>>}
344 */
345 testMecab() {
346 return this._invoke('testMecab', void 0);
347 }
348
349 /**
350 * @param {string} url
351 * @returns {Promise<import('api').ApiReturn<'testYomitanApi'>>}
352 */
353 testYomitanApi(url) {
354 return this._invoke('testYomitanApi', {url});
355 }
356
357 /**
358 * @param {import('api').ApiParam<'isTextLookupWorthy', 'text'>} text
359 * @param {import('api').ApiParam<'isTextLookupWorthy', 'language'>} language
360 * @returns {Promise<import('api').ApiReturn<'isTextLookupWorthy'>>}
361 */
362 isTextLookupWorthy(text, language) {
363 return this._invoke('isTextLookupWorthy', {text, language});
364 }
365
366 /**
367 * @param {import('api').ApiParam<'getTermFrequencies', 'termReadingList'>} termReadingList
368 * @param {import('api').ApiParam<'getTermFrequencies', 'dictionaries'>} dictionaries
369 * @returns {Promise<import('api').ApiReturn<'getTermFrequencies'>>}
370 */
371 getTermFrequencies(termReadingList, dictionaries) {
372 return this._invoke('getTermFrequencies', {termReadingList, dictionaries});
373 }
374
375 /**
376 * @param {import('api').ApiParam<'findAnkiNotes', 'query'>} query
377 * @returns {Promise<import('api').ApiReturn<'findAnkiNotes'>>}
378 */
379 findAnkiNotes(query) {
380 return this._invoke('findAnkiNotes', {query});
381 }
382
383 /**
384 * @param {import('api').ApiParam<'openCrossFramePort', 'targetTabId'>} targetTabId
385 * @param {import('api').ApiParam<'openCrossFramePort', 'targetFrameId'>} targetFrameId
386 * @returns {Promise<import('api').ApiReturn<'openCrossFramePort'>>}
387 */
388 openCrossFramePort(targetTabId, targetFrameId) {
389 return this._invoke('openCrossFramePort', {targetTabId, targetFrameId});
390 }
391
392 /**
393 * This is used to keep the background page alive on Firefox MV3, as it does not support offscreen.
394 * The reason that backend persistency is required on FF is actually different from the reason it's required on Chromium --
395 * on Chromium, persistency (which we achieve via the offscreen page, not via this heartbeat) is required because the load time
396 * for the IndexedDB is incredibly long, which makes the first lookup after the extension sleeps take one minute+, which is
397 * not acceptable. However, on Firefox, the database is backed by sqlite and starts very fast. Instead, the problem is that the
398 * media-drawing-worker on the frontend holds a MessagePort to the database-worker on the backend, which closes when the extension
399 * sleeps, because the database-worker is killed and currently there is no way to detect a closed port due to
400 * https://github.com/whatwg/html/issues/1766 / https://github.com/whatwg/html/issues/10201
401 *
402 * So this is our only choice. We can remove this once there is a way to gracefully detect the closed MessagePort and rebuild it.
403 * @returns {Promise<import('api').ApiReturn<'heartbeat'>>}
404 */
405 heartbeat() {
406 return this._invoke('heartbeat', void 0);
407 }
408
409 /**
410 * @param {Transferable[]} transferables
411 */
412 registerOffscreenPort(transferables) {
413 this._pmInvoke('registerOffscreenPort', void 0, transferables);
414 }
415
416 /**
417 * @param {MessagePort} port
418 */
419 connectToDatabaseWorker(port) {
420 this._pmInvoke('connectToDatabaseWorker', void 0, [port]);
421 }
422
423 /**
424 * @returns {Promise<import('api').ApiReturn<'getLanguageSummaries'>>}
425 */
426 getLanguageSummaries() {
427 return this._invoke('getLanguageSummaries', void 0);
428 }
429
430 /**
431 * @returns {Promise<import('api').ApiReturn<'forceSync'>>}
432 */
433 forceSync() {
434 return this._invoke('forceSync', void 0);
435 }
436
437 // Utilities
438
439 /**
440 * @template {import('api').ApiNames} TAction
441 * @template {import('api').ApiParams<TAction>} TParams
442 * @param {TAction} action
443 * @param {TParams} params
444 * @returns {Promise<import('api').ApiReturn<TAction>>}
445 */
446 _invoke(action, params) {
447 /** @type {import('api').ApiMessage<TAction>} */
448 const data = {action, params};
449 return new Promise((resolve, reject) => {
450 try {
451 this._webExtension.sendMessage(data, (response) => {
452 this._webExtension.getLastError();
453 if (response !== null && typeof response === 'object') {
454 const {error} = /** @type {import('core').UnknownObject} */ (response);
455 if (typeof error !== 'undefined') {
456 reject(ExtensionError.deserialize(/** @type {import('core').SerializedError} */(error)));
457 } else {
458 const {result} = /** @type {import('core').UnknownObject} */ (response);
459 resolve(/** @type {import('api').ApiReturn<TAction>} */(result));
460 }
461 } else {
462 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.`;
463 reject(new Error(`${message} (${JSON.stringify(data)})`));
464 }
465 });
466 } catch (e) {
467 reject(e);
468 }
469 });
470 }
471
472 /**
473 * @template {import('api').PmApiNames} TAction
474 * @template {import('api').PmApiParams<TAction>} TParams
475 * @param {TAction} action
476 * @param {TParams} params
477 * @param {Transferable[]} transferables
478 */
479 _pmInvoke(action, params, transferables) {
480 // on firefox, there is no service worker, so we instead use a MessageChannel which is established
481 // via a handshake via a SharedWorker
482 if (!('serviceWorker' in navigator)) {
483 if (this._backendPort === null) {
484 log.error('no backend port available');
485 return;
486 }
487 this._backendPort.postMessage({action, params}, transferables);
488 } else {
489 void navigator.serviceWorker.ready.then((serviceWorkerRegistration) => {
490 if (serviceWorkerRegistration.active !== null) {
491 serviceWorkerRegistration.active.postMessage({action, params}, transferables);
492 } else {
493 log.error(`[${self.constructor.name}] no active service worker`);
494 }
495 });
496 }
497 }
498}