Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2023-2025 Yomitan Authors
3 * Copyright (C) 2016-2022 Yomichan Authors
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
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}