Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ext/js/data/anki-note-data-creator.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ function getPublicContext(context) {
}

/**
* @param {import('dictionary').TermDictionaryEntry|import('dictionary').KanjiDictionaryEntry} dictionaryEntry
* @param {import('dictionary').TermDictionaryEntry|import('dictionary').KanjiDictionaryEntry|import('translation-internal').TermDictionaryEntry} dictionaryEntry
* @param {number?} requestedHeadwordIndex
* @param {import('dictionary-data').FrequencyMode|undefined?} requestedFrequencyMode
* @returns {import('anki-templates').FrequencyNumber[]}
Expand Down Expand Up @@ -205,7 +205,7 @@ function getFrequencyNumbers(dictionaryEntry, requestedHeadwordIndex, requestedF
}

/**
* @param {import('dictionary').TermDictionaryEntry|import('dictionary').KanjiDictionaryEntry} dictionaryEntry
* @param {import('dictionary').TermDictionaryEntry|import('dictionary').KanjiDictionaryEntry|import('translation-internal').TermDictionaryEntry} dictionaryEntry
* @param {number?} headwordIndex
* @param {import('dictionary-data').FrequencyMode|undefined?} frequencyMode
* @returns {number}
Expand Down
25 changes: 25 additions & 0 deletions ext/js/data/sort-frequency-dictionary.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2023-2026 Yomitan 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/>.
*/

/**
* Sentinel value for the `sortFrequencyDictionary` setting indicating that search
* results should be sorted by the harmonic average frequency across all installed
* frequency dictionaries, rather than by a single dictionary. The value contains a
* leading NUL character so it cannot collide with a real dictionary title.
* @type {string}
*/
export const SORT_FREQUENCY_DICTIONARY_AVERAGE = '\0average';
57 changes: 55 additions & 2 deletions ext/js/language/translator.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
*/

import {safePerformance} from '../core/safe-performance.js';
import {getFrequencyHarmonic} from '../data/anki-note-data-creator.js';
import {SORT_FREQUENCY_DICTIONARY_AVERAGE} from '../data/sort-frequency-dictionary.js';
import {applyTextReplacement} from '../general/regex-util.js';
import {isCodePointJapanese} from './ja/japanese.js';
import {isCodePointKorean} from './ko/korean.js';
Expand Down Expand Up @@ -105,7 +107,10 @@ export class Translator {
this._removeExcludedDefinitions(dictionaryEntries, excludeDictionaryDefinitions);
}

if (mode !== 'simple' || useAllFrequencyDictionaries) {
const sortByAverageFrequency = sortFrequencyDictionary === SORT_FREQUENCY_DICTIONARY_AVERAGE;

if (mode !== 'simple' || useAllFrequencyDictionaries || sortByAverageFrequency) {
// Sorting by the average frequency requires the metadata of every enabled frequency dictionary
await this._addTermMeta(dictionaryEntries, enabledDictionaryMap, tagAggregator);
await this._expandTagGroupsAndGroup(tagAggregator.getTagExpansionTargets());
} else {
Expand All @@ -120,7 +125,9 @@ export class Translator {
}
}

if (sortFrequencyDictionary !== null) {
if (sortByAverageFrequency) {
this._updateSortFrequenciesHarmonic(dictionaryEntries, sortFrequencyDictionaryOrder === 'ascending');
} else if (sortFrequencyDictionary !== null) {
this._updateSortFrequencies(dictionaryEntries, sortFrequencyDictionary, sortFrequencyDictionaryOrder === 'ascending');
}
if (dictionaryEntries.length > 1) {
Expand Down Expand Up @@ -2377,6 +2384,52 @@ export class Translator {
}
}

/**
* Assigns `frequencyOrder` based on the harmonic average frequency across all frequency
* dictionaries. The harmonic value used here is identical to the "Average" value shown in the
* frequency display (see `getFrequencyHarmonic`), so sorting matches what the user sees.
* @param {import('translation-internal').TermDictionaryEntry[]} dictionaryEntries
* @param {boolean} ascending
*/
_updateSortFrequenciesHarmonic(dictionaryEntries, ascending) {
for (const dictionaryEntry of dictionaryEntries) {
const {definitions, headwords} = dictionaryEntry;
// Frequencies must be grouped by dictionary (as they are for display) so the harmonic
// average counts at most one value per dictionary.
this._sortTermDictionaryEntrySimpleData(dictionaryEntry.frequencies);

const headwordIndices = [];
for (let i = 0, ii = headwords.length; i < ii; ++i) { headwordIndices.push(i); }
dictionaryEntry.frequencyOrder = this._getHarmonicFrequencyOrder(dictionaryEntry, headwordIndices, ascending);

for (const definition of definitions) {
definition.frequencyOrder = this._getHarmonicFrequencyOrder(dictionaryEntry, definition.headwordIndices, ascending);
}
}
}

/**
* @param {import('translation-internal').TermDictionaryEntry} dictionaryEntry
* @param {number[]} headwordIndices
* @param {boolean} ascending
* @returns {number}
*/
_getHarmonicFrequencyOrder(dictionaryEntry, headwordIndices, ascending) {
let frequencyMin = Number.MAX_SAFE_INTEGER;
let frequencyMax = Number.MIN_SAFE_INTEGER;
for (const headwordIndex of headwordIndices) {
const frequency = getFrequencyHarmonic(dictionaryEntry, headwordIndex, null);
if (frequency < 0) { continue; }
frequencyMin = Math.min(frequencyMin, frequency);
frequencyMax = Math.max(frequencyMax, frequency);
}
return (
frequencyMin <= frequencyMax ?
(ascending ? frequencyMin : -frequencyMax) :
(ascending ? Number.MAX_SAFE_INTEGER : 0)
);
}

/**
* @param {import('translation-internal').TextProcessorRuleChainCandidate[]} inflectionRuleChainCandidates
* @returns {number}
Expand Down
24 changes: 15 additions & 9 deletions ext/js/pages/settings/sort-frequency-dictionary-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import {SORT_FREQUENCY_DICTIONARY_AVERAGE} from '../../data/sort-frequency-dictionary.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';

export class SortFrequencyDictionaryController {
Expand Down Expand Up @@ -93,26 +94,31 @@ export class SortFrequencyDictionaryController {
/** */
_onSortFrequencyDictionaryOrderAutoButtonClick() {
const {value} = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect);
if (value === '') { return; }
if (value === '' || value === SORT_FREQUENCY_DICTIONARY_AVERAGE) { return; }
void this._autoUpdateOrder(value);
}

/**
* @param {import('dictionary-importer').Summary[]} dictionaries
*/
_updateDictionaryOptions(dictionaries) {
const frequencyDictionaries = dictionaries.filter(({counts}) => !!counts && !!counts.termMeta && counts.termMeta.freq > 0);
const fragment = document.createDocumentFragment();
let option = document.createElement('option');
option.value = '';
option.textContent = 'None';
fragment.appendChild(option);
for (const {title, counts} of dictionaries) {
if (counts && counts.termMeta && counts.termMeta.freq > 0) {
option = document.createElement('option');
option.value = title;
option.textContent = title;
fragment.appendChild(option);
}
if (frequencyDictionaries.length > 0) {
option = document.createElement('option');
option.value = SORT_FREQUENCY_DICTIONARY_AVERAGE;
option.textContent = 'Average (all dictionaries)';
fragment.appendChild(option);
}
for (const {title} of frequencyDictionaries) {
option = document.createElement('option');
option.value = title;
option.textContent = title;
fragment.appendChild(option);
}
const select = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect);
select.textContent = '';
Expand All @@ -125,7 +131,7 @@ export class SortFrequencyDictionaryController {
async _setSortFrequencyDictionaryValue(value) {
/** @type {HTMLElement} */ (this._sortFrequencyDictionaryOrderContainerNode).hidden = (value === null);
await this._settingsController.setProfileSetting('general.sortFrequencyDictionary', value);
if (value !== null) {
if (value !== null && value !== SORT_FREQUENCY_DICTIONARY_AVERAGE) {
await this._autoUpdateOrder(value);
}
}
Expand Down
5 changes: 5 additions & 0 deletions ext/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ <h1>Yomitan Settings</h1>
This can be beneficial when using multiple dictionaries which may not have
consistent sorting information.
</p>
<p>
Selecting <em>Average (all dictionaries)</em> instead sorts results by the harmonic
average frequency across all installed frequency dictionaries, matching the
&quot;Average&quot; value shown in the frequency display.
</p>
<p>
<a tabindex="0" class="more-toggle" data-parent-distance="3">Less&hellip;</a>
</p>
Expand Down
129 changes: 129 additions & 0 deletions test/translator-frequency-sort.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright (C) 2026 Yomitan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import {describe, expect, test} from 'vitest';
import {Translator} from '../ext/js/language/translator.js';

/**
* Creates a term frequency in the internal shape produced by the translator.
* @param {number} index
* @param {string} dictionary
* @param {number} dictionaryIndex
* @param {number} frequency
* @returns {import('dictionary').TermFrequency}
*/
function createFrequency(index, dictionary, dictionaryIndex, frequency) {
return {
index,
headwordIndex: 0,
dictionary,
dictionaryAlias: dictionary,
dictionaryIndex,
frequencyMode: 'rank-based',
hasReading: false,
frequency,
displayValue: null,
displayValueParsed: false,
};
}

/**
* Builds a minimal single-headword term dictionary entry carrying only the fields
* used by the harmonic frequency sort.
* @param {import('dictionary').TermFrequency[]} frequencies
* @returns {import('translation-internal').TermDictionaryEntry}
*/
function createEntry(frequencies) {
return /** @type {import('translation-internal').TermDictionaryEntry} */ (/** @type {unknown} */ ({
headwords: [{}],
definitions: [{headwordIndices: [0], frequencyOrder: 0}],
frequencies,
frequencyOrder: 0,
}));
}

describe('Translator harmonic frequency sort', () => {
const translator = new Translator(/** @type {import('../ext/js/dictionary/dictionary-database.js').DictionaryDatabase} */ (/** @type {unknown} */ ({})));
// Accessed via a string key so the underscore-prefixed method name does not trip no-underscore-dangle.
// eslint-disable-next-line dot-notation
const updateSortFrequenciesHarmonic = translator['_updateSortFrequenciesHarmonic'].bind(translator);

test('averages the frequency across all dictionaries (matching the displayed harmonic value)', () => {
// harmonic(1, 3) = 2 / (1/1 + 1/3) = 1.5 -> floor 1
const entryLow = createEntry([
createFrequency(0, 'A', 0, 1),
createFrequency(1, 'B', 1, 3),
]);
// harmonic(4, 12) = 2 / (1/4 + 1/12) = 6
const entryHigh = createEntry([
createFrequency(0, 'A', 0, 4),
createFrequency(1, 'B', 1, 12),
]);

/** @type {import('translation-internal').TermDictionaryEntry[]} */
const entries = [entryHigh, entryLow];
updateSortFrequenciesHarmonic(entries, true);

expect(entryLow.frequencyOrder).toStrictEqual(1);
expect(entryHigh.frequencyOrder).toStrictEqual(6);
expect(entryLow.definitions[0].frequencyOrder).toStrictEqual(1);
expect(entryHigh.definitions[0].frequencyOrder).toStrictEqual(6);

// Ascending places the lower average first.
entries.sort((a, b) => a.frequencyOrder - b.frequencyOrder);
expect(entries).toStrictEqual([entryLow, entryHigh]);
});

test('descending negates the order so the higher average sorts first', () => {
const entryLow = createEntry([createFrequency(0, 'A', 0, 2)]);
const entryHigh = createEntry([createFrequency(0, 'A', 0, 50)]);

const entries = [entryLow, entryHigh];
updateSortFrequenciesHarmonic(entries, false);

expect(entryLow.frequencyOrder).toStrictEqual(-2);
expect(entryHigh.frequencyOrder).toStrictEqual(-50);

entries.sort((a, b) => a.frequencyOrder - b.frequencyOrder);
expect(entries).toStrictEqual([entryHigh, entryLow]);
});

test('counts at most one frequency per dictionary', () => {
// The second value from dictionary A must be ignored: harmonic(1, 3) = 1, not harmonic(1, 100, 3).
const entry = createEntry([
createFrequency(0, 'A', 0, 1),
createFrequency(1, 'A', 0, 100),
createFrequency(2, 'B', 1, 3),
]);
updateSortFrequenciesHarmonic([entry], true);
expect(entry.frequencyOrder).toStrictEqual(1);
});

test('entries without frequency data sort last', () => {
const entryWithData = createEntry([createFrequency(0, 'A', 0, 5)]);
const entryNoData = createEntry([]);

const ascending = [entryWithData, entryNoData];
updateSortFrequenciesHarmonic(ascending, true);
expect(entryNoData.frequencyOrder).toStrictEqual(Number.MAX_SAFE_INTEGER);
expect(entryWithData.frequencyOrder).toStrictEqual(5);

updateSortFrequenciesHarmonic([entryWithData, entryNoData], false);
expect(entryNoData.frequencyOrder).toStrictEqual(0);
expect(entryWithData.frequencyOrder).toStrictEqual(-5);
});
});