/*
* 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 {applyTextReplacement} from '../general/regex-util.js';
import {TextSourceMap} from '../general/text-source-map.js';
import {convertAlphabeticToKana} from './japanese-wanakana.js';
import {collapseEmphaticSequences, convertHalfWidthKanaToFullWidth, convertHiraganaToKatakana, convertKatakanaToHiragana, convertNumericToFullWidth, isCodePointJapanese} from './japanese.js';
import {LanguageTransformer} from './language-transformer.js';
/**
* Class which finds term and kanji dictionary entries for text.
*/
export class Translator {
/**
* Creates a new Translator instance.
* @param {import('translator').ConstructorDetails} details The details for the class.
*/
constructor({database}) {
/** @type {import('../dictionary/dictionary-database.js').DictionaryDatabase} */
this._database = database;
/** @type {LanguageTransformer} */
this._languageTransformer = new LanguageTransformer();
/** @type {import('translator').DictionaryTagCache} */
this._tagCache = new Map();
/** @type {Intl.Collator} */
this._stringComparer = new Intl.Collator('en-US'); // Invariant locale
/** @type {RegExp} */
this._numberRegex = /[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/;
}
/**
* Initializes the instance for use. The public API should not be used until this function has been called.
* @param {import('language-transformer').LanguageTransformDescriptor} descriptor
*/
prepare(descriptor) {
this._languageTransformer.addDescriptor(descriptor);
}
/**
* Clears the database tag cache. This should be executed if the database is changed.
*/
clearDatabaseCaches() {
this._tagCache.clear();
}
/**
* Finds term definitions for the given text.
* @param {import('translator').FindTermsMode} mode The mode to use for finding terms, which determines the format of the resulting array.
* One of: 'group', 'merge', 'split', 'simple'
* @param {string} text The text to find terms for.
* @param {import('translation').FindTermsOptions} options A object describing settings about the lookup.
* @returns {Promise<{dictionaryEntries: import('dictionary').TermDictionaryEntry[], originalTextLength: number}>} An object containing dictionary entries and the length of the original source text.
*/
async findTerms(mode, text, options) {
const {enabledDictionaryMap, excludeDictionaryDefinitions, sortFrequencyDictionary, sortFrequencyDictionaryOrder} = options;
const tagAggregator = new TranslatorTagAggregator();
let {dictionaryEntries, originalTextLength} = await this._findTermsInternal(text, enabledDictionaryMap, options, tagAggregator);
switch (mode) {
case 'group':
dictionaryEntries = this._groupDictionaryEntriesByHeadword(dictionaryEntries, tagAggregator);
break;
case 'merge':
dictionaryEntries = await this._getRelatedDictionaryEntries(dictionaryEntries, options.mainDictionary, enabledDictionaryMap, tagAggregator);
break;
}
if (excludeDictionaryDefinitions !== null) {
this._removeExcludedDefinitions(dictionaryEntries, excludeDictionaryDefinitions);
}
if (mode !== 'simple') {
await this._addTermMeta(dictionaryEntries, enabledDictionaryMap, tagAggregator);
await this._expandTagGroupsAndGroup(tagAggregator.getTagExpansionTargets());
} else {
if (sortFrequencyDictionary !== null) {
/** @type {import('translation').TermEnabledDictionaryMap} */
const sortDictionaryMap = new Map();
const value = enabledDictionaryMap.get(sortFrequencyDictionary);
if (typeof value !== 'undefined') {
sortDictionaryMap.set(sortFrequencyDictionary, value);
}
await this._addTermMeta(dictionaryEntries, sortDictionaryMap, tagAggregator);
}
}
if (sortFrequencyDictionary !== null) {
this._updateSortFrequencies(dictionaryEntries, sortFrequencyDictionary, sortFrequencyDictionaryOrder === 'ascending');
}
if (dictionaryEntries.length > 1) {
this._sortTermDictionaryEntries(dictionaryEntries);
}
for (const {definitions, frequencies, pronunciations} of dictionaryEntries) {
this._flagRedundantDefinitionTags(definitions);
if (definitions.length > 1) { this._sortTermDictionaryEntryDefinitions(definitions); }
if (frequencies.length > 1) { this._sortTermDictionaryEntrySimpleData(frequencies); }
if (pronunciations.length > 1) { this._sortTermDictionaryEntrySimpleData(pronunciations); }
}
return {dictionaryEntries, originalTextLength};
}
/**
* Finds kanji definitions for the given text.
* @param {string} text The text to find kanji definitions for. This string can be of any length,
* but is typically just one character, which is a single kanji. If the string is multiple
* characters long, each character will be searched in the database.
* @param {import('translation').FindKanjiOptions} options A object describing settings about the lookup.
* @returns {Promise} An array of definitions. See the _createKanjiDefinition() function for structure details.
*/
async findKanji(text, options) {
if (options.removeNonJapaneseCharacters) {
text = this._getJapaneseOnlyText(text);
}
const {enabledDictionaryMap} = options;
const kanjiUnique = new Set();
for (const c of text) {
kanjiUnique.add(c);
}
const databaseEntries = await this._database.findKanjiBulk([...kanjiUnique], enabledDictionaryMap);
if (databaseEntries.length === 0) { return []; }
this._sortDatabaseEntriesByIndex(databaseEntries);
/** @type {import('dictionary').KanjiDictionaryEntry[]} */
const dictionaryEntries = [];
const tagAggregator = new TranslatorTagAggregator();
for (const {character, onyomi, kunyomi, tags, definitions, stats, dictionary} of databaseEntries) {
const expandedStats = await this._expandKanjiStats(stats, dictionary);
const dictionaryEntry = this._createKanjiDictionaryEntry(character, dictionary, onyomi, kunyomi, expandedStats, definitions);
dictionaryEntries.push(dictionaryEntry);
tagAggregator.addTags(dictionaryEntry.tags, dictionary, tags);
}
await this._addKanjiMeta(dictionaryEntries, enabledDictionaryMap);
await this._expandTagGroupsAndGroup(tagAggregator.getTagExpansionTargets());
this._sortKanjiDictionaryEntryData(dictionaryEntries);
return dictionaryEntries;
}
/**
* Gets a list of frequency information for a given list of term-reading pairs
* and a list of dictionaries.
* @param {import('translator').TermReadingList} termReadingList An array of `{term, reading}` pairs. If reading is null,
* the reading won't be compared.
* @param {string[]} dictionaries An array of dictionary names.
* @returns {Promise} An array of term frequencies.
*/
async getTermFrequencies(termReadingList, dictionaries) {
const dictionarySet = new Set();
for (const dictionary of dictionaries) {
dictionarySet.add(dictionary);
}
const termList = termReadingList.map(({term}) => term);
const metas = await this._database.findTermMetaBulk(termList, dictionarySet);
/** @type {import('translator').TermFrequencySimple[]} */
const results = [];
for (const {mode, data, dictionary, index} of metas) {
if (mode !== 'freq') { continue; }
let {term, reading} = termReadingList[index];
const hasReading = (data !== null && typeof data === 'object' && typeof data.reading === 'string');
if (hasReading && data.reading !== reading) {
if (reading !== null) { continue; }
reading = data.reading;
}
const frequency = hasReading ? data.frequency : /** @type {import('dictionary-data').GenericFrequencyData} */ (data);
const {frequency: frequencyValue, displayValue, displayValueParsed} = this._getFrequencyInfo(frequency);
results.push({
term,
reading,
dictionary,
hasReading,
frequency: frequencyValue,
displayValue,
displayValueParsed
});
}
return results;
}
// Find terms internal implementation
/**
* @param {string} text
* @param {Map} enabledDictionaryMap
* @param {import('translation').FindTermsOptions} options
* @param {TranslatorTagAggregator} tagAggregator
* @returns {Promise}
*/
async _findTermsInternal(text, enabledDictionaryMap, options, tagAggregator) {
if (options.removeNonJapaneseCharacters) {
text = this._getJapaneseOnlyText(text);
}
if (text.length === 0) {
return {dictionaryEntries: [], originalTextLength: 0};
}
const deinflections = await this._getDeinflections(text, enabledDictionaryMap, options);
let originalTextLength = 0;
/** @type {import('dictionary').TermDictionaryEntry[]} */
const dictionaryEntries = [];
const ids = new Set();
for (const {databaseEntries, originalText, transformedText, deinflectedText, inflectionRuleChainCandidates} of deinflections) {
if (databaseEntries.length === 0) { continue; }
originalTextLength = Math.max(originalTextLength, originalText.length);
for (const databaseEntry of databaseEntries) {
const {id} = databaseEntry;
if (ids.has(id)) {
const existingEntry = dictionaryEntries.find((entry) => {
return entry.definitions.some((definition) => definition.id === id);
});
if (existingEntry && transformedText.length >= existingEntry.headwords[0].sources[0].transformedText.length) {
this._mergeInflectionRuleChains(existingEntry, inflectionRuleChainCandidates);
}
continue;
}
const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, originalText, transformedText, deinflectedText, inflectionRuleChainCandidates, true, enabledDictionaryMap, tagAggregator);
dictionaryEntries.push(dictionaryEntry);
ids.add(id);
}
}
return {dictionaryEntries, originalTextLength};
}
/**
* @param {import('dictionary').TermDictionaryEntry} existingEntry
* @param {import('dictionary').InflectionRuleChainCandidate[]} inflectionRuleChainCandidates
*/
_mergeInflectionRuleChains(existingEntry, inflectionRuleChainCandidates) {
const existingChains = existingEntry.inflectionRuleChainCandidates;
for (const {source, inflectionRules} of inflectionRuleChainCandidates) {
const duplicate = existingChains.find((existingChain) => this._areArraysEqualIgnoreOrder(existingChain.inflectionRules, inflectionRules));
if (!duplicate) {
existingEntry.inflectionRuleChainCandidates.push({source, inflectionRules});
} else if (duplicate.source !== source) {
duplicate.source = 'both';
}
}
}
/**
* @param {string[]} array1
* @param {string[]} array2
* @returns {boolean}
*/
_areArraysEqualIgnoreOrder(array1, array2) {
if (array1.length !== array2.length) {
return false;
}
const frequencyCounter = new Map();
for (const element of array1) {
frequencyCounter.set(element, (frequencyCounter.get(element) || 0) + 1);
}
for (const element of array2) {
const frequency = frequencyCounter.get(element);
if (!frequency) {
return false;
}
frequencyCounter.set(element, frequency - 1);
}
return true;
}
/**
* @param {string} text
* @param {Map} enabledDictionaryMap
* @param {import('translation').FindTermsOptions} options
* @returns {Promise}
*/
async _getDeinflections(text, enabledDictionaryMap, options) {
let deinflections = (
options.deinflect ?
this._getAlgorithmDeinflections(text, options) :
[this._createDeinflection(text, text, text, 0, [])]
);
if (deinflections.length === 0) { return []; }
const {matchType} = options;
await this._addEntriesToDeinflections(deinflections, enabledDictionaryMap, matchType);
const dictionaryDeinflections = await this._getDictionaryDeinflections(deinflections, enabledDictionaryMap, matchType);
deinflections.push(...dictionaryDeinflections);
for (const deinflection of deinflections) {
for (const entry of deinflection.databaseEntries) {
entry.definitions = entry.definitions.filter((definition) => !Array.isArray(definition));
}
deinflection.databaseEntries = deinflection.databaseEntries.filter((entry) => entry.definitions.length);
}
deinflections = deinflections.filter((deinflection) => deinflection.databaseEntries.length);
return deinflections;
}
/**
* @param {import('translation-internal').DatabaseDeinflection[]} deinflections
* @param {Map} enabledDictionaryMap
* @param {import('dictionary').TermSourceMatchType} matchType
* @returns {Promise}
*/
async _getDictionaryDeinflections(deinflections, enabledDictionaryMap, matchType) {
/** @type {import('translation-internal').DatabaseDeinflection[]} */
const dictionaryDeinflections = [];
for (const deinflection of deinflections) {
const {originalText, transformedText, inflectionRuleChainCandidates: algorithmChains, databaseEntries} = deinflection;
for (const entry of databaseEntries) {
const {dictionary, definitions} = entry;
const entryDictionary = enabledDictionaryMap.get(dictionary);
const useDeinflections = entryDictionary?.useDeinflections ?? true;
if (!useDeinflections) { continue; }
for (const definition of definitions) {
if (Array.isArray(definition)) {
const [formOf, inflectionRules] = definition;
if (!formOf) { continue; }
const inflectionRuleChainCandidates = algorithmChains.map(({inflectionRules: algInflections}) => {
return {
source: /** @type {import('dictionary').InflectionSource} */ (algInflections.length === 0 ? 'dictionary' : 'both'),
inflectionRules: [...algInflections, ...inflectionRules]
};
});
const dictionaryDeinflection = this._createDeinflection(originalText, transformedText, formOf, 0, inflectionRuleChainCandidates);
dictionaryDeinflections.push(dictionaryDeinflection);
}
}
}
}
await this._addEntriesToDeinflections(dictionaryDeinflections, enabledDictionaryMap, matchType);
return dictionaryDeinflections;
}
/**
* @param {import('translation-internal').DatabaseDeinflection[]} deinflections
* @param {Map} enabledDictionaryMap
* @param {import('dictionary').TermSourceMatchType} matchType
*/
async _addEntriesToDeinflections(deinflections, enabledDictionaryMap, matchType) {
const uniqueDeinflectionsMap = this._groupDeinflectionsByTerm(deinflections);
const uniqueDeinflectionArrays = [...uniqueDeinflectionsMap.values()];
const uniqueDeinflectionTerms = [...uniqueDeinflectionsMap.keys()];
const databaseEntries = await this._database.findTermsBulk(uniqueDeinflectionTerms, enabledDictionaryMap, matchType);
this._matchEntriesToDeinflections(databaseEntries, uniqueDeinflectionArrays, enabledDictionaryMap);
}
/**
* @param {import('translation-internal').DatabaseDeinflection[]} deinflections
* @returns {Map}
*/
_groupDeinflectionsByTerm(deinflections) {
const result = new Map();
for (const deinflection of deinflections) {
const {deinflectedText} = deinflection;
let deinflectionArray = result.get(deinflectedText);
if (typeof deinflectionArray === 'undefined') {
deinflectionArray = [];
result.set(deinflectedText, deinflectionArray);
}
deinflectionArray.push(deinflection);
}
return result;
}
/**
* @param {import('dictionary-database').TermEntry[]} databaseEntries
* @param {import('translation-internal').DatabaseDeinflection[][]} uniqueDeinflectionArrays
* @param {Map} enabledDictionaryMap
*/
_matchEntriesToDeinflections(databaseEntries, uniqueDeinflectionArrays, enabledDictionaryMap) {
for (const databaseEntry of databaseEntries) {
const entryDictionary = /** @type {import('translation').FindTermDictionary} */ (enabledDictionaryMap.get(databaseEntry.dictionary));
const {partsOfSpeechFilter} = entryDictionary;
const definitionConditions = this._languageTransformer.getConditionFlagsFromPartsOfSpeech(databaseEntry.rules);
for (const deinflection of uniqueDeinflectionArrays[databaseEntry.index]) {
if (!partsOfSpeechFilter || LanguageTransformer.conditionsMatch(deinflection.conditions, definitionConditions)) {
deinflection.databaseEntries.push(databaseEntry);
}
}
}
}
// Deinflections and text transformations
/**
* @param {string} text
* @param {import('translation').FindTermsOptions} options
* @returns {import('translation-internal').DatabaseDeinflection[]}
*/
_getAlgorithmDeinflections(text, options) {
/** @type {import('translation-internal').TextDeinflectionOptionsArrays} */
const textOptionVariantArray = [
this._getTextReplacementsVariants(options),
this._getTextOptionEntryVariants(options.convertHalfWidthCharacters),
this._getTextOptionEntryVariants(options.convertNumericCharacters),
this._getTextOptionEntryVariants(options.convertAlphabeticCharacters),
this._getTextOptionEntryVariants(options.convertHiraganaToKatakana),
this._getTextOptionEntryVariants(options.convertKatakanaToHiragana),
this._getCollapseEmphaticOptions(options)
];
/** @type {import('translation-internal').DatabaseDeinflection[]} */
const deinflections = [];
const used = new Set();
for (const [textReplacements, halfWidth, numeric, alphabetic, katakana, hiragana, [collapseEmphatic, collapseEmphaticFull]] of /** @type {Generator} */ (this._getArrayVariants(textOptionVariantArray))) {
let text2 = text;
const sourceMap = new TextSourceMap(text2);
if (textReplacements !== null) {
text2 = this._applyTextReplacements(text2, sourceMap, textReplacements);
}
if (halfWidth) {
text2 = convertHalfWidthKanaToFullWidth(text2, sourceMap);
}
if (numeric) {
text2 = convertNumericToFullWidth(text2);
}
if (alphabetic) {
text2 = convertAlphabeticToKana(text2, sourceMap);
}
if (katakana) {
text2 = convertHiraganaToKatakana(text2);
}
if (hiragana) {
text2 = convertKatakanaToHiragana(text2);
}
if (collapseEmphatic) {
text2 = collapseEmphaticSequences(text2, collapseEmphaticFull, sourceMap);
}
for (
let source = text2, i = text2.length;
i > 0;
i = this._getNextSubstringLength(options.searchResolution, i, source)
) {
source = text2.substring(0, i);
if (used.has(source)) { break; }
used.add(source);
const rawSource = sourceMap.source.substring(0, sourceMap.getSourceLength(i));
for (const {text: transformedText, conditions, trace} of this._languageTransformer.transform(source)) {
/** @type {import('dictionary').InflectionRuleChainCandidate} */
const inflectionRuleChainCandidate = {
source: 'algorithm',
inflectionRules: trace.map((frame) => frame.transform)
};
deinflections.push(this._createDeinflection(rawSource, source, transformedText, conditions, [inflectionRuleChainCandidate]));
}
}
}
return deinflections;
}
/**
* @param {string} searchResolution
* @param {number} currentLength
* @param {string} source
* @returns {number}
*/
_getNextSubstringLength(searchResolution, currentLength, source) {
if (searchResolution === 'word') {
return source.search(/[^\p{Letter}][\p{Letter}\p{Number}]*$/u);
} else {
return currentLength - 1;
}
}
/**
* @param {string} text
* @param {TextSourceMap} sourceMap
* @param {import('translation').FindTermsTextReplacement[]} replacements
* @returns {string}
*/
_applyTextReplacements(text, sourceMap, replacements) {
for (const {pattern, replacement} of replacements) {
text = applyTextReplacement(text, sourceMap, pattern, replacement);
}
return text;
}
/**
* @param {string} text
* @returns {string}
*/
_getJapaneseOnlyText(text) {
let length = 0;
for (const c of text) {
if (!isCodePointJapanese(/** @type {number} */ (c.codePointAt(0)))) {
return text.substring(0, length);
}
length += c.length;
}
return text;
}
/**
* @param {import('translation').FindTermsVariantMode} value
* @returns {boolean[]}
*/
_getTextOptionEntryVariants(value) {
switch (value) {
case 'true': return [true];
case 'variant': return [false, true];
default: return [false];
}
}
/**
* @param {import('translation').FindTermsOptions} options
* @returns {[collapseEmphatic: boolean, collapseEmphaticFull: boolean][]}
*/
_getCollapseEmphaticOptions(options) {
/** @type {[collapseEmphatic: boolean, collapseEmphaticFull: boolean][]} */
const collapseEmphaticOptions = [[false, false]];
switch (options.collapseEmphaticSequences) {
case 'true':
collapseEmphaticOptions.push([true, false]);
break;
case 'full':
collapseEmphaticOptions.push([true, false], [true, true]);
break;
}
return collapseEmphaticOptions;
}
/**
* @param {import('translation').FindTermsOptions} options
* @returns {(import('translation').FindTermsTextReplacement[] | null)[]}
*/
_getTextReplacementsVariants(options) {
return options.textReplacements;
}
/**
* @param {string} originalText
* @param {string} transformedText
* @param {string} deinflectedText
* @param {number} conditions
* @param {import('dictionary').InflectionRuleChainCandidate[]} inflectionRuleChainCandidates
* @returns {import('translation-internal').DatabaseDeinflection}
*/
_createDeinflection(originalText, transformedText, deinflectedText, conditions, inflectionRuleChainCandidates) {
return {originalText, transformedText, deinflectedText, conditions, inflectionRuleChainCandidates, databaseEntries: []};
}
// Term dictionary entry grouping
/**
* @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
* @param {string} mainDictionary
* @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap
* @param {TranslatorTagAggregator} tagAggregator
* @returns {Promise}
*/
async _getRelatedDictionaryEntries(dictionaryEntries, mainDictionary, enabledDictionaryMap, tagAggregator) {
/** @type {import('translator').SequenceQuery[]} */
const sequenceList = [];
/** @type {import('translator').DictionaryEntryGroup[]} */
const groupedDictionaryEntries = [];
/** @type {Map} */
const groupedDictionaryEntriesMap = new Map();
/** @type {Map} */
const ungroupedDictionaryEntriesMap = new Map();
for (const dictionaryEntry of dictionaryEntries) {
const {definitions: [{id, dictionary, sequences: [sequence]}]} = dictionaryEntry;
if (mainDictionary === dictionary && sequence >= 0) {
let group = groupedDictionaryEntriesMap.get(sequence);
if (typeof group === 'undefined') {
group = {
ids: new Set(),
dictionaryEntries: []
};
sequenceList.push({query: sequence, dictionary});
groupedDictionaryEntries.push(group);
groupedDictionaryEntriesMap.set(sequence, group);
}
group.dictionaryEntries.push(dictionaryEntry);
group.ids.add(id);
} else {
ungroupedDictionaryEntriesMap.set(id, dictionaryEntry);
}
}
if (sequenceList.length > 0) {
const secondarySearchDictionaryMap = this._getSecondarySearchDictionaryMap(enabledDictionaryMap);
await this._addRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, sequenceList, enabledDictionaryMap, tagAggregator);
for (const group of groupedDictionaryEntries) {
this._sortTermDictionaryEntriesById(group.dictionaryEntries);
}
if (ungroupedDictionaryEntriesMap.size !== 0 || secondarySearchDictionaryMap.size !== 0) {
await this._addSecondaryRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, enabledDictionaryMap, secondarySearchDictionaryMap, tagAggregator);
}
}
const newDictionaryEntries = [];
for (const group of groupedDictionaryEntries) {
newDictionaryEntries.push(this._createGroupedDictionaryEntry(group.dictionaryEntries, true, tagAggregator));
}
newDictionaryEntries.push(...this._groupDictionaryEntriesByHeadword(ungroupedDictionaryEntriesMap.values(), tagAggregator));
return newDictionaryEntries;
}
/**
* @param {import('translator').DictionaryEntryGroup[]} groupedDictionaryEntries
* @param {Map} ungroupedDictionaryEntriesMap
* @param {import('translator').SequenceQuery[]} sequenceList
* @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap
* @param {TranslatorTagAggregator} tagAggregator
*/
async _addRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, sequenceList, enabledDictionaryMap, tagAggregator) {
const databaseEntries = await this._database.findTermsBySequenceBulk(sequenceList);
for (const databaseEntry of databaseEntries) {
const {dictionaryEntries, ids} = groupedDictionaryEntries[databaseEntry.index];
const {id} = databaseEntry;
if (ids.has(id)) { continue; }
const {term} = databaseEntry;
const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, term, term, term, [], false, enabledDictionaryMap, tagAggregator);
dictionaryEntries.push(dictionaryEntry);
ids.add(id);
ungroupedDictionaryEntriesMap.delete(id);
}
}
/**
* @param {import('translator').DictionaryEntryGroup[]} groupedDictionaryEntries
* @param {Map} ungroupedDictionaryEntriesMap
* @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap
* @param {import('translation').TermEnabledDictionaryMap} secondarySearchDictionaryMap
* @param {TranslatorTagAggregator} tagAggregator
*/
async _addSecondaryRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, enabledDictionaryMap, secondarySearchDictionaryMap, tagAggregator) {
// Prepare grouping info
/** @type {import('dictionary-database').TermExactRequest[]} */
const termList = [];
const targetList = [];
const targetMap = new Map();
for (const group of groupedDictionaryEntries) {
const {dictionaryEntries} = group;
for (const dictionaryEntry of dictionaryEntries) {
const {term, reading} = dictionaryEntry.headwords[0];
const key = this._createMapKey([term, reading]);
let target = targetMap.get(key);
if (typeof target === 'undefined') {
target = {
groups: []
};
targetMap.set(key, target);
termList.push({term, reading});
targetList.push(target);
}
target.groups.push(group);
}
}
// Group unsequenced dictionary entries with sequenced entries that have a matching [term, reading].
for (const [id, dictionaryEntry] of ungroupedDictionaryEntriesMap.entries()) {
const {term, reading} = dictionaryEntry.headwords[0];
const key = this._createMapKey([term, reading]);
const target = targetMap.get(key);
if (typeof target === 'undefined') { continue; }
for (const {ids, dictionaryEntries} of target.groups) {
if (ids.has(id)) { continue; }
dictionaryEntries.push(dictionaryEntry);
ids.add(id);
}
ungroupedDictionaryEntriesMap.delete(id);
}
// Search database for additional secondary terms
if (termList.length === 0 || secondarySearchDictionaryMap.size === 0) { return; }
const databaseEntries = await this._database.findTermsExactBulk(termList, secondarySearchDictionaryMap);
this._sortDatabaseEntriesByIndex(databaseEntries);
for (const databaseEntry of databaseEntries) {
const {index, id} = databaseEntry;
const sourceText = termList[index].term;
const target = targetList[index];
for (const {ids, dictionaryEntries} of target.groups) {
if (ids.has(id)) { continue; }
const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, sourceText, sourceText, sourceText, [], false, enabledDictionaryMap, tagAggregator);
dictionaryEntries.push(dictionaryEntry);
ids.add(id);
ungroupedDictionaryEntriesMap.delete(id);
}
}
}
/**
* @param {Iterable} dictionaryEntries
* @param {TranslatorTagAggregator} tagAggregator
* @returns {import('dictionary').TermDictionaryEntry[]}
*/
_groupDictionaryEntriesByHeadword(dictionaryEntries, tagAggregator) {
const groups = new Map();
for (const dictionaryEntry of dictionaryEntries) {
const {inflectionRuleChainCandidates, headwords: [{term, reading}]} = dictionaryEntry;
const key = this._createMapKey([term, reading, ...inflectionRuleChainCandidates]);
let groupDictionaryEntries = groups.get(key);
if (typeof groupDictionaryEntries === 'undefined') {
groupDictionaryEntries = [];
groups.set(key, groupDictionaryEntries);
}
groupDictionaryEntries.push(dictionaryEntry);
}
const newDictionaryEntries = [];
for (const groupDictionaryEntries of groups.values()) {
newDictionaryEntries.push(this._createGroupedDictionaryEntry(groupDictionaryEntries, false, tagAggregator));
}
return newDictionaryEntries;
}
// Removing data
/**
* @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
* @param {Set} excludeDictionaryDefinitions
*/
_removeExcludedDefinitions(dictionaryEntries, excludeDictionaryDefinitions) {
for (let i = dictionaryEntries.length - 1; i >= 0; --i) {
const dictionaryEntry = dictionaryEntries[i];
const {definitions, pronunciations, frequencies, headwords} = dictionaryEntry;
const definitionsChanged = this._removeArrayItemsWithDictionary(definitions, excludeDictionaryDefinitions);
this._removeArrayItemsWithDictionary(pronunciations, excludeDictionaryDefinitions);
this._removeArrayItemsWithDictionary(frequencies, excludeDictionaryDefinitions);
this._removeTagGroupsWithDictionary(definitions, excludeDictionaryDefinitions);
this._removeTagGroupsWithDictionary(headwords, excludeDictionaryDefinitions);
if (!definitionsChanged) { continue; }
if (definitions.length === 0) {
dictionaryEntries.splice(i, 1);
} else {
this._removeUnusedHeadwords(dictionaryEntry);
}
}
}
/**
* @param {import('dictionary').TermDictionaryEntry} dictionaryEntry
*/
_removeUnusedHeadwords(dictionaryEntry) {
const {definitions, pronunciations, frequencies, headwords} = dictionaryEntry;
const removeHeadwordIndices = new Set();
for (let i = 0, ii = headwords.length; i < ii; ++i) {
removeHeadwordIndices.add(i);
}
for (const {headwordIndices} of definitions) {
for (const headwordIndex of headwordIndices) {
removeHeadwordIndices.delete(headwordIndex);
}
}
if (removeHeadwordIndices.size === 0) { return; }
/** @type {Map} */
const indexRemap = new Map();
let oldIndex = 0;
for (let i = 0, ii = headwords.length; i < ii; ++i) {
if (removeHeadwordIndices.has(oldIndex)) {
headwords.splice(i, 1);
--i;
--ii;
} else {
indexRemap.set(oldIndex, indexRemap.size);
}
++oldIndex;
}
this._updateDefinitionHeadwordIndices(definitions, indexRemap);
this._updateArrayItemsHeadwordIndex(pronunciations, indexRemap);
this._updateArrayItemsHeadwordIndex(frequencies, indexRemap);
}
/**
* @param {import('dictionary').TermDefinition[]} definitions
* @param {Map} indexRemap
*/
_updateDefinitionHeadwordIndices(definitions, indexRemap) {
for (const {headwordIndices} of definitions) {
for (let i = headwordIndices.length - 1; i >= 0; --i) {
const newHeadwordIndex = indexRemap.get(headwordIndices[i]);
if (typeof newHeadwordIndex === 'undefined') {
headwordIndices.splice(i, 1);
} else {
headwordIndices[i] = newHeadwordIndex;
}
}
}
}
/**
* @param {import('dictionary').TermPronunciation[]|import('dictionary').TermFrequency[]} array
* @param {Map} indexRemap
*/
_updateArrayItemsHeadwordIndex(array, indexRemap) {
for (let i = array.length - 1; i >= 0; --i) {
const item = array[i];
const {headwordIndex} = item;
const newHeadwordIndex = indexRemap.get(headwordIndex);
if (typeof newHeadwordIndex === 'undefined') {
array.splice(i, 1);
} else {
item.headwordIndex = newHeadwordIndex;
}
}
}
/**
* @param {import('dictionary').TermPronunciation[]|import('dictionary').TermFrequency[]|import('dictionary').TermDefinition[]} array
* @param {Set} excludeDictionaryDefinitions
* @returns {boolean}
*/
_removeArrayItemsWithDictionary(array, excludeDictionaryDefinitions) {
let changed = false;
for (let j = array.length - 1; j >= 0; --j) {
const {dictionary} = array[j];
if (!excludeDictionaryDefinitions.has(dictionary)) { continue; }
array.splice(j, 1);
changed = true;
}
return changed;
}
/**
* @param {import('dictionary').Tag[]} array
* @param {Set} excludeDictionaryDefinitions
* @returns {boolean}
*/
_removeArrayItemsWithDictionary2(array, excludeDictionaryDefinitions) {
let changed = false;
for (let j = array.length - 1; j >= 0; --j) {
const {dictionaries} = array[j];
if (this._hasAny(excludeDictionaryDefinitions, dictionaries)) { continue; }
array.splice(j, 1);
changed = true;
}
return changed;
}
/**
* @param {import('dictionary').TermDefinition[]|import('dictionary').TermHeadword[]} array
* @param {Set} excludeDictionaryDefinitions
*/
_removeTagGroupsWithDictionary(array, excludeDictionaryDefinitions) {
for (const {tags} of array) {
this._removeArrayItemsWithDictionary2(tags, excludeDictionaryDefinitions);
}
}
// Tags
/**
* @param {import('translator').TagExpansionTarget[]} tagExpansionTargets
*/
async _expandTagGroupsAndGroup(tagExpansionTargets) {
await this._expandTagGroups(tagExpansionTargets);
this._groupTags(tagExpansionTargets);
}
/**
* @param {import('translator').TagExpansionTarget[]} tagTargets
*/
async _expandTagGroups(tagTargets) {
const allItems = [];
/** @type {import('translator').TagTargetMap} */
const targetMap = new Map();
for (const {tagGroups, tags} of tagTargets) {
for (const {dictionary, tagNames} of tagGroups) {
let dictionaryItems = targetMap.get(dictionary);
if (typeof dictionaryItems === 'undefined') {
dictionaryItems = new Map();
targetMap.set(dictionary, dictionaryItems);
}
for (const tagName of tagNames) {
let item = dictionaryItems.get(tagName);
if (typeof item === 'undefined') {
const query = this._getNameBase(tagName);
item = {query, dictionary, tagName, cache: null, databaseTag: null, targets: []};
dictionaryItems.set(tagName, item);
allItems.push(item);
}
item.targets.push(tags);
}
}
}
const nonCachedItems = [];
const tagCache = this._tagCache;
for (const [dictionary, dictionaryItems] of targetMap.entries()) {
let cache = tagCache.get(dictionary);
if (typeof cache === 'undefined') {
cache = new Map();
tagCache.set(dictionary, cache);
}
for (const item of dictionaryItems.values()) {
const databaseTag = cache.get(item.query);
if (typeof databaseTag !== 'undefined') {
item.databaseTag = databaseTag;
} else {
item.cache = cache;
nonCachedItems.push(item);
}
}
}
const nonCachedItemCount = nonCachedItems.length;
if (nonCachedItemCount > 0) {
const databaseTags = await this._database.findTagMetaBulk(nonCachedItems);
for (let i = 0; i < nonCachedItemCount; ++i) {
const item = nonCachedItems[i];
const databaseTag = databaseTags[i];
const databaseTag2 = typeof databaseTag !== 'undefined' ? databaseTag : null;
item.databaseTag = databaseTag2;
if (item.cache !== null) {
item.cache.set(item.query, databaseTag2);
}
}
}
for (const {dictionary, tagName, databaseTag, targets} of allItems) {
for (const tags of targets) {
tags.push(this._createTag(databaseTag, tagName, dictionary));
}
}
}
/**
* @param {import('translator').TagExpansionTarget[]} tagTargets
*/
_groupTags(tagTargets) {
const stringComparer = this._stringComparer;
/**
* @param {import('dictionary').Tag} v1
* @param {import('dictionary').Tag} v2
* @returns {number}
*/
const compare = (v1, v2) => {
const i = v1.order - v2.order;
return i !== 0 ? i : stringComparer.compare(v1.name, v2.name);
};
for (const {tags} of tagTargets) {
if (tags.length <= 1) { continue; }
this._mergeSimilarTags(tags);
tags.sort(compare);
}
}
/**
* @param {import('dictionary').Tag[]} tags
*/
_mergeSimilarTags(tags) {
let tagCount = tags.length;
for (let i = 0; i < tagCount; ++i) {
const tag1 = tags[i];
const {category, name} = tag1;
for (let j = i + 1; j < tagCount; ++j) {
const tag2 = tags[j];
if (tag2.name !== name || tag2.category !== category) { continue; }
// Merge tag
tag1.order = Math.min(tag1.order, tag2.order);
tag1.score = Math.max(tag1.score, tag2.score);
tag1.dictionaries.push(...tag2.dictionaries);
this._addUniqueSimple(tag1.content, tag2.content);
tags.splice(j, 1);
--tagCount;
--j;
}
}
}
/**
* @param {import('dictionary').Tag[]} tags
* @param {string} category
* @returns {string[]}
*/
_getTagNamesWithCategory(tags, category) {
const results = [];
for (const tag of tags) {
if (tag.category !== category) { continue; }
results.push(tag.name);
}
results.sort();
return results;
}
/**
* @param {import('dictionary').TermDefinition[]} definitions
*/
_flagRedundantDefinitionTags(definitions) {
if (definitions.length === 0) { return; }
let lastDictionary = null;
let lastPartOfSpeech = '';
const removeCategoriesSet = new Set();
for (const {dictionary, tags} of definitions) {
const partOfSpeech = this._createMapKey(this._getTagNamesWithCategory(tags, 'partOfSpeech'));
if (lastDictionary !== dictionary) {
lastDictionary = dictionary;
lastPartOfSpeech = '';
}
if (lastPartOfSpeech === partOfSpeech) {
removeCategoriesSet.add('partOfSpeech');
} else {
lastPartOfSpeech = partOfSpeech;
}
if (removeCategoriesSet.size > 0) {
for (const tag of tags) {
if (removeCategoriesSet.has(tag.category)) {
tag.redundant = true;
}
}
removeCategoriesSet.clear();
}
}
}
// Metadata
/**
* @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
* @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap
* @param {TranslatorTagAggregator} tagAggregator
*/
async _addTermMeta(dictionaryEntries, enabledDictionaryMap, tagAggregator) {
const headwordMap = new Map();
const headwordMapKeys = [];
const headwordReadingMaps = [];
for (const {headwords, pronunciations, frequencies} of dictionaryEntries) {
for (let i = 0, ii = headwords.length; i < ii; ++i) {
const {term, reading} = headwords[i];
let readingMap = headwordMap.get(term);
if (typeof readingMap === 'undefined') {
readingMap = new Map();
headwordMap.set(term, readingMap);
headwordMapKeys.push(term);
headwordReadingMaps.push(readingMap);
}
let targets = readingMap.get(reading);
if (typeof targets === 'undefined') {
targets = [];
readingMap.set(reading, targets);
}
targets.push({headwordIndex: i, pronunciations, frequencies});
}
}
const metas = await this._database.findTermMetaBulk(headwordMapKeys, enabledDictionaryMap);
for (const {mode, data, dictionary, index} of metas) {
const {index: dictionaryIndex, priority: dictionaryPriority} = this._getDictionaryOrder(dictionary, enabledDictionaryMap);
const map2 = headwordReadingMaps[index];
for (const [reading, targets] of map2.entries()) {
switch (mode) {
case 'freq':
{
const hasReading = (data !== null && typeof data === 'object' && typeof data.reading === 'string');
if (hasReading && data.reading !== reading) { continue; }
const frequency = hasReading ? data.frequency : /** @type {import('dictionary-data').GenericFrequencyData} */ (data);
for (const {frequencies, headwordIndex} of targets) {
const {frequency: frequencyValue, displayValue, displayValueParsed} = this._getFrequencyInfo(frequency);
frequencies.push(this._createTermFrequency(
frequencies.length,
headwordIndex,
dictionary,
dictionaryIndex,
dictionaryPriority,
hasReading,
frequencyValue,
displayValue,
displayValueParsed
));
}
}
break;
case 'pitch':
{
if (data.reading !== reading) { continue; }
/** @type {import('dictionary').PitchAccent[]} */
const pitches = [];
for (const {position, tags, nasal, devoice} of data.pitches) {
/** @type {import('dictionary').Tag[]} */
const tags2 = [];
if (Array.isArray(tags)) {
tagAggregator.addTags(tags2, dictionary, tags);
}
const nasalPositions = this._toNumberArray(nasal);
const devoicePositions = this._toNumberArray(devoice);
pitches.push({
type: 'pitch-accent',
position,
nasalPositions,
devoicePositions,
tags: tags2
});
}
for (const {pronunciations, headwordIndex} of targets) {
pronunciations.push(this._createTermPronunciation(
pronunciations.length,
headwordIndex,
dictionary,
dictionaryIndex,
dictionaryPriority,
pitches
));
}
}
break;
case 'ipa':
{
if (data.reading !== reading) { continue; }
/** @type {import('dictionary').PhoneticTranscription[]} */
const phoneticTranscriptions = [];
for (const {ipa, tags} of data.transcriptions) {
/** @type {import('dictionary').Tag[]} */
const tags2 = [];
if (Array.isArray(tags)) {
tagAggregator.addTags(tags2, dictionary, tags);
}
phoneticTranscriptions.push({
type: 'phonetic-transcription',
ipa,
tags: tags2
});
}
for (const {pronunciations, headwordIndex} of targets) {
pronunciations.push(this._createTermPronunciation(
pronunciations.length,
headwordIndex,
dictionary,
dictionaryIndex,
dictionaryPriority,
phoneticTranscriptions
));
}
}
}
}
}
}
/**
* @param {import('dictionary').KanjiDictionaryEntry[]} dictionaryEntries
* @param {import('translation').KanjiEnabledDictionaryMap} enabledDictionaryMap
*/
async _addKanjiMeta(dictionaryEntries, enabledDictionaryMap) {
const kanjiList = [];
for (const {character} of dictionaryEntries) {
kanjiList.push(character);
}
const metas = await this._database.findKanjiMetaBulk(kanjiList, enabledDictionaryMap);
for (const {character, mode, data, dictionary, index} of metas) {
const {index: dictionaryIndex, priority: dictionaryPriority} = this._getDictionaryOrder(dictionary, enabledDictionaryMap);
switch (mode) {
case 'freq':
{
const {frequencies} = dictionaryEntries[index];
const {frequency, displayValue, displayValueParsed} = this._getFrequencyInfo(data);
frequencies.push(this._createKanjiFrequency(
frequencies.length,
dictionary,
dictionaryIndex,
dictionaryPriority,
character,
frequency,
displayValue,
displayValueParsed
));
}
break;
}
}
}
/**
* @param {{[key: string]: (string|number)}} stats
* @param {string} dictionary
* @returns {Promise}
*/
async _expandKanjiStats(stats, dictionary) {
const statsEntries = Object.entries(stats);
const items = [];
for (const [name] of statsEntries) {
const query = this._getNameBase(name);
items.push({query, dictionary});
}
const databaseInfos = await this._database.findTagMetaBulk(items);
/** @type {Map} */
const statsGroups = new Map();
for (let i = 0, ii = statsEntries.length; i < ii; ++i) {
const databaseInfo = databaseInfos[i];
if (typeof databaseInfo === 'undefined') { continue; }
const [name, value] = statsEntries[i];
const {category} = databaseInfo;
let group = statsGroups.get(category);
if (typeof group === 'undefined') {
group = [];
statsGroups.set(category, group);
}
group.push(this._createKanjiStat(name, value, databaseInfo, dictionary));
}
/** @type {import('dictionary').KanjiStatGroups} */
const groupedStats = {};
for (const [category, group] of statsGroups.entries()) {
this._sortKanjiStats(group);
groupedStats[category] = group;
}
return groupedStats;
}
/**
* @param {import('dictionary').KanjiStat[]} stats
*/
_sortKanjiStats(stats) {
if (stats.length <= 1) { return; }
const stringComparer = this._stringComparer;
stats.sort((v1, v2) => {
const i = v1.order - v2.order;
return (i !== 0) ? i : stringComparer.compare(v1.content, v2.content);
});
}
/**
* @param {string} value
* @returns {number}
*/
_convertStringToNumber(value) {
const match = this._numberRegex.exec(value);
if (match === null) { return 0; }
const result = Number.parseFloat(match[0]);
return Number.isFinite(result) ? result : 0;
}
/**
* @param {import('dictionary-data').GenericFrequencyData} frequency
* @returns {{frequency: number, displayValue: ?string, displayValueParsed: boolean}}
*/
_getFrequencyInfo(frequency) {
let frequencyValue = 0;
let displayValue = null;
let displayValueParsed = false;
if (typeof frequency === 'object' && frequency !== null) {
const {value: frequencyValue2, displayValue: displayValue2} = frequency;
if (typeof frequencyValue2 === 'number') { frequencyValue = frequencyValue2; }
if (typeof displayValue2 === 'string') { displayValue = displayValue2; }
} else {
switch (typeof frequency) {
case 'number':
frequencyValue = frequency;
break;
case 'string':
displayValue = frequency;
displayValueParsed = true;
frequencyValue = this._convertStringToNumber(frequency);
break;
}
}
return {frequency: frequencyValue, displayValue, displayValueParsed};
}
// Helpers
/**
* @param {string} name
* @returns {string}
*/
_getNameBase(name) {
const pos = name.indexOf(':');
return (pos >= 0 ? name.substring(0, pos) : name);
}
/**
* @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap
* @returns {import('translation').TermEnabledDictionaryMap}
*/
_getSecondarySearchDictionaryMap(enabledDictionaryMap) {
const secondarySearchDictionaryMap = new Map();
for (const [dictionary, details] of enabledDictionaryMap.entries()) {
if (!details.allowSecondarySearches) { continue; }
secondarySearchDictionaryMap.set(dictionary, details);
}
return secondarySearchDictionaryMap;
}
/**
* @param {string} dictionary
* @param {import('translation').TermEnabledDictionaryMap|import('translation').KanjiEnabledDictionaryMap} enabledDictionaryMap
* @returns {{index: number, priority: number}}
*/
_getDictionaryOrder(dictionary, enabledDictionaryMap) {
const info = enabledDictionaryMap.get(dictionary);
const {index, priority} = typeof info !== 'undefined' ? info : {index: enabledDictionaryMap.size, priority: 0};
return {index, priority};
}
/**
* @param {[...args: unknown[][]]} arrayVariants
* @yields {[...args: unknown[]]}
* @returns {Generator}
*/
*_getArrayVariants(arrayVariants) {
const ii = arrayVariants.length;
let total = 1;
for (let i = 0; i < ii; ++i) {
total *= arrayVariants[i].length;
}
for (let a = 0; a < total; ++a) {
const variant = [];
let index = a;
for (let i = 0; i < ii; ++i) {
const entryVariants = arrayVariants[i];
variant.push(entryVariants[index % entryVariants.length]);
index = Math.floor(index / entryVariants.length);
}
yield variant;
}
}
/**
* @param {unknown[]} array
* @returns {string}
*/
_createMapKey(array) {
return JSON.stringify(array);
}
/**
* @param {number|number[]|undefined} value
* @returns {number[]}
*/
_toNumberArray(value) {
return Array.isArray(value) ? value : (typeof value === 'number' ? [value] : []);
}
// Kanji data
/**
* @param {string} name
* @param {string|number} value
* @param {import('dictionary-database').Tag} databaseInfo
* @param {string} dictionary
* @returns {import('dictionary').KanjiStat}
*/
_createKanjiStat(name, value, databaseInfo, dictionary) {
const {category, notes, order, score} = databaseInfo;
return {
name,
category: (typeof category === 'string' && category.length > 0 ? category : 'default'),
content: (typeof notes === 'string' ? notes : ''),
order: (typeof order === 'number' ? order : 0),
score: (typeof score === 'number' ? score : 0),
dictionary,
value
};
}
/**
* @param {number} index
* @param {string} dictionary
* @param {number} dictionaryIndex
* @param {number} dictionaryPriority
* @param {string} character
* @param {number} frequency
* @param {?string} displayValue
* @param {boolean} displayValueParsed
* @returns {import('dictionary').KanjiFrequency}
*/
_createKanjiFrequency(index, dictionary, dictionaryIndex, dictionaryPriority, character, frequency, displayValue, displayValueParsed) {
return {index, dictionary, dictionaryIndex, dictionaryPriority, character, frequency, displayValue, displayValueParsed};
}
/**
* @param {string} character
* @param {string} dictionary
* @param {string[]} onyomi
* @param {string[]} kunyomi
* @param {import('dictionary').KanjiStatGroups} stats
* @param {string[]} definitions
* @returns {import('dictionary').KanjiDictionaryEntry}
*/
_createKanjiDictionaryEntry(character, dictionary, onyomi, kunyomi, stats, definitions) {
return {
type: 'kanji',
character,
dictionary,
onyomi,
kunyomi,
tags: [],
stats,
definitions,
frequencies: []
};
}
// Term data
/**
* @param {?import('dictionary-database').Tag} databaseTag
* @param {string} name
* @param {string} dictionary
* @returns {import('dictionary').Tag}
*/
_createTag(databaseTag, name, dictionary) {
let category, notes, order, score;
if (typeof databaseTag === 'object' && databaseTag !== null) {
({category, notes, order, score} = databaseTag);
}
return {
name,
category: (typeof category === 'string' && category.length > 0 ? category : 'default'),
order: (typeof order === 'number' ? order : 0),
score: (typeof score === 'number' ? score : 0),
content: (typeof notes === 'string' && notes.length > 0 ? [notes] : []),
dictionaries: [dictionary],
redundant: false
};
}
/**
* @param {string} originalText
* @param {string} transformedText
* @param {string} deinflectedText
* @param {import('dictionary').TermSourceMatchType} matchType
* @param {import('dictionary').TermSourceMatchSource} matchSource
* @param {boolean} isPrimary
* @returns {import('dictionary').TermSource}
*/
_createSource(originalText, transformedText, deinflectedText, matchType, matchSource, isPrimary) {
return {originalText, transformedText, deinflectedText, matchType, matchSource, isPrimary};
}
/**
* @param {number} index
* @param {string} term
* @param {string} reading
* @param {import('dictionary').TermSource[]} sources
* @param {import('dictionary').Tag[]} tags
* @param {string[]} wordClasses
* @returns {import('dictionary').TermHeadword}
*/
_createTermHeadword(index, term, reading, sources, tags, wordClasses) {
return {index, term, reading, sources, tags, wordClasses};
}
/**
* @param {number} index
* @param {number[]} headwordIndices
* @param {string} dictionary
* @param {number} dictionaryIndex
* @param {number} dictionaryPriority
* @param {number} id
* @param {number} score
* @param {number[]} sequences
* @param {boolean} isPrimary
* @param {import('dictionary').Tag[]} tags
* @param {import('dictionary-data').TermGlossaryContent[]} entries
* @returns {import('dictionary').TermDefinition}
*/
_createTermDefinition(index, headwordIndices, dictionary, dictionaryIndex, dictionaryPriority, id, score, sequences, isPrimary, tags, entries) {
return {
index,
headwordIndices,
dictionary,
dictionaryIndex,
dictionaryPriority,
id,
score,
frequencyOrder: 0,
sequences,
isPrimary,
tags,
entries
};
}
/**
* @param {number} index
* @param {number} headwordIndex
* @param {string} dictionary
* @param {number} dictionaryIndex
* @param {number} dictionaryPriority
* @param {import('dictionary').Pronunciation[]} pronunciations
* @returns {import('dictionary').TermPronunciation}
*/
_createTermPronunciation(index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations) {
return {index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations};
}
/**
* @param {number} index
* @param {number} headwordIndex
* @param {string} dictionary
* @param {number} dictionaryIndex
* @param {number} dictionaryPriority
* @param {boolean} hasReading
* @param {number} frequency
* @param {?string} displayValue
* @param {boolean} displayValueParsed
* @returns {import('dictionary').TermFrequency}
*/
_createTermFrequency(index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, hasReading, frequency, displayValue, displayValueParsed) {
return {index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, hasReading, frequency, displayValue, displayValueParsed};
}
/**
* @param {boolean} isPrimary
* @param {import('dictionary').InflectionRuleChainCandidate[]} inflectionRuleChainCandidates
* @param {number} score
* @param {number} dictionaryIndex
* @param {number} dictionaryPriority
* @param {number} sourceTermExactMatchCount
* @param {number} maxTransformedTextLength
* @param {import('dictionary').TermHeadword[]} headwords
* @param {import('dictionary').TermDefinition[]} definitions
* @returns {import('dictionary').TermDictionaryEntry}
*/
_createTermDictionaryEntry(isPrimary, inflectionRuleChainCandidates, score, dictionaryIndex, dictionaryPriority, sourceTermExactMatchCount, maxTransformedTextLength, headwords, definitions) {
return {
type: 'term',
isPrimary,
inflectionRuleChainCandidates,
score,
frequencyOrder: 0,
dictionaryIndex,
dictionaryPriority,
sourceTermExactMatchCount,
maxTransformedTextLength,
headwords,
definitions,
pronunciations: [],
frequencies: []
};
}
/**
* @param {import('dictionary-database').TermEntry} databaseEntry
* @param {string} originalText
* @param {string} transformedText
* @param {string} deinflectedText
* @param {import('dictionary').InflectionRuleChainCandidate[]} inflectionRuleChainCandidates
* @param {boolean} isPrimary
* @param {Map} enabledDictionaryMap
* @param {TranslatorTagAggregator} tagAggregator
* @returns {import('dictionary').TermDictionaryEntry}
*/
_createTermDictionaryEntryFromDatabaseEntry(databaseEntry, originalText, transformedText, deinflectedText, inflectionRuleChainCandidates, isPrimary, enabledDictionaryMap, tagAggregator) {
const {
matchType,
matchSource,
term,
reading: rawReading,
definitionTags,
termTags,
definitions,
score,
dictionary,
id,
sequence: rawSequence,
rules
} = databaseEntry;
// cast is safe because getDeinflections filters out deinflection definitions
const contentDefinitions = /** @type {import('dictionary-data').TermGlossaryContent[]} */ (definitions);
const reading = (rawReading.length > 0 ? rawReading : term);
const {index: dictionaryIndex, priority: dictionaryPriority} = this._getDictionaryOrder(dictionary, enabledDictionaryMap);
const sourceTermExactMatchCount = (isPrimary && deinflectedText === term ? 1 : 0);
const source = this._createSource(originalText, transformedText, deinflectedText, matchType, matchSource, isPrimary);
const maxTransformedTextLength = transformedText.length;
const hasSequence = (rawSequence >= 0);
const sequence = hasSequence ? rawSequence : -1;
/** @type {import('dictionary').Tag[]} */
const headwordTagGroups = [];
/** @type {import('dictionary').Tag[]} */
const definitionTagGroups = [];
tagAggregator.addTags(headwordTagGroups, dictionary, termTags);
tagAggregator.addTags(definitionTagGroups, dictionary, definitionTags);
return this._createTermDictionaryEntry(
isPrimary,
inflectionRuleChainCandidates,
score,
dictionaryIndex,
dictionaryPriority,
sourceTermExactMatchCount,
maxTransformedTextLength,
[this._createTermHeadword(0, term, reading, [source], headwordTagGroups, rules)],
[this._createTermDefinition(0, [0], dictionary, dictionaryIndex, dictionaryPriority, id, score, [sequence], isPrimary, definitionTagGroups, contentDefinitions)]
);
}
/**
* @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
* @param {boolean} checkDuplicateDefinitions
* @param {TranslatorTagAggregator} tagAggregator
* @returns {import('dictionary').TermDictionaryEntry}
*/
_createGroupedDictionaryEntry(dictionaryEntries, checkDuplicateDefinitions, tagAggregator) {
// Headwords are generated before sorting, so that the order of dictionaryEntries can be maintained
const definitionEntries = [];
/** @type {Map} */
const headwords = new Map();
for (const dictionaryEntry of dictionaryEntries) {
const headwordIndexMap = this._addTermHeadwords(headwords, dictionaryEntry.headwords, tagAggregator);
definitionEntries.push({index: definitionEntries.length, dictionaryEntry, headwordIndexMap});
}
// Sort
if (definitionEntries.length <= 1) {
checkDuplicateDefinitions = false;
}
// Merge dictionary entry data
let score = Number.MIN_SAFE_INTEGER;
let dictionaryIndex = Number.MAX_SAFE_INTEGER;
let dictionaryPriority = Number.MIN_SAFE_INTEGER;
let maxTransformedTextLength = 0;
let isPrimary = false;
/** @type {import('dictionary').TermDefinition[]} */
const definitions = [];
/** @type {?Map} */
const definitionsMap = checkDuplicateDefinitions ? new Map() : null;
let inflections = null;
for (const {dictionaryEntry, headwordIndexMap} of definitionEntries) {
score = Math.max(score, dictionaryEntry.score);
dictionaryIndex = Math.min(dictionaryIndex, dictionaryEntry.dictionaryIndex);
dictionaryPriority = Math.max(dictionaryPriority, dictionaryEntry.dictionaryPriority);
if (dictionaryEntry.isPrimary) {
isPrimary = true;
maxTransformedTextLength = Math.max(maxTransformedTextLength, dictionaryEntry.maxTransformedTextLength);
const dictionaryEntryInflections = dictionaryEntry.inflectionRuleChainCandidates;
if (inflections === null || dictionaryEntryInflections.length < inflections.length) {
inflections = dictionaryEntryInflections;
}
}
if (definitionsMap !== null) {
this._addTermDefinitions(definitions, definitionsMap, dictionaryEntry.definitions, headwordIndexMap, tagAggregator);
} else {
this._addTermDefinitionsFast(definitions, dictionaryEntry.definitions, headwordIndexMap);
}
}
const headwordsArray = [...headwords.values()];
let sourceTermExactMatchCount = 0;
for (const {sources} of headwordsArray) {
for (const source of sources) {
if (source.isPrimary && source.matchSource === 'term') {
++sourceTermExactMatchCount;
break;
}
}
}
return this._createTermDictionaryEntry(
isPrimary,
inflections !== null ? inflections : [],
score,
dictionaryIndex,
dictionaryPriority,
sourceTermExactMatchCount,
maxTransformedTextLength,
headwordsArray,
definitions
);
}
// Data collection addition functions
/**
* @template [T=unknown]
* @param {T[]} list
* @param {T[]} newItems
*/
_addUniqueSimple(list, newItems) {
for (const item of newItems) {
if (!list.includes(item)) {
list.push(item);
}
}
}
/**
* @param {import('dictionary').TermSource[]} sources
* @param {import('dictionary').TermSource[]} newSources
*/
_addUniqueSources(sources, newSources) {
if (newSources.length === 0) { return; }
if (sources.length === 0) {
sources.push(...newSources);
return;
}
for (const newSource of newSources) {
const {originalText, transformedText, deinflectedText, matchType, matchSource, isPrimary} = newSource;
let has = false;
for (const source of sources) {
if (
source.deinflectedText === deinflectedText &&
source.transformedText === transformedText &&
source.originalText === originalText &&
source.matchType === matchType &&
source.matchSource === matchSource
) {
if (isPrimary) { source.isPrimary = true; }
has = true;
break;
}
}
if (!has) {
sources.push(newSource);
}
}
}
/**
* @param {Map} headwordsMap
* @param {import('dictionary').TermHeadword[]} headwords
* @param {TranslatorTagAggregator} tagAggregator
* @returns {number[]}
*/
_addTermHeadwords(headwordsMap, headwords, tagAggregator) {
/** @type {number[]} */
const headwordIndexMap = [];
for (const {term, reading, sources, tags, wordClasses} of headwords) {
const key = this._createMapKey([term, reading]);
let headword = headwordsMap.get(key);
if (typeof headword === 'undefined') {
headword = this._createTermHeadword(headwordsMap.size, term, reading, [], [], []);
headwordsMap.set(key, headword);
}
this._addUniqueSources(headword.sources, sources);
this._addUniqueSimple(headword.wordClasses, wordClasses);
tagAggregator.mergeTags(headword.tags, tags);
headwordIndexMap.push(headword.index);
}
return headwordIndexMap;
}
/**
* @param {number[]} headwordIndices
* @param {number} headwordIndex
*/
_addUniqueTermHeadwordIndex(headwordIndices, headwordIndex) {
let end = headwordIndices.length;
if (end === 0) {
headwordIndices.push(headwordIndex);
return;
}
let start = 0;
while (start < end) {
const mid = Math.floor((start + end) / 2);
const value = headwordIndices[mid];
if (headwordIndex === value) { return; }
if (headwordIndex > value) {
start = mid + 1;
} else {
end = mid;
}
}
if (headwordIndex === headwordIndices[start]) { return; }
headwordIndices.splice(start, 0, headwordIndex);
}
/**
* @param {import('dictionary').TermDefinition[]} definitions
* @param {import('dictionary').TermDefinition[]} newDefinitions
* @param {number[]} headwordIndexMap
*/
_addTermDefinitionsFast(definitions, newDefinitions, headwordIndexMap) {
for (const {headwordIndices, dictionary, dictionaryIndex, dictionaryPriority, sequences, id, score, isPrimary, tags, entries} of newDefinitions) {
const headwordIndicesNew = [];
for (const headwordIndex of headwordIndices) {
headwordIndicesNew.push(headwordIndexMap[headwordIndex]);
}
definitions.push(this._createTermDefinition(definitions.length, headwordIndicesNew, dictionary, dictionaryIndex, dictionaryPriority, id, score, sequences, isPrimary, tags, entries));
}
}
/**
* @param {import('dictionary').TermDefinition[]} definitions
* @param {Map} definitionsMap
* @param {import('dictionary').TermDefinition[]} newDefinitions
* @param {number[]} headwordIndexMap
* @param {TranslatorTagAggregator} tagAggregator
*/
_addTermDefinitions(definitions, definitionsMap, newDefinitions, headwordIndexMap, tagAggregator) {
for (const {headwordIndices, dictionary, dictionaryIndex, dictionaryPriority, sequences, id, score, isPrimary, tags, entries} of newDefinitions) {
const key = this._createMapKey([dictionary, ...entries]);
let definition = definitionsMap.get(key);
if (typeof definition === 'undefined') {
definition = this._createTermDefinition(definitions.length, [], dictionary, dictionaryIndex, dictionaryPriority, id, score, [...sequences], isPrimary, [], [...entries]);
definitions.push(definition);
definitionsMap.set(key, definition);
} else {
if (isPrimary) {
definition.isPrimary = true;
}
this._addUniqueSimple(definition.sequences, sequences);
}
const newHeadwordIndices = definition.headwordIndices;
for (const headwordIndex of headwordIndices) {
this._addUniqueTermHeadwordIndex(newHeadwordIndices, headwordIndexMap[headwordIndex]);
}
tagAggregator.mergeTags(definition.tags, tags);
}
}
// Sorting functions
/**
* @param {import('dictionary-database').TermEntry[]|import('dictionary-database').KanjiEntry[]} databaseEntries
*/
_sortDatabaseEntriesByIndex(databaseEntries) {
if (databaseEntries.length <= 1) { return; }
/**
* @param {import('dictionary-database').TermEntry|import('dictionary-database').KanjiEntry} v1
* @param {import('dictionary-database').TermEntry|import('dictionary-database').KanjiEntry} v2
* @returns {number}
*/
const compareFunction = (v1, v2) => v1.index - v2.index;
databaseEntries.sort(compareFunction);
}
/**
* @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
*/
_sortTermDictionaryEntries(dictionaryEntries) {
const stringComparer = this._stringComparer;
/**
* @param {import('dictionary').TermDictionaryEntry} v1
* @param {import('dictionary').TermDictionaryEntry} v2
* @returns {number}
*/
const compareFunction = (v1, v2) => {
// Sort by length of source term
let i = v2.maxTransformedTextLength - v1.maxTransformedTextLength;
if (i !== 0) { return i; }
// Sort by the number of inflection reasons
i = v1.inflectionRuleChainCandidates.length - v2.inflectionRuleChainCandidates.length;
if (i !== 0) { return i; }
// Sort by how many terms exactly match the source (e.g. for exact kana prioritization)
i = v2.sourceTermExactMatchCount - v1.sourceTermExactMatchCount;
if (i !== 0) { return i; }
// Sort by frequency order
i = v1.frequencyOrder - v2.frequencyOrder;
if (i !== 0) { return i; }
// Sort by dictionary priority
i = v2.dictionaryPriority - v1.dictionaryPriority;
if (i !== 0) { return i; }
// Sort by term score
i = v2.score - v1.score;
if (i !== 0) { return i; }
// Sort by headword term text
const headwords1 = v1.headwords;
const headwords2 = v2.headwords;
for (let j = 0, jj = Math.min(headwords1.length, headwords2.length); j < jj; ++j) {
const term1 = headwords1[j].term;
const term2 = headwords2[j].term;
i = term2.length - term1.length;
if (i !== 0) { return i; }
i = stringComparer.compare(term1, term2);
if (i !== 0) { return i; }
}
// Sort by definition count
i = v2.definitions.length - v1.definitions.length;
if (i !== 0) { return i; }
// Sort by dictionary order
i = v1.dictionaryIndex - v2.dictionaryIndex;
return i;
};
dictionaryEntries.sort(compareFunction);
}
/**
* @param {import('dictionary').TermDefinition[]} definitions
*/
_sortTermDictionaryEntryDefinitions(definitions) {
/**
* @param {import('dictionary').TermDefinition} v1
* @param {import('dictionary').TermDefinition} v2
* @returns {number}
*/
const compareFunction = (v1, v2) => {
// Sort by frequency order
let i = v1.frequencyOrder - v2.frequencyOrder;
if (i !== 0) { return i; }
// Sort by dictionary priority
i = v2.dictionaryPriority - v1.dictionaryPriority;
if (i !== 0) { return i; }
// Sort by term score
i = v2.score - v1.score;
if (i !== 0) { return i; }
// Sort by definition headword index
const headwordIndices1 = v1.headwordIndices;
const headwordIndices2 = v2.headwordIndices;
const jj = headwordIndices1.length;
i = headwordIndices2.length - jj;
if (i !== 0) { return i; }
for (let j = 0; j < jj; ++j) {
i = headwordIndices1[j] - headwordIndices2[j];
if (i !== 0) { return i; }
}
// Sort by dictionary order
i = v1.dictionaryIndex - v2.dictionaryIndex;
if (i !== 0) { return i; }
// Sort by original order
i = v1.index - v2.index;
return i;
};
definitions.sort(compareFunction);
}
/**
* @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
*/
_sortTermDictionaryEntriesById(dictionaryEntries) {
if (dictionaryEntries.length <= 1) { return; }
dictionaryEntries.sort((a, b) => a.definitions[0].id - b.definitions[0].id);
}
/**
* @param {import('dictionary').TermFrequency[]|import('dictionary').TermPronunciation[]} dataList
*/
_sortTermDictionaryEntrySimpleData(dataList) {
/**
* @param {import('dictionary').TermFrequency|import('dictionary').TermPronunciation} v1
* @param {import('dictionary').TermFrequency|import('dictionary').TermPronunciation} v2
* @returns {number}
*/
const compare = (v1, v2) => {
// Sort by dictionary priority
let i = v2.dictionaryPriority - v1.dictionaryPriority;
if (i !== 0) { return i; }
// Sory by headword order
i = v1.headwordIndex - v2.headwordIndex;
if (i !== 0) { return i; }
// Sort by dictionary order
i = v1.dictionaryIndex - v2.dictionaryIndex;
if (i !== 0) { return i; }
// Default order
i = v1.index - v2.index;
return i;
};
dataList.sort(compare);
}
/**
* @param {import('dictionary').KanjiDictionaryEntry[]} dictionaryEntries
*/
_sortKanjiDictionaryEntryData(dictionaryEntries) {
/**
* @param {import('dictionary').KanjiFrequency} v1
* @param {import('dictionary').KanjiFrequency} v2
* @returns {number}
*/
const compare = (v1, v2) => {
// Sort by dictionary priority
let i = v2.dictionaryPriority - v1.dictionaryPriority;
if (i !== 0) { return i; }
// Sort by dictionary order
i = v1.dictionaryIndex - v2.dictionaryIndex;
if (i !== 0) { return i; }
// Default order
i = v1.index - v2.index;
return i;
};
for (const {frequencies} of dictionaryEntries) {
frequencies.sort(compare);
}
}
/**
* @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
* @param {string} dictionary
* @param {boolean} ascending
*/
_updateSortFrequencies(dictionaryEntries, dictionary, ascending) {
const frequencyMap = new Map();
for (const dictionaryEntry of dictionaryEntries) {
const {definitions, frequencies} = dictionaryEntry;
let frequencyMin = Number.MAX_SAFE_INTEGER;
let frequencyMax = Number.MIN_SAFE_INTEGER;
for (const item of frequencies) {
if (item.dictionary !== dictionary) { continue; }
const {headwordIndex, frequency} = item;
if (typeof frequency !== 'number') { continue; }
frequencyMap.set(headwordIndex, frequency);
frequencyMin = Math.min(frequencyMin, frequency);
frequencyMax = Math.max(frequencyMax, frequency);
}
dictionaryEntry.frequencyOrder = (
frequencyMin <= frequencyMax ?
(ascending ? frequencyMin : -frequencyMax) :
(ascending ? Number.MAX_SAFE_INTEGER : 0)
);
for (const definition of definitions) {
frequencyMin = Number.MAX_SAFE_INTEGER;
frequencyMax = Number.MIN_SAFE_INTEGER;
const {headwordIndices} = definition;
for (const headwordIndex of headwordIndices) {
const frequency = frequencyMap.get(headwordIndex);
if (typeof frequency !== 'number') { continue; }
frequencyMin = Math.min(frequencyMin, frequency);
frequencyMax = Math.max(frequencyMax, frequency);
}
definition.frequencyOrder = (
frequencyMin <= frequencyMax ?
(ascending ? frequencyMin : -frequencyMax) :
(ascending ? Number.MAX_SAFE_INTEGER : 0)
);
}
frequencyMap.clear();
}
}
// Miscellaneous
/**
* @template [T=unknown]
* @param {Set} set
* @param {T[]} values
* @returns {boolean}
*/
_hasAny(set, values) {
for (const value of values) {
if (set.has(value)) { return true; }
}
return false;
}
}
class TranslatorTagAggregator {
constructor() {
/** @type {Map} */
this._tagExpansionTargetMap = new Map();
}
/**
* @param {import('dictionary').Tag[]} tags
* @param {string} dictionary
* @param {string[]} tagNames
*/
addTags(tags, dictionary, tagNames) {
if (tagNames.length === 0) { return; }
const tagGroups = this._getOrCreateTagGroups(tags);
const tagGroup = this._getOrCreateTagGroup(tagGroups, dictionary);
this._addUniqueTags(tagGroup, tagNames);
}
/**
* @returns {import('translator').TagExpansionTarget[]}
*/
getTagExpansionTargets() {
const results = [];
for (const [tags, tagGroups] of this._tagExpansionTargetMap) {
results.push({tags, tagGroups});
}
return results;
}
/**
* @param {import('dictionary').Tag[]} tags
* @param {import('dictionary').Tag[]} newTags
*/
mergeTags(tags, newTags) {
const newTagGroups = this._tagExpansionTargetMap.get(newTags);
if (typeof newTagGroups === 'undefined') { return; }
const tagGroups = this._getOrCreateTagGroups(tags);
for (const {dictionary, tagNames} of newTagGroups) {
const tagGroup = this._getOrCreateTagGroup(tagGroups, dictionary);
this._addUniqueTags(tagGroup, tagNames);
}
}
/**
* @param {import('dictionary').Tag[]} tags
* @returns {import('translator').TagGroup[]}
*/
_getOrCreateTagGroups(tags) {
let tagGroups = this._tagExpansionTargetMap.get(tags);
if (typeof tagGroups === 'undefined') {
tagGroups = [];
this._tagExpansionTargetMap.set(tags, tagGroups);
}
return tagGroups;
}
/**
* @param {import('translator').TagGroup[]} tagGroups
* @param {string} dictionary
* @returns {import('translator').TagGroup}
*/
_getOrCreateTagGroup(tagGroups, dictionary) {
for (const tagGroup of tagGroups) {
if (tagGroup.dictionary === dictionary) { return tagGroup; }
}
const newTagGroup = {dictionary, tagNames: []};
tagGroups.push(newTagGroup);
return newTagGroup;
}
/**
* @param {import('translator').TagGroup} tagGroup
* @param {string[]} newTagNames
*/
_addUniqueTags(tagGroup, newTagNames) {
const {tagNames} = tagGroup;
for (const tagName of newTagNames) {
if (tagNames.includes(tagName)) { continue; }
tagNames.push(tagName);
}
}
}