Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 2982 lines 114 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 */ 18import {AccessibilityController} from '../accessibility/accessibility-controller.js'; 19import {AnkiConnect} from '../comm/anki-connect.js'; 20import {ClipboardMonitor} from '../comm/clipboard-monitor.js'; 21import {ClipboardReader} from '../comm/clipboard-reader.js'; 22import {Mecab} from '../comm/mecab.js'; 23import {YomitanApi} from '../comm/yomitan-api.js'; 24import {createApiMap, invokeApiMapHandler} from '../core/api-map.js'; 25import {ExtensionError} from '../core/extension-error.js'; 26import {fetchText} from '../core/fetch-utilities.js'; 27import {logErrorLevelToNumber} from '../core/log-utilities.js'; 28import {log} from '../core/log.js'; 29import {isObjectNotArray} from '../core/object-utilities.js'; 30import {clone, deferPromise, promiseTimeout} from '../core/utilities.js'; 31import {generateAnkiNoteMediaFileName, INVALID_NOTE_ID, isNoteDataValid} from '../data/anki-util.js'; 32import {arrayBufferToBase64} from '../data/array-buffer-util.js'; 33import {OptionsUtil} from '../data/options-util.js'; 34import {getAllPermissions, hasPermissions, hasRequiredPermissionsForOptions} from '../data/permissions-util.js'; 35import {DictionaryDatabase} from '../dictionary/dictionary-database.js'; 36import {Environment} from '../extension/environment.js'; 37import {CacheMap} from '../general/cache-map.js'; 38import {ObjectPropertyAccessor} from '../general/object-property-accessor.js'; 39import {distributeFuriganaInflected, isCodePointJapanese, convertKatakanaToHiragana as jpConvertKatakanaToHiragana} from '../language/ja/japanese.js'; 40import {getLanguageSummaries, isTextLookupWorthy} from '../language/languages.js'; 41import {Translator} from '../language/translator.js'; 42import {AudioDownloader} from '../media/audio-downloader.js'; 43import {getFileExtensionFromAudioMediaType, getFileExtensionFromImageMediaType} from '../media/media-util.js'; 44import {ClipboardReaderProxy, DictionaryDatabaseProxy, OffscreenProxy, TranslatorProxy} from './offscreen-proxy.js'; 45import {createSchema, normalizeContext} from './profile-conditions-util.js'; 46import {RequestBuilder} from './request-builder.js'; 47import {injectStylesheet} from './script-manager.js'; 48 49/** 50 * This class controls the core logic of the extension, including API calls 51 * and various forms of communication between browser tabs and external applications. 52 */ 53export class Backend { 54 /** 55 * @param {import('../extension/web-extension.js').WebExtension} webExtension 56 */ 57 constructor(webExtension) { 58 /** @type {import('../extension/web-extension.js').WebExtension} */ 59 this._webExtension = webExtension; 60 /** @type {Environment} */ 61 this._environment = new Environment(); 62 /** @type {AnkiConnect} */ 63 this._anki = new AnkiConnect(); 64 /** @type {Mecab} */ 65 this._mecab = new Mecab(); 66 67 if (!chrome.offscreen) { 68 /** @type {?OffscreenProxy} */ 69 this._offscreen = null; 70 /** @type {DictionaryDatabase|DictionaryDatabaseProxy} */ 71 this._dictionaryDatabase = new DictionaryDatabase(); 72 /** @type {Translator|TranslatorProxy} */ 73 this._translator = new Translator(this._dictionaryDatabase); 74 /** @type {ClipboardReader|ClipboardReaderProxy} */ 75 this._clipboardReader = new ClipboardReader( 76 (typeof document === 'object' && document !== null ? document : null), 77 '#clipboard-paste-target', 78 '#clipboard-rich-content-paste-target', 79 ); 80 } else { 81 /** @type {?OffscreenProxy} */ 82 this._offscreen = new OffscreenProxy(webExtension); 83 /** @type {DictionaryDatabase|DictionaryDatabaseProxy} */ 84 this._dictionaryDatabase = new DictionaryDatabaseProxy(this._offscreen); 85 /** @type {Translator|TranslatorProxy} */ 86 this._translator = new TranslatorProxy(this._offscreen); 87 /** @type {ClipboardReader|ClipboardReaderProxy} */ 88 this._clipboardReader = new ClipboardReaderProxy(this._offscreen); 89 } 90 91 /** @type {ClipboardMonitor} */ 92 this._clipboardMonitor = new ClipboardMonitor(this._clipboardReader); 93 /** @type {?import('settings').Options} */ 94 this._options = null; 95 /** @type {import('../data/json-schema.js').JsonSchema[]} */ 96 this._profileConditionsSchemaCache = []; 97 /** @type {?string} */ 98 this._ankiClipboardImageFilenameCache = null; 99 /** @type {?string} */ 100 this._ankiClipboardImageDataUrlCache = null; 101 /** @type {?string} */ 102 this._defaultAnkiFieldTemplates = null; 103 /** @type {RequestBuilder} */ 104 this._requestBuilder = new RequestBuilder(); 105 /** @type {AudioDownloader} */ 106 this._audioDownloader = new AudioDownloader(this._requestBuilder); 107 /** @type {OptionsUtil} */ 108 this._optionsUtil = new OptionsUtil(); 109 /** @type {AccessibilityController} */ 110 this._accessibilityController = new AccessibilityController(); 111 112 /** @type {?number} */ 113 this._searchPopupTabId = null; 114 /** @type {?Promise<{tab: chrome.tabs.Tab, created: boolean}>} */ 115 this._searchPopupTabCreatePromise = null; 116 117 /** @type {boolean} */ 118 this._isPrepared = false; 119 /** @type {boolean} */ 120 this._prepareError = false; 121 /** @type {?Promise<void>} */ 122 this._preparePromise = null; 123 /** @type {import('core').DeferredPromiseDetails<void>} */ 124 const {promise, resolve, reject} = deferPromise(); 125 /** @type {Promise<void>} */ 126 this._prepareCompletePromise = promise; 127 /** @type {() => void} */ 128 this._prepareCompleteResolve = resolve; 129 /** @type {(reason?: unknown) => void} */ 130 this._prepareCompleteReject = reject; 131 132 /** @type {?string} */ 133 this._defaultBrowserActionTitle = null; 134 /** @type {?import('core').Timeout} */ 135 this._badgePrepareDelayTimer = null; 136 /** @type {?import('log').LogLevel} */ 137 this._logErrorLevel = null; 138 /** @type {?chrome.permissions.Permissions} */ 139 this._permissions = null; 140 /** @type {Map<string, (() => void)[]>} */ 141 this._applicationReadyHandlers = new Map(); 142 143 /* eslint-disable @stylistic/no-multi-spaces */ 144 /** @type {import('api').ApiMap} */ 145 this._apiMap = createApiMap([ 146 ['applicationReady', this._onApiApplicationReady.bind(this)], 147 ['requestBackendReadySignal', this._onApiRequestBackendReadySignal.bind(this)], 148 ['optionsGet', this._onApiOptionsGet.bind(this)], 149 ['optionsGetFull', this._onApiOptionsGetFull.bind(this)], 150 ['kanjiFind', this._onApiKanjiFind.bind(this)], 151 ['termsFind', this._onApiTermsFind.bind(this)], 152 ['parseText', this._onApiParseText.bind(this)], 153 ['getAnkiConnectVersion', this._onApiGetAnkiConnectVersion.bind(this)], 154 ['isAnkiConnected', this._onApiIsAnkiConnected.bind(this)], 155 ['addAnkiNote', this._onApiAddAnkiNote.bind(this)], 156 ['updateAnkiNote', this._onApiUpdateAnkiNote.bind(this)], 157 ['getAnkiNoteInfo', this._onApiGetAnkiNoteInfo.bind(this)], 158 ['injectAnkiNoteMedia', this._onApiInjectAnkiNoteMedia.bind(this)], 159 ['viewNotes', this._onApiViewNotes.bind(this)], 160 ['suspendAnkiCardsForNote', this._onApiSuspendAnkiCardsForNote.bind(this)], 161 ['commandExec', this._onApiCommandExec.bind(this)], 162 ['getTermAudioInfoList', this._onApiGetTermAudioInfoList.bind(this)], 163 ['sendMessageToFrame', this._onApiSendMessageToFrame.bind(this)], 164 ['broadcastTab', this._onApiBroadcastTab.bind(this)], 165 ['frameInformationGet', this._onApiFrameInformationGet.bind(this)], 166 ['injectStylesheet', this._onApiInjectStylesheet.bind(this)], 167 ['getStylesheetContent', this._onApiGetStylesheetContent.bind(this)], 168 ['getEnvironmentInfo', this._onApiGetEnvironmentInfo.bind(this)], 169 ['clipboardGet', this._onApiClipboardGet.bind(this)], 170 ['getZoom', this._onApiGetZoom.bind(this)], 171 ['getDefaultAnkiFieldTemplates', this._onApiGetDefaultAnkiFieldTemplates.bind(this)], 172 ['getDictionaryInfo', this._onApiGetDictionaryInfo.bind(this)], 173 ['purgeDatabase', this._onApiPurgeDatabase.bind(this)], 174 ['getMedia', this._onApiGetMedia.bind(this)], 175 ['logGenericErrorBackend', this._onApiLogGenericErrorBackend.bind(this)], 176 ['logIndicatorClear', this._onApiLogIndicatorClear.bind(this)], 177 ['modifySettings', this._onApiModifySettings.bind(this)], 178 ['getSettings', this._onApiGetSettings.bind(this)], 179 ['setAllSettings', this._onApiSetAllSettings.bind(this)], 180 ['getOrCreateSearchPopup', this._onApiGetOrCreateSearchPopup.bind(this)], 181 ['isTabSearchPopup', this._onApiIsTabSearchPopup.bind(this)], 182 ['triggerDatabaseUpdated', this._onApiTriggerDatabaseUpdated.bind(this)], 183 ['testMecab', this._onApiTestMecab.bind(this)], 184 ['testYomitanApi', this._onApiTestYomitanApi.bind(this)], 185 ['isTextLookupWorthy', this._onApiIsTextLookupWorthy.bind(this)], 186 ['getTermFrequencies', this._onApiGetTermFrequencies.bind(this)], 187 ['findAnkiNotes', this._onApiFindAnkiNotes.bind(this)], 188 ['openCrossFramePort', this._onApiOpenCrossFramePort.bind(this)], 189 ['getLanguageSummaries', this._onApiGetLanguageSummaries.bind(this)], 190 ['heartbeat', this._onApiHeartbeat.bind(this)], 191 ['forceSync', this._onApiForceSync.bind(this)], 192 ]); 193 194 /** @type {import('api').PmApiMap} */ 195 this._pmApiMap = createApiMap([ 196 ['connectToDatabaseWorker', this._onPmConnectToDatabaseWorker.bind(this)], 197 ['registerOffscreenPort', this._onPmApiRegisterOffscreenPort.bind(this)], 198 ]); 199 /* eslint-enable @stylistic/no-multi-spaces */ 200 201 /** @type {Map<string, (params?: import('core').SerializableObject) => void>} */ 202 this._commandHandlers = new Map(/** @type {[name: string, handler: (params?: import('core').SerializableObject) => void][]} */ ([ 203 ['toggleTextScanning', this._onCommandToggleTextScanning.bind(this)], 204 ['openInfoPage', this._onCommandOpenInfoPage.bind(this)], 205 ['openSettingsPage', this._onCommandOpenSettingsPage.bind(this)], 206 ['openSearchPage', this._onCommandOpenSearchPage.bind(this)], 207 ['openPopupWindow', this._onCommandOpenPopupWindow.bind(this)], 208 ])); 209 210 /** @type {YomitanApi} */ 211 this._yomitanApi = new YomitanApi(this._apiMap, this._offscreen); 212 /** @type {CacheMap<string, {originalTextLength: number, textSegments: import('api').ParseTextSegment[]}>} */ 213 this._textParseCache = new CacheMap(10000, 3600000); // 1 hour idle time, ~32MB per 1000 entries for Japanese 214 } 215 216 /** 217 * Initializes the instance. 218 * @returns {Promise<void>} A promise which is resolved when initialization completes. 219 */ 220 prepare() { 221 if (this._preparePromise === null) { 222 const promise = this._prepareInternal(); 223 promise.then( 224 () => { 225 this._isPrepared = true; 226 this._prepareCompleteResolve(); 227 }, 228 (error) => { 229 this._prepareError = true; 230 this._prepareCompleteReject(error); 231 }, 232 ); 233 void promise.finally(() => this._updateBadge()); 234 this._preparePromise = promise; 235 } 236 return this._prepareCompletePromise; 237 } 238 239 // Private 240 241 /** 242 * @returns {void} 243 */ 244 _prepareInternalSync() { 245 if (isObjectNotArray(chrome.commands) && isObjectNotArray(chrome.commands.onCommand)) { 246 const onCommand = this._onWebExtensionEventWrapper(this._onCommand.bind(this)); 247 chrome.commands.onCommand.addListener(onCommand); 248 } 249 250 if (isObjectNotArray(chrome.tabs) && isObjectNotArray(chrome.tabs.onZoomChange)) { 251 const onZoomChange = this._onWebExtensionEventWrapper(this._onZoomChange.bind(this)); 252 chrome.tabs.onZoomChange.addListener(onZoomChange); 253 } 254 255 const onMessage = this._onMessageWrapper.bind(this); 256 chrome.runtime.onMessage.addListener(onMessage); 257 258 // On Chrome, this is for receiving messages sent with navigator.serviceWorker, which has the benefit of being able to transfer objects, but doesn't accept callbacks 259 (/** @type {ServiceWorkerGlobalScope & typeof globalThis} */ (globalThis)).addEventListener('message', this._onPmMessage.bind(this)); 260 (/** @type {ServiceWorkerGlobalScope & typeof globalThis} */ (globalThis)).addEventListener('messageerror', this._onPmMessageError.bind(this)); 261 262 if (this._canObservePermissionsChanges()) { 263 const onPermissionsChanged = this._onWebExtensionEventWrapper(this._onPermissionsChanged.bind(this)); 264 chrome.permissions.onAdded.addListener(onPermissionsChanged); 265 chrome.permissions.onRemoved.addListener(onPermissionsChanged); 266 } 267 268 chrome.runtime.onInstalled.addListener(this._onInstalled.bind(this)); 269 } 270 271 /** @type {import('api').PmApiHandler<'connectToDatabaseWorker'>} */ 272 async _onPmConnectToDatabaseWorker(_params, ports) { 273 if (ports !== null && ports.length > 0) { 274 await this._dictionaryDatabase.connectToDatabaseWorker(ports[0]); 275 } 276 } 277 278 /** @type {import('api').PmApiHandler<'registerOffscreenPort'>} */ 279 async _onPmApiRegisterOffscreenPort(_params, ports) { 280 if (ports !== null && ports.length > 0) { 281 await this._offscreen?.registerOffscreenPort(ports[0]); 282 } 283 } 284 285 /** 286 * @returns {Promise<void>} 287 */ 288 async _prepareInternal() { 289 try { 290 this._prepareInternalSync(); 291 292 this._permissions = await getAllPermissions(); 293 this._defaultBrowserActionTitle = this._getBrowserIconTitle(); 294 this._badgePrepareDelayTimer = setTimeout(() => { 295 this._badgePrepareDelayTimer = null; 296 this._updateBadge(); 297 }, 1000); 298 this._updateBadge(); 299 300 log.on('logGenericError', this._onLogGenericError.bind(this)); 301 302 await this._requestBuilder.prepare(); 303 await this._environment.prepare(); 304 if (this._offscreen !== null) { 305 await this._offscreen.prepare(); 306 } 307 this._clipboardReader.browser = this._environment.getInfo().browser; 308 309 // if this is Firefox and therefore not running in Service Worker, we need to use a SharedWorker to setup a MessageChannel to postMessage with the popup 310 if (self.constructor.name === 'Window') { 311 const sharedWorkerBridge = new SharedWorker(new URL('../comm/shared-worker-bridge.js', import.meta.url), {type: 'module'}); 312 sharedWorkerBridge.port.postMessage({action: 'registerBackendPort'}); 313 sharedWorkerBridge.port.addEventListener('message', (/** @type {MessageEvent} */ e) => { 314 // connectToBackend2 315 e.ports[0].onmessage = this._onPmMessage.bind(this); 316 }); 317 sharedWorkerBridge.port.addEventListener('messageerror', this._onPmMessageError.bind(this)); 318 sharedWorkerBridge.port.start(); 319 } 320 try { 321 await this._dictionaryDatabase.prepare(); 322 } catch (e) { 323 log.error(e); 324 } 325 326 void this._translator.prepare(); 327 328 await this._optionsUtil.prepare(); 329 this._defaultAnkiFieldTemplates = (await fetchText('/data/templates/default-anki-field-templates.handlebars')).trim(); 330 this._options = await this._optionsUtil.load(); 331 332 this._applyOptions('background'); 333 334 this._attachOmniboxListener(); 335 336 const options = this._getProfileOptions({current: true}, false); 337 if (options.general.showGuide) { 338 void this._openWelcomeGuidePageOnce(); 339 } 340 341 this._clipboardMonitor.on('change', this._onClipboardTextChange.bind(this)); 342 343 this._sendMessageAllTabsIgnoreResponse({action: 'applicationBackendReady'}); 344 this._sendMessageIgnoreResponse({action: 'applicationBackendReady'}); 345 } catch (e) { 346 log.error(e); 347 throw e; 348 } finally { 349 if (this._badgePrepareDelayTimer !== null) { 350 clearTimeout(this._badgePrepareDelayTimer); 351 this._badgePrepareDelayTimer = null; 352 } 353 } 354 } 355 356 // Event handlers 357 358 /** 359 * @param {import('clipboard-monitor').EventArgument<'change'>} details 360 */ 361 async _onClipboardTextChange({text}) { 362 // Only update if tab does not exist 363 if (await this._tabExists('/search.html')) { return; } 364 365 const { 366 general: {language}, 367 clipboard: {maximumSearchLength}, 368 } = this._getProfileOptions({current: true}, false); 369 if (!isTextLookupWorthy(text, language)) { return; } 370 if (text.length > maximumSearchLength) { 371 text = text.substring(0, maximumSearchLength); 372 } 373 try { 374 const {tab, created} = await this._getOrCreateSearchPopupWrapper(); 375 const {id} = tab; 376 if (typeof id !== 'number') { 377 throw new Error('Tab does not have an id'); 378 } 379 await this._focusTab(tab); 380 await this._updateSearchQuery(id, text, !created); 381 } catch (e) { 382 // NOP 383 } 384 } 385 386 /** 387 * @param {import('log').Events['logGenericError']} params 388 */ 389 _onLogGenericError({level}) { 390 const levelValue = logErrorLevelToNumber(level); 391 const currentLogErrorLevel = this._logErrorLevel !== null ? logErrorLevelToNumber(this._logErrorLevel) : 0; 392 if (levelValue <= currentLogErrorLevel) { return; } 393 394 this._logErrorLevel = level; 395 this._updateBadge(); 396 } 397 398 // WebExtension event handlers (with prepared checks) 399 400 /** 401 * @template {(...args: import('core').SafeAny[]) => void} T 402 * @param {T} handler 403 * @returns {T} 404 */ 405 _onWebExtensionEventWrapper(handler) { 406 return /** @type {T} */ ((...args) => { 407 if (this._isPrepared) { 408 // This is using SafeAny to just forward the parameters 409 // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 410 handler(...args); 411 return; 412 } 413 414 this._prepareCompletePromise.then( 415 () => { 416 // This is using SafeAny to just forward the parameters 417 // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 418 handler(...args); 419 }, 420 () => {}, // NOP 421 ); 422 }); 423 } 424 425 /** @type {import('extension').ChromeRuntimeOnMessageCallback<import('api').ApiMessageAny>} */ 426 _onMessageWrapper(message, sender, sendResponse) { 427 if (this._isPrepared) { 428 return this._onMessage(message, sender, sendResponse); 429 } 430 431 this._prepareCompletePromise.then( 432 () => { this._onMessage(message, sender, sendResponse); }, 433 () => { sendResponse(); }, 434 ); 435 return true; 436 } 437 438 // WebExtension event handlers 439 440 /** 441 * @param {string} command 442 */ 443 _onCommand(command) { 444 this._runCommand(command, void 0); 445 } 446 447 /** 448 * @param {import('api').ApiMessageAny} message 449 * @param {chrome.runtime.MessageSender} sender 450 * @param {(response?: unknown) => void} callback 451 * @returns {boolean} 452 */ 453 _onMessage({action, params}, sender, callback) { 454 return invokeApiMapHandler(this._apiMap, action, params, [sender], callback); 455 } 456 457 /** 458 * @param {MessageEvent<import('api').PmApiMessageAny>} event 459 * @returns {boolean} 460 */ 461 _onPmMessage(event) { 462 const {action, params} = event.data; 463 return invokeApiMapHandler(this._pmApiMap, action, params, [event.ports], () => {}); 464 } 465 466 /** 467 * @param {MessageEvent<import('api').PmApiMessageAny>} event 468 */ 469 _onPmMessageError(event) { 470 const error = new ExtensionError('Backend: Error receiving message via postMessage'); 471 error.data = event; 472 log.error(error); 473 } 474 475 476 /** 477 * @param {chrome.tabs.ZoomChangeInfo} event 478 */ 479 _onZoomChange({tabId, oldZoomFactor, newZoomFactor}) { 480 this._sendMessageTabIgnoreResponse(tabId, {action: 'applicationZoomChanged', params: {oldZoomFactor, newZoomFactor}}, {}); 481 } 482 483 /** 484 * @returns {void} 485 */ 486 _onPermissionsChanged() { 487 void this._checkPermissions(); 488 } 489 490 /** 491 * @param {chrome.runtime.InstalledDetails} event 492 */ 493 _onInstalled({reason}) { 494 if (reason !== 'install') { return; } 495 void this._requestPersistentStorage(); 496 } 497 498 // Message handlers 499 500 /** @type {import('api').ApiHandler<'applicationReady'>} */ 501 _onApiApplicationReady(_params, sender) { 502 const {tab, frameId} = sender; 503 if (!tab || typeof frameId !== 'number') { return; } 504 const {id} = tab; 505 if (typeof id !== 'number') { return; } 506 const key = `${id}:${frameId}`; 507 const handlers = this._applicationReadyHandlers.get(key); 508 if (typeof handlers === 'undefined') { return; } 509 for (const handler of handlers) { 510 handler(); 511 } 512 this._applicationReadyHandlers.delete(key); 513 } 514 515 /** @type {import('api').ApiHandler<'requestBackendReadySignal'>} */ 516 _onApiRequestBackendReadySignal(_params, sender) { 517 // Tab ID isn't set in background (e.g. browser_action) 518 /** @type {import('application').ApiMessage<'applicationBackendReady'>} */ 519 const data = {action: 'applicationBackendReady'}; 520 if (typeof sender.tab === 'undefined') { 521 this._sendMessageIgnoreResponse(data); 522 return false; 523 } else { 524 const {id} = sender.tab; 525 if (typeof id === 'number') { 526 this._sendMessageTabIgnoreResponse(id, data, {}); 527 } 528 return true; 529 } 530 } 531 532 /** @type {import('api').ApiHandler<'optionsGet'>} */ 533 _onApiOptionsGet({optionsContext}) { 534 return this._getProfileOptions(optionsContext, false); 535 } 536 537 /** @type {import('api').ApiHandler<'optionsGetFull'>} */ 538 _onApiOptionsGetFull() { 539 return this._getOptionsFull(false); 540 } 541 542 /** @type {import('api').ApiHandler<'kanjiFind'>} */ 543 async _onApiKanjiFind({text, optionsContext}) { 544 const options = this._getProfileOptions(optionsContext, false); 545 const {general: {maxResults}} = options; 546 const findKanjiOptions = this._getTranslatorFindKanjiOptions(options); 547 const dictionaryEntries = await this._translator.findKanji(text, findKanjiOptions); 548 dictionaryEntries.splice(maxResults); 549 return dictionaryEntries; 550 } 551 552 /** @type {import('api').ApiHandler<'termsFind'>} */ 553 async _onApiTermsFind({text, details, optionsContext}) { 554 const options = this._getProfileOptions(optionsContext, false); 555 const {general: {resultOutputMode: mode, maxResults}} = options; 556 const findTermsOptions = this._getTranslatorFindTermsOptions(mode, details, options); 557 const {dictionaryEntries, originalTextLength} = await this._translator.findTerms(mode, text, findTermsOptions); 558 dictionaryEntries.splice(maxResults); 559 return {dictionaryEntries, originalTextLength}; 560 } 561 562 /** @type {import('api').ApiHandler<'parseText'>} */ 563 async _onApiParseText({text, optionsContext, scanLength, useInternalParser, useMecabParser}) { 564 /** @type {import('api').ParseTextResultItem[]} */ 565 const results = []; 566 567 const [internalResults, mecabResults] = await Promise.all([ 568 useInternalParser ? 569 (Array.isArray(text) ? 570 Promise.all(text.map((t) => this._textParseScanning(t, scanLength, optionsContext))) : 571 Promise.all([this._textParseScanning(text, scanLength, optionsContext)])) : 572 null, 573 useMecabParser ? 574 (Array.isArray(text) ? 575 Promise.all(text.map((t) => this._textParseMecab(t))) : 576 Promise.all([this._textParseMecab(text)])) : 577 null, 578 ]); 579 580 if (internalResults !== null) { 581 for (const [index, internalResult] of internalResults.entries()) { 582 results.push({ 583 id: 'scan', 584 source: 'scanning-parser', 585 dictionary: null, 586 index, 587 content: internalResult, 588 }); 589 } 590 } 591 if (mecabResults !== null) { 592 for (const [index, mecabResult] of mecabResults.entries()) { 593 for (const [dictionary, content] of mecabResult) { 594 results.push({ 595 id: `mecab-${dictionary}`, 596 source: 'mecab', 597 dictionary, 598 index, 599 content, 600 }); 601 } 602 } 603 } 604 605 return results; 606 } 607 608 /** @type {import('api').ApiHandler<'getAnkiConnectVersion'>} */ 609 async _onApiGetAnkiConnectVersion() { 610 return await this._anki.getVersion(); 611 } 612 613 /** @type {import('api').ApiHandler<'isAnkiConnected'>} */ 614 async _onApiIsAnkiConnected() { 615 return await this._anki.isConnected(); 616 } 617 618 /** @type {import('api').ApiHandler<'addAnkiNote'>} */ 619 async _onApiAddAnkiNote({note}) { 620 return await this._anki.addNote(note); 621 } 622 623 /** @type {import('api').ApiHandler<'updateAnkiNote'>} */ 624 async _onApiUpdateAnkiNote({noteWithId}) { 625 return await this._anki.updateNoteFields(noteWithId); 626 } 627 628 /** 629 * Removes all fields except the first field from an array of notes 630 * @param {import('anki').Note[]} notes 631 * @returns {import('anki').Note[]} 632 */ 633 _stripNotesArray(notes) { 634 const newNotes = structuredClone(notes); 635 for (let i = 0; i < newNotes.length; i++) { 636 if (Object.keys(newNotes[i].fields).length === 0) { continue; } 637 const [firstField, firstFieldValue] = Object.entries(newNotes[i].fields)[0]; 638 newNotes[i].fields = {}; 639 newNotes[i].fields[firstField] = firstFieldValue; 640 } 641 return newNotes; 642 } 643 644 /** 645 * @param {import('anki').Note[]} notes 646 * @param {import('anki').Note[]} notesStrippedNoDuplicates 647 * @returns {Promise<{ note: import('anki').Note, isDuplicate: boolean }[]>} 648 */ 649 async _findDuplicates(notes, notesStrippedNoDuplicates) { 650 const canAddNotesWithErrors = await this._anki.canAddNotesWithErrorDetail(notesStrippedNoDuplicates); 651 return canAddNotesWithErrors.map((item, i) => ({ 652 note: notes[i], 653 isDuplicate: item.error === null ? 654 false : 655 item.error.includes('cannot create note because it is a duplicate'), 656 })); 657 } 658 659 /** 660 * @param {import('anki').Note[]} notes 661 * @param {import('anki').Note[]} notesStrippedNoDuplicates 662 * @param {import('anki').Note[]} notesStrippedDuplicates 663 * @returns {Promise<{ note: import('anki').Note, isDuplicate: boolean }[]>} 664 */ 665 async _findDuplicatesFallback(notes, notesStrippedNoDuplicates, notesStrippedDuplicates) { 666 const [withDuplicatesAllowed, noDuplicatesAllowed] = await Promise.all([ 667 this._anki.canAddNotes(notesStrippedDuplicates), 668 this._anki.canAddNotes(notesStrippedNoDuplicates), 669 ]); 670 671 return withDuplicatesAllowed.map((item, i) => ({ 672 note: notes[i], 673 isDuplicate: item !== noDuplicatesAllowed[i], 674 })); 675 } 676 677 /** 678 * @param {import('anki').Note[]} notes 679 * @returns {Promise<import('backend').CanAddResults>} 680 */ 681 async partitionAddibleNotes(notes) { 682 // strip all fields except the first from notes before dupe checking 683 // minimizes the amount of data being sent and reduce network latency and AnkiConnect latency 684 const strippedNotes = this._stripNotesArray(notes); 685 686 // `allowDuplicate` is on for all notes by default, so we temporarily set it to false 687 // to check which notes are duplicates. 688 const notesNoDuplicatesAllowed = strippedNotes.map((note) => ({...note, options: {...note.options, allowDuplicate: false}})); 689 690 try { 691 return await this._findDuplicates(notes, notesNoDuplicatesAllowed); 692 } catch (e) { 693 // User has older anki-connect that does not support canAddNotesWithErrorDetail 694 if (e instanceof ExtensionError && e.message.includes('Anki error: unsupported action')) { 695 return await this._findDuplicatesFallback(notes, notesNoDuplicatesAllowed, strippedNotes); 696 } 697 698 throw e; 699 } 700 } 701 702 /** @type {import('api').ApiHandler<'getAnkiNoteInfo'>} */ 703 async _onApiGetAnkiNoteInfo({notes, fetchAdditionalInfo}) { 704 const canAddArray = await this.partitionAddibleNotes(notes); 705 706 /** @type {import('anki').NoteInfoWrapper[]} */ 707 const results = []; 708 709 /** @type {import('anki').Note[]} */ 710 const duplicateNotes = []; 711 712 /** @type {number[]} */ 713 const originalIndices = []; 714 715 for (let i = 0; i < canAddArray.length; i++) { 716 if (canAddArray[i].isDuplicate) { 717 duplicateNotes.push(canAddArray[i].note); 718 // Keep original indices to locate duplicate inside `duplicateNoteIds` 719 originalIndices.push(i); 720 } 721 } 722 723 const duplicateNoteIds = 724 duplicateNotes.length > 0 ? 725 await this._anki.findNoteIds(duplicateNotes) : 726 []; 727 728 for (let i = 0; i < canAddArray.length; ++i) { 729 const {note, isDuplicate} = canAddArray[i]; 730 731 const valid = isNoteDataValid(note); 732 733 if (isDuplicate && duplicateNoteIds[originalIndices.indexOf(i)].length === 0) { 734 duplicateNoteIds[originalIndices.indexOf(i)] = [INVALID_NOTE_ID]; 735 } 736 737 const noteIds = isDuplicate ? duplicateNoteIds[originalIndices.indexOf(i)] : null; 738 const noteInfos = (fetchAdditionalInfo && noteIds !== null && noteIds.length > 0) ? await this._notesCardsInfo(noteIds) : []; 739 740 const info = { 741 canAdd: valid, 742 valid, 743 noteIds: noteIds, 744 noteInfos: noteInfos, 745 }; 746 747 results.push(info); 748 } 749 750 return results; 751 } 752 753 /** 754 * @param {number[]} noteIds 755 * @returns {Promise<(?import('anki').NoteInfo)[]>} 756 */ 757 async _notesCardsInfo(noteIds) { 758 const notesInfo = await this._anki.notesInfo(noteIds); 759 /** @type {number[]} */ 760 // @ts-expect-error - ts is not smart enough to realize that filtering !!x removes null and undefined 761 const cardIds = notesInfo.flatMap((x) => x?.cards).filter((x) => !!x); 762 const cardsInfo = await this._anki.cardsInfo(cardIds); 763 for (let i = 0; i < notesInfo.length; i++) { 764 if (notesInfo[i] !== null) { 765 const cardInfo = cardsInfo.find((x) => x?.noteId === notesInfo[i]?.noteId); 766 if (cardInfo) { 767 notesInfo[i]?.cardsInfo.push(cardInfo); 768 } 769 } 770 } 771 return notesInfo; 772 } 773 774 /** @type {import('api').ApiHandler<'injectAnkiNoteMedia'>} */ 775 async _onApiInjectAnkiNoteMedia({timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails}) { 776 return await this._injectAnkNoteMedia( 777 this._anki, 778 timestamp, 779 definitionDetails, 780 audioDetails, 781 screenshotDetails, 782 clipboardDetails, 783 dictionaryMediaDetails, 784 ); 785 } 786 787 /** @type {import('api').ApiHandler<'viewNotes'>} */ 788 async _onApiViewNotes({noteIds, mode, allowFallback}) { 789 if (noteIds.length === 1 && mode === 'edit') { 790 try { 791 await this._anki.guiEditNote(noteIds[0]); 792 return 'edit'; 793 } catch (e) { 794 if (!(e instanceof Error && this._anki.isErrorUnsupportedAction(e))) { 795 throw e; 796 } else if (!allowFallback) { 797 throw new Error('Mode not supported'); 798 } 799 } 800 } 801 await this._anki.guiBrowseNotes(noteIds); 802 return 'browse'; 803 } 804 805 /** @type {import('api').ApiHandler<'suspendAnkiCardsForNote'>} */ 806 async _onApiSuspendAnkiCardsForNote({noteId}) { 807 const cardIds = await this._anki.findCardsForNote(noteId); 808 const count = cardIds.length; 809 if (count > 0) { 810 const okay = await this._anki.suspendCards(cardIds); 811 if (!okay) { return 0; } 812 } 813 return count; 814 } 815 816 /** @type {import('api').ApiHandler<'commandExec'>} */ 817 _onApiCommandExec({command, params}) { 818 return this._runCommand(command, params); 819 } 820 821 /** @type {import('api').ApiHandler<'getTermAudioInfoList'>} */ 822 async _onApiGetTermAudioInfoList({source, term, reading, languageSummary}) { 823 return await this._audioDownloader.getTermAudioInfoList(source, term, reading, languageSummary); 824 } 825 826 /** @type {import('api').ApiHandler<'sendMessageToFrame'>} */ 827 _onApiSendMessageToFrame({frameId: targetFrameId, message}, sender) { 828 if (!sender) { return false; } 829 const {tab} = sender; 830 if (!tab) { return false; } 831 const {id} = tab; 832 if (typeof id !== 'number') { return false; } 833 const {frameId} = sender; 834 /** @type {import('application').ApiMessageAny} */ 835 const message2 = {...message, frameId}; 836 this._sendMessageTabIgnoreResponse(id, message2, {frameId: targetFrameId}); 837 return true; 838 } 839 840 /** @type {import('api').ApiHandler<'broadcastTab'>} */ 841 _onApiBroadcastTab({message}, sender) { 842 if (!sender) { return false; } 843 const {tab} = sender; 844 if (!tab) { return false; } 845 const {id} = tab; 846 if (typeof id !== 'number') { return false; } 847 const {frameId} = sender; 848 /** @type {import('application').ApiMessageAny} */ 849 const message2 = {...message, frameId}; 850 this._sendMessageTabIgnoreResponse(id, message2, {}); 851 return true; 852 } 853 854 /** @type {import('api').ApiHandler<'frameInformationGet'>} */ 855 _onApiFrameInformationGet(_params, sender) { 856 const tab = sender.tab; 857 const tabId = tab ? tab.id : void 0; 858 const frameId = sender.frameId; 859 return { 860 tabId: typeof tabId === 'number' ? tabId : null, 861 frameId: typeof frameId === 'number' ? frameId : null, 862 }; 863 } 864 865 /** @type {import('api').ApiHandler<'injectStylesheet'>} */ 866 async _onApiInjectStylesheet({type, value}, sender) { 867 const {frameId, tab} = sender; 868 if (typeof tab !== 'object' || tab === null || typeof tab.id !== 'number') { throw new Error('Invalid tab'); } 869 return await injectStylesheet(type, value, tab.id, frameId, false); 870 } 871 872 /** @type {import('api').ApiHandler<'getStylesheetContent'>} */ 873 async _onApiGetStylesheetContent({url}) { 874 if (!url.startsWith('/') || url.startsWith('//') || !url.endsWith('.css')) { 875 throw new Error('Invalid URL'); 876 } 877 return await fetchText(url); 878 } 879 880 /** @type {import('api').ApiHandler<'getEnvironmentInfo'>} */ 881 _onApiGetEnvironmentInfo() { 882 return this._environment.getInfo(); 883 } 884 885 /** @type {import('api').ApiHandler<'clipboardGet'>} */ 886 async _onApiClipboardGet() { 887 return this._clipboardReader.getText(false); 888 } 889 890 /** @type {import('api').ApiHandler<'getZoom'>} */ 891 _onApiGetZoom(_params, sender) { 892 return new Promise((resolve, reject) => { 893 if (!sender || !sender.tab) { 894 reject(new Error('Invalid tab')); 895 return; 896 } 897 898 const tabId = sender.tab.id; 899 if (!( 900 typeof tabId === 'number' && 901 chrome.tabs !== null && 902 typeof chrome.tabs === 'object' && 903 typeof chrome.tabs.getZoom === 'function' 904 )) { 905 // Not supported 906 resolve({zoomFactor: 1}); 907 return; 908 } 909 chrome.tabs.getZoom(tabId, (zoomFactor) => { 910 const e = chrome.runtime.lastError; 911 if (e) { 912 reject(new Error(e.message)); 913 } else { 914 resolve({zoomFactor}); 915 } 916 }); 917 }); 918 } 919 920 /** @type {import('api').ApiHandler<'getDefaultAnkiFieldTemplates'>} */ 921 _onApiGetDefaultAnkiFieldTemplates() { 922 return /** @type {string} */ (this._defaultAnkiFieldTemplates); 923 } 924 925 /** @type {import('api').ApiHandler<'getDictionaryInfo'>} */ 926 async _onApiGetDictionaryInfo() { 927 return await this._dictionaryDatabase.getDictionaryInfo(); 928 } 929 930 /** @type {import('api').ApiHandler<'purgeDatabase'>} */ 931 async _onApiPurgeDatabase() { 932 await this._dictionaryDatabase.purge(); 933 this._triggerDatabaseUpdated('dictionary', 'purge'); 934 } 935 936 /** @type {import('api').ApiHandler<'getMedia'>} */ 937 async _onApiGetMedia({targets}) { 938 return await this._getNormalizedDictionaryDatabaseMedia(targets); 939 } 940 941 /** @type {import('api').ApiHandler<'logGenericErrorBackend'>} */ 942 _onApiLogGenericErrorBackend({error, level, context}) { 943 log.logGenericError(ExtensionError.deserialize(error), level, context); 944 } 945 946 /** @type {import('api').ApiHandler<'logIndicatorClear'>} */ 947 _onApiLogIndicatorClear() { 948 if (this._logErrorLevel === null) { return; } 949 this._logErrorLevel = null; 950 this._updateBadge(); 951 } 952 953 /** @type {import('api').ApiHandler<'modifySettings'>} */ 954 _onApiModifySettings({targets, source}) { 955 return this._modifySettings(targets, source); 956 } 957 958 /** @type {import('api').ApiHandler<'getSettings'>} */ 959 _onApiGetSettings({targets}) { 960 const results = []; 961 for (const target of targets) { 962 try { 963 const result = this._getSetting(target); 964 results.push({result: clone(result)}); 965 } catch (e) { 966 results.push({error: ExtensionError.serialize(e)}); 967 } 968 } 969 return results; 970 } 971 972 /** @type {import('api').ApiHandler<'setAllSettings'>} */ 973 async _onApiSetAllSettings({value, source}) { 974 this._optionsUtil.validate(value); 975 this._options = clone(value); 976 await this._saveOptions(source); 977 } 978 979 /** @type {import('api').ApiHandlerNoExtraArgs<'getOrCreateSearchPopup'>} */ 980 async _onApiGetOrCreateSearchPopup({focus = false, text}) { 981 const {tab, created} = await this._getOrCreateSearchPopupWrapper(); 982 if (focus === true || (focus === 'ifCreated' && created)) { 983 await this._focusTab(tab); 984 } 985 if (typeof text === 'string') { 986 const {id} = tab; 987 if (typeof id === 'number') { 988 await this._updateSearchQuery(id, text, !created); 989 } 990 } 991 const {id} = tab; 992 return {tabId: typeof id === 'number' ? id : null, windowId: tab.windowId}; 993 } 994 995 /** @type {import('api').ApiHandler<'isTabSearchPopup'>} */ 996 async _onApiIsTabSearchPopup({tabId}) { 997 const baseUrl = chrome.runtime.getURL('/search.html'); 998 const tab = typeof tabId === 'number' ? await this._checkTabUrl(tabId, (url) => url !== null && url.startsWith(baseUrl)) : null; 999 return (tab !== null); 1000 } 1001 1002 /** @type {import('api').ApiHandler<'triggerDatabaseUpdated'>} */ 1003 _onApiTriggerDatabaseUpdated({type, cause}) { 1004 this._triggerDatabaseUpdated(type, cause); 1005 } 1006 1007 /** @type {import('api').ApiHandler<'testMecab'>} */ 1008 async _onApiTestMecab() { 1009 if (!this._mecab.isEnabled()) { 1010 throw new Error('MeCab not enabled'); 1011 } 1012 1013 let permissionsOkay = false; 1014 try { 1015 permissionsOkay = await hasPermissions({permissions: ['nativeMessaging']}); 1016 } catch (e) { 1017 // NOP 1018 } 1019 if (!permissionsOkay) { 1020 throw new Error('Insufficient permissions'); 1021 } 1022 1023 const disconnect = !this._mecab.isConnected(); 1024 try { 1025 const version = await this._mecab.getVersion(); 1026 if (version === null) { 1027 throw new Error('Could not connect to native MeCab component'); 1028 } 1029 1030 const localVersion = this._mecab.getLocalVersion(); 1031 if (version !== localVersion) { 1032 throw new Error(`MeCab component version not supported: ${version}`); 1033 } 1034 } finally { 1035 // Disconnect if the connection was previously disconnected 1036 if (disconnect && this._mecab.isEnabled() && this._mecab.isActive()) { 1037 this._mecab.disconnect(); 1038 } 1039 } 1040 1041 return true; 1042 } 1043 1044 /** @type {import('api').ApiHandler<'testYomitanApi'>} */ 1045 async _onApiTestYomitanApi({url}) { 1046 if (!this._yomitanApi.isEnabled()) { 1047 throw new Error('Yomitan Api not enabled'); 1048 } 1049 1050 let permissionsOkay = false; 1051 try { 1052 permissionsOkay = await hasPermissions({permissions: ['nativeMessaging']}); 1053 } catch (e) { 1054 // NOP 1055 } 1056 if (!permissionsOkay) { 1057 throw new Error('Insufficient permissions'); 1058 } 1059 1060 const disconnect = !this._yomitanApi.isConnected(); 1061 try { 1062 const version = await this._yomitanApi.getRemoteVersion(url); 1063 if (version === null) { 1064 throw new Error('Could not connect to native Yomitan API component'); 1065 } 1066 1067 const localVersion = this._yomitanApi.getLocalVersion(); 1068 if (version !== localVersion) { 1069 throw new Error(`Yomitan API component version not supported: ${version}`); 1070 } 1071 } finally { 1072 // Disconnect if the connection was previously disconnected 1073 if (disconnect && this._yomitanApi.isEnabled()) { 1074 this._yomitanApi.disconnect(); 1075 } 1076 } 1077 1078 return true; 1079 } 1080 1081 /** @type {import('api').ApiHandler<'isTextLookupWorthy'>} */ 1082 _onApiIsTextLookupWorthy({text, language}) { 1083 return isTextLookupWorthy(text, language); 1084 } 1085 1086 /** @type {import('api').ApiHandler<'getTermFrequencies'>} */ 1087 async _onApiGetTermFrequencies({termReadingList, dictionaries}) { 1088 return await this._translator.getTermFrequencies(termReadingList, dictionaries); 1089 } 1090 1091 /** @type {import('api').ApiHandler<'findAnkiNotes'>} */ 1092 async _onApiFindAnkiNotes({query}) { 1093 return await this._anki.findNotes(query); 1094 } 1095 1096 /** @type {import('api').ApiHandler<'openCrossFramePort'>} */ 1097 _onApiOpenCrossFramePort({targetTabId, targetFrameId}, sender) { 1098 const sourceTabId = (sender && sender.tab ? sender.tab.id : null); 1099 if (typeof sourceTabId !== 'number') { 1100 throw new Error('Port does not have an associated tab ID'); 1101 } 1102 const sourceFrameId = sender.frameId; 1103 if (typeof sourceFrameId !== 'number') { 1104 throw new Error('Port does not have an associated frame ID'); 1105 } 1106 1107 /** @type {import('cross-frame-api').CrossFrameCommunicationPortDetails} */ 1108 const sourceDetails = { 1109 name: 'cross-frame-communication-port', 1110 otherTabId: targetTabId, 1111 otherFrameId: targetFrameId, 1112 }; 1113 /** @type {import('cross-frame-api').CrossFrameCommunicationPortDetails} */ 1114 const targetDetails = { 1115 name: 'cross-frame-communication-port', 1116 otherTabId: sourceTabId, 1117 otherFrameId: sourceFrameId, 1118 }; 1119 /** @type {?chrome.runtime.Port} */ 1120 let sourcePort = chrome.tabs.connect(sourceTabId, {frameId: sourceFrameId, name: JSON.stringify(sourceDetails)}); 1121 /** @type {?chrome.runtime.Port} */ 1122 let targetPort = chrome.tabs.connect(targetTabId, {frameId: targetFrameId, name: JSON.stringify(targetDetails)}); 1123 1124 const cleanup = () => { 1125 this._checkLastError(chrome.runtime.lastError); 1126 if (targetPort !== null) { 1127 targetPort.disconnect(); 1128 targetPort = null; 1129 } 1130 if (sourcePort !== null) { 1131 sourcePort.disconnect(); 1132 sourcePort = null; 1133 } 1134 }; 1135 1136 sourcePort.onMessage.addListener((message) => { 1137 if (targetPort !== null) { targetPort.postMessage(message); } 1138 }); 1139 targetPort.onMessage.addListener((message) => { 1140 if (sourcePort !== null) { sourcePort.postMessage(message); } 1141 }); 1142 sourcePort.onDisconnect.addListener(cleanup); 1143 targetPort.onDisconnect.addListener(cleanup); 1144 1145 return {targetTabId, targetFrameId}; 1146 } 1147 1148 /** @type {import('api').ApiHandler<'getLanguageSummaries'>} */ 1149 _onApiGetLanguageSummaries() { 1150 return getLanguageSummaries(); 1151 } 1152 1153 /** @type {import('api').ApiHandler<'heartbeat'>} */ 1154 _onApiHeartbeat() { 1155 return void 0; 1156 } 1157 1158 /** @type {import('api').ApiHandler<'forceSync'>} */ 1159 async _onApiForceSync() { 1160 try { 1161 await this._anki.makeAnkiSync(); 1162 } catch (e) { 1163 log.error(e); 1164 throw e; 1165 } 1166 return void 0; 1167 } 1168 1169 // Command handlers 1170 1171 /** 1172 * @param {undefined|{mode: import('backend').Mode, query?: string}} params 1173 */ 1174 async _onCommandOpenSearchPage(params) { 1175 /** @type {import('backend').Mode} */ 1176 let mode = 'existingOrNewTab'; 1177 let query = ''; 1178 if (typeof params === 'object' && params !== null) { 1179 mode = this._normalizeOpenSettingsPageMode(params.mode, mode); 1180 const paramsQuery = params.query; 1181 if (typeof paramsQuery === 'string') { query = paramsQuery; } 1182 } 1183 1184 const baseUrl = chrome.runtime.getURL('/search.html'); 1185 /** @type {{[key: string]: string}} */ 1186 const queryParams = {}; 1187 if (query.length > 0) { queryParams.query = query; } 1188 const queryString = new URLSearchParams(queryParams).toString(); 1189 let queryUrl = baseUrl; 1190 if (queryString.length > 0) { 1191 queryUrl += `?${queryString}`; 1192 } 1193 1194 /** @type {import('backend').FindTabsPredicate} */ 1195 const predicate = ({url}) => { 1196 if (url === null || !url.startsWith(baseUrl)) { return false; } 1197 const parsedUrl = new URL(url); 1198 const parsedBaseUrl = `${parsedUrl.origin}${parsedUrl.pathname}`; 1199 const parsedMode = parsedUrl.searchParams.get('mode'); 1200 return parsedBaseUrl === baseUrl && (parsedMode === mode || (!parsedMode && mode === 'existingOrNewTab')); 1201 }; 1202 1203 const openInTab = async () => { 1204 const tabInfo = /** @type {?import('backend').TabInfo} */ (await this._findTabs(1000, false, predicate, false)); 1205 if (tabInfo !== null) { 1206 const {tab} = tabInfo; 1207 const {id} = tab; 1208 if (typeof id === 'number') { 1209 await this._focusTab(tab); 1210 if (queryParams.query) { 1211 await this._updateSearchQuery(id, queryParams.query, true); 1212 } 1213 return true; 1214 } 1215 } 1216 return false; 1217 }; 1218 1219 switch (mode) { 1220 case 'existingOrNewTab': 1221 try { 1222 if (await openInTab()) { return; } 1223 } catch (e) { 1224 // NOP 1225 } 1226 await this._createTab(queryUrl); 1227 return; 1228 case 'newTab': 1229 await this._createTab(queryUrl); 1230 return; 1231 case 'popup': 1232 return; 1233 } 1234 } 1235 1236 /** 1237 * @returns {Promise<void>} 1238 */ 1239 async _onCommandOpenInfoPage() { 1240 await this._openInfoPage(); 1241 } 1242 1243 /** 1244 * @param {undefined|{mode: import('backend').Mode}} params 1245 */ 1246 async _onCommandOpenSettingsPage(params) { 1247 /** @type {import('backend').Mode} */ 1248 let mode = 'existingOrNewTab'; 1249 if (typeof params === 'object' && params !== null) { 1250 mode = this._normalizeOpenSettingsPageMode(params.mode, mode); 1251 } 1252 await this._openSettingsPage(mode); 1253 } 1254 1255 /** 1256 * @returns {Promise<void>} 1257 */ 1258 async _onCommandToggleTextScanning() { 1259 const options = this._getProfileOptions({current: true}, false); 1260 /** @type {import('settings-modifications').ScopedModificationSet} */ 1261 const modification = { 1262 action: 'set', 1263 path: 'general.enable', 1264 value: !options.general.enable, 1265 scope: 'profile', 1266 optionsContext: {current: true}, 1267 }; 1268 await this._modifySettings([modification], 'backend'); 1269 } 1270 1271 /** 1272 * @returns {Promise<void>} 1273 */ 1274 async _onCommandOpenPopupWindow() { 1275 await this._onApiGetOrCreateSearchPopup({focus: true}); 1276 } 1277 1278 // Utilities 1279 1280 /** 1281 * @param {import('settings-modifications').ScopedModification[]} targets 1282 * @param {string} source 1283 * @returns {Promise<import('core').Response<import('settings-modifications').ModificationResult>[]>} 1284 */ 1285 async _modifySettings(targets, source) { 1286 /** @type {import('core').Response<import('settings-modifications').ModificationResult>[]} */ 1287 const results = []; 1288 for (const target of targets) { 1289 try { 1290 const result = this._modifySetting(target); 1291 results.push({result: clone(result)}); 1292 } catch (e) { 1293 results.push({error: ExtensionError.serialize(e)}); 1294 } 1295 } 1296 await this._saveOptions(source); 1297 return results; 1298 } 1299 1300 /** 1301 * @returns {Promise<{tab: chrome.tabs.Tab, created: boolean}>} 1302 */ 1303 _getOrCreateSearchPopupWrapper() { 1304 if (this._searchPopupTabCreatePromise === null) { 1305 const promise = this._getOrCreateSearchPopup(); 1306 this._searchPopupTabCreatePromise = promise; 1307 void promise.then(() => { this._searchPopupTabCreatePromise = null; }); 1308 } 1309 return this._searchPopupTabCreatePromise; 1310 } 1311 1312 /** 1313 * @returns {Promise<{tab: chrome.tabs.Tab, created: boolean}>} 1314 */ 1315 async _getOrCreateSearchPopup() { 1316 // Use existing tab 1317 const baseUrl = chrome.runtime.getURL('/search.html'); 1318 /** 1319 * @param {?string} url 1320 * @returns {boolean} 1321 */ 1322 const urlPredicate = (url) => url !== null && url.startsWith(baseUrl); 1323 if (this._searchPopupTabId !== null) { 1324 const tab = await this._checkTabUrl(this._searchPopupTabId, urlPredicate); 1325 if (tab !== null) { 1326 return {tab, created: false}; 1327 } 1328 this._searchPopupTabId = null; 1329 } 1330 1331 // Find existing tab 1332 const existingTabInfo = await this._findSearchPopupTab(urlPredicate); 1333 if (existingTabInfo !== null) { 1334 const existingTab = existingTabInfo.tab; 1335 const {id} = existingTab; 1336 if (typeof id === 'number') { 1337 this._searchPopupTabId = id; 1338 return {tab: existingTab, created: false}; 1339 } 1340 } 1341 1342 // chrome.windows not supported (e.g. on Firefox mobile) 1343 if (!isObjectNotArray(chrome.windows)) { 1344 throw new Error('Window creation not supported'); 1345 } 1346 1347 // Create a new window 1348 const options = this._getProfileOptions({current: true}, false); 1349 const createData = this._getSearchPopupWindowCreateData(baseUrl, options); 1350 const {popupWindow: {windowState}} = options; 1351 const popupWindow = await this._createWindow(createData); 1352 if (windowState !== 'normal' && typeof popupWindow.id === 'number') { 1353 await this._updateWindow(popupWindow.id, {state: windowState}); 1354 } 1355 1356 const {tabs} = popupWindow; 1357 if (!Array.isArray(tabs) || tabs.length === 0) { 1358 throw new Error('Created window did not contain a tab'); 1359 } 1360 1361 const tab = tabs[0]; 1362 const {id} = tab; 1363 if (typeof id !== 'number') { 1364 throw new Error('Tab does not have an id'); 1365 } 1366 await this._waitUntilTabFrameIsReady(id, 0, 2000); 1367 1368 await this._sendMessageTabPromise( 1369 id, 1370 {action: 'searchDisplayControllerSetMode', params: {mode: 'popup'}}, 1371 {frameId: 0}, 1372 ); 1373 1374 this._searchPopupTabId = id; 1375 return {tab, created: true}; 1376 } 1377 1378 /** 1379 * @param {(url: ?string) => boolean} urlPredicate 1380 * @returns {Promise<?import('backend').TabInfo>} 1381 */ 1382 async _findSearchPopupTab(urlPredicate) { 1383 /** @type {import('backend').FindTabsPredicate} */ 1384 const predicate = async ({url, tab}) => { 1385 const {id} = tab; 1386 if (typeof id === 'undefined' || !urlPredicate(url)) { return false; } 1387 try { 1388 const mode = await this._sendMessageTabPromise( 1389 id, 1390 {action: 'searchDisplayControllerGetMode'}, 1391 {frameId: 0}, 1392 ); 1393 return mode === 'popup'; 1394 } catch (e) { 1395 return false; 1396 } 1397 }; 1398 return /** @type {?import('backend').TabInfo} */ (await this._findTabs(1000, false, predicate, true)); 1399 } 1400 1401 /** 1402 * @param {string} urlParam 1403 * @returns {Promise<boolean>} 1404 */ 1405 async _tabExists(urlParam) { 1406 const baseUrl = chrome.runtime.getURL(urlParam); 1407 const urlPredicate = (/** @type {?string} */ url) => url !== null && url.startsWith(baseUrl); 1408 return await this._findSearchPopupTab(urlPredicate) !== null; 1409 } 1410 1411 /** 1412 * @param {string} url 1413 * @param {import('settings').ProfileOptions} options 1414 * @returns {chrome.windows.CreateData} 1415 */ 1416 _getSearchPopupWindowCreateData(url, options) { 1417 const {popupWindow: {width, height, left, top, useLeft, useTop, windowType}} = options; 1418 return { 1419 url, 1420 width, 1421 height, 1422 left: useLeft ? left : void 0, 1423 top: useTop ? top : void 0, 1424 type: windowType, 1425 state: 'normal', 1426 }; 1427 } 1428 1429 /** 1430 * @param {chrome.windows.CreateData} createData 1431 * @returns {Promise<chrome.windows.Window>} 1432 */ 1433 _createWindow(createData) { 1434 return new Promise((resolve, reject) => { 1435 chrome.windows.create( 1436 createData, 1437 (result) => { 1438 const error = chrome.runtime.lastError; 1439 if (error) { 1440 reject(new Error(error.message)); 1441 } else { 1442 resolve(/** @type {chrome.windows.Window} */ (result)); 1443 } 1444 }, 1445 ); 1446 }); 1447 } 1448 1449 /** 1450 * @param {number} windowId 1451 * @param {chrome.windows.UpdateInfo} updateInfo 1452 * @returns {Promise<chrome.windows.Window>} 1453 */ 1454 _updateWindow(windowId, updateInfo) { 1455 return new Promise((resolve, reject) => { 1456 chrome.windows.update( 1457 windowId, 1458 updateInfo, 1459 (result) => { 1460 const error = chrome.runtime.lastError; 1461 if (error) { 1462 reject(new Error(error.message)); 1463 } else { 1464 resolve(result); 1465 } 1466 }, 1467 ); 1468 }); 1469 } 1470 1471 /** 1472 * @param {number} tabId 1473 * @param {string} text 1474 * @param {boolean} animate 1475 * @returns {Promise<void>} 1476 */ 1477 async _updateSearchQuery(tabId, text, animate) { 1478 await this._sendMessageTabPromise( 1479 tabId, 1480 {action: 'searchDisplayControllerUpdateSearchQuery', params: {text, animate}}, 1481 {frameId: 0}, 1482 ); 1483 } 1484 1485 /** 1486 * @param {string} source 1487 */ 1488 _applyOptions(source) { 1489 const options = this._getProfileOptions({current: true}, false); 1490 this._updateBadge(); 1491 1492 const enabled = options.general.enable; 1493 1494 /** @type {?string} */ 1495 let apiKey = options.anki.apiKey; 1496 if (apiKey === '') { apiKey = null; } 1497 this._anki.server = options.anki.server; 1498 this._anki.enabled = options.anki.enable; 1499 this._anki.apiKey = apiKey; 1500 1501 this._mecab.setEnabled(options.parsing.enableMecabParser && enabled); 1502 1503 void this._yomitanApi.setEnabled(options.general.enableYomitanApi && enabled); 1504 1505 if (options.clipboard.enableBackgroundMonitor && enabled) { 1506 this._clipboardMonitor.start(); 1507 } else { 1508 this._clipboardMonitor.stop(); 1509 } 1510 1511 this._setupContextMenu(options); 1512 1513 void this._accessibilityController.update(this._getOptionsFull(false)); 1514 1515 this._textParseCache.clear(); 1516 1517 this._sendMessageAllTabsIgnoreResponse({action: 'applicationOptionsUpdated', params: {source}}); 1518 } 1519 1520 /** 1521 * @param {import('settings').ProfileOptions} options 1522 */ 1523 _setupContextMenu(options) { 1524 try { 1525 if (!chrome.contextMenus) { return; } 1526 1527 if (options.general.enableContextMenuScanSelected) { 1528 chrome.contextMenus.create({ 1529 id: 'yomitan_lookup', 1530 title: 'Lookup in Yomitan', 1531 contexts: ['selection'], 1532 }, () => this._checkLastError(chrome.runtime.lastError)); 1533 chrome.contextMenus.onClicked.addListener((info) => { 1534 if (info.selectionText) { 1535 this._sendMessageAllTabsIgnoreResponse({action: 'frontendScanSelectedText'}); 1536 } 1537 }); 1538 } else { 1539 chrome.contextMenus.remove('yomitan_lookup', () => this._checkLastError(chrome.runtime.lastError)); 1540 } 1541 } catch (e) { 1542 log.error(e); 1543 } 1544 } 1545 1546 /** */ 1547 _attachOmniboxListener() { 1548 try { 1549 if (!chrome.omnibox) { return; } 1550 chrome.omnibox.onInputEntered.addListener((text) => { 1551 const newURL = 'search.html?query=' + encodeURIComponent(text); 1552 void chrome.tabs.create({url: newURL}); 1553 }); 1554 } catch (e) { 1555 log.error(e); 1556 } 1557 } 1558 1559 /** 1560 * @param {boolean} useSchema 1561 * @returns {import('settings').Options} 1562 * @throws {Error} 1563 */ 1564 _getOptionsFull(useSchema) { 1565 const options = this._options; 1566 if (options === null) { throw new Error('Options is null'); } 1567 return useSchema ? /** @type {import('settings').Options} */ (this._optionsUtil.createValidatingProxy(options)) : options; 1568 } 1569 1570 /** 1571 * @param {import('settings').OptionsContext} optionsContext 1572 * @param {boolean} useSchema 1573 * @returns {import('settings').ProfileOptions} 1574 */ 1575 _getProfileOptions(optionsContext, useSchema) { 1576 return this._getProfile(optionsContext, useSchema).options; 1577 } 1578 1579 /** 1580 * @param {import('settings').OptionsContext} optionsContext 1581 * @param {boolean} useSchema 1582 * @returns {import('settings').Profile} 1583 * @throws {Error} 1584 */ 1585 _getProfile(optionsContext, useSchema) { 1586 const options = this._getOptionsFull(useSchema); 1587 const profiles = options.profiles; 1588 if (!optionsContext.current) { 1589 // Specific index 1590 const {index} = optionsContext; 1591 if (typeof index === 'number') { 1592 if (index < 0 || index >= profiles.length) { 1593 throw this._createDataError(`Invalid profile index: ${index}`, optionsContext); 1594 } 1595 return profiles[index]; 1596 } 1597 // From context 1598 const profile = this._getProfileFromContext(options, optionsContext); 1599 if (profile !== null) { 1600 return profile; 1601 } 1602 } 1603 // Default 1604 const {profileCurrent} = options; 1605 if (profileCurrent < 0 || profileCurrent >= profiles.length) { 1606 throw this._createDataError(`Invalid current profile index: ${profileCurrent}`, optionsContext); 1607 } 1608 return profiles[profileCurrent]; 1609 } 1610 1611 /** 1612 * @param {import('settings').Options} options 1613 * @param {import('settings').OptionsContext} optionsContext 1614 * @returns {?import('settings').Profile} 1615 */ 1616 _getProfileFromContext(options, optionsContext) { 1617 const normalizedOptionsContext = normalizeContext(optionsContext); 1618 1619 let index = 0; 1620 for (const profile of options.profiles) { 1621 const conditionGroups = profile.conditionGroups; 1622 1623 let schema; 1624 if (index < this._profileConditionsSchemaCache.length) { 1625 schema = this._profileConditionsSchemaCache[index]; 1626 } else { 1627 schema = createSchema(conditionGroups); 1628 this._profileConditionsSchemaCache.push(schema); 1629 } 1630 1631 if (conditionGroups.length > 0 && schema.isValid(normalizedOptionsContext)) { 1632 return profile; 1633 } 1634 ++index; 1635 } 1636 1637 return null; 1638 } 1639 1640 /** 1641 * @param {string} message 1642 * @param {unknown} data 1643 * @returns {ExtensionError} 1644 */ 1645 _createDataError(message, data) { 1646 const error = new ExtensionError(message); 1647 error.data = data; 1648 return error; 1649 } 1650 1651 /** 1652 * @returns {void} 1653 */ 1654 _clearProfileConditionsSchemaCache() { 1655 this._profileConditionsSchemaCache = []; 1656 } 1657 1658 /** 1659 * @param {unknown} _ignore 1660 */ 1661 _checkLastError(_ignore) { 1662 // NOP 1663 } 1664 1665 /** 1666 * @param {string} command 1667 * @param {import('core').SerializableObject|undefined} params 1668 * @returns {boolean} 1669 */ 1670 _runCommand(command, params) { 1671 const handler = this._commandHandlers.get(command); 1672 if (typeof handler !== 'function') { return false; } 1673 1674 handler(params); 1675 return true; 1676 } 1677 1678 /** 1679 * @param {string} text 1680 * @param {number} scanLength 1681 * @param {import('settings').OptionsContext} optionsContext 1682 * @returns {Promise<import('api').ParseTextLine[]>} 1683 */ 1684 async _textParseScanning(text, scanLength, optionsContext) { 1685 /** @type {import('translator').FindTermsMode} */ 1686 const mode = 'simple'; 1687 const options = this._getProfileOptions(optionsContext, false); 1688 1689 /** @type {import('api').FindTermsDetails} */ 1690 const details = {matchType: 'exact', deinflect: true}; 1691 const findTermsOptions = this._getTranslatorFindTermsOptions(mode, details, options); 1692 /** @type {import('api').ParseTextLine[]} */ 1693 const results = []; 1694 let previousUngroupedSegment = null; 1695 let i = 0; 1696 const ii = text.length; 1697 while (i < ii) { 1698 const codePoint = /** @type {number} */ (text.codePointAt(i)); 1699 const character = String.fromCodePoint(codePoint); 1700 const substring = text.substring(i, i + scanLength); 1701 const cacheKey = `${optionsContext.index}:${substring}`; 1702 let cached = this._textParseCache.get(cacheKey); 1703 if (typeof cached === 'undefined') { 1704 const {dictionaryEntries, originalTextLength} = await this._translator.findTerms( 1705 mode, 1706 substring, 1707 findTermsOptions, 1708 ); 1709 /** @type {import('api').ParseTextSegment[]} */ 1710 const textSegments = []; 1711 if (dictionaryEntries.length > 0 && 1712 originalTextLength > 0 && 1713 (originalTextLength !== character.length || isCodePointJapanese(codePoint)) 1714 ) { 1715 const {headwords: [{term, reading}]} = dictionaryEntries[0]; 1716 const source = substring.substring(0, originalTextLength); 1717 for (const {text: text2, reading: reading2} of distributeFuriganaInflected(term, reading, source)) { 1718 textSegments.push({text: text2, reading: reading2}); 1719 } 1720 if (textSegments.length > 0) { 1721 const token = textSegments.map((s) => s.text).join(''); 1722 const trimmedHeadwords = []; 1723 for (const dictionaryEntry of dictionaryEntries) { 1724 const validHeadwords = []; 1725 for (const headword of dictionaryEntry.headwords) { 1726 const validSources = []; 1727 for (const src of headword.sources) { 1728 if (src.originalText !== token) { continue; } 1729 if (!src.isPrimary) { continue; } 1730 if (src.matchType !== 'exact') { continue; } 1731 validSources.push(src); 1732 } 1733 if (validSources.length > 0) { validHeadwords.push({term: headword.term, reading: headword.reading, sources: validSources}); } 1734 } 1735 if (validHeadwords.length > 0) { trimmedHeadwords.push(validHeadwords); } 1736 } 1737 textSegments[0].headwords = trimmedHeadwords; 1738 } 1739 } 1740 cached = {originalTextLength, textSegments}; 1741 if (typeof optionsContext.index !== 'undefined') { this._textParseCache.set(cacheKey, cached); } 1742 } 1743 const {originalTextLength, textSegments} = cached; 1744 if (textSegments.length > 0) { 1745 previousUngroupedSegment = null; 1746 results.push(textSegments); 1747 i += originalTextLength; 1748 } else { 1749 if (previousUngroupedSegment === null) { 1750 previousUngroupedSegment = {text: character, reading: ''}; 1751 results.push([previousUngroupedSegment]); 1752 } else { 1753 previousUngroupedSegment.text += character; 1754 } 1755 i += character.length; 1756 } 1757 } 1758 return results; 1759 } 1760 1761 /** 1762 * @param {string} text 1763 * @returns {Promise<import('backend').MecabParseResults>} 1764 */ 1765 async _textParseMecab(text) { 1766 let parseTextResults; 1767 try { 1768 parseTextResults = await this._mecab.parseText(text); 1769 } catch (e) { 1770 return []; 1771 } 1772 1773 /** @type {import('backend').MecabParseResults} */ 1774 const results = []; 1775 for (const {name, lines} of parseTextResults) { 1776 /** @type {import('api').ParseTextLine[]} */ 1777 const result = []; 1778 for (const line of lines) { 1779 for (const {term, reading, source} of line) { 1780 const termParts = []; 1781 for (const {text: text2, reading: reading2} of distributeFuriganaInflected( 1782 term.length > 0 ? term : source, 1783 jpConvertKatakanaToHiragana(reading), 1784 source, 1785 )) { 1786 termParts.push({text: text2, reading: reading2}); 1787 } 1788 result.push(termParts); 1789 } 1790 result.push([{text: '\n', reading: ''}]); 1791 } 1792 results.push([name, result]); 1793 } 1794 return results; 1795 } 1796 1797 /** 1798 * @param {import('settings-modifications').OptionsScope} target 1799 * @returns {import('settings').Options|import('settings').ProfileOptions} 1800 * @throws {Error} 1801 */ 1802 _getModifySettingObject(target) { 1803 const scope = target.scope; 1804 switch (scope) { 1805 case 'profile': 1806 { 1807 const {optionsContext} = target; 1808 if (typeof optionsContext !== 'object' || optionsContext === null) { throw new Error('Invalid optionsContext'); } 1809 return /** @type {import('settings').ProfileOptions} */ (this._getProfileOptions(optionsContext, true)); 1810 } 1811 case 'global': 1812 return /** @type {import('settings').Options} */ (this._getOptionsFull(true)); 1813 default: 1814 throw new Error(`Invalid scope: ${scope}`); 1815 } 1816 } 1817 1818 /** 1819 * @param {import('settings-modifications').OptionsScope&import('settings-modifications').Read} target 1820 * @returns {unknown} 1821 * @throws {Error} 1822 */ 1823 _getSetting(target) { 1824 const options = this._getModifySettingObject(target); 1825 const accessor = new ObjectPropertyAccessor(options); 1826 const {path} = target; 1827 if (typeof path !== 'string') { throw new Error('Invalid path'); } 1828 return accessor.get(ObjectPropertyAccessor.getPathArray(path)); 1829 } 1830 1831 /** 1832 * @param {import('settings-modifications').ScopedModification} target 1833 * @returns {import('settings-modifications').ModificationResult} 1834 * @throws {Error} 1835 */ 1836 _modifySetting(target) { 1837 const options = this._getModifySettingObject(target); 1838 const accessor = new ObjectPropertyAccessor(options); 1839 const action = target.action; 1840 switch (action) { 1841 case 'set': 1842 { 1843 const {path, value} = target; 1844 if (typeof path !== 'string') { throw new Error('Invalid path'); } 1845 const pathArray = ObjectPropertyAccessor.getPathArray(path); 1846 accessor.set(pathArray, value); 1847 return accessor.get(pathArray); 1848 } 1849 case 'delete': 1850 { 1851 const {path} = target; 1852 if (typeof path !== 'string') { throw new Error('Invalid path'); } 1853 accessor.delete(ObjectPropertyAccessor.getPathArray(path)); 1854 return true; 1855 } 1856 case 'swap': 1857 { 1858 const {path1, path2} = target; 1859 if (typeof path1 !== 'string') { throw new Error('Invalid path1'); } 1860 if (typeof path2 !== 'string') { throw new Error('Invalid path2'); } 1861 accessor.swap(ObjectPropertyAccessor.getPathArray(path1), ObjectPropertyAccessor.getPathArray(path2)); 1862 return true; 1863 } 1864 case 'splice': 1865 { 1866 const {path, start, deleteCount, items} = target; 1867 if (typeof path !== 'string') { throw new Error('Invalid path'); } 1868 if (typeof start !== 'number' || Math.floor(start) !== start) { throw new Error('Invalid start'); } 1869 if (typeof deleteCount !== 'number' || Math.floor(deleteCount) !== deleteCount) { throw new Error('Invalid deleteCount'); } 1870 if (!Array.isArray(items)) { throw new Error('Invalid items'); } 1871 const array = accessor.get(ObjectPropertyAccessor.getPathArray(path)); 1872 if (!Array.isArray(array)) { throw new Error('Invalid target type'); } 1873 return array.splice(start, deleteCount, ...items); 1874 } 1875 case 'push': 1876 { 1877 const {path, items} = target; 1878 if (typeof path !== 'string') { throw new Error('Invalid path'); } 1879 if (!Array.isArray(items)) { throw new Error('Invalid items'); } 1880 const array = accessor.get(ObjectPropertyAccessor.getPathArray(path)); 1881 if (!Array.isArray(array)) { throw new Error('Invalid target type'); } 1882 const start = array.length; 1883 array.push(...items); 1884 return start; 1885 } 1886 default: 1887 throw new Error(`Unknown action: ${action}`); 1888 } 1889 } 1890 1891 /** 1892 * Returns the action's default title. 1893 * @throws {Error} 1894 * @returns {string} 1895 */ 1896 _getBrowserIconTitle() { 1897 const manifest = /** @type {chrome.runtime.ManifestV3} */ (chrome.runtime.getManifest()); 1898 const action = manifest.action; 1899 if (typeof action === 'undefined') { throw new Error('Failed to find action'); } 1900 const defaultTitle = action.default_title; 1901 if (typeof defaultTitle === 'undefined') { throw new Error('Failed to find default_title'); } 1902 1903 return defaultTitle; 1904 } 1905 1906 /** 1907 * @returns {void} 1908 */ 1909 _updateBadge() { 1910 let title = this._defaultBrowserActionTitle; 1911 if (title === null || !isObjectNotArray(chrome.action)) { 1912 // Not ready or invalid 1913 return; 1914 } 1915 1916 let text = ''; 1917 let color = null; 1918 let status = null; 1919 1920 if (this._logErrorLevel !== null) { 1921 switch (this._logErrorLevel) { 1922 case 'error': 1923 text = '!!'; 1924 color = '#f04e4e'; 1925 status = 'Error'; 1926 break; 1927 default: // 'warn' 1928 text = '!'; 1929 color = '#f0ad4e'; 1930 status = 'Warning'; 1931 break; 1932 } 1933 } else if (!this._isPrepared) { 1934 if (this._prepareError) { 1935 text = '!!'; 1936 color = '#f04e4e'; 1937 status = 'Error'; 1938 } else if (this._badgePrepareDelayTimer === null) { 1939 text = '...'; 1940 color = '#f0ad4e'; 1941 status = 'Loading'; 1942 } 1943 } else { 1944 const options = this._getProfileOptions({current: true}, false); 1945 if (!options.general.enable) { 1946 text = 'off'; 1947 color = '#555555'; 1948 status = 'Disabled'; 1949 } else if (!this._hasRequiredPermissionsForSettings(options)) { 1950 text = '!'; 1951 color = '#f0ad4e'; 1952 status = 'Some settings require additional permissions'; 1953 } else if (!this._isAnyDictionaryEnabled(options)) { 1954 text = '!'; 1955 color = '#f0ad4e'; 1956 status = 'No dictionaries installed'; 1957 } 1958 } 1959 1960 if (color !== null && typeof chrome.action.setBadgeBackgroundColor === 'function') { 1961 void chrome.action.setBadgeBackgroundColor({color}); 1962 } 1963 if (text !== null && typeof chrome.action.setBadgeText === 'function') { 1964 void chrome.action.setBadgeText({text}); 1965 } 1966 if (typeof chrome.action.setTitle === 'function') { 1967 if (status !== null) { 1968 title = `${title} - ${status}`; 1969 } 1970 void chrome.action.setTitle({title}); 1971 } 1972 } 1973 1974 /** 1975 * @param {import('settings').ProfileOptions} options 1976 * @returns {boolean} 1977 */ 1978 _isAnyDictionaryEnabled(options) { 1979 for (const {enabled} of options.dictionaries) { 1980 if (enabled) { 1981 return true; 1982 } 1983 } 1984 return false; 1985 } 1986 1987 /** 1988 * @param {number} tabId 1989 * @returns {Promise<?string>} 1990 */ 1991 async _getTabUrl(tabId) { 1992 try { 1993 const response = await this._sendMessageTabPromise( 1994 tabId, 1995 {action: 'applicationGetUrl'}, 1996 {frameId: 0}, 1997 ); 1998 const url = typeof response === 'object' && response !== null ? /** @type {import('core').SerializableObject} */ (response).url : void 0; 1999 if (typeof url === 'string') { 2000 return url; 2001 } 2002 } catch (e) { 2003 // NOP 2004 } 2005 return null; 2006 } 2007 2008 /** 2009 * @returns {Promise<chrome.tabs.Tab[]>} 2010 */ 2011 _getAllTabs() { 2012 return new Promise((resolve, reject) => { 2013 chrome.tabs.query({}, (tabs) => { 2014 const e = chrome.runtime.lastError; 2015 if (e) { 2016 reject(new Error(e.message)); 2017 } else { 2018 resolve(tabs); 2019 } 2020 }); 2021 }); 2022 } 2023 2024 /** 2025 * This function works around the need to have the "tabs" permission to access tab.url. 2026 * @param {number} timeout 2027 * @param {boolean} multiple 2028 * @param {import('backend').FindTabsPredicate} predicate 2029 * @param {boolean} predicateIsAsync 2030 * @returns {Promise<import('backend').TabInfo[]|(?import('backend').TabInfo)>} 2031 */ 2032 async _findTabs(timeout, multiple, predicate, predicateIsAsync) { 2033 const tabs = await this._getAllTabs(); 2034 2035 let done = false; 2036 /** 2037 * @param {chrome.tabs.Tab} tab 2038 * @param {(tabInfo: import('backend').TabInfo) => boolean} add 2039 */ 2040 const checkTab = async (tab, add) => { 2041 const {id} = tab; 2042 const url = typeof id === 'number' ? await this._getTabUrl(id) : null; 2043 2044 if (done) { return; } 2045 2046 let okay = false; 2047 const item = {tab, url}; 2048 try { 2049 const okayOrPromise = predicate(item); 2050 okay = predicateIsAsync ? await okayOrPromise : /** @type {boolean} */ (okayOrPromise); 2051 } catch (e) { 2052 // NOP 2053 } 2054 2055 if (okay && !done && add(item)) { 2056 done = true; 2057 } 2058 }; 2059 2060 if (multiple) { 2061 /** @type {import('backend').TabInfo[]} */ 2062 const results = []; 2063 /** 2064 * @param {import('backend').TabInfo} value 2065 * @returns {boolean} 2066 */ 2067 const add = (value) => { 2068 results.push(value); 2069 return false; 2070 }; 2071 const checkTabPromises = tabs.map((tab) => checkTab(tab, add)); 2072 await Promise.race([ 2073 Promise.all(checkTabPromises), 2074 promiseTimeout(timeout), 2075 ]); 2076 return results; 2077 } else { 2078 const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise()); 2079 /** @type {?import('backend').TabInfo} */ 2080 let result = null; 2081 /** 2082 * @param {import('backend').TabInfo} value 2083 * @returns {boolean} 2084 */ 2085 const add = (value) => { 2086 result = value; 2087 resolve(); 2088 return true; 2089 }; 2090 const checkTabPromises = tabs.map((tab) => checkTab(tab, add)); 2091 await Promise.race([ 2092 promise, 2093 Promise.all(checkTabPromises), 2094 promiseTimeout(timeout), 2095 ]); 2096 resolve(); 2097 return result; 2098 } 2099 } 2100 2101 /** 2102 * @param {chrome.tabs.Tab} tab 2103 */ 2104 async _focusTab(tab) { 2105 await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { 2106 const {id} = tab; 2107 if (typeof id !== 'number') { 2108 reject(new Error('Cannot focus a tab without an id')); 2109 return; 2110 } 2111 chrome.tabs.update(id, {active: true}, () => { 2112 const e = chrome.runtime.lastError; 2113 if (e) { 2114 reject(new Error(e.message)); 2115 } else { 2116 resolve(); 2117 } 2118 }); 2119 })); 2120 2121 if (!(typeof chrome.windows === 'object' && chrome.windows !== null)) { 2122 // Windows not supported (e.g. on Firefox mobile) 2123 return; 2124 } 2125 2126 try { 2127 const tabWindow = await this._getWindow(tab.windowId); 2128 if (!tabWindow.focused) { 2129 await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { 2130 chrome.windows.update(tab.windowId, {focused: true}, () => { 2131 const e = chrome.runtime.lastError; 2132 if (e) { 2133 reject(new Error(e.message)); 2134 } else { 2135 resolve(); 2136 } 2137 }); 2138 })); 2139 } 2140 } catch (e) { 2141 // Edge throws exception for no reason here. 2142 } 2143 } 2144 2145 /** 2146 * @param {number} windowId 2147 * @returns {Promise<chrome.windows.Window>} 2148 */ 2149 _getWindow(windowId) { 2150 return new Promise((resolve, reject) => { 2151 chrome.windows.get(windowId, {}, (value) => { 2152 const e = chrome.runtime.lastError; 2153 if (e) { 2154 reject(new Error(e.message)); 2155 } else { 2156 resolve(value); 2157 } 2158 }); 2159 }); 2160 } 2161 2162 /** 2163 * @param {number} tabId 2164 * @param {number} frameId 2165 * @param {?number} [timeout=null] 2166 * @returns {Promise<void>} 2167 */ 2168 _waitUntilTabFrameIsReady(tabId, frameId, timeout = null) { 2169 return new Promise((resolve, reject) => { 2170 /** @type {?import('core').Timeout} */ 2171 let timer = null; 2172 2173 const readyHandler = () => { 2174 cleanup(); 2175 resolve(); 2176 }; 2177 const cleanup = () => { 2178 if (timer !== null) { 2179 clearTimeout(timer); 2180 timer = null; 2181 } 2182 this._removeApplicationReadyHandler(tabId, frameId, readyHandler); 2183 }; 2184 2185 this._addApplicationReadyHandler(tabId, frameId, readyHandler); 2186 2187 this._sendMessageTabPromise(tabId, {action: 'applicationIsReady'}, {frameId}) 2188 .then( 2189 (value) => { 2190 if (!value) { return; } 2191 cleanup(); 2192 resolve(); 2193 }, 2194 () => {}, // NOP 2195 ); 2196 2197 if (timeout !== null) { 2198 timer = setTimeout(() => { 2199 timer = null; 2200 cleanup(); 2201 reject(new Error('Timeout')); 2202 }, timeout); 2203 } 2204 }); 2205 } 2206 2207 /** 2208 * @template {import('application').ApiNames} TName 2209 * @param {import('application').ApiMessage<TName>} message 2210 */ 2211 _sendMessageIgnoreResponse(message) { 2212 this._webExtension.sendMessageIgnoreResponse(message); 2213 } 2214 2215 /** 2216 * @param {number} tabId 2217 * @param {import('application').ApiMessageAny} message 2218 * @param {chrome.tabs.MessageSendOptions} options 2219 */ 2220 _sendMessageTabIgnoreResponse(tabId, message, options) { 2221 const callback = () => this._checkLastError(chrome.runtime.lastError); 2222 chrome.tabs.sendMessage(tabId, message, options, callback); 2223 } 2224 2225 /** 2226 * @param {import('application').ApiMessageAny} message 2227 */ 2228 _sendMessageAllTabsIgnoreResponse(message) { 2229 const callback = () => this._checkLastError(chrome.runtime.lastError); 2230 chrome.tabs.query({}, (tabs) => { 2231 for (const tab of tabs) { 2232 const {id} = tab; 2233 if (typeof id !== 'number') { continue; } 2234 chrome.tabs.sendMessage(id, message, callback); 2235 } 2236 }); 2237 } 2238 2239 /** 2240 * @template {import('application').ApiNames} TName 2241 * @param {number} tabId 2242 * @param {import('application').ApiMessage<TName>} message 2243 * @param {chrome.tabs.MessageSendOptions} options 2244 * @returns {Promise<import('application').ApiReturn<TName>>} 2245 */ 2246 _sendMessageTabPromise(tabId, message, options) { 2247 return new Promise((resolve, reject) => { 2248 /** 2249 * @param {unknown} response 2250 */ 2251 const callback = (response) => { 2252 try { 2253 resolve(/** @type {import('application').ApiReturn<TName>} */ (this._getMessageResponseResult(response))); 2254 } catch (error) { 2255 reject(error); 2256 } 2257 }; 2258 2259 chrome.tabs.sendMessage(tabId, message, options, callback); 2260 }); 2261 } 2262 2263 /** 2264 * @param {unknown} response 2265 * @returns {unknown} 2266 * @throws {Error} 2267 */ 2268 _getMessageResponseResult(response) { 2269 const error = chrome.runtime.lastError; 2270 if (error) { 2271 throw new Error(error.message); 2272 } 2273 if (typeof response !== 'object' || response === null) { 2274 throw new Error('Tab did not respond'); 2275 } 2276 const responseError = /** @type {import('core').Response<unknown>} */ (response).error; 2277 if (typeof responseError === 'object' && responseError !== null) { 2278 throw ExtensionError.deserialize(responseError); 2279 } 2280 return /** @type {import('core').Response<unknown>} */ (response).result; 2281 } 2282 2283 /** 2284 * @param {number} tabId 2285 * @param {(url: ?string) => boolean} urlPredicate 2286 * @returns {Promise<?chrome.tabs.Tab>} 2287 */ 2288 async _checkTabUrl(tabId, urlPredicate) { 2289 let tab; 2290 try { 2291 tab = await this._getTabById(tabId); 2292 } catch (e) { 2293 return null; 2294 } 2295 2296 const url = await this._getTabUrl(tabId); 2297 const isValidTab = urlPredicate(url); 2298 return isValidTab ? tab : null; 2299 } 2300 2301 /** 2302 * @param {number} tabId 2303 * @param {number} frameId 2304 * @param {'jpeg'|'png'} format 2305 * @param {number} quality 2306 * @returns {Promise<string>} 2307 */ 2308 async _getScreenshot(tabId, frameId, format, quality) { 2309 const tab = await this._getTabById(tabId); 2310 const {windowId} = tab; 2311 2312 let token = null; 2313 try { 2314 if (typeof tabId === 'number' && typeof frameId === 'number') { 2315 const action = 'frontendSetAllVisibleOverride'; 2316 const params = {value: false, priority: 0, awaitFrame: true}; 2317 token = await this._sendMessageTabPromise(tabId, {action, params}, {frameId}); 2318 } 2319 2320 return await new Promise((resolve, reject) => { 2321 chrome.tabs.captureVisibleTab(windowId, {format, quality}, (result) => { 2322 const e = chrome.runtime.lastError; 2323 if (e) { 2324 reject(new Error(e.message)); 2325 } else { 2326 resolve(result); 2327 } 2328 }); 2329 }); 2330 } finally { 2331 if (token !== null) { 2332 const action = 'frontendClearAllVisibleOverride'; 2333 const params = {token}; 2334 try { 2335 await this._sendMessageTabPromise(tabId, {action, params}, {frameId}); 2336 } catch (e) { 2337 // NOP 2338 } 2339 } 2340 } 2341 } 2342 2343 /** 2344 * @param {AnkiConnect} ankiConnect 2345 * @param {number} timestamp 2346 * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails 2347 * @param {?import('api').InjectAnkiNoteMediaAudioDetails} audioDetails 2348 * @param {?import('api').InjectAnkiNoteMediaScreenshotDetails} screenshotDetails 2349 * @param {?import('api').InjectAnkiNoteMediaClipboardDetails} clipboardDetails 2350 * @param {import('api').InjectAnkiNoteMediaDictionaryMediaDetails[]} dictionaryMediaDetails 2351 * @returns {Promise<import('api').ApiReturn<'injectAnkiNoteMedia'>>} 2352 */ 2353 async _injectAnkNoteMedia(ankiConnect, timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails) { 2354 let screenshotFileName = null; 2355 let clipboardImageFileName = null; 2356 let clipboardText = null; 2357 let audioFileName = null; 2358 const errors = []; 2359 2360 try { 2361 if (screenshotDetails !== null) { 2362 screenshotFileName = await this._injectAnkiNoteScreenshot(ankiConnect, timestamp, screenshotDetails); 2363 } 2364 } catch (e) { 2365 errors.push(ExtensionError.serialize(e)); 2366 } 2367 2368 try { 2369 if (clipboardDetails !== null && clipboardDetails.image) { 2370 clipboardImageFileName = await this._injectAnkiNoteClipboardImage(ankiConnect, timestamp); 2371 } 2372 } catch (e) { 2373 errors.push(ExtensionError.serialize(e)); 2374 } 2375 2376 try { 2377 if (clipboardDetails !== null && clipboardDetails.text) { 2378 clipboardText = await this._clipboardReader.getText(false); 2379 } 2380 } catch (e) { 2381 errors.push(ExtensionError.serialize(e)); 2382 } 2383 2384 try { 2385 if (audioDetails !== null) { 2386 audioFileName = await this._injectAnkiNoteAudio(ankiConnect, timestamp, definitionDetails, audioDetails); 2387 } 2388 } catch (e) { 2389 errors.push(ExtensionError.serialize(e)); 2390 } 2391 2392 /** @type {import('api').InjectAnkiNoteDictionaryMediaResult[]} */ 2393 let dictionaryMedia; 2394 try { 2395 let errors2; 2396 ({results: dictionaryMedia, errors: errors2} = await this._injectAnkiNoteDictionaryMedia(ankiConnect, timestamp, dictionaryMediaDetails)); 2397 for (const error of errors2) { 2398 errors.push(ExtensionError.serialize(error)); 2399 } 2400 } catch (e) { 2401 dictionaryMedia = []; 2402 errors.push(ExtensionError.serialize(e)); 2403 } 2404 2405 return { 2406 screenshotFileName, 2407 clipboardImageFileName, 2408 clipboardText, 2409 audioFileName, 2410 dictionaryMedia, 2411 errors: errors, 2412 }; 2413 } 2414 2415 /** 2416 * @param {AnkiConnect} ankiConnect 2417 * @param {number} timestamp 2418 * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails 2419 * @param {import('api').InjectAnkiNoteMediaAudioDetails} details 2420 * @returns {Promise<?string>} 2421 */ 2422 async _injectAnkiNoteAudio(ankiConnect, timestamp, definitionDetails, details) { 2423 if (definitionDetails.type !== 'term') { return null; } 2424 const {term, reading} = definitionDetails; 2425 if (term.length === 0 && reading.length === 0) { return null; } 2426 2427 const {sources, preferredAudioIndex, idleTimeout, languageSummary, enableDefaultAudioSources} = details; 2428 let data; 2429 let contentType; 2430 try { 2431 ({data, contentType} = await this._audioDownloader.downloadTermAudio( 2432 sources, 2433 preferredAudioIndex, 2434 term, 2435 reading, 2436 idleTimeout, 2437 languageSummary, 2438 enableDefaultAudioSources, 2439 )); 2440 } catch (e) { 2441 const error = this._getAudioDownloadError(e); 2442 if (error !== null) { throw error; } 2443 // No audio 2444 log.logGenericError(e, 'log'); 2445 return null; 2446 } 2447 2448 let extension = contentType !== null ? getFileExtensionFromAudioMediaType(contentType) : null; 2449 if (extension === null) { extension = '.mp3'; } 2450 let fileName = generateAnkiNoteMediaFileName('yomitan_audio', extension, timestamp); 2451 fileName = fileName.replace(/\]/g, ''); 2452 return await ankiConnect.storeMediaFile(fileName, data); 2453 } 2454 2455 /** 2456 * @param {AnkiConnect} ankiConnect 2457 * @param {number} timestamp 2458 * @param {import('api').InjectAnkiNoteMediaScreenshotDetails} details 2459 * @returns {Promise<?string>} 2460 */ 2461 async _injectAnkiNoteScreenshot(ankiConnect, timestamp, details) { 2462 const {tabId, frameId, format, quality} = details; 2463 const dataUrl = await this._getScreenshot(tabId, frameId, format, quality); 2464 2465 const {mediaType, data} = this._getDataUrlInfo(dataUrl); 2466 const extension = getFileExtensionFromImageMediaType(mediaType); 2467 if (extension === null) { 2468 throw new Error('Unknown media type for screenshot image'); 2469 } 2470 2471 const fileName = generateAnkiNoteMediaFileName('yomitan_browser_screenshot', extension, timestamp); 2472 return await ankiConnect.storeMediaFile(fileName, data); 2473 } 2474 2475 /** 2476 * @param {AnkiConnect} ankiConnect 2477 * @param {number} timestamp 2478 * @returns {Promise<?string>} 2479 */ 2480 async _injectAnkiNoteClipboardImage(ankiConnect, timestamp) { 2481 const dataUrl = await this._clipboardReader.getImage(); 2482 if (dataUrl === null) { 2483 return null; 2484 } 2485 2486 const {mediaType, data} = this._getDataUrlInfo(dataUrl); 2487 const extension = getFileExtensionFromImageMediaType(mediaType); 2488 if (extension === null) { 2489 throw new Error('Unknown media type for clipboard image'); 2490 } 2491 2492 const fileName = dataUrl === this._ankiClipboardImageDataUrlCache && this._ankiClipboardImageFilenameCache ? 2493 this._ankiClipboardImageFilenameCache : 2494 generateAnkiNoteMediaFileName('yomitan_clipboard_image', extension, timestamp); 2495 2496 const storedFileName = await ankiConnect.storeMediaFile(fileName, data); 2497 2498 if (storedFileName !== null) { 2499 this._ankiClipboardImageDataUrlCache = dataUrl; 2500 this._ankiClipboardImageFilenameCache = storedFileName; 2501 } 2502 2503 return storedFileName; 2504 } 2505 2506 /** 2507 * @param {AnkiConnect} ankiConnect 2508 * @param {number} timestamp 2509 * @param {import('api').InjectAnkiNoteMediaDictionaryMediaDetails[]} dictionaryMediaDetails 2510 * @returns {Promise<{results: import('api').InjectAnkiNoteDictionaryMediaResult[], errors: unknown[]}>} 2511 */ 2512 async _injectAnkiNoteDictionaryMedia(ankiConnect, timestamp, dictionaryMediaDetails) { 2513 const targets = []; 2514 const detailsList = []; 2515 /** @type {Map<string, {dictionary: string, path: string, media: ?import('dictionary-database').MediaDataStringContent}>} */ 2516 const detailsMap = new Map(); 2517 for (const {dictionary, path} of dictionaryMediaDetails) { 2518 const target = {dictionary, path}; 2519 const details = {dictionary, path, media: null}; 2520 const key = JSON.stringify(target); 2521 targets.push(target); 2522 detailsList.push(details); 2523 detailsMap.set(key, details); 2524 } 2525 const mediaList = await this._getNormalizedDictionaryDatabaseMedia(targets); 2526 2527 for (const media of mediaList) { 2528 const {dictionary, path} = media; 2529 const key = JSON.stringify({dictionary, path}); 2530 const details = detailsMap.get(key); 2531 if (typeof details === 'undefined' || details.media !== null) { continue; } 2532 details.media = media; 2533 } 2534 2535 const errors = []; 2536 /** @type {import('api').InjectAnkiNoteDictionaryMediaResult[]} */ 2537 const results = []; 2538 for (let i = 0, ii = detailsList.length; i < ii; ++i) { 2539 const {dictionary, path, media} = detailsList[i]; 2540 let fileName = null; 2541 if (media !== null) { 2542 const {content, mediaType} = media; 2543 const extension = getFileExtensionFromImageMediaType(mediaType); 2544 fileName = generateAnkiNoteMediaFileName( 2545 `yomitan_dictionary_media_${i + 1}`, 2546 extension !== null ? extension : '', 2547 timestamp, 2548 ); 2549 try { 2550 fileName = await ankiConnect.storeMediaFile(fileName, content); 2551 } catch (e) { 2552 errors.push(e); 2553 fileName = null; 2554 } 2555 } 2556 results.push({dictionary, path, fileName}); 2557 } 2558 2559 return {results, errors}; 2560 } 2561 2562 /** 2563 * @param {unknown} error 2564 * @returns {?ExtensionError} 2565 */ 2566 _getAudioDownloadError(error) { 2567 if (error instanceof ExtensionError && typeof error.data === 'object' && error.data !== null) { 2568 const {errors} = /** @type {import('core').SerializableObject} */ (error.data); 2569 if (Array.isArray(errors)) { 2570 for (const errorDetail of errors) { 2571 if (!(errorDetail instanceof Error)) { continue; } 2572 if (errorDetail.name === 'AbortError') { 2573 return this._createAudioDownloadError('Audio download was cancelled due to an idle timeout', 'audio-download-idle-timeout', errors); 2574 } 2575 if (!(errorDetail instanceof ExtensionError)) { continue; } 2576 const {data} = errorDetail; 2577 if (!(typeof data === 'object' && data !== null)) { continue; } 2578 const {details} = /** @type {import('core').SerializableObject} */ (data); 2579 if (!(typeof details === 'object' && details !== null)) { continue; } 2580 const error3 = /** @type {import('core').SerializableObject} */ (details).error; 2581 if (typeof error3 !== 'string') { continue; } 2582 switch (error3) { 2583 case 'net::ERR_FAILED': 2584 // This is potentially an error due to the extension not having enough URL privileges. 2585 // The message logged to the console looks like this: 2586 // Access to fetch at '<URL>' from origin 'chrome-extension://<ID>' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled. 2587 return this._createAudioDownloadError('Audio download failed due to possible extension permissions error', 'audio-download-failed-permissions-error', errors); 2588 case 'net::ERR_CERT_DATE_INVALID': // Chrome 2589 case 'Peer’s Certificate has expired.': // Firefox 2590 // This error occurs when a server certificate expires. 2591 return this._createAudioDownloadError('Audio download failed due to an expired server certificate', 'audio-download-failed-expired-server-certificate', errors); 2592 } 2593 } 2594 } 2595 } 2596 return null; 2597 } 2598 2599 /** 2600 * @param {string} message 2601 * @param {?string} issueId 2602 * @param {?(Error[])} errors 2603 * @returns {ExtensionError} 2604 */ 2605 _createAudioDownloadError(message, issueId, errors) { 2606 const error = new ExtensionError(message); 2607 const hasErrors = Array.isArray(errors); 2608 const hasIssueId = (typeof issueId === 'string'); 2609 if (hasErrors || hasIssueId) { 2610 /** @type {{errors?: import('core').SerializedError[], referenceUrl?: string}} */ 2611 const data = {}; 2612 error.data = {}; 2613 if (hasErrors) { 2614 // Errors need to be serialized since they are passed to other frames 2615 data.errors = errors.map((e) => ExtensionError.serialize(e)); 2616 } 2617 if (hasIssueId) { 2618 data.referenceUrl = `/issues.html#${issueId}`; 2619 } 2620 } 2621 return error; 2622 } 2623 2624 /** 2625 * @param {string} dataUrl 2626 * @returns {{mediaType: string, data: string}} 2627 * @throws {Error} 2628 */ 2629 _getDataUrlInfo(dataUrl) { 2630 const match = /^data:([^,]*?)(;base64)?,/.exec(dataUrl); 2631 if (match === null) { 2632 throw new Error('Invalid data URL'); 2633 } 2634 2635 let mediaType = match[1]; 2636 if (mediaType.length === 0) { mediaType = 'text/plain'; } 2637 2638 let data = dataUrl.substring(match[0].length); 2639 if (typeof match[2] === 'undefined') { data = btoa(data); } 2640 2641 return {mediaType, data}; 2642 } 2643 2644 /** 2645 * @param {import('backend').DatabaseUpdateType} type 2646 * @param {import('backend').DatabaseUpdateCause} cause 2647 */ 2648 _triggerDatabaseUpdated(type, cause) { 2649 void this._translator.clearDatabaseCaches(); 2650 this._sendMessageAllTabsIgnoreResponse({action: 'applicationDatabaseUpdated', params: {type, cause}}); 2651 } 2652 2653 /** 2654 * @param {string} source 2655 */ 2656 async _saveOptions(source) { 2657 this._clearProfileConditionsSchemaCache(); 2658 const options = this._getOptionsFull(false); 2659 await this._optionsUtil.save(options); 2660 this._applyOptions(source); 2661 } 2662 2663 /** 2664 * Creates an options object for use with `Translator.findTerms`. 2665 * @param {import('translator').FindTermsMode} mode The display mode for the dictionary entries. 2666 * @param {import('api').FindTermsDetails} details Custom info for finding terms. 2667 * @param {import('settings').ProfileOptions} options The options. 2668 * @returns {import('translation').FindTermsOptions} An options object. 2669 */ 2670 _getTranslatorFindTermsOptions(mode, details, options) { 2671 let {matchType, deinflect, primaryReading} = details; 2672 if (typeof matchType !== 'string') { matchType = /** @type {import('translation').FindTermsMatchType} */ ('exact'); } 2673 if (typeof deinflect !== 'boolean') { deinflect = true; } 2674 if (typeof primaryReading !== 'string') { primaryReading = ''; } 2675 const enabledDictionaryMap = this._getTranslatorEnabledDictionaryMap(options); 2676 const { 2677 general: {mainDictionary, sortFrequencyDictionary, sortFrequencyDictionaryOrder, language}, 2678 scanning: {alphanumeric}, 2679 translation: { 2680 textReplacements: textReplacementsOptions, 2681 searchResolution, 2682 }, 2683 } = options; 2684 const textReplacements = this._getTranslatorTextReplacements(textReplacementsOptions); 2685 let excludeDictionaryDefinitions = null; 2686 if (mode === 'merge' && !enabledDictionaryMap.has(mainDictionary)) { 2687 enabledDictionaryMap.set(mainDictionary, { 2688 index: enabledDictionaryMap.size, 2689 alias: mainDictionary, 2690 allowSecondarySearches: false, 2691 partsOfSpeechFilter: true, 2692 useDeinflections: true, 2693 }); 2694 excludeDictionaryDefinitions = new Set(); 2695 excludeDictionaryDefinitions.add(mainDictionary); 2696 } 2697 return { 2698 matchType, 2699 deinflect, 2700 primaryReading, 2701 mainDictionary, 2702 sortFrequencyDictionary, 2703 sortFrequencyDictionaryOrder, 2704 removeNonJapaneseCharacters: !alphanumeric, 2705 searchResolution, 2706 textReplacements, 2707 enabledDictionaryMap, 2708 excludeDictionaryDefinitions, 2709 language, 2710 }; 2711 } 2712 2713 /** 2714 * Creates an options object for use with `Translator.findKanji`. 2715 * @param {import('settings').ProfileOptions} options The options. 2716 * @returns {import('translation').FindKanjiOptions} An options object. 2717 */ 2718 _getTranslatorFindKanjiOptions(options) { 2719 const enabledDictionaryMap = this._getTranslatorEnabledDictionaryMap(options); 2720 return { 2721 enabledDictionaryMap, 2722 removeNonJapaneseCharacters: !options.scanning.alphanumeric, 2723 }; 2724 } 2725 2726 /** 2727 * @param {import('settings').ProfileOptions} options 2728 * @returns {Map<string, import('translation').FindTermDictionary>} 2729 */ 2730 _getTranslatorEnabledDictionaryMap(options) { 2731 const enabledDictionaryMap = new Map(); 2732 for (const dictionary of options.dictionaries) { 2733 if (!dictionary.enabled) { continue; } 2734 const {name, alias, allowSecondarySearches, partsOfSpeechFilter, useDeinflections} = dictionary; 2735 enabledDictionaryMap.set(name, { 2736 index: enabledDictionaryMap.size, 2737 alias, 2738 allowSecondarySearches, 2739 partsOfSpeechFilter, 2740 useDeinflections, 2741 }); 2742 } 2743 return enabledDictionaryMap; 2744 } 2745 2746 /** 2747 * @param {import('settings').TranslationTextReplacementOptions} textReplacementsOptions 2748 * @returns {(?(import('translation').FindTermsTextReplacement[]))[]} 2749 */ 2750 _getTranslatorTextReplacements(textReplacementsOptions) { 2751 /** @type {(?(import('translation').FindTermsTextReplacement[]))[]} */ 2752 const textReplacements = []; 2753 for (const group of textReplacementsOptions.groups) { 2754 /** @type {import('translation').FindTermsTextReplacement[]} */ 2755 const textReplacementsEntries = []; 2756 for (const {pattern, ignoreCase, replacement} of group) { 2757 let patternRegExp; 2758 try { 2759 patternRegExp = ignoreCase ? 2760 new RegExp(pattern.replace(/['’]/g, "['’]"), 'gi') : 2761 new RegExp(pattern, 'g'); 2762 } catch (e) { 2763 // Invalid pattern 2764 continue; 2765 } 2766 textReplacementsEntries.push({pattern: patternRegExp, replacement}); 2767 } 2768 if (textReplacementsEntries.length > 0) { 2769 textReplacements.push(textReplacementsEntries); 2770 } 2771 } 2772 if (textReplacements.length === 0 || textReplacementsOptions.searchOriginal) { 2773 textReplacements.unshift(null); 2774 } 2775 return textReplacements; 2776 } 2777 2778 /** 2779 * @returns {Promise<void>} 2780 */ 2781 async _openWelcomeGuidePageOnce() { 2782 const result = await chrome.storage.session.get(['openedWelcomePage']); 2783 if (!result.openedWelcomePage) { 2784 await Promise.all([ 2785 this._openWelcomeGuidePage(), 2786 chrome.storage.session.set({openedWelcomePage: true}), 2787 ]); 2788 } 2789 } 2790 2791 /** 2792 * @returns {Promise<void>} 2793 */ 2794 async _openWelcomeGuidePage() { 2795 await this._createTab(chrome.runtime.getURL('/welcome.html')); 2796 } 2797 2798 /** 2799 * @returns {Promise<void>} 2800 */ 2801 async _openInfoPage() { 2802 await this._createTab(chrome.runtime.getURL('/info.html')); 2803 } 2804 2805 /** 2806 * @param {import('backend').Mode} mode 2807 */ 2808 async _openSettingsPage(mode) { 2809 const manifest = chrome.runtime.getManifest(); 2810 const optionsUI = manifest.options_ui; 2811 if (typeof optionsUI === 'undefined') { throw new Error('Failed to find options_ui'); } 2812 const {page} = optionsUI; 2813 if (typeof page === 'undefined') { throw new Error('Failed to find options_ui.page'); } 2814 const url = chrome.runtime.getURL(page); 2815 switch (mode) { 2816 case 'existingOrNewTab': 2817 await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { 2818 chrome.runtime.openOptionsPage(() => { 2819 const e = chrome.runtime.lastError; 2820 if (e) { 2821 reject(new Error(e.message)); 2822 } else { 2823 resolve(); 2824 } 2825 }); 2826 })); 2827 break; 2828 case 'newTab': 2829 await this._createTab(url); 2830 break; 2831 } 2832 } 2833 2834 /** 2835 * @param {string} url 2836 * @returns {Promise<chrome.tabs.Tab>} 2837 */ 2838 _createTab(url) { 2839 return new Promise((resolve, reject) => { 2840 chrome.tabs.create({url}, (tab) => { 2841 const e = chrome.runtime.lastError; 2842 if (e) { 2843 reject(new Error(e.message)); 2844 } else { 2845 resolve(tab); 2846 } 2847 }); 2848 }); 2849 } 2850 2851 /** 2852 * @param {number} tabId 2853 * @returns {Promise<chrome.tabs.Tab>} 2854 */ 2855 _getTabById(tabId) { 2856 return new Promise((resolve, reject) => { 2857 chrome.tabs.get( 2858 tabId, 2859 (result) => { 2860 const e = chrome.runtime.lastError; 2861 if (e) { 2862 reject(new Error(e.message)); 2863 } else { 2864 resolve(result); 2865 } 2866 }, 2867 ); 2868 }); 2869 } 2870 2871 /** 2872 * @returns {Promise<void>} 2873 */ 2874 async _checkPermissions() { 2875 this._permissions = await getAllPermissions(); 2876 this._updateBadge(); 2877 } 2878 2879 /** 2880 * @returns {boolean} 2881 */ 2882 _canObservePermissionsChanges() { 2883 return isObjectNotArray(chrome.permissions) && isObjectNotArray(chrome.permissions.onAdded) && isObjectNotArray(chrome.permissions.onRemoved); 2884 } 2885 2886 /** 2887 * @param {import('settings').ProfileOptions} options 2888 * @returns {boolean} 2889 */ 2890 _hasRequiredPermissionsForSettings(options) { 2891 if (!this._canObservePermissionsChanges()) { return true; } 2892 return this._permissions === null || hasRequiredPermissionsForOptions(this._permissions, options); 2893 } 2894 2895 /** 2896 * Only request this permission for Firefox versions >= 77. 2897 * https://bugzilla.mozilla.org/show_bug.cgi?id=1630413 2898 * @returns {Promise<void>} 2899 */ 2900 async _requestPersistentStorage() { 2901 try { 2902 if (await navigator.storage.persisted()) { return; } 2903 2904 const {vendor, version} = await browser.runtime.getBrowserInfo(); 2905 if (vendor !== 'Mozilla') { return; } 2906 2907 const match = /^\d+/.exec(version); 2908 if (match === null) { return; } 2909 2910 const versionNumber = Number.parseInt(match[0], 10); 2911 if (!(Number.isFinite(versionNumber) && versionNumber >= 77)) { return; } 2912 2913 await navigator.storage.persist(); 2914 } catch (e) { 2915 // NOP 2916 } 2917 } 2918 2919 /** 2920 * @param {{path: string, dictionary: string}[]} targets 2921 * @returns {Promise<import('dictionary-database').MediaDataStringContent[]>} 2922 */ 2923 async _getNormalizedDictionaryDatabaseMedia(targets) { 2924 const results = []; 2925 for (const item of await this._dictionaryDatabase.getMedia(targets)) { 2926 const {content, dictionary, height, mediaType, path, width} = item; 2927 const content2 = arrayBufferToBase64(content); 2928 results.push({content: content2, dictionary, height, mediaType, path, width}); 2929 } 2930 return results; 2931 } 2932 2933 /** 2934 * @param {unknown} mode 2935 * @param {import('backend').Mode} defaultValue 2936 * @returns {import('backend').Mode} 2937 */ 2938 _normalizeOpenSettingsPageMode(mode, defaultValue) { 2939 switch (mode) { 2940 case 'existingOrNewTab': 2941 case 'newTab': 2942 case 'popup': 2943 return mode; 2944 default: 2945 return defaultValue; 2946 } 2947 } 2948 2949 /** 2950 * @param {number} tabId 2951 * @param {number} frameId 2952 * @param {() => void} handler 2953 */ 2954 _addApplicationReadyHandler(tabId, frameId, handler) { 2955 const key = `${tabId}:${frameId}`; 2956 let handlers = this._applicationReadyHandlers.get(key); 2957 if (typeof handlers === 'undefined') { 2958 handlers = []; 2959 this._applicationReadyHandlers.set(key, handlers); 2960 } 2961 handlers.push(handler); 2962 } 2963 2964 /** 2965 * @param {number} tabId 2966 * @param {number} frameId 2967 * @param {() => void} handler 2968 * @returns {boolean} 2969 */ 2970 _removeApplicationReadyHandler(tabId, frameId, handler) { 2971 const key = `${tabId}:${frameId}`; 2972 const handlers = this._applicationReadyHandlers.get(key); 2973 if (typeof handlers === 'undefined') { return false; } 2974 const index = handlers.indexOf(handler); 2975 if (index < 0) { return false; } 2976 handlers.splice(index, 1); 2977 if (handlers.length === 0) { 2978 this._applicationReadyHandlers.delete(key); 2979 } 2980 return true; 2981 } 2982}