feat(dictionary): add Hachidori backend support

- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
This commit is contained in:
2026-09-22 00:21:19 -07:00
parent 1508863dbb
commit d9fdc7ef6d
446 changed files with 109060 additions and 244 deletions
+2
View File
@@ -22,6 +22,7 @@ export type {
} from './definitions/shared';
const {
dictionaryBackend,
subtitlePosition,
keybindings,
websocket,
@@ -57,6 +58,7 @@ const { stats } = STATS_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
dictionaryBackend,
subtitlePosition,
keybindings,
websocket,
+2
View File
@@ -2,6 +2,7 @@ import { ResolvedConfig } from '../../types/config';
export const CORE_DEFAULT_CONFIG: Pick<
ResolvedConfig,
| 'dictionaryBackend'
| 'subtitlePosition'
| 'keybindings'
| 'websocket'
@@ -18,6 +19,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'notifications'
| 'auto_start_overlay'
> = {
dictionaryBackend: 'yomitan',
subtitlePosition: { yPercent: 10 },
keybindings: [],
websocket: {
+7
View File
@@ -81,6 +81,13 @@ export function buildCoreConfigOptionRegistry(
] as const;
return [
{
path: 'dictionaryBackend',
kind: 'enum',
enumValues: ['yomitan', 'hachidori'],
defaultValue: defaultConfig.dictionaryBackend,
description: 'Dictionary lookup backend. Restart SubMiner after changing this setting.',
},
{
path: 'logging.level',
kind: 'enum',
@@ -1,6 +1,14 @@
import { ConfigTemplateSection } from './shared';
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Dictionary Backend',
description: ['Select the dictionary lookup backend: yomitan or hachidori.'],
notes: [
'Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.',
],
key: 'dictionaryBackend',
},
{
title: 'Japanese Subtitle Generation',
description: [
+11
View File
@@ -6,6 +6,17 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
export function applyCoreDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (src.dictionaryBackend === 'yomitan' || src.dictionaryBackend === 'hachidori') {
resolved.dictionaryBackend = src.dictionaryBackend;
} else if (src.dictionaryBackend !== undefined) {
warn(
'dictionaryBackend',
src.dictionaryBackend,
resolved.dictionaryBackend,
"Expected 'yomitan' or 'hachidori'.",
);
}
if (isObject(src.texthooker)) {
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
if (launchAtStartup !== undefined) {
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveConfig } from '../resolve';
import { buildConfigSettingsRegistry } from '../settings/registry';
import { createResolveContext } from './context';
import { applyCoreDomainConfig } from './core-domains';
test('dictionary backend defaults to Yomitan and accepts Hachidori', () => {
assert.equal(resolveConfig({}).resolved.dictionaryBackend, 'yomitan');
const { resolved, warnings } = resolveConfig({ dictionaryBackend: 'hachidori' });
assert.equal(resolved.dictionaryBackend, 'hachidori');
assert.deepEqual(warnings, []);
const field = buildConfigSettingsRegistry(resolved).find(
(entry) => entry.configPath === 'dictionaryBackend',
);
assert.equal(field?.restartBehavior, 'restart');
assert.deepEqual(field?.enumValues, ['yomitan', 'hachidori']);
assert.equal(field?.category, 'integrations');
});
test('unknown dictionary backend values warn and preserve the default', () => {
for (const dictionaryBackend of ['unknown', '', null, true, {}]) {
const { context, warnings } = createResolveContext({});
context.src.dictionaryBackend = dictionaryBackend;
applyCoreDomainConfig(context);
assert.equal(context.resolved.dictionaryBackend, 'yomitan');
assert.equal(warnings.length, 1);
assert.equal(warnings[0]?.path, 'dictionaryBackend');
}
});
+7 -3
View File
@@ -152,6 +152,7 @@ const SECTION_ORDER = new Map<string, number>(
'Discord Rich Presence',
'Jellyfin',
'Texthooker',
'Dictionary Lookup',
'Yomitan',
'Stats dashboard',
'Startup warmups',
@@ -234,8 +235,8 @@ const LABEL_OVERRIDES: Record<string, string> = {
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
'subtitleStyle.css': 'CSS Declarations',
@@ -276,7 +277,7 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
'subtitleStyle.primaryVisibleOnYomitanPopup':
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.',
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a dictionary popup is open.',
'websocket.enabled':
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
'discordPresence.updateIntervalMs':
@@ -340,6 +341,9 @@ function humanizePath(path: string): string {
}
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
if (path === 'dictionaryBackend') {
return { category: 'integrations', section: 'Dictionary Lookup' };
}
if (
path === 'subtitleStyle.autoPauseVideoOnHover' ||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||