Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 498 lines 19 kB view raw
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}