Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2023-2025 Yomitan Authors
3 * Copyright (C) 2016-2022 Yomichan Authors
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
19import {fetchJson, fetchText} from '../core/fetch-utilities.js';
20import {parseJson} from '../core/json.js';
21import {isObjectNotArray} from '../core/object-utilities.js';
22import {escapeRegExp} from '../core/utilities.js';
23import {TemplatePatcher} from '../templates/template-patcher.js';
24import {JsonSchema} from './json-schema.js';
25
26// Some type safety rules are disabled for this file since it deals with upgrading an older format
27// of the options object to a newer format. SafeAny is used for much of this, since every single
28// legacy format does not contain type definitions.
29/* eslint-disable @typescript-eslint/no-unsafe-argument */
30/* eslint-disable @typescript-eslint/no-unsafe-assignment */
31
32export class OptionsUtil {
33 constructor() {
34 /** @type {?TemplatePatcher} */
35 this._templatePatcher = null;
36 /** @type {?JsonSchema} */
37 this._optionsSchema = null;
38 }
39
40 /** */
41 async prepare() {
42 /** @type {import('ext/json-schema').Schema} */
43 const schema = await fetchJson('/data/schemas/options-schema.json');
44 this._optionsSchema = new JsonSchema(schema);
45 }
46
47 /**
48 * @param {unknown} optionsInput
49 * @param {?number} [targetVersion]
50 * @returns {Promise<import('settings').Options>}
51 */
52 async update(optionsInput, targetVersion = null) {
53 // Invalid options
54 let options = /** @type {{[key: string]: unknown}} */ (
55 typeof optionsInput === 'object' && optionsInput !== null && !Array.isArray(optionsInput) ?
56 optionsInput :
57 {}
58 );
59
60 // Check for legacy options
61 let defaultProfileOptions = {};
62 if (!Array.isArray(options.profiles)) {
63 defaultProfileOptions = options;
64 options = {};
65 }
66
67 // Ensure profiles is an array
68 if (!Array.isArray(options.profiles)) {
69 options.profiles = [];
70 }
71
72 // Remove invalid profiles
73 const profiles = /** @type {unknown[]} */ (options.profiles);
74 for (let i = profiles.length - 1; i >= 0; --i) {
75 if (!isObjectNotArray(profiles[i])) {
76 profiles.splice(i, 1);
77 }
78 }
79
80 // Require at least one profile
81 if (profiles.length === 0) {
82 profiles.push({
83 name: 'Default',
84 options: defaultProfileOptions,
85 conditionGroups: [],
86 });
87 }
88
89 // Ensure profileCurrent is valid
90 const profileCurrent = options.profileCurrent;
91 if (!(
92 typeof profileCurrent === 'number' &&
93 Number.isFinite(profileCurrent) &&
94 Math.floor(profileCurrent) === profileCurrent &&
95 profileCurrent >= 0 &&
96 profileCurrent < profiles.length
97 )) {
98 options.profileCurrent = 0;
99 }
100
101 // Version
102 if (typeof options.version !== 'number') {
103 options.version = 0;
104 }
105
106 // Generic updates
107 options = await this._applyUpdates(options, this._getVersionUpdates(targetVersion));
108
109 // Validation
110 return /** @type {import('settings').Options} */ (/** @type {JsonSchema} */ (this._optionsSchema).getValidValueOrDefault(options));
111 }
112
113 /**
114 * @returns {Promise<import('settings').Options>}
115 */
116 async load() {
117 let options;
118 try {
119 const optionsStr = await new Promise((resolve, reject) => {
120 chrome.storage.local.get(['options'], (store) => {
121 const error = chrome.runtime.lastError;
122 if (error) {
123 reject(new Error(error.message));
124 } else {
125 resolve(store.options);
126 }
127 });
128 });
129 if (typeof optionsStr !== 'string') {
130 throw new Error('Invalid value for options');
131 }
132 options = parseJson(optionsStr);
133 } catch (e) {
134 // NOP
135 }
136
137 if (typeof options !== 'undefined') {
138 options = await this.update(options);
139 await this.save(options);
140 } else {
141 options = this.getDefault();
142 }
143
144 return options;
145 }
146
147 /**
148 * @param {import('settings').Options} options
149 * @returns {Promise<void>}
150 */
151 save(options) {
152 return new Promise((resolve, reject) => {
153 chrome.storage.local.set({options: JSON.stringify(options)}, () => {
154 const error = chrome.runtime.lastError;
155 if (error) {
156 reject(new Error(error.message));
157 } else {
158 resolve();
159 }
160 });
161 });
162 }
163
164 /**
165 * @returns {import('settings').Options}
166 */
167 getDefault() {
168 const optionsVersion = this._getVersionUpdates(null).length;
169 const options = /** @type {import('settings').Options} */ (/** @type {JsonSchema} */ (this._optionsSchema).getValidValueOrDefault());
170 options.version = optionsVersion;
171 return options;
172 }
173
174 /**
175 * @param {import('settings').Options} options
176 * @returns {import('settings').Options}
177 */
178 createValidatingProxy(options) {
179 return /** @type {import('settings').Options} */ (/** @type {JsonSchema} */ (this._optionsSchema).createProxy(options));
180 }
181
182 /**
183 * @param {import('settings').Options} options
184 */
185 validate(options) {
186 /** @type {JsonSchema} */ (this._optionsSchema).validate(options);
187 }
188
189 // Legacy profile updating
190
191 /**
192 * @returns {(?import('options-util').LegacyUpdateFunction)[]}
193 */
194 _legacyProfileUpdateGetUpdates() {
195 return [
196 null,
197 null,
198 null,
199 null,
200 (options) => {
201 options.general.audioSource = options.general.audioPlayback ? 'jpod101' : 'disabled';
202 },
203 (options) => {
204 options.general.showGuide = false;
205 },
206 (options) => {
207 options.scanning.modifier = options.scanning.requireShift ? 'shift' : 'none';
208 },
209 (options) => {
210 options.general.resultOutputMode = options.general.groupResults ? 'group' : 'split';
211 options.anki.fieldTemplates = null;
212 },
213 (options) => {
214 if (this._getStringHashCode(options.anki.fieldTemplates) === 1285806040) {
215 options.anki.fieldTemplates = null;
216 }
217 },
218 (options) => {
219 if (this._getStringHashCode(options.anki.fieldTemplates) === -250091611) {
220 options.anki.fieldTemplates = null;
221 }
222 },
223 (options) => {
224 const oldAudioSource = options.general.audioSource;
225 const disabled = oldAudioSource === 'disabled';
226 options.audio.enabled = !disabled;
227 options.audio.volume = options.general.audioVolume;
228 options.audio.autoPlay = options.general.autoPlayAudio;
229 options.audio.sources = [disabled ? 'jpod101' : oldAudioSource];
230
231 delete options.general.audioSource;
232 delete options.general.audioVolume;
233 delete options.general.autoPlayAudio;
234 },
235 (options) => {
236 // Version 12 changes:
237 // The preferred default value of options.anki.fieldTemplates has been changed to null.
238 if (this._getStringHashCode(options.anki.fieldTemplates) === 1444379824) {
239 options.anki.fieldTemplates = null;
240 }
241 },
242 (options) => {
243 // Version 13 changes:
244 // Default anki field tempaltes updated to include {document-title}.
245 let fieldTemplates = options.anki.fieldTemplates;
246 if (typeof fieldTemplates === 'string') {
247 fieldTemplates += '\n\n{{#*inline "document-title"}}\n {{~context.document.title~}}\n{{/inline}}';
248 options.anki.fieldTemplates = fieldTemplates;
249 }
250 },
251 (options) => {
252 // Version 14 changes:
253 // Changed template for Anki audio and tags.
254 let fieldTemplates = options.anki.fieldTemplates;
255 if (typeof fieldTemplates !== 'string') { return; }
256
257 const replacements = [
258 [
259 '{{#*inline "audio"}}{{/inline}}',
260 '{{#*inline "audio"}}\n {{~#if definition.audioFileName~}}\n [sound:{{definition.audioFileName}}]\n {{~/if~}}\n{{/inline}}',
261 ],
262 [
263 '{{#*inline "tags"}}\n {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}}\n{{/inline}}',
264 '{{#*inline "tags"}}\n {{~#mergeTags definition group merge}}{{this}}{{/mergeTags~}}\n{{/inline}}',
265 ],
266 ];
267
268 for (const [pattern, replacement] of replacements) {
269 let replaced = false;
270 fieldTemplates = fieldTemplates.replace(new RegExp(escapeRegExp(pattern), 'g'), () => {
271 replaced = true;
272 return replacement;
273 });
274
275 if (!replaced) {
276 fieldTemplates += '\n\n' + replacement;
277 }
278 }
279
280 options.anki.fieldTemplates = fieldTemplates;
281 },
282 ];
283 }
284
285 /**
286 * @returns {import('options-util').LegacyOptions}
287 */
288 _legacyProfileUpdateGetDefaults() {
289 return {
290 general: {
291 enable: true,
292 enableClipboardPopups: false,
293 resultOutputMode: 'group',
294 debugInfo: false,
295 maxResults: 32,
296 fontFamily: '',
297 fontSize: 14,
298 lineHeight: '1.5',
299 showAdvanced: false,
300 popupDisplayMode: 'default',
301 popupWidth: 400,
302 popupHeight: 250,
303 popupHorizontalOffset: 0,
304 popupVerticalOffset: 10,
305 popupHorizontalOffset2: 10,
306 popupVerticalOffset2: 0,
307 popupHorizontalTextPosition: 'below',
308 popupVerticalTextPosition: 'before',
309 popupScalingFactor: 1,
310 popupScaleRelativeToPageZoom: false,
311 popupScaleRelativeToVisualViewport: true,
312 showGuide: true,
313 compactTags: false,
314 compactGlossaries: false,
315 mainDictionary: '',
316 popupTheme: 'default',
317 popupOuterTheme: 'default',
318 customPopupCss: '',
319 customPopupOuterCss: '',
320 enableWanakana: true,
321 enableClipboardMonitor: false,
322 showPitchAccentDownstepNotation: true,
323 showPitchAccentPositionNotation: true,
324 showPitchAccentGraph: false,
325 showIframePopupsInRootFrame: false,
326 useSecurePopupFrameUrl: true,
327 usePopupShadowDom: true,
328 },
329
330 audio: {
331 enabled: true,
332 sources: ['jpod101'],
333 volume: 100,
334 autoPlay: false,
335 customSourceUrl: '',
336 textToSpeechVoice: '',
337 },
338
339 scanning: {
340 middleMouse: true,
341 touchInputEnabled: true,
342 selectText: true,
343 alphanumeric: true,
344 autoHideResults: false,
345 delay: 20,
346 length: 10,
347 modifier: 'shift',
348 deepDomScan: false,
349 popupNestingMaxDepth: 10,
350 enablePopupSearch: false,
351 enableOnPopupExpressions: false,
352 enableOnSearchPage: true,
353 enableSearchTags: false,
354 layoutAwareScan: false,
355 scanAltText: true,
356 },
357
358 translation: {
359 convertHalfWidthCharacters: 'false',
360 convertNumericCharacters: 'false',
361 convertAlphabeticCharacters: 'false',
362 convertHiraganaToKatakana: 'false',
363 convertKatakanaToHiragana: 'variant',
364 collapseEmphaticSequences: 'false',
365 },
366
367 dictionaries: {},
368
369 parsing: {
370 enableScanningParser: true,
371 enableMecabParser: false,
372 selectedParser: null,
373 termSpacing: true,
374 readingMode: 'hiragana',
375 },
376
377 anki: {
378 enable: false,
379 server: 'http://127.0.0.1:8765',
380 tags: ['yomitan'],
381 sentenceExt: 200,
382 screenshot: {format: 'png', quality: 92},
383 terms: {deck: '', model: '', fields: {}},
384 kanji: {deck: '', model: '', fields: {}},
385 duplicateScope: 'collection',
386 fieldTemplates: null,
387 },
388 };
389 }
390
391 /**
392 * @param {import('options-util').IntermediateOptions} options
393 * @returns {import('options-util').IntermediateOptions}
394 */
395 _legacyProfileUpdateAssignDefaults(options) {
396 const defaults = this._legacyProfileUpdateGetDefaults();
397
398 /**
399 * @param {import('options-util').IntermediateOptions} target
400 * @param {import('core').UnknownObject} source
401 */
402 const combine = (target, source) => {
403 for (const key in source) {
404 if (!Object.prototype.hasOwnProperty.call(target, key)) {
405 target[key] = source[key];
406 }
407 }
408 };
409
410 combine(options, defaults);
411 combine(options.general, defaults.general);
412 combine(options.scanning, defaults.scanning);
413 combine(options.anki, defaults.anki);
414 combine(options.anki.terms, defaults.anki.terms);
415 combine(options.anki.kanji, defaults.anki.kanji);
416
417 return options;
418 }
419
420 /**
421 * @param {import('options-util').IntermediateOptions} options
422 * @returns {import('options-util').IntermediateOptions}
423 */
424 _legacyProfileUpdateUpdateVersion(options) {
425 const updates = this._legacyProfileUpdateGetUpdates();
426 this._legacyProfileUpdateAssignDefaults(options);
427
428 const targetVersion = updates.length;
429 const currentVersion = options.version;
430
431 if (typeof currentVersion === 'number' && Number.isFinite(currentVersion)) {
432 for (let i = Math.max(0, Math.floor(currentVersion)); i < targetVersion; ++i) {
433 const update = updates[i];
434 if (update !== null) {
435 update(options);
436 }
437 }
438 }
439
440 options.version = targetVersion;
441 return options;
442 }
443
444 // Private
445
446 /**
447 * @param {import('options-util').IntermediateOptions} options
448 * @param {string} modificationsUrl
449 */
450 async _applyAnkiFieldTemplatesPatch(options, modificationsUrl) {
451 let patch = null;
452 for (const {options: profileOptions} of options.profiles) {
453 const fieldTemplates = profileOptions.anki.fieldTemplates;
454 if (fieldTemplates === null) { continue; }
455
456 if (patch === null) {
457 const content = await fetchText(modificationsUrl);
458 if (this._templatePatcher === null) {
459 this._templatePatcher = new TemplatePatcher();
460 }
461 patch = this._templatePatcher.parsePatch(content);
462 }
463
464 profileOptions.anki.fieldTemplates = /** @type {TemplatePatcher} */ (this._templatePatcher).applyPatch(fieldTemplates, patch);
465 }
466 }
467
468 /**
469 * @param {string} string
470 * @returns {number}
471 */
472 _getStringHashCode(string) {
473 let hashCode = 0;
474
475 if (typeof string !== 'string') { return hashCode; }
476
477 for (let i = 0, charCode = string.charCodeAt(i); i < string.length; charCode = string.charCodeAt(++i)) {
478 hashCode = ((hashCode << 5) - hashCode) + charCode;
479 hashCode |= 0;
480 }
481
482 return hashCode;
483 }
484
485 /**
486 * @param {import('options-util').IntermediateOptions} options
487 * @param {import('options-util').UpdateFunction[]} updates
488 * @returns {Promise<import('settings').Options>}
489 */
490 async _applyUpdates(options, updates) {
491 const targetVersion = updates.length;
492 let currentVersion = options.version;
493
494 if (typeof currentVersion !== 'number' || !Number.isFinite(currentVersion)) {
495 currentVersion = 0;
496 }
497
498 for (let i = Math.max(0, Math.floor(currentVersion)); i < targetVersion; ++i) {
499 const update = updates[i];
500 const result = update.call(this, options);
501 if (result instanceof Promise) { await result; }
502 }
503
504 options.version = targetVersion;
505 return options;
506 }
507
508 /**
509 * @param {?number} targetVersion
510 * @returns {import('options-util').UpdateFunction[]}
511 */
512 _getVersionUpdates(targetVersion) {
513 /* eslint-disable @typescript-eslint/unbound-method */
514 const result = [
515 this._updateVersion1,
516 this._updateVersion2,
517 this._updateVersion3,
518 this._updateVersion4,
519 this._updateVersion5,
520 this._updateVersion6,
521 this._updateVersion7,
522 this._updateVersion8,
523 this._updateVersion9,
524 this._updateVersion10,
525 this._updateVersion11,
526 this._updateVersion12,
527 this._updateVersion13,
528 this._updateVersion14,
529 this._updateVersion15,
530 this._updateVersion16,
531 this._updateVersion17,
532 this._updateVersion18,
533 this._updateVersion19,
534 this._updateVersion20,
535 this._updateVersion21,
536 this._updateVersion22,
537 this._updateVersion23,
538 this._updateVersion24,
539 this._updateVersion25,
540 this._updateVersion26,
541 this._updateVersion27,
542 this._updateVersion28,
543 this._updateVersion29,
544 this._updateVersion30,
545 this._updateVersion31,
546 this._updateVersion32,
547 this._updateVersion33,
548 this._updateVersion34,
549 this._updateVersion35,
550 this._updateVersion36,
551 this._updateVersion37,
552 this._updateVersion38,
553 this._updateVersion39,
554 this._updateVersion40,
555 this._updateVersion41,
556 this._updateVersion42,
557 this._updateVersion43,
558 this._updateVersion44,
559 this._updateVersion45,
560 this._updateVersion46,
561 this._updateVersion47,
562 this._updateVersion48,
563 this._updateVersion49,
564 this._updateVersion50,
565 this._updateVersion51,
566 this._updateVersion52,
567 this._updateVersion53,
568 this._updateVersion54,
569 this._updateVersion55,
570 this._updateVersion56,
571 this._updateVersion57,
572 this._updateVersion58,
573 this._updateVersion59,
574 this._updateVersion60,
575 this._updateVersion61,
576 this._updateVersion62,
577 this._updateVersion63,
578 this._updateVersion64,
579 this._updateVersion65,
580 this._updateVersion66,
581 this._updateVersion67,
582 this._updateVersion68,
583 this._updateVersion69,
584 this._updateVersion70,
585 this._updateVersion71,
586 this._updateVersion72,
587 this._updateVersion73,
588 this._updateVersion74,
589 ];
590 /* eslint-enable @typescript-eslint/unbound-method */
591 if (typeof targetVersion === 'number' && targetVersion < result.length) {
592 result.splice(targetVersion);
593 }
594 return result;
595 }
596
597 /**
598 * - Added options.global.database.prefixWildcardsSupported = false.
599 * @type {import('options-util').UpdateFunction}
600 */
601 _updateVersion1(options) {
602 options.global = {
603 database: {
604 prefixWildcardsSupported: false,
605 },
606 };
607 }
608
609 /**
610 * - Legacy profile update process moved into this upgrade function.
611 * @type {import('options-util').UpdateFunction}
612 */
613 _updateVersion2(options) {
614 for (const profile of options.profiles) {
615 if (!Array.isArray(profile.conditionGroups)) {
616 profile.conditionGroups = [];
617 }
618 profile.options = this._legacyProfileUpdateUpdateVersion(profile.options);
619 }
620 }
621
622 /**
623 * - Pitch accent Anki field templates added.
624 * @type {import('options-util').UpdateFunction}
625 */
626 async _updateVersion3(options) {
627 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v2.handlebars');
628 }
629
630 /**
631 * - Options conditions converted to string representations.
632 * - Added usePopupWindow.
633 * - Updated handlebars templates to include "clipboard-image" definition.
634 * - Updated handlebars templates to include "clipboard-text" definition.
635 * - Added hideDelay.
636 * - Added inputs to profileOptions.scanning.
637 * - Added pointerEventsEnabled to profileOptions.scanning.
638 * - Added preventMiddleMouse to profileOptions.scanning.
639 * @type {import('options-util').UpdateFunction}
640 */
641 async _updateVersion4(options) {
642 for (const {conditionGroups} of options.profiles) {
643 for (const {conditions} of conditionGroups) {
644 for (const condition of conditions) {
645 const value = condition.value;
646 condition.value = (
647 Array.isArray(value) ?
648 value.join(', ') :
649 `${value}`
650 );
651 }
652 }
653 }
654 const createInputDefaultOptions = () => ({
655 showAdvanced: false,
656 searchTerms: true,
657 searchKanji: true,
658 scanOnTouchMove: false,
659 scanOnPenHover: false,
660 scanOnPenPress: true,
661 scanOnPenRelease: false,
662 preventTouchScrolling: true,
663 minimumTouchTime: 0,
664 });
665 for (const {options: profileOptions} of options.profiles) {
666 profileOptions.general.usePopupWindow = false;
667 profileOptions.scanning.hideDelay = 0;
668 profileOptions.scanning.pointerEventsEnabled = false;
669 profileOptions.scanning.preventMiddleMouse = {
670 onTextHover: false,
671 onWebPages: false,
672 onPopupPages: false,
673 onSearchPages: false,
674 onSearchQuery: false,
675 };
676 profileOptions.scanning.preventBackForward = {
677 onTextHover: false,
678 onWebPages: false,
679 onPopupPages: false,
680 onSearchPages: false,
681 onSearchQuery: false,
682 };
683
684 const {modifier, middleMouse} = profileOptions.scanning;
685 delete profileOptions.scanning.modifier;
686 delete profileOptions.scanning.middleMouse;
687 const scanningInputs = [];
688 let modifierInput = '';
689 switch (modifier) {
690 case 'alt':
691 case 'ctrl':
692 case 'shift':
693 case 'meta':
694 modifierInput = modifier;
695 break;
696 case 'none':
697 modifierInput = '';
698 break;
699 }
700 scanningInputs.push({
701 include: modifierInput,
702 exclude: 'mouse0',
703 types: {mouse: true, touch: false, pen: false},
704 options: createInputDefaultOptions(),
705 });
706 if (middleMouse) {
707 scanningInputs.push({
708 include: 'mouse2',
709 exclude: '',
710 types: {mouse: true, touch: false, pen: false},
711 options: createInputDefaultOptions(),
712 });
713 }
714 scanningInputs.push({
715 include: '',
716 exclude: '',
717 types: {mouse: false, touch: true, pen: true},
718 options: createInputDefaultOptions(),
719 });
720 profileOptions.scanning.inputs = scanningInputs;
721 }
722 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v4.handlebars');
723 }
724
725 /**
726 * - Removed legacy version number from profile options.
727 * @type {import('options-util').UpdateFunction}
728 */
729 _updateVersion5(options) {
730 for (const profile of options.profiles) {
731 delete profile.options.version;
732 }
733 }
734
735 /**
736 * - Updated handlebars templates to include "conjugation" definition.
737 * - Added global option showPopupPreview.
738 * - Added global option useSettingsV2.
739 * - Added anki.checkForDuplicates.
740 * - Added general.glossaryLayoutMode; removed general.compactGlossaries.
741 * @type {import('options-util').UpdateFunction}
742 */
743 async _updateVersion6(options) {
744 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v6.handlebars');
745 options.global.showPopupPreview = false;
746 options.global.useSettingsV2 = false;
747 for (const profile of options.profiles) {
748 profile.options.anki.checkForDuplicates = true;
749 profile.options.general.glossaryLayoutMode = (profile.options.general.compactGlossaries ? 'compact' : 'default');
750 delete profile.options.general.compactGlossaries;
751 const fieldTemplates = profile.options.anki.fieldTemplates;
752 if (typeof fieldTemplates === 'string') {
753 profile.options.anki.fieldTemplates = this._updateVersion6AnkiTemplatesCompactTags(fieldTemplates);
754 }
755 }
756 }
757
758 /**
759 * @param {string} templates
760 * @returns {string}
761 */
762 _updateVersion6AnkiTemplatesCompactTags(templates) {
763 const rawPattern1 = '{{~#if definitionTags~}}<i>({{#each definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each}})</i> {{/if~}}';
764 const pattern1 = new RegExp(`((\r?\n)?[ \t]*)${escapeRegExp(rawPattern1)}`, 'g');
765 const replacement1 = (
766 // eslint-disable-next-line @stylistic/indent
767`{{~#scope~}}
768 {{~#set "any" false}}{{/set~}}
769 {{~#if definitionTags~}}{{#each definitionTags~}}
770 {{~#if (op "||" (op "!" ../data.compactTags) (op "!" redundant))~}}
771 {{~#if (get "any")}}, {{else}}<i>({{/if~}}
772 {{name}}
773 {{~#set "any" true}}{{/set~}}
774 {{~/if~}}
775 {{~/each~}}
776 {{~#if (get "any")}})</i> {{/if~}}
777 {{~/if~}}
778{{~/scope~}}`
779 );
780 const simpleNewline = /\n/g;
781 templates = templates.replace(pattern1, (g0, space) => (space + replacement1.replace(simpleNewline, space)));
782 templates = templates.replace(/\bcompactGlossaries=((?:\.*\/)*)compactGlossaries\b/g, (g0, g1) => `${g0} data=${g1}.`);
783 return templates;
784 }
785
786 /**
787 * - Added general.maximumClipboardSearchLength.
788 * - Added general.popupCurrentIndicatorMode.
789 * - Added general.popupActionBarVisibility.
790 * - Added general.popupActionBarLocation.
791 * - Removed global option showPopupPreview.
792 * @type {import('options-util').UpdateFunction}
793 */
794 _updateVersion7(options) {
795 delete options.global.showPopupPreview;
796 for (const profile of options.profiles) {
797 profile.options.general.maximumClipboardSearchLength = 1000;
798 profile.options.general.popupCurrentIndicatorMode = 'triangle';
799 profile.options.general.popupActionBarVisibility = 'auto';
800 profile.options.general.popupActionBarLocation = 'right';
801 }
802 }
803
804 /**
805 * - Added translation.textReplacements.
806 * - Moved anki.sentenceExt to sentenceParsing.scanExtent.
807 * - Added sentenceParsing.enableTerminationCharacters.
808 * - Added sentenceParsing.terminationCharacters.
809 * - Changed general.popupActionBarLocation.
810 * - Added inputs.hotkeys.
811 * - Added anki.suspendNewCards.
812 * - Added popupWindow.
813 * - Updated handlebars templates to include "stroke-count" definition.
814 * - Updated global.useSettingsV2 to be true (opt-out).
815 * - Added audio.customSourceType.
816 * - Moved general.enableClipboardPopups => clipboard.enableBackgroundMonitor.
817 * - Moved general.enableClipboardMonitor => clipboard.enableSearchPageMonitor. Forced value to false due to a bug which caused its value to not be read.
818 * - Moved general.maximumClipboardSearchLength => clipboard.maximumSearchLength.
819 * - Added clipboard.autoSearchContent.
820 * @type {import('options-util').UpdateFunction}
821 */
822 async _updateVersion8(options) {
823 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v8.handlebars');
824 options.global.useSettingsV2 = true;
825 for (const profile of options.profiles) {
826 profile.options.translation.textReplacements = {
827 searchOriginal: true,
828 groups: [],
829 };
830 profile.options.sentenceParsing = {
831 scanExtent: profile.options.anki.sentenceExt,
832 enableTerminationCharacters: true,
833 terminationCharacters: [
834 {enabled: true, character1: '「', character2: '」', includeCharacterAtStart: false, includeCharacterAtEnd: false},
835 {enabled: true, character1: '『', character2: '』', includeCharacterAtStart: false, includeCharacterAtEnd: false},
836 {enabled: true, character1: '"', character2: '"', includeCharacterAtStart: false, includeCharacterAtEnd: false},
837 {enabled: true, character1: '\'', character2: '\'', includeCharacterAtStart: false, includeCharacterAtEnd: false},
838 {enabled: true, character1: '.', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true},
839 {enabled: true, character1: '!', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true},
840 {enabled: true, character1: '?', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true},
841 {enabled: true, character1: '.', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true},
842 {enabled: true, character1: '。', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true},
843 {enabled: true, character1: '!', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true},
844 {enabled: true, character1: '?', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true},
845 {enabled: true, character1: '…', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true},
846 ],
847 };
848 delete profile.options.anki.sentenceExt;
849 profile.options.general.popupActionBarLocation = 'top';
850 /* eslint-disable @stylistic/no-multi-spaces */
851 profile.options.inputs = {
852 hotkeys: [
853 {action: 'close', key: 'Escape', modifiers: [], scopes: ['popup'], enabled: true},
854 {action: 'focusSearchBox', key: 'Escape', modifiers: [], scopes: ['search'], enabled: true},
855 {action: 'previousEntry3', key: 'PageUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
856 {action: 'nextEntry3', key: 'PageDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
857 {action: 'lastEntry', key: 'End', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
858 {action: 'firstEntry', key: 'Home', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
859 {action: 'previousEntry', key: 'ArrowUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
860 {action: 'nextEntry', key: 'ArrowDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
861 {action: 'historyBackward', key: 'KeyB', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
862 {action: 'historyForward', key: 'KeyF', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
863 {action: 'addNoteKanji', key: 'KeyK', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
864 {action: 'addNoteTermKanji', key: 'KeyE', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
865 {action: 'addNoteTermKana', key: 'KeyR', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
866 {action: 'playAudio', key: 'KeyP', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
867 {action: 'viewNote', key: 'KeyV', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
868 {action: 'copyHostSelection', key: 'KeyC', modifiers: ['ctrl'], scopes: ['popup'], enabled: true},
869 ],
870 };
871 /* eslint-enable @stylistic/no-multi-spaces */
872 profile.options.anki.suspendNewCards = false;
873 profile.options.popupWindow = {
874 width: profile.options.general.popupWidth,
875 height: profile.options.general.popupHeight,
876 left: 0,
877 top: 0,
878 useLeft: false,
879 useTop: false,
880 windowType: 'popup',
881 windowState: 'normal',
882 };
883 profile.options.audio.customSourceType = 'audio';
884 profile.options.clipboard = {
885 enableBackgroundMonitor: profile.options.general.enableClipboardPopups,
886 enableSearchPageMonitor: false,
887 autoSearchContent: true,
888 maximumSearchLength: profile.options.general.maximumClipboardSearchLength,
889 };
890 delete profile.options.general.enableClipboardPopups;
891 delete profile.options.general.enableClipboardMonitor;
892 delete profile.options.general.maximumClipboardSearchLength;
893 }
894 }
895
896 /**
897 * - Added general.frequencyDisplayMode.
898 * - Added general.termDisplayMode.
899 * @type {import('options-util').UpdateFunction}
900 */
901 _updateVersion9(options) {
902 for (const profile of options.profiles) {
903 profile.options.general.frequencyDisplayMode = 'split-tags-grouped';
904 profile.options.general.termDisplayMode = 'ruby';
905 }
906 }
907
908 /**
909 * - Removed global option useSettingsV2.
910 * - Added part-of-speech field template.
911 * - Added an argument to hotkey inputs.
912 * - Added definitionsCollapsible to dictionary options.
913 * @type {import('options-util').UpdateFunction}
914 */
915 async _updateVersion10(options) {
916 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v10.handlebars');
917 delete options.global.useSettingsV2;
918 for (const profile of options.profiles) {
919 for (const dictionaryOptions of Object.values(profile.options.dictionaries)) {
920 dictionaryOptions.definitionsCollapsible = 'not-collapsible';
921 }
922 for (const hotkey of profile.options.inputs.hotkeys) {
923 switch (hotkey.action) {
924 case 'previousEntry':
925 hotkey.argument = '1';
926 break;
927 case 'previousEntry3':
928 hotkey.action = 'previousEntry';
929 hotkey.argument = '3';
930 break;
931 case 'nextEntry':
932 hotkey.argument = '1';
933 break;
934 case 'nextEntry3':
935 hotkey.action = 'nextEntry';
936 hotkey.argument = '3';
937 break;
938 default:
939 hotkey.argument = '';
940 break;
941 }
942 }
943 }
944 }
945
946 /**
947 * - Changed dictionaries to an array.
948 * - Changed audio.customSourceUrl's {expression} marker to {term}.
949 * - Added anki.displayTags.
950 * @type {import('options-util').UpdateFunction}
951 */
952 _updateVersion11(options) {
953 const customSourceUrlPattern = /\{expression\}/g;
954 for (const profile of options.profiles) {
955 const dictionariesNew = [];
956 for (const [name, {priority, enabled, allowSecondarySearches, definitionsCollapsible}] of Object.entries(profile.options.dictionaries)) {
957 dictionariesNew.push({name, priority, enabled, allowSecondarySearches, definitionsCollapsible});
958 }
959 profile.options.dictionaries = dictionariesNew;
960
961 let {customSourceUrl} = profile.options.audio;
962 if (typeof customSourceUrl === 'string') {
963 customSourceUrl = customSourceUrl.replace(customSourceUrlPattern, '{term}');
964 }
965 profile.options.audio.customSourceUrl = customSourceUrl;
966
967 profile.options.anki.displayTags = 'never';
968 }
969 }
970
971 /**
972 * - Changed sentenceParsing.enableTerminationCharacters to sentenceParsing.terminationCharacterMode.
973 * - Added {search-query} field marker.
974 * - Updated audio.sources[] to change 'custom' into 'custom-json'.
975 * - Removed audio.customSourceType.
976 * @type {import('options-util').UpdateFunction}
977 */
978 async _updateVersion12(options) {
979 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v12.handlebars');
980 for (const profile of options.profiles) {
981 const {sentenceParsing, audio} = profile.options;
982
983 sentenceParsing.terminationCharacterMode = sentenceParsing.enableTerminationCharacters ? 'custom' : 'newlines';
984 delete sentenceParsing.enableTerminationCharacters;
985
986 const {sources, customSourceUrl, customSourceType, textToSpeechVoice} = audio;
987 audio.sources = /** @type {string[]} */ (sources).map((type) => {
988 switch (type) {
989 case 'text-to-speech':
990 case 'text-to-speech-reading':
991 return {type, url: '', voice: textToSpeechVoice};
992 case 'custom':
993 return {type: (customSourceType === 'json' ? 'custom-json' : 'custom'), url: customSourceUrl, voice: ''};
994 default:
995 return {type, url: '', voice: ''};
996 }
997 });
998 delete audio.customSourceType;
999 delete audio.customSourceUrl;
1000 delete audio.textToSpeechVoice;
1001 }
1002 }
1003
1004 /**
1005 * - Handlebars templates updated to use formatGlossary.
1006 * - Handlebars templates updated to use new media format.
1007 * - Added {selection-text} field marker.
1008 * - Added {sentence-furigana} field marker.
1009 * - Added anki.duplicateScopeCheckAllModels.
1010 * - Updated pronunciation templates.
1011 * @type {import('options-util').UpdateFunction}
1012 */
1013 async _updateVersion13(options) {
1014 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v13.handlebars');
1015 for (const profile of options.profiles) {
1016 profile.options.anki.duplicateScopeCheckAllModels = false;
1017 }
1018 }
1019
1020 /**
1021 * - Added accessibility options.
1022 * @type {import('options-util').UpdateFunction}
1023 */
1024 _updateVersion14(options) {
1025 for (const profile of options.profiles) {
1026 profile.options.accessibility = {
1027 forceGoogleDocsHtmlRendering: false,
1028 };
1029 }
1030 }
1031
1032 /**
1033 * - Added general.sortFrequencyDictionary.
1034 * - Added general.sortFrequencyDictionaryOrder.
1035 * @type {import('options-util').UpdateFunction}
1036 */
1037 _updateVersion15(options) {
1038 for (const profile of options.profiles) {
1039 profile.options.general.sortFrequencyDictionary = null;
1040 profile.options.general.sortFrequencyDictionaryOrder = 'descending';
1041 }
1042 }
1043
1044 /**
1045 * - Added scanning.matchTypePrefix.
1046 * @type {import('options-util').UpdateFunction}
1047 */
1048 _updateVersion16(options) {
1049 for (const profile of options.profiles) {
1050 profile.options.scanning.matchTypePrefix = false;
1051 }
1052 }
1053
1054 /**
1055 * - Added vertical sentence punctuation to terminationCharacters.
1056 * @type {import('options-util').UpdateFunction}
1057 */
1058 _updateVersion17(options) {
1059 const additions = ['︒', '︕', '︖', '︙'];
1060 for (const profile of options.profiles) {
1061 /** @type {import('settings').SentenceParsingTerminationCharacterOption[]} */
1062 const terminationCharacters = profile.options.sentenceParsing.terminationCharacters;
1063 const newAdditions = [];
1064 for (const character of additions) {
1065 if (!terminationCharacters.some((value) => (value.character1 === character && value.character2 === null))) {
1066 newAdditions.push(character);
1067 }
1068 }
1069 for (const character of newAdditions) {
1070 terminationCharacters.push({
1071 enabled: true,
1072 character1: character,
1073 character2: null,
1074 includeCharacterAtStart: false,
1075 includeCharacterAtEnd: true,
1076 });
1077 }
1078 }
1079 }
1080
1081 /**
1082 * - general.popupTheme's 'default' value changed to 'light'
1083 * - general.popupOuterTheme's 'default' value changed to 'light'
1084 * - general.popupOuterTheme's 'auto' value changed to 'site'
1085 * - Added scanning.hidePopupOnCursorExit.
1086 * - Added scanning.hidePopupOnCursorExitDelay.
1087 * @type {import('options-util').UpdateFunction}
1088 */
1089 _updateVersion18(options) {
1090 for (const profile of options.profiles) {
1091 const {general} = profile.options;
1092 if (general.popupTheme === 'default') {
1093 general.popupTheme = 'light';
1094 }
1095 switch (general.popupOuterTheme) {
1096 case 'default': general.popupOuterTheme = 'light'; break;
1097 case 'auto': general.popupOuterTheme = 'site'; break;
1098 }
1099 profile.options.scanning.hidePopupOnCursorExit = false;
1100 profile.options.scanning.hidePopupOnCursorExitDelay = profile.options.scanning.hideDelay;
1101 }
1102 }
1103
1104 /**
1105 * - Added anki.noteGuiMode.
1106 * - Added anki.apiKey.
1107 * - Renamed scanning.inputs[].options.scanOnPenPress to scanOnPenMove.
1108 * - Renamed scanning.inputs[].options.scanOnPenRelease to scanOnPenReleaseHover.
1109 * - Added scanning.inputs[].options.scanOnTouchPress.
1110 * - Added scanning.inputs[].options.scanOnTouchRelease.
1111 * - Added scanning.inputs[].options.scanOnPenPress.
1112 * - Added scanning.inputs[].options.scanOnPenRelease.
1113 * - Added scanning.inputs[].options.preventPenScrolling.
1114 * @type {import('options-util').UpdateFunction}
1115 */
1116 _updateVersion19(options) {
1117 for (const profile of options.profiles) {
1118 profile.options.anki.noteGuiMode = 'browse';
1119 profile.options.anki.apiKey = '';
1120 for (const input of profile.options.scanning.inputs) {
1121 input.options.scanOnPenMove = input.options.scanOnPenPress;
1122 input.options.scanOnPenReleaseHover = input.options.scanOnPenRelease;
1123 input.options.scanOnTouchPress = true;
1124 input.options.scanOnTouchRelease = false;
1125 input.options.scanOnPenPress = input.options.scanOnPenMove;
1126 input.options.scanOnPenRelease = false;
1127 input.options.preventPenScrolling = input.options.preventTouchScrolling;
1128 }
1129 }
1130 }
1131
1132 /**
1133 * - Added anki.downloadTimeout.
1134 * - Added scanning.normalizeCssZoom.
1135 * - Fixed general.popupTheme invalid default.
1136 * - Fixed general.popupOuterTheme invalid default.
1137 * @type {import('options-util').UpdateFunction}
1138 */
1139 _updateVersion20(options) {
1140 for (const profile of options.profiles) {
1141 profile.options.anki.downloadTimeout = 0;
1142 profile.options.scanning.normalizeCssZoom = true;
1143 const {general} = profile.options;
1144 if (general.popupTheme === 'default') {
1145 general.popupTheme = 'light';
1146 }
1147 if (general.popupOuterTheme === 'default') {
1148 general.popupOuterTheme = 'light';
1149 }
1150 }
1151 }
1152
1153 /**
1154 * - Converted Handlebars templates to new format.
1155 * - Assigned flag to show users a warning about template changes.
1156 * @type {import('options-util').UpdateFunction}
1157 */
1158 async _updateVersion21(options) {
1159 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v21.handlebars');
1160
1161 let customTemplates = false;
1162 for (const {options: profileOptions} of options.profiles) {
1163 if (profileOptions.anki.fieldTemplates !== null) {
1164 customTemplates = true;
1165 }
1166 }
1167
1168 if (customTemplates && isObjectNotArray(chrome.storage)) {
1169 void chrome.storage.session.set({needsCustomTemplatesWarning: true});
1170 await this._createTab(chrome.runtime.getURL('/welcome.html'));
1171 void chrome.storage.session.set({openedWelcomePage: true});
1172 }
1173 }
1174
1175 /**
1176 * - Added translation.searchResolution.
1177 * @type {import('options-util').UpdateFunction}
1178 */
1179 _updateVersion22(options) {
1180 for (const {options: profileOptions} of options.profiles) {
1181 profileOptions.translation.searchResolution = 'letter';
1182 }
1183 }
1184
1185 /**
1186 * - Added dictionaries[].partsOfSpeechFilter.
1187 * @type {import('options-util').UpdateFunction}
1188 */
1189 _updateVersion23(options) {
1190 for (const {options: profileOptions} of options.profiles) {
1191 if (Array.isArray(profileOptions.dictionaries)) {
1192 for (const dictionary of profileOptions.dictionaries) {
1193 dictionary.partsOfSpeechFilter = true;
1194 }
1195 }
1196 }
1197 }
1198
1199 /**
1200 * - Added dictionaries[].useDeinflections.
1201 * @type {import('options-util').UpdateFunction}
1202 */
1203 async _updateVersion24(options) {
1204 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v24.handlebars');
1205
1206 for (const {options: profileOptions} of options.profiles) {
1207 if (Array.isArray(profileOptions.dictionaries)) {
1208 for (const dictionary of profileOptions.dictionaries) {
1209 dictionary.useDeinflections = true;
1210 }
1211 }
1212 }
1213 }
1214
1215 /**
1216 * - Change 'viewNote' action to 'viewNotes'.
1217 * @type {import('options-util').UpdateFunction}
1218 */
1219 async _updateVersion25(options) {
1220 for (const profile of options.profiles) {
1221 if ('inputs' in profile.options && 'hotkeys' in profile.options.inputs) {
1222 for (const hotkey of profile.options.inputs.hotkeys) {
1223 if (hotkey.action === 'viewNote') {
1224 hotkey.action = 'viewNotes';
1225 }
1226 }
1227 }
1228 }
1229 }
1230
1231 /**
1232 * - Added general.language.
1233 * - Modularized text preprocessors.
1234 * @type {import('options-util').UpdateFunction}
1235 */
1236 _updateVersion26(options) {
1237 const textPreprocessors = [
1238 'convertHalfWidthCharacters',
1239 'convertNumericCharacters',
1240 'convertAlphabeticCharacters',
1241 'convertHiraganaToKatakana',
1242 'convertKatakanaToHiragana',
1243 'collapseEmphaticSequences',
1244 ];
1245
1246 for (const {options: profileOptions} of options.profiles) {
1247 profileOptions.general.language = 'ja';
1248
1249 for (const preprocessor of textPreprocessors) {
1250 delete profileOptions.translation[preprocessor];
1251 }
1252 }
1253 }
1254
1255 /**
1256 * - Updated handlebars.
1257 * @type {import('options-util').UpdateFunction}
1258 */
1259 async _updateVersion27(options) {
1260 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v27.handlebars');
1261 }
1262
1263 /**
1264 * - Removed whitespace in URL handlebars template.
1265 * @type {import('options-util').UpdateFunction}
1266 */
1267 async _updateVersion28(options) {
1268 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v28.handlebars');
1269 }
1270
1271 /**
1272 * - Added new handlebar for different pitch accent graph style.
1273 * @type {import('options-util').UpdateFunction}
1274 */
1275 async _updateVersion29(options) {
1276 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v29.handlebars');
1277 }
1278
1279 /**
1280 * - Added scanning.inputs[].options.scanOnTouchTap.
1281 * - Set touch settings to be more sensible.
1282 * @type {import('options-util').UpdateFunction}
1283 */
1284 async _updateVersion30(options) {
1285 for (const profile of options.profiles) {
1286 for (const input of profile.options.scanning.inputs) {
1287 input.options.scanOnTouchTap = true;
1288 input.options.scanOnTouchPress = false;
1289 input.options.scanOnTouchRelease = false;
1290 }
1291 }
1292 }
1293
1294 /**
1295 * - Added anki.duplicateBehavior
1296 * @type {import('options-util').UpdateFunction}
1297 */
1298 _updateVersion31(options) {
1299 for (const {options: profileOptions} of options.profiles) {
1300 profileOptions.anki.duplicateBehavior = 'new';
1301 }
1302 }
1303
1304 /**
1305 * - Added profilePrevious and profileNext to hotkeys.
1306 * @type {import('options-util').UpdateFunction}
1307 */
1308 async _updateVersion32(options) {
1309 for (const profile of options.profiles) {
1310 profile.options.inputs.hotkeys.push(
1311 {action: 'profilePrevious', key: 'Minus', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
1312 {action: 'profileNext', key: 'Equal', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true},
1313 );
1314 }
1315 }
1316
1317 /**
1318 * - Updated handlebars to fix escaping when using `definition.cloze` or text-based `getMedia`.
1319 * @type {import('options-util').UpdateFunction}
1320 */
1321 async _updateVersion33(options) {
1322 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v33.handlebars');
1323 }
1324
1325 /**
1326 * - Added dynamic handlebars for single dictionaries.
1327 * @type {import('options-util').UpdateFunction}
1328 */
1329 async _updateVersion34(options) {
1330 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v34.handlebars');
1331 }
1332
1333 /**
1334 * - Added dynamic handlebars for first dictionary entry only.
1335 * @type {import('options-util').UpdateFunction}
1336 */
1337 async _updateVersion35(options) {
1338 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v35.handlebars');
1339 }
1340
1341 /**
1342 * - Added handlebars for onyomi reading in hiragana.
1343 * @type {import('options-util').UpdateFunction}
1344 */
1345 async _updateVersion36(options) {
1346 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v36.handlebars');
1347 }
1348
1349 /**
1350 * - Removed `No pitch accent data` return from pitch handlebars when no data is found
1351 * @type {import('options-util').UpdateFunction}
1352 */
1353 async _updateVersion37(options) {
1354 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v37.handlebars');
1355 }
1356
1357 /**
1358 * - Updated `conjugation` handlebars for new inflection chain format.
1359 * @type {import('options-util').UpdateFunction}
1360 */
1361 async _updateVersion38(options) {
1362 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v38.handlebars');
1363 }
1364
1365 /**
1366 * - Add new setting enableContextMenuScanSelected
1367 * @type {import('options-util').UpdateFunction}
1368 */
1369 async _updateVersion39(options) {
1370 for (const profile of options.profiles) {
1371 profile.options.general.enableContextMenuScanSelected = true;
1372 }
1373 }
1374
1375 /**
1376 * - Added support for web hotkey scope to profilePrevious and profileNext
1377 * @type {import('options-util').UpdateFunction}
1378 */
1379 async _updateVersion40(options) {
1380 for (const profile of options.profiles) {
1381 for (const hotkey of profile.options.inputs.hotkeys) {
1382 if (hotkey.action === 'profilePrevious' || hotkey.action === 'profileNext') {
1383 hotkey.scopes = ['popup', 'search', 'web'];
1384 }
1385 }
1386 }
1387 }
1388
1389 /**
1390 * - Updated `glossary` handlebars to support dictionary css.
1391 * @type {import('options-util').UpdateFunction}
1392 */
1393 async _updateVersion41(options) {
1394 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v41.handlebars');
1395 }
1396
1397 /**
1398 * - Added scanning.scanAltText
1399 * @type {import('options-util').UpdateFunction}
1400 */
1401 async _updateVersion42(options) {
1402 for (const profile of options.profiles) {
1403 profile.options.scanning.scanAltText = true;
1404 }
1405 }
1406
1407 /**
1408 * - Added option for sticky search header.
1409 * @type {import('options-util').UpdateFunction}
1410 */
1411 _updateVersion43(options) {
1412 for (const profile of options.profiles) {
1413 profile.options.general.stickySearchHeader = false;
1414 }
1415 }
1416
1417 /**
1418 * - Added general.fontFamily
1419 * - Added general.fontSize
1420 * - Added general.lineHeight
1421 * @type {import('options-util').UpdateFunction}
1422 */
1423 async _updateVersion44(options) {
1424 for (const profile of options.profiles) {
1425 profile.options.general.fontFamily = 'sans-serif';
1426 profile.options.general.fontSize = 14;
1427 profile.options.general.lineHeight = '1.5';
1428 }
1429 }
1430
1431 /**
1432 * - Renamed `selection-text` to `popup-selection-text`
1433 * @type {import('options-util').UpdateFunction}
1434 */
1435 async _updateVersion45(options) {
1436 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v45.handlebars');
1437 const oldMarkerRegex = new RegExp('{selection-text}', 'g');
1438 const newMarker = '{popup-selection-text}';
1439 for (const profile of options.profiles) {
1440 const termsFields = profile.options.anki.terms.fields;
1441 for (const key of Object.keys(termsFields)) {
1442 termsFields[key] = termsFields[key].replace(oldMarkerRegex, newMarker);
1443 }
1444 const kanjiFields = profile.options.anki.kanji.fields;
1445 for (const key of Object.keys(kanjiFields)) {
1446 kanjiFields[key] = kanjiFields[key].replace(oldMarkerRegex, newMarker);
1447 }
1448 }
1449 }
1450
1451 /**
1452 * - Set default font to empty
1453 * @type {import('options-util').UpdateFunction}
1454 */
1455 async _updateVersion46(options) {
1456 for (const profile of options.profiles) {
1457 if (profile.options.general.fontFamily === 'sans-serif') {
1458 profile.options.general.fontFamily = '';
1459 }
1460 }
1461 }
1462
1463 /**
1464 * - Added scanning.scanWithoutMousemove
1465 * @type {import('options-util').UpdateFunction}
1466 */
1467 async _updateVersion47(options) {
1468 for (const profile of options.profiles) {
1469 profile.options.scanning.scanWithoutMousemove = true;
1470 }
1471 }
1472
1473 /**
1474 * - Added general.showDebug
1475 * @type {import('options-util').UpdateFunction}
1476 */
1477 async _updateVersion48(options) {
1478 for (const profile of options.profiles) {
1479 profile.options.general.showDebug = false;
1480 }
1481 }
1482
1483 /**
1484 * - Added dictionary alias
1485 * @type {import('options-util').UpdateFunction}
1486 */
1487 async _updateVersion49(options) {
1488 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v49.handlebars');
1489 for (const {options: profileOptions} of options.profiles) {
1490 if (Array.isArray(profileOptions.dictionaries)) {
1491 for (const dictionary of profileOptions.dictionaries) {
1492 dictionary.alias = dictionary.name;
1493 }
1494 }
1495 }
1496 }
1497
1498 /**
1499 * - Generalized jpod101-alternate to language-pod-101
1500 * @type {import('options-util').UpdateFunction}
1501 */
1502 async _updateVersion50(options) {
1503 for (const profile of options.profiles) {
1504 for (const source of profile.options.audio.sources) {
1505 if (source.type === 'jpod101-alternate') {
1506 source.type = 'language-pod-101';
1507 }
1508 }
1509 }
1510 }
1511
1512 /**
1513 * - Add scanning.scanResolution
1514 * @type {import('options-util').UpdateFunction}
1515 */
1516 async _updateVersion51(options) {
1517 for (const profile of options.profiles) {
1518 profile.options.scanning.scanResolution = 'character';
1519 }
1520 }
1521
1522 /**
1523 * - Remove scanning.scanAltText
1524 * @type {import('options-util').UpdateFunction}
1525 */
1526 async _updateVersion52(options) {
1527 for (const profile of options.profiles) {
1528 delete profile.options.scanning.scanAltText;
1529 }
1530 }
1531
1532 /**
1533 * - Added profile id
1534 * @type {import('options-util').UpdateFunction}
1535 */
1536 async _updateVersion53(options) {
1537 for (let i = 0; i < options.profiles.length; i++) {
1538 options.profiles[i].id = `profile-${i}`;
1539 }
1540 }
1541
1542 /**
1543 * - Renamed anki.displayTags to anki.displayTagsAndFlags
1544 * @type {import('options-util').UpdateFunction}
1545 */
1546 async _updateVersion54(options) {
1547 for (const profile of options.profiles) {
1548 profile.options.anki.displayTagsAndFlags = profile.options.anki.displayTags;
1549 delete profile.options.anki.displayTags;
1550 }
1551 }
1552
1553 /**
1554 * - Remove scanning.touchInputEnabled
1555 * - Remove scanning.pointerEventsEnabled
1556 * @type {import('options-util').UpdateFunction}
1557 */
1558 async _updateVersion55(options) {
1559 for (const profile of options.profiles) {
1560 delete profile.options.scanning.touchInputEnabled;
1561 delete profile.options.scanning.pointerEventsEnabled;
1562 }
1563 }
1564
1565 /**
1566 * - Sorted dictionaries by priority
1567 * - Removed priority from dictionaries
1568 * @type {import('options-util').UpdateFunction}
1569 */
1570 async _updateVersion56(options) {
1571 for (const {options: profileOptions} of options.profiles) {
1572 if (Array.isArray(profileOptions.dictionaries)) {
1573 profileOptions.dictionaries.sort((/** @type {{ priority: number; }} */ a, /** @type {{ priority: number; }} */ b) => {
1574 return b.priority - a.priority;
1575 });
1576 for (const dictionary of profileOptions.dictionaries) {
1577 delete dictionary.priority;
1578 }
1579 }
1580 }
1581 }
1582
1583 /**
1584 * - Added scanning.inputs[].options.minimumTouchTime.
1585 * @type {import('options-util').UpdateFunction}
1586 */
1587 async _updateVersion57(options) {
1588 for (const profile of options.profiles) {
1589 for (const input of profile.options.scanning.inputs) {
1590 input.options.minimumTouchTime = 0;
1591 }
1592 }
1593 }
1594
1595 /**
1596 * - Added audio.options.playFallbackSound
1597 * @type {import('options-util').UpdateFunction}
1598 */
1599 async _updateVersion58(options) {
1600 for (const profile of options.profiles) {
1601 profile.options.audio.playFallbackSound = true;
1602 }
1603 }
1604
1605 /**
1606 * - Added overwriteMode to anki.fields
1607 * @type {import('options-util').UpdateFunction}
1608 */
1609 async _updateVersion59(options) {
1610 for (const profile of options.profiles) {
1611 for (const type of ['terms', 'kanji']) {
1612 const fields = profile.options.anki[type].fields;
1613 for (const [field, value] of Object.entries(fields)) {
1614 fields[field] = {value, overwriteMode: 'coalesce'};
1615 }
1616 }
1617 }
1618 }
1619
1620 /**
1621 * - Replaced audio.playFallbackSound with audio.fallbackSoundType
1622 * @type {import('options-util').UpdateFunction}
1623 */
1624 async _updateVersion60(options) {
1625 for (const profile of options.profiles) {
1626 profile.options.audio.fallbackSoundType = profile.options.audio.playFallbackSound ? 'click' : 'none';
1627 delete profile.options.audio.playFallbackSound;
1628 }
1629 }
1630
1631 /**
1632 * - Added sentence-furigana-plain handlebar
1633 * @type {import('options-util').UpdateFunction}
1634 */
1635 async _updateVersion61(options) {
1636 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v61.handlebars');
1637 }
1638
1639 /**
1640 * - Added options.general.averageFrequency
1641 * @type {import('options-util').UpdateFunction}
1642 */
1643 async _updateVersion62(options) {
1644 for (const profile of options.profiles) {
1645 profile.options.general.averageFrequency = false;
1646 }
1647 }
1648
1649 /**
1650 * - Added selectable tags to phonetic transcriptions handlebar
1651 * @type {import('options-util').UpdateFunction}
1652 */
1653 async _updateVersion63(options) {
1654 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v63.handlebars');
1655 }
1656
1657 /**
1658 * - Added multiple anki card formats
1659 * - Updated expression template to remove modeTermKana
1660 * - Updated hotkeys to use generic note actions
1661 * @type {import('options-util').UpdateFunction}
1662 */
1663 async _updateVersion64(options) {
1664 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v64.handlebars');
1665
1666 for (const profile of options.profiles) {
1667 const oldTerms = profile.options.anki.terms;
1668
1669 const updatedCardFormats = [{
1670 name: 'Expression',
1671 icon: 'big-circle',
1672 deck: oldTerms.deck,
1673 model: oldTerms.model,
1674 fields: oldTerms.fields,
1675 type: 'term',
1676 }];
1677
1678 if (Object.values(oldTerms.fields).some((field) => field.value.includes('{expression}'))) {
1679 updatedCardFormats.push({
1680 name: 'Reading',
1681 icon: 'small-circle',
1682 deck: oldTerms.deck,
1683 model: oldTerms.model,
1684 fields: Object.fromEntries(
1685 Object.entries(oldTerms.fields).map(([key, field]) => [
1686 key,
1687 {...field, value: field.value.replace(/{expression}/g, '{reading}')},
1688 ]),
1689 ),
1690 type: 'term',
1691 });
1692 }
1693
1694 const language = profile.options.general.language;
1695 const logographLanguages = ['ja', 'zh', 'yue'];
1696 if (logographLanguages.includes(language)) {
1697 const oldKanji = profile.options.anki.kanji;
1698 const kanjiNote = {
1699 name: language === 'ja' ? 'Kanji' : 'Hanzi',
1700 icon: 'big-circle',
1701 deck: oldKanji.deck,
1702 model: oldKanji.model,
1703 fields: oldKanji.fields,
1704 type: 'kanji',
1705 };
1706 updatedCardFormats.push(kanjiNote);
1707 }
1708
1709 profile.options.anki.cardFormats = [...updatedCardFormats];
1710
1711 delete profile.options.anki.terms;
1712 delete profile.options.anki.kanji;
1713
1714 if (!profile.options.inputs || !profile.options.inputs.hotkeys) {
1715 continue;
1716 }
1717
1718 for (const hotkey of profile.options.inputs.hotkeys) {
1719 if (!('argument' in hotkey)) {
1720 hotkey.argument = '';
1721 }
1722 switch (hotkey.action) {
1723 case 'addNoteTermKanji':
1724 hotkey.action = 'addNote';
1725 hotkey.argument = '0';
1726 break;
1727 case 'addNoteTermKana':
1728 hotkey.action = 'addNote';
1729 hotkey.argument = `${Math.min(1, updatedCardFormats.length - 1)}`;
1730 break;
1731 case 'addNoteKanji':
1732 hotkey.action = 'addNote';
1733 hotkey.argument = `${updatedCardFormats.length - 1}`;
1734 break;
1735 case 'viewNotes':
1736 hotkey.action = 'viewNotes';
1737 hotkey.argument = '0';
1738 break;
1739 }
1740 }
1741 }
1742 }
1743
1744 /**
1745 * - Added general.enableYomitanApi
1746 * - Added general.yomitanApiServer
1747 * @type {import('options-util').UpdateFunction}
1748 */
1749 async _updateVersion65(options) {
1750 for (const profile of options.profiles) {
1751 profile.options.general.enableYomitanApi = false;
1752 profile.options.general.yomitanApiServer = 'http://127.0.0.1:8766';
1753 }
1754 }
1755
1756 /**
1757 * - Added glossary-plain handlebars
1758 * @type {import('options-util').UpdateFunction}
1759 */
1760 async _updateVersion66(options) {
1761 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v66.handlebars');
1762 }
1763
1764 /**
1765 * - Added dynamic handlebars for single frequency dictionaries.
1766 * @type {import('options-util').UpdateFunction}
1767 */
1768 async _updateVersion67(options) {
1769 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v67.handlebars');
1770 }
1771
1772 /**
1773 * - Changed pitch-accent-item param name
1774 * @type {import('options-util').UpdateFunction}
1775 */
1776 async _updateVersion68(options) {
1777 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v68.handlebars');
1778 }
1779
1780 /**
1781 * - Change default Yomitan API port to 19633
1782 * @type {import('options-util').UpdateFunction}
1783 */
1784 async _updateVersion69(options) {
1785 for (const profile of options.profiles) {
1786 profile.options.general.yomitanApiServer = 'http://127.0.0.1:19633';
1787 }
1788 }
1789
1790 /**
1791 * - Added audio.enableDefaultAudioSources
1792 * @type {import('options-util').UpdateFunction}
1793 */
1794 async _updateVersion70(options) {
1795 for (const profile of options.profiles) {
1796 profile.options.audio.enableDefaultAudioSources = true;
1797 }
1798 }
1799
1800 /**
1801 * - Added global.dataTransmissionConsentShown
1802 * @type {import('options-util').UpdateFunction}
1803 */
1804 async _updateVersion71(options) {
1805 options.global.dataTransmissionConsentShown = false;
1806 }
1807
1808 /**
1809 * - Always put dict glosses in a list for the `glossary` handlebar (and brief and no-dictionary)
1810 * @type {import('options-util').UpdateFunction}
1811 */
1812 async _updateVersion72(options) {
1813 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v71.handlebars');
1814 }
1815
1816 /**
1817 * - Added anki.targetTags
1818 * @type {import('options-util').UpdateFunction}
1819 */
1820 async _updateVersion73(options) {
1821 for (const profile of options.profiles) {
1822 profile.options.anki.targetTags = [];
1823 }
1824 }
1825
1826 /**
1827 * - Fix glossary-plain and glossary-plain-no-dictionary not working when resultOutputMode (Result grouping mode) == split (No grouping)
1828 * @type {import('options-util').UpdateFunction}
1829 */
1830 async _updateVersion74(options) {
1831 await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v74.handlebars');
1832 }
1833
1834 /**
1835 * @param {string} url
1836 * @returns {Promise<chrome.tabs.Tab>}
1837 */
1838 _createTab(url) {
1839 return new Promise((resolve, reject) => {
1840 chrome.tabs.create({url}, (tab) => {
1841 const e = chrome.runtime.lastError;
1842 if (e) {
1843 reject(new Error(e.message));
1844 } else {
1845 resolve(tab);
1846 }
1847 });
1848 });
1849 }
1850}
1851
1852/* eslint-enable @typescript-eslint/no-unsafe-assignment */
1853/* eslint-enable @typescript-eslint/no-unsafe-argument */