refactor: split shared type entrypoints

This commit is contained in:
2026-03-26 23:17:04 -07:00
parent 5b06579e65
commit 5dd8bb7fbf
52 changed files with 1498 additions and 1346 deletions

View File

@@ -1,4 +1,4 @@
import type { AnkiConnectConfig } from './types';
import type { AnkiConnectConfig } from './types/anki';
type NoteFieldValue = { value?: string } | string | null | undefined;
@@ -8,7 +8,9 @@ function normalizeFieldName(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
export function getConfiguredWordFieldName(config?: Pick<AnkiConnectConfig, 'fields'> | null): string {
export function getConfiguredWordFieldName(
config?: Pick<AnkiConnectConfig, 'fields'> | null,
): string {
return normalizeFieldName(config?.fields?.word) ?? 'Expression';
}

View File

@@ -1,4 +1,4 @@
import type { AiConfig } from '../types';
import type { AiConfig } from '../types/integrations';
import { requestAiChatCompletion } from '../ai/client';
const DEFAULT_AI_SYSTEM_PROMPT =

View File

@@ -4,7 +4,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import type { AnkiConnectConfig } from '../types';
import type { AnkiConnectConfig } from '../types/anki';
type NoteInfoLike = {
noteId: number;
@@ -36,9 +36,7 @@ export function extractSoundFilenames(value: string): string[] {
}
function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'media'>): boolean {
return (
config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false
);
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
}
export async function probeAudioDurationSeconds(

View File

@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import type { AnkiConnectConfig } from '../types';
import type { AnkiConnectConfig } from '../types/anki';
test('CardCreationService counts locally created sentence cards', async () => {
const minedCards: Array<{ count: number; noteIds?: number[] }> = [];

View File

@@ -3,10 +3,11 @@ import {
getConfiguredWordFieldName,
getPreferredWordValueFromExtractedFields,
} from '../anki-field-config';
import { AiConfig, AnkiConnectConfig } from '../types';
import { AnkiConnectConfig } from '../types/anki';
import { createLogger } from '../logger';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import { MpvClient } from '../types';
import { AiConfig } from '../types/integrations';
import { MpvClient } from '../types/runtime';
import { resolveSentenceBackText } from './ai';
import { resolveMediaGenerationInputPath } from './media-source';

View File

@@ -1,4 +1,4 @@
import { AnkiConnectConfig } from '../types';
import { AnkiConnectConfig } from '../types/anki';
import { getConfiguredWordFieldName } from '../anki-field-config';
interface FieldGroupingMergeMedia {

View File

@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { FieldGroupingWorkflow } from './field-grouping-workflow';
import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types';
import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/anki';
type NoteInfo = {
noteId: number;

View File

@@ -1,4 +1,4 @@
import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types';
import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/anki';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
export interface FieldGroupingWorkflowNoteInfo {
@@ -181,7 +181,8 @@ export class FieldGroupingWorkflow {
return {
noteId: noteInfo.noteId,
expression:
getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig()) || fallbackExpression,
getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig()) ||
fallbackExpression,
sentencePreview: this.deps.truncateSentence(
fields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] ||
(isOriginal ? '' : this.deps.getCurrentSubtitleText() || ''),

View File

@@ -1,4 +1,4 @@
import { KikuMergePreviewResponse } from '../types';
import { KikuMergePreviewResponse } from '../types/anki';
import { createLogger } from '../logger';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';

View File

@@ -4,7 +4,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { AnkiConnectConfig } from '../types';
import type { AnkiConnectConfig } from '../types/anki';
import { KnownWordCacheManager } from './known-word-cache';
async function waitForCondition(
@@ -351,10 +351,7 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
scope: string;
words: string[];
};
assert.equal(
persisted.scope,
'{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
);
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}');
assert.deepEqual(persisted.words, ['猫']);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });

View File

@@ -3,7 +3,7 @@ import path from 'path';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getConfiguredWordFieldName } from '../anki-field-config';
import { AnkiConnectConfig } from '../types';
import { AnkiConnectConfig } from '../types/anki';
import { createLogger } from '../logger';
const log = createLogger('anki').child('integration.known-word-cache');
@@ -316,9 +316,9 @@ export class KnownWordCacheManager {
const currentDeck = this.deps.getConfig().deck?.trim();
const selectedDeckEntry =
currentDeck !== undefined && currentDeck.length > 0
? trimmedDeckEntries.find(([deckName]) => deckName === currentDeck) ?? null
? (trimmedDeckEntries.find(([deckName]) => deckName === currentDeck) ?? null)
: trimmedDeckEntries.length === 1
? trimmedDeckEntries[0] ?? null
? (trimmedDeckEntries[0] ?? null)
: null;
if (!selectedDeckEntry) {
@@ -329,7 +329,10 @@ export class KnownWordCacheManager {
if (Array.isArray(deckFields)) {
const normalizedFields = [
...new Set(
deckFields.map(String).map((field) => field.trim()).filter((field) => field.length > 0),
deckFields
.map(String)
.map((field) => field.trim())
.filter((field) => field.length > 0),
),
];
if (normalizedFields.length > 0) {
@@ -353,7 +356,14 @@ export class KnownWordCacheManager {
continue;
}
const normalizedFields = Array.isArray(fields)
? [...new Set(fields.map(String).map((field) => field.trim()).filter(Boolean))]
? [
...new Set(
fields
.map(String)
.map((field) => field.trim())
.filter(Boolean),
),
]
: [];
scopes.push({
query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
@@ -402,7 +412,10 @@ export class KnownWordCacheManager {
private async fetchKnownWordNoteFieldsById(): Promise<Map<number, string[]>> {
const scopes = this.getKnownWordQueryScopes();
const noteFieldsById = new Map<number, string[]>();
log.debug('Refreshing known-word cache', `queries=${scopes.map((scope) => scope.query).join(' | ')}`);
log.debug(
'Refreshing known-word cache',
`queries=${scopes.map((scope) => scope.query).join(' | ')}`,
);
for (const scope of scopes) {
const noteIds = (await this.deps.client.findNotes(scope.query, {
@@ -414,10 +427,7 @@ export class KnownWordCacheManager {
continue;
}
const existingFields = noteFieldsById.get(noteId) ?? [];
noteFieldsById.set(
noteId,
[...new Set([...existingFields, ...scope.fields])],
);
noteFieldsById.set(noteId, [...new Set([...existingFields, ...scope.fields])]);
}
}

View File

@@ -1,5 +1,5 @@
import { isRemoteMediaPath } from '../jimaku/utils';
import type { MpvClient } from '../types';
import type { MpvClient } from '../types/runtime';
export type MediaGenerationKind = 'audio' | 'video';
@@ -50,7 +50,7 @@ function resolvePreferredUrlFromMpvEdlSource(
// mpv EDL sources usually list audio streams first and video streams last, so
// when classifyMediaUrl cannot identify a typed URL we fall back to stream order.
return kind === 'audio' ? urls[0] ?? null : urls[urls.length - 1] ?? null;
return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null);
}
export async function resolveMediaGenerationInputPath(

View File

@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import type { AnkiConnectConfig } from '../types';
import type { AnkiConnectConfig } from '../types/anki';
import { AnkiIntegrationRuntime } from './runtime';
function createRuntime(

View File

@@ -1,5 +1,5 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import type { AnkiConnectConfig } from '../types';
import type { AnkiConnectConfig } from '../types/anki';
import {
getKnownWordCacheLifecycleConfig,
getKnownWordCacheRefreshIntervalMinutes,

View File

@@ -1,4 +1,4 @@
import { NotificationOptions } from '../types';
import { NotificationOptions } from '../types/anki';
export interface UiFeedbackState {
progressDepth: number;

View File

@@ -1,4 +1,4 @@
import { RawConfig, ResolvedConfig } from '../types';
import { RawConfig, ResolvedConfig } from '../types/config';
import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core';
import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion';
import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations';

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types';
import { ResolvedConfig } from '../../types/config';
export const CORE_DEFAULT_CONFIG: Pick<
ResolvedConfig,

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types';
import { ResolvedConfig } from '../../types/config';
export const IMMERSION_DEFAULT_CONFIG: Pick<ResolvedConfig, 'immersionTracking'> = {
immersionTracking: {

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types';
import { ResolvedConfig } from '../../types/config';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types.js';
import { ResolvedConfig } from '../../types/config.js';
export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = {
stats: {

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types';
import { ResolvedConfig } from '../../types/config';
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
subtitleStyle: {

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types';
import { ResolvedConfig } from '../../types/config';
import { ConfigOptionRegistryEntry } from './shared';
export function buildCoreConfigOptionRegistry(
@@ -263,7 +263,8 @@ export function buildCoreConfigOptionRegistry(
{
path: `controller.bindings.${binding.id}.axisIndex`,
kind: 'number' as const,
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
defaultValue:
binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
description: 'Raw axis index captured for this discrete controller action.',
},
{
@@ -293,7 +294,8 @@ export function buildCoreConfigOptionRegistry(
{
path: `controller.bindings.${binding.id}.axisIndex`,
kind: 'number' as const,
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
defaultValue:
binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
description: 'Raw axis index captured for this analog controller action.',
},
{
@@ -302,7 +304,8 @@ export function buildCoreConfigOptionRegistry(
enumValues: ['none', 'horizontal', 'vertical'],
defaultValue:
binding.defaultValue.kind === 'axis' ? binding.defaultValue.dpadFallback : undefined,
description: 'Optional D-pad fallback used when this analog controller action should also read D-pad input.',
description:
'Optional D-pad fallback used when this analog controller action should also read D-pad input.',
},
]),
{

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types';
import { ResolvedConfig } from '../../types/config';
import { ConfigOptionRegistryEntry } from './shared';
export function buildImmersionConfigOptionRegistry(

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types';
import { ResolvedConfig } from '../../types/config';
import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared';
export function buildIntegrationConfigOptionRegistry(
@@ -369,13 +369,15 @@ export function buildIntegrationConfigOptionRegistry(
path: 'youtubeSubgen.whisperBin',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperBin,
description: 'Legacy compatibility path kept for external subtitle fallback tools; not used by default.',
description:
'Legacy compatibility path kept for external subtitle fallback tools; not used by default.',
},
{
path: 'youtubeSubgen.whisperModel',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperModel,
description: 'Legacy compatibility model path kept for external subtitle fallback tooling; not used by default.',
description:
'Legacy compatibility model path kept for external subtitle fallback tooling; not used by default.',
},
{
path: 'youtubeSubgen.whisperVadModel',

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types.js';
import { ResolvedConfig } from '../../types/config.js';
import { ConfigOptionRegistryEntry } from './shared.js';
export function buildStatsConfigOptionRegistry(
@@ -15,7 +15,8 @@ export function buildStatsConfigOptionRegistry(
path: 'stats.markWatchedKey',
kind: 'string',
defaultValue: defaultConfig.stats.markWatchedKey,
description: 'Key code to mark the current video as watched and advance to the next playlist entry.',
description:
'Key code to mark the current video as watched and advance to the next playlist entry.',
},
{
path: 'stats.serverPort',

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types';
import { ResolvedConfig } from '../../types/config';
import { ConfigOptionRegistryEntry } from './shared';
export function buildSubtitleConfigOptionRegistry(

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types';
import { ResolvedConfig } from '../../types/config';
import { RuntimeOptionRegistryEntry } from './shared';
export function buildRuntimeOptionRegistry(

View File

@@ -1,11 +1,11 @@
import {
AnkiConnectConfig,
ResolvedConfig,
import type { AnkiConnectConfig } from '../../types/anki';
import type { ResolvedConfig } from '../../types/config';
import type {
RuntimeOptionId,
RuntimeOptionScope,
RuntimeOptionValue,
RuntimeOptionValueType,
} from '../../types';
} from '../../types/runtime-options';
export type ConfigValueKind = 'boolean' | 'number' | 'string' | 'enum' | 'array' | 'object';

View File

@@ -1,5 +1,5 @@
import * as fs from 'fs';
import { RawConfig } from '../types';
import { RawConfig } from '../types/config';
import { parseConfigContent } from './parse';
export interface ConfigPaths {

View File

@@ -1,4 +1,4 @@
import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types';
import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/config';
import { applyAnkiConnectResolution } from './resolve/anki-connect';
import { createResolveContext } from './resolve/context';
import { applyCoreDomainConfig } from './resolve/core-domains';

View File

@@ -1,4 +1,4 @@
import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types';
import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions';
import { createWarningCollector } from '../warnings';
import { isObject } from './shared';

View File

@@ -8,7 +8,7 @@ import type {
ControllerDiscreteBindingConfig,
ResolvedControllerAxisBinding,
ResolvedControllerDiscreteBinding,
} from '../../types';
} from '../../types/runtime';
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
@@ -27,7 +27,12 @@ const CONTROLLER_BUTTON_BINDINGS = [
'rightTrigger',
] as const;
const CONTROLLER_AXIS_BINDINGS = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
const CONTROLLER_AXIS_BINDINGS = [
'leftStickX',
'leftStickY',
'rightStickX',
'rightStickY',
] as const;
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
leftStickX: 0,
@@ -98,7 +103,9 @@ function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteB
return { kind: 'none' };
}
if (value.kind === 'button') {
return typeof value.buttonIndex === 'number' && Number.isInteger(value.buttonIndex) && value.buttonIndex >= 0
return typeof value.buttonIndex === 'number' &&
Number.isInteger(value.buttonIndex) &&
value.buttonIndex >= 0
? { kind: 'button', buttonIndex: value.buttonIndex }
: null;
}
@@ -121,7 +128,11 @@ function parseAxisBindingObject(
return { kind: 'none' };
}
if (!isObject(value) || value.kind !== 'axis') return null;
if (typeof value.axisIndex !== 'number' || !Number.isInteger(value.axisIndex) || value.axisIndex < 0) {
if (
typeof value.axisIndex !== 'number' ||
!Number.isInteger(value.axisIndex) ||
value.axisIndex < 0
) {
return null;
}
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
@@ -368,7 +379,9 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_BUTTON_BINDINGS.includes(legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number])
CONTROLLER_BUTTON_BINDINGS.includes(
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
)
) {
resolved.controller.bindings[key] = resolveLegacyDiscreteBinding(
legacyValue as ControllerButtonBinding,
@@ -401,7 +414,9 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
CONTROLLER_AXIS_BINDINGS.includes(
legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number],
)
) {
resolved.controller.bindings[key] = resolveLegacyAxisBinding(
legacyValue as ControllerAxisBinding,

View File

@@ -1,5 +1,8 @@
import { ResolveContext } from './context';
import { ImmersionTrackingRetentionMode, ImmersionTrackingRetentionPreset } from '../../types';
import {
ImmersionTrackingRetentionMode,
ImmersionTrackingRetentionPreset,
} from '../../types/integrations';
import { asBoolean, asNumber, asString, isObject } from './shared';
const DEFAULT_RETENTION_MODE: ImmersionTrackingRetentionMode = 'preset';

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../../types';
import { ResolvedConfig } from '../../types/config';
import { ResolveContext } from './context';
import {
asBoolean,
@@ -467,7 +467,9 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
if (pauseVideoOnHover !== undefined) {
resolved.subtitleSidebar.pauseVideoOnHover = pauseVideoOnHover;
} else if ((src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover !== undefined) {
} else if (
(src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover !== undefined
) {
resolved.subtitleSidebar.pauseVideoOnHover = fallback.pauseVideoOnHover;
warn(
'subtitleSidebar.pauseVideoOnHover',

View File

@@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types';
import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/config';
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
import { resolveConfig } from './resolve';

View File

@@ -1,4 +1,4 @@
import { ResolvedConfig } from '../types';
import { ResolvedConfig } from '../types/config';
import {
CONFIG_OPTION_REGISTRY,
CONFIG_TEMPLATE_SECTIONS,

View File

@@ -1,4 +1,4 @@
import { ConfigValidationWarning } from '../types';
import { ConfigValidationWarning } from '../types/config';
export interface WarningCollector {
warnings: ConfigValidationWarning[];

View File

@@ -1,4 +1,8 @@
import { RuntimeOptionApplyResult, RuntimeOptionId, RuntimeOptionValue } from '../../types';
import {
RuntimeOptionApplyResult,
RuntimeOptionId,
RuntimeOptionValue,
} from '../../types/runtime-options';
export interface RuntimeOptionsManagerLike {
setOptionValue: (id: RuntimeOptionId, value: RuntimeOptionValue) => RuntimeOptionApplyResult;

View File

@@ -2,9 +2,9 @@ import type {
AnkiConnectConfig,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
WindowGeometry,
} from '../../types';
} from '../../types/anki';
import type { BrowserWindow } from 'electron';
import type { WindowGeometry } from '../../types/runtime';
import type { BaseWindowTracker } from '../../window-trackers';
type OverlayRuntimeOptions = {

View File

@@ -16,14 +16,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { AnkiConnectConfig } from './types/anki';
import {
AnkiConnectConfig,
RuntimeOptionApplyResult,
RuntimeOptionId,
RuntimeOptionState,
RuntimeOptionValue,
SubtitleStyleConfig,
} from './types';
} from './types/runtime-options';
import { SubtitleStyleConfig } from './types/subtitle';
import { RUNTIME_OPTION_REGISTRY, RuntimeOptionRegistryEntry } from './config';
type RuntimeOverrides = Record<string, unknown>;

View File

@@ -1,4 +1,5 @@
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types';
import type { OverlayContentMeasurement } from '../../types/runtime';
import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-options';
export const OVERLAY_HOSTED_MODALS = [
'runtime-options',

View File

@@ -1,17 +1,17 @@
import type { KikuFieldGroupingChoice, KikuMergePreviewRequest } from '../../types/anki';
import type {
ControllerConfigUpdate,
ControllerPreferenceUpdate,
JimakuDownloadQuery,
JimakuFilesQuery,
JimakuSearchQuery,
KikuFieldGroupingChoice,
KikuMergePreviewRequest,
RuntimeOptionId,
RuntimeOptionValue,
SubtitlePosition,
SubsyncManualRunRequest,
YoutubePickerResolveRequest,
} from '../../types';
} from '../../types/integrations';
import type {
ControllerConfigUpdate,
ControllerPreferenceUpdate,
SubsyncManualRunRequest,
} from '../../types/runtime';
import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-options';
import type { SubtitlePosition } from '../../types/subtitle';
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
@@ -255,7 +255,9 @@ export function parseJimakuDownloadQuery(value: unknown): JimakuDownloadQuery |
};
}
export function parseYoutubePickerResolveRequest(value: unknown): YoutubePickerResolveRequest | null {
export function parseYoutubePickerResolveRequest(
value: unknown,
): YoutubePickerResolveRequest | null {
if (!isObject(value)) return null;
if (typeof value.sessionId !== 'string' || !value.sessionId.trim()) return null;
if (value.action !== 'use-selected' && value.action !== 'continue-without-subtitles') return null;
@@ -270,7 +272,11 @@ export function parseYoutubePickerResolveRequest(value: unknown): YoutubePickerR
secondaryTrackId: null,
};
}
if (value.primaryTrackId !== null && value.primaryTrackId !== undefined && typeof value.primaryTrackId !== 'string') {
if (
value.primaryTrackId !== null &&
value.primaryTrackId !== undefined &&
typeof value.primaryTrackId !== 'string'
) {
return null;
}
if (

View File

@@ -0,0 +1,39 @@
import { PartOfSpeech as LegacyPartOfSpeech } from './types';
import type { AnkiConnectConfig } from './types/anki';
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from './types/config';
import type { JimakuConfig, YoutubePickerOpenPayload } from './types/integrations';
import type { ElectronAPI, MpvClient, OverlayContentMeasurement } from './types/runtime';
import type { RuntimeOptionId, RuntimeOptionValue } from './types/runtime-options';
import { PartOfSpeech, type SubtitleSidebarSnapshot } from './types/subtitle';
type Assert<T extends true> = T;
type IsAssignable<From, To> = [From] extends [To] ? true : false;
const runtimeEntryPointMatchesLegacyBarrel = LegacyPartOfSpeech === PartOfSpeech;
void runtimeEntryPointMatchesLegacyBarrel;
type SubtitleEnumStillCompatible = Assert<
IsAssignable<typeof PartOfSpeech, typeof LegacyPartOfSpeech>
>;
type ConfigEntryPointContracts = [
RawConfig,
ResolvedConfig,
ConfigValidationWarning,
RuntimeOptionId,
RuntimeOptionValue,
];
type IntegrationEntryPointContracts = [AnkiConnectConfig, JimakuConfig, YoutubePickerOpenPayload];
type RuntimeEntryPointContracts = [
OverlayContentMeasurement,
ElectronAPI,
MpvClient,
SubtitleSidebarSnapshot,
];
void (null as unknown as SubtitleEnumStillCompatible);
void (null as unknown as ConfigEntryPointContracts);
void (null as unknown as IntegrationEntryPointContracts);
void (null as unknown as RuntimeEntryPointContracts);

File diff suppressed because it is too large Load Diff

113
src/types/anki.ts Normal file
View File

@@ -0,0 +1,113 @@
import type { AiFeatureConfig } from './integrations';
import type { NPlusOneMatchMode } from './subtitle';
export interface NotificationOptions {
body?: string;
icon?: string;
}
export interface KikuDuplicateCardInfo {
noteId: number;
expression: string;
sentencePreview: string;
hasAudio: boolean;
hasImage: boolean;
isOriginal: boolean;
}
export interface KikuFieldGroupingRequestData {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}
export interface KikuFieldGroupingChoice {
keepNoteId: number;
deleteNoteId: number;
deleteDuplicate: boolean;
cancelled: boolean;
}
export interface KikuMergePreviewRequest {
keepNoteId: number;
deleteNoteId: number;
deleteDuplicate: boolean;
}
export interface KikuMergePreviewResponse {
ok: boolean;
compact?: Record<string, unknown>;
full?: Record<string, unknown>;
error?: string;
}
export interface AnkiConnectConfig {
enabled?: boolean;
url?: string;
pollingRate?: number;
proxy?: {
enabled?: boolean;
host?: string;
port?: number;
upstreamUrl?: string;
};
tags?: string[];
fields?: {
word?: string;
audio?: string;
image?: string;
sentence?: string;
miscInfo?: string;
translation?: string;
};
ai?: boolean | AiFeatureConfig;
media?: {
generateAudio?: boolean;
generateImage?: boolean;
imageType?: 'static' | 'avif';
imageFormat?: 'jpg' | 'png' | 'webp';
imageQuality?: number;
imageMaxWidth?: number;
imageMaxHeight?: number;
animatedFps?: number;
animatedMaxWidth?: number;
animatedMaxHeight?: number;
animatedCrf?: number;
syncAnimatedImageToWordAudio?: boolean;
audioPadding?: number;
fallbackDuration?: number;
maxMediaDuration?: number;
};
knownWords?: {
highlightEnabled?: boolean;
refreshMinutes?: number;
addMinedWordsImmediately?: boolean;
matchMode?: NPlusOneMatchMode;
decks?: Record<string, string[]>;
color?: string;
};
nPlusOne?: {
nPlusOne?: string;
minSentenceWords?: number;
};
behavior?: {
overwriteAudio?: boolean;
overwriteImage?: boolean;
mediaInsertMode?: 'append' | 'prepend';
highlightWord?: boolean;
notificationType?: 'osd' | 'system' | 'both' | 'none';
autoUpdateNewCards?: boolean;
};
metadata?: {
pattern?: string;
};
deck?: string;
isLapis?: {
enabled?: boolean;
sentenceCardModel?: string;
};
isKiku?: {
enabled?: boolean;
fieldGrouping?: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto?: boolean;
};
}

340
src/types/config.ts Normal file
View File

@@ -0,0 +1,340 @@
import type { AnkiConnectConfig } from './anki';
import type {
AiConfig,
AiFeatureConfig,
AnilistCharacterDictionaryCollapsibleSectionsConfig,
AnilistCharacterDictionaryEvictionPolicy,
AnilistCharacterDictionaryProfileScope,
AnilistConfig,
DiscordPresenceConfig,
ImmersionTrackingConfig,
ImmersionTrackingRetentionMode,
ImmersionTrackingRetentionPreset,
JellyfinConfig,
JimakuConfig,
JimakuLanguagePreference,
StatsConfig,
YomitanConfig,
YoutubeConfig,
YoutubeSubgenConfig,
} from './integrations';
import type {
ControllerButtonIndicesConfig,
ControllerConfig,
ControllerTriggerInputMode,
Keybinding,
ResolvedControllerBindingsConfig,
} from './runtime';
import type {
FrequencyDictionaryMatchMode,
FrequencyDictionaryMode,
NPlusOneMatchMode,
SecondarySubConfig,
SubtitlePosition,
SubtitleSidebarConfig,
SubtitleStyleConfig,
} from './subtitle';
export interface WebSocketConfig {
enabled?: boolean | 'auto';
port?: number;
}
export interface AnnotationWebSocketConfig {
enabled?: boolean;
port?: number;
}
export interface TexthookerConfig {
launchAtStartup?: boolean;
openBrowser?: boolean;
}
export type SubsyncMode = 'auto' | 'manual';
export interface SubsyncConfig {
defaultMode?: SubsyncMode;
alass_path?: string;
ffsubsync_path?: string;
ffmpeg_path?: string;
replace?: boolean;
}
export interface StartupWarmupsConfig {
lowPowerMode?: boolean;
mecab?: boolean;
yomitanExtension?: boolean;
subtitleDictionaries?: boolean;
jellyfinRemoteSession?: boolean;
}
export interface ShortcutsConfig {
toggleVisibleOverlayGlobal?: string | null;
copySubtitle?: string | null;
copySubtitleMultiple?: string | null;
updateLastCardFromClipboard?: string | null;
triggerFieldGrouping?: string | null;
triggerSubsync?: string | null;
mineSentence?: string | null;
mineSentenceMultiple?: string | null;
multiCopyTimeoutMs?: number;
toggleSecondarySub?: string | null;
markAudioCard?: string | null;
openRuntimeOptions?: string | null;
openJimaku?: string | null;
}
export interface Config {
subtitlePosition?: SubtitlePosition;
keybindings?: Keybinding[];
websocket?: WebSocketConfig;
annotationWebsocket?: AnnotationWebSocketConfig;
texthooker?: TexthookerConfig;
controller?: ControllerConfig;
ankiConnect?: AnkiConnectConfig;
shortcuts?: ShortcutsConfig;
secondarySub?: SecondarySubConfig;
subsync?: SubsyncConfig;
startupWarmups?: StartupWarmupsConfig;
subtitleStyle?: SubtitleStyleConfig;
subtitleSidebar?: SubtitleSidebarConfig;
auto_start_overlay?: boolean;
jimaku?: JimakuConfig;
anilist?: AnilistConfig;
yomitan?: YomitanConfig;
jellyfin?: JellyfinConfig;
discordPresence?: DiscordPresenceConfig;
ai?: AiConfig;
youtube?: YoutubeConfig;
youtubeSubgen?: YoutubeSubgenConfig;
immersionTracking?: ImmersionTrackingConfig;
stats?: StatsConfig;
logging?: {
level?: 'debug' | 'info' | 'warn' | 'error';
};
}
export type RawConfig = Config;
export interface ResolvedConfig {
subtitlePosition: SubtitlePosition;
keybindings: Keybinding[];
websocket: Required<WebSocketConfig>;
annotationWebsocket: Required<AnnotationWebSocketConfig>;
texthooker: Required<TexthookerConfig>;
controller: {
enabled: boolean;
preferredGamepadId: string;
preferredGamepadLabel: string;
smoothScroll: boolean;
scrollPixelsPerSecond: number;
horizontalJumpPixels: number;
stickDeadzone: number;
triggerInputMode: ControllerTriggerInputMode;
triggerDeadzone: number;
repeatDelayMs: number;
repeatIntervalMs: number;
buttonIndices: Required<ControllerButtonIndicesConfig>;
bindings: Required<ResolvedControllerBindingsConfig>;
};
ankiConnect: AnkiConnectConfig & {
enabled: boolean;
url: string;
pollingRate: number;
proxy: {
enabled: boolean;
host: string;
port: number;
upstreamUrl: string;
};
tags: string[];
fields: {
word: string;
audio: string;
image: string;
sentence: string;
miscInfo: string;
translation: string;
};
ai: AiFeatureConfig & {
enabled: boolean;
};
media: {
generateAudio: boolean;
generateImage: boolean;
imageType: 'static' | 'avif';
imageFormat: 'jpg' | 'png' | 'webp';
imageQuality: number;
imageMaxWidth?: number;
imageMaxHeight?: number;
animatedFps: number;
animatedMaxWidth: number;
animatedMaxHeight?: number;
animatedCrf: number;
syncAnimatedImageToWordAudio: boolean;
audioPadding: number;
fallbackDuration: number;
maxMediaDuration: number;
};
knownWords: {
highlightEnabled: boolean;
refreshMinutes: number;
addMinedWordsImmediately: boolean;
matchMode: NPlusOneMatchMode;
decks: Record<string, string[]>;
color: string;
};
nPlusOne: {
nPlusOne: string;
minSentenceWords: number;
};
behavior: {
overwriteAudio: boolean;
overwriteImage: boolean;
mediaInsertMode: 'append' | 'prepend';
highlightWord: boolean;
notificationType: 'osd' | 'system' | 'both' | 'none';
autoUpdateNewCards: boolean;
};
metadata: {
pattern: string;
};
isLapis: {
enabled: boolean;
sentenceCardModel: string;
};
isKiku: {
enabled: boolean;
fieldGrouping: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto: boolean;
};
};
shortcuts: Required<ShortcutsConfig>;
secondarySub: Required<SecondarySubConfig>;
subsync: Required<SubsyncConfig>;
startupWarmups: {
lowPowerMode: boolean;
mecab: boolean;
yomitanExtension: boolean;
subtitleDictionaries: boolean;
jellyfinRemoteSession: boolean;
};
subtitleStyle: Required<Omit<SubtitleStyleConfig, 'secondary' | 'frequencyDictionary'>> & {
secondary: Required<NonNullable<SubtitleStyleConfig['secondary']>>;
frequencyDictionary: {
enabled: boolean;
sourcePath: string;
topX: number;
mode: FrequencyDictionaryMode;
matchMode: FrequencyDictionaryMatchMode;
singleColor: string;
bandedColors: [string, string, string, string, string];
};
};
subtitleSidebar: Required<SubtitleSidebarConfig>;
auto_start_overlay: boolean;
jimaku: JimakuConfig & {
apiBaseUrl: string;
languagePreference: JimakuLanguagePreference;
maxEntryResults: number;
};
anilist: {
enabled: boolean;
accessToken: string;
characterDictionary: {
enabled: boolean;
refreshTtlHours: number;
maxLoaded: number;
evictionPolicy: AnilistCharacterDictionaryEvictionPolicy;
profileScope: AnilistCharacterDictionaryProfileScope;
collapsibleSections: Required<AnilistCharacterDictionaryCollapsibleSectionsConfig>;
};
};
yomitan: {
externalProfilePath: string;
};
jellyfin: {
enabled: boolean;
serverUrl: string;
username: string;
deviceId: string;
clientName: string;
clientVersion: string;
defaultLibraryId: string;
remoteControlEnabled: boolean;
remoteControlAutoConnect: boolean;
autoAnnounce: boolean;
remoteControlDeviceName: string;
pullPictures: boolean;
iconCacheDir: string;
directPlayPreferred: boolean;
directPlayContainers: string[];
transcodeVideoCodec: string;
};
discordPresence: {
enabled: boolean;
updateIntervalMs: number;
debounceMs: number;
};
ai: AiConfig & {
enabled: boolean;
apiKey: string;
apiKeyCommand: string;
baseUrl: string;
model: string;
systemPrompt: string;
requestTimeoutMs: number;
};
youtube: YoutubeConfig & {
primarySubLanguages: string[];
};
youtubeSubgen: YoutubeSubgenConfig & {
whisperBin: string;
whisperModel: string;
whisperVadModel: string;
whisperThreads: number;
fixWithAi: boolean;
ai: AiFeatureConfig;
};
immersionTracking: {
enabled: boolean;
dbPath?: string;
batchSize: number;
flushIntervalMs: number;
queueCap: number;
payloadCapBytes: number;
maintenanceIntervalMs: number;
retentionMode: ImmersionTrackingRetentionMode;
retentionPreset: ImmersionTrackingRetentionPreset;
retention: {
eventsDays: number;
telemetryDays: number;
sessionsDays: number;
dailyRollupsDays: number;
monthlyRollupsDays: number;
vacuumIntervalDays: number;
};
lifetimeSummaries: {
global: boolean;
anime: boolean;
media: boolean;
};
};
stats: {
toggleKey: string;
markWatchedKey: string;
serverPort: number;
autoStartServer: boolean;
autoOpenBrowser: boolean;
};
logging: {
level: 'debug' | 'info' | 'warn' | 'error';
};
}
export interface ConfigValidationWarning {
path: string;
value: unknown;
fallback: unknown;
message: string;
}

235
src/types/integrations.ts Normal file
View File

@@ -0,0 +1,235 @@
import type { YoutubeTrackKind } from '../core/services/youtube/kinds';
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
export type { YoutubeTrackKind };
export interface YoutubeTrackOption {
id: string;
language: string;
sourceLanguage: string;
kind: YoutubeTrackKind;
label: string;
title?: string;
downloadUrl?: string;
fileExtension?: string;
}
export interface YoutubePickerOpenPayload {
sessionId: string;
url: string;
tracks: YoutubeTrackOption[];
defaultPrimaryTrackId: string | null;
defaultSecondaryTrackId: string | null;
hasTracks: boolean;
}
export type YoutubePickerResolveRequest =
| {
sessionId: string;
action: 'continue-without-subtitles';
primaryTrackId: null;
secondaryTrackId: null;
}
| {
sessionId: string;
action: 'use-selected';
primaryTrackId: string | null;
secondaryTrackId: string | null;
};
export interface YoutubePickerResolveResult {
ok: boolean;
message: string;
}
export interface JimakuConfig {
apiKey?: string;
apiKeyCommand?: string;
apiBaseUrl?: string;
languagePreference?: JimakuLanguagePreference;
maxEntryResults?: number;
}
export type AnilistCharacterDictionaryEvictionPolicy = 'disable' | 'delete';
export type AnilistCharacterDictionaryProfileScope = 'all' | 'active';
export type AnilistCharacterDictionaryCollapsibleSectionKey =
| 'description'
| 'characterInformation'
| 'voicedBy';
export interface AnilistCharacterDictionaryCollapsibleSectionsConfig {
description?: boolean;
characterInformation?: boolean;
voicedBy?: boolean;
}
export interface AnilistCharacterDictionaryConfig {
enabled?: boolean;
refreshTtlHours?: number;
maxLoaded?: number;
evictionPolicy?: AnilistCharacterDictionaryEvictionPolicy;
profileScope?: AnilistCharacterDictionaryProfileScope;
collapsibleSections?: AnilistCharacterDictionaryCollapsibleSectionsConfig;
}
export interface AnilistConfig {
enabled?: boolean;
accessToken?: string;
characterDictionary?: AnilistCharacterDictionaryConfig;
}
export interface YomitanConfig {
externalProfilePath?: string;
}
export interface JellyfinConfig {
enabled?: boolean;
serverUrl?: string;
username?: string;
deviceId?: string;
clientName?: string;
clientVersion?: string;
defaultLibraryId?: string;
remoteControlEnabled?: boolean;
remoteControlAutoConnect?: boolean;
autoAnnounce?: boolean;
remoteControlDeviceName?: string;
pullPictures?: boolean;
iconCacheDir?: string;
directPlayPreferred?: boolean;
directPlayContainers?: string[];
transcodeVideoCodec?: string;
}
export interface DiscordPresenceConfig {
enabled?: boolean;
updateIntervalMs?: number;
debounceMs?: number;
}
export interface AiFeatureConfig {
enabled?: boolean;
model?: string;
systemPrompt?: string;
}
export interface AiConfig {
enabled?: boolean;
apiKey?: string;
apiKeyCommand?: string;
baseUrl?: string;
model?: string;
systemPrompt?: string;
requestTimeoutMs?: number;
}
export interface YoutubeConfig {
primarySubLanguages?: string[];
}
export interface YoutubeSubgenConfig {
whisperBin?: string;
whisperModel?: string;
whisperVadModel?: string;
whisperThreads?: number;
fixWithAi?: boolean;
ai?: AiFeatureConfig;
}
export interface StatsConfig {
toggleKey?: string;
markWatchedKey?: string;
serverPort?: number;
autoStartServer?: boolean;
autoOpenBrowser?: boolean;
}
export type ImmersionTrackingRetentionMode = 'preset' | 'advanced';
export type ImmersionTrackingRetentionPreset = 'minimal' | 'balanced' | 'deep-history';
export interface ImmersionTrackingConfig {
enabled?: boolean;
dbPath?: string;
batchSize?: number;
flushIntervalMs?: number;
queueCap?: number;
payloadCapBytes?: number;
maintenanceIntervalMs?: number;
retentionMode?: ImmersionTrackingRetentionMode;
retentionPreset?: ImmersionTrackingRetentionPreset;
retention?: {
eventsDays?: number;
telemetryDays?: number;
sessionsDays?: number;
dailyRollupsDays?: number;
monthlyRollupsDays?: number;
vacuumIntervalDays?: number;
};
lifetimeSummaries?: {
global?: boolean;
anime?: boolean;
media?: boolean;
};
}
export type JimakuConfidence = 'high' | 'medium' | 'low';
export interface JimakuMediaInfo {
title: string;
season: number | null;
episode: number | null;
confidence: JimakuConfidence;
filename: string;
rawTitle: string;
}
export interface JimakuSearchQuery {
query: string;
}
export interface JimakuEntryFlags {
anime?: boolean;
movie?: boolean;
adult?: boolean;
external?: boolean;
unverified?: boolean;
}
export interface JimakuEntry {
id: number;
name: string;
english_name?: string | null;
japanese_name?: string | null;
flags?: JimakuEntryFlags;
last_modified?: string;
}
export interface JimakuFilesQuery {
entryId: number;
episode?: number | null;
}
export interface JimakuFileEntry {
name: string;
url: string;
size: number;
last_modified: string;
}
export interface JimakuDownloadQuery {
entryId: number;
url: string;
name: string;
}
export interface JimakuApiError {
error: string;
code?: number;
retryAfter?: number;
}
export type JimakuApiResponse<T> = { ok: true; data: T } | { ok: false; error: JimakuApiError };
export type JimakuDownloadResult =
| { ok: true; path: string }
| { ok: false; error: JimakuApiError };

View File

@@ -0,0 +1,31 @@
export type RuntimeOptionId =
| 'anki.autoUpdateNewCards'
| 'subtitle.annotation.nPlusOne'
| 'subtitle.annotation.jlpt'
| 'subtitle.annotation.frequency'
| 'anki.kikuFieldGrouping'
| 'anki.nPlusOneMatchMode';
export type RuntimeOptionScope = 'ankiConnect' | 'subtitle';
export type RuntimeOptionValueType = 'boolean' | 'enum';
export type RuntimeOptionValue = boolean | string;
export interface RuntimeOptionState {
id: RuntimeOptionId;
label: string;
scope: RuntimeOptionScope;
valueType: RuntimeOptionValueType;
value: RuntimeOptionValue;
allowedValues: RuntimeOptionValue[];
requiresRestart: boolean;
}
export interface RuntimeOptionApplyResult {
ok: boolean;
option?: RuntimeOptionState;
osdMessage?: string;
requiresRestart?: boolean;
error?: string;
}

394
src/types/runtime.ts Normal file
View File

@@ -0,0 +1,394 @@
import type {
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
KikuMergePreviewRequest,
KikuMergePreviewResponse,
} from './anki';
import type { ResolvedConfig, ShortcutsConfig } from './config';
import type {
JimakuApiResponse,
JimakuDownloadQuery,
JimakuDownloadResult,
JimakuEntry,
JimakuFileEntry,
JimakuFilesQuery,
JimakuMediaInfo,
JimakuSearchQuery,
YoutubePickerOpenPayload,
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
} from './integrations';
import type {
SecondarySubMode,
SubtitleData,
SubtitlePosition,
SubtitleSidebarConfig,
SubtitleSidebarSnapshot,
SubtitleStyleConfig,
} from './subtitle';
import type {
RuntimeOptionApplyResult,
RuntimeOptionId,
RuntimeOptionState,
RuntimeOptionValue,
} from './runtime-options';
export interface WindowGeometry {
x: number;
y: number;
width: number;
height: number;
}
export interface Keybinding {
key: string;
command: (string | number)[] | null;
}
export interface MpvClient {
currentSubText: string;
currentVideoPath: string;
currentMediaTitle?: string | null;
currentTimePos: number;
currentSubStart: number;
currentSubEnd: number;
currentAudioStreamIndex: number | null;
requestProperty?: (name: string) => Promise<unknown>;
send(command: { command: unknown[]; request_id?: number }): boolean;
}
export interface SubsyncSourceTrack {
id: number;
label: string;
}
export interface SubsyncManualPayload {
sourceTracks: SubsyncSourceTrack[];
}
export interface SubsyncManualRunRequest {
engine: 'alass' | 'ffsubsync';
sourceTrackId?: number | null;
}
export interface SubsyncResult {
ok: boolean;
message: string;
}
export type ControllerButtonBinding =
| 'none'
| 'select'
| 'buttonSouth'
| 'buttonEast'
| 'buttonNorth'
| 'buttonWest'
| 'leftShoulder'
| 'rightShoulder'
| 'leftStickPress'
| 'rightStickPress'
| 'leftTrigger'
| 'rightTrigger';
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
export type ControllerAxisDirection = 'negative' | 'positive';
export type ControllerDpadFallback = 'none' | 'horizontal' | 'vertical';
export interface ControllerNoneBinding {
kind: 'none';
}
export interface ControllerButtonInputBinding {
kind: 'button';
buttonIndex: number;
}
export interface ControllerAxisDirectionInputBinding {
kind: 'axis';
axisIndex: number;
direction: ControllerAxisDirection;
}
export interface ControllerAxisInputBinding {
kind: 'axis';
axisIndex: number;
dpadFallback?: ControllerDpadFallback;
}
export type ControllerDiscreteBindingConfig =
| ControllerButtonBinding
| ControllerNoneBinding
| ControllerButtonInputBinding
| ControllerAxisDirectionInputBinding;
export type ResolvedControllerDiscreteBinding =
| ControllerNoneBinding
| ControllerButtonInputBinding
| ControllerAxisDirectionInputBinding;
export type ControllerAxisBindingConfig =
| ControllerAxisBinding
| ControllerNoneBinding
| ControllerAxisInputBinding;
export type ResolvedControllerAxisBinding =
| ControllerNoneBinding
| {
kind: 'axis';
axisIndex: number;
dpadFallback: ControllerDpadFallback;
};
export interface ControllerBindingsConfig {
toggleLookup?: ControllerDiscreteBindingConfig;
closeLookup?: ControllerDiscreteBindingConfig;
toggleKeyboardOnlyMode?: ControllerDiscreteBindingConfig;
mineCard?: ControllerDiscreteBindingConfig;
quitMpv?: ControllerDiscreteBindingConfig;
previousAudio?: ControllerDiscreteBindingConfig;
nextAudio?: ControllerDiscreteBindingConfig;
playCurrentAudio?: ControllerDiscreteBindingConfig;
toggleMpvPause?: ControllerDiscreteBindingConfig;
leftStickHorizontal?: ControllerAxisBindingConfig;
leftStickVertical?: ControllerAxisBindingConfig;
rightStickHorizontal?: ControllerAxisBindingConfig;
rightStickVertical?: ControllerAxisBindingConfig;
}
export interface ResolvedControllerBindingsConfig {
toggleLookup?: ResolvedControllerDiscreteBinding;
closeLookup?: ResolvedControllerDiscreteBinding;
toggleKeyboardOnlyMode?: ResolvedControllerDiscreteBinding;
mineCard?: ResolvedControllerDiscreteBinding;
quitMpv?: ResolvedControllerDiscreteBinding;
previousAudio?: ResolvedControllerDiscreteBinding;
nextAudio?: ResolvedControllerDiscreteBinding;
playCurrentAudio?: ResolvedControllerDiscreteBinding;
toggleMpvPause?: ResolvedControllerDiscreteBinding;
leftStickHorizontal?: ResolvedControllerAxisBinding;
leftStickVertical?: ResolvedControllerAxisBinding;
rightStickHorizontal?: ResolvedControllerAxisBinding;
rightStickVertical?: ResolvedControllerAxisBinding;
}
export interface ControllerButtonIndicesConfig {
select?: number;
buttonSouth?: number;
buttonEast?: number;
buttonNorth?: number;
buttonWest?: number;
leftShoulder?: number;
rightShoulder?: number;
leftStickPress?: number;
rightStickPress?: number;
leftTrigger?: number;
rightTrigger?: number;
}
export interface ControllerConfig {
enabled?: boolean;
preferredGamepadId?: string;
preferredGamepadLabel?: string;
smoothScroll?: boolean;
scrollPixelsPerSecond?: number;
horizontalJumpPixels?: number;
stickDeadzone?: number;
triggerInputMode?: ControllerTriggerInputMode;
triggerDeadzone?: number;
repeatDelayMs?: number;
repeatIntervalMs?: number;
buttonIndices?: ControllerButtonIndicesConfig;
bindings?: ControllerBindingsConfig;
}
export interface ControllerPreferenceUpdate {
preferredGamepadId: string;
preferredGamepadLabel: string;
}
export type ControllerConfigUpdate = ControllerConfig;
export interface ControllerDeviceInfo {
id: string;
index: number;
mapping: string;
connected: boolean;
}
export interface ControllerButtonSnapshot {
value: number;
pressed: boolean;
touched?: boolean;
}
export interface ControllerRuntimeSnapshot {
connectedGamepads: ControllerDeviceInfo[];
activeGamepadId: string | null;
rawAxes: number[];
rawButtons: ControllerButtonSnapshot[];
}
export interface MpvSubtitleRenderMetrics {
subPos: number;
subFontSize: number;
subScale: number;
subMarginY: number;
subMarginX: number;
subFont: string;
subSpacing: number;
subBold: boolean;
subItalic: boolean;
subBorderSize: number;
subShadowOffset: number;
subAssOverride: string;
subScaleByWindow: boolean;
subUseMargins: boolean;
osdHeight: number;
osdDimensions: {
w: number;
h: number;
ml: number;
mr: number;
mt: number;
mb: number;
} | null;
}
export type OverlayLayer = 'visible';
export interface OverlayContentRect {
x: number;
y: number;
width: number;
height: number;
}
export interface OverlayContentMeasurement {
layer: OverlayLayer;
measuredAtMs: number;
viewport: {
width: number;
height: number;
};
contentRect: OverlayContentRect | null;
}
export interface MecabStatus {
available: boolean;
enabled: boolean;
path: string | null;
}
export interface ClipboardAppendResult {
ok: boolean;
message: string;
}
export interface ConfigHotReloadPayload {
keybindings: Keybinding[];
subtitleStyle: SubtitleStyleConfig | null;
subtitleSidebar: Required<SubtitleSidebarConfig>;
secondarySubMode: SecondarySubMode;
}
export type ResolvedControllerConfig = ResolvedConfig['controller'];
export interface ElectronAPI {
getOverlayLayer: () => 'visible' | 'modal' | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void;
onVisibility: (callback: (visible: boolean) => void) => void;
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
getOverlayVisibility: () => Promise<boolean>;
getCurrentSubtitle: () => Promise<SubtitleData>;
getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>;
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => Promise<boolean | null>;
onSubtitleAss: (callback: (assText: string) => void) => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
openYomitanSettings: () => void;
recordYomitanLookup: () => void;
getSubtitlePosition: () => Promise<SubtitlePosition | null>;
saveSubtitlePosition: (position: SubtitlePosition) => void;
getMecabStatus: () => Promise<MecabStatus>;
setMecabEnabled: (enabled: boolean) => void;
sendMpvCommand: (command: (string | number)[]) => void;
getKeybindings: () => Promise<Keybinding[]>;
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
getStatsToggleKey: () => Promise<string>;
getMarkWatchedKey: () => Promise<string>;
markActiveVideoWatched: () => Promise<boolean>;
getControllerConfig: () => Promise<ResolvedControllerConfig>;
saveControllerConfig: (update: ControllerConfigUpdate) => Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
jimakuDownloadFile: (query: JimakuDownloadQuery) => Promise<JimakuDownloadResult>;
quitApp: () => void;
toggleDevTools: () => void;
toggleOverlay: () => void;
toggleStatsOverlay: () => void;
getAnkiConnectStatus: () => Promise<boolean>;
setAnkiConnectEnabled: (enabled: boolean) => void;
clearAnkiConnectHistory: () => void;
onSecondarySub: (callback: (text: string) => void) => void;
onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => void;
getSecondarySubMode: () => Promise<SecondarySubMode>;
getCurrentSecondarySub: () => Promise<string>;
focusMainWindow: () => Promise<void>;
getSubtitleStyle: () => Promise<SubtitleStyleConfig | null>;
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
kikuBuildMergePreview: (request: KikuMergePreviewRequest) => Promise<KikuMergePreviewResponse>;
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void;
getRuntimeOptions: () => Promise<RuntimeOptionState[]>;
setRuntimeOptionValue: (
id: RuntimeOptionId,
value: RuntimeOptionValue,
) => Promise<RuntimeOptionApplyResult>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise<RuntimeOptionApplyResult>;
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
onOpenRuntimeOptions: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void;
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onCancelYoutubeTrackPicker: (callback: () => void) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
youtubePickerResolve: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
notifyOverlayModalClosed: (
modal:
| 'runtime-options'
| 'subsync'
| 'jimaku'
| 'youtube-track-picker'
| 'kiku'
| 'controller-select'
| 'controller-debug'
| 'subtitle-sidebar',
) => void;
notifyOverlayModalOpened: (
modal:
| 'runtime-options'
| 'subsync'
| 'jimaku'
| 'youtube-track-picker'
| 'kiku'
| 'controller-select'
| 'controller-debug'
| 'subtitle-sidebar',
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}

195
src/types/subtitle.ts Normal file
View File

@@ -0,0 +1,195 @@
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
export enum PartOfSpeech {
noun = 'noun',
verb = 'verb',
i_adjective = 'i_adjective',
na_adjective = 'na_adjective',
particle = 'particle',
bound_auxiliary = 'bound_auxiliary',
symbol = 'symbol',
other = 'other',
}
export interface Token {
word: string;
partOfSpeech: PartOfSpeech;
pos1: string;
pos2: string;
pos3: string;
pos4: string;
inflectionType: string;
inflectionForm: string;
headword: string;
katakanaReading: string;
pronunciation: string;
}
export interface MergedToken {
surface: string;
reading: string;
headword: string;
startPos: number;
endPos: number;
partOfSpeech: PartOfSpeech;
pos1?: string;
pos2?: string;
pos3?: string;
isMerged: boolean;
isKnown: boolean;
isNPlusOneTarget: boolean;
isNameMatch?: boolean;
jlptLevel?: JlptLevel;
frequencyRank?: number;
}
export type FrequencyDictionaryLookup = (term: string) => number | null;
export type JlptLevel = 'N1' | 'N2' | 'N3' | 'N4' | 'N5';
export interface SubtitlePosition {
yPercent: number;
}
export interface SubtitleStyle {
fontSize: number;
}
export type SecondarySubMode = 'hidden' | 'visible' | 'hover';
export interface SecondarySubConfig {
secondarySubLanguages?: string[];
autoLoadSecondarySub?: boolean;
defaultMode?: SecondarySubMode;
}
export type NPlusOneMatchMode = 'headword' | 'surface';
export type FrequencyDictionaryMatchMode = 'headword' | 'surface';
export interface SubtitleStyleConfig {
enableJlpt?: boolean;
preserveLineBreaks?: boolean;
autoPauseVideoOnHover?: boolean;
autoPauseVideoOnYomitanPopup?: boolean;
hoverTokenColor?: string;
hoverTokenBackgroundColor?: string;
nameMatchEnabled?: boolean;
nameMatchColor?: string;
fontFamily?: string;
fontSize?: number;
fontColor?: string;
fontWeight?: string | number;
fontStyle?: string;
lineHeight?: string | number;
letterSpacing?: string;
wordSpacing?: string | number;
fontKerning?: string;
textRendering?: string;
textShadow?: string;
backdropFilter?: string;
backgroundColor?: string;
nPlusOneColor?: string;
knownWordColor?: string;
jlptColors?: {
N1: string;
N2: string;
N3: string;
N4: string;
N5: string;
};
frequencyDictionary?: {
enabled?: boolean;
sourcePath?: string;
topX?: number;
mode?: FrequencyDictionaryMode;
matchMode?: FrequencyDictionaryMatchMode;
singleColor?: string;
bandedColors?: [string, string, string, string, string];
};
secondary?: {
fontFamily?: string;
fontSize?: number;
fontColor?: string;
fontWeight?: string | number;
fontStyle?: string;
lineHeight?: string | number;
letterSpacing?: string;
wordSpacing?: string | number;
fontKerning?: string;
textRendering?: string;
textShadow?: string;
backdropFilter?: string;
backgroundColor?: string;
};
}
export interface TokenPos1ExclusionConfig {
defaults?: string[];
add?: string[];
remove?: string[];
}
export interface ResolvedTokenPos1ExclusionConfig {
defaults: string[];
add: string[];
remove: string[];
}
export interface TokenPos2ExclusionConfig {
defaults?: string[];
add?: string[];
remove?: string[];
}
export interface ResolvedTokenPos2ExclusionConfig {
defaults: string[];
add: string[];
remove: string[];
}
export type FrequencyDictionaryMode = 'single' | 'banded';
export type { SubtitleCue };
export type SubtitleSidebarLayout = 'overlay' | 'embedded';
export interface SubtitleSidebarConfig {
enabled?: boolean;
autoOpen?: boolean;
layout?: SubtitleSidebarLayout;
toggleKey?: string;
pauseVideoOnHover?: boolean;
autoScroll?: boolean;
maxWidth?: number;
opacity?: number;
backgroundColor?: string;
textColor?: string;
fontFamily?: string;
fontSize?: number;
timestampColor?: string;
activeLineColor?: string;
activeLineBackgroundColor?: string;
hoverLineBackgroundColor?: string;
}
export interface SubtitleData {
text: string;
tokens: MergedToken[] | null;
startTime?: number | null;
endTime?: number | null;
}
export interface SubtitleSidebarSnapshot {
cues: SubtitleCue[];
currentTimeSec?: number | null;
currentSubtitle: {
text: string;
startTime: number | null;
endTime: number | null;
};
config: Required<SubtitleSidebarConfig>;
}
export interface SubtitleHoverTokenPayload {
tokenIndex: number | null;
}