yomichan/ext/js/language/translator.js

1473 lines
55 KiB
JavaScript

/*
* Copyright (C) 2016-2021 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* global
* Deinflector
* TextSourceMap
*/
/**
* Class which finds term and kanji definitions for text.
*/
class Translator {
/**
* Creates a new Translator instance.
* @param database An instance of DictionaryDatabase.
*/
constructor({japaneseUtil, database}) {
this._japaneseUtil = japaneseUtil;
this._database = database;
this._deinflector = null;
this._tagCache = new Map();
this._stringComparer = new Intl.Collator('en-US'); // Invariant locale
}
/**
* Initializes the instance for use. The public API should not be used until
* this function has been called.
* @param deinflectionReasons The raw deinflections reasons data that the Deinflector uses.
*/
prepare(deinflectionReasons) {
this._deinflector = new Deinflector(deinflectionReasons);
}
/**
* 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 mode The mode to use for finding terms, which determines the format of the resulting array.
* One of: 'group', 'merge', 'split', 'simple'
* @param text The text to find terms for.
* @param options An object using the following structure:
* {
* wildcard: (enum: null, 'prefix', 'suffix'),
* mainDictionary: (string),
* alphanumeric: (boolean),
* convertHalfWidthCharacters: (enum: 'false', 'true', 'variant'),
* convertNumericCharacters: (enum: 'false', 'true', 'variant'),
* convertAlphabeticCharacters: (enum: 'false', 'true', 'variant'),
* convertHiraganaToKatakana: (enum: 'false', 'true', 'variant'),
* convertKatakanaToHiragana: (enum: 'false', 'true', 'variant'),
* collapseEmphaticSequences: (enum: 'false', 'true', 'full'),
* textReplacements: [
* (null or [
* {pattern: (RegExp), replacement: (string)}
* ...
* ])
* ...
* ],
* enabledDictionaryMap: (Map of [
* (string),
* {
* index: (number),
* priority: (number),
* allowSecondarySearches: (boolean)
* }
* ])
* }
* @returns An array of [definitions, textLength]. The structure of each definition depends on the
* mode parameter, see the _create?TermDefinition?() functions for structure details.
*/
async findTerms(mode, text, options) {
switch (mode) {
case 'group':
return await this._findTermsGrouped(text, options);
case 'merge':
return await this._findTermsMerged(text, options);
case 'split':
return await this._findTermsSplit(text, options);
case 'simple':
return await this._findTermsSimple(text, options);
default:
return [[], 0];
}
}
/**
* Finds kanji definitions for the given text.
* @param 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 options An object using the following structure:
* {
* enabledDictionaryMap: (Map of [
* (string),
* {
* index: (number),
* priority: (number)
* }
* ])
* }
* @returns An array of definitions. See the _createKanjiDefinition() function for structure details.
*/
async findKanji(text, options) {
const {enabledDictionaryMap} = options;
const kanjiUnique = new Set();
for (const c of text) {
kanjiUnique.add(c);
}
const databaseDefinitions = await this._database.findKanjiBulk([...kanjiUnique], enabledDictionaryMap);
if (databaseDefinitions.length === 0) { return []; }
this._sortDatabaseDefinitionsByIndex(databaseDefinitions);
const definitions = [];
for (const {character, onyomi, kunyomi, tags, glossary, stats, dictionary} of databaseDefinitions) {
const expandedStats = await this._expandStats(stats, dictionary);
const expandedTags = await this._expandTags(tags, dictionary);
this._sortTags(expandedTags);
const definition = this._createKanjiDefinition(character, dictionary, onyomi, kunyomi, glossary, expandedTags, expandedStats);
definitions.push(definition);
}
await this._buildKanjiMeta(definitions, enabledDictionaryMap);
return definitions;
}
// Find terms core functions
async _findTermsSimple(text, options) {
const {enabledDictionaryMap} = options;
const [definitions, length] = await this._findTermsInternal(text, enabledDictionaryMap, options);
this._sortDefinitions(definitions);
return [definitions, length];
}
async _findTermsSplit(text, options) {
const {enabledDictionaryMap} = options;
const [definitions, length] = await this._findTermsInternal(text, enabledDictionaryMap, options);
await this._buildTermMeta(definitions, enabledDictionaryMap);
this._sortDefinitions(definitions);
return [definitions, length];
}
async _findTermsGrouped(text, options) {
const {enabledDictionaryMap} = options;
const [definitions, length] = await this._findTermsInternal(text, enabledDictionaryMap, options);
const groupedDefinitions = this._groupTerms(definitions, enabledDictionaryMap);
await this._buildTermMeta(groupedDefinitions, enabledDictionaryMap);
this._sortDefinitions(groupedDefinitions);
for (const definition of groupedDefinitions) {
this._flagRedundantDefinitionTags(definition.definitions);
}
return [groupedDefinitions, length];
}
async _findTermsMerged(text, options) {
const {mainDictionary, enabledDictionaryMap} = options;
const [definitions, length] = await this._findTermsInternal(text, enabledDictionaryMap, options);
const {sequencedDefinitions, unsequencedDefinitions} = await this._getSequencedDefinitions(definitions, mainDictionary, enabledDictionaryMap);
const definitionsMerged = [];
for (const {relatedDefinitions, secondaryDefinitions} of sequencedDefinitions) {
const mergedDefinition = this._getMergedDefinition(relatedDefinitions, secondaryDefinitions);
definitionsMerged.push(mergedDefinition);
}
for (const groupedDefinition of this._groupTerms(unsequencedDefinitions, enabledDictionaryMap)) {
const {reasons, score, expression, reading, source, rawSource, sourceTerm, furiganaSegments, termTags, definitions: definitions2} = groupedDefinition;
const termDetailsList = [this._createTermDetails(sourceTerm, expression, reading, furiganaSegments, termTags)];
const compatibilityDefinition = this._createMergedTermDefinition(
source,
rawSource,
this._convertTermDefinitionsToMergedGlossaryTermDefinitions(definitions2),
[expression],
[reading],
termDetailsList,
reasons,
score
);
definitionsMerged.push(compatibilityDefinition);
}
await this._buildTermMeta(definitionsMerged, enabledDictionaryMap);
this._sortDefinitions(definitionsMerged);
for (const definition of definitionsMerged) {
this._flagRedundantDefinitionTags(definition.definitions);
}
return [definitionsMerged, length];
}
// Find terms internal implementation
async _findTermsInternal(text, enabledDictionaryMap, options) {
const {alphanumeric, wildcard} = options;
text = this._getSearchableText(text, alphanumeric);
if (text.length === 0) {
return [[], 0];
}
const deinflections = (
wildcard ?
await this._findTermWildcard(text, enabledDictionaryMap, wildcard) :
await this._findTermDeinflections(text, enabledDictionaryMap, options)
);
let maxLength = 0;
const definitions = [];
const definitionIds = new Set();
for (const {databaseDefinitions, source, rawSource, term, reasons} of deinflections) {
if (databaseDefinitions.length === 0) { continue; }
maxLength = Math.max(maxLength, rawSource.length);
for (const databaseDefinition of databaseDefinitions) {
const {id} = databaseDefinition;
if (definitionIds.has(id)) { continue; }
const definition = await this._createTermDefinitionFromDatabaseDefinition(databaseDefinition, source, rawSource, term, reasons, true, enabledDictionaryMap);
definitions.push(definition);
definitionIds.add(id);
}
}
return [definitions, maxLength];
}
async _findTermWildcard(text, enabledDictionaryMap, wildcard) {
const databaseDefinitions = await this._database.findTermsBulk([text], enabledDictionaryMap, wildcard);
if (databaseDefinitions.length === 0) {
return [];
}
return [{
source: text,
rawSource: text,
term: text,
rules: 0,
reasons: [],
databaseDefinitions
}];
}
async _findTermDeinflections(text, enabledDictionaryMap, options) {
const deinflections = this._getAllDeinflections(text, options);
if (deinflections.length === 0) {
return [];
}
const uniqueDeinflectionTerms = [];
const uniqueDeinflectionArrays = [];
const uniqueDeinflectionsMap = new Map();
for (const deinflection of deinflections) {
const term = deinflection.term;
let deinflectionArray = uniqueDeinflectionsMap.get(term);
if (typeof deinflectionArray === 'undefined') {
deinflectionArray = [];
uniqueDeinflectionTerms.push(term);
uniqueDeinflectionArrays.push(deinflectionArray);
uniqueDeinflectionsMap.set(term, deinflectionArray);
}
deinflectionArray.push(deinflection);
}
const databaseDefinitions = await this._database.findTermsBulk(uniqueDeinflectionTerms, enabledDictionaryMap, null);
for (const databaseDefinition of databaseDefinitions) {
const definitionRules = Deinflector.rulesToRuleFlags(databaseDefinition.rules);
for (const deinflection of uniqueDeinflectionArrays[databaseDefinition.index]) {
const deinflectionRules = deinflection.rules;
if (deinflectionRules === 0 || (definitionRules & deinflectionRules) !== 0) {
deinflection.databaseDefinitions.push(databaseDefinition);
}
}
}
return deinflections;
}
_getAllDeinflections(text, options) {
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)
];
const jp = this._japaneseUtil;
const deinflections = [];
const used = new Set();
for (const [textReplacements, halfWidth, numeric, alphabetic, katakana, hiragana, [collapseEmphatic, collapseEmphaticFull]] of this._getArrayVariants(textOptionVariantArray)) {
let text2 = text;
const sourceMap = new TextSourceMap(text2);
if (textReplacements !== null) {
text2 = this._applyTextReplacements(text2, sourceMap, textReplacements);
}
if (halfWidth) {
text2 = jp.convertHalfWidthKanaToFullWidth(text2, sourceMap);
}
if (numeric) {
text2 = jp.convertNumericToFullWidth(text2);
}
if (alphabetic) {
text2 = jp.convertAlphabeticToKana(text2, sourceMap);
}
if (katakana) {
text2 = jp.convertHiraganaToKatakana(text2);
}
if (hiragana) {
text2 = jp.convertKatakanaToHiragana(text2);
}
if (collapseEmphatic) {
text2 = jp.collapseEmphaticSequences(text2, collapseEmphaticFull, sourceMap);
}
for (let i = text2.length; i > 0; --i) {
const text2Substring = text2.substring(0, i);
if (used.has(text2Substring)) { break; }
used.add(text2Substring);
const rawSource = sourceMap.source.substring(0, sourceMap.getSourceLength(i));
for (const deinflection of this._deinflector.deinflect(text2Substring, rawSource)) {
deinflections.push(deinflection);
}
}
}
return deinflections;
}
/**
* @param definitions An array of 'term' definitions.
* @param mainDictionary The name of the main dictionary.
* @param enabledDictionaryMap The map of enabled dictionaries and their settings.
*/
async _getSequencedDefinitions(definitions, mainDictionary, enabledDictionaryMap) {
const secondarySearchDictionaryMap = this._getSecondarySearchDictionaryMap(enabledDictionaryMap);
const sequenceList = [];
const sequencedDefinitionMap = new Map();
const sequencedDefinitions = [];
const unsequencedDefinitions = new Map();
for (const definition of definitions) {
const {sequence, dictionary, id} = definition;
if (mainDictionary === dictionary && sequence >= 0) {
let sequencedDefinition = sequencedDefinitionMap.get(sequence);
if (typeof sequencedDefinition === 'undefined') {
sequencedDefinition = {
relatedDefinitions: [],
definitionIds: new Set(),
secondaryDefinitions: []
};
sequencedDefinitionMap.set(sequence, sequencedDefinition);
sequencedDefinitions.push(sequencedDefinition);
sequenceList.push(sequence);
}
sequencedDefinition.relatedDefinitions.push(definition);
sequencedDefinition.definitionIds.add(id);
} else {
unsequencedDefinitions.set(id, definition);
}
}
if (sequenceList.length > 0) {
await this._addRelatedDefinitions(sequencedDefinitions, unsequencedDefinitions, sequenceList, mainDictionary, enabledDictionaryMap);
await this._addSecondaryDefinitions(sequencedDefinitions, unsequencedDefinitions, enabledDictionaryMap, secondarySearchDictionaryMap);
}
for (const {relatedDefinitions} of sequencedDefinitions) {
this._sortDefinitionsById(relatedDefinitions);
}
return {sequencedDefinitions, unsequencedDefinitions: [...unsequencedDefinitions.values()]};
}
async _addRelatedDefinitions(sequencedDefinitions, unsequencedDefinitions, sequenceList, mainDictionary, enabledDictionaryMap) {
const databaseDefinitions = await this._database.findTermsBySequenceBulk(sequenceList, mainDictionary);
for (const databaseDefinition of databaseDefinitions) {
const {relatedDefinitions, definitionIds} = sequencedDefinitions[databaseDefinition.index];
const {id} = databaseDefinition;
if (definitionIds.has(id)) { continue; }
const {source, rawSource, sourceTerm} = relatedDefinitions[0];
const definition = await this._createTermDefinitionFromDatabaseDefinition(databaseDefinition, source, rawSource, sourceTerm, [], false, enabledDictionaryMap);
relatedDefinitions.push(definition);
definitionIds.add(id);
unsequencedDefinitions.delete(id);
}
}
async _addSecondaryDefinitions(sequencedDefinitions, unsequencedDefinitions, enabledDictionaryMap, secondarySearchDictionaryMap) {
if (unsequencedDefinitions.length === 0 && secondarySearchDictionaryMap.size === 0) { return; }
// Prepare grouping info
const expressionList = [];
const readingList = [];
const targetList = [];
const targetMap = new Map();
for (const sequencedDefinition of sequencedDefinitions) {
const {relatedDefinitions} = sequencedDefinition;
for (const definition of relatedDefinitions) {
const {expressions: [{expression, reading}]} = definition;
const key = this._createMapKey([expression, reading]);
let target = targetMap.get(key);
if (typeof target === 'undefined') {
target = {
sequencedDefinitions: [],
searchSecondary: false
};
targetMap.set(key, target);
}
target.sequencedDefinitions.push(sequencedDefinition);
if (!definition.isPrimary && !target.searchSecondary) {
target.searchSecondary = true;
expressionList.push(expression);
readingList.push(reading);
targetList.push(target);
}
}
}
// Group unsequenced definitions with sequenced definitions that have a matching [expression, reading].
for (const [id, definition] of unsequencedDefinitions.entries()) {
const {expressions: [{expression, reading}]} = definition;
const key = this._createMapKey([expression, reading]);
const target = targetMap.get(key);
if (typeof target === 'undefined') { continue; }
for (const {definitionIds, secondaryDefinitions} of target.sequencedDefinitions) {
if (definitionIds.has(id)) { continue; }
secondaryDefinitions.push(definition);
definitionIds.add(id);
unsequencedDefinitions.delete(id);
break;
}
}
// Search database for additional secondary terms
if (expressionList.length === 0 || secondarySearchDictionaryMap.size === 0) { return; }
const databaseDefinitions = await this._database.findTermsExactBulk(expressionList, readingList, secondarySearchDictionaryMap);
this._sortDatabaseDefinitionsByIndex(databaseDefinitions);
for (const databaseDefinition of databaseDefinitions) {
const {index, id} = databaseDefinition;
const source = expressionList[index];
const target = targetList[index];
for (const {definitionIds, secondaryDefinitions} of target.sequencedDefinitions) {
if (definitionIds.has(id)) { continue; }
const definition = await this._createTermDefinitionFromDatabaseDefinition(databaseDefinition, source, source, source, [], false, enabledDictionaryMap);
secondaryDefinitions.push(definition);
definitionIds.add(id);
unsequencedDefinitions.delete(id);
}
}
}
_getMergedDefinition(relatedDefinitions, secondaryDefinitions) {
const {reasons, source, rawSource} = relatedDefinitions[0];
const allDefinitions = secondaryDefinitions.length > 0 ? [...relatedDefinitions, ...secondaryDefinitions] : relatedDefinitions;
const score = this._getMaxPrimaryDefinitionScore(allDefinitions);
// Merge by glossary
const allExpressions = new Set();
const allReadings = new Set();
const glossaryDefinitionGroupMap = new Map();
for (const definition of allDefinitions) {
const {dictionary, glossary, expressions: [{expression, reading}]} = definition;
const key = this._createMapKey([dictionary, ...glossary]);
let group = glossaryDefinitionGroupMap.get(key);
if (typeof group === 'undefined') {
group = {
expressions: new Set(),
readings: new Set(),
definitions: []
};
glossaryDefinitionGroupMap.set(key, group);
}
allExpressions.add(expression);
allReadings.add(reading);
group.expressions.add(expression);
group.readings.add(reading);
group.definitions.push(definition);
}
const glossaryDefinitions = [];
for (const {expressions, readings, definitions} of glossaryDefinitionGroupMap.values()) {
const glossaryDefinition = this._createMergedGlossaryTermDefinition(
source,
rawSource,
definitions,
expressions,
readings,
allExpressions,
allReadings
);
glossaryDefinitions.push(glossaryDefinition);
}
this._sortDefinitions(glossaryDefinitions);
const termDetailsList = this._createTermDetailsList(allDefinitions);
return this._createMergedTermDefinition(
source,
rawSource,
glossaryDefinitions,
[...allExpressions],
[...allReadings],
termDetailsList,
reasons,
score
);
}
_removeUsedDefinitions(definitions, termInfoMap, usedDefinitions) {
for (let i = 0, ii = definitions.length; i < ii; ++i) {
const definition = definitions[i];
const {expression, reading} = definition;
const expressionMap = termInfoMap.get(expression);
if (
typeof expressionMap !== 'undefined' &&
typeof expressionMap.get(reading) !== 'undefined'
) {
usedDefinitions.add(definition);
} else {
definitions.splice(i, 1);
--i;
--ii;
}
}
}
_getUniqueDefinitionTags(definitions) {
const definitionTagsMap = new Map();
for (const {definitionTags} of definitions) {
for (const tag of definitionTags) {
const {name} = tag;
if (definitionTagsMap.has(name)) { continue; }
definitionTagsMap.set(name, this._cloneTag(tag));
}
}
return [...definitionTagsMap.values()];
}
_flagRedundantDefinitionTags(definitions) {
let lastDictionary = null;
let lastPartOfSpeech = '';
const removeCategoriesSet = new Set();
for (const {dictionary, definitionTags} of definitions) {
const partOfSpeech = this._createMapKey(this._getTagNamesWithCategory(definitionTags, 'partOfSpeech'));
if (lastDictionary !== dictionary) {
lastDictionary = dictionary;
lastPartOfSpeech = '';
}
if (lastPartOfSpeech === partOfSpeech) {
removeCategoriesSet.add('partOfSpeech');
} else {
lastPartOfSpeech = partOfSpeech;
}
if (removeCategoriesSet.size > 0) {
this._flagTagsWithCategoryAsRedundant(definitionTags, removeCategoriesSet);
removeCategoriesSet.clear();
}
}
}
/**
* Groups definitions with the same [source, expression, reading, reasons].
* @param definitions An array of 'term' definitions.
* @returns An array of 'termGrouped' definitions.
*/
_groupTerms(definitions) {
const groups = new Map();
for (const definition of definitions) {
const {source, reasons, expressions: [{expression, reading}]} = definition;
const key = this._createMapKey([source, expression, reading, ...reasons]);
let groupDefinitions = groups.get(key);
if (typeof groupDefinitions === 'undefined') {
groupDefinitions = [];
groups.set(key, groupDefinitions);
}
groupDefinitions.push(definition);
}
const results = [];
for (const groupDefinitions of groups.values()) {
this._sortDefinitions(groupDefinitions);
const definition = this._createGroupedTermDefinition(groupDefinitions);
results.push(definition);
}
return results;
}
_convertTermDefinitionsToMergedGlossaryTermDefinitions(definitions) {
const convertedDefinitions = [];
for (const definition of definitions) {
const {source, rawSource, expression, reading} = definition;
const expressions = new Set([expression]);
const readings = new Set([reading]);
const convertedDefinition = this._createMergedGlossaryTermDefinition(source, rawSource, [definition], expressions, readings, expressions, readings);
convertedDefinitions.push(convertedDefinition);
}
return convertedDefinitions;
}
// Metadata building
async _buildTermMeta(definitions, enabledDictionaryMap) {
const allDefinitions = this._getAllDefinitions(definitions);
const expressionMap = new Map();
const expressionValues = [];
const expressionKeys = [];
for (const {expressions, frequencies: frequencies1, pitches: pitches1} of allDefinitions) {
for (let i = 0, ii = expressions.length; i < ii; ++i) {
let {expression, reading, frequencies: frequencies2, pitches: pitches2} = expressions[i];
if (reading.length === 0) { reading = expression; }
let readingMap = expressionMap.get(expression);
if (typeof readingMap === 'undefined') {
readingMap = new Map();
expressionMap.set(expression, readingMap);
expressionValues.push(readingMap);
expressionKeys.push(expression);
}
let targets = readingMap.get(reading);
if (typeof targets === 'undefined') {
targets = [];
readingMap.set(reading, targets);
}
targets.push(
{frequencies: frequencies1, pitches: pitches1, index: i},
{frequencies: frequencies2, pitches: pitches2, index: i}
);
}
}
const metas = await this._database.findTermMetaBulk(expressionKeys, enabledDictionaryMap);
for (const {expression, mode, data, dictionary, index} of metas) {
const dictionaryOrder = this._getDictionaryOrder(dictionary, enabledDictionaryMap);
const map2 = expressionValues[index];
for (const [reading, targets] of map2.entries()) {
switch (mode) {
case 'freq':
{
let frequency = data;
const hasReading = (data !== null && typeof data === 'object');
if (hasReading) {
if (data.reading !== reading) { continue; }
frequency = data.frequency;
}
for (const {frequencies, index: expressionIndex} of targets) {
frequencies.push({index: frequencies.length, expressionIndex, dictionary, dictionaryOrder, expression, reading, hasReading, frequency});
}
}
break;
case 'pitch':
{
if (data.reading !== reading) { continue; }
const pitches2 = [];
for (let {position, tags} of data.pitches) {
tags = Array.isArray(tags) ? await this._expandTags(tags, dictionary) : [];
pitches2.push({position, tags});
}
for (const {pitches, index: expressionIndex} of targets) {
pitches.push({index: pitches.length, expressionIndex, dictionary, dictionaryOrder, expression, reading, pitches: pitches2});
}
}
break;
}
}
}
for (const definition of allDefinitions) {
this._sortTermDefinitionMeta(definition);
}
}
async _buildKanjiMeta(definitions, enabledDictionaryMap) {
const kanjiList = [];
for (const {character} of definitions) {
kanjiList.push(character);
}
const metas = await this._database.findKanjiMetaBulk(kanjiList, enabledDictionaryMap);
for (const {character, mode, data, dictionary, index} of metas) {
const dictionaryOrder = this._getDictionaryOrder(dictionary, enabledDictionaryMap);
switch (mode) {
case 'freq':
{
const {frequencies} = definitions[index];
frequencies.push({index: frequencies.length, dictionary, dictionaryOrder, character, frequency: data});
}
break;
}
}
for (const definition of definitions) {
this._sortKanjiDefinitionMeta(definition);
}
}
async _expandTags(names, dictionary) {
const tagMetaList = await this._getTagMetaList(names, dictionary);
const results = [];
for (let i = 0, ii = tagMetaList.length; i < ii; ++i) {
const meta = tagMetaList[i];
const name = names[i];
const {category, notes, order, score} = (meta !== null ? meta : {});
const tag = this._createTag(name, category, notes, order, score, dictionary, false);
results.push(tag);
}
return results;
}
async _expandStats(items, dictionary) {
const names = Object.keys(items);
const tagMetaList = await this._getTagMetaList(names, dictionary);
const statsGroups = new Map();
for (let i = 0; i < names.length; ++i) {
const name = names[i];
const meta = tagMetaList[i];
if (meta === null) { continue; }
const {category, notes, order, score} = meta;
let group = statsGroups.get(category);
if (typeof group === 'undefined') {
group = [];
statsGroups.set(category, group);
}
const value = items[name];
const stat = this._createKanjiStat(name, category, notes, order, score, dictionary, value);
group.push(stat);
}
const stats = {};
for (const [category, group] of statsGroups.entries()) {
this._sortKanjiStats(group);
stats[category] = group;
}
return stats;
}
async _getTagMetaList(names, dictionary) {
const tagMetaList = [];
let cache = this._tagCache.get(dictionary);
if (typeof cache === 'undefined') {
cache = new Map();
this._tagCache.set(dictionary, cache);
}
for (const name of names) {
const base = this._getNameBase(name);
let tagMeta = cache.get(base);
if (typeof tagMeta === 'undefined') {
tagMeta = await this._database.findTagForTitle(base, dictionary);
cache.set(base, tagMeta);
}
tagMetaList.push(tagMeta);
}
return tagMetaList;
}
// Simple helpers
_scoreToTermFrequency(score) {
if (score > 0) {
return 'popular';
} else if (score < 0) {
return 'rare';
} else {
return 'normal';
}
}
_getNameBase(name) {
const pos = name.indexOf(':');
return (pos >= 0 ? name.substring(0, pos) : name);
}
_getSearchableText(text, allowAlphanumericCharacters) {
if (allowAlphanumericCharacters) {
return text;
}
const jp = this._japaneseUtil;
let newText = '';
for (const c of text) {
if (!jp.isCodePointJapanese(c.codePointAt(0))) {
break;
}
newText += c;
}
return newText;
}
_getTextOptionEntryVariants(value) {
switch (value) {
case 'true': return [true];
case 'variant': return [false, true];
default: return [false];
}
}
_getCollapseEmphaticOptions(options) {
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;
}
_getTextReplacementsVariants(options) {
return options.textReplacements;
}
_getSecondarySearchDictionaryMap(enabledDictionaryMap) {
const secondarySearchDictionaryMap = new Map();
for (const [dictionary, details] of enabledDictionaryMap.entries()) {
if (!details.allowSecondarySearches) { continue; }
secondarySearchDictionaryMap.set(dictionary, details);
}
return secondarySearchDictionaryMap;
}
_getDictionaryOrder(dictionary, enabledDictionaryMap) {
const info = enabledDictionaryMap.get(dictionary);
const {index, priority} = typeof info !== 'undefined' ? info : {index: enabledDictionaryMap.size, priority: 0};
return {index, priority};
}
_getTagNamesWithCategory(tags, category) {
const results = [];
for (const tag of tags) {
if (tag.category !== category) { continue; }
results.push(tag.name);
}
results.sort();
return results;
}
_flagTagsWithCategoryAsRedundant(tags, removeCategoriesSet) {
for (const tag of tags) {
if (removeCategoriesSet.has(tag.category)) {
tag.redundant = true;
}
}
}
_getUniqueDictionaryNames(definitions) {
const uniqueDictionaryNames = new Set();
for (const {dictionaryNames} of definitions) {
for (const dictionaryName of dictionaryNames) {
uniqueDictionaryNames.add(dictionaryName);
}
}
return [...uniqueDictionaryNames];
}
_getUniqueTermTags(definitions) {
const newTermTags = [];
if (definitions.length <= 1) {
for (const {termTags} of definitions) {
for (const tag of termTags) {
newTermTags.push(this._cloneTag(tag));
}
}
} else {
const tagsSet = new Set();
let checkTagsMap = false;
for (const {termTags} of definitions) {
for (const tag of termTags) {
const key = this._getTagMapKey(tag);
if (checkTagsMap && tagsSet.has(key)) { continue; }
tagsSet.add(key);
newTermTags.push(this._cloneTag(tag));
}
checkTagsMap = true;
}
}
return newTermTags;
}
*_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;
}
}
_areSetsEqual(set1, set2) {
if (set1.size !== set2.size) {
return false;
}
for (const value of set1) {
if (!set2.has(value)) {
return false;
}
}
return true;
}
_getSetIntersection(set1, set2) {
const result = [];
for (const value of set1) {
if (set2.has(value)) {
result.push(value);
}
}
return result;
}
_getAllDefinitions(definitions) {
definitions = [...definitions];
for (let i = 0; i < definitions.length; ++i) {
const childDefinitions = definitions[i].definitions;
if (Array.isArray(childDefinitions)) {
definitions.push(...childDefinitions);
}
}
return definitions;
}
// Reduction functions
_getTermTagsScoreSum(termTags) {
let result = 0;
for (const {score} of termTags) {
result += score;
}
return result;
}
_getSourceTermMatchCountSum(definitions) {
let result = 0;
for (const {sourceTermExactMatchCount} of definitions) {
result += sourceTermExactMatchCount;
}
return result;
}
_getMaxDefinitionScore(definitions) {
let result = Number.MIN_SAFE_INTEGER;
for (const {score} of definitions) {
if (score > result) { result = score; }
}
return result;
}
_getMaxPrimaryDefinitionScore(definitions) {
let result = Number.MIN_SAFE_INTEGER;
for (const {isPrimary, score} of definitions) {
if (isPrimary && score > result) { result = score; }
}
return result;
}
_getBestDictionaryOrder(definitions) {
let index = Number.MAX_SAFE_INTEGER;
let priority = Number.MIN_SAFE_INTEGER;
for (const {dictionaryOrder: {index: index2, priority: priority2}} of definitions) {
if (index2 < index) { index = index2; }
if (priority2 > priority) { priority = priority2; }
}
return {index, priority};
}
// Common data creation and cloning functions
_cloneTag(tag) {
const {name, category, notes, order, score, dictionary, redundant} = tag;
return this._createTag(name, category, notes, order, score, dictionary, redundant);
}
_getTagMapKey(tag) {
const {name, category, notes} = tag;
return this._createMapKey([name, category, notes]);
}
_createMapKey(array) {
return JSON.stringify(array);
}
_createTag(name, category, notes, order, score, dictionary, redundant) {
return {
name,
category: (typeof category === 'string' && category.length > 0 ? category : 'default'),
notes: (typeof notes === 'string' ? notes : ''),
order: (typeof order === 'number' ? order : 0),
score: (typeof score === 'number' ? score : 0),
dictionary: (typeof dictionary === 'string' ? dictionary : null),
redundant
};
}
_createKanjiStat(name, category, notes, order, score, dictionary, value) {
return {
name,
category: (typeof category === 'string' && category.length > 0 ? category : 'default'),
notes: (typeof notes === 'string' ? notes : ''),
order: (typeof order === 'number' ? order : 0),
score: (typeof score === 'number' ? score : 0),
dictionary: (typeof dictionary === 'string' ? dictionary : null),
value
};
}
_createKanjiDefinition(character, dictionary, onyomi, kunyomi, glossary, tags, stats) {
return {
type: 'kanji',
character,
dictionary,
onyomi,
kunyomi,
glossary,
tags,
stats,
frequencies: []
};
}
async _createTermDefinitionFromDatabaseDefinition(databaseDefinition, source, rawSource, sourceTerm, reasons, isPrimary, enabledDictionaryMap) {
const {expression, reading, definitionTags, termTags, glossary, score, dictionary, id, sequence} = databaseDefinition;
const dictionaryOrder = this._getDictionaryOrder(dictionary, enabledDictionaryMap);
const termTagsExpanded = await this._expandTags(termTags, dictionary);
const definitionTagsExpanded = await this._expandTags(definitionTags, dictionary);
this._sortTags(definitionTagsExpanded);
this._sortTags(termTagsExpanded);
const furiganaSegments = this._japaneseUtil.distributeFurigana(expression, reading);
const termDetailsList = [this._createTermDetails(sourceTerm, expression, reading, furiganaSegments, termTagsExpanded)];
const sourceTermExactMatchCount = (sourceTerm === expression ? 1 : 0);
return {
type: 'term',
id,
source,
rawSource,
sourceTerm,
reasons,
score,
isPrimary,
sequence,
dictionary,
dictionaryOrder,
dictionaryNames: [dictionary],
expression,
reading,
expressions: termDetailsList,
furiganaSegments,
glossary,
definitionTags: definitionTagsExpanded,
termTags: termTagsExpanded,
// definitions
frequencies: [],
pitches: [],
// only
sourceTermExactMatchCount
};
}
_createGroupedTermDefinition(definitions) {
const {expression, reading, furiganaSegments, reasons, source, rawSource, sourceTerm} = definitions[0];
const score = this._getMaxDefinitionScore(definitions);
const dictionaryOrder = this._getBestDictionaryOrder(definitions);
const dictionaryNames = this._getUniqueDictionaryNames(definitions);
const termTags = this._getUniqueTermTags(definitions);
const termDetailsList = [this._createTermDetails(sourceTerm, expression, reading, furiganaSegments, termTags)];
const sourceTermExactMatchCount = (sourceTerm === expression ? 1 : 0);
return {
type: 'termGrouped',
// id
source,
rawSource,
sourceTerm,
reasons: [...reasons],
score,
// isPrimary
// sequence
dictionary: dictionaryNames[0],
dictionaryOrder,
dictionaryNames,
expression,
reading,
expressions: termDetailsList,
furiganaSegments, // Contains duplicate data
// glossary
// definitionTags
termTags,
definitions, // type: 'term'
frequencies: [],
pitches: [],
// only
sourceTermExactMatchCount
};
}
_createMergedTermDefinition(source, rawSource, definitions, expressions, readings, termDetailsList, reasons, score) {
const dictionaryOrder = this._getBestDictionaryOrder(definitions);
const sourceTermExactMatchCount = this._getSourceTermMatchCountSum(definitions);
const dictionaryNames = this._getUniqueDictionaryNames(definitions);
return {
type: 'termMerged',
// id
source,
rawSource,
// sourceTerm
reasons,
score,
// isPrimary
// sequence
dictionary: dictionaryNames[0],
dictionaryOrder,
dictionaryNames,
expression: expressions,
reading: readings,
expressions: termDetailsList,
// furiganaSegments
// glossary
// definitionTags
// termTags
definitions, // type: 'termMergedByGlossary'
frequencies: [],
pitches: [],
// only
sourceTermExactMatchCount
};
}
_createMergedGlossaryTermDefinition(source, rawSource, definitions, expressions, readings, allExpressions, allReadings) {
const only = [];
if (!this._areSetsEqual(expressions, allExpressions)) {
only.push(...this._getSetIntersection(expressions, allExpressions));
}
if (!this._areSetsEqual(readings, allReadings)) {
only.push(...this._getSetIntersection(readings, allReadings));
}
const sourceTermExactMatchCount = this._getSourceTermMatchCountSum(definitions);
const dictionaryNames = this._getUniqueDictionaryNames(definitions);
const termDetailsList = this._createTermDetailsList(definitions);
const definitionTags = this._getUniqueDefinitionTags(definitions);
this._sortTags(definitionTags);
const {glossary} = definitions[0];
const score = this._getMaxDefinitionScore(definitions);
const dictionaryOrder = this._getBestDictionaryOrder(definitions);
return {
type: 'termMergedByGlossary',
// id
source,
rawSource,
// sourceTerm
reasons: [],
score,
// isPrimary
// sequence
dictionary: dictionaryNames[0],
dictionaryOrder,
dictionaryNames,
expression: [...expressions],
reading: [...readings],
expressions: termDetailsList,
// furiganaSegments
glossary: [...glossary],
definitionTags,
// termTags
definitions, // type: 'term'; contains duplicate data
frequencies: [],
pitches: [],
only,
sourceTermExactMatchCount
};
}
_createTermDetailsList(definitions) {
const termInfoMap = new Map();
for (const {expression, reading, sourceTerm, furiganaSegments, termTags} of definitions) {
let readingMap = termInfoMap.get(expression);
if (typeof readingMap === 'undefined') {
readingMap = new Map();
termInfoMap.set(expression, readingMap);
}
let termInfo = readingMap.get(reading);
if (typeof termInfo === 'undefined') {
termInfo = {
sourceTerm,
furiganaSegments,
termTagsMap: new Map()
};
readingMap.set(reading, termInfo);
}
const {termTagsMap} = termInfo;
for (const tag of termTags) {
const {name} = tag;
if (termTagsMap.has(name)) { continue; }
termTagsMap.set(name, this._cloneTag(tag));
}
}
const termDetailsList = [];
for (const [expression, readingMap] of termInfoMap.entries()) {
for (const [reading, {termTagsMap, sourceTerm, furiganaSegments}] of readingMap.entries()) {
const termTags = [...termTagsMap.values()];
this._sortTags(termTags);
termDetailsList.push(this._createTermDetails(sourceTerm, expression, reading, furiganaSegments, termTags));
}
}
return termDetailsList;
}
_createTermDetails(sourceTerm, expression, reading, furiganaSegments, termTags) {
const termFrequency = this._scoreToTermFrequency(this._getTermTagsScoreSum(termTags));
return {
sourceTerm,
expression,
reading,
furiganaSegments, // Contains duplicate data
termTags,
termFrequency,
frequencies: [],
pitches: []
};
}
// Sorting functions
_sortTags(tags) {
if (tags.length <= 1) { return; }
const stringComparer = this._stringComparer;
tags.sort((v1, v2) => {
const i = v1.order - v2.order;
if (i !== 0) { return i; }
return stringComparer.compare(v1.name, v2.name);
});
}
_sortDefinitions(definitions) {
if (definitions.length <= 1) { return; }
const stringComparer = this._stringComparer;
const compareFunction = (v1, v2) => {
// Sort by length of source term
let i = v2.source.length - v1.source.length;
if (i !== 0) { return i; }
// Sort by the number of inflection reasons
i = v1.reasons.length - v2.reasons.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 dictionary priority
i = v2.dictionaryOrder.priority - v1.dictionaryOrder.priority;
if (i !== 0) { return i; }
// Sort by term score
i = v2.score - v1.score;
if (i !== 0) { return i; }
// Sort by expression string comparison (skip if either expression is not a string, e.g. array)
const expression1 = v1.expression;
const expression2 = v2.expression;
if (typeof expression1 === 'string' && typeof expression2 === 'string') {
i = expression2.length - expression1.length;
if (i !== 0) { return i; }
i = stringComparer.compare(expression1, expression2);
if (i !== 0) { return i; }
}
// Sort by dictionary order
i = v1.dictionaryOrder.index - v2.dictionaryOrder.index;
return i;
};
definitions.sort(compareFunction);
}
_sortDatabaseDefinitionsByIndex(definitions) {
if (definitions.length <= 1) { return; }
definitions.sort((a, b) => a.index - b.index);
}
_sortDefinitionsById(definitions) {
if (definitions.length <= 1) { return; }
definitions.sort((a, b) => a.id - b.id);
}
_sortKanjiStats(stats) {
if (stats.length <= 1) { return; }
const stringComparer = this._stringComparer;
stats.sort((v1, v2) => {
const i = v1.order - v2.order;
if (i !== 0) { return i; }
return stringComparer.compare(v1.notes, v2.notes);
});
}
_sortTermDefinitionMeta(definition) {
const compareFunction = (v1, v2) => {
// Sort by dictionary priority
let i = v2.dictionaryOrder.priority - v1.dictionaryOrder.priority;
if (i !== 0) { return i; }
// Sory by expression order
i = v1.expressionIndex - v2.expressionIndex;
if (i !== 0) { return i; }
// Sort by dictionary order
i = v1.dictionaryOrder.index - v2.dictionaryOrder.index;
if (i !== 0) { return i; }
// Default order
i = v1.index - v2.index;
return i;
};
const {expressions, frequencies: frequencies1, pitches: pitches1} = definition;
frequencies1.sort(compareFunction);
pitches1.sort(compareFunction);
for (const {frequencies: frequencies2, pitches: pitches2} of expressions) {
frequencies2.sort(compareFunction);
pitches2.sort(compareFunction);
}
}
_sortKanjiDefinitionMeta(definition) {
const compareFunction = (v1, v2) => {
// Sort by dictionary priority
let i = v2.dictionaryOrder.priority - v1.dictionaryOrder.priority;
if (i !== 0) { return i; }
// Sort by dictionary order
i = v1.dictionaryOrder.index - v2.dictionaryOrder.index;
if (i !== 0) { return i; }
// Default order
i = v1.index - v2.index;
return i;
};
const {frequencies} = definition;
frequencies.sort(compareFunction);
}
// Regex functions
_applyTextReplacements(text, sourceMap, replacements) {
for (const {pattern, replacement} of replacements) {
text = this._applyTextReplacement(text, sourceMap, pattern, replacement);
}
return text;
}
_applyTextReplacement(text, sourceMap, pattern, replacement) {
const isGlobal = pattern.global;
if (isGlobal) { pattern.lastIndex = 0; }
for (let loop = true; loop; loop = isGlobal) {
const match = pattern.exec(text);
if (match === null) { break; }
const matchText = match[0];
const index = match.index;
const actualReplacement = this._applyMatchReplacement(replacement, match);
const actualReplacementLength = actualReplacement.length;
const delta = actualReplacementLength - (matchText.length > 0 ? matchText.length : -1);
text = `${text.substring(0, index)}${actualReplacement}${text.substring(index + matchText.length)}`;
pattern.lastIndex += delta;
if (actualReplacementLength > 0) {
sourceMap.insert(index, ...(new Array(actualReplacementLength).fill(0)));
sourceMap.combine(index - 1 + actualReplacementLength, matchText.length);
} else {
sourceMap.combine(index, matchText.length);
}
}
return text;
}
_applyMatchReplacement(replacement, match) {
const pattern = /\$(?:\$|&|`|'|(\d\d?)|<([^>]*)>)/g;
return replacement.replace(pattern, (g0, g1, g2) => {
if (typeof g1 !== 'undefined') {
const matchIndex = Number.parseInt(g1, 10);
if (matchIndex >= 1 && matchIndex <= match.length) {
return match[matchIndex];
}
} else if (typeof g2 !== 'undefined') {
const {groups} = match;
if (typeof groups === 'object' && groups !== null && Object.prototype.hasOwnProperty.call(groups, g2)) {
return groups[g2];
}
} else {
switch (g0) {
case '$': return '$';
case '&': return match[0];
case '`': return replacement.substring(0, match.index);
case '\'': return replacement.substring(match.index + g0.length);
}
}
return g0;
});
}
}