/* * Copyright (C) 2023-2024 Yomitan Authors * Copyright (C) 2020-2022 Yomichan Authors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import fs from 'fs'; import {fileURLToPath} from 'node:url'; import path from 'path'; import {describe, expect, test, vi} from 'vitest'; import {OptionsUtil} from '../ext/js/data/options-util.js'; import {TemplatePatcher} from '../ext/js/templates/template-patcher.js'; import {chrome, fetch} from './mocks/common.js'; const dirname = path.dirname(fileURLToPath(import.meta.url)); vi.stubGlobal('fetch', fetch); vi.stubGlobal('chrome', chrome); /** * @returns {unknown} */ function createProfileOptionsTestData1() { return { version: 14, general: { enable: true, enableClipboardPopups: false, resultOutputMode: 'group', debugInfo: false, maxResults: 32, showAdvanced: false, popupDisplayMode: 'default', popupWidth: 400, popupHeight: 250, popupHorizontalOffset: 0, popupVerticalOffset: 10, popupHorizontalOffset2: 10, popupVerticalOffset2: 0, popupHorizontalTextPosition: 'below', popupVerticalTextPosition: 'before', popupScalingFactor: 1, popupScaleRelativeToPageZoom: false, popupScaleRelativeToVisualViewport: true, showGuide: true, compactTags: false, compactGlossaries: false, mainDictionary: '', popupTheme: 'default', popupOuterTheme: 'default', customPopupCss: '', customPopupOuterCss: '', enableWanakana: true, enableClipboardMonitor: false, showPitchAccentDownstepNotation: true, showPitchAccentPositionNotation: true, showPitchAccentGraph: false, showIframePopupsInRootFrame: false, useSecurePopupFrameUrl: true, usePopupShadowDom: true }, audio: { enabled: true, sources: ['jpod101', 'text-to-speech', 'custom'], volume: 100, autoPlay: false, customSourceUrl: 'http://localhost/audio.mp3?term={expression}&reading={reading}', textToSpeechVoice: 'example-voice' }, scanning: { middleMouse: true, touchInputEnabled: true, selectText: true, alphanumeric: true, autoHideResults: false, delay: 20, length: 10, modifier: 'shift', deepDomScan: false, popupNestingMaxDepth: 0, enablePopupSearch: false, enableOnPopupExpressions: false, enableOnSearchPage: true, enableSearchTags: false, layoutAwareScan: false }, translation: { convertHalfWidthCharacters: 'false', convertNumericCharacters: 'false', convertAlphabeticCharacters: 'false', convertHiraganaToKatakana: 'false', convertKatakanaToHiragana: 'variant', collapseEmphaticSequences: 'false' }, dictionaries: { 'Test Dictionary': { priority: 0, enabled: true, allowSecondarySearches: false } }, parsing: { enableScanningParser: true, enableMecabParser: false, selectedParser: null, termSpacing: true, readingMode: 'hiragana' }, anki: { enable: false, server: 'http://127.0.0.1:8765', tags: ['yomitan'], sentenceExt: 200, screenshot: {format: 'png', quality: 92}, terms: {deck: '', model: '', fields: {}}, kanji: {deck: '', model: '', fields: {}}, duplicateScope: 'collection', fieldTemplates: null } }; } /** * @returns {unknown} */ function createOptionsTestData1() { return { profiles: [ { name: 'Default', options: createProfileOptionsTestData1(), conditionGroups: [ { conditions: [ { type: 'popupLevel', operator: 'equal', value: 1 }, { type: 'popupLevel', operator: 'notEqual', value: 0 }, { type: 'popupLevel', operator: 'lessThan', value: 3 }, { type: 'popupLevel', operator: 'greaterThan', value: 0 }, { type: 'popupLevel', operator: 'lessThanOrEqual', value: 2 }, { type: 'popupLevel', operator: 'greaterThanOrEqual', value: 1 } ] }, { conditions: [ { type: 'url', operator: 'matchDomain', value: 'example.com' }, { type: 'url', operator: 'matchRegExp', value: 'example\\.com' } ] }, { conditions: [ { type: 'modifierKeys', operator: 'are', value: [ 'ctrl', 'shift' ] }, { type: 'modifierKeys', operator: 'areNot', value: [ 'alt', 'shift' ] }, { type: 'modifierKeys', operator: 'include', value: 'alt' }, { type: 'modifierKeys', operator: 'notInclude', value: 'ctrl' } ] } ] } ], profileCurrent: 0, version: 2, global: { database: { prefixWildcardsSupported: false } } }; } /** * @returns {unknown} */ function createProfileOptionsUpdatedTestData1() { return { general: { enable: true, resultOutputMode: 'group', debugInfo: false, maxResults: 32, showAdvanced: false, popupDisplayMode: 'default', popupWidth: 400, popupHeight: 250, popupHorizontalOffset: 0, popupVerticalOffset: 10, popupHorizontalOffset2: 10, popupVerticalOffset2: 0, popupHorizontalTextPosition: 'below', popupVerticalTextPosition: 'before', popupScalingFactor: 1, popupScaleRelativeToPageZoom: false, popupScaleRelativeToVisualViewport: true, showGuide: true, compactTags: false, glossaryLayoutMode: 'default', mainDictionary: '', popupTheme: 'light', popupOuterTheme: 'light', customPopupCss: '', customPopupOuterCss: '', enableWanakana: true, showPitchAccentDownstepNotation: true, showPitchAccentPositionNotation: true, showPitchAccentGraph: false, showIframePopupsInRootFrame: false, useSecurePopupFrameUrl: true, usePopupShadowDom: true, usePopupWindow: false, popupCurrentIndicatorMode: 'triangle', popupActionBarVisibility: 'auto', popupActionBarLocation: 'top', frequencyDisplayMode: 'split-tags-grouped', termDisplayMode: 'ruby', sortFrequencyDictionary: null, sortFrequencyDictionaryOrder: 'descending' }, audio: { enabled: true, sources: [ { type: 'jpod101', url: '', voice: '' }, { type: 'text-to-speech', url: '', voice: 'example-voice' }, { type: 'custom', url: 'http://localhost/audio.mp3?term={term}&reading={reading}', voice: '' } ], volume: 100, autoPlay: false }, scanning: { touchInputEnabled: true, selectText: true, alphanumeric: true, autoHideResults: false, delay: 20, length: 10, deepDomScan: false, popupNestingMaxDepth: 0, enablePopupSearch: false, enableOnPopupExpressions: false, enableOnSearchPage: true, enableSearchTags: false, layoutAwareScan: false, hideDelay: 0, pointerEventsEnabled: false, matchTypePrefix: false, hidePopupOnCursorExit: false, hidePopupOnCursorExitDelay: 0, normalizeCssZoom: true, preventMiddleMouse: { onWebPages: false, onPopupPages: false, onSearchPages: false, onSearchQuery: false }, inputs: [ { include: 'shift', exclude: 'mouse0', types: { mouse: true, touch: false, pen: false }, options: { showAdvanced: false, searchTerms: true, searchKanji: true, scanOnTouchMove: true, scanOnTouchPress: true, scanOnTouchRelease: false, scanOnPenMove: true, scanOnPenHover: true, scanOnPenReleaseHover: false, scanOnPenPress: true, scanOnPenRelease: false, preventTouchScrolling: true, preventPenScrolling: true } }, { include: 'mouse2', exclude: '', types: { mouse: true, touch: false, pen: false }, options: { showAdvanced: false, searchTerms: true, searchKanji: true, scanOnTouchMove: true, scanOnTouchPress: true, scanOnTouchRelease: false, scanOnPenMove: true, scanOnPenHover: true, scanOnPenReleaseHover: false, scanOnPenPress: true, scanOnPenRelease: false, preventTouchScrolling: true, preventPenScrolling: true } }, { include: '', exclude: '', types: { mouse: false, touch: true, pen: true }, options: { showAdvanced: false, searchTerms: true, searchKanji: true, scanOnTouchMove: true, scanOnTouchPress: true, scanOnTouchRelease: false, scanOnPenMove: true, scanOnPenHover: true, scanOnPenReleaseHover: false, scanOnPenPress: true, scanOnPenRelease: false, preventTouchScrolling: true, preventPenScrolling: true } } ] }, translation: { convertHalfWidthCharacters: 'false', convertNumericCharacters: 'false', convertAlphabeticCharacters: 'false', convertHiraganaToKatakana: 'false', convertKatakanaToHiragana: 'variant', collapseEmphaticSequences: 'false', searchResolution: 'letter', textReplacements: { searchOriginal: true, groups: [] } }, dictionaries: [ { name: 'Test Dictionary', priority: 0, enabled: true, allowSecondarySearches: false, definitionsCollapsible: 'not-collapsible', partsOfSpeechFilter: true, useDeinflections: true } ], parsing: { enableScanningParser: true, enableMecabParser: false, selectedParser: null, termSpacing: true, readingMode: 'hiragana' }, anki: { enable: false, server: 'http://127.0.0.1:8765', tags: ['yomitan'], screenshot: {format: 'png', quality: 92}, terms: {deck: '', model: '', fields: {}}, kanji: {deck: '', model: '', fields: {}}, duplicateScope: 'collection', duplicateScopeCheckAllModels: false, displayTags: 'never', checkForDuplicates: true, fieldTemplates: null, suspendNewCards: false, noteGuiMode: 'browse', apiKey: '', downloadTimeout: 0 }, sentenceParsing: { scanExtent: 200, terminationCharacterMode: 'custom', terminationCharacters: [ {enabled: true, character1: '「', character2: '」', includeCharacterAtStart: false, includeCharacterAtEnd: false}, {enabled: true, character1: '『', character2: '』', includeCharacterAtStart: false, includeCharacterAtEnd: false}, {enabled: true, character1: '"', character2: '"', includeCharacterAtStart: false, includeCharacterAtEnd: false}, {enabled: true, character1: '\'', character2: '\'', includeCharacterAtStart: false, includeCharacterAtEnd: false}, {enabled: true, character1: '.', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, {enabled: true, character1: '!', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, {enabled: true, character1: '?', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, {enabled: true, character1: '.', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, {enabled: true, character1: '。', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, {enabled: true, character1: '!', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, {enabled: true, character1: '?', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, {enabled: true, character1: '…', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, {enabled: true, character1: '︒', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, {enabled: true, character1: '︕', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, {enabled: true, character1: '︖', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, {enabled: true, character1: '︙', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true} ] }, inputs: { /* eslint-disable @stylistic/no-multi-spaces */ hotkeys: [ {action: 'close', argument: '', key: 'Escape', modifiers: [], scopes: ['popup'], enabled: true}, {action: 'focusSearchBox', argument: '', key: 'Escape', modifiers: [], scopes: ['search'], enabled: true}, {action: 'previousEntry', argument: '3', key: 'PageUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'nextEntry', argument: '3', key: 'PageDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'lastEntry', argument: '', key: 'End', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'firstEntry', argument: '', key: 'Home', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'previousEntry', argument: '1', key: 'ArrowUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'nextEntry', argument: '1', key: 'ArrowDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'historyBackward', argument: '', key: 'KeyB', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'historyForward', argument: '', key: 'KeyF', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'addNoteKanji', argument: '', key: 'KeyK', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'addNoteTermKanji', argument: '', key: 'KeyE', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'addNoteTermKana', argument: '', key: 'KeyR', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'playAudio', argument: '', key: 'KeyP', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'viewNote', argument: '', key: 'KeyV', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, {action: 'copyHostSelection', argument: '', key: 'KeyC', modifiers: ['ctrl'], scopes: ['popup'], enabled: true} ] /* eslint-enable @stylistic/no-multi-spaces */ }, popupWindow: { width: 400, height: 250, left: 0, top: 0, useLeft: false, useTop: false, windowType: 'popup', windowState: 'normal' }, clipboard: { enableBackgroundMonitor: false, enableSearchPageMonitor: false, autoSearchContent: true, maximumSearchLength: 1000 }, accessibility: { forceGoogleDocsHtmlRendering: false } }; } /** * @returns {unknown} */ function createOptionsUpdatedTestData1() { return { profiles: [ { name: 'Default', options: createProfileOptionsUpdatedTestData1(), conditionGroups: [ { conditions: [ { type: 'popupLevel', operator: 'equal', value: '1' }, { type: 'popupLevel', operator: 'notEqual', value: '0' }, { type: 'popupLevel', operator: 'lessThan', value: '3' }, { type: 'popupLevel', operator: 'greaterThan', value: '0' }, { type: 'popupLevel', operator: 'lessThanOrEqual', value: '2' }, { type: 'popupLevel', operator: 'greaterThanOrEqual', value: '1' } ] }, { conditions: [ { type: 'url', operator: 'matchDomain', value: 'example.com' }, { type: 'url', operator: 'matchRegExp', value: 'example\\.com' } ] }, { conditions: [ { type: 'modifierKeys', operator: 'are', value: 'ctrl, shift' }, { type: 'modifierKeys', operator: 'areNot', value: 'alt, shift' }, { type: 'modifierKeys', operator: 'include', value: 'alt' }, { type: 'modifierKeys', operator: 'notInclude', value: 'ctrl' } ] } ] } ], profileCurrent: 0, version: 24, global: { database: { prefixWildcardsSupported: false } } }; } /** */ async function testUpdate() { test('Update', async () => { const optionsUtil = new OptionsUtil(); await optionsUtil.prepare(); const options = createOptionsTestData1(); const optionsUpdated = structuredClone(await optionsUtil.update(options)); const optionsExpected = createOptionsUpdatedTestData1(); expect(optionsUpdated).toStrictEqual(optionsExpected); }); } /** */ async function testCumulativeFieldTemplatesUpdates() { /** * @param {string} templates * @returns {Map<string, string>} */ const getHandlebarsPartials = (templates) => { const inlinePartialRegex = /{{~?#\*inline .*?"([^"]*)"~?}}.*?{{~?\/inline~?}}/gs; const matches = templates.matchAll(inlinePartialRegex); const partials = new Map(); for (const match of matches) { const [template, name] = match; partials.set(name, template); } return partials; }; test('CumulativeFieldTemplatesUpdates', async () => { const optionsUtil = new OptionsUtil(); await optionsUtil.prepare(); const options = /** @type {import('core').SafeAny} */ (createOptionsTestData1()); const oldAnkiFieldTemplates = fs.readFileSync(path.join(dirname, 'data', 'templates', 'old-default-anki-field-templates.handlebars'), {encoding: 'utf8'}); const defaultAnkiFieldTemplates = fs.readFileSync(path.join(dirname, '..', 'ext', 'data', 'templates', 'default-anki-field-templates.handlebars'), {encoding: 'utf8'}); options.profiles[0].options.anki.fieldTemplates = oldAnkiFieldTemplates; const optionsUpdated = structuredClone(await optionsUtil.update(options)); const fieldTemplatesUpdated = optionsUpdated.profiles[0].options.anki.fieldTemplates || ''; const partialsUpdated = getHandlebarsPartials(fieldTemplatesUpdated); const partialsExpected = getHandlebarsPartials(defaultAnkiFieldTemplates); expect(partialsUpdated).toStrictEqual(partialsExpected); }); } /** */ async function testDefault() { describe('Default', () => { /** @type {((options: import('options-util').IntermediateOptions) => void)[]} */ const data = [ (options) => options, (options) => { delete options.profiles[0].options.audio.autoPlay; }, (options) => { options.profiles[0].options.audio.autoPlay = void 0; } ]; test.each(data)('default-test-%#', async (modify) => { const optionsUtil = new OptionsUtil(); await optionsUtil.prepare(); const options = optionsUtil.getDefault(); const optionsModified = structuredClone(options); modify(optionsModified); const optionsUpdated = await optionsUtil.update(structuredClone(optionsModified)); expect(structuredClone(optionsUpdated)).toStrictEqual(structuredClone(options)); }); }); } /** */ async function testFieldTemplatesUpdate() { describe('FieldTemplatesUpdate', () => { const templatePatcher = new TemplatePatcher(); /** * @param {string} fileName * @returns {string} */ const loadDataFile = (fileName) => { const content = fs.readFileSync(fileName, {encoding: 'utf8'}); return templatePatcher.parsePatch(content).addition; }; /** @type {import('options-util').TemplateFieldUpdate[]} */ const updates = []; const fileNameRegex = /^anki-field-templates-upgrade-v(\d+)\.handlebars$/; const templatesDirPath = path.join(dirname, '..', 'ext', 'data', 'templates'); const templatesDir = fs.readdirSync(templatesDirPath, {encoding: 'utf8'}); for (const fileName of templatesDir) { const match = fileNameRegex.exec(fileName); if (match !== null) { updates.push({ version: Number.parseInt(match[1]), changes: loadDataFile(path.join(templatesDirPath, match[0])) }); } } updates.sort((a, b) => a.version - b.version); /** * @param {number} startVersion * @param {number} targetVersion * @returns {string} */ const getUpdateAdditions = (startVersion, targetVersion) => { let value = ''; for (const {version, changes} of updates) { if (version <= startVersion || version > targetVersion || changes.length === 0) { continue; } if (value.length > 0) { value += '\n'; } value += changes; } return value; }; const data = [ // Standard format { oldVersion: 0, newVersion: 12, old: ` {{#*inline "character"}} {{~definition.character~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart(), expected: ` {{#*inline "character"}} {{~definition.character~}} {{/inline}} <<<UPDATE-ADDITIONS>>> {{~> (lookup . "marker") ~}}`.trimStart() }, // Non-standard marker format { oldVersion: 0, newVersion: 12, old: ` {{#*inline "character"}} {{~definition.character~}} {{/inline}} {{~> (lookup . "marker2") ~}}`.trimStart(), expected: ` {{#*inline "character"}} {{~definition.character~}} {{/inline}} {{~> (lookup . "marker2") ~}} <<<UPDATE-ADDITIONS>>>`.trimStart() }, // Empty test { oldVersion: 0, newVersion: 12, old: ` {{~> (lookup . "marker") ~}}`.trimStart(), expected: ` <<<UPDATE-ADDITIONS>>> {{~> (lookup . "marker") ~}}`.trimStart() }, // Definition tags update { oldVersion: 0, newVersion: 12, old: ` {{#*inline "glossary-single"}} {{~#unless brief~}} {{~#if definitionTags~}}<i>({{#each definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each}})</i> {{/if~}} {{~#if only~}}({{#each only}}{{{.}}}{{#unless @last}}, {{/unless}}{{/each}} only) {{/if~}} {{~/unless~}} {{/inline}} {{#*inline "glossary-single2"}} {{~#unless brief~}} {{~#if definitionTags~}}<i>({{#each definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each}})</i> {{/if~}} {{~#if only~}}({{#each only}}{{{.}}}{{#unless @last}}, {{/unless}}{{/each}} only) {{/if~}} {{~/unless~}} {{/inline}} {{#*inline "glossary"}} {{~> glossary-single definition brief=brief compactGlossaries=compactGlossaries~}} {{~> glossary-single definition brief=brief compactGlossaries=../compactGlossaries~}} {{/inline}} {{~> (lookup . "marker") ~}} `.trimStart(), expected: ` {{#*inline "glossary-single"}} {{~#unless brief~}} {{~#scope~}} {{~#set "any" false}}{{/set~}} {{~#if definitionTags~}}{{#each definitionTags~}} {{~#if (op "||" (op "!" ../data.compactTags) (op "!" redundant))~}} {{~#if (get "any")}}, {{else}}<i>({{/if~}} {{name}} {{~#set "any" true}}{{/set~}} {{~/if~}} {{~/each~}} {{~#if (get "any")}})</i> {{/if~}} {{~/if~}} {{~/scope~}} {{~#if only~}}({{#each only}}{{{.}}}{{#unless @last}}, {{/unless}}{{/each}} only) {{/if~}} {{~/unless~}} {{/inline}} {{#*inline "glossary-single2"}} {{~#unless brief~}} {{~#scope~}} {{~#set "any" false}}{{/set~}} {{~#if definitionTags~}}{{#each definitionTags~}} {{~#if (op "||" (op "!" ../data.compactTags) (op "!" redundant))~}} {{~#if (get "any")}}, {{else}}<i>({{/if~}} {{name}} {{~#set "any" true}}{{/set~}} {{~/if~}} {{~/each~}} {{~#if (get "any")}})</i> {{/if~}} {{~/if~}} {{~/scope~}} {{~#if only~}}({{#each only}}{{{.}}}{{#unless @last}}, {{/unless}}{{/each}} only) {{/if~}} {{~/unless~}} {{/inline}} {{#*inline "glossary"}} {{~> glossary-single definition brief=brief compactGlossaries=compactGlossaries data=.~}} {{~> glossary-single definition brief=brief compactGlossaries=../compactGlossaries data=../.~}} {{/inline}} <<<UPDATE-ADDITIONS>>> {{~> (lookup . "marker") ~}} `.trimStart() }, // glossary and glossary-brief update { oldVersion: 7, newVersion: 12, old: ` {{#*inline "glossary-single"}} {{~#unless brief~}} {{~#scope~}} {{~#set "any" false}}{{/set~}} {{~#if definitionTags~}}{{#each definitionTags~}} {{~#if (op "||" (op "!" ../data.compactTags) (op "!" redundant))~}} {{~#if (get "any")}}, {{else}}<i>({{/if~}} {{name}} {{~#set "any" true}}{{/set~}} {{~/if~}} {{~/each~}} {{~#if (get "any")}})</i> {{/if~}} {{~/if~}} {{~/scope~}} {{~#if only~}}({{#each only}}{{{.}}}{{#unless @last}}, {{/unless}}{{/each}} only) {{/if~}} {{~/unless~}} {{~#if glossary.[1]~}} {{~#if compactGlossaries~}} {{#each glossary}}{{#multiLine}}{{.}}{{/multiLine}}{{#unless @last}} | {{/unless}}{{/each}} {{~else~}} <ul>{{#each glossary}}<li>{{#multiLine}}{{.}}{{/multiLine}}</li>{{/each}}</ul> {{~/if~}} {{~else~}} {{~#multiLine}}{{glossary.[0]}}{{/multiLine~}} {{~/if~}} {{/inline}} {{#*inline "character"}} {{~definition.character~}} {{/inline}} {{#*inline "glossary"}} <div style="text-align: left;"> {{~#if modeKanji~}} {{~#if definition.glossary.[1]~}} <ol>{{#each definition.glossary}}<li>{{.}}</li>{{/each}}</ol> {{~else~}} {{definition.glossary.[0]}} {{~/if~}} {{~else~}} {{~#if group~}} {{~#if definition.definitions.[1]~}} <ol>{{#each definition.definitions}}<li>{{> glossary-single brief=../brief compactGlossaries=../compactGlossaries data=../.}}</li>{{/each}}</ol> {{~else~}} {{~> glossary-single definition.definitions.[0] brief=brief compactGlossaries=compactGlossaries data=.~}} {{~/if~}} {{~else if merge~}} {{~#if definition.definitions.[1]~}} <ol>{{#each definition.definitions}}<li>{{> glossary-single brief=../brief compactGlossaries=../compactGlossaries data=../.}}</li>{{/each}}</ol> {{~else~}} {{~> glossary-single definition.definitions.[0] brief=brief compactGlossaries=compactGlossaries data=.~}} {{~/if~}} {{~else~}} {{~> glossary-single definition brief=brief compactGlossaries=compactGlossaries data=.~}} {{~/if~}} {{~/if~}} </div> {{/inline}} {{#*inline "glossary-brief"}} {{~> glossary brief=true ~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart(), expected: ` {{#*inline "glossary-single"}} {{~#unless brief~}} {{~#scope~}} {{~#set "any" false}}{{/set~}} {{~#each definitionTags~}} {{~#if (op "||" (op "!" @root.compactTags) (op "!" redundant))~}} {{~#if (get "any")}}, {{else}}<i>({{/if~}} {{name}} {{~#set "any" true}}{{/set~}} {{~/if~}} {{~/each~}} {{~#unless noDictionaryTag~}} {{~#if (op "||" (op "!" @root.compactTags) (op "!==" dictionary (get "previousDictionary")))~}} {{~#if (get "any")}}, {{else}}<i>({{/if~}} {{dictionary}} {{~#set "any" true}}{{/set~}} {{~/if~}} {{~/unless~}} {{~#if (get "any")}})</i> {{/if~}} {{~/scope~}} {{~#if only~}}({{#each only}}{{.}}{{#unless @last}}, {{/unless}}{{/each}} only) {{/if~}} {{~/unless~}} {{~#if (op "<=" glossary.length 1)~}} {{#each glossary}}{{#multiLine}}{{.}}{{/multiLine}}{{/each}} {{~else if @root.compactGlossaries~}} {{#each glossary}}{{#multiLine}}{{.}}{{/multiLine}}{{#unless @last}} | {{/unless}}{{/each}} {{~else~}} <ul>{{#each glossary}}<li>{{#multiLine}}{{.}}{{/multiLine}}</li>{{/each}}</ul> {{~/if~}} {{~#set "previousDictionary" dictionary~}}{{~/set~}} {{/inline}} {{#*inline "character"}} {{~definition.character~}} {{/inline}} {{~#*inline "glossary"~}} <div style="text-align: left;"> {{~#scope~}} {{~#if (op "===" definition.type "term")~}} {{~> glossary-single definition brief=brief noDictionaryTag=noDictionaryTag ~}} {{~else if (op "||" (op "===" definition.type "termGrouped") (op "===" definition.type "termMerged"))~}} {{~#if (op ">" definition.definitions.length 1)~}} <ol>{{~#each definition.definitions~}}<li>{{~> glossary-single . brief=../brief noDictionaryTag=../noDictionaryTag ~}}</li>{{~/each~}}</ol> {{~else~}} {{~#each definition.definitions~}}{{~> glossary-single . brief=../brief noDictionaryTag=../noDictionaryTag ~}}{{~/each~}} {{~/if~}} {{~else if (op "===" definition.type "kanji")~}} {{~#if (op ">" definition.glossary.length 1)~}} <ol>{{#each definition.glossary}}<li>{{.}}</li>{{/each}}</ol> {{~else~}} {{~#each definition.glossary~}}{{.}}{{~/each~}} {{~/if~}} {{~/if~}} {{~/scope~}} </div> {{~/inline~}} {{#*inline "glossary-no-dictionary"}} {{~> glossary noDictionaryTag=true ~}} {{/inline}} {{#*inline "glossary-brief"}} {{~> glossary brief=true ~}} {{/inline}} <<<UPDATE-ADDITIONS>>> {{~> (lookup . "marker") ~}}`.trimStart() }, // formatGlossary update { oldVersion: 12, newVersion: 13, old: ` {{#*inline "example"}} {{~#if (op "<=" glossary.length 1)~}} {{#each glossary}}{{#multiLine}}{{.}}{{/multiLine}}{{/each}} {{~else if @root.compactGlossaries~}} {{#each glossary}}{{#multiLine}}{{.}}{{/multiLine}}{{#unless @last}} | {{/unless}}{{/each}} {{~else~}} <ul>{{#each glossary}}<li>{{#multiLine}}{{.}}{{/multiLine}}</li>{{/each}}</ul> {{~/if~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart(), expected: ` {{#*inline "example"}} {{~#if (op "<=" glossary.length 1)~}} {{#each glossary}}{{#formatGlossary ../dictionary}}{{{.}}}{{/formatGlossary}}{{/each}} {{~else if @root.compactGlossaries~}} {{#each glossary}}{{#formatGlossary ../dictionary}}{{{.}}}{{/formatGlossary}}{{#unless @last}} | {{/unless}}{{/each}} {{~else~}} <ul>{{#each glossary}}<li>{{#formatGlossary ../dictionary}}{{{.}}}{{/formatGlossary}}</li>{{/each}}</ul> {{~/if~}} {{/inline}} <<<UPDATE-ADDITIONS>>> {{~> (lookup . "marker") ~}}`.trimStart() }, // hasMedia/getMedia update { oldVersion: 12, newVersion: 13, old: ` {{#*inline "audio"}} {{~#if definition.audioFileName~}} [sound:{{definition.audioFileName}}] {{~/if~}} {{/inline}} {{#*inline "screenshot"}} <img src="{{definition.screenshotFileName}}" /> {{/inline}} {{#*inline "clipboard-image"}} {{~#if definition.clipboardImageFileName~}} <img src="{{definition.clipboardImageFileName}}" /> {{~/if~}} {{/inline}} {{#*inline "clipboard-text"}} {{~#if definition.clipboardText~}}{{definition.clipboardText}}{{~/if~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart(), expected: ` {{#*inline "audio"}} {{~#if (hasMedia "audio")~}} [sound:{{#getMedia "audio"}}{{/getMedia}}] {{~/if~}} {{/inline}} {{#*inline "screenshot"}} {{~#if (hasMedia "screenshot")~}} <img src="{{#getMedia "screenshot"}}{{/getMedia}}" /> {{~/if~}} {{/inline}} {{#*inline "clipboard-image"}} {{~#if (hasMedia "clipboardImage")~}} <img src="{{#getMedia "clipboardImage"}}{{/getMedia}}" /> {{~/if~}} {{/inline}} {{#*inline "clipboard-text"}} {{~#if (hasMedia "clipboardText")}}{{#getMedia "clipboardText"}}{{/getMedia}}{{/if~}} {{/inline}} <<<UPDATE-ADDITIONS>>> {{~> (lookup . "marker") ~}}`.trimStart() }, // hasMedia/getMedia update { oldVersion: 12, newVersion: 13, old: ` {{! Pitch Accents }} {{#*inline "pitch-accent-item-downstep-notation"}} {{~#scope~}} <span> {{~#set "style1a"~}}display:inline-block;position:relative;{{~/set~}} {{~#set "style1b"~}}padding-right:0.1em;margin-right:0.1em;{{~/set~}} {{~#set "style2a"~}}display:block;user-select:none;pointer-events:none;position:absolute;top:0.1em;left:0;right:0;height:0;border-top:0.1em solid;{{~/set~}} {{~#set "style2b"~}}right:-0.1em;height:0.4em;border-right:0.1em solid;{{~/set~}} {{~#each (getKanaMorae reading)~}} {{~#set "style1"}}{{#get "style1a"}}{{/get}}{{/set~}} {{~#set "style2"}}{{/set~}} {{~#if (isMoraPitchHigh @index ../position)}} {{~#set "style2"}}{{#get "style2a"}}{{/get}}{{/set~}} {{~#if (op "!" (isMoraPitchHigh (op "+" @index 1) ../position))~}} {{~#set "style1" (op "+" (get "style1") (get "style1b"))}}{{/set~}} {{~#set "style2" (op "+" (get "style2") (get "style2b"))}}{{/set~}} {{~/if~}} {{~/if~}} <span style="{{#get "style1"}}{{/get}}">{{{.}}}<span style="{{#get "style2"}}{{/get}}"></span></span> {{~/each~}} </span> {{~/scope~}} {{/inline}} {{#*inline "pitch-accent-item-graph-position-x"}}{{#op "+" 25 (op "*" index 50)}}{{/op}}{{/inline}} {{#*inline "pitch-accent-item-graph-position-y"}}{{#op "+" 25 (op "?:" (isMoraPitchHigh index position) 0 50)}}{{/op}}{{/inline}} {{#*inline "pitch-accent-item-graph-position"}}{{> pitch-accent-item-graph-position-x index=index position=position}} {{> pitch-accent-item-graph-position-y index=index position=position}}{{/inline}} {{#*inline "pitch-accent-item-graph"}} {{~#scope~}} {{~#set "morae" (getKanaMorae reading)}}{{/set~}} {{~#set "morae-count" (property (get "morae") "length")}}{{/set~}} <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {{#op "+" 50 (op "*" 50 (get "morae-count"))}}{{/op}} 100" style="display:inline-block;height:2em;"> <defs> <g id="term-pitch-accent-graph-dot"><circle cx="0" cy="0" r="15" style="fill:#000;stroke:#000;stroke-width:5;" /></g> <g id="term-pitch-accent-graph-dot-downstep"><circle cx="0" cy="0" r="15" style="fill:none;stroke:#000;stroke-width:5;" /><circle cx="0" cy="0" r="5" style="fill:none;stroke:#000;stroke-width:5;" /></g> <g id="term-pitch-accent-graph-triangle"><path d="M0 13 L15 -13 L-15 -13 Z" style="fill:none;stroke:#000;stroke-width:5;" /></g> </defs> <path style="fill:none;stroke:#000;stroke-width:5;" d=" {{~#set "cmd" "M"}}{{/set~}} {{~#each (get "morae")~}} {{~#get "cmd"}}{{/get~}} {{~> pitch-accent-item-graph-position index=@index position=../position~}} {{~#set "cmd" "L"}}{{/set~}} {{~/each~}} "></path> <path style="fill:none;stroke:#000;stroke-width:5;stroke-dasharray:5 5;" d="M{{> pitch-accent-item-graph-position index=(op "-" (get "morae-count") 1) position=position}} L{{> pitch-accent-item-graph-position index=(get "morae-count") position=position}}"></path> {{#each (get "morae")}} <use href="{{#if (op "&&" (isMoraPitchHigh @index ../position) (op "!" (isMoraPitchHigh (op "+" @index 1) ../position)))}}#term-pitch-accent-graph-dot-downstep{{else}}#term-pitch-accent-graph-dot{{/if}}" x="{{> pitch-accent-item-graph-position-x index=@index position=../position}}" y="{{> pitch-accent-item-graph-position-y index=@index position=../position}}"></use> {{/each}} <use href="#term-pitch-accent-graph-triangle" x="{{> pitch-accent-item-graph-position-x index=(get "morae-count") position=position}}" y="{{> pitch-accent-item-graph-position-y index=(get "morae-count") position=position}}"></use> </svg> {{~/scope~}} {{/inline}} {{#*inline "pitch-accent-item-position"~}} <span>[{{position}}]</span> {{~/inline}} {{#*inline "pitch-accent-item"}} {{~#if (op "==" format "downstep-notation")~}} {{~> pitch-accent-item-downstep-notation~}} {{~else if (op "==" format "graph")~}} {{~> pitch-accent-item-graph~}} {{~else if (op "==" format "position")~}} {{~> pitch-accent-item-position~}} {{~/if~}} {{/inline}} {{#*inline "pitch-accent-item-disambiguation"}} {{~#scope~}} {{~#set "exclusive" (spread exclusiveExpressions exclusiveReadings)}}{{/set~}} {{~#if (op ">" (property (get "exclusive") "length") 0)~}} {{~#set "separator" ""~}}{{/set~}} <em>({{#each (get "exclusive")~}} {{~#get "separator"}}{{/get~}}{{{.}}} {{~/each}} only) </em> {{~/if~}} {{~/scope~}} {{/inline}} {{#*inline "pitch-accent-list"}} {{~#if (op ">" pitchCount 0)~}} {{~#if (op ">" pitchCount 1)~}}<ol>{{~/if~}} {{~#each pitches~}} {{~#each pitches~}} {{~#if (op ">" ../../pitchCount 1)~}}<li>{{~/if~}} {{~> pitch-accent-item-disambiguation~}} {{~> pitch-accent-item format=../../format~}} {{~#if (op ">" ../../pitchCount 1)~}}</li>{{~/if~}} {{~/each~}} {{~/each~}} {{~#if (op ">" pitchCount 1)~}}</ol>{{~/if~}} {{~else~}} No pitch accent data {{~/if~}} {{/inline}} {{#*inline "pitch-accents"}} {{~> pitch-accent-list format='downstep-notation'~}} {{/inline}} {{#*inline "pitch-accent-graphs"}} {{~> pitch-accent-list format='graph'~}} {{/inline}} {{#*inline "pitch-accent-positions"}} {{~> pitch-accent-list format='position'~}} {{/inline}} {{! End Pitch Accents }} {{~> (lookup . "marker") ~}}`.trimStart(), expected: ` {{! Pitch Accents }} {{#*inline "pitch-accent-item"}} {{~#pronunciation format=format reading=reading downstepPosition=position nasalPositions=nasalPositions devoicePositions=devoicePositions~}}{{~/pronunciation~}} {{/inline}} {{#*inline "pitch-accent-item-disambiguation"}} {{~#scope~}} {{~#set "exclusive" (spread exclusiveExpressions exclusiveReadings)}}{{/set~}} {{~#if (op ">" (property (get "exclusive") "length") 0)~}} {{~#set "separator" ""~}}{{/set~}} <em>({{#each (get "exclusive")~}} {{~#get "separator"}}{{/get~}}{{{.}}} {{~/each}} only) </em> {{~/if~}} {{~/scope~}} {{/inline}} {{#*inline "pitch-accent-list"}} {{~#if (op ">" pitchCount 0)~}} {{~#if (op ">" pitchCount 1)~}}<ol>{{~/if~}} {{~#each pitches~}} {{~#each pitches~}} {{~#if (op ">" ../../pitchCount 1)~}}<li>{{~/if~}} {{~> pitch-accent-item-disambiguation~}} {{~> pitch-accent-item format=../../format~}} {{~#if (op ">" ../../pitchCount 1)~}}</li>{{~/if~}} {{~/each~}} {{~/each~}} {{~#if (op ">" pitchCount 1)~}}</ol>{{~/if~}} {{~else~}} No pitch accent data {{~/if~}} {{/inline}} {{#*inline "pitch-accents"}} {{~> pitch-accent-list format='text'~}} {{/inline}} {{#*inline "pitch-accent-graphs"}} {{~> pitch-accent-list format='graph'~}} {{/inline}} {{#*inline "pitch-accent-positions"}} {{~> pitch-accent-list format='position'~}} {{/inline}} {{! End Pitch Accents }} <<<UPDATE-ADDITIONS>>> {{~> (lookup . "marker") ~}}`.trimStart() }, // block helper update: furigana and furiganaPlain { oldVersion: 20, newVersion: 21, old: ` {{#*inline "furigana"}} {{~#if merge~}} {{~#each definition.expressions~}} <span class="expression-{{termFrequency}}">{{~#furigana}}{{{.}}}{{/furigana~}}</span> {{~#unless @last}}、{{/unless~}} {{~/each~}} {{~else~}} {{#furigana}}{{{definition}}}{{/furigana}} {{~/if~}} {{/inline}} {{#*inline "furigana-plain"}} {{~#if merge~}} {{~#each definition.expressions~}} <span class="expression-{{termFrequency}}">{{~#furiganaPlain}}{{{.}}}{{/furiganaPlain~}}</span> {{~#unless @last}}、{{/unless~}} {{~/each~}} {{~else~}} {{#furiganaPlain}}{{{definition}}}{{/furiganaPlain}} {{~/if~}} {{/inline}} {{#*inline "frequencies"}} {{~#if (op ">" definition.frequencies.length 0)~}} <ul style="text-align: left;"> {{~#each definition.frequencies~}} <li> {{~#if (op "!==" ../definition.type "kanji")~}} {{~#if (op "||" (op ">" ../uniqueExpressions.length 1) (op ">" ../uniqueReadings.length 1))~}}( {{~#furigana expression reading~}}{{~/furigana~}} ) {{/if~}} {{~/if~}} {{~dictionary}}: {{frequency~}} </li> {{~/each~}} </ul> {{~/if~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart(), expected: ` {{#*inline "furigana"}} {{~#if merge~}} {{~#each definition.expressions~}} <span class="expression-{{termFrequency}}">{{~furigana .~}}</span> {{~#unless @last}}、{{/unless~}} {{~/each~}} {{~else~}} {{furigana definition}} {{~/if~}} {{/inline}} {{#*inline "furigana-plain"}} {{~#if merge~}} {{~#each definition.expressions~}} <span class="expression-{{termFrequency}}">{{~furiganaPlain .~}}</span> {{~#unless @last}}、{{/unless~}} {{~/each~}} {{~else~}} {{furiganaPlain definition}} {{~/if~}} {{/inline}} {{#*inline "frequencies"}} {{~#if (op ">" definition.frequencies.length 0)~}} <ul style="text-align: left;"> {{~#each definition.frequencies~}} <li> {{~#if (op "!==" ../definition.type "kanji")~}} {{~#if (op "||" (op ">" ../uniqueExpressions.length 1) (op ">" ../uniqueReadings.length 1))~}}( {{~furigana expression reading~}} ) {{/if~}} {{~/if~}} {{~dictionary}}: {{frequency~}} </li> {{~/each~}} </ul> {{~/if~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart() }, // block helper update: formatGlossary { oldVersion: 20, newVersion: 21, old: ` {{#*inline "glossary-single"}} {{~#unless brief~}} {{~#scope~}} {{~#set "any" false}}{{/set~}} {{~#each definitionTags~}} {{~#if (op "||" (op "!" @root.compactTags) (op "!" redundant))~}} {{~#if (get "any")}}, {{else}}<i>({{/if~}} {{name}} {{~#set "any" true}}{{/set~}} {{~/if~}} {{~/each~}} {{~#unless noDictionaryTag~}} {{~#if (op "||" (op "!" @root.compactTags) (op "!==" dictionary (get "previousDictionary")))~}} {{~#if (get "any")}}, {{else}}<i>({{/if~}} {{dictionary}} {{~#set "any" true}}{{/set~}} {{~/if~}} {{~/unless~}} {{~#if (get "any")}})</i> {{/if~}} {{~/scope~}} {{~#if only~}}({{#each only}}{{.}}{{#unless @last}}, {{/unless}}{{/each}} only) {{/if~}} {{~/unless~}} {{~#if (op "<=" glossary.length 1)~}} {{#each glossary}}{{#formatGlossary ../dictionary}}{{{.}}}{{/formatGlossary}}{{/each}} {{~else if @root.compactGlossaries~}} {{#each glossary}}{{#formatGlossary ../dictionary}}{{{.}}}{{/formatGlossary}}{{#unless @last}} | {{/unless}}{{/each}} {{~else~}} <ul>{{#each glossary}}<li>{{#formatGlossary ../dictionary}}{{{.}}}{{/formatGlossary}}</li>{{/each}}</ul> {{~/if~}} {{~#set "previousDictionary" dictionary~}}{{~/set~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart(), expected: ` {{#*inline "glossary-single"}} {{~#unless brief~}} {{~#scope~}} {{~set "any" false~}} {{~#each definitionTags~}} {{~#if (op "||" (op "!" @root.compactTags) (op "!" redundant))~}} {{~#if (get "any")}}, {{else}}<i>({{/if~}} {{name}} {{~set "any" true~}} {{~/if~}} {{~/each~}} {{~#unless noDictionaryTag~}} {{~#if (op "||" (op "!" @root.compactTags) (op "!==" dictionary (get "previousDictionary")))~}} {{~#if (get "any")}}, {{else}}<i>({{/if~}} {{dictionary}} {{~set "any" true~}} {{~/if~}} {{~/unless~}} {{~#if (get "any")}})</i> {{/if~}} {{~/scope~}} {{~#if only~}}({{#each only}}{{.}}{{#unless @last}}, {{/unless}}{{/each}} only) {{/if~}} {{~/unless~}} {{~#if (op "<=" glossary.length 1)~}} {{#each glossary}}{{formatGlossary ../dictionary .}}{{/each}} {{~else if @root.compactGlossaries~}} {{#each glossary}}{{formatGlossary ../dictionary .}}{{#unless @last}} | {{/unless}}{{/each}} {{~else~}} <ul>{{#each glossary}}<li>{{formatGlossary ../dictionary .}}</li>{{/each}}</ul> {{~/if~}} {{~set "previousDictionary" dictionary~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart() }, // block helper update: set and get { oldVersion: 20, newVersion: 21, old: ` {{#*inline "pitch-accent-item-disambiguation"}} {{~#scope~}} {{~#set "exclusive" (spread exclusiveExpressions exclusiveReadings)}}{{/set~}} {{~#if (op ">" (property (get "exclusive") "length") 0)~}} {{~#set "separator" ""~}}{{/set~}} <em>({{#each (get "exclusive")~}} {{~#get "separator"}}{{/get~}}{{{.}}} {{~/each}} only) </em> {{~/if~}} {{~/scope~}} {{/inline}} {{#*inline "stroke-count"}} {{~#scope~}} {{~#set "found" false}}{{/set~}} {{~#each definition.stats.misc~}} {{~#if (op "===" name "strokes")~}} {{~#set "found" true}}{{/set~}} Stroke count: {{value}} {{~/if~}} {{~/each~}} {{~#if (op "!" (get "found"))~}} Stroke count: Unknown {{~/if~}} {{~/scope~}} {{/inline}} {{#*inline "part-of-speech"}} {{~#scope~}} {{~#if (op "!==" definition.type "kanji")~}} {{~#set "first" true}}{{/set~}} {{~#each definition.expressions~}} {{~#each wordClasses~}} {{~#unless (get (concat "used_" .))~}} {{~> part-of-speech-pretty . ~}} {{~#unless (get "first")}}, {{/unless~}} {{~#set (concat "used_" .) true~}}{{~/set~}} {{~#set "first" false~}}{{~/set~}} {{~/unless~}} {{~/each~}} {{~/each~}} {{~#if (get "first")~}}Unknown{{~/if~}} {{~/if~}} {{~/scope~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart(), expected: ` {{#*inline "pitch-accent-item-disambiguation"}} {{~#scope~}} {{~set "exclusive" (spread exclusiveExpressions exclusiveReadings)~}} {{~#if (op ">" (property (get "exclusive") "length") 0)~}} {{~set "separator" ""~}} <em>({{#each (get "exclusive")~}} {{~get "separator"~}}{{{.}}} {{~/each}} only) </em> {{~/if~}} {{~/scope~}} {{/inline}} {{#*inline "stroke-count"}} {{~#scope~}} {{~set "found" false~}} {{~#each definition.stats.misc~}} {{~#if (op "===" name "strokes")~}} {{~set "found" true~}} Stroke count: {{value}} {{~/if~}} {{~/each~}} {{~#if (op "!" (get "found"))~}} Stroke count: Unknown {{~/if~}} {{~/scope~}} {{/inline}} {{#*inline "part-of-speech"}} {{~#scope~}} {{~#if (op "!==" definition.type "kanji")~}} {{~set "first" true~}} {{~#each definition.expressions~}} {{~#each wordClasses~}} {{~#unless (get (concat "used_" .))~}} {{~> part-of-speech-pretty . ~}} {{~#unless (get "first")}}, {{/unless~}} {{~set (concat "used_" .) true~}} {{~set "first" false~}} {{~/unless~}} {{~/each~}} {{~/each~}} {{~#if (get "first")~}}Unknown{{~/if~}} {{~/if~}} {{~/scope~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart() }, // block helper update: hasMedia and getMedia { oldVersion: 20, newVersion: 21, old: ` {{#*inline "audio"}} {{~#if (hasMedia "audio")~}} [sound:{{#getMedia "audio"}}{{/getMedia}}] {{~/if~}} {{/inline}} {{#*inline "screenshot"}} {{~#if (hasMedia "screenshot")~}} <img src="{{#getMedia "screenshot"}}{{/getMedia}}" /> {{~/if~}} {{/inline}} {{#*inline "clipboard-image"}} {{~#if (hasMedia "clipboardImage")~}} <img src="{{#getMedia "clipboardImage"}}{{/getMedia}}" /> {{~/if~}} {{/inline}} {{#*inline "clipboard-text"}} {{~#if (hasMedia "clipboardText")}}{{#getMedia "clipboardText"}}{{/getMedia}}{{/if~}} {{/inline}} {{#*inline "selection-text"}} {{~#if (hasMedia "selectionText")}}{{#getMedia "selectionText"}}{{/getMedia}}{{/if~}} {{/inline}} {{#*inline "sentence-furigana"}} {{~#if definition.cloze~}} {{~#if (hasMedia "textFurigana" definition.cloze.sentence)~}} {{#getMedia "textFurigana" definition.cloze.sentence escape=false}}{{/getMedia}} {{~else~}} {{definition.cloze.sentence}} {{~/if~}} {{~/if~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart(), expected: ` {{#*inline "audio"}} {{~#if (hasMedia "audio")~}} [sound:{{getMedia "audio"}}] {{~/if~}} {{/inline}} {{#*inline "screenshot"}} {{~#if (hasMedia "screenshot")~}} <img src="{{getMedia "screenshot"}}" /> {{~/if~}} {{/inline}} {{#*inline "clipboard-image"}} {{~#if (hasMedia "clipboardImage")~}} <img src="{{getMedia "clipboardImage"}}" /> {{~/if~}} {{/inline}} {{#*inline "clipboard-text"}} {{~#if (hasMedia "clipboardText")}}{{getMedia "clipboardText"}}{{/if~}} {{/inline}} {{#*inline "selection-text"}} {{~#if (hasMedia "selectionText")}}{{getMedia "selectionText"}}{{/if~}} {{/inline}} {{#*inline "sentence-furigana"}} {{~#if definition.cloze~}} {{~#if (hasMedia "textFurigana" definition.cloze.sentence)~}} {{getMedia "textFurigana" definition.cloze.sentence escape=false}} {{~else~}} {{definition.cloze.sentence}} {{~/if~}} {{~/if~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart() }, // block helper update: pronunciation { oldVersion: 20, newVersion: 21, old: ` {{#*inline "pitch-accent-item"}} {{~#pronunciation format=format reading=reading downstepPosition=position nasalPositions=nasalPositions devoicePositions=devoicePositions~}}{{~/pronunciation~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart(), expected: ` {{#*inline "pitch-accent-item"}} {{~pronunciation format=format reading=reading downstepPosition=position nasalPositions=nasalPositions devoicePositions=devoicePositions~}} {{/inline}} {{~> (lookup . "marker") ~}}`.trimStart() }, { oldVersion: 21, newVersion: 24, old: ` {{#*inline "conjugation"}} {{~#if definition.reasons~}} {{~#each definition.reasons~}} {{~#if (op ">" @index 0)}} « {{/if~}} {{.}} {{~/each~}} {{~/if~}} {{/inline}}`.trimStart(), expected: ` {{#*inline "conjugation"}} {{~#if (op ">" definition.inflectionRuleChainCandidates.length 0)~}} {{~set "multiple" false~}} {{~#if (op ">" definition.inflectionRuleChainCandidates.length 1)~}} {{~set "multiple" true~}} {{~/if~}} {{~#if (get "multiple")~}}<ul>{{/if~}} {{~#each definition.inflectionRuleChainCandidates~}} {{~#if (op ">" inflectionRules.length 0)~}} {{~#if (get "multiple")~}}<li>{{/if~}} {{~#each inflectionRules~}} {{~#if (op ">" @index 0)}} « {{/if~}} {{.}} {{~/each~}} {{~#if (get "multiple")~}}</li>{{/if~}} {{~/if~}} {{~/each~}} {{~#if (get "multiple")~}}</ul>{{/if~}} {{~/if~}} {{/inline}} {{#*inline "cloze-body-kana"}} {{~#if definition.cloze}}{{definition.cloze.bodyKana}}{{/if~}} {{/inline}} {{#*inline "phonetic-transcriptions"}} {{~#if (op ">" definition.phoneticTranscriptions.length 0)~}} <ul> {{~#each definition.phoneticTranscriptions~}} {{~#each phoneticTranscriptions~}} <li> {{~set "any" false~}} {{~#each tags~}} {{~#if (get "any")}}, {{else}}<i>({{/if~}} {{name}} {{~set "any" true~}} {{~/each~}} {{~#if (get "any")}})</i> {{/if~}} {{ipa~}} </li> {{~/each~}} {{~/each~}} </ul> {{~/if~}} {{/inline}} {{#*inline "frequency-harmonic-rank"}} {{~#if (op "===" definition.frequencyHarmonic -1) ~}} 9999999 {{~else ~}} {{definition.frequencyHarmonic}} {{~/if~}} {{/inline}} {{#*inline "frequency-harmonic-occurrence"}} {{~#if (op "===" definition.frequencyHarmonic -1) ~}} 0 {{~else ~}} {{definition.frequencyHarmonic}} {{~/if~}} {{/inline}} {{#*inline "frequency-average-rank"}} {{~#if (op "===" definition.frequencyAverage -1) ~}} 9999999 {{~else ~}} {{definition.frequencyAverage}} {{~/if~}} {{/inline}} {{#*inline "frequency-average-occurrence"}} {{~#if (op "===" definition.frequencyAverage -1) ~}} 0 {{~else ~}} {{definition.frequencyAverage}} {{~/if~}} {{/inline}} {{~#*inline "pitch-accent-categories"~}} {{~#each (pitchCategories @root)~}}{{~.~}}{{~#unless @last~}},{{~/unless~}}{{~/each~}} {{~/inline~}}`.trimStart() } ]; const updatesPattern = /<<<UPDATE-ADDITIONS>>>/g; test.each(data)('field-templates-update-test-%#', async ({old, expected, oldVersion, newVersion}) => { const optionsUtil = new OptionsUtil(); await optionsUtil.prepare(); const options = /** @type {import('core').SafeAny} */ (createOptionsTestData1()); options.profiles[0].options.anki.fieldTemplates = old; options.version = oldVersion; const expected2 = expected.replace(updatesPattern, getUpdateAdditions(oldVersion, newVersion)); const optionsUpdated = structuredClone(await optionsUtil.update(options, newVersion)); const fieldTemplatesActual = optionsUpdated.profiles[0].options.anki.fieldTemplates; expect(fieldTemplatesActual).toStrictEqual(expected2); }); }); } /** */ async function main() { await testUpdate(); await testDefault(); await testFieldTemplatesUpdate(); await testCumulativeFieldTemplatesUpdates(); } await main();