From 4fe881d68d4c1182bee2e78a559c2064aaf48b0d Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Mon, 4 Mar 2024 07:43:31 -0500 Subject: Move sandbox files (#731) * Move sandbox files * Update order --- ext/js/background/backend.js | 2 +- ext/js/background/offscreen-proxy.js | 2 +- ext/js/background/offscreen.js | 2 +- ext/js/data/anki-note-builder.js | 4 +- ext/js/data/anki-note-data-creator.js | 936 +++++++++++++++++++++ ext/js/data/array-buffer-util.js | 72 ++ ext/js/data/sandbox/anki-note-data-creator.js | 936 --------------------- ext/js/data/sandbox/array-buffer-util.js | 72 -- ext/js/data/sandbox/string-util.js | 70 -- ext/js/data/string-util.js | 70 ++ ext/js/display/display-content-manager.js | 2 +- ext/js/display/display-generator.js | 4 +- ext/js/display/pronunciation-generator.js | 236 ++++++ ext/js/display/sandbox/pronunciation-generator.js | 236 ------ .../sandbox/structured-content-generator.js | 464 ---------- ext/js/display/structured-content-generator.js | 464 ++++++++++ ext/js/dom/css-style-applier.js | 196 +++++ ext/js/dom/dom-text-scanner.js | 2 +- ext/js/dom/sandbox/css-style-applier.js | 196 ----- ext/js/dom/text-source-element.js | 2 +- ext/js/media/audio-downloader.js | 2 +- ext/js/pages/settings/backup-controller.js | 2 +- .../anki-template-renderer-content-manager.js | 73 ++ ext/js/templates/anki-template-renderer.js | 827 ++++++++++++++++++ .../anki-template-renderer-content-manager.js | 73 -- ext/js/templates/sandbox/anki-template-renderer.js | 827 ------------------ .../sandbox/template-renderer-frame-api.js | 103 --- .../sandbox/template-renderer-frame-main.js | 30 - .../sandbox/template-renderer-media-provider.js | 189 ----- ext/js/templates/sandbox/template-renderer.js | 206 ----- ext/js/templates/template-renderer-frame-api.js | 103 +++ ext/js/templates/template-renderer-frame-main.js | 30 + .../templates/template-renderer-media-provider.js | 189 +++++ ext/js/templates/template-renderer.js | 206 +++++ 34 files changed, 3414 insertions(+), 3414 deletions(-) create mode 100644 ext/js/data/anki-note-data-creator.js create mode 100644 ext/js/data/array-buffer-util.js delete mode 100644 ext/js/data/sandbox/anki-note-data-creator.js delete mode 100644 ext/js/data/sandbox/array-buffer-util.js delete mode 100644 ext/js/data/sandbox/string-util.js create mode 100644 ext/js/data/string-util.js create mode 100644 ext/js/display/pronunciation-generator.js delete mode 100644 ext/js/display/sandbox/pronunciation-generator.js delete mode 100644 ext/js/display/sandbox/structured-content-generator.js create mode 100644 ext/js/display/structured-content-generator.js create mode 100644 ext/js/dom/css-style-applier.js delete mode 100644 ext/js/dom/sandbox/css-style-applier.js create mode 100644 ext/js/templates/anki-template-renderer-content-manager.js create mode 100644 ext/js/templates/anki-template-renderer.js delete mode 100644 ext/js/templates/sandbox/anki-template-renderer-content-manager.js delete mode 100644 ext/js/templates/sandbox/anki-template-renderer.js delete mode 100644 ext/js/templates/sandbox/template-renderer-frame-api.js delete mode 100644 ext/js/templates/sandbox/template-renderer-frame-main.js delete mode 100644 ext/js/templates/sandbox/template-renderer-media-provider.js delete mode 100644 ext/js/templates/sandbox/template-renderer.js create mode 100644 ext/js/templates/template-renderer-frame-api.js create mode 100644 ext/js/templates/template-renderer-frame-main.js create mode 100644 ext/js/templates/template-renderer-media-provider.js create mode 100644 ext/js/templates/template-renderer.js (limited to 'ext/js') diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js index d19c3b45..182f11aa 100644 --- a/ext/js/background/backend.js +++ b/ext/js/background/backend.js @@ -29,9 +29,9 @@ import {log} from '../core/log.js'; import {isObjectNotArray} from '../core/object-utilities.js'; import {clone, deferPromise, promiseTimeout} from '../core/utilities.js'; import {isNoteDataValid} from '../data/anki-util.js'; +import {arrayBufferToBase64} from '../data/array-buffer-util.js'; import {OptionsUtil} from '../data/options-util.js'; import {getAllPermissions, hasPermissions, hasRequiredPermissionsForOptions} from '../data/permissions-util.js'; -import {arrayBufferToBase64} from '../data/sandbox/array-buffer-util.js'; import {DictionaryDatabase} from '../dictionary/dictionary-database.js'; import {Environment} from '../extension/environment.js'; import {ObjectPropertyAccessor} from '../general/object-property-accessor.js'; diff --git a/ext/js/background/offscreen-proxy.js b/ext/js/background/offscreen-proxy.js index 59d1291e..30c6862a 100644 --- a/ext/js/background/offscreen-proxy.js +++ b/ext/js/background/offscreen-proxy.js @@ -18,7 +18,7 @@ import {ExtensionError} from '../core/extension-error.js'; import {isObjectNotArray} from '../core/object-utilities.js'; -import {base64ToArrayBuffer} from '../data/sandbox/array-buffer-util.js'; +import {base64ToArrayBuffer} from '../data/array-buffer-util.js'; /** * This class is responsible for creating and communicating with an offscreen document. diff --git a/ext/js/background/offscreen.js b/ext/js/background/offscreen.js index e38d4ba3..b0b11c93 100644 --- a/ext/js/background/offscreen.js +++ b/ext/js/background/offscreen.js @@ -18,7 +18,7 @@ import {ClipboardReader} from '../comm/clipboard-reader.js'; import {createApiMap, invokeApiMapHandler} from '../core/api-map.js'; -import {arrayBufferToBase64} from '../data/sandbox/array-buffer-util.js'; +import {arrayBufferToBase64} from '../data/array-buffer-util.js'; import {DictionaryDatabase} from '../dictionary/dictionary-database.js'; import {Translator} from '../language/translator.js'; diff --git a/ext/js/data/anki-note-builder.js b/ext/js/data/anki-note-builder.js index 32d4b593..e156103a 100644 --- a/ext/js/data/anki-note-builder.js +++ b/ext/js/data/anki-note-builder.js @@ -25,14 +25,14 @@ export class AnkiNoteBuilder { /** * Initiate an instance of AnkiNoteBuilder. * @param {import('anki-note-builder').MinimalApi} api - * @param {import('../templates/template-renderer-proxy.js').TemplateRendererProxy|import('../templates/sandbox/template-renderer.js').TemplateRenderer} templateRenderer + * @param {import('../templates/template-renderer-proxy.js').TemplateRendererProxy|import('../templates/template-renderer.js').TemplateRenderer} templateRenderer */ constructor(api, templateRenderer) { /** @type {import('anki-note-builder').MinimalApi} */ this._api = api; /** @type {RegExp} */ this._markerPattern = cloneFieldMarkerPattern(true); - /** @type {import('../templates/template-renderer-proxy.js').TemplateRendererProxy|import('../templates/sandbox/template-renderer.js').TemplateRenderer} */ + /** @type {import('../templates/template-renderer-proxy.js').TemplateRendererProxy|import('../templates/template-renderer.js').TemplateRenderer} */ this._templateRenderer = templateRenderer; /** @type {import('anki-note-builder').BatchedRequestGroup[]} */ this._batchedRequests = []; diff --git a/ext/js/data/anki-note-data-creator.js b/ext/js/data/anki-note-data-creator.js new file mode 100644 index 00000000..fbeb8cee --- /dev/null +++ b/ext/js/data/anki-note-data-creator.js @@ -0,0 +1,936 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 2021-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 . + */ + +import {getDisambiguations, getGroupedPronunciations, getPronunciationsOfType, getTermFrequency, groupTermTags} from '../dictionary/dictionary-data-util.js'; +import {distributeFurigana, distributeFuriganaInflected} from '../language/ja/japanese.js'; + +/** + * Creates a compatibility representation of the specified data. + * @param {string} marker The marker that is being used for template rendering. + * @param {import('anki-templates-internal').CreateDetails} details Information which is used to generate the data. + * @returns {import('anki-templates').NoteData} An object used for rendering Anki templates. + */ +export function createAnkiNoteData(marker, { + dictionaryEntry, + resultOutputMode, + mode, + glossaryLayoutMode, + compactTags, + context, + media +}) { + const definition = createCachedValue(getDefinition.bind(null, dictionaryEntry, context, resultOutputMode)); + const uniqueExpressions = createCachedValue(getUniqueExpressions.bind(null, dictionaryEntry)); + const uniqueReadings = createCachedValue(getUniqueReadings.bind(null, dictionaryEntry)); + const context2 = createCachedValue(getPublicContext.bind(null, context)); + const pitches = createCachedValue(getPitches.bind(null, dictionaryEntry)); + const pitchCount = createCachedValue(getPitchCount.bind(null, pitches)); + const phoneticTranscriptions = createCachedValue(getPhoneticTranscriptions.bind(null, dictionaryEntry)); + + if (typeof media !== 'object' || media === null || Array.isArray(media)) { + media = { + audio: void 0, + screenshot: void 0, + clipboardImage: void 0, + clipboardText: void 0, + selectionText: void 0, + textFurigana: [], + dictionaryMedia: {} + }; + } + /** @type {import('anki-templates').NoteData} */ + const result = { + marker, + get definition() { return getCachedValue(definition); }, + glossaryLayoutMode, + compactTags, + group: (resultOutputMode === 'group'), + merge: (resultOutputMode === 'merge'), + modeTermKanji: (mode === 'term-kanji'), + modeTermKana: (mode === 'term-kana'), + modeKanji: (mode === 'kanji'), + compactGlossaries: (glossaryLayoutMode === 'compact'), + get uniqueExpressions() { return getCachedValue(uniqueExpressions); }, + get uniqueReadings() { return getCachedValue(uniqueReadings); }, + get pitches() { return getCachedValue(pitches); }, + get pitchCount() { return getCachedValue(pitchCount); }, + get phoneticTranscriptions() { return getCachedValue(phoneticTranscriptions); }, + get context() { return getCachedValue(context2); }, + media, + dictionaryEntry + }; + Object.defineProperty(result, 'dictionaryEntry', { + configurable: false, + enumerable: false, + writable: false, + value: dictionaryEntry + }); + return result; +} + +/** + * Creates a deferred-evaluation value. + * @template [T=unknown] + * @param {() => T} getter The function to invoke to get the return value. + * @returns {import('anki-templates-internal').CachedValue} An object which can be passed into `getCachedValue`. + */ +export function createCachedValue(getter) { + return {getter, hasValue: false, value: void 0}; +} + +/** + * Gets the value of a cached object. + * @template [T=unknown] + * @param {import('anki-templates-internal').CachedValue} item An object that was returned from `createCachedValue`. + * @returns {T} The result of evaluating the getter, which is cached after the first invocation. + */ +export function getCachedValue(item) { + if (item.hasValue) { return /** @type {T} */ (item.value); } + const value = item.getter(); + item.value = value; + item.hasValue = true; + return value; +} + +// Private + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {?import('dictionary').TermSource} + */ +function getPrimarySource(dictionaryEntry) { + for (const headword of dictionaryEntry.headwords) { + for (const source of headword.sources) { + if (source.isPrimary) { return source; } + } + } + return null; +} + +/** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {string[]} + */ +function getUniqueExpressions(dictionaryEntry) { + if (dictionaryEntry.type === 'term') { + const results = new Set(); + for (const {term} of dictionaryEntry.headwords) { + results.add(term); + } + return [...results]; + } else { + return []; + } +} + +/** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {string[]} + */ +function getUniqueReadings(dictionaryEntry) { + if (dictionaryEntry.type === 'term') { + const results = new Set(); + for (const {reading} of dictionaryEntry.headwords) { + results.add(reading); + } + return [...results]; + } else { + return []; + } +} + +/** + * @param {import('anki-templates-internal').Context} context + * @returns {import('anki-templates').Context} + */ +function getPublicContext(context) { + let {documentTitle, query, fullQuery} = context; + if (typeof documentTitle !== 'string') { documentTitle = ''; } + return { + query, + fullQuery, + document: { + title: documentTitle + } + }; +} + +/** + * @param {import('dictionary').TermDictionaryEntry|import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @returns {number[]} + */ +function getFrequencyNumbers(dictionaryEntry) { + let previousDictionary; + const frequencies = []; + for (const {dictionary, frequency, displayValue} of dictionaryEntry.frequencies) { + if (dictionary === previousDictionary) { + continue; + } + previousDictionary = dictionary; + + if (displayValue !== null) { + const frequencyMatch = displayValue.match(/\d+/); + if (frequencyMatch !== null) { + frequencies.push(Number.parseInt(frequencyMatch[0], 10)); + continue; + } + } + frequencies.push(frequency); + } + return frequencies; +} + +/** + * @param {import('dictionary').TermDictionaryEntry|import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @returns {number} + */ +function getFrequencyHarmonic(dictionaryEntry) { + const frequencies = getFrequencyNumbers(dictionaryEntry); + + if (frequencies.length === 0) { + return -1; + } + + let total = 0; + for (const frequency of frequencies) { + total += 1 / frequency; + } + return Math.floor(frequencies.length / total); +} + +/** + * @param {import('dictionary').TermDictionaryEntry|import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @returns {number} + */ +function getFrequencyAverage(dictionaryEntry) { + const frequencies = getFrequencyNumbers(dictionaryEntry); + + if (frequencies.length === 0) { + return -1; + } + + let total = 0; + for (const frequency of frequencies) { + total += frequency; + } + return Math.floor(total / frequencies.length); +} + +/** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').PitchGroup[]} + */ +function getPitches(dictionaryEntry) { + /** @type {import('anki-templates').PitchGroup[]} */ + const results = []; + if (dictionaryEntry.type === 'term') { + for (const {dictionary, pronunciations} of getGroupedPronunciations(dictionaryEntry)) { + /** @type {import('anki-templates').Pitch[]} */ + const pitches = []; + for (const groupedPronunciation of pronunciations) { + const {pronunciation} = groupedPronunciation; + if (pronunciation.type !== 'pitch-accent') { continue; } + const {position, nasalPositions, devoicePositions, tags} = pronunciation; + const {terms, reading, exclusiveTerms, exclusiveReadings} = groupedPronunciation; + pitches.push({ + expressions: terms, + reading, + position, + nasalPositions, + devoicePositions, + tags: convertPitchTags(tags), + exclusiveExpressions: exclusiveTerms, + exclusiveReadings + }); + } + results.push({dictionary, pitches}); + } + } + return results; +} + +/** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TranscriptionGroup[]} + */ +function getPhoneticTranscriptions(dictionaryEntry) { + const results = []; + if (dictionaryEntry.type === 'term') { + for (const {dictionary, pronunciations} of getGroupedPronunciations(dictionaryEntry)) { + const phoneticTranscriptions = []; + for (const groupedPronunciation of pronunciations) { + const {pronunciation} = groupedPronunciation; + if (pronunciation.type !== 'phonetic-transcription') { continue; } + const {ipa, tags} = pronunciation; + const {terms, reading, exclusiveTerms, exclusiveReadings} = groupedPronunciation; + phoneticTranscriptions.push({ + expressions: terms, + reading, + ipa, + tags, + exclusiveExpressions: exclusiveTerms, + exclusiveReadings + }); + } + results.push({dictionary, phoneticTranscriptions}); + } + } + return results; +} + +/** + * @param {import('anki-templates-internal').CachedValue} cachedPitches + * @returns {number} + */ +function getPitchCount(cachedPitches) { + const pitches = getCachedValue(cachedPitches); + return pitches.reduce((i, v) => i + v.pitches.length, 0); +} + +/** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @param {import('settings').ResultOutputMode} resultOutputMode + * @returns {import('anki-templates').DictionaryEntry} + */ +function getDefinition(dictionaryEntry, context, resultOutputMode) { + switch (dictionaryEntry.type) { + case 'term': + return getTermDefinition(dictionaryEntry, context, resultOutputMode); + case 'kanji': + return getKanjiDefinition(dictionaryEntry, context); + default: + return /** @type {import('anki-templates').UnknownDictionaryEntry} */ ({}); + } +} + +/** + * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @returns {import('anki-templates').KanjiDictionaryEntry} + */ +function getKanjiDefinition(dictionaryEntry, context) { + const {character, dictionary, onyomi, kunyomi, definitions} = dictionaryEntry; + + let {url} = context; + if (typeof url !== 'string') { url = ''; } + + const stats = createCachedValue(getKanjiStats.bind(null, dictionaryEntry)); + const tags = createCachedValue(convertTags.bind(null, dictionaryEntry.tags)); + const frequencies = createCachedValue(getKanjiFrequencies.bind(null, dictionaryEntry)); + const frequencyHarmonic = createCachedValue(getFrequencyHarmonic.bind(null, dictionaryEntry)); + const frequencyAverage = createCachedValue(getFrequencyAverage.bind(null, dictionaryEntry)); + const cloze = createCachedValue(getCloze.bind(null, dictionaryEntry, context)); + + return { + type: 'kanji', + character, + dictionary, + onyomi, + kunyomi, + glossary: definitions, + get tags() { return getCachedValue(tags); }, + get stats() { return getCachedValue(stats); }, + get frequencies() { return getCachedValue(frequencies); }, + get frequencyHarmonic() { return getCachedValue(frequencyHarmonic); }, + get frequencyAverage() { return getCachedValue(frequencyAverage); }, + url, + get cloze() { return getCachedValue(cloze); } + }; +} + +/** + * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').KanjiStatGroups} + */ +function getKanjiStats(dictionaryEntry) { + /** @type {import('anki-templates').KanjiStatGroups} */ + const results = {}; + for (const [key, value] of Object.entries(dictionaryEntry.stats)) { + results[key] = value.map(convertKanjiStat); + } + return results; +} + +/** + * @param {import('dictionary').KanjiStat} kanjiStat + * @returns {import('anki-templates').KanjiStat} + */ +function convertKanjiStat({name, category, content, order, score, dictionary, value}) { + return { + name, + category, + notes: content, + order, + score, + dictionary, + value + }; +} + +/** + * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').KanjiFrequency[]} + */ +function getKanjiFrequencies(dictionaryEntry) { + /** @type {import('anki-templates').KanjiFrequency[]} */ + const results = []; + for (const {index, dictionary, dictionaryIndex, dictionaryPriority, character, frequency, displayValue} of dictionaryEntry.frequencies) { + results.push({ + index, + dictionary, + dictionaryOrder: { + index: dictionaryIndex, + priority: dictionaryPriority + }, + character, + frequency: displayValue !== null ? displayValue : frequency + }); + } + return results; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @param {import('settings').ResultOutputMode} resultOutputMode + * @returns {import('anki-templates').TermDictionaryEntry} + */ +function getTermDefinition(dictionaryEntry, context, resultOutputMode) { + /** @type {import('anki-templates').TermDictionaryEntryType} */ + let type = 'term'; + switch (resultOutputMode) { + case 'group': type = 'termGrouped'; break; + case 'merge': type = 'termMerged'; break; + } + + const {inflectionRuleChainCandidates, score, dictionaryIndex, dictionaryPriority, sourceTermExactMatchCount, definitions} = dictionaryEntry; + + let {url} = context; + if (typeof url !== 'string') { url = ''; } + + const primarySource = getPrimarySource(dictionaryEntry); + + const dictionaryNames = createCachedValue(getTermDictionaryNames.bind(null, dictionaryEntry)); + const commonInfo = createCachedValue(getTermDictionaryEntryCommonInfo.bind(null, dictionaryEntry, type)); + const termTags = createCachedValue(getTermTags.bind(null, dictionaryEntry, type)); + const expressions = createCachedValue(getTermExpressions.bind(null, dictionaryEntry)); + const frequencies = createCachedValue(getTermFrequencies.bind(null, dictionaryEntry)); + const frequencyHarmonic = createCachedValue(getFrequencyHarmonic.bind(null, dictionaryEntry)); + const frequencyAverage = createCachedValue(getFrequencyAverage.bind(null, dictionaryEntry)); + const pitches = createCachedValue(getTermPitches.bind(null, dictionaryEntry)); + const phoneticTranscriptions = createCachedValue(getTermPhoneticTranscriptions.bind(null, dictionaryEntry)); + const glossary = createCachedValue(getTermGlossaryArray.bind(null, dictionaryEntry, type)); + const cloze = createCachedValue(getCloze.bind(null, dictionaryEntry, context)); + const furiganaSegments = createCachedValue(getTermFuriganaSegments.bind(null, dictionaryEntry, type)); + const sequence = createCachedValue(getTermDictionaryEntrySequence.bind(null, dictionaryEntry)); + + return { + type, + id: (type === 'term' && definitions.length > 0 ? definitions[0].id : void 0), + source: (primarySource !== null ? primarySource.transformedText : null), + rawSource: (primarySource !== null ? primarySource.originalText : null), + sourceTerm: (type !== 'termMerged' ? (primarySource !== null ? primarySource.deinflectedText : null) : void 0), + inflectionRuleChainCandidates, + score, + isPrimary: (type === 'term' ? dictionaryEntry.isPrimary : void 0), + get sequence() { return getCachedValue(sequence); }, + get dictionary() { return getCachedValue(dictionaryNames)[0]; }, + dictionaryOrder: { + index: dictionaryIndex, + priority: dictionaryPriority + }, + get dictionaryNames() { return getCachedValue(dictionaryNames); }, + get expression() { + const {uniqueTerms} = getCachedValue(commonInfo); + return (type === 'term' || type === 'termGrouped' ? uniqueTerms[0] : uniqueTerms); + }, + get reading() { + const {uniqueReadings} = getCachedValue(commonInfo); + return (type === 'term' || type === 'termGrouped' ? uniqueReadings[0] : uniqueReadings); + }, + get expressions() { return getCachedValue(expressions); }, + get glossary() { return getCachedValue(glossary); }, + get definitionTags() { return type === 'term' ? getCachedValue(commonInfo).definitionTags : void 0; }, + get termTags() { return getCachedValue(termTags); }, + get definitions() { return getCachedValue(commonInfo).definitions; }, + get frequencies() { return getCachedValue(frequencies); }, + get frequencyHarmonic() { return getCachedValue(frequencyHarmonic); }, + get frequencyAverage() { return getCachedValue(frequencyAverage); }, + get pitches() { return getCachedValue(pitches); }, + get phoneticTranscriptions() { return getCachedValue(phoneticTranscriptions); }, + sourceTermExactMatchCount, + url, + get cloze() { return getCachedValue(cloze); }, + get furiganaSegments() { return getCachedValue(furiganaSegments); } + }; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {string[]} + */ +function getTermDictionaryNames(dictionaryEntry) { + const dictionaryNames = new Set(); + for (const {dictionary} of dictionaryEntry.definitions) { + dictionaryNames.add(dictionary); + } + return [...dictionaryNames]; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('anki-templates').TermDictionaryEntryCommonInfo} + */ +function getTermDictionaryEntryCommonInfo(dictionaryEntry, type) { + const merged = (type === 'termMerged'); + const hasDefinitions = (type !== 'term'); + + /** @type {Set} */ + const allTermsSet = new Set(); + /** @type {Set} */ + const allReadingsSet = new Set(); + for (const {term, reading} of dictionaryEntry.headwords) { + allTermsSet.add(term); + allReadingsSet.add(reading); + } + const uniqueTerms = [...allTermsSet]; + const uniqueReadings = [...allReadingsSet]; + + /** @type {import('anki-templates').TermDefinition[]} */ + const definitions = []; + /** @type {import('anki-templates').Tag[]} */ + const definitionTags = []; + for (const {tags, headwordIndices, entries, dictionary, sequences} of dictionaryEntry.definitions) { + const definitionTags2 = []; + for (const tag of tags) { + definitionTags.push(convertTag(tag)); + definitionTags2.push(convertTag(tag)); + } + if (!hasDefinitions) { continue; } + const only = merged ? getDisambiguations(dictionaryEntry.headwords, headwordIndices, allTermsSet, allReadingsSet) : void 0; + definitions.push({ + sequence: sequences[0], + dictionary, + glossary: entries, + definitionTags: definitionTags2, + only + }); + } + + return { + uniqueTerms, + uniqueReadings, + definitionTags, + definitions: hasDefinitions ? definitions : void 0 + }; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TermFrequency[]} + */ +function getTermFrequencies(dictionaryEntry) { + const results = []; + const {headwords} = dictionaryEntry; + for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, hasReading, frequency, displayValue} of dictionaryEntry.frequencies) { + const {term, reading} = headwords[headwordIndex]; + results.push({ + index: results.length, + expressionIndex: headwordIndex, + dictionary, + dictionaryOrder: { + index: dictionaryIndex, + priority: dictionaryPriority + }, + expression: term, + reading, + hasReading, + frequency: displayValue !== null ? displayValue : frequency + }); + } + return results; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TermPitchAccent[]} + */ +function getTermPitches(dictionaryEntry) { + const results = []; + const {headwords} = dictionaryEntry; + for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations} of dictionaryEntry.pronunciations) { + const {term, reading} = headwords[headwordIndex]; + const pitches = getPronunciationsOfType(pronunciations, 'pitch-accent'); + const cachedPitches = createCachedValue(getTermPitchesInner.bind(null, pitches)); + results.push({ + index: results.length, + expressionIndex: headwordIndex, + dictionary, + dictionaryOrder: { + index: dictionaryIndex, + priority: dictionaryPriority + }, + expression: term, + reading, + get pitches() { return getCachedValue(cachedPitches); } + }); + } + return results; +} + +/** + * @param {import('dictionary').PitchAccent[]} pitches + * @returns {import('anki-templates').PitchAccent[]} + */ +function getTermPitchesInner(pitches) { + const results = []; + for (const {position, tags} of pitches) { + const cachedTags = createCachedValue(convertTags.bind(null, tags)); + results.push({ + position, + get tags() { return getCachedValue(cachedTags); } + }); + } + return results; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TermPhoneticTranscription[]} + */ +function getTermPhoneticTranscriptions(dictionaryEntry) { + const results = []; + const {headwords} = dictionaryEntry; + for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations} of dictionaryEntry.pronunciations) { + const {term, reading} = headwords[headwordIndex]; + const phoneticTranscriptions = getPronunciationsOfType(pronunciations, 'phonetic-transcription'); + const termPhoneticTranscriptions = getTermPhoneticTranscriptionsInner(phoneticTranscriptions); + results.push({ + index: results.length, + expressionIndex: headwordIndex, + dictionary, + dictionaryOrder: { + index: dictionaryIndex, + priority: dictionaryPriority + }, + expression: term, + reading, + get phoneticTranscriptions() { return termPhoneticTranscriptions; } + }); + } + + return results; +} + +/** + * @param {import('dictionary').PhoneticTranscription[]} phoneticTranscriptions + * @returns {import('anki-templates').PhoneticTranscription[]} + */ +function getTermPhoneticTranscriptionsInner(phoneticTranscriptions) { + const results = []; + for (const {ipa, tags} of phoneticTranscriptions) { + const cachedTags = createCachedValue(convertTags.bind(null, tags)); + results.push({ + ipa, + get tags() { return getCachedValue(cachedTags); } + }); + } + return results; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TermHeadword[]} + */ +function getTermExpressions(dictionaryEntry) { + const results = []; + const {headwords} = dictionaryEntry; + for (let i = 0, ii = headwords.length; i < ii; ++i) { + const {term, reading, tags, sources: [{deinflectedText}], wordClasses} = headwords[i]; + const termTags = createCachedValue(convertTags.bind(null, tags)); + const frequencies = createCachedValue(getTermExpressionFrequencies.bind(null, dictionaryEntry, i)); + const pitches = createCachedValue(getTermExpressionPitches.bind(null, dictionaryEntry, i)); + const termFrequency = createCachedValue(getTermExpressionTermFrequency.bind(null, termTags)); + const furiganaSegments = createCachedValue(getTermHeadwordFuriganaSegments.bind(null, term, reading)); + const item = { + sourceTerm: deinflectedText, + expression: term, + reading, + get termTags() { return getCachedValue(termTags); }, + get frequencies() { return getCachedValue(frequencies); }, + get pitches() { return getCachedValue(pitches); }, + get furiganaSegments() { return getCachedValue(furiganaSegments); }, + get termFrequency() { return getCachedValue(termFrequency); }, + wordClasses + }; + results.push(item); + } + return results; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {number} i + * @returns {import('anki-templates').TermFrequency[]} + */ +function getTermExpressionFrequencies(dictionaryEntry, i) { + const results = []; + const {headwords, frequencies} = dictionaryEntry; + for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, hasReading, frequency, displayValue} of frequencies) { + if (headwordIndex !== i) { continue; } + const {term, reading} = headwords[headwordIndex]; + results.push({ + index: results.length, + expressionIndex: headwordIndex, + dictionary, + dictionaryOrder: { + index: dictionaryIndex, + priority: dictionaryPriority + }, + expression: term, + reading, + hasReading, + frequency: displayValue !== null ? displayValue : frequency + }); + } + return results; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {number} i + * @returns {import('anki-templates').TermPitchAccent[]} + */ +function getTermExpressionPitches(dictionaryEntry, i) { + const results = []; + const {headwords, pronunciations: termPronunciations} = dictionaryEntry; + for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations} of termPronunciations) { + if (headwordIndex !== i) { continue; } + const {term, reading} = headwords[headwordIndex]; + const pitches = getPronunciationsOfType(pronunciations, 'pitch-accent'); + const cachedPitches = createCachedValue(getTermPitchesInner.bind(null, pitches)); + results.push({ + index: results.length, + expressionIndex: headwordIndex, + dictionary, + dictionaryOrder: { + index: dictionaryIndex, + priority: dictionaryPriority + }, + expression: term, + reading, + get pitches() { return getCachedValue(cachedPitches); } + }); + } + return results; +} + +/** + * @param {import('anki-templates-internal').CachedValue} cachedTermTags + * @returns {import('anki-templates').TermFrequencyType} + */ +function getTermExpressionTermFrequency(cachedTermTags) { + const termTags = getCachedValue(cachedTermTags); + return getTermFrequency(termTags); +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('dictionary-data').TermGlossary[]|undefined} + */ +function getTermGlossaryArray(dictionaryEntry, type) { + if (type === 'term') { + const results = []; + for (const {entries} of dictionaryEntry.definitions) { + results.push(...entries); + } + return results; + } + return void 0; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('anki-templates').Tag[]|undefined} + */ +function getTermTags(dictionaryEntry, type) { + if (type !== 'termMerged') { + const results = []; + for (const {tag} of groupTermTags(dictionaryEntry)) { + results.push(convertTag(tag)); + } + return results; + } + return void 0; +} + +/** + * @param {import('dictionary').Tag[]} tags + * @returns {import('anki-templates').Tag[]} + */ +function convertTags(tags) { + const results = []; + for (const tag of tags) { + results.push(convertTag(tag)); + } + return results; +} + +/** + * @param {import('dictionary').Tag} tag + * @returns {import('anki-templates').Tag} + */ +function convertTag({name, category, content, order, score, dictionaries, redundant}) { + return { + name, + category, + notes: (content.length > 0 ? content[0] : ''), + order, + score, + dictionary: (dictionaries.length > 0 ? dictionaries[0] : ''), + redundant + }; +} + +/** + * @param {import('dictionary').Tag[]} tags + * @returns {import('anki-templates').PitchTag[]} + */ +function convertPitchTags(tags) { + const results = []; + for (const tag of tags) { + results.push(convertPitchTag(tag)); + } + return results; +} + +/** + * @param {import('dictionary').Tag} tag + * @returns {import('anki-templates').PitchTag} + */ +function convertPitchTag({name, category, content, order, score, dictionaries, redundant}) { + return { + name, + category, + order, + score, + content: [...content], + dictionaries: [...dictionaries], + redundant + }; +} + +/** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @returns {import('anki-templates').Cloze} + */ +function getCloze(dictionaryEntry, context) { + let originalText = ''; + let term = ''; + let reading = ''; + switch (dictionaryEntry.type) { + case 'term': + { + term = dictionaryEntry.headwords[0].term; + reading = dictionaryEntry.headwords[0].reading; + const primarySource = getPrimarySource(dictionaryEntry); + if (primarySource !== null) { originalText = primarySource.originalText; } + } + break; + case 'kanji': + originalText = dictionaryEntry.character; + break; + } + + const {sentence} = context; + let text; + let offset; + if (typeof sentence === 'object' && sentence !== null) { + ({text, offset} = sentence); + } + if (typeof text !== 'string') { text = ''; } + if (typeof offset !== 'number') { offset = 0; } + + const textSegments = []; + for (const {text: text2, reading: reading2} of distributeFuriganaInflected(term, reading, text.substring(offset, offset + originalText.length))) { + textSegments.push(reading2.length > 0 ? reading2 : text2); + } + + return { + sentence: text, + prefix: text.substring(0, offset), + body: text.substring(offset, offset + originalText.length), + bodyKana: textSegments.join(''), + suffix: text.substring(offset + originalText.length) + }; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('anki-templates').FuriganaSegment[]|undefined} + */ +function getTermFuriganaSegments(dictionaryEntry, type) { + if (type === 'term') { + for (const {term, reading} of dictionaryEntry.headwords) { + return getTermHeadwordFuriganaSegments(term, reading); + } + } + return void 0; +} + +/** + * @param {string} term + * @param {string} reading + * @returns {import('anki-templates').FuriganaSegment[]} + */ +function getTermHeadwordFuriganaSegments(term, reading) { + /** @type {import('anki-templates').FuriganaSegment[]} */ + const result = []; + for (const {text, reading: reading2} of distributeFurigana(term, reading)) { + result.push({text, furigana: reading2}); + } + return result; +} + +/** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {number} + */ +function getTermDictionaryEntrySequence(dictionaryEntry) { + let hasSequence = false; + let mainSequence = -1; + if (!dictionaryEntry.isPrimary) { return mainSequence; } + for (const {sequences} of dictionaryEntry.definitions) { + const sequence = sequences[0]; + if (!hasSequence) { + mainSequence = sequence; + hasSequence = true; + if (mainSequence === -1) { break; } + } else if (mainSequence !== sequence) { + mainSequence = -1; + break; + } + } + return mainSequence; +} diff --git a/ext/js/data/array-buffer-util.js b/ext/js/data/array-buffer-util.js new file mode 100644 index 00000000..487fcd24 --- /dev/null +++ b/ext/js/data/array-buffer-util.js @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 2021-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 . + */ + +/** + * Decodes the contents of an ArrayBuffer using UTF8. + * @param {ArrayBuffer} arrayBuffer The input ArrayBuffer. + * @returns {string} A UTF8-decoded string. + */ +export function arrayBufferUtf8Decode(arrayBuffer) { + try { + return new TextDecoder('utf-8').decode(arrayBuffer); + } catch (e) { + return decodeURIComponent(escape(arrayBufferToBinaryString(arrayBuffer))); + } +} + +/** + * Converts the contents of an ArrayBuffer to a base64 string. + * @param {ArrayBuffer} arrayBuffer The input ArrayBuffer. + * @returns {string} A base64 string representing the binary content. + */ +export function arrayBufferToBase64(arrayBuffer) { + return btoa(arrayBufferToBinaryString(arrayBuffer)); +} + +/** + * Converts the contents of an ArrayBuffer to a binary string. + * @param {ArrayBuffer} arrayBuffer The input ArrayBuffer. + * @returns {string} A string representing the binary content. + */ +export function arrayBufferToBinaryString(arrayBuffer) { + const bytes = new Uint8Array(arrayBuffer); + try { + return String.fromCharCode(...bytes); + } catch (e) { + let binary = ''; + for (let i = 0, ii = bytes.byteLength; i < ii; ++i) { + binary += String.fromCharCode(bytes[i]); + } + return binary; + } +} + +/** + * Converts a base64 string to an ArrayBuffer. + * @param {string} content The binary content string encoded in base64. + * @returns {ArrayBuffer} A new `ArrayBuffer` object corresponding to the specified content. + */ +export function base64ToArrayBuffer(content) { + const binaryContent = atob(content); + const length = binaryContent.length; + const array = new Uint8Array(length); + for (let i = 0; i < length; ++i) { + array[i] = binaryContent.charCodeAt(i); + } + return array.buffer; +} diff --git a/ext/js/data/sandbox/anki-note-data-creator.js b/ext/js/data/sandbox/anki-note-data-creator.js deleted file mode 100644 index d0456b0f..00000000 --- a/ext/js/data/sandbox/anki-note-data-creator.js +++ /dev/null @@ -1,936 +0,0 @@ -/* - * Copyright (C) 2023-2024 Yomitan Authors - * Copyright (C) 2021-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 . - */ - -import {getDisambiguations, getGroupedPronunciations, getPronunciationsOfType, getTermFrequency, groupTermTags} from '../../dictionary/dictionary-data-util.js'; -import {distributeFurigana, distributeFuriganaInflected} from '../../language/ja/japanese.js'; - -/** - * Creates a compatibility representation of the specified data. - * @param {string} marker The marker that is being used for template rendering. - * @param {import('anki-templates-internal').CreateDetails} details Information which is used to generate the data. - * @returns {import('anki-templates').NoteData} An object used for rendering Anki templates. - */ -export function createAnkiNoteData(marker, { - dictionaryEntry, - resultOutputMode, - mode, - glossaryLayoutMode, - compactTags, - context, - media -}) { - const definition = createCachedValue(getDefinition.bind(null, dictionaryEntry, context, resultOutputMode)); - const uniqueExpressions = createCachedValue(getUniqueExpressions.bind(null, dictionaryEntry)); - const uniqueReadings = createCachedValue(getUniqueReadings.bind(null, dictionaryEntry)); - const context2 = createCachedValue(getPublicContext.bind(null, context)); - const pitches = createCachedValue(getPitches.bind(null, dictionaryEntry)); - const pitchCount = createCachedValue(getPitchCount.bind(null, pitches)); - const phoneticTranscriptions = createCachedValue(getPhoneticTranscriptions.bind(null, dictionaryEntry)); - - if (typeof media !== 'object' || media === null || Array.isArray(media)) { - media = { - audio: void 0, - screenshot: void 0, - clipboardImage: void 0, - clipboardText: void 0, - selectionText: void 0, - textFurigana: [], - dictionaryMedia: {} - }; - } - /** @type {import('anki-templates').NoteData} */ - const result = { - marker, - get definition() { return getCachedValue(definition); }, - glossaryLayoutMode, - compactTags, - group: (resultOutputMode === 'group'), - merge: (resultOutputMode === 'merge'), - modeTermKanji: (mode === 'term-kanji'), - modeTermKana: (mode === 'term-kana'), - modeKanji: (mode === 'kanji'), - compactGlossaries: (glossaryLayoutMode === 'compact'), - get uniqueExpressions() { return getCachedValue(uniqueExpressions); }, - get uniqueReadings() { return getCachedValue(uniqueReadings); }, - get pitches() { return getCachedValue(pitches); }, - get pitchCount() { return getCachedValue(pitchCount); }, - get phoneticTranscriptions() { return getCachedValue(phoneticTranscriptions); }, - get context() { return getCachedValue(context2); }, - media, - dictionaryEntry - }; - Object.defineProperty(result, 'dictionaryEntry', { - configurable: false, - enumerable: false, - writable: false, - value: dictionaryEntry - }); - return result; -} - -/** - * Creates a deferred-evaluation value. - * @template [T=unknown] - * @param {() => T} getter The function to invoke to get the return value. - * @returns {import('anki-templates-internal').CachedValue} An object which can be passed into `getCachedValue`. - */ -export function createCachedValue(getter) { - return {getter, hasValue: false, value: void 0}; -} - -/** - * Gets the value of a cached object. - * @template [T=unknown] - * @param {import('anki-templates-internal').CachedValue} item An object that was returned from `createCachedValue`. - * @returns {T} The result of evaluating the getter, which is cached after the first invocation. - */ -export function getCachedValue(item) { - if (item.hasValue) { return /** @type {T} */ (item.value); } - const value = item.getter(); - item.value = value; - item.hasValue = true; - return value; -} - -// Private - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @returns {?import('dictionary').TermSource} - */ -function getPrimarySource(dictionaryEntry) { - for (const headword of dictionaryEntry.headwords) { - for (const source of headword.sources) { - if (source.isPrimary) { return source; } - } - } - return null; -} - -/** - * @param {import('dictionary').DictionaryEntry} dictionaryEntry - * @returns {string[]} - */ -function getUniqueExpressions(dictionaryEntry) { - if (dictionaryEntry.type === 'term') { - const results = new Set(); - for (const {term} of dictionaryEntry.headwords) { - results.add(term); - } - return [...results]; - } else { - return []; - } -} - -/** - * @param {import('dictionary').DictionaryEntry} dictionaryEntry - * @returns {string[]} - */ -function getUniqueReadings(dictionaryEntry) { - if (dictionaryEntry.type === 'term') { - const results = new Set(); - for (const {reading} of dictionaryEntry.headwords) { - results.add(reading); - } - return [...results]; - } else { - return []; - } -} - -/** - * @param {import('anki-templates-internal').Context} context - * @returns {import('anki-templates').Context} - */ -function getPublicContext(context) { - let {documentTitle, query, fullQuery} = context; - if (typeof documentTitle !== 'string') { documentTitle = ''; } - return { - query, - fullQuery, - document: { - title: documentTitle - } - }; -} - -/** - * @param {import('dictionary').TermDictionaryEntry|import('dictionary').KanjiDictionaryEntry} dictionaryEntry - * @returns {number[]} - */ -function getFrequencyNumbers(dictionaryEntry) { - let previousDictionary; - const frequencies = []; - for (const {dictionary, frequency, displayValue} of dictionaryEntry.frequencies) { - if (dictionary === previousDictionary) { - continue; - } - previousDictionary = dictionary; - - if (displayValue !== null) { - const frequencyMatch = displayValue.match(/\d+/); - if (frequencyMatch !== null) { - frequencies.push(Number.parseInt(frequencyMatch[0], 10)); - continue; - } - } - frequencies.push(frequency); - } - return frequencies; -} - -/** - * @param {import('dictionary').TermDictionaryEntry|import('dictionary').KanjiDictionaryEntry} dictionaryEntry - * @returns {number} - */ -function getFrequencyHarmonic(dictionaryEntry) { - const frequencies = getFrequencyNumbers(dictionaryEntry); - - if (frequencies.length === 0) { - return -1; - } - - let total = 0; - for (const frequency of frequencies) { - total += 1 / frequency; - } - return Math.floor(frequencies.length / total); -} - -/** - * @param {import('dictionary').TermDictionaryEntry|import('dictionary').KanjiDictionaryEntry} dictionaryEntry - * @returns {number} - */ -function getFrequencyAverage(dictionaryEntry) { - const frequencies = getFrequencyNumbers(dictionaryEntry); - - if (frequencies.length === 0) { - return -1; - } - - let total = 0; - for (const frequency of frequencies) { - total += frequency; - } - return Math.floor(total / frequencies.length); -} - -/** - * @param {import('dictionary').DictionaryEntry} dictionaryEntry - * @returns {import('anki-templates').PitchGroup[]} - */ -function getPitches(dictionaryEntry) { - /** @type {import('anki-templates').PitchGroup[]} */ - const results = []; - if (dictionaryEntry.type === 'term') { - for (const {dictionary, pronunciations} of getGroupedPronunciations(dictionaryEntry)) { - /** @type {import('anki-templates').Pitch[]} */ - const pitches = []; - for (const groupedPronunciation of pronunciations) { - const {pronunciation} = groupedPronunciation; - if (pronunciation.type !== 'pitch-accent') { continue; } - const {position, nasalPositions, devoicePositions, tags} = pronunciation; - const {terms, reading, exclusiveTerms, exclusiveReadings} = groupedPronunciation; - pitches.push({ - expressions: terms, - reading, - position, - nasalPositions, - devoicePositions, - tags: convertPitchTags(tags), - exclusiveExpressions: exclusiveTerms, - exclusiveReadings - }); - } - results.push({dictionary, pitches}); - } - } - return results; -} - -/** - * @param {import('dictionary').DictionaryEntry} dictionaryEntry - * @returns {import('anki-templates').TranscriptionGroup[]} - */ -function getPhoneticTranscriptions(dictionaryEntry) { - const results = []; - if (dictionaryEntry.type === 'term') { - for (const {dictionary, pronunciations} of getGroupedPronunciations(dictionaryEntry)) { - const phoneticTranscriptions = []; - for (const groupedPronunciation of pronunciations) { - const {pronunciation} = groupedPronunciation; - if (pronunciation.type !== 'phonetic-transcription') { continue; } - const {ipa, tags} = pronunciation; - const {terms, reading, exclusiveTerms, exclusiveReadings} = groupedPronunciation; - phoneticTranscriptions.push({ - expressions: terms, - reading, - ipa, - tags, - exclusiveExpressions: exclusiveTerms, - exclusiveReadings - }); - } - results.push({dictionary, phoneticTranscriptions}); - } - } - return results; -} - -/** - * @param {import('anki-templates-internal').CachedValue} cachedPitches - * @returns {number} - */ -function getPitchCount(cachedPitches) { - const pitches = getCachedValue(cachedPitches); - return pitches.reduce((i, v) => i + v.pitches.length, 0); -} - -/** - * @param {import('dictionary').DictionaryEntry} dictionaryEntry - * @param {import('anki-templates-internal').Context} context - * @param {import('settings').ResultOutputMode} resultOutputMode - * @returns {import('anki-templates').DictionaryEntry} - */ -function getDefinition(dictionaryEntry, context, resultOutputMode) { - switch (dictionaryEntry.type) { - case 'term': - return getTermDefinition(dictionaryEntry, context, resultOutputMode); - case 'kanji': - return getKanjiDefinition(dictionaryEntry, context); - default: - return /** @type {import('anki-templates').UnknownDictionaryEntry} */ ({}); - } -} - -/** - * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry - * @param {import('anki-templates-internal').Context} context - * @returns {import('anki-templates').KanjiDictionaryEntry} - */ -function getKanjiDefinition(dictionaryEntry, context) { - const {character, dictionary, onyomi, kunyomi, definitions} = dictionaryEntry; - - let {url} = context; - if (typeof url !== 'string') { url = ''; } - - const stats = createCachedValue(getKanjiStats.bind(null, dictionaryEntry)); - const tags = createCachedValue(convertTags.bind(null, dictionaryEntry.tags)); - const frequencies = createCachedValue(getKanjiFrequencies.bind(null, dictionaryEntry)); - const frequencyHarmonic = createCachedValue(getFrequencyHarmonic.bind(null, dictionaryEntry)); - const frequencyAverage = createCachedValue(getFrequencyAverage.bind(null, dictionaryEntry)); - const cloze = createCachedValue(getCloze.bind(null, dictionaryEntry, context)); - - return { - type: 'kanji', - character, - dictionary, - onyomi, - kunyomi, - glossary: definitions, - get tags() { return getCachedValue(tags); }, - get stats() { return getCachedValue(stats); }, - get frequencies() { return getCachedValue(frequencies); }, - get frequencyHarmonic() { return getCachedValue(frequencyHarmonic); }, - get frequencyAverage() { return getCachedValue(frequencyAverage); }, - url, - get cloze() { return getCachedValue(cloze); } - }; -} - -/** - * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry - * @returns {import('anki-templates').KanjiStatGroups} - */ -function getKanjiStats(dictionaryEntry) { - /** @type {import('anki-templates').KanjiStatGroups} */ - const results = {}; - for (const [key, value] of Object.entries(dictionaryEntry.stats)) { - results[key] = value.map(convertKanjiStat); - } - return results; -} - -/** - * @param {import('dictionary').KanjiStat} kanjiStat - * @returns {import('anki-templates').KanjiStat} - */ -function convertKanjiStat({name, category, content, order, score, dictionary, value}) { - return { - name, - category, - notes: content, - order, - score, - dictionary, - value - }; -} - -/** - * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry - * @returns {import('anki-templates').KanjiFrequency[]} - */ -function getKanjiFrequencies(dictionaryEntry) { - /** @type {import('anki-templates').KanjiFrequency[]} */ - const results = []; - for (const {index, dictionary, dictionaryIndex, dictionaryPriority, character, frequency, displayValue} of dictionaryEntry.frequencies) { - results.push({ - index, - dictionary, - dictionaryOrder: { - index: dictionaryIndex, - priority: dictionaryPriority - }, - character, - frequency: displayValue !== null ? displayValue : frequency - }); - } - return results; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @param {import('anki-templates-internal').Context} context - * @param {import('settings').ResultOutputMode} resultOutputMode - * @returns {import('anki-templates').TermDictionaryEntry} - */ -function getTermDefinition(dictionaryEntry, context, resultOutputMode) { - /** @type {import('anki-templates').TermDictionaryEntryType} */ - let type = 'term'; - switch (resultOutputMode) { - case 'group': type = 'termGrouped'; break; - case 'merge': type = 'termMerged'; break; - } - - const {inflectionRuleChainCandidates, score, dictionaryIndex, dictionaryPriority, sourceTermExactMatchCount, definitions} = dictionaryEntry; - - let {url} = context; - if (typeof url !== 'string') { url = ''; } - - const primarySource = getPrimarySource(dictionaryEntry); - - const dictionaryNames = createCachedValue(getTermDictionaryNames.bind(null, dictionaryEntry)); - const commonInfo = createCachedValue(getTermDictionaryEntryCommonInfo.bind(null, dictionaryEntry, type)); - const termTags = createCachedValue(getTermTags.bind(null, dictionaryEntry, type)); - const expressions = createCachedValue(getTermExpressions.bind(null, dictionaryEntry)); - const frequencies = createCachedValue(getTermFrequencies.bind(null, dictionaryEntry)); - const frequencyHarmonic = createCachedValue(getFrequencyHarmonic.bind(null, dictionaryEntry)); - const frequencyAverage = createCachedValue(getFrequencyAverage.bind(null, dictionaryEntry)); - const pitches = createCachedValue(getTermPitches.bind(null, dictionaryEntry)); - const phoneticTranscriptions = createCachedValue(getTermPhoneticTranscriptions.bind(null, dictionaryEntry)); - const glossary = createCachedValue(getTermGlossaryArray.bind(null, dictionaryEntry, type)); - const cloze = createCachedValue(getCloze.bind(null, dictionaryEntry, context)); - const furiganaSegments = createCachedValue(getTermFuriganaSegments.bind(null, dictionaryEntry, type)); - const sequence = createCachedValue(getTermDictionaryEntrySequence.bind(null, dictionaryEntry)); - - return { - type, - id: (type === 'term' && definitions.length > 0 ? definitions[0].id : void 0), - source: (primarySource !== null ? primarySource.transformedText : null), - rawSource: (primarySource !== null ? primarySource.originalText : null), - sourceTerm: (type !== 'termMerged' ? (primarySource !== null ? primarySource.deinflectedText : null) : void 0), - inflectionRuleChainCandidates, - score, - isPrimary: (type === 'term' ? dictionaryEntry.isPrimary : void 0), - get sequence() { return getCachedValue(sequence); }, - get dictionary() { return getCachedValue(dictionaryNames)[0]; }, - dictionaryOrder: { - index: dictionaryIndex, - priority: dictionaryPriority - }, - get dictionaryNames() { return getCachedValue(dictionaryNames); }, - get expression() { - const {uniqueTerms} = getCachedValue(commonInfo); - return (type === 'term' || type === 'termGrouped' ? uniqueTerms[0] : uniqueTerms); - }, - get reading() { - const {uniqueReadings} = getCachedValue(commonInfo); - return (type === 'term' || type === 'termGrouped' ? uniqueReadings[0] : uniqueReadings); - }, - get expressions() { return getCachedValue(expressions); }, - get glossary() { return getCachedValue(glossary); }, - get definitionTags() { return type === 'term' ? getCachedValue(commonInfo).definitionTags : void 0; }, - get termTags() { return getCachedValue(termTags); }, - get definitions() { return getCachedValue(commonInfo).definitions; }, - get frequencies() { return getCachedValue(frequencies); }, - get frequencyHarmonic() { return getCachedValue(frequencyHarmonic); }, - get frequencyAverage() { return getCachedValue(frequencyAverage); }, - get pitches() { return getCachedValue(pitches); }, - get phoneticTranscriptions() { return getCachedValue(phoneticTranscriptions); }, - sourceTermExactMatchCount, - url, - get cloze() { return getCachedValue(cloze); }, - get furiganaSegments() { return getCachedValue(furiganaSegments); } - }; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @returns {string[]} - */ -function getTermDictionaryNames(dictionaryEntry) { - const dictionaryNames = new Set(); - for (const {dictionary} of dictionaryEntry.definitions) { - dictionaryNames.add(dictionary); - } - return [...dictionaryNames]; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @param {import('anki-templates').TermDictionaryEntryType} type - * @returns {import('anki-templates').TermDictionaryEntryCommonInfo} - */ -function getTermDictionaryEntryCommonInfo(dictionaryEntry, type) { - const merged = (type === 'termMerged'); - const hasDefinitions = (type !== 'term'); - - /** @type {Set} */ - const allTermsSet = new Set(); - /** @type {Set} */ - const allReadingsSet = new Set(); - for (const {term, reading} of dictionaryEntry.headwords) { - allTermsSet.add(term); - allReadingsSet.add(reading); - } - const uniqueTerms = [...allTermsSet]; - const uniqueReadings = [...allReadingsSet]; - - /** @type {import('anki-templates').TermDefinition[]} */ - const definitions = []; - /** @type {import('anki-templates').Tag[]} */ - const definitionTags = []; - for (const {tags, headwordIndices, entries, dictionary, sequences} of dictionaryEntry.definitions) { - const definitionTags2 = []; - for (const tag of tags) { - definitionTags.push(convertTag(tag)); - definitionTags2.push(convertTag(tag)); - } - if (!hasDefinitions) { continue; } - const only = merged ? getDisambiguations(dictionaryEntry.headwords, headwordIndices, allTermsSet, allReadingsSet) : void 0; - definitions.push({ - sequence: sequences[0], - dictionary, - glossary: entries, - definitionTags: definitionTags2, - only - }); - } - - return { - uniqueTerms, - uniqueReadings, - definitionTags, - definitions: hasDefinitions ? definitions : void 0 - }; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @returns {import('anki-templates').TermFrequency[]} - */ -function getTermFrequencies(dictionaryEntry) { - const results = []; - const {headwords} = dictionaryEntry; - for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, hasReading, frequency, displayValue} of dictionaryEntry.frequencies) { - const {term, reading} = headwords[headwordIndex]; - results.push({ - index: results.length, - expressionIndex: headwordIndex, - dictionary, - dictionaryOrder: { - index: dictionaryIndex, - priority: dictionaryPriority - }, - expression: term, - reading, - hasReading, - frequency: displayValue !== null ? displayValue : frequency - }); - } - return results; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @returns {import('anki-templates').TermPitchAccent[]} - */ -function getTermPitches(dictionaryEntry) { - const results = []; - const {headwords} = dictionaryEntry; - for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations} of dictionaryEntry.pronunciations) { - const {term, reading} = headwords[headwordIndex]; - const pitches = getPronunciationsOfType(pronunciations, 'pitch-accent'); - const cachedPitches = createCachedValue(getTermPitchesInner.bind(null, pitches)); - results.push({ - index: results.length, - expressionIndex: headwordIndex, - dictionary, - dictionaryOrder: { - index: dictionaryIndex, - priority: dictionaryPriority - }, - expression: term, - reading, - get pitches() { return getCachedValue(cachedPitches); } - }); - } - return results; -} - -/** - * @param {import('dictionary').PitchAccent[]} pitches - * @returns {import('anki-templates').PitchAccent[]} - */ -function getTermPitchesInner(pitches) { - const results = []; - for (const {position, tags} of pitches) { - const cachedTags = createCachedValue(convertTags.bind(null, tags)); - results.push({ - position, - get tags() { return getCachedValue(cachedTags); } - }); - } - return results; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @returns {import('anki-templates').TermPhoneticTranscription[]} - */ -function getTermPhoneticTranscriptions(dictionaryEntry) { - const results = []; - const {headwords} = dictionaryEntry; - for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations} of dictionaryEntry.pronunciations) { - const {term, reading} = headwords[headwordIndex]; - const phoneticTranscriptions = getPronunciationsOfType(pronunciations, 'phonetic-transcription'); - const termPhoneticTranscriptions = getTermPhoneticTranscriptionsInner(phoneticTranscriptions); - results.push({ - index: results.length, - expressionIndex: headwordIndex, - dictionary, - dictionaryOrder: { - index: dictionaryIndex, - priority: dictionaryPriority - }, - expression: term, - reading, - get phoneticTranscriptions() { return termPhoneticTranscriptions; } - }); - } - - return results; -} - -/** - * @param {import('dictionary').PhoneticTranscription[]} phoneticTranscriptions - * @returns {import('anki-templates').PhoneticTranscription[]} - */ -function getTermPhoneticTranscriptionsInner(phoneticTranscriptions) { - const results = []; - for (const {ipa, tags} of phoneticTranscriptions) { - const cachedTags = createCachedValue(convertTags.bind(null, tags)); - results.push({ - ipa, - get tags() { return getCachedValue(cachedTags); } - }); - } - return results; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @returns {import('anki-templates').TermHeadword[]} - */ -function getTermExpressions(dictionaryEntry) { - const results = []; - const {headwords} = dictionaryEntry; - for (let i = 0, ii = headwords.length; i < ii; ++i) { - const {term, reading, tags, sources: [{deinflectedText}], wordClasses} = headwords[i]; - const termTags = createCachedValue(convertTags.bind(null, tags)); - const frequencies = createCachedValue(getTermExpressionFrequencies.bind(null, dictionaryEntry, i)); - const pitches = createCachedValue(getTermExpressionPitches.bind(null, dictionaryEntry, i)); - const termFrequency = createCachedValue(getTermExpressionTermFrequency.bind(null, termTags)); - const furiganaSegments = createCachedValue(getTermHeadwordFuriganaSegments.bind(null, term, reading)); - const item = { - sourceTerm: deinflectedText, - expression: term, - reading, - get termTags() { return getCachedValue(termTags); }, - get frequencies() { return getCachedValue(frequencies); }, - get pitches() { return getCachedValue(pitches); }, - get furiganaSegments() { return getCachedValue(furiganaSegments); }, - get termFrequency() { return getCachedValue(termFrequency); }, - wordClasses - }; - results.push(item); - } - return results; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @param {number} i - * @returns {import('anki-templates').TermFrequency[]} - */ -function getTermExpressionFrequencies(dictionaryEntry, i) { - const results = []; - const {headwords, frequencies} = dictionaryEntry; - for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, hasReading, frequency, displayValue} of frequencies) { - if (headwordIndex !== i) { continue; } - const {term, reading} = headwords[headwordIndex]; - results.push({ - index: results.length, - expressionIndex: headwordIndex, - dictionary, - dictionaryOrder: { - index: dictionaryIndex, - priority: dictionaryPriority - }, - expression: term, - reading, - hasReading, - frequency: displayValue !== null ? displayValue : frequency - }); - } - return results; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @param {number} i - * @returns {import('anki-templates').TermPitchAccent[]} - */ -function getTermExpressionPitches(dictionaryEntry, i) { - const results = []; - const {headwords, pronunciations: termPronunciations} = dictionaryEntry; - for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations} of termPronunciations) { - if (headwordIndex !== i) { continue; } - const {term, reading} = headwords[headwordIndex]; - const pitches = getPronunciationsOfType(pronunciations, 'pitch-accent'); - const cachedPitches = createCachedValue(getTermPitchesInner.bind(null, pitches)); - results.push({ - index: results.length, - expressionIndex: headwordIndex, - dictionary, - dictionaryOrder: { - index: dictionaryIndex, - priority: dictionaryPriority - }, - expression: term, - reading, - get pitches() { return getCachedValue(cachedPitches); } - }); - } - return results; -} - -/** - * @param {import('anki-templates-internal').CachedValue} cachedTermTags - * @returns {import('anki-templates').TermFrequencyType} - */ -function getTermExpressionTermFrequency(cachedTermTags) { - const termTags = getCachedValue(cachedTermTags); - return getTermFrequency(termTags); -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @param {import('anki-templates').TermDictionaryEntryType} type - * @returns {import('dictionary-data').TermGlossary[]|undefined} - */ -function getTermGlossaryArray(dictionaryEntry, type) { - if (type === 'term') { - const results = []; - for (const {entries} of dictionaryEntry.definitions) { - results.push(...entries); - } - return results; - } - return void 0; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @param {import('anki-templates').TermDictionaryEntryType} type - * @returns {import('anki-templates').Tag[]|undefined} - */ -function getTermTags(dictionaryEntry, type) { - if (type !== 'termMerged') { - const results = []; - for (const {tag} of groupTermTags(dictionaryEntry)) { - results.push(convertTag(tag)); - } - return results; - } - return void 0; -} - -/** - * @param {import('dictionary').Tag[]} tags - * @returns {import('anki-templates').Tag[]} - */ -function convertTags(tags) { - const results = []; - for (const tag of tags) { - results.push(convertTag(tag)); - } - return results; -} - -/** - * @param {import('dictionary').Tag} tag - * @returns {import('anki-templates').Tag} - */ -function convertTag({name, category, content, order, score, dictionaries, redundant}) { - return { - name, - category, - notes: (content.length > 0 ? content[0] : ''), - order, - score, - dictionary: (dictionaries.length > 0 ? dictionaries[0] : ''), - redundant - }; -} - -/** - * @param {import('dictionary').Tag[]} tags - * @returns {import('anki-templates').PitchTag[]} - */ -function convertPitchTags(tags) { - const results = []; - for (const tag of tags) { - results.push(convertPitchTag(tag)); - } - return results; -} - -/** - * @param {import('dictionary').Tag} tag - * @returns {import('anki-templates').PitchTag} - */ -function convertPitchTag({name, category, content, order, score, dictionaries, redundant}) { - return { - name, - category, - order, - score, - content: [...content], - dictionaries: [...dictionaries], - redundant - }; -} - -/** - * @param {import('dictionary').DictionaryEntry} dictionaryEntry - * @param {import('anki-templates-internal').Context} context - * @returns {import('anki-templates').Cloze} - */ -function getCloze(dictionaryEntry, context) { - let originalText = ''; - let term = ''; - let reading = ''; - switch (dictionaryEntry.type) { - case 'term': - { - term = dictionaryEntry.headwords[0].term; - reading = dictionaryEntry.headwords[0].reading; - const primarySource = getPrimarySource(dictionaryEntry); - if (primarySource !== null) { originalText = primarySource.originalText; } - } - break; - case 'kanji': - originalText = dictionaryEntry.character; - break; - } - - const {sentence} = context; - let text; - let offset; - if (typeof sentence === 'object' && sentence !== null) { - ({text, offset} = sentence); - } - if (typeof text !== 'string') { text = ''; } - if (typeof offset !== 'number') { offset = 0; } - - const textSegments = []; - for (const {text: text2, reading: reading2} of distributeFuriganaInflected(term, reading, text.substring(offset, offset + originalText.length))) { - textSegments.push(reading2.length > 0 ? reading2 : text2); - } - - return { - sentence: text, - prefix: text.substring(0, offset), - body: text.substring(offset, offset + originalText.length), - bodyKana: textSegments.join(''), - suffix: text.substring(offset + originalText.length) - }; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @param {import('anki-templates').TermDictionaryEntryType} type - * @returns {import('anki-templates').FuriganaSegment[]|undefined} - */ -function getTermFuriganaSegments(dictionaryEntry, type) { - if (type === 'term') { - for (const {term, reading} of dictionaryEntry.headwords) { - return getTermHeadwordFuriganaSegments(term, reading); - } - } - return void 0; -} - -/** - * @param {string} term - * @param {string} reading - * @returns {import('anki-templates').FuriganaSegment[]} - */ -function getTermHeadwordFuriganaSegments(term, reading) { - /** @type {import('anki-templates').FuriganaSegment[]} */ - const result = []; - for (const {text, reading: reading2} of distributeFurigana(term, reading)) { - result.push({text, furigana: reading2}); - } - return result; -} - -/** - * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @returns {number} - */ -function getTermDictionaryEntrySequence(dictionaryEntry) { - let hasSequence = false; - let mainSequence = -1; - if (!dictionaryEntry.isPrimary) { return mainSequence; } - for (const {sequences} of dictionaryEntry.definitions) { - const sequence = sequences[0]; - if (!hasSequence) { - mainSequence = sequence; - hasSequence = true; - if (mainSequence === -1) { break; } - } else if (mainSequence !== sequence) { - mainSequence = -1; - break; - } - } - return mainSequence; -} diff --git a/ext/js/data/sandbox/array-buffer-util.js b/ext/js/data/sandbox/array-buffer-util.js deleted file mode 100644 index 487fcd24..00000000 --- a/ext/js/data/sandbox/array-buffer-util.js +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (C) 2023-2024 Yomitan Authors - * Copyright (C) 2021-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 . - */ - -/** - * Decodes the contents of an ArrayBuffer using UTF8. - * @param {ArrayBuffer} arrayBuffer The input ArrayBuffer. - * @returns {string} A UTF8-decoded string. - */ -export function arrayBufferUtf8Decode(arrayBuffer) { - try { - return new TextDecoder('utf-8').decode(arrayBuffer); - } catch (e) { - return decodeURIComponent(escape(arrayBufferToBinaryString(arrayBuffer))); - } -} - -/** - * Converts the contents of an ArrayBuffer to a base64 string. - * @param {ArrayBuffer} arrayBuffer The input ArrayBuffer. - * @returns {string} A base64 string representing the binary content. - */ -export function arrayBufferToBase64(arrayBuffer) { - return btoa(arrayBufferToBinaryString(arrayBuffer)); -} - -/** - * Converts the contents of an ArrayBuffer to a binary string. - * @param {ArrayBuffer} arrayBuffer The input ArrayBuffer. - * @returns {string} A string representing the binary content. - */ -export function arrayBufferToBinaryString(arrayBuffer) { - const bytes = new Uint8Array(arrayBuffer); - try { - return String.fromCharCode(...bytes); - } catch (e) { - let binary = ''; - for (let i = 0, ii = bytes.byteLength; i < ii; ++i) { - binary += String.fromCharCode(bytes[i]); - } - return binary; - } -} - -/** - * Converts a base64 string to an ArrayBuffer. - * @param {string} content The binary content string encoded in base64. - * @returns {ArrayBuffer} A new `ArrayBuffer` object corresponding to the specified content. - */ -export function base64ToArrayBuffer(content) { - const binaryContent = atob(content); - const length = binaryContent.length; - const array = new Uint8Array(length); - for (let i = 0; i < length; ++i) { - array[i] = binaryContent.charCodeAt(i); - } - return array.buffer; -} diff --git a/ext/js/data/sandbox/string-util.js b/ext/js/data/sandbox/string-util.js deleted file mode 100644 index 45e52f08..00000000 --- a/ext/js/data/sandbox/string-util.js +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (C) 2023-2024 Yomitan Authors - * Copyright (C) 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 . - */ - -/** - * Reads code points from a string in the forward direction. - * @param {string} text The text to read the code points from. - * @param {number} position The index of the first character to read. - * @param {number} count The number of code points to read. - * @returns {string} The code points from the string. - */ -export function readCodePointsForward(text, position, count) { - const textLength = text.length; - let result = ''; - for (; count > 0; --count) { - const char = text[position]; - result += char; - if (++position >= textLength) { break; } - const charCode = char.charCodeAt(0); - if (charCode >= 0xd800 && charCode < 0xdc00) { // charCode is a high surrogate code - const char2 = text[position]; - const charCode2 = char2.charCodeAt(0); - if (charCode2 >= 0xdc00 && charCode2 < 0xe000) { // charCode2 is a low surrogate code - result += char2; - if (++position >= textLength) { break; } - } - } - } - return result; -} - -/** - * Reads code points from a string in the backward direction. - * @param {string} text The text to read the code points from. - * @param {number} position The index of the first character to read. - * @param {number} count The number of code points to read. - * @returns {string} The code points from the string. - */ -export function readCodePointsBackward(text, position, count) { - let result = ''; - for (; count > 0; --count) { - const char = text[position]; - result = char + result; - if (--position < 0) { break; } - const charCode = char.charCodeAt(0); - if (charCode >= 0xdc00 && charCode < 0xe000) { // charCode is a low surrogate code - const char2 = text[position]; - const charCode2 = char2.charCodeAt(0); - if (charCode2 >= 0xd800 && charCode2 < 0xdc00) { // charCode2 is a high surrogate code - result = char2 + result; - if (--position < 0) { break; } - } - } - } - return result; -} diff --git a/ext/js/data/string-util.js b/ext/js/data/string-util.js new file mode 100644 index 00000000..45e52f08 --- /dev/null +++ b/ext/js/data/string-util.js @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 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 . + */ + +/** + * Reads code points from a string in the forward direction. + * @param {string} text The text to read the code points from. + * @param {number} position The index of the first character to read. + * @param {number} count The number of code points to read. + * @returns {string} The code points from the string. + */ +export function readCodePointsForward(text, position, count) { + const textLength = text.length; + let result = ''; + for (; count > 0; --count) { + const char = text[position]; + result += char; + if (++position >= textLength) { break; } + const charCode = char.charCodeAt(0); + if (charCode >= 0xd800 && charCode < 0xdc00) { // charCode is a high surrogate code + const char2 = text[position]; + const charCode2 = char2.charCodeAt(0); + if (charCode2 >= 0xdc00 && charCode2 < 0xe000) { // charCode2 is a low surrogate code + result += char2; + if (++position >= textLength) { break; } + } + } + } + return result; +} + +/** + * Reads code points from a string in the backward direction. + * @param {string} text The text to read the code points from. + * @param {number} position The index of the first character to read. + * @param {number} count The number of code points to read. + * @returns {string} The code points from the string. + */ +export function readCodePointsBackward(text, position, count) { + let result = ''; + for (; count > 0; --count) { + const char = text[position]; + result = char + result; + if (--position < 0) { break; } + const charCode = char.charCodeAt(0); + if (charCode >= 0xdc00 && charCode < 0xe000) { // charCode is a low surrogate code + const char2 = text[position]; + const charCode2 = char2.charCodeAt(0); + if (charCode2 >= 0xd800 && charCode2 < 0xdc00) { // charCode2 is a high surrogate code + result = char2 + result; + if (--position < 0) { break; } + } + } + } + return result; +} diff --git a/ext/js/display/display-content-manager.js b/ext/js/display/display-content-manager.js index c61c3aca..28fdc709 100644 --- a/ext/js/display/display-content-manager.js +++ b/ext/js/display/display-content-manager.js @@ -17,7 +17,7 @@ */ import {EventListenerCollection} from '../core/event-listener-collection.js'; -import {base64ToArrayBuffer} from '../data/sandbox/array-buffer-util.js'; +import {base64ToArrayBuffer} from '../data/array-buffer-util.js'; /** * The content manager which is used when generating HTML display content. diff --git a/ext/js/display/display-generator.js b/ext/js/display/display-generator.js index 0caf4d71..8a388a38 100644 --- a/ext/js/display/display-generator.js +++ b/ext/js/display/display-generator.js @@ -21,8 +21,8 @@ import {getDisambiguations, getGroupedPronunciations, getTermFrequency, groupKan import {HtmlTemplateCollection} from '../dom/html-template-collection.js'; import {distributeFurigana, getKanaMorae, getPitchCategory, isCodePointKanji} from '../language/ja/japanese.js'; import {getLanguageFromText} from '../language/text-utilities.js'; -import {createPronunciationDownstepPosition, createPronunciationGraph, createPronunciationText} from './sandbox/pronunciation-generator.js'; -import {StructuredContentGenerator} from './sandbox/structured-content-generator.js'; +import {createPronunciationDownstepPosition, createPronunciationGraph, createPronunciationText} from './pronunciation-generator.js'; +import {StructuredContentGenerator} from './structured-content-generator.js'; export class DisplayGenerator { /** diff --git a/ext/js/display/pronunciation-generator.js b/ext/js/display/pronunciation-generator.js new file mode 100644 index 00000000..2c03eb94 --- /dev/null +++ b/ext/js/display/pronunciation-generator.js @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 2021-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 . + */ + +import {getKanaDiacriticInfo, isMoraPitchHigh} from '../language/ja/japanese.js'; + +/** + * @param {string[]} morae + * @param {number} downstepPosition + * @param {number[]} nasalPositions + * @param {number[]} devoicePositions + * @returns {HTMLSpanElement} + */ +export function createPronunciationText(morae, downstepPosition, nasalPositions, devoicePositions) { + const nasalPositionsSet = nasalPositions.length > 0 ? new Set(nasalPositions) : null; + const devoicePositionsSet = devoicePositions.length > 0 ? new Set(devoicePositions) : null; + const container = document.createElement('span'); + container.className = 'pronunciation-text'; + for (let i = 0, ii = morae.length; i < ii; ++i) { + const i1 = i + 1; + const mora = morae[i]; + const highPitch = isMoraPitchHigh(i, downstepPosition); + const highPitchNext = isMoraPitchHigh(i1, downstepPosition); + const nasal = nasalPositionsSet !== null && nasalPositionsSet.has(i1); + const devoice = devoicePositionsSet !== null && devoicePositionsSet.has(i1); + + const n1 = document.createElement('span'); + n1.className = 'pronunciation-mora'; + n1.dataset.position = `${i}`; + n1.dataset.pitch = highPitch ? 'high' : 'low'; + n1.dataset.pitchNext = highPitchNext ? 'high' : 'low'; + + const characterNodes = []; + for (const character of mora) { + const n2 = document.createElement('span'); + n2.className = 'pronunciation-character'; + n2.textContent = character; + n1.appendChild(n2); + characterNodes.push(n2); + } + + if (devoice) { + n1.dataset.devoice = 'true'; + const n3 = document.createElement('span'); + n3.className = 'pronunciation-devoice-indicator'; + n1.appendChild(n3); + } + if (nasal && characterNodes.length > 0) { + n1.dataset.nasal = 'true'; + + const group = document.createElement('span'); + group.className = 'pronunciation-character-group'; + + const n2 = characterNodes[0]; + const character = /** @type {string} */ (n2.textContent); + + const characterInfo = getKanaDiacriticInfo(character); + if (characterInfo !== null) { + n1.dataset.originalText = mora; + n2.dataset.originalText = character; + n2.textContent = characterInfo.character; + } + + let n3 = document.createElement('span'); + n3.className = 'pronunciation-nasal-diacritic'; + n3.textContent = '\u309a'; // Combining handakuten + group.appendChild(n3); + + n3 = document.createElement('span'); + n3.className = 'pronunciation-nasal-indicator'; + group.appendChild(n3); + + /** @type {ParentNode} */ (n2.parentNode).replaceChild(group, n2); + group.insertBefore(n2, group.firstChild); + } + + const line = document.createElement('span'); + line.className = 'pronunciation-mora-line'; + n1.appendChild(line); + + container.appendChild(n1); + } + return container; +} + +/** + * @param {string[]} morae + * @param {number} downstepPosition + * @returns {SVGSVGElement} + */ +export function createPronunciationGraph(morae, downstepPosition) { + const ii = morae.length; + + const svgns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(svgns, 'svg'); + svg.setAttribute('xmlns', svgns); + svg.setAttribute('class', 'pronunciation-graph'); + svg.setAttribute('focusable', 'false'); + svg.setAttribute('viewBox', `0 0 ${50 * (ii + 1)} 100`); + + if (ii <= 0) { return svg; } + + const path1 = document.createElementNS(svgns, 'path'); + svg.appendChild(path1); + + const path2 = document.createElementNS(svgns, 'path'); + svg.appendChild(path2); + + const pathPoints = []; + for (let i = 0; i < ii; ++i) { + const highPitch = isMoraPitchHigh(i, downstepPosition); + const highPitchNext = isMoraPitchHigh(i + 1, downstepPosition); + const x = i * 50 + 25; + const y = highPitch ? 25 : 75; + if (highPitch && !highPitchNext) { + addGraphDotDownstep(svg, svgns, x, y); + } else { + addGraphDot(svg, svgns, x, y); + } + pathPoints.push(`${x} ${y}`); + } + + path1.setAttribute('class', 'pronunciation-graph-line'); + path1.setAttribute('d', `M${pathPoints.join(' L')}`); + + pathPoints.splice(0, ii - 1); + { + const highPitch = isMoraPitchHigh(ii, downstepPosition); + const x = ii * 50 + 25; + const y = highPitch ? 25 : 75; + addGraphTriangle(svg, svgns, x, y); + pathPoints.push(`${x} ${y}`); + } + + path2.setAttribute('class', 'pronunciation-graph-line-tail'); + path2.setAttribute('d', `M${pathPoints.join(' L')}`); + + return svg; +} + +/** + * @param {number} downstepPosition + * @returns {HTMLSpanElement} + */ +export function createPronunciationDownstepPosition(downstepPosition) { + const downstepPositionString = `${downstepPosition}`; + + const n1 = document.createElement('span'); + n1.className = 'pronunciation-downstep-notation'; + n1.dataset.downstepPosition = downstepPositionString; + + let n2 = document.createElement('span'); + n2.className = 'pronunciation-downstep-notation-prefix'; + n2.textContent = '['; + n1.appendChild(n2); + + n2 = document.createElement('span'); + n2.className = 'pronunciation-downstep-notation-number'; + n2.textContent = downstepPositionString; + n1.appendChild(n2); + + n2 = document.createElement('span'); + n2.className = 'pronunciation-downstep-notation-suffix'; + n2.textContent = ']'; + n1.appendChild(n2); + + return n1; +} + +// Private + +/** + * @param {Element} container + * @param {string} svgns + * @param {number} x + * @param {number} y + */ +function addGraphDot(container, svgns, x, y) { + container.appendChild(createGraphCircle(svgns, 'pronunciation-graph-dot', x, y, '15')); +} + +/** + * @param {Element} container + * @param {string} svgns + * @param {number} x + * @param {number} y + */ +function addGraphDotDownstep(container, svgns, x, y) { + container.appendChild(createGraphCircle(svgns, 'pronunciation-graph-dot-downstep1', x, y, '15')); + container.appendChild(createGraphCircle(svgns, 'pronunciation-graph-dot-downstep2', x, y, '5')); +} + +/** + * @param {Element} container + * @param {string} svgns + * @param {number} x + * @param {number} y + */ +function addGraphTriangle(container, svgns, x, y) { + const node = document.createElementNS(svgns, 'path'); + node.setAttribute('class', 'pronunciation-graph-triangle'); + node.setAttribute('d', 'M0 13 L15 -13 L-15 -13 Z'); + node.setAttribute('transform', `translate(${x},${y})`); + container.appendChild(node); +} + +/** + * @param {string} svgns + * @param {string} className + * @param {number} x + * @param {number} y + * @param {string} radius + * @returns {Element} + */ +function createGraphCircle(svgns, className, x, y, radius) { + const node = document.createElementNS(svgns, 'circle'); + node.setAttribute('class', className); + node.setAttribute('cx', `${x}`); + node.setAttribute('cy', `${y}`); + node.setAttribute('r', radius); + return node; +} diff --git a/ext/js/display/sandbox/pronunciation-generator.js b/ext/js/display/sandbox/pronunciation-generator.js deleted file mode 100644 index f28520be..00000000 --- a/ext/js/display/sandbox/pronunciation-generator.js +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright (C) 2023-2024 Yomitan Authors - * Copyright (C) 2021-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 . - */ - -import {getKanaDiacriticInfo, isMoraPitchHigh} from '../../language/ja/japanese.js'; - -/** - * @param {string[]} morae - * @param {number} downstepPosition - * @param {number[]} nasalPositions - * @param {number[]} devoicePositions - * @returns {HTMLSpanElement} - */ -export function createPronunciationText(morae, downstepPosition, nasalPositions, devoicePositions) { - const nasalPositionsSet = nasalPositions.length > 0 ? new Set(nasalPositions) : null; - const devoicePositionsSet = devoicePositions.length > 0 ? new Set(devoicePositions) : null; - const container = document.createElement('span'); - container.className = 'pronunciation-text'; - for (let i = 0, ii = morae.length; i < ii; ++i) { - const i1 = i + 1; - const mora = morae[i]; - const highPitch = isMoraPitchHigh(i, downstepPosition); - const highPitchNext = isMoraPitchHigh(i1, downstepPosition); - const nasal = nasalPositionsSet !== null && nasalPositionsSet.has(i1); - const devoice = devoicePositionsSet !== null && devoicePositionsSet.has(i1); - - const n1 = document.createElement('span'); - n1.className = 'pronunciation-mora'; - n1.dataset.position = `${i}`; - n1.dataset.pitch = highPitch ? 'high' : 'low'; - n1.dataset.pitchNext = highPitchNext ? 'high' : 'low'; - - const characterNodes = []; - for (const character of mora) { - const n2 = document.createElement('span'); - n2.className = 'pronunciation-character'; - n2.textContent = character; - n1.appendChild(n2); - characterNodes.push(n2); - } - - if (devoice) { - n1.dataset.devoice = 'true'; - const n3 = document.createElement('span'); - n3.className = 'pronunciation-devoice-indicator'; - n1.appendChild(n3); - } - if (nasal && characterNodes.length > 0) { - n1.dataset.nasal = 'true'; - - const group = document.createElement('span'); - group.className = 'pronunciation-character-group'; - - const n2 = characterNodes[0]; - const character = /** @type {string} */ (n2.textContent); - - const characterInfo = getKanaDiacriticInfo(character); - if (characterInfo !== null) { - n1.dataset.originalText = mora; - n2.dataset.originalText = character; - n2.textContent = characterInfo.character; - } - - let n3 = document.createElement('span'); - n3.className = 'pronunciation-nasal-diacritic'; - n3.textContent = '\u309a'; // Combining handakuten - group.appendChild(n3); - - n3 = document.createElement('span'); - n3.className = 'pronunciation-nasal-indicator'; - group.appendChild(n3); - - /** @type {ParentNode} */ (n2.parentNode).replaceChild(group, n2); - group.insertBefore(n2, group.firstChild); - } - - const line = document.createElement('span'); - line.className = 'pronunciation-mora-line'; - n1.appendChild(line); - - container.appendChild(n1); - } - return container; -} - -/** - * @param {string[]} morae - * @param {number} downstepPosition - * @returns {SVGSVGElement} - */ -export function createPronunciationGraph(morae, downstepPosition) { - const ii = morae.length; - - const svgns = 'http://www.w3.org/2000/svg'; - const svg = document.createElementNS(svgns, 'svg'); - svg.setAttribute('xmlns', svgns); - svg.setAttribute('class', 'pronunciation-graph'); - svg.setAttribute('focusable', 'false'); - svg.setAttribute('viewBox', `0 0 ${50 * (ii + 1)} 100`); - - if (ii <= 0) { return svg; } - - const path1 = document.createElementNS(svgns, 'path'); - svg.appendChild(path1); - - const path2 = document.createElementNS(svgns, 'path'); - svg.appendChild(path2); - - const pathPoints = []; - for (let i = 0; i < ii; ++i) { - const highPitch = isMoraPitchHigh(i, downstepPosition); - const highPitchNext = isMoraPitchHigh(i + 1, downstepPosition); - const x = i * 50 + 25; - const y = highPitch ? 25 : 75; - if (highPitch && !highPitchNext) { - addGraphDotDownstep(svg, svgns, x, y); - } else { - addGraphDot(svg, svgns, x, y); - } - pathPoints.push(`${x} ${y}`); - } - - path1.setAttribute('class', 'pronunciation-graph-line'); - path1.setAttribute('d', `M${pathPoints.join(' L')}`); - - pathPoints.splice(0, ii - 1); - { - const highPitch = isMoraPitchHigh(ii, downstepPosition); - const x = ii * 50 + 25; - const y = highPitch ? 25 : 75; - addGraphTriangle(svg, svgns, x, y); - pathPoints.push(`${x} ${y}`); - } - - path2.setAttribute('class', 'pronunciation-graph-line-tail'); - path2.setAttribute('d', `M${pathPoints.join(' L')}`); - - return svg; -} - -/** - * @param {number} downstepPosition - * @returns {HTMLSpanElement} - */ -export function createPronunciationDownstepPosition(downstepPosition) { - const downstepPositionString = `${downstepPosition}`; - - const n1 = document.createElement('span'); - n1.className = 'pronunciation-downstep-notation'; - n1.dataset.downstepPosition = downstepPositionString; - - let n2 = document.createElement('span'); - n2.className = 'pronunciation-downstep-notation-prefix'; - n2.textContent = '['; - n1.appendChild(n2); - - n2 = document.createElement('span'); - n2.className = 'pronunciation-downstep-notation-number'; - n2.textContent = downstepPositionString; - n1.appendChild(n2); - - n2 = document.createElement('span'); - n2.className = 'pronunciation-downstep-notation-suffix'; - n2.textContent = ']'; - n1.appendChild(n2); - - return n1; -} - -// Private - -/** - * @param {Element} container - * @param {string} svgns - * @param {number} x - * @param {number} y - */ -function addGraphDot(container, svgns, x, y) { - container.appendChild(createGraphCircle(svgns, 'pronunciation-graph-dot', x, y, '15')); -} - -/** - * @param {Element} container - * @param {string} svgns - * @param {number} x - * @param {number} y - */ -function addGraphDotDownstep(container, svgns, x, y) { - container.appendChild(createGraphCircle(svgns, 'pronunciation-graph-dot-downstep1', x, y, '15')); - container.appendChild(createGraphCircle(svgns, 'pronunciation-graph-dot-downstep2', x, y, '5')); -} - -/** - * @param {Element} container - * @param {string} svgns - * @param {number} x - * @param {number} y - */ -function addGraphTriangle(container, svgns, x, y) { - const node = document.createElementNS(svgns, 'path'); - node.setAttribute('class', 'pronunciation-graph-triangle'); - node.setAttribute('d', 'M0 13 L15 -13 L-15 -13 Z'); - node.setAttribute('transform', `translate(${x},${y})`); - container.appendChild(node); -} - -/** - * @param {string} svgns - * @param {string} className - * @param {number} x - * @param {number} y - * @param {string} radius - * @returns {Element} - */ -function createGraphCircle(svgns, className, x, y, radius) { - const node = document.createElementNS(svgns, 'circle'); - node.setAttribute('class', className); - node.setAttribute('cx', `${x}`); - node.setAttribute('cy', `${y}`); - node.setAttribute('r', radius); - return node; -} diff --git a/ext/js/display/sandbox/structured-content-generator.js b/ext/js/display/sandbox/structured-content-generator.js deleted file mode 100644 index 90a47158..00000000 --- a/ext/js/display/sandbox/structured-content-generator.js +++ /dev/null @@ -1,464 +0,0 @@ -/* - * Copyright (C) 2023-2024 Yomitan Authors - * Copyright (C) 2021-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 . - */ - -import {getLanguageFromText} from '../../language/text-utilities.js'; - -export class StructuredContentGenerator { - /** - * @param {import('../../display/display-content-manager.js').DisplayContentManager|import('../../templates/sandbox/anki-template-renderer-content-manager.js').AnkiTemplateRendererContentManager} contentManager - * @param {Document} document - */ - constructor(contentManager, document) { - /** @type {import('../../display/display-content-manager.js').DisplayContentManager|import('../../templates/sandbox/anki-template-renderer-content-manager.js').AnkiTemplateRendererContentManager} */ - this._contentManager = contentManager; - /** @type {Document} */ - this._document = document; - } - - /** - * @param {HTMLElement} node - * @param {import('structured-content').Content} content - * @param {string} dictionary - */ - appendStructuredContent(node, content, dictionary) { - node.classList.add('structured-content'); - this._appendStructuredContent(node, content, dictionary, null); - } - - /** - * @param {import('structured-content').Content} content - * @param {string} dictionary - * @returns {HTMLElement} - */ - createStructuredContent(content, dictionary) { - const node = this._createElement('span', 'structured-content'); - this._appendStructuredContent(node, content, dictionary, null); - return node; - } - - /** - * @param {import('structured-content').ImageElement|import('dictionary-data').TermGlossaryImage} data - * @param {string} dictionary - * @returns {HTMLAnchorElement} - */ - createDefinitionImage(data, dictionary) { - const { - path, - width = 100, - height = 100, - preferredWidth, - preferredHeight, - title, - alt, - pixelated, - imageRendering, - appearance, - background, - collapsed, - collapsible, - verticalAlign, - border, - borderRadius, - sizeUnits - } = data; - - const hasPreferredWidth = (typeof preferredWidth === 'number'); - const hasPreferredHeight = (typeof preferredHeight === 'number'); - const invAspectRatio = ( - hasPreferredWidth && hasPreferredHeight ? - preferredHeight / preferredWidth : - height / width - ); - const usedWidth = ( - hasPreferredWidth ? - preferredWidth : - (hasPreferredHeight ? preferredHeight / invAspectRatio : width) - ); - - const node = /** @type {HTMLAnchorElement} */ (this._createElement('a', 'gloss-image-link')); - node.target = '_blank'; - node.rel = 'noreferrer noopener'; - - const imageContainer = this._createElement('span', 'gloss-image-container'); - node.appendChild(imageContainer); - - const aspectRatioSizer = this._createElement('span', 'gloss-image-sizer'); - imageContainer.appendChild(aspectRatioSizer); - - const imageBackground = this._createElement('span', 'gloss-image-background'); - imageContainer.appendChild(imageBackground); - - const image = /** @type {HTMLImageElement} */ (this._createElement('img', 'gloss-image')); - image.alt = typeof alt === 'string' ? alt : ''; - imageContainer.appendChild(image); - - const overlay = this._createElement('span', 'gloss-image-container-overlay'); - imageContainer.appendChild(overlay); - - const linkText = this._createElement('span', 'gloss-image-link-text'); - linkText.textContent = 'Image'; - node.appendChild(linkText); - - node.dataset.path = path; - node.dataset.dictionary = dictionary; - node.dataset.imageLoadState = 'not-loaded'; - node.dataset.hasAspectRatio = 'true'; - node.dataset.imageRendering = typeof imageRendering === 'string' ? imageRendering : (pixelated ? 'pixelated' : 'auto'); - node.dataset.appearance = typeof appearance === 'string' ? appearance : 'auto'; - node.dataset.background = typeof background === 'boolean' ? `${background}` : 'true'; - node.dataset.collapsed = typeof collapsed === 'boolean' ? `${collapsed}` : 'false'; - node.dataset.collapsible = typeof collapsible === 'boolean' ? `${collapsible}` : 'true'; - if (typeof verticalAlign === 'string') { - node.dataset.verticalAlign = verticalAlign; - } - if (typeof sizeUnits === 'string' && (hasPreferredWidth || hasPreferredHeight)) { - node.dataset.sizeUnits = sizeUnits; - } - - imageContainer.style.width = `${usedWidth}em`; - if (typeof border === 'string') { imageContainer.style.border = border; } - if (typeof borderRadius === 'string') { imageContainer.style.borderRadius = borderRadius; } - if (typeof title === 'string') { - imageContainer.title = title; - } - - aspectRatioSizer.style.paddingTop = `${invAspectRatio * 100}%`; - - if (this._contentManager !== null) { - this._contentManager.loadMedia( - path, - dictionary, - (url) => this._setImageData(node, image, imageBackground, url, false), - () => this._setImageData(node, image, imageBackground, null, true) - ); - } - - return node; - } - - // Private - - /** - * @param {HTMLElement} container - * @param {import('structured-content').Content|undefined} content - * @param {string} dictionary - * @param {?string} language - */ - _appendStructuredContent(container, content, dictionary, language) { - if (typeof content === 'string') { - if (content.length > 0) { - container.appendChild(this._createTextNode(content)); - if (language === null) { - const language2 = getLanguageFromText(content); - if (language2 !== null) { - container.lang = language2; - } - } - } - return; - } - if (!(typeof content === 'object' && content !== null)) { - return; - } - if (Array.isArray(content)) { - for (const item of content) { - this._appendStructuredContent(container, item, dictionary, language); - } - return; - } - const node = this._createStructuredContentGenericElement(content, dictionary, language); - if (node !== null) { - container.appendChild(node); - } - } - - /** - * @param {string} tagName - * @param {string} className - * @returns {HTMLElement} - */ - _createElement(tagName, className) { - const node = this._document.createElement(tagName); - node.className = className; - return node; - } - - /** - * @param {string} data - * @returns {Text} - */ - _createTextNode(data) { - return this._document.createTextNode(data); - } - - /** - * @param {HTMLElement} element - * @param {import('structured-content').Data} data - */ - _setElementDataset(element, data) { - for (let [key, value] of Object.entries(data)) { - if (key.length > 0) { - key = `${key[0].toUpperCase()}${key.substring(1)}`; - } - key = `sc${key}`; - try { - element.dataset[key] = value; - } catch (e) { - // DOMException if key is malformed - } - } - } - - /** - * @param {HTMLAnchorElement} node - * @param {HTMLImageElement} image - * @param {HTMLElement} imageBackground - * @param {?string} url - * @param {boolean} unloaded - */ - _setImageData(node, image, imageBackground, url, unloaded) { - if (url !== null) { - image.src = url; - node.href = url; - node.dataset.imageLoadState = 'loaded'; - imageBackground.style.setProperty('--image', `url("${url}")`); - } else { - image.removeAttribute('src'); - node.removeAttribute('href'); - node.dataset.imageLoadState = unloaded ? 'unloaded' : 'load-error'; - imageBackground.style.removeProperty('--image'); - } - } - - /** - * @param {import('structured-content').Element} content - * @param {string} dictionary - * @param {?string} language - * @returns {?HTMLElement} - */ - _createStructuredContentGenericElement(content, dictionary, language) { - const {tag} = content; - switch (tag) { - case 'br': - return this._createStructuredContentElement(tag, content, dictionary, language, 'simple', false, false); - case 'ruby': - case 'rt': - case 'rp': - return this._createStructuredContentElement(tag, content, dictionary, language, 'simple', true, false); - case 'table': - return this._createStructuredContentTableElement(tag, content, dictionary, language); - case 'thead': - case 'tbody': - case 'tfoot': - case 'tr': - return this._createStructuredContentElement(tag, content, dictionary, language, 'table', true, false); - case 'th': - case 'td': - return this._createStructuredContentElement(tag, content, dictionary, language, 'table-cell', true, true); - case 'div': - case 'span': - case 'ol': - case 'ul': - case 'li': - return this._createStructuredContentElement(tag, content, dictionary, language, 'simple', true, true); - case 'img': - return this.createDefinitionImage(content, dictionary); - case 'a': - return this._createLinkElement(content, dictionary, language); - } - return null; - } - - /** - * @param {string} tag - * @param {import('structured-content').UnstyledElement} content - * @param {string} dictionary - * @param {?string} language - * @returns {HTMLElement} - */ - _createStructuredContentTableElement(tag, content, dictionary, language) { - const container = this._createElement('div', 'gloss-sc-table-container'); - const table = this._createStructuredContentElement(tag, content, dictionary, language, 'table', true, false); - container.appendChild(table); - return container; - } - - /** - * @param {string} tag - * @param {import('structured-content').StyledElement|import('structured-content').UnstyledElement|import('structured-content').TableElement|import('structured-content').LineBreak} content - * @param {string} dictionary - * @param {?string} language - * @param {'simple'|'table'|'table-cell'} type - * @param {boolean} hasChildren - * @param {boolean} hasStyle - * @returns {HTMLElement} - */ - _createStructuredContentElement(tag, content, dictionary, language, type, hasChildren, hasStyle) { - const node = this._createElement(tag, `gloss-sc-${tag}`); - const {data, lang} = content; - if (typeof data === 'object' && data !== null) { this._setElementDataset(node, data); } - if (typeof lang === 'string') { - node.lang = lang; - language = lang; - } - switch (type) { - case 'table-cell': - { - const cell = /** @type {HTMLTableCellElement} */ (node); - const {colSpan, rowSpan} = /** @type {import('structured-content').TableElement} */ (content); - if (typeof colSpan === 'number') { cell.colSpan = colSpan; } - if (typeof rowSpan === 'number') { cell.rowSpan = rowSpan; } - } - break; - } - if (hasStyle) { - const {style, title} = /** @type {import('structured-content').StyledElement} */ (content); - if (typeof style === 'object' && style !== null) { - this._setStructuredContentElementStyle(node, style); - } - if (typeof title === 'string') { node.title = title; } - } - if (hasChildren) { - this._appendStructuredContent(node, content.content, dictionary, language); - } - return node; - } - - /** - * @param {HTMLElement} node - * @param {import('structured-content').StructuredContentStyle} contentStyle - */ - _setStructuredContentElementStyle(node, contentStyle) { - const {style} = node; - const { - fontStyle, - fontWeight, - fontSize, - color, - background, - backgroundColor, - textDecorationLine, - textDecorationStyle, - textDecorationColor, - borderColor, - borderStyle, - borderRadius, - borderWidth, - clipPath, - verticalAlign, - textAlign, - textEmphasis, - textShadow, - margin, - marginTop, - marginLeft, - marginRight, - marginBottom, - padding, - paddingTop, - paddingLeft, - paddingRight, - paddingBottom, - wordBreak, - whiteSpace, - cursor, - listStyleType - } = contentStyle; - if (typeof fontStyle === 'string') { style.fontStyle = fontStyle; } - if (typeof fontWeight === 'string') { style.fontWeight = fontWeight; } - if (typeof fontSize === 'string') { style.fontSize = fontSize; } - if (typeof color === 'string') { style.color = color; } - if (typeof background === 'string') { style.background = background; } - if (typeof backgroundColor === 'string') { style.backgroundColor = backgroundColor; } - if (typeof verticalAlign === 'string') { style.verticalAlign = verticalAlign; } - if (typeof textAlign === 'string') { style.textAlign = textAlign; } - if (typeof textEmphasis === 'string') { style.textEmphasis = textEmphasis; } - if (typeof textShadow === 'string') { style.textShadow = textShadow; } - if (typeof textDecorationLine === 'string') { - style.textDecoration = textDecorationLine; - } else if (Array.isArray(textDecorationLine)) { - style.textDecoration = textDecorationLine.join(' '); - } - if (typeof textDecorationStyle === 'string') { - style.textDecorationStyle = textDecorationStyle; - } - if (typeof textDecorationColor === 'string') { - style.textDecorationColor = textDecorationColor; - } - if (typeof borderColor === 'string') { style.borderColor = borderColor; } - if (typeof borderStyle === 'string') { style.borderStyle = borderStyle; } - if (typeof borderRadius === 'string') { style.borderRadius = borderRadius; } - if (typeof borderWidth === 'string') { style.borderWidth = borderWidth; } - if (typeof clipPath === 'string') { style.clipPath = clipPath; } - if (typeof margin === 'string') { style.margin = margin; } - if (typeof marginTop === 'number') { style.marginTop = `${marginTop}em`; } - if (typeof marginTop === 'string') { style.marginTop = marginTop; } - if (typeof marginLeft === 'number') { style.marginLeft = `${marginLeft}em`; } - if (typeof marginLeft === 'string') { style.marginLeft = marginLeft; } - if (typeof marginRight === 'number') { style.marginRight = `${marginRight}em`; } - if (typeof marginRight === 'string') { style.marginRight = marginRight; } - if (typeof marginBottom === 'number') { style.marginBottom = `${marginBottom}em`; } - if (typeof marginBottom === 'string') { style.marginBottom = marginBottom; } - if (typeof padding === 'string') { style.padding = padding; } - if (typeof paddingTop === 'string') { style.paddingTop = paddingTop; } - if (typeof paddingLeft === 'string') { style.paddingLeft = paddingLeft; } - if (typeof paddingRight === 'string') { style.paddingRight = paddingRight; } - if (typeof paddingBottom === 'string') { style.paddingBottom = paddingBottom; } - if (typeof wordBreak === 'string') { style.wordBreak = wordBreak; } - if (typeof whiteSpace === 'string') { style.whiteSpace = whiteSpace; } - if (typeof cursor === 'string') { style.cursor = cursor; } - if (typeof listStyleType === 'string') { style.listStyleType = listStyleType; } - } - - /** - * @param {import('structured-content').LinkElement} content - * @param {string} dictionary - * @param {?string} language - * @returns {HTMLAnchorElement} - */ - _createLinkElement(content, dictionary, language) { - let {href} = content; - const internal = href.startsWith('?'); - if (internal) { - href = `${location.protocol}//${location.host}/search.html${href.length > 1 ? href : ''}`; - } - - const node = /** @type {HTMLAnchorElement} */ (this._createElement('a', 'gloss-link')); - node.dataset.external = `${!internal}`; - - const text = this._createElement('span', 'gloss-link-text'); - node.appendChild(text); - - const {lang} = content; - if (typeof lang === 'string') { - node.lang = lang; - language = lang; - } - - this._appendStructuredContent(text, content.content, dictionary, language); - - if (!internal) { - const icon = this._createElement('span', 'gloss-link-external-icon icon'); - icon.dataset.icon = 'external-link'; - node.appendChild(icon); - } - - this._contentManager.prepareLink(node, href, internal); - return node; - } -} diff --git a/ext/js/display/structured-content-generator.js b/ext/js/display/structured-content-generator.js new file mode 100644 index 00000000..a7fd9f3d --- /dev/null +++ b/ext/js/display/structured-content-generator.js @@ -0,0 +1,464 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 2021-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 . + */ + +import {getLanguageFromText} from '../language/text-utilities.js'; + +export class StructuredContentGenerator { + /** + * @param {import('./display-content-manager.js').DisplayContentManager|import('../templates/anki-template-renderer-content-manager.js').AnkiTemplateRendererContentManager} contentManager + * @param {Document} document + */ + constructor(contentManager, document) { + /** @type {import('./display-content-manager.js').DisplayContentManager|import('../templates/anki-template-renderer-content-manager.js').AnkiTemplateRendererContentManager} */ + this._contentManager = contentManager; + /** @type {Document} */ + this._document = document; + } + + /** + * @param {HTMLElement} node + * @param {import('structured-content').Content} content + * @param {string} dictionary + */ + appendStructuredContent(node, content, dictionary) { + node.classList.add('structured-content'); + this._appendStructuredContent(node, content, dictionary, null); + } + + /** + * @param {import('structured-content').Content} content + * @param {string} dictionary + * @returns {HTMLElement} + */ + createStructuredContent(content, dictionary) { + const node = this._createElement('span', 'structured-content'); + this._appendStructuredContent(node, content, dictionary, null); + return node; + } + + /** + * @param {import('structured-content').ImageElement|import('dictionary-data').TermGlossaryImage} data + * @param {string} dictionary + * @returns {HTMLAnchorElement} + */ + createDefinitionImage(data, dictionary) { + const { + path, + width = 100, + height = 100, + preferredWidth, + preferredHeight, + title, + alt, + pixelated, + imageRendering, + appearance, + background, + collapsed, + collapsible, + verticalAlign, + border, + borderRadius, + sizeUnits + } = data; + + const hasPreferredWidth = (typeof preferredWidth === 'number'); + const hasPreferredHeight = (typeof preferredHeight === 'number'); + const invAspectRatio = ( + hasPreferredWidth && hasPreferredHeight ? + preferredHeight / preferredWidth : + height / width + ); + const usedWidth = ( + hasPreferredWidth ? + preferredWidth : + (hasPreferredHeight ? preferredHeight / invAspectRatio : width) + ); + + const node = /** @type {HTMLAnchorElement} */ (this._createElement('a', 'gloss-image-link')); + node.target = '_blank'; + node.rel = 'noreferrer noopener'; + + const imageContainer = this._createElement('span', 'gloss-image-container'); + node.appendChild(imageContainer); + + const aspectRatioSizer = this._createElement('span', 'gloss-image-sizer'); + imageContainer.appendChild(aspectRatioSizer); + + const imageBackground = this._createElement('span', 'gloss-image-background'); + imageContainer.appendChild(imageBackground); + + const image = /** @type {HTMLImageElement} */ (this._createElement('img', 'gloss-image')); + image.alt = typeof alt === 'string' ? alt : ''; + imageContainer.appendChild(image); + + const overlay = this._createElement('span', 'gloss-image-container-overlay'); + imageContainer.appendChild(overlay); + + const linkText = this._createElement('span', 'gloss-image-link-text'); + linkText.textContent = 'Image'; + node.appendChild(linkText); + + node.dataset.path = path; + node.dataset.dictionary = dictionary; + node.dataset.imageLoadState = 'not-loaded'; + node.dataset.hasAspectRatio = 'true'; + node.dataset.imageRendering = typeof imageRendering === 'string' ? imageRendering : (pixelated ? 'pixelated' : 'auto'); + node.dataset.appearance = typeof appearance === 'string' ? appearance : 'auto'; + node.dataset.background = typeof background === 'boolean' ? `${background}` : 'true'; + node.dataset.collapsed = typeof collapsed === 'boolean' ? `${collapsed}` : 'false'; + node.dataset.collapsible = typeof collapsible === 'boolean' ? `${collapsible}` : 'true'; + if (typeof verticalAlign === 'string') { + node.dataset.verticalAlign = verticalAlign; + } + if (typeof sizeUnits === 'string' && (hasPreferredWidth || hasPreferredHeight)) { + node.dataset.sizeUnits = sizeUnits; + } + + imageContainer.style.width = `${usedWidth}em`; + if (typeof border === 'string') { imageContainer.style.border = border; } + if (typeof borderRadius === 'string') { imageContainer.style.borderRadius = borderRadius; } + if (typeof title === 'string') { + imageContainer.title = title; + } + + aspectRatioSizer.style.paddingTop = `${invAspectRatio * 100}%`; + + if (this._contentManager !== null) { + this._contentManager.loadMedia( + path, + dictionary, + (url) => this._setImageData(node, image, imageBackground, url, false), + () => this._setImageData(node, image, imageBackground, null, true) + ); + } + + return node; + } + + // Private + + /** + * @param {HTMLElement} container + * @param {import('structured-content').Content|undefined} content + * @param {string} dictionary + * @param {?string} language + */ + _appendStructuredContent(container, content, dictionary, language) { + if (typeof content === 'string') { + if (content.length > 0) { + container.appendChild(this._createTextNode(content)); + if (language === null) { + const language2 = getLanguageFromText(content); + if (language2 !== null) { + container.lang = language2; + } + } + } + return; + } + if (!(typeof content === 'object' && content !== null)) { + return; + } + if (Array.isArray(content)) { + for (const item of content) { + this._appendStructuredContent(container, item, dictionary, language); + } + return; + } + const node = this._createStructuredContentGenericElement(content, dictionary, language); + if (node !== null) { + container.appendChild(node); + } + } + + /** + * @param {string} tagName + * @param {string} className + * @returns {HTMLElement} + */ + _createElement(tagName, className) { + const node = this._document.createElement(tagName); + node.className = className; + return node; + } + + /** + * @param {string} data + * @returns {Text} + */ + _createTextNode(data) { + return this._document.createTextNode(data); + } + + /** + * @param {HTMLElement} element + * @param {import('structured-content').Data} data + */ + _setElementDataset(element, data) { + for (let [key, value] of Object.entries(data)) { + if (key.length > 0) { + key = `${key[0].toUpperCase()}${key.substring(1)}`; + } + key = `sc${key}`; + try { + element.dataset[key] = value; + } catch (e) { + // DOMException if key is malformed + } + } + } + + /** + * @param {HTMLAnchorElement} node + * @param {HTMLImageElement} image + * @param {HTMLElement} imageBackground + * @param {?string} url + * @param {boolean} unloaded + */ + _setImageData(node, image, imageBackground, url, unloaded) { + if (url !== null) { + image.src = url; + node.href = url; + node.dataset.imageLoadState = 'loaded'; + imageBackground.style.setProperty('--image', `url("${url}")`); + } else { + image.removeAttribute('src'); + node.removeAttribute('href'); + node.dataset.imageLoadState = unloaded ? 'unloaded' : 'load-error'; + imageBackground.style.removeProperty('--image'); + } + } + + /** + * @param {import('structured-content').Element} content + * @param {string} dictionary + * @param {?string} language + * @returns {?HTMLElement} + */ + _createStructuredContentGenericElement(content, dictionary, language) { + const {tag} = content; + switch (tag) { + case 'br': + return this._createStructuredContentElement(tag, content, dictionary, language, 'simple', false, false); + case 'ruby': + case 'rt': + case 'rp': + return this._createStructuredContentElement(tag, content, dictionary, language, 'simple', true, false); + case 'table': + return this._createStructuredContentTableElement(tag, content, dictionary, language); + case 'thead': + case 'tbody': + case 'tfoot': + case 'tr': + return this._createStructuredContentElement(tag, content, dictionary, language, 'table', true, false); + case 'th': + case 'td': + return this._createStructuredContentElement(tag, content, dictionary, language, 'table-cell', true, true); + case 'div': + case 'span': + case 'ol': + case 'ul': + case 'li': + return this._createStructuredContentElement(tag, content, dictionary, language, 'simple', true, true); + case 'img': + return this.createDefinitionImage(content, dictionary); + case 'a': + return this._createLinkElement(content, dictionary, language); + } + return null; + } + + /** + * @param {string} tag + * @param {import('structured-content').UnstyledElement} content + * @param {string} dictionary + * @param {?string} language + * @returns {HTMLElement} + */ + _createStructuredContentTableElement(tag, content, dictionary, language) { + const container = this._createElement('div', 'gloss-sc-table-container'); + const table = this._createStructuredContentElement(tag, content, dictionary, language, 'table', true, false); + container.appendChild(table); + return container; + } + + /** + * @param {string} tag + * @param {import('structured-content').StyledElement|import('structured-content').UnstyledElement|import('structured-content').TableElement|import('structured-content').LineBreak} content + * @param {string} dictionary + * @param {?string} language + * @param {'simple'|'table'|'table-cell'} type + * @param {boolean} hasChildren + * @param {boolean} hasStyle + * @returns {HTMLElement} + */ + _createStructuredContentElement(tag, content, dictionary, language, type, hasChildren, hasStyle) { + const node = this._createElement(tag, `gloss-sc-${tag}`); + const {data, lang} = content; + if (typeof data === 'object' && data !== null) { this._setElementDataset(node, data); } + if (typeof lang === 'string') { + node.lang = lang; + language = lang; + } + switch (type) { + case 'table-cell': + { + const cell = /** @type {HTMLTableCellElement} */ (node); + const {colSpan, rowSpan} = /** @type {import('structured-content').TableElement} */ (content); + if (typeof colSpan === 'number') { cell.colSpan = colSpan; } + if (typeof rowSpan === 'number') { cell.rowSpan = rowSpan; } + } + break; + } + if (hasStyle) { + const {style, title} = /** @type {import('structured-content').StyledElement} */ (content); + if (typeof style === 'object' && style !== null) { + this._setStructuredContentElementStyle(node, style); + } + if (typeof title === 'string') { node.title = title; } + } + if (hasChildren) { + this._appendStructuredContent(node, content.content, dictionary, language); + } + return node; + } + + /** + * @param {HTMLElement} node + * @param {import('structured-content').StructuredContentStyle} contentStyle + */ + _setStructuredContentElementStyle(node, contentStyle) { + const {style} = node; + const { + fontStyle, + fontWeight, + fontSize, + color, + background, + backgroundColor, + textDecorationLine, + textDecorationStyle, + textDecorationColor, + borderColor, + borderStyle, + borderRadius, + borderWidth, + clipPath, + verticalAlign, + textAlign, + textEmphasis, + textShadow, + margin, + marginTop, + marginLeft, + marginRight, + marginBottom, + padding, + paddingTop, + paddingLeft, + paddingRight, + paddingBottom, + wordBreak, + whiteSpace, + cursor, + listStyleType + } = contentStyle; + if (typeof fontStyle === 'string') { style.fontStyle = fontStyle; } + if (typeof fontWeight === 'string') { style.fontWeight = fontWeight; } + if (typeof fontSize === 'string') { style.fontSize = fontSize; } + if (typeof color === 'string') { style.color = color; } + if (typeof background === 'string') { style.background = background; } + if (typeof backgroundColor === 'string') { style.backgroundColor = backgroundColor; } + if (typeof verticalAlign === 'string') { style.verticalAlign = verticalAlign; } + if (typeof textAlign === 'string') { style.textAlign = textAlign; } + if (typeof textEmphasis === 'string') { style.textEmphasis = textEmphasis; } + if (typeof textShadow === 'string') { style.textShadow = textShadow; } + if (typeof textDecorationLine === 'string') { + style.textDecoration = textDecorationLine; + } else if (Array.isArray(textDecorationLine)) { + style.textDecoration = textDecorationLine.join(' '); + } + if (typeof textDecorationStyle === 'string') { + style.textDecorationStyle = textDecorationStyle; + } + if (typeof textDecorationColor === 'string') { + style.textDecorationColor = textDecorationColor; + } + if (typeof borderColor === 'string') { style.borderColor = borderColor; } + if (typeof borderStyle === 'string') { style.borderStyle = borderStyle; } + if (typeof borderRadius === 'string') { style.borderRadius = borderRadius; } + if (typeof borderWidth === 'string') { style.borderWidth = borderWidth; } + if (typeof clipPath === 'string') { style.clipPath = clipPath; } + if (typeof margin === 'string') { style.margin = margin; } + if (typeof marginTop === 'number') { style.marginTop = `${marginTop}em`; } + if (typeof marginTop === 'string') { style.marginTop = marginTop; } + if (typeof marginLeft === 'number') { style.marginLeft = `${marginLeft}em`; } + if (typeof marginLeft === 'string') { style.marginLeft = marginLeft; } + if (typeof marginRight === 'number') { style.marginRight = `${marginRight}em`; } + if (typeof marginRight === 'string') { style.marginRight = marginRight; } + if (typeof marginBottom === 'number') { style.marginBottom = `${marginBottom}em`; } + if (typeof marginBottom === 'string') { style.marginBottom = marginBottom; } + if (typeof padding === 'string') { style.padding = padding; } + if (typeof paddingTop === 'string') { style.paddingTop = paddingTop; } + if (typeof paddingLeft === 'string') { style.paddingLeft = paddingLeft; } + if (typeof paddingRight === 'string') { style.paddingRight = paddingRight; } + if (typeof paddingBottom === 'string') { style.paddingBottom = paddingBottom; } + if (typeof wordBreak === 'string') { style.wordBreak = wordBreak; } + if (typeof whiteSpace === 'string') { style.whiteSpace = whiteSpace; } + if (typeof cursor === 'string') { style.cursor = cursor; } + if (typeof listStyleType === 'string') { style.listStyleType = listStyleType; } + } + + /** + * @param {import('structured-content').LinkElement} content + * @param {string} dictionary + * @param {?string} language + * @returns {HTMLAnchorElement} + */ + _createLinkElement(content, dictionary, language) { + let {href} = content; + const internal = href.startsWith('?'); + if (internal) { + href = `${location.protocol}//${location.host}/search.html${href.length > 1 ? href : ''}`; + } + + const node = /** @type {HTMLAnchorElement} */ (this._createElement('a', 'gloss-link')); + node.dataset.external = `${!internal}`; + + const text = this._createElement('span', 'gloss-link-text'); + node.appendChild(text); + + const {lang} = content; + if (typeof lang === 'string') { + node.lang = lang; + language = lang; + } + + this._appendStructuredContent(text, content.content, dictionary, language); + + if (!internal) { + const icon = this._createElement('span', 'gloss-link-external-icon icon'); + icon.dataset.icon = 'external-link'; + node.appendChild(icon); + } + + this._contentManager.prepareLink(node, href, internal); + return node; + } +} diff --git a/ext/js/dom/css-style-applier.js b/ext/js/dom/css-style-applier.js new file mode 100644 index 00000000..ba49d6aa --- /dev/null +++ b/ext/js/dom/css-style-applier.js @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 2021-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 . + */ + +import {readResponseJson} from '../core/json.js'; + +/** + * This class is used to apply CSS styles to elements using a consistent method + * that is the same across different browsers. + */ +export class CssStyleApplier { + /** + * Creates a new instance of the class. + * @param {string} styleDataUrl The local URL to the JSON file continaing the style rules. + * The style rules should follow the format of `CssStyleApplierRawStyleData`. + */ + constructor(styleDataUrl) { + /** @type {string} */ + this._styleDataUrl = styleDataUrl; + /** @type {import('css-style-applier').CssRule[]} */ + this._styleData = []; + /** @type {Map} */ + this._cachedRules = new Map(); + /** @type {RegExp} */ + this._patternHtmlWhitespace = /[\t\r\n\f ]+/g; + /** @type {RegExp} */ + this._patternClassNameCharacter = /[0-9a-zA-Z-_]/; + } + + /** + * Loads the data file for use. + */ + async prepare() { + /** @type {import('css-style-applier').RawStyleData} */ + let rawData = []; + try { + rawData = await this._fetchJsonAsset(this._styleDataUrl); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + const styleData = this._styleData; + styleData.length = 0; + for (const {selectors, styles} of rawData) { + const selectors2 = selectors.join(','); + const styles2 = []; + for (const [property, value] of styles) { + styles2.push({property, value}); + } + styleData.push({ + selectors: selectors2, + styles: styles2 + }); + } + } + + /** + * Applies CSS styles directly to the "style" attribute using the "class" attribute. + * This only works for elements with a single class. + * @param {Iterable} elements An iterable collection of HTMLElement objects. + */ + applyClassStyles(elements) { + const elementStyles = []; + for (const element of elements) { + const className = element.getAttribute('class'); + if (className === null || className.length === 0) { continue; } + let cssTextNew = ''; + for (const {selectors, styles} of this._getCandidateCssRulesForClass(className)) { + if (!element.matches(selectors)) { continue; } + cssTextNew += this._getCssText(styles); + } + cssTextNew += element.style.cssText; + elementStyles.push({element, style: cssTextNew}); + } + for (const {element, style} of elementStyles) { + element.removeAttribute('class'); + if (style.length > 0) { + element.setAttribute('style', style); + } else { + element.removeAttribute('style'); + } + } + } + + // Private + + /** + * Fetches and parses a JSON file. + * @template [T=unknown] + * @param {string} url The URL to the file. + * @returns {Promise} A JSON object. + * @throws {Error} An error is thrown if the fetch fails. + */ + async _fetchJsonAsset(url) { + const response = await fetch(url, { + method: 'GET', + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' + }); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status}`); + } + return await readResponseJson(response); + } + + /** + * Gets an array of candidate CSS rules which might match a specific class. + * @param {string} className A whitespace-separated list of classes. + * @returns {import('css-style-applier').CssRule[]} An array of candidate CSS rules. + */ + _getCandidateCssRulesForClass(className) { + let rules = this._cachedRules.get(className); + if (typeof rules !== 'undefined') { return rules; } + + rules = []; + this._cachedRules.set(className, rules); + + const classList = this._getTokens(className); + for (const {selectors, styles} of this._styleData) { + if (!this._selectorMightMatch(selectors, classList)) { continue; } + rules.push({selectors, styles}); + } + + return rules; + } + + /** + * Converts an array of CSS rules to a CSS string. + * @param {import('css-style-applier').CssDeclaration[]} styles An array of CSS rules. + * @returns {string} The CSS string. + */ + _getCssText(styles) { + let cssText = ''; + for (const {property, value} of styles) { + cssText += `${property}:${value};`; + } + return cssText; + } + + /** + * Checks whether or not a CSS string might match at least one class in a list. + * @param {string} selectors A CSS selector string. + * @param {string[]} classList An array of CSS classes. + * @returns {boolean} `true` if the selector string might match one of the classes in `classList`, false otherwise. + */ + _selectorMightMatch(selectors, classList) { + const pattern = this._patternClassNameCharacter; + for (const item of classList) { + const prefixedItem = `.${item}`; + let start = 0; + while (true) { + const index = selectors.indexOf(prefixedItem, start); + if (index < 0) { break; } + start = index + prefixedItem.length; + if (start >= selectors.length || !pattern.test(selectors[start])) { return true; } + } + } + return false; + } + + /** + * Gets the whitespace-delimited tokens from a string. + * @param {string} tokenListString The string to parse. + * @returns {string[]} An array of tokens. + */ + _getTokens(tokenListString) { + let start = 0; + const pattern = this._patternHtmlWhitespace; + pattern.lastIndex = 0; + const result = []; + while (true) { + const match = pattern.exec(tokenListString); + const end = match === null ? tokenListString.length : match.index; + if (end > start) { result.push(tokenListString.substring(start, end)); } + if (match === null) { return result; } + start = end + match[0].length; + } + } +} diff --git a/ext/js/dom/dom-text-scanner.js b/ext/js/dom/dom-text-scanner.js index e263d9f3..b86d6586 100644 --- a/ext/js/dom/dom-text-scanner.js +++ b/ext/js/dom/dom-text-scanner.js @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import {readCodePointsBackward, readCodePointsForward} from '../data/sandbox/string-util.js'; +import {readCodePointsBackward, readCodePointsForward} from '../data/string-util.js'; /** * A class used to scan text in a document. diff --git a/ext/js/dom/sandbox/css-style-applier.js b/ext/js/dom/sandbox/css-style-applier.js deleted file mode 100644 index ea3f1a28..00000000 --- a/ext/js/dom/sandbox/css-style-applier.js +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright (C) 2023-2024 Yomitan Authors - * Copyright (C) 2021-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 . - */ - -import {readResponseJson} from '../../core/json.js'; - -/** - * This class is used to apply CSS styles to elements using a consistent method - * that is the same across different browsers. - */ -export class CssStyleApplier { - /** - * Creates a new instance of the class. - * @param {string} styleDataUrl The local URL to the JSON file continaing the style rules. - * The style rules should follow the format of `CssStyleApplierRawStyleData`. - */ - constructor(styleDataUrl) { - /** @type {string} */ - this._styleDataUrl = styleDataUrl; - /** @type {import('css-style-applier').CssRule[]} */ - this._styleData = []; - /** @type {Map} */ - this._cachedRules = new Map(); - /** @type {RegExp} */ - this._patternHtmlWhitespace = /[\t\r\n\f ]+/g; - /** @type {RegExp} */ - this._patternClassNameCharacter = /[0-9a-zA-Z-_]/; - } - - /** - * Loads the data file for use. - */ - async prepare() { - /** @type {import('css-style-applier').RawStyleData} */ - let rawData = []; - try { - rawData = await this._fetchJsonAsset(this._styleDataUrl); - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - } - const styleData = this._styleData; - styleData.length = 0; - for (const {selectors, styles} of rawData) { - const selectors2 = selectors.join(','); - const styles2 = []; - for (const [property, value] of styles) { - styles2.push({property, value}); - } - styleData.push({ - selectors: selectors2, - styles: styles2 - }); - } - } - - /** - * Applies CSS styles directly to the "style" attribute using the "class" attribute. - * This only works for elements with a single class. - * @param {Iterable} elements An iterable collection of HTMLElement objects. - */ - applyClassStyles(elements) { - const elementStyles = []; - for (const element of elements) { - const className = element.getAttribute('class'); - if (className === null || className.length === 0) { continue; } - let cssTextNew = ''; - for (const {selectors, styles} of this._getCandidateCssRulesForClass(className)) { - if (!element.matches(selectors)) { continue; } - cssTextNew += this._getCssText(styles); - } - cssTextNew += element.style.cssText; - elementStyles.push({element, style: cssTextNew}); - } - for (const {element, style} of elementStyles) { - element.removeAttribute('class'); - if (style.length > 0) { - element.setAttribute('style', style); - } else { - element.removeAttribute('style'); - } - } - } - - // Private - - /** - * Fetches and parses a JSON file. - * @template [T=unknown] - * @param {string} url The URL to the file. - * @returns {Promise} A JSON object. - * @throws {Error} An error is thrown if the fetch fails. - */ - async _fetchJsonAsset(url) { - const response = await fetch(url, { - method: 'GET', - mode: 'no-cors', - cache: 'default', - credentials: 'omit', - redirect: 'follow', - referrerPolicy: 'no-referrer' - }); - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.status}`); - } - return await readResponseJson(response); - } - - /** - * Gets an array of candidate CSS rules which might match a specific class. - * @param {string} className A whitespace-separated list of classes. - * @returns {import('css-style-applier').CssRule[]} An array of candidate CSS rules. - */ - _getCandidateCssRulesForClass(className) { - let rules = this._cachedRules.get(className); - if (typeof rules !== 'undefined') { return rules; } - - rules = []; - this._cachedRules.set(className, rules); - - const classList = this._getTokens(className); - for (const {selectors, styles} of this._styleData) { - if (!this._selectorMightMatch(selectors, classList)) { continue; } - rules.push({selectors, styles}); - } - - return rules; - } - - /** - * Converts an array of CSS rules to a CSS string. - * @param {import('css-style-applier').CssDeclaration[]} styles An array of CSS rules. - * @returns {string} The CSS string. - */ - _getCssText(styles) { - let cssText = ''; - for (const {property, value} of styles) { - cssText += `${property}:${value};`; - } - return cssText; - } - - /** - * Checks whether or not a CSS string might match at least one class in a list. - * @param {string} selectors A CSS selector string. - * @param {string[]} classList An array of CSS classes. - * @returns {boolean} `true` if the selector string might match one of the classes in `classList`, false otherwise. - */ - _selectorMightMatch(selectors, classList) { - const pattern = this._patternClassNameCharacter; - for (const item of classList) { - const prefixedItem = `.${item}`; - let start = 0; - while (true) { - const index = selectors.indexOf(prefixedItem, start); - if (index < 0) { break; } - start = index + prefixedItem.length; - if (start >= selectors.length || !pattern.test(selectors[start])) { return true; } - } - } - return false; - } - - /** - * Gets the whitespace-delimited tokens from a string. - * @param {string} tokenListString The string to parse. - * @returns {string[]} An array of tokens. - */ - _getTokens(tokenListString) { - let start = 0; - const pattern = this._patternHtmlWhitespace; - pattern.lastIndex = 0; - const result = []; - while (true) { - const match = pattern.exec(tokenListString); - const end = match === null ? tokenListString.length : match.index; - if (end > start) { result.push(tokenListString.substring(start, end)); } - if (match === null) { return result; } - start = end + match[0].length; - } - } -} diff --git a/ext/js/dom/text-source-element.js b/ext/js/dom/text-source-element.js index b2829e75..d91e75f3 100644 --- a/ext/js/dom/text-source-element.js +++ b/ext/js/dom/text-source-element.js @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import {readCodePointsBackward, readCodePointsForward} from '../data/sandbox/string-util.js'; +import {readCodePointsBackward, readCodePointsForward} from '../data/string-util.js'; import {convertMultipleRectZoomCoordinates} from './document-util.js'; /** diff --git a/ext/js/media/audio-downloader.js b/ext/js/media/audio-downloader.js index 4cb3eb81..b3d2d55c 100644 --- a/ext/js/media/audio-downloader.js +++ b/ext/js/media/audio-downloader.js @@ -19,8 +19,8 @@ import {RequestBuilder} from '../background/request-builder.js'; import {ExtensionError} from '../core/extension-error.js'; import {readResponseJson} from '../core/json.js'; +import {arrayBufferToBase64} from '../data/array-buffer-util.js'; import {JsonSchema} from '../data/json-schema.js'; -import {arrayBufferToBase64} from '../data/sandbox/array-buffer-util.js'; import {NativeSimpleDOMParser} from '../dom/native-simple-dom-parser.js'; import {SimpleDOMParser} from '../dom/simple-dom-parser.js'; import {isStringEntirelyKana} from '../language/ja/japanese.js'; diff --git a/ext/js/pages/settings/backup-controller.js b/ext/js/pages/settings/backup-controller.js index dd739d39..5c168849 100644 --- a/ext/js/pages/settings/backup-controller.js +++ b/ext/js/pages/settings/backup-controller.js @@ -21,9 +21,9 @@ import {parseJson} from '../../core/json.js'; import {log} from '../../core/log.js'; import {isObjectNotArray} from '../../core/object-utilities.js'; import {toError} from '../../core/to-error.js'; +import {arrayBufferUtf8Decode} from '../../data/array-buffer-util.js'; import {OptionsUtil} from '../../data/options-util.js'; import {getAllPermissions} from '../../data/permissions-util.js'; -import {arrayBufferUtf8Decode} from '../../data/sandbox/array-buffer-util.js'; import {querySelectorNotNull} from '../../dom/query-selector.js'; import {DictionaryController} from './dictionary-controller.js'; diff --git a/ext/js/templates/anki-template-renderer-content-manager.js b/ext/js/templates/anki-template-renderer-content-manager.js new file mode 100644 index 00000000..664746bf --- /dev/null +++ b/ext/js/templates/anki-template-renderer-content-manager.js @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 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 . + */ + +/** + * The content manager which is used when generating content for Anki. + */ +export class AnkiTemplateRendererContentManager { + /** + * Creates a new instance of the class. + * @param {import('./template-renderer-media-provider.js').TemplateRendererMediaProvider} mediaProvider The media provider for the object. + * @param {import('anki-templates').NoteData} data The data object passed to the Handlebars template renderer. + */ + constructor(mediaProvider, data) { + /** @type {import('./template-renderer-media-provider.js').TemplateRendererMediaProvider} */ + this._mediaProvider = mediaProvider; + /** @type {import('anki-templates').NoteData} */ + this._data = data; + /** @type {import('anki-template-renderer-content-manager').OnUnloadCallback[]} */ + this._onUnloadCallbacks = []; + } + + /** + * Attempts to load the media file from a given dictionary. + * @param {string} path The path to the media file in the dictionary. + * @param {string} dictionary The name of the dictionary. + * @param {import('anki-template-renderer-content-manager').OnLoadCallback} onLoad The callback that is executed if the media was loaded successfully. + * No assumptions should be made about the synchronicity of this callback. + * @param {import('anki-template-renderer-content-manager').OnUnloadCallback} onUnload The callback that is executed when the media should be unloaded. + */ + loadMedia(path, dictionary, onLoad, onUnload) { + const imageUrl = this._mediaProvider.getMedia(this._data, ['dictionaryMedia', path], {dictionary, format: 'fileName', default: null}); + if (imageUrl === null) { return; } + onLoad(imageUrl); + if (typeof onUnload === 'function') { + this._onUnloadCallbacks.push(onUnload); + } + } + + /** + * Unloads all media that has been loaded. + */ + unloadAll() { + for (const onUnload of this._onUnloadCallbacks) { + onUnload(true); + } + this._onUnloadCallbacks = []; + } + + /** + * Sets up attributes and events for a link element. + * @param {HTMLAnchorElement} element The link element. + * @param {string} href The URL. + * @param {boolean} internal Whether or not the URL is an internal or external link. + */ + prepareLink(element, href, internal) { + element.href = internal ? '#' : href; + } +} diff --git a/ext/js/templates/anki-template-renderer.js b/ext/js/templates/anki-template-renderer.js new file mode 100644 index 00000000..4bb56a4b --- /dev/null +++ b/ext/js/templates/anki-template-renderer.js @@ -0,0 +1,827 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 2021-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 . + */ + +import {Handlebars} from '../../lib/handlebars.js'; +import {createAnkiNoteData} from '../data/anki-note-data-creator.js'; +import {getPronunciationsOfType, isNonNounVerbOrAdjective} from '../dictionary/dictionary-data-util.js'; +import {createPronunciationDownstepPosition, createPronunciationGraph, createPronunciationText} from '../display/pronunciation-generator.js'; +import {StructuredContentGenerator} from '../display/structured-content-generator.js'; +import {CssStyleApplier} from '../dom/css-style-applier.js'; +import {convertHiraganaToKatakana, convertKatakanaToHiragana, distributeFurigana, getKanaMorae, getPitchCategory, isMoraPitchHigh} from '../language/ja/japanese.js'; +import {AnkiTemplateRendererContentManager} from './anki-template-renderer-content-manager.js'; +import {TemplateRendererMediaProvider} from './template-renderer-media-provider.js'; +import {TemplateRenderer} from './template-renderer.js'; + +/** + * This class contains all Anki-specific template rendering functionality. It is built on + * the generic TemplateRenderer class and various other Anki-related classes. + */ +export class AnkiTemplateRenderer { + /** + * Creates a new instance of the class. + */ + constructor() { + /** @type {CssStyleApplier} */ + this._structuredContentStyleApplier = new CssStyleApplier('/data/structured-content-style.json'); + /** @type {CssStyleApplier} */ + this._pronunciationStyleApplier = new CssStyleApplier('/data/pronunciation-style.json'); + /** @type {RegExp} */ + this._structuredContentDatasetKeyIgnorePattern = /^sc([^a-z]|$)/; + /** @type {TemplateRenderer} */ + this._templateRenderer = new TemplateRenderer(); + /** @type {TemplateRendererMediaProvider} */ + this._mediaProvider = new TemplateRendererMediaProvider(); + /** @type {?(Map[])} */ + this._stateStack = null; + /** @type {?import('anki-note-builder').Requirement[]} */ + this._requirements = null; + /** @type {(() => void)[]} */ + this._cleanupCallbacks = []; + /** @type {?HTMLElement} */ + this._temporaryElement = null; + } + + /** + * Gets the generic TemplateRenderer instance. + * @type {TemplateRenderer} + */ + get templateRenderer() { + return this._templateRenderer; + } + + /** + * Prepares the data that is necessary before the template renderer can be safely used. + */ + async prepare() { + /* eslint-disable @stylistic/no-multi-spaces */ + this._templateRenderer.registerHelpers([ + ['dumpObject', this._dumpObject.bind(this)], + ['furigana', this._furigana.bind(this)], + ['furiganaPlain', this._furiganaPlain.bind(this)], + ['multiLine', this._multiLine.bind(this)], + ['regexReplace', this._regexReplace.bind(this)], + ['regexMatch', this._regexMatch.bind(this)], + ['mergeTags', this._mergeTags.bind(this)], + ['eachUpTo', this._eachUpTo.bind(this)], + ['spread', this._spread.bind(this)], + ['op', this._op.bind(this)], + ['get', this._get.bind(this)], + ['set', this._set.bind(this)], + ['scope', this._scope.bind(this)], + ['property', this._property.bind(this)], + ['noop', this._noop.bind(this)], + ['isMoraPitchHigh', this._isMoraPitchHigh.bind(this)], + ['getKanaMorae', this._getKanaMorae.bind(this)], + ['typeof', this._getTypeof.bind(this)], + ['join', this._join.bind(this)], + ['concat', this._concat.bind(this)], + ['pitchCategories', this._pitchCategories.bind(this)], + ['formatGlossary', this._formatGlossary.bind(this)], + ['hasMedia', this._hasMedia.bind(this)], + ['getMedia', this._getMedia.bind(this)], + ['pronunciation', this._pronunciation.bind(this)], + ['hiragana', this._hiragana.bind(this)], + ['katakana', this._katakana.bind(this)] + ]); + /* eslint-enable @stylistic/no-multi-spaces */ + this._templateRenderer.registerDataType('ankiNote', { + modifier: ({marker, commonData}) => createAnkiNoteData(marker, commonData), + composeData: ({marker}, commonData) => ({marker, commonData}) + }); + this._templateRenderer.setRenderCallbacks( + this._onRenderSetup.bind(this), + this._onRenderCleanup.bind(this) + ); + await Promise.all([ + this._structuredContentStyleApplier.prepare(), + this._pronunciationStyleApplier.prepare() + ]); + } + + // Private + + /** + * @returns {{requirements: import('anki-note-builder').Requirement[]}} + */ + _onRenderSetup() { + /** @type {import('anki-note-builder').Requirement[]} */ + const requirements = []; + this._stateStack = [new Map()]; + this._requirements = requirements; + this._mediaProvider.requirements = requirements; + return {requirements}; + } + + /** + * @returns {void} + */ + _onRenderCleanup() { + for (const callback of this._cleanupCallbacks) { callback(); } + this._stateStack = null; + this._requirements = null; + this._mediaProvider.requirements = null; + this._cleanupCallbacks.length = 0; + } + + /** + * @param {string} text + * @returns {string} + */ + _safeString(text) { + return new Handlebars.SafeString(text); + } + + // Template helpers + + /** @type {import('template-renderer').HelperFunction} */ + _dumpObject(object) { + return JSON.stringify(object, null, 4); + } + + /** @type {import('template-renderer').HelperFunction} */ + _furigana(args, context, options) { + const {expression, reading} = this._getFuriganaExpressionAndReading(args, context, options); + const segments = distributeFurigana(expression, reading); + + let result = ''; + for (const {text, reading: reading2} of segments) { + result += ( + reading2.length > 0 ? + `${text}${reading2}` : + text + ); + } + + return this._safeString(result); + } + + /** @type {import('template-renderer').HelperFunction} */ + _furiganaPlain(args, context, options) { + const {expression, reading} = this._getFuriganaExpressionAndReading(args, context, options); + const segments = distributeFurigana(expression, reading); + + let result = ''; + for (const {text, reading: reading2} of segments) { + if (reading2.length > 0) { + if (result.length > 0) { result += ' '; } + result += `${text}[${reading2}]`; + } else { + result += text; + } + } + + return result; + } + + /** + * @type {import('template-renderer').HelperFunction<{expression: string, reading: string}>} + */ + _getFuriganaExpressionAndReading(args) { + let expression; + let reading; + if (args.length >= 2) { + [expression, reading] = /** @type {[expression?: string, reading?: string]} */ (args); + } else { + ({expression, reading} = /** @type {import('core').SerializableObject} */ (args[0])); + } + return { + expression: typeof expression === 'string' ? expression : '', + reading: typeof reading === 'string' ? reading : '' + }; + } + + /** + * @param {string} string + * @returns {string} + */ + _stringToMultiLineHtml(string) { + return string.split('\n').join('
'); + } + + /** @type {import('template-renderer').HelperFunction} */ + _multiLine(_args, context, options) { + return this._stringToMultiLineHtml(this._computeValueString(options, context)); + } + + /** + * Usage: + * ```{{#regexReplace regex string [flags] [content]...}}content{{/regexReplace}}``` + * - regex: regular expression string + * - string: string to replace + * - flags: optional flags for regular expression. + * e.g. "i" for case-insensitive, "g" for replace all + * @type {import('template-renderer').HelperFunction} + */ + _regexReplace(args, context, options) { + const argCount = args.length; + let value = this._computeValueString(options, context); + if (argCount > 3) { + value = `${args.slice(3).join('')}${value}`; + } + if (argCount > 1) { + try { + const [pattern, replacement, flags] = args; + if (typeof pattern !== 'string') { throw new Error('Invalid pattern'); } + if (typeof replacement !== 'string') { throw new Error('Invalid replacement'); } + const regex = new RegExp(pattern, typeof flags === 'string' ? flags : 'g'); + value = value.replace(regex, replacement); + } catch (e) { + return `${e}`; + } + } + return value; + } + + /** + * Usage: + * {{#regexMatch regex [flags] [content]...}}content{{/regexMatch}} + * - regex: regular expression string + * - flags: optional flags for regular expression + * e.g. "i" for case-insensitive, "g" for match all + * @type {import('template-renderer').HelperFunction} + */ + _regexMatch(args, context, options) { + const argCount = args.length; + let value = this._computeValueString(options, context); + if (argCount > 2) { + value = `${args.slice(2).join('')}${value}`; + } + if (argCount > 0) { + try { + const [pattern, flags] = args; + if (typeof pattern !== 'string') { throw new Error('Invalid pattern'); } + const regex = new RegExp(pattern, typeof flags === 'string' ? flags : ''); + /** @type {string[]} */ + const parts = []; + value.replace(regex, (g0) => { + parts.push(g0); + return g0; + }); + value = parts.join(''); + } catch (e) { + return `${e}`; + } + } + return value; + } + + /** + * @type {import('template-renderer').HelperFunction} + */ + _mergeTags(args) { + const [object, isGroupMode, isMergeMode] = /** @type {[object: import('anki-templates').TermDictionaryEntry, isGroupMode: boolean, isMergeMode: boolean]} */ (args); + const tagSources = []; + if (isGroupMode || isMergeMode) { + const {definitions} = object; + if (Array.isArray(definitions)) { + for (const definition of definitions) { + tagSources.push(definition.definitionTags); + } + } + } else { + tagSources.push(object.definitionTags); + } + + const tags = new Set(); + for (const tagSource of tagSources) { + if (!Array.isArray(tagSource)) { continue; } + for (const tag of tagSource) { + tags.add(tag.name); + } + } + + return [...tags].join(', '); + } + + /** @type {import('template-renderer').HelperFunction} */ + _eachUpTo(args, context, options) { + const [iterable, maxCount] = /** @type {[iterable: Iterable, maxCount: number]} */ (args); + if (iterable) { + const results = []; + let any = false; + for (const entry of iterable) { + any = true; + if (results.length >= maxCount) { break; } + const processedEntry = this._computeValue(options, entry); + results.push(processedEntry); + } + if (any) { + return results.join(''); + } + } + return this._computeInverseString(options, context); + } + + /** @type {import('template-renderer').HelperFunction} */ + _spread(args) { + const result = []; + for (const array of /** @type {Iterable[]} */ (args)) { + try { + result.push(...array); + } catch (e) { + // NOP + } + } + return result; + } + + /** @type {import('template-renderer').HelperFunction} */ + _op(args) { + const [operator] = /** @type {[operator: string, operand1: import('core').SafeAny, operand2?: import('core').SafeAny, operand3?: import('core').SafeAny]} */ (args); + switch (args.length) { + case 2: return this._evaluateUnaryExpression(operator, args[1]); + case 3: return this._evaluateBinaryExpression(operator, args[1], args[2]); + case 4: return this._evaluateTernaryExpression(operator, args[1], args[2], args[3]); + default: return void 0; + } + } + + /** + * @param {string} operator + * @param {import('core').SafeAny} operand1 + * @returns {unknown} + */ + _evaluateUnaryExpression(operator, operand1) { + switch (operator) { + case '+': return +operand1; + case '-': return -operand1; + case '~': return ~operand1; + case '!': return !operand1; + default: return void 0; + } + } + + /** + * @param {string} operator + * @param {import('core').SafeAny} operand1 + * @param {import('core').SafeAny} operand2 + * @returns {unknown} + */ + _evaluateBinaryExpression(operator, operand1, operand2) { + switch (operator) { + case '+': return operand1 + operand2; + case '-': return operand1 - operand2; + case '/': return operand1 / operand2; + case '*': return operand1 * operand2; + case '%': return operand1 % operand2; + case '**': return operand1 ** operand2; + case '==': return operand1 == operand2; // eslint-disable-line eqeqeq + case '!=': return operand1 != operand2; // eslint-disable-line eqeqeq + case '===': return operand1 === operand2; + case '!==': return operand1 !== operand2; + case '<': return operand1 < operand2; + case '<=': return operand1 <= operand2; + case '>': return operand1 > operand2; + case '>=': return operand1 >= operand2; + case '<<': return operand1 << operand2; + case '>>': return operand1 >> operand2; + case '>>>': return operand1 >>> operand2; + case '&': return operand1 & operand2; + case '|': return operand1 | operand2; + case '^': return operand1 ^ operand2; + case '&&': return operand1 && operand2; + case '||': return operand1 || operand2; + default: return void 0; + } + } + + /** + * @param {string} operator + * @param {import('core').SafeAny} operand1 + * @param {import('core').SafeAny} operand2 + * @param {import('core').SafeAny} operand3 + * @returns {unknown} + */ + _evaluateTernaryExpression(operator, operand1, operand2, operand3) { + switch (operator) { + case '?:': return operand1 ? operand2 : operand3; + default: return void 0; + } + } + + /** @type {import('template-renderer').HelperFunction} */ + _get(args) { + const [key] = /** @type {[key: string]} */ (args); + const stateStack = this._stateStack; + if (stateStack === null) { throw new Error('Invalid state'); } + for (let i = stateStack.length; --i >= 0;) { + const map = stateStack[i]; + if (map.has(key)) { + return map.get(key); + } + } + return void 0; + } + + /** @type {import('template-renderer').HelperFunction} */ + _set(args, context, options) { + const stateStack = this._stateStack; + if (stateStack === null) { throw new Error('Invalid state'); } + switch (args.length) { + case 1: + { + const [key] = /** @type {[key: string]} */ (args); + const value = this._computeValue(options, context); + stateStack[stateStack.length - 1].set(key, value); + } + break; + case 2: + { + const [key, value] = /** @type {[key: string, value: unknown]} */ (args); + stateStack[stateStack.length - 1].set(key, value); + } + break; + } + return ''; + } + + /** @type {import('template-renderer').HelperFunction} */ + _scope(_args, context, options) { + const stateStack = this._stateStack; + if (stateStack === null) { throw new Error('Invalid state'); } + try { + stateStack.push(new Map()); + return this._computeValue(options, context); + } finally { + if (stateStack.length > 1) { + stateStack.pop(); + } + } + } + + /** @type {import('template-renderer').HelperFunction} */ + _property(args) { + const ii = args.length; + if (ii <= 0) { return void 0; } + + try { + let value = args[0]; + for (let i = 1; i < ii; ++i) { + if (typeof value !== 'object' || value === null) { throw new Error('Invalid object'); } + const key = args[i]; + switch (typeof key) { + case 'number': + case 'string': + case 'symbol': + break; + default: + throw new Error('Invalid key'); + } + value = /** @type {import('core').UnknownObject} */ (value)[key]; + } + return value; + } catch (e) { + return void 0; + } + } + + /** @type {import('template-renderer').HelperFunction} */ + _noop(_args, context, options) { + return this._computeValue(options, context); + } + + /** @type {import('template-renderer').HelperFunction} */ + _isMoraPitchHigh(args) { + const [index, position] = /** @type {[index: number, position: number]} */ (args); + return isMoraPitchHigh(index, position); + } + + /** @type {import('template-renderer').HelperFunction} */ + _getKanaMorae(args) { + const [text] = /** @type {[text: string]} */ (args); + return getKanaMorae(`${text}`); + } + + /** @type {import('template-renderer').HelperFunction} */ + _getTypeof(args, context, options) { + const ii = args.length; + const value = (ii > 0 ? args[0] : this._computeValue(options, context)); + return typeof value; + } + + /** @type {import('template-renderer').HelperFunction} */ + _join(args) { + return args.length > 0 ? args.slice(1, args.length).flat().join(/** @type {string} */ (args[0])) : ''; + } + + /** @type {import('template-renderer').HelperFunction} */ + _concat(args) { + let result = ''; + for (let i = 0, ii = args.length; i < ii; ++i) { + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + result += args[i]; + } + return result; + } + + /** @type {import('template-renderer').HelperFunction} */ + _pitchCategories(args) { + const [data] = /** @type {[data: import('anki-templates').NoteData]} */ (args); + const {dictionaryEntry} = data; + if (dictionaryEntry.type !== 'term') { return []; } + const {pronunciations: termPronunciations, headwords} = dictionaryEntry; + /** @type {Set} */ + const categories = new Set(); + for (const {headwordIndex, pronunciations} of termPronunciations) { + const {reading, wordClasses} = headwords[headwordIndex]; + const isVerbOrAdjective = isNonNounVerbOrAdjective(wordClasses); + const pitches = getPronunciationsOfType(pronunciations, 'pitch-accent'); + for (const {position} of pitches) { + const category = getPitchCategory(reading, position, isVerbOrAdjective); + if (category !== null) { + categories.add(category); + } + } + } + return [...categories]; + } + + /** + * @returns {HTMLElement} + */ + _getTemporaryElement() { + let element = this._temporaryElement; + if (element === null) { + element = document.createElement('div'); + this._temporaryElement = element; + } + return element; + } + + /** + * @param {Element} node + * @returns {string} + */ + _getStructuredContentHtml(node) { + return this._getHtml(node, this._structuredContentStyleApplier, this._structuredContentDatasetKeyIgnorePattern); + } + + /** + * @param {Element} node + * @returns {string} + */ + _getPronunciationHtml(node) { + return this._getHtml(node, this._pronunciationStyleApplier, null); + } + + /** + * @param {Element} node + * @param {CssStyleApplier} styleApplier + * @param {?RegExp} datasetKeyIgnorePattern + * @returns {string} + */ + _getHtml(node, styleApplier, datasetKeyIgnorePattern) { + const container = this._getTemporaryElement(); + container.appendChild(node); + this._normalizeHtml(container, styleApplier, datasetKeyIgnorePattern); + const result = container.innerHTML; + container.textContent = ''; + return this._safeString(result); + } + + /** + * @param {Element} root + * @param {CssStyleApplier} styleApplier + * @param {?RegExp} datasetKeyIgnorePattern + */ + _normalizeHtml(root, styleApplier, datasetKeyIgnorePattern) { + const {ELEMENT_NODE, TEXT_NODE} = Node; + const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + /** @type {HTMLElement[]} */ + const elements = []; + /** @type {Text[]} */ + const textNodes = []; + while (true) { + const node = treeWalker.nextNode(); + if (node === null) { break; } + switch (node.nodeType) { + case ELEMENT_NODE: + elements.push(/** @type {HTMLElement} */ (node)); + break; + case TEXT_NODE: + textNodes.push(/** @type {Text} */ (node)); + break; + } + } + styleApplier.applyClassStyles(elements); + for (const element of elements) { + const {dataset} = element; + for (const key of Object.keys(dataset)) { + if (datasetKeyIgnorePattern !== null && datasetKeyIgnorePattern.test(key)) { continue; } + delete dataset[key]; + } + } + for (const textNode of textNodes) { + this._replaceNewlines(textNode); + } + } + + /** + * @param {Text} textNode + */ + _replaceNewlines(textNode) { + const parts = /** @type {string} */ (textNode.nodeValue).split('\n'); + if (parts.length <= 1) { return; } + const {parentNode} = textNode; + if (parentNode === null) { return; } + const fragment = document.createDocumentFragment(); + for (let i = 0, ii = parts.length; i < ii; ++i) { + if (i > 0) { fragment.appendChild(document.createElement('br')); } + fragment.appendChild(document.createTextNode(parts[i])); + } + parentNode.replaceChild(fragment, textNode); + } + + /** + * @param {import('anki-templates').NoteData} data + * @returns {StructuredContentGenerator} + */ + _createStructuredContentGenerator(data) { + const contentManager = new AnkiTemplateRendererContentManager(this._mediaProvider, data); + const instance = new StructuredContentGenerator(contentManager, document); + this._cleanupCallbacks.push(() => contentManager.unloadAll()); + return instance; + } + + /** + * @type {import('template-renderer').HelperFunction} + */ + _formatGlossary(args, _context, options) { + const [dictionary, content] = /** @type {[dictionary: string, content: import('dictionary-data').TermGlossaryContent]} */ (args); + /** @type {import('anki-templates').NoteData} */ + const data = options.data.root; + if (typeof content === 'string') { return this._safeString(this._stringToMultiLineHtml(content)); } + if (!(typeof content === 'object' && content !== null)) { return ''; } + switch (content.type) { + case 'image': return this._formatGlossaryImage(content, dictionary, data); + case 'structured-content': return this._formatStructuredContent(content, dictionary, data); + case 'text': return this._safeString(this._stringToMultiLineHtml(content.text)); + } + return ''; + } + + /** + * @param {import('dictionary-data').TermGlossaryImage} content + * @param {string} dictionary + * @param {import('anki-templates').NoteData} data + * @returns {string} + */ + _formatGlossaryImage(content, dictionary, data) { + const structuredContentGenerator = this._createStructuredContentGenerator(data); + const node = structuredContentGenerator.createDefinitionImage(content, dictionary); + return this._getStructuredContentHtml(node); + } + + /** + * @param {import('dictionary-data').TermGlossaryStructuredContent} content + * @param {string} dictionary + * @param {import('anki-templates').NoteData} data + * @returns {string} + */ + _formatStructuredContent(content, dictionary, data) { + const structuredContentGenerator = this._createStructuredContentGenerator(data); + const node = structuredContentGenerator.createStructuredContent(content.content, dictionary); + return node !== null ? this._getStructuredContentHtml(node) : ''; + } + + /** + * @type {import('template-renderer').HelperFunction} + */ + _hasMedia(args, _context, options) { + /** @type {import('anki-templates').NoteData} */ + const data = options.data.root; + return this._mediaProvider.hasMedia(data, args, options.hash); + } + + /** + * @type {import('template-renderer').HelperFunction} + */ + _getMedia(args, _context, options) { + /** @type {import('anki-templates').NoteData} */ + const data = options.data.root; + return this._mediaProvider.getMedia(data, args, options.hash); + } + + /** + * @type {import('template-renderer').HelperFunction} + */ + _pronunciation(_args, _context, options) { + const {format, reading, downstepPosition} = options.hash; + + if ( + typeof reading !== 'string' || + reading.length === 0 || + typeof downstepPosition !== 'number' + ) { + return ''; + } + const morae = getKanaMorae(reading); + + switch (format) { + case 'text': + { + const nasalPositions = this._getValidNumberArray(options.hash.nasalPositions); + const devoicePositions = this._getValidNumberArray(options.hash.devoicePositions); + return this._getPronunciationHtml(createPronunciationText(morae, downstepPosition, nasalPositions, devoicePositions)); + } + case 'graph': + return this._getPronunciationHtml(createPronunciationGraph(morae, downstepPosition)); + case 'position': + return this._getPronunciationHtml(createPronunciationDownstepPosition(downstepPosition)); + default: + return ''; + } + } + + /** + * @param {unknown} value + * @returns {number[]} + */ + _getValidNumberArray(value) { + const result = []; + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === 'number') { result.push(item); } + } + } + return result; + } + + /** + * @type {import('template-renderer').HelperFunction} + */ + _hiragana(args, context, options) { + const ii = args.length; + const {keepProlongedSoundMarks} = options.hash; + const value = (ii > 0 ? args[0] : this._computeValue(options, context)); + return typeof value === 'string' ? convertKatakanaToHiragana(value, keepProlongedSoundMarks === true) : ''; + } + + /** + * @type {import('template-renderer').HelperFunction} + */ + _katakana(args, context, options) { + const ii = args.length; + const value = (ii > 0 ? args[0] : this._computeValue(options, context)); + return typeof value === 'string' ? convertHiraganaToKatakana(value) : ''; + } + + /** + * @param {unknown} value + * @returns {string} + */ + _asString(value) { + return typeof value === 'string' ? value : `${value}`; + } + + /** + * @param {import('template-renderer').HelperOptions} options + * @param {unknown} context + * @returns {unknown} + */ + _computeValue(options, context) { + return typeof options.fn === 'function' ? options.fn(context) : ''; + } + + /** + * @param {import('template-renderer').HelperOptions} options + * @param {unknown} context + * @returns {string} + */ + _computeValueString(options, context) { + return this._asString(this._computeValue(options, context)); + } + + /** + * @param {import('template-renderer').HelperOptions} options + * @param {unknown} context + * @returns {unknown} + */ + _computeInverse(options, context) { + return typeof options.inverse === 'function' ? options.inverse(context) : ''; + } + + /** + * @param {import('template-renderer').HelperOptions} options + * @param {unknown} context + * @returns {string} + */ + _computeInverseString(options, context) { + return this._asString(this._computeInverse(options, context)); + } +} diff --git a/ext/js/templates/sandbox/anki-template-renderer-content-manager.js b/ext/js/templates/sandbox/anki-template-renderer-content-manager.js deleted file mode 100644 index 664746bf..00000000 --- a/ext/js/templates/sandbox/anki-template-renderer-content-manager.js +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright (C) 2023-2024 Yomitan Authors - * Copyright (C) 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 . - */ - -/** - * The content manager which is used when generating content for Anki. - */ -export class AnkiTemplateRendererContentManager { - /** - * Creates a new instance of the class. - * @param {import('./template-renderer-media-provider.js').TemplateRendererMediaProvider} mediaProvider The media provider for the object. - * @param {import('anki-templates').NoteData} data The data object passed to the Handlebars template renderer. - */ - constructor(mediaProvider, data) { - /** @type {import('./template-renderer-media-provider.js').TemplateRendererMediaProvider} */ - this._mediaProvider = mediaProvider; - /** @type {import('anki-templates').NoteData} */ - this._data = data; - /** @type {import('anki-template-renderer-content-manager').OnUnloadCallback[]} */ - this._onUnloadCallbacks = []; - } - - /** - * Attempts to load the media file from a given dictionary. - * @param {string} path The path to the media file in the dictionary. - * @param {string} dictionary The name of the dictionary. - * @param {import('anki-template-renderer-content-manager').OnLoadCallback} onLoad The callback that is executed if the media was loaded successfully. - * No assumptions should be made about the synchronicity of this callback. - * @param {import('anki-template-renderer-content-manager').OnUnloadCallback} onUnload The callback that is executed when the media should be unloaded. - */ - loadMedia(path, dictionary, onLoad, onUnload) { - const imageUrl = this._mediaProvider.getMedia(this._data, ['dictionaryMedia', path], {dictionary, format: 'fileName', default: null}); - if (imageUrl === null) { return; } - onLoad(imageUrl); - if (typeof onUnload === 'function') { - this._onUnloadCallbacks.push(onUnload); - } - } - - /** - * Unloads all media that has been loaded. - */ - unloadAll() { - for (const onUnload of this._onUnloadCallbacks) { - onUnload(true); - } - this._onUnloadCallbacks = []; - } - - /** - * Sets up attributes and events for a link element. - * @param {HTMLAnchorElement} element The link element. - * @param {string} href The URL. - * @param {boolean} internal Whether or not the URL is an internal or external link. - */ - prepareLink(element, href, internal) { - element.href = internal ? '#' : href; - } -} diff --git a/ext/js/templates/sandbox/anki-template-renderer.js b/ext/js/templates/sandbox/anki-template-renderer.js deleted file mode 100644 index 022716c3..00000000 --- a/ext/js/templates/sandbox/anki-template-renderer.js +++ /dev/null @@ -1,827 +0,0 @@ -/* - * Copyright (C) 2023-2024 Yomitan Authors - * Copyright (C) 2021-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 . - */ - -import {Handlebars} from '../../../lib/handlebars.js'; -import {createAnkiNoteData} from '../../data/sandbox/anki-note-data-creator.js'; -import {getPronunciationsOfType, isNonNounVerbOrAdjective} from '../../dictionary/dictionary-data-util.js'; -import {createPronunciationDownstepPosition, createPronunciationGraph, createPronunciationText} from '../../display/sandbox/pronunciation-generator.js'; -import {StructuredContentGenerator} from '../../display/sandbox/structured-content-generator.js'; -import {CssStyleApplier} from '../../dom/sandbox/css-style-applier.js'; -import {convertHiraganaToKatakana, convertKatakanaToHiragana, distributeFurigana, getKanaMorae, getPitchCategory, isMoraPitchHigh} from '../../language/ja/japanese.js'; -import {AnkiTemplateRendererContentManager} from './anki-template-renderer-content-manager.js'; -import {TemplateRendererMediaProvider} from './template-renderer-media-provider.js'; -import {TemplateRenderer} from './template-renderer.js'; - -/** - * This class contains all Anki-specific template rendering functionality. It is built on - * the generic TemplateRenderer class and various other Anki-related classes. - */ -export class AnkiTemplateRenderer { - /** - * Creates a new instance of the class. - */ - constructor() { - /** @type {CssStyleApplier} */ - this._structuredContentStyleApplier = new CssStyleApplier('/data/structured-content-style.json'); - /** @type {CssStyleApplier} */ - this._pronunciationStyleApplier = new CssStyleApplier('/data/pronunciation-style.json'); - /** @type {RegExp} */ - this._structuredContentDatasetKeyIgnorePattern = /^sc([^a-z]|$)/; - /** @type {TemplateRenderer} */ - this._templateRenderer = new TemplateRenderer(); - /** @type {TemplateRendererMediaProvider} */ - this._mediaProvider = new TemplateRendererMediaProvider(); - /** @type {?(Map[])} */ - this._stateStack = null; - /** @type {?import('anki-note-builder').Requirement[]} */ - this._requirements = null; - /** @type {(() => void)[]} */ - this._cleanupCallbacks = []; - /** @type {?HTMLElement} */ - this._temporaryElement = null; - } - - /** - * Gets the generic TemplateRenderer instance. - * @type {TemplateRenderer} - */ - get templateRenderer() { - return this._templateRenderer; - } - - /** - * Prepares the data that is necessary before the template renderer can be safely used. - */ - async prepare() { - /* eslint-disable @stylistic/no-multi-spaces */ - this._templateRenderer.registerHelpers([ - ['dumpObject', this._dumpObject.bind(this)], - ['furigana', this._furigana.bind(this)], - ['furiganaPlain', this._furiganaPlain.bind(this)], - ['multiLine', this._multiLine.bind(this)], - ['regexReplace', this._regexReplace.bind(this)], - ['regexMatch', this._regexMatch.bind(this)], - ['mergeTags', this._mergeTags.bind(this)], - ['eachUpTo', this._eachUpTo.bind(this)], - ['spread', this._spread.bind(this)], - ['op', this._op.bind(this)], - ['get', this._get.bind(this)], - ['set', this._set.bind(this)], - ['scope', this._scope.bind(this)], - ['property', this._property.bind(this)], - ['noop', this._noop.bind(this)], - ['isMoraPitchHigh', this._isMoraPitchHigh.bind(this)], - ['getKanaMorae', this._getKanaMorae.bind(this)], - ['typeof', this._getTypeof.bind(this)], - ['join', this._join.bind(this)], - ['concat', this._concat.bind(this)], - ['pitchCategories', this._pitchCategories.bind(this)], - ['formatGlossary', this._formatGlossary.bind(this)], - ['hasMedia', this._hasMedia.bind(this)], - ['getMedia', this._getMedia.bind(this)], - ['pronunciation', this._pronunciation.bind(this)], - ['hiragana', this._hiragana.bind(this)], - ['katakana', this._katakana.bind(this)] - ]); - /* eslint-enable @stylistic/no-multi-spaces */ - this._templateRenderer.registerDataType('ankiNote', { - modifier: ({marker, commonData}) => createAnkiNoteData(marker, commonData), - composeData: ({marker}, commonData) => ({marker, commonData}) - }); - this._templateRenderer.setRenderCallbacks( - this._onRenderSetup.bind(this), - this._onRenderCleanup.bind(this) - ); - await Promise.all([ - this._structuredContentStyleApplier.prepare(), - this._pronunciationStyleApplier.prepare() - ]); - } - - // Private - - /** - * @returns {{requirements: import('anki-note-builder').Requirement[]}} - */ - _onRenderSetup() { - /** @type {import('anki-note-builder').Requirement[]} */ - const requirements = []; - this._stateStack = [new Map()]; - this._requirements = requirements; - this._mediaProvider.requirements = requirements; - return {requirements}; - } - - /** - * @returns {void} - */ - _onRenderCleanup() { - for (const callback of this._cleanupCallbacks) { callback(); } - this._stateStack = null; - this._requirements = null; - this._mediaProvider.requirements = null; - this._cleanupCallbacks.length = 0; - } - - /** - * @param {string} text - * @returns {string} - */ - _safeString(text) { - return new Handlebars.SafeString(text); - } - - // Template helpers - - /** @type {import('template-renderer').HelperFunction} */ - _dumpObject(object) { - return JSON.stringify(object, null, 4); - } - - /** @type {import('template-renderer').HelperFunction} */ - _furigana(args, context, options) { - const {expression, reading} = this._getFuriganaExpressionAndReading(args, context, options); - const segments = distributeFurigana(expression, reading); - - let result = ''; - for (const {text, reading: reading2} of segments) { - result += ( - reading2.length > 0 ? - `${text}${reading2}` : - text - ); - } - - return this._safeString(result); - } - - /** @type {import('template-renderer').HelperFunction} */ - _furiganaPlain(args, context, options) { - const {expression, reading} = this._getFuriganaExpressionAndReading(args, context, options); - const segments = distributeFurigana(expression, reading); - - let result = ''; - for (const {text, reading: reading2} of segments) { - if (reading2.length > 0) { - if (result.length > 0) { result += ' '; } - result += `${text}[${reading2}]`; - } else { - result += text; - } - } - - return result; - } - - /** - * @type {import('template-renderer').HelperFunction<{expression: string, reading: string}>} - */ - _getFuriganaExpressionAndReading(args) { - let expression; - let reading; - if (args.length >= 2) { - [expression, reading] = /** @type {[expression?: string, reading?: string]} */ (args); - } else { - ({expression, reading} = /** @type {import('core').SerializableObject} */ (args[0])); - } - return { - expression: typeof expression === 'string' ? expression : '', - reading: typeof reading === 'string' ? reading : '' - }; - } - - /** - * @param {string} string - * @returns {string} - */ - _stringToMultiLineHtml(string) { - return string.split('\n').join('
'); - } - - /** @type {import('template-renderer').HelperFunction} */ - _multiLine(_args, context, options) { - return this._stringToMultiLineHtml(this._computeValueString(options, context)); - } - - /** - * Usage: - * ```{{#regexReplace regex string [flags] [content]...}}content{{/regexReplace}}``` - * - regex: regular expression string - * - string: string to replace - * - flags: optional flags for regular expression. - * e.g. "i" for case-insensitive, "g" for replace all - * @type {import('template-renderer').HelperFunction} - */ - _regexReplace(args, context, options) { - const argCount = args.length; - let value = this._computeValueString(options, context); - if (argCount > 3) { - value = `${args.slice(3).join('')}${value}`; - } - if (argCount > 1) { - try { - const [pattern, replacement, flags] = args; - if (typeof pattern !== 'string') { throw new Error('Invalid pattern'); } - if (typeof replacement !== 'string') { throw new Error('Invalid replacement'); } - const regex = new RegExp(pattern, typeof flags === 'string' ? flags : 'g'); - value = value.replace(regex, replacement); - } catch (e) { - return `${e}`; - } - } - return value; - } - - /** - * Usage: - * {{#regexMatch regex [flags] [content]...}}content{{/regexMatch}} - * - regex: regular expression string - * - flags: optional flags for regular expression - * e.g. "i" for case-insensitive, "g" for match all - * @type {import('template-renderer').HelperFunction} - */ - _regexMatch(args, context, options) { - const argCount = args.length; - let value = this._computeValueString(options, context); - if (argCount > 2) { - value = `${args.slice(2).join('')}${value}`; - } - if (argCount > 0) { - try { - const [pattern, flags] = args; - if (typeof pattern !== 'string') { throw new Error('Invalid pattern'); } - const regex = new RegExp(pattern, typeof flags === 'string' ? flags : ''); - /** @type {string[]} */ - const parts = []; - value.replace(regex, (g0) => { - parts.push(g0); - return g0; - }); - value = parts.join(''); - } catch (e) { - return `${e}`; - } - } - return value; - } - - /** - * @type {import('template-renderer').HelperFunction} - */ - _mergeTags(args) { - const [object, isGroupMode, isMergeMode] = /** @type {[object: import('anki-templates').TermDictionaryEntry, isGroupMode: boolean, isMergeMode: boolean]} */ (args); - const tagSources = []; - if (isGroupMode || isMergeMode) { - const {definitions} = object; - if (Array.isArray(definitions)) { - for (const definition of definitions) { - tagSources.push(definition.definitionTags); - } - } - } else { - tagSources.push(object.definitionTags); - } - - const tags = new Set(); - for (const tagSource of tagSources) { - if (!Array.isArray(tagSource)) { continue; } - for (const tag of tagSource) { - tags.add(tag.name); - } - } - - return [...tags].join(', '); - } - - /** @type {import('template-renderer').HelperFunction} */ - _eachUpTo(args, context, options) { - const [iterable, maxCount] = /** @type {[iterable: Iterable, maxCount: number]} */ (args); - if (iterable) { - const results = []; - let any = false; - for (const entry of iterable) { - any = true; - if (results.length >= maxCount) { break; } - const processedEntry = this._computeValue(options, entry); - results.push(processedEntry); - } - if (any) { - return results.join(''); - } - } - return this._computeInverseString(options, context); - } - - /** @type {import('template-renderer').HelperFunction} */ - _spread(args) { - const result = []; - for (const array of /** @type {Iterable[]} */ (args)) { - try { - result.push(...array); - } catch (e) { - // NOP - } - } - return result; - } - - /** @type {import('template-renderer').HelperFunction} */ - _op(args) { - const [operator] = /** @type {[operator: string, operand1: import('core').SafeAny, operand2?: import('core').SafeAny, operand3?: import('core').SafeAny]} */ (args); - switch (args.length) { - case 2: return this._evaluateUnaryExpression(operator, args[1]); - case 3: return this._evaluateBinaryExpression(operator, args[1], args[2]); - case 4: return this._evaluateTernaryExpression(operator, args[1], args[2], args[3]); - default: return void 0; - } - } - - /** - * @param {string} operator - * @param {import('core').SafeAny} operand1 - * @returns {unknown} - */ - _evaluateUnaryExpression(operator, operand1) { - switch (operator) { - case '+': return +operand1; - case '-': return -operand1; - case '~': return ~operand1; - case '!': return !operand1; - default: return void 0; - } - } - - /** - * @param {string} operator - * @param {import('core').SafeAny} operand1 - * @param {import('core').SafeAny} operand2 - * @returns {unknown} - */ - _evaluateBinaryExpression(operator, operand1, operand2) { - switch (operator) { - case '+': return operand1 + operand2; - case '-': return operand1 - operand2; - case '/': return operand1 / operand2; - case '*': return operand1 * operand2; - case '%': return operand1 % operand2; - case '**': return operand1 ** operand2; - case '==': return operand1 == operand2; // eslint-disable-line eqeqeq - case '!=': return operand1 != operand2; // eslint-disable-line eqeqeq - case '===': return operand1 === operand2; - case '!==': return operand1 !== operand2; - case '<': return operand1 < operand2; - case '<=': return operand1 <= operand2; - case '>': return operand1 > operand2; - case '>=': return operand1 >= operand2; - case '<<': return operand1 << operand2; - case '>>': return operand1 >> operand2; - case '>>>': return operand1 >>> operand2; - case '&': return operand1 & operand2; - case '|': return operand1 | operand2; - case '^': return operand1 ^ operand2; - case '&&': return operand1 && operand2; - case '||': return operand1 || operand2; - default: return void 0; - } - } - - /** - * @param {string} operator - * @param {import('core').SafeAny} operand1 - * @param {import('core').SafeAny} operand2 - * @param {import('core').SafeAny} operand3 - * @returns {unknown} - */ - _evaluateTernaryExpression(operator, operand1, operand2, operand3) { - switch (operator) { - case '?:': return operand1 ? operand2 : operand3; - default: return void 0; - } - } - - /** @type {import('template-renderer').HelperFunction} */ - _get(args) { - const [key] = /** @type {[key: string]} */ (args); - const stateStack = this._stateStack; - if (stateStack === null) { throw new Error('Invalid state'); } - for (let i = stateStack.length; --i >= 0;) { - const map = stateStack[i]; - if (map.has(key)) { - return map.get(key); - } - } - return void 0; - } - - /** @type {import('template-renderer').HelperFunction} */ - _set(args, context, options) { - const stateStack = this._stateStack; - if (stateStack === null) { throw new Error('Invalid state'); } - switch (args.length) { - case 1: - { - const [key] = /** @type {[key: string]} */ (args); - const value = this._computeValue(options, context); - stateStack[stateStack.length - 1].set(key, value); - } - break; - case 2: - { - const [key, value] = /** @type {[key: string, value: unknown]} */ (args); - stateStack[stateStack.length - 1].set(key, value); - } - break; - } - return ''; - } - - /** @type {import('template-renderer').HelperFunction} */ - _scope(_args, context, options) { - const stateStack = this._stateStack; - if (stateStack === null) { throw new Error('Invalid state'); } - try { - stateStack.push(new Map()); - return this._computeValue(options, context); - } finally { - if (stateStack.length > 1) { - stateStack.pop(); - } - } - } - - /** @type {import('template-renderer').HelperFunction} */ - _property(args) { - const ii = args.length; - if (ii <= 0) { return void 0; } - - try { - let value = args[0]; - for (let i = 1; i < ii; ++i) { - if (typeof value !== 'object' || value === null) { throw new Error('Invalid object'); } - const key = args[i]; - switch (typeof key) { - case 'number': - case 'string': - case 'symbol': - break; - default: - throw new Error('Invalid key'); - } - value = /** @type {import('core').UnknownObject} */ (value)[key]; - } - return value; - } catch (e) { - return void 0; - } - } - - /** @type {import('template-renderer').HelperFunction} */ - _noop(_args, context, options) { - return this._computeValue(options, context); - } - - /** @type {import('template-renderer').HelperFunction} */ - _isMoraPitchHigh(args) { - const [index, position] = /** @type {[index: number, position: number]} */ (args); - return isMoraPitchHigh(index, position); - } - - /** @type {import('template-renderer').HelperFunction} */ - _getKanaMorae(args) { - const [text] = /** @type {[text: string]} */ (args); - return getKanaMorae(`${text}`); - } - - /** @type {import('template-renderer').HelperFunction} */ - _getTypeof(args, context, options) { - const ii = args.length; - const value = (ii > 0 ? args[0] : this._computeValue(options, context)); - return typeof value; - } - - /** @type {import('template-renderer').HelperFunction} */ - _join(args) { - return args.length > 0 ? args.slice(1, args.length).flat().join(/** @type {string} */ (args[0])) : ''; - } - - /** @type {import('template-renderer').HelperFunction} */ - _concat(args) { - let result = ''; - for (let i = 0, ii = args.length; i < ii; ++i) { - // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - result += args[i]; - } - return result; - } - - /** @type {import('template-renderer').HelperFunction} */ - _pitchCategories(args) { - const [data] = /** @type {[data: import('anki-templates').NoteData]} */ (args); - const {dictionaryEntry} = data; - if (dictionaryEntry.type !== 'term') { return []; } - const {pronunciations: termPronunciations, headwords} = dictionaryEntry; - /** @type {Set} */ - const categories = new Set(); - for (const {headwordIndex, pronunciations} of termPronunciations) { - const {reading, wordClasses} = headwords[headwordIndex]; - const isVerbOrAdjective = isNonNounVerbOrAdjective(wordClasses); - const pitches = getPronunciationsOfType(pronunciations, 'pitch-accent'); - for (const {position} of pitches) { - const category = getPitchCategory(reading, position, isVerbOrAdjective); - if (category !== null) { - categories.add(category); - } - } - } - return [...categories]; - } - - /** - * @returns {HTMLElement} - */ - _getTemporaryElement() { - let element = this._temporaryElement; - if (element === null) { - element = document.createElement('div'); - this._temporaryElement = element; - } - return element; - } - - /** - * @param {Element} node - * @returns {string} - */ - _getStructuredContentHtml(node) { - return this._getHtml(node, this._structuredContentStyleApplier, this._structuredContentDatasetKeyIgnorePattern); - } - - /** - * @param {Element} node - * @returns {string} - */ - _getPronunciationHtml(node) { - return this._getHtml(node, this._pronunciationStyleApplier, null); - } - - /** - * @param {Element} node - * @param {CssStyleApplier} styleApplier - * @param {?RegExp} datasetKeyIgnorePattern - * @returns {string} - */ - _getHtml(node, styleApplier, datasetKeyIgnorePattern) { - const container = this._getTemporaryElement(); - container.appendChild(node); - this._normalizeHtml(container, styleApplier, datasetKeyIgnorePattern); - const result = container.innerHTML; - container.textContent = ''; - return this._safeString(result); - } - - /** - * @param {Element} root - * @param {CssStyleApplier} styleApplier - * @param {?RegExp} datasetKeyIgnorePattern - */ - _normalizeHtml(root, styleApplier, datasetKeyIgnorePattern) { - const {ELEMENT_NODE, TEXT_NODE} = Node; - const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); - /** @type {HTMLElement[]} */ - const elements = []; - /** @type {Text[]} */ - const textNodes = []; - while (true) { - const node = treeWalker.nextNode(); - if (node === null) { break; } - switch (node.nodeType) { - case ELEMENT_NODE: - elements.push(/** @type {HTMLElement} */ (node)); - break; - case TEXT_NODE: - textNodes.push(/** @type {Text} */ (node)); - break; - } - } - styleApplier.applyClassStyles(elements); - for (const element of elements) { - const {dataset} = element; - for (const key of Object.keys(dataset)) { - if (datasetKeyIgnorePattern !== null && datasetKeyIgnorePattern.test(key)) { continue; } - delete dataset[key]; - } - } - for (const textNode of textNodes) { - this._replaceNewlines(textNode); - } - } - - /** - * @param {Text} textNode - */ - _replaceNewlines(textNode) { - const parts = /** @type {string} */ (textNode.nodeValue).split('\n'); - if (parts.length <= 1) { return; } - const {parentNode} = textNode; - if (parentNode === null) { return; } - const fragment = document.createDocumentFragment(); - for (let i = 0, ii = parts.length; i < ii; ++i) { - if (i > 0) { fragment.appendChild(document.createElement('br')); } - fragment.appendChild(document.createTextNode(parts[i])); - } - parentNode.replaceChild(fragment, textNode); - } - - /** - * @param {import('anki-templates').NoteData} data - * @returns {StructuredContentGenerator} - */ - _createStructuredContentGenerator(data) { - const contentManager = new AnkiTemplateRendererContentManager(this._mediaProvider, data); - const instance = new StructuredContentGenerator(contentManager, document); - this._cleanupCallbacks.push(() => contentManager.unloadAll()); - return instance; - } - - /** - * @type {import('template-renderer').HelperFunction} - */ - _formatGlossary(args, _context, options) { - const [dictionary, content] = /** @type {[dictionary: string, content: import('dictionary-data').TermGlossaryContent]} */ (args); - /** @type {import('anki-templates').NoteData} */ - const data = options.data.root; - if (typeof content === 'string') { return this._safeString(this._stringToMultiLineHtml(content)); } - if (!(typeof content === 'object' && content !== null)) { return ''; } - switch (content.type) { - case 'image': return this._formatGlossaryImage(content, dictionary, data); - case 'structured-content': return this._formatStructuredContent(content, dictionary, data); - case 'text': return this._safeString(this._stringToMultiLineHtml(content.text)); - } - return ''; - } - - /** - * @param {import('dictionary-data').TermGlossaryImage} content - * @param {string} dictionary - * @param {import('anki-templates').NoteData} data - * @returns {string} - */ - _formatGlossaryImage(content, dictionary, data) { - const structuredContentGenerator = this._createStructuredContentGenerator(data); - const node = structuredContentGenerator.createDefinitionImage(content, dictionary); - return this._getStructuredContentHtml(node); - } - - /** - * @param {import('dictionary-data').TermGlossaryStructuredContent} content - * @param {string} dictionary - * @param {import('anki-templates').NoteData} data - * @returns {string} - */ - _formatStructuredContent(content, dictionary, data) { - const structuredContentGenerator = this._createStructuredContentGenerator(data); - const node = structuredContentGenerator.createStructuredContent(content.content, dictionary); - return node !== null ? this._getStructuredContentHtml(node) : ''; - } - - /** - * @type {import('template-renderer').HelperFunction} - */ - _hasMedia(args, _context, options) { - /** @type {import('anki-templates').NoteData} */ - const data = options.data.root; - return this._mediaProvider.hasMedia(data, args, options.hash); - } - - /** - * @type {import('template-renderer').HelperFunction} - */ - _getMedia(args, _context, options) { - /** @type {import('anki-templates').NoteData} */ - const data = options.data.root; - return this._mediaProvider.getMedia(data, args, options.hash); - } - - /** - * @type {import('template-renderer').HelperFunction} - */ - _pronunciation(_args, _context, options) { - const {format, reading, downstepPosition} = options.hash; - - if ( - typeof reading !== 'string' || - reading.length === 0 || - typeof downstepPosition !== 'number' - ) { - return ''; - } - const morae = getKanaMorae(reading); - - switch (format) { - case 'text': - { - const nasalPositions = this._getValidNumberArray(options.hash.nasalPositions); - const devoicePositions = this._getValidNumberArray(options.hash.devoicePositions); - return this._getPronunciationHtml(createPronunciationText(morae, downstepPosition, nasalPositions, devoicePositions)); - } - case 'graph': - return this._getPronunciationHtml(createPronunciationGraph(morae, downstepPosition)); - case 'position': - return this._getPronunciationHtml(createPronunciationDownstepPosition(downstepPosition)); - default: - return ''; - } - } - - /** - * @param {unknown} value - * @returns {number[]} - */ - _getValidNumberArray(value) { - const result = []; - if (Array.isArray(value)) { - for (const item of value) { - if (typeof item === 'number') { result.push(item); } - } - } - return result; - } - - /** - * @type {import('template-renderer').HelperFunction} - */ - _hiragana(args, context, options) { - const ii = args.length; - const {keepProlongedSoundMarks} = options.hash; - const value = (ii > 0 ? args[0] : this._computeValue(options, context)); - return typeof value === 'string' ? convertKatakanaToHiragana(value, keepProlongedSoundMarks === true) : ''; - } - - /** - * @type {import('template-renderer').HelperFunction} - */ - _katakana(args, context, options) { - const ii = args.length; - const value = (ii > 0 ? args[0] : this._computeValue(options, context)); - return typeof value === 'string' ? convertHiraganaToKatakana(value) : ''; - } - - /** - * @param {unknown} value - * @returns {string} - */ - _asString(value) { - return typeof value === 'string' ? value : `${value}`; - } - - /** - * @param {import('template-renderer').HelperOptions} options - * @param {unknown} context - * @returns {unknown} - */ - _computeValue(options, context) { - return typeof options.fn === 'function' ? options.fn(context) : ''; - } - - /** - * @param {import('template-renderer').HelperOptions} options - * @param {unknown} context - * @returns {string} - */ - _computeValueString(options, context) { - return this._asString(this._computeValue(options, context)); - } - - /** - * @param {import('template-renderer').HelperOptions} options - * @param {unknown} context - * @returns {unknown} - */ - _computeInverse(options, context) { - return typeof options.inverse === 'function' ? options.inverse(context) : ''; - } - - /** - * @param {import('template-renderer').HelperOptions} options - * @param {unknown} context - * @returns {string} - */ - _computeInverseString(options, context) { - return this._asString(this._computeInverse(options, context)); - } -} diff --git a/ext/js/templates/sandbox/template-renderer-frame-api.js b/ext/js/templates/sandbox/template-renderer-frame-api.js deleted file mode 100644 index a0017d70..00000000 --- a/ext/js/templates/sandbox/template-renderer-frame-api.js +++ /dev/null @@ -1,103 +0,0 @@ -/* - * 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 . - */ - -import {createApiMap, invokeApiMapHandler} from '../../core/api-map.js'; -import {parseJson} from '../../core/json.js'; - -export class TemplateRendererFrameApi { - /** - * @param {import('./template-renderer.js').TemplateRenderer} templateRenderer - */ - constructor(templateRenderer) { - /** @type {import('./template-renderer.js').TemplateRenderer} */ - this._templateRenderer = templateRenderer; - /** @type {import('template-renderer-proxy').FrontendApiMap} */ - this._windowMessageHandlers = createApiMap([ - ['render', this._onRender.bind(this)], - ['renderMulti', this._onRenderMulti.bind(this)], - ['getModifiedData', this._onGetModifiedData.bind(this)] - ]); - } - - /** - * @returns {void} - */ - prepare() { - window.addEventListener('message', this._onWindowMessage.bind(this), false); - this._postMessage(window.parent, 'ready', void 0, null); - } - - // Private - - /** - * @param {MessageEvent} e - */ - _onWindowMessage(e) { - const {source, data: {action, params, id}} = e; - invokeApiMapHandler(this._windowMessageHandlers, action, params, [], (response) => { - this._postMessage(/** @type {Window} */ (source), 'response', response, id); - }); - } - - /** - * @param {{template: string, data: import('template-renderer').PartialOrCompositeRenderData, type: import('anki-templates').RenderMode}} event - * @returns {import('template-renderer').RenderResult} - */ - _onRender({template, data, type}) { - return this._templateRenderer.render(template, data, type); - } - - /** - * @param {{items: import('template-renderer').RenderMultiItem[]}} event - * @returns {import('core').Response[]} - */ - _onRenderMulti({items}) { - return this._templateRenderer.renderMulti(items); - } - - /** - * @param {{data: import('template-renderer').CompositeRenderData, type: import('anki-templates').RenderMode}} event - * @returns {import('anki-templates').NoteData} - */ - _onGetModifiedData({data, type}) { - const result = this._templateRenderer.getModifiedData(data, type); - return this._clone(result); - } - - /** - * @template [T=unknown] - * @param {T} value - * @returns {T} - */ - _clone(value) { - return parseJson(JSON.stringify(value)); - } - - /** - * @template {import('template-renderer-proxy').BackendApiNames} TName - * @param {Window} target - * @param {TName} action - * @param {import('template-renderer-proxy').BackendApiParams} params - * @param {?string} id - */ - _postMessage(target, action, params, id) { - /** @type {import('template-renderer-proxy').BackendMessageAny} */ - const data = {action, params, id}; - target.postMessage(data, '*'); - } -} diff --git a/ext/js/templates/sandbox/template-renderer-frame-main.js b/ext/js/templates/sandbox/template-renderer-frame-main.js deleted file mode 100644 index 4ab7d2bc..00000000 --- a/ext/js/templates/sandbox/template-renderer-frame-main.js +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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 . - */ - -import {AnkiTemplateRenderer} from './anki-template-renderer.js'; -import {TemplateRendererFrameApi} from './template-renderer-frame-api.js'; - -/** Entry point. */ -async function main() { - const ankiTemplateRenderer = new AnkiTemplateRenderer(); - await ankiTemplateRenderer.prepare(); - const templateRendererFrameApi = new TemplateRendererFrameApi(ankiTemplateRenderer.templateRenderer); - templateRendererFrameApi.prepare(); -} - -await main(); diff --git a/ext/js/templates/sandbox/template-renderer-media-provider.js b/ext/js/templates/sandbox/template-renderer-media-provider.js deleted file mode 100644 index 29dd29ae..00000000 --- a/ext/js/templates/sandbox/template-renderer-media-provider.js +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright (C) 2023-2024 Yomitan Authors - * Copyright (C) 2021-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 . - */ - -import {Handlebars} from '../../../lib/handlebars.js'; - -export class TemplateRendererMediaProvider { - constructor() { - /** @type {?import('anki-note-builder').Requirement[]} */ - this._requirements = null; - } - - /** @type {?import('anki-note-builder').Requirement[]} */ - get requirements() { - return this._requirements; - } - - set requirements(value) { - this._requirements = value; - } - - /** - * @param {import('anki-templates').NoteData} root - * @param {unknown[]} args - * @param {import('core').SerializableObject} namedArgs - * @returns {boolean} - */ - hasMedia(root, args, namedArgs) { - const {media} = root; - const data = this._getMediaData(media, args, namedArgs); - return (data !== null); - } - - /** - * @param {import('anki-templates').NoteData} root - * @param {unknown[]} args - * @param {import('core').SerializableObject} namedArgs - * @returns {?string} - */ - getMedia(root, args, namedArgs) { - const {media} = root; - const data = this._getMediaData(media, args, namedArgs); - if (data !== null) { - const result = this._getFormattedValue(data, namedArgs); - if (typeof result === 'string') { return result; } - } - const defaultValue = namedArgs.default; - return defaultValue === null || typeof defaultValue === 'string' ? defaultValue : ''; - } - - // Private - - /** - * @param {import('anki-note-builder').Requirement} value - */ - _addRequirement(value) { - if (this._requirements === null) { return; } - this._requirements.push(value); - } - - /** - * @param {import('anki-templates').MediaObject} data - * @param {import('core').SerializableObject} namedArgs - * @returns {string} - */ - _getFormattedValue(data, namedArgs) { - let {value} = data; - const {escape = true} = namedArgs; - if (escape) { - value = Handlebars.Utils.escapeExpression(value); - } - return value; - } - - /** - * @param {import('anki-templates').Media} media - * @param {unknown[]} args - * @param {import('core').SerializableObject} namedArgs - * @returns {?(import('anki-templates').MediaObject)} - */ - _getMediaData(media, args, namedArgs) { - const type = args[0]; - switch (type) { - case 'audio': return this._getSimpleMediaData(media, 'audio'); - case 'screenshot': return this._getSimpleMediaData(media, 'screenshot'); - case 'clipboardImage': return this._getSimpleMediaData(media, 'clipboardImage'); - case 'clipboardText': return this._getSimpleMediaData(media, 'clipboardText'); - case 'selectionText': return this._getSimpleMediaData(media, 'selectionText'); - case 'textFurigana': return this._getTextFurigana(media, args[1], namedArgs); - case 'dictionaryMedia': return this._getDictionaryMedia(media, args[1], namedArgs); - default: return null; - } - } - - /** - * @param {import('anki-templates').Media} media - * @param {import('anki-templates').MediaSimpleType} type - * @returns {?import('anki-templates').MediaObject} - */ - _getSimpleMediaData(media, type) { - const result = media[type]; - if (typeof result === 'object' && result !== null) { return result; } - this._addRequirement({type}); - return null; - } - - /** - * @param {import('anki-templates').Media} media - * @param {unknown} path - * @param {import('core').SerializableObject} namedArgs - * @returns {?import('anki-templates').MediaObject} - */ - _getDictionaryMedia(media, path, namedArgs) { - if (typeof path !== 'string') { return null; } - const {dictionaryMedia} = media; - const {dictionary} = namedArgs; - if (typeof dictionary !== 'string') { return null; } - if ( - typeof dictionaryMedia !== 'undefined' && - Object.prototype.hasOwnProperty.call(dictionaryMedia, dictionary) - ) { - const dictionaryMedia2 = dictionaryMedia[dictionary]; - if (Object.prototype.hasOwnProperty.call(dictionaryMedia2, path)) { - const result = dictionaryMedia2[path]; - if (typeof result === 'object' && result !== null) { - return result; - } - } - } - this._addRequirement({ - type: 'dictionaryMedia', - dictionary, - path - }); - return null; - } - - /** - * @param {import('anki-templates').Media} media - * @param {unknown} text - * @param {import('core').SerializableObject} namedArgs - * @returns {?import('anki-templates').MediaObject} - */ - _getTextFurigana(media, text, namedArgs) { - if (typeof text !== 'string') { return null; } - const readingMode = this._normalizeReadingMode(namedArgs.readingMode); - const {textFurigana} = media; - if (Array.isArray(textFurigana)) { - for (const entry of textFurigana) { - if (entry.text !== text || entry.readingMode !== readingMode) { continue; } - return entry.details; - } - } - this._addRequirement({ - type: 'textFurigana', - text, - readingMode - }); - return null; - } - - /** - * @param {unknown} value - * @returns {?import('anki-templates').TextFuriganaReadingMode} - */ - _normalizeReadingMode(value) { - switch (value) { - case 'hiragana': - case 'katakana': - return value; - default: - return null; - } - } -} diff --git a/ext/js/templates/sandbox/template-renderer.js b/ext/js/templates/sandbox/template-renderer.js deleted file mode 100644 index 84eb6a19..00000000 --- a/ext/js/templates/sandbox/template-renderer.js +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright (C) 2023-2024 Yomitan Authors - * Copyright (C) 2016-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 . - */ - -import {Handlebars} from '../../../lib/handlebars.js'; -import {ExtensionError} from '../../core/extension-error.js'; - -export class TemplateRenderer { - constructor() { - /** @type {Map>} */ - this._cache = new Map(); - /** @type {number} */ - this._cacheMaxSize = 5; - /** @type {Map} */ - this._dataTypes = new Map(); - /** @type {?((noteData: import('anki-templates').NoteData) => import('template-renderer').SetupCallbackResult)} */ - this._renderSetup = null; - /** @type {?((noteData: import('anki-templates').NoteData) => import('template-renderer').CleanupCallbackResult)} */ - this._renderCleanup = null; - } - - /** - * @param {import('template-renderer').HelperFunctionsDescriptor} helpers - */ - registerHelpers(helpers) { - for (const [name, helper] of helpers) { - this._registerHelper(name, helper); - } - } - - /** - * @param {import('anki-templates').RenderMode} name - * @param {import('template-renderer').DataType} details - */ - registerDataType(name, {modifier, composeData}) { - this._dataTypes.set(name, {modifier, composeData}); - } - - /** - * @param {?((noteData: import('anki-templates').NoteData) => import('template-renderer').SetupCallbackResult)} setup - * @param {?((noteData: import('anki-templates').NoteData) => import('template-renderer').CleanupCallbackResult)} cleanup - */ - setRenderCallbacks(setup, cleanup) { - this._renderSetup = setup; - this._renderCleanup = cleanup; - } - - /** - * @param {string} template - * @param {import('template-renderer').PartialOrCompositeRenderData} data - * @param {import('anki-templates').RenderMode} type - * @returns {import('template-renderer').RenderResult} - */ - render(template, data, type) { - const instance = this._getTemplateInstance(template); - const modifiedData = this._getModifiedData(data, void 0, type); - return this._renderTemplate(instance, modifiedData); - } - - /** - * @param {import('template-renderer').RenderMultiItem[]} items - * @returns {import('core').Response[]} - */ - renderMulti(items) { - /** @type {import('core').Response[]} */ - const results = []; - for (const {template, templateItems} of items) { - const instance = this._getTemplateInstance(template); - for (const {type, commonData, datas} of templateItems) { - for (const data of datas) { - let result; - try { - const data2 = this._getModifiedData(data, commonData, type); - const renderResult = this._renderTemplate(instance, data2); - result = {result: renderResult}; - } catch (error) { - result = {error: ExtensionError.serialize(error)}; - } - results.push(result); - } - } - } - return results; - } - - /** - * @param {import('template-renderer').CompositeRenderData} data - * @param {import('anki-templates').RenderMode} type - * @returns {import('anki-templates').NoteData} - */ - getModifiedData(data, type) { - return this._getModifiedData(data, void 0, type); - } - - // Private - - /** - * @param {string} template - * @returns {import('handlebars').TemplateDelegate} - */ - _getTemplateInstance(template) { - const cache = this._cache; - let instance = cache.get(template); - if (typeof instance === 'undefined') { - this._updateCacheSize(this._cacheMaxSize - 1); - instance = /** @type {import('handlebars').TemplateDelegate} */ (Handlebars.compileAST(template)); - cache.set(template, instance); - } - - return instance; - } - - /** - * @param {import('handlebars').TemplateDelegate} instance - * @param {import('anki-templates').NoteData} data - * @returns {import('template-renderer').RenderResult} - */ - _renderTemplate(instance, data) { - const renderSetup = this._renderSetup; - const renderCleanup = this._renderCleanup; - /** @type {string} */ - let result; - /** @type {?import('template-renderer').SetupCallbackResult} */ - let additions1; - /** @type {?import('template-renderer').CleanupCallbackResult} */ - let additions2; - try { - additions1 = (typeof renderSetup === 'function' ? renderSetup(data) : null); - result = instance(data).replace(/^\n+|\n+$/g, ''); - } finally { - additions2 = (typeof renderCleanup === 'function' ? renderCleanup(data) : null); - } - return /** @type {import('template-renderer').RenderResult} */ (Object.assign({result}, additions1, additions2)); - } - - /** - * @param {import('template-renderer').PartialOrCompositeRenderData} data - * @param {import('anki-note-builder').CommonData|undefined} commonData - * @param {import('anki-templates').RenderMode} type - * @returns {import('anki-templates').NoteData} - * @throws {Error} - */ - _getModifiedData(data, commonData, type) { - if (typeof type === 'string') { - const typeInfo = this._dataTypes.get(type); - if (typeof typeInfo !== 'undefined') { - if (typeof commonData !== 'undefined') { - const {composeData} = typeInfo; - data = composeData(data, commonData); - } else if (typeof data.commonData === 'undefined') { - throw new Error('Incomplete data'); - } - const {modifier} = typeInfo; - return modifier(/** @type {import('template-renderer').CompositeRenderData} */ (data)); - } - } - throw new Error(`Invalid type: ${type}`); - } - - /** - * @param {number} maxSize - */ - _updateCacheSize(maxSize) { - const cache = this._cache; - let removeCount = cache.size - maxSize; - if (removeCount <= 0) { return; } - - for (const key of cache.keys()) { - cache.delete(key); - if (--removeCount <= 0) { break; } - } - } - - /** - * @param {string} name - * @param {import('template-renderer').HelperFunction} helper - */ - _registerHelper(name, helper) { - /** - * @this {unknown} - * @param {unknown[]} args - * @returns {unknown} - */ - function wrapper(...args) { - const argCountM1 = Math.max(0, args.length - 1); - const options = /** @type {import('handlebars').HelperOptions} */ (args[argCountM1]); - args.length = argCountM1; - return helper(args, this, options); - } - Handlebars.registerHelper(name, wrapper); - } -} diff --git a/ext/js/templates/template-renderer-frame-api.js b/ext/js/templates/template-renderer-frame-api.js new file mode 100644 index 00000000..cd9b4232 --- /dev/null +++ b/ext/js/templates/template-renderer-frame-api.js @@ -0,0 +1,103 @@ +/* + * 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 . + */ + +import {createApiMap, invokeApiMapHandler} from '../core/api-map.js'; +import {parseJson} from '../core/json.js'; + +export class TemplateRendererFrameApi { + /** + * @param {import('./template-renderer.js').TemplateRenderer} templateRenderer + */ + constructor(templateRenderer) { + /** @type {import('./template-renderer.js').TemplateRenderer} */ + this._templateRenderer = templateRenderer; + /** @type {import('template-renderer-proxy').FrontendApiMap} */ + this._windowMessageHandlers = createApiMap([ + ['render', this._onRender.bind(this)], + ['renderMulti', this._onRenderMulti.bind(this)], + ['getModifiedData', this._onGetModifiedData.bind(this)] + ]); + } + + /** + * @returns {void} + */ + prepare() { + window.addEventListener('message', this._onWindowMessage.bind(this), false); + this._postMessage(window.parent, 'ready', void 0, null); + } + + // Private + + /** + * @param {MessageEvent} e + */ + _onWindowMessage(e) { + const {source, data: {action, params, id}} = e; + invokeApiMapHandler(this._windowMessageHandlers, action, params, [], (response) => { + this._postMessage(/** @type {Window} */ (source), 'response', response, id); + }); + } + + /** + * @param {{template: string, data: import('template-renderer').PartialOrCompositeRenderData, type: import('anki-templates').RenderMode}} event + * @returns {import('template-renderer').RenderResult} + */ + _onRender({template, data, type}) { + return this._templateRenderer.render(template, data, type); + } + + /** + * @param {{items: import('template-renderer').RenderMultiItem[]}} event + * @returns {import('core').Response[]} + */ + _onRenderMulti({items}) { + return this._templateRenderer.renderMulti(items); + } + + /** + * @param {{data: import('template-renderer').CompositeRenderData, type: import('anki-templates').RenderMode}} event + * @returns {import('anki-templates').NoteData} + */ + _onGetModifiedData({data, type}) { + const result = this._templateRenderer.getModifiedData(data, type); + return this._clone(result); + } + + /** + * @template [T=unknown] + * @param {T} value + * @returns {T} + */ + _clone(value) { + return parseJson(JSON.stringify(value)); + } + + /** + * @template {import('template-renderer-proxy').BackendApiNames} TName + * @param {Window} target + * @param {TName} action + * @param {import('template-renderer-proxy').BackendApiParams} params + * @param {?string} id + */ + _postMessage(target, action, params, id) { + /** @type {import('template-renderer-proxy').BackendMessageAny} */ + const data = {action, params, id}; + target.postMessage(data, '*'); + } +} diff --git a/ext/js/templates/template-renderer-frame-main.js b/ext/js/templates/template-renderer-frame-main.js new file mode 100644 index 00000000..4ab7d2bc --- /dev/null +++ b/ext/js/templates/template-renderer-frame-main.js @@ -0,0 +1,30 @@ +/* + * 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 . + */ + +import {AnkiTemplateRenderer} from './anki-template-renderer.js'; +import {TemplateRendererFrameApi} from './template-renderer-frame-api.js'; + +/** Entry point. */ +async function main() { + const ankiTemplateRenderer = new AnkiTemplateRenderer(); + await ankiTemplateRenderer.prepare(); + const templateRendererFrameApi = new TemplateRendererFrameApi(ankiTemplateRenderer.templateRenderer); + templateRendererFrameApi.prepare(); +} + +await main(); diff --git a/ext/js/templates/template-renderer-media-provider.js b/ext/js/templates/template-renderer-media-provider.js new file mode 100644 index 00000000..2f238e20 --- /dev/null +++ b/ext/js/templates/template-renderer-media-provider.js @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 2021-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 . + */ + +import {Handlebars} from '../../lib/handlebars.js'; + +export class TemplateRendererMediaProvider { + constructor() { + /** @type {?import('anki-note-builder').Requirement[]} */ + this._requirements = null; + } + + /** @type {?import('anki-note-builder').Requirement[]} */ + get requirements() { + return this._requirements; + } + + set requirements(value) { + this._requirements = value; + } + + /** + * @param {import('anki-templates').NoteData} root + * @param {unknown[]} args + * @param {import('core').SerializableObject} namedArgs + * @returns {boolean} + */ + hasMedia(root, args, namedArgs) { + const {media} = root; + const data = this._getMediaData(media, args, namedArgs); + return (data !== null); + } + + /** + * @param {import('anki-templates').NoteData} root + * @param {unknown[]} args + * @param {import('core').SerializableObject} namedArgs + * @returns {?string} + */ + getMedia(root, args, namedArgs) { + const {media} = root; + const data = this._getMediaData(media, args, namedArgs); + if (data !== null) { + const result = this._getFormattedValue(data, namedArgs); + if (typeof result === 'string') { return result; } + } + const defaultValue = namedArgs.default; + return defaultValue === null || typeof defaultValue === 'string' ? defaultValue : ''; + } + + // Private + + /** + * @param {import('anki-note-builder').Requirement} value + */ + _addRequirement(value) { + if (this._requirements === null) { return; } + this._requirements.push(value); + } + + /** + * @param {import('anki-templates').MediaObject} data + * @param {import('core').SerializableObject} namedArgs + * @returns {string} + */ + _getFormattedValue(data, namedArgs) { + let {value} = data; + const {escape = true} = namedArgs; + if (escape) { + value = Handlebars.Utils.escapeExpression(value); + } + return value; + } + + /** + * @param {import('anki-templates').Media} media + * @param {unknown[]} args + * @param {import('core').SerializableObject} namedArgs + * @returns {?(import('anki-templates').MediaObject)} + */ + _getMediaData(media, args, namedArgs) { + const type = args[0]; + switch (type) { + case 'audio': return this._getSimpleMediaData(media, 'audio'); + case 'screenshot': return this._getSimpleMediaData(media, 'screenshot'); + case 'clipboardImage': return this._getSimpleMediaData(media, 'clipboardImage'); + case 'clipboardText': return this._getSimpleMediaData(media, 'clipboardText'); + case 'selectionText': return this._getSimpleMediaData(media, 'selectionText'); + case 'textFurigana': return this._getTextFurigana(media, args[1], namedArgs); + case 'dictionaryMedia': return this._getDictionaryMedia(media, args[1], namedArgs); + default: return null; + } + } + + /** + * @param {import('anki-templates').Media} media + * @param {import('anki-templates').MediaSimpleType} type + * @returns {?import('anki-templates').MediaObject} + */ + _getSimpleMediaData(media, type) { + const result = media[type]; + if (typeof result === 'object' && result !== null) { return result; } + this._addRequirement({type}); + return null; + } + + /** + * @param {import('anki-templates').Media} media + * @param {unknown} path + * @param {import('core').SerializableObject} namedArgs + * @returns {?import('anki-templates').MediaObject} + */ + _getDictionaryMedia(media, path, namedArgs) { + if (typeof path !== 'string') { return null; } + const {dictionaryMedia} = media; + const {dictionary} = namedArgs; + if (typeof dictionary !== 'string') { return null; } + if ( + typeof dictionaryMedia !== 'undefined' && + Object.prototype.hasOwnProperty.call(dictionaryMedia, dictionary) + ) { + const dictionaryMedia2 = dictionaryMedia[dictionary]; + if (Object.prototype.hasOwnProperty.call(dictionaryMedia2, path)) { + const result = dictionaryMedia2[path]; + if (typeof result === 'object' && result !== null) { + return result; + } + } + } + this._addRequirement({ + type: 'dictionaryMedia', + dictionary, + path + }); + return null; + } + + /** + * @param {import('anki-templates').Media} media + * @param {unknown} text + * @param {import('core').SerializableObject} namedArgs + * @returns {?import('anki-templates').MediaObject} + */ + _getTextFurigana(media, text, namedArgs) { + if (typeof text !== 'string') { return null; } + const readingMode = this._normalizeReadingMode(namedArgs.readingMode); + const {textFurigana} = media; + if (Array.isArray(textFurigana)) { + for (const entry of textFurigana) { + if (entry.text !== text || entry.readingMode !== readingMode) { continue; } + return entry.details; + } + } + this._addRequirement({ + type: 'textFurigana', + text, + readingMode + }); + return null; + } + + /** + * @param {unknown} value + * @returns {?import('anki-templates').TextFuriganaReadingMode} + */ + _normalizeReadingMode(value) { + switch (value) { + case 'hiragana': + case 'katakana': + return value; + default: + return null; + } + } +} diff --git a/ext/js/templates/template-renderer.js b/ext/js/templates/template-renderer.js new file mode 100644 index 00000000..7bb93aa2 --- /dev/null +++ b/ext/js/templates/template-renderer.js @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 2016-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 . + */ + +import {Handlebars} from '../../lib/handlebars.js'; +import {ExtensionError} from '../core/extension-error.js'; + +export class TemplateRenderer { + constructor() { + /** @type {Map>} */ + this._cache = new Map(); + /** @type {number} */ + this._cacheMaxSize = 5; + /** @type {Map} */ + this._dataTypes = new Map(); + /** @type {?((noteData: import('anki-templates').NoteData) => import('template-renderer').SetupCallbackResult)} */ + this._renderSetup = null; + /** @type {?((noteData: import('anki-templates').NoteData) => import('template-renderer').CleanupCallbackResult)} */ + this._renderCleanup = null; + } + + /** + * @param {import('template-renderer').HelperFunctionsDescriptor} helpers + */ + registerHelpers(helpers) { + for (const [name, helper] of helpers) { + this._registerHelper(name, helper); + } + } + + /** + * @param {import('anki-templates').RenderMode} name + * @param {import('template-renderer').DataType} details + */ + registerDataType(name, {modifier, composeData}) { + this._dataTypes.set(name, {modifier, composeData}); + } + + /** + * @param {?((noteData: import('anki-templates').NoteData) => import('template-renderer').SetupCallbackResult)} setup + * @param {?((noteData: import('anki-templates').NoteData) => import('template-renderer').CleanupCallbackResult)} cleanup + */ + setRenderCallbacks(setup, cleanup) { + this._renderSetup = setup; + this._renderCleanup = cleanup; + } + + /** + * @param {string} template + * @param {import('template-renderer').PartialOrCompositeRenderData} data + * @param {import('anki-templates').RenderMode} type + * @returns {import('template-renderer').RenderResult} + */ + render(template, data, type) { + const instance = this._getTemplateInstance(template); + const modifiedData = this._getModifiedData(data, void 0, type); + return this._renderTemplate(instance, modifiedData); + } + + /** + * @param {import('template-renderer').RenderMultiItem[]} items + * @returns {import('core').Response[]} + */ + renderMulti(items) { + /** @type {import('core').Response[]} */ + const results = []; + for (const {template, templateItems} of items) { + const instance = this._getTemplateInstance(template); + for (const {type, commonData, datas} of templateItems) { + for (const data of datas) { + let result; + try { + const data2 = this._getModifiedData(data, commonData, type); + const renderResult = this._renderTemplate(instance, data2); + result = {result: renderResult}; + } catch (error) { + result = {error: ExtensionError.serialize(error)}; + } + results.push(result); + } + } + } + return results; + } + + /** + * @param {import('template-renderer').CompositeRenderData} data + * @param {import('anki-templates').RenderMode} type + * @returns {import('anki-templates').NoteData} + */ + getModifiedData(data, type) { + return this._getModifiedData(data, void 0, type); + } + + // Private + + /** + * @param {string} template + * @returns {import('handlebars').TemplateDelegate} + */ + _getTemplateInstance(template) { + const cache = this._cache; + let instance = cache.get(template); + if (typeof instance === 'undefined') { + this._updateCacheSize(this._cacheMaxSize - 1); + instance = /** @type {import('handlebars').TemplateDelegate} */ (Handlebars.compileAST(template)); + cache.set(template, instance); + } + + return instance; + } + + /** + * @param {import('handlebars').TemplateDelegate} instance + * @param {import('anki-templates').NoteData} data + * @returns {import('template-renderer').RenderResult} + */ + _renderTemplate(instance, data) { + const renderSetup = this._renderSetup; + const renderCleanup = this._renderCleanup; + /** @type {string} */ + let result; + /** @type {?import('template-renderer').SetupCallbackResult} */ + let additions1; + /** @type {?import('template-renderer').CleanupCallbackResult} */ + let additions2; + try { + additions1 = (typeof renderSetup === 'function' ? renderSetup(data) : null); + result = instance(data).replace(/^\n+|\n+$/g, ''); + } finally { + additions2 = (typeof renderCleanup === 'function' ? renderCleanup(data) : null); + } + return /** @type {import('template-renderer').RenderResult} */ (Object.assign({result}, additions1, additions2)); + } + + /** + * @param {import('template-renderer').PartialOrCompositeRenderData} data + * @param {import('anki-note-builder').CommonData|undefined} commonData + * @param {import('anki-templates').RenderMode} type + * @returns {import('anki-templates').NoteData} + * @throws {Error} + */ + _getModifiedData(data, commonData, type) { + if (typeof type === 'string') { + const typeInfo = this._dataTypes.get(type); + if (typeof typeInfo !== 'undefined') { + if (typeof commonData !== 'undefined') { + const {composeData} = typeInfo; + data = composeData(data, commonData); + } else if (typeof data.commonData === 'undefined') { + throw new Error('Incomplete data'); + } + const {modifier} = typeInfo; + return modifier(/** @type {import('template-renderer').CompositeRenderData} */ (data)); + } + } + throw new Error(`Invalid type: ${type}`); + } + + /** + * @param {number} maxSize + */ + _updateCacheSize(maxSize) { + const cache = this._cache; + let removeCount = cache.size - maxSize; + if (removeCount <= 0) { return; } + + for (const key of cache.keys()) { + cache.delete(key); + if (--removeCount <= 0) { break; } + } + } + + /** + * @param {string} name + * @param {import('template-renderer').HelperFunction} helper + */ + _registerHelper(name, helper) { + /** + * @this {unknown} + * @param {unknown[]} args + * @returns {unknown} + */ + function wrapper(...args) { + const argCountM1 = Math.max(0, args.length - 1); + const options = /** @type {import('handlebars').HelperOptions} */ (args[argCountM1]); + args.length = argCountM1; + return helper(args, this, options); + } + Handlebars.registerHelper(name, wrapper); + } +} -- cgit v1.2.3