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