From 09e142279ac5fb9121b708f2422dd8ddb0acd87d Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 13:16:01 -0800 Subject: [PATCH] feat(core): add module scaffolding and provider registries --- src/core/action-bus.ts | 21 +++++ src/core/actions.ts | 16 ++++ src/core/app-context.ts | 45 +++++++++++ src/core/module-registry.ts | 36 +++++++++ src/core/module.ts | 6 ++ src/ipc/contract.ts | 61 +++++++++++++++ src/ipc/main-api.ts | 19 +++++ src/ipc/renderer-api.ts | 27 +++++++ src/modules/jimaku/module.ts | 72 +++++++++++++++++ src/modules/runtime-options/module.ts | 61 +++++++++++++++ src/modules/subsync/module.ts | 78 +++++++++++++++++++ src/subsync/engines.ts | 95 +++++++++++++++++++++++ src/subtitle/pipeline.ts | 46 +++++++++++ src/subtitle/stages/merge.ts | 12 +++ src/subtitle/stages/normalize.ts | 14 ++++ src/subtitle/stages/tokenize.ts | 12 +++ src/token-mergers/index.ts | 42 ++++++++++ src/tokenizers/index.ts | 53 +++++++++++++ src/translators/index.ts | 106 ++++++++++++++++++++++++++ 19 files changed, 822 insertions(+) create mode 100644 src/core/action-bus.ts create mode 100644 src/core/actions.ts create mode 100644 src/core/app-context.ts create mode 100644 src/core/module-registry.ts create mode 100644 src/core/module.ts create mode 100644 src/ipc/contract.ts create mode 100644 src/ipc/main-api.ts create mode 100644 src/ipc/renderer-api.ts create mode 100644 src/modules/jimaku/module.ts create mode 100644 src/modules/runtime-options/module.ts create mode 100644 src/modules/subsync/module.ts create mode 100644 src/subsync/engines.ts create mode 100644 src/subtitle/pipeline.ts create mode 100644 src/subtitle/stages/merge.ts create mode 100644 src/subtitle/stages/normalize.ts create mode 100644 src/subtitle/stages/tokenize.ts create mode 100644 src/token-mergers/index.ts create mode 100644 src/tokenizers/index.ts create mode 100644 src/translators/index.ts diff --git a/src/core/action-bus.ts b/src/core/action-bus.ts new file mode 100644 index 0000000..92e7fbe --- /dev/null +++ b/src/core/action-bus.ts @@ -0,0 +1,21 @@ +export type ActionWithType = { type: string }; + +export type ActionHandler = ( + action: TAction, +) => void | Promise; + +export class ActionBus { + private handlers = new Map>(); + + register(type: TAction["type"], handler: ActionHandler): void { + this.handlers.set(type, handler); + } + + async dispatch(action: TAction): Promise { + const handler = this.handlers.get(action.type); + if (!handler) { + throw new Error(`No handler registered for action: ${action.type}`); + } + await handler(action); + } +} diff --git a/src/core/actions.ts b/src/core/actions.ts new file mode 100644 index 0000000..aa4d61a --- /dev/null +++ b/src/core/actions.ts @@ -0,0 +1,16 @@ +export type AppAction = + | { type: "overlay.toggleVisible" } + | { type: "overlay.toggleInvisible" } + | { type: "overlay.setVisible"; visible: boolean } + | { type: "overlay.setInvisibleVisible"; visible: boolean } + | { type: "overlay.openSettings" } + | { type: "subtitle.copyCurrent" } + | { type: "subtitle.copyMultiplePrompt"; timeoutMs: number } + | { type: "anki.mineSentence" } + | { type: "anki.mineSentenceMultiplePrompt"; timeoutMs: number } + | { type: "anki.updateLastCardFromClipboard" } + | { type: "anki.markAudioCard" } + | { type: "kiku.triggerFieldGrouping" } + | { type: "subsync.triggerFromConfig" } + | { type: "secondarySub.toggleMode" } + | { type: "runtimeOptions.openPalette" }; diff --git a/src/core/app-context.ts b/src/core/app-context.ts new file mode 100644 index 0000000..e907fe7 --- /dev/null +++ b/src/core/app-context.ts @@ -0,0 +1,45 @@ +import { + AnkiConnectConfig, + JimakuApiResponse, + JimakuDownloadQuery, + JimakuDownloadResult, + JimakuEntry, + JimakuFileEntry, + JimakuFilesQuery, + JimakuMediaInfo, + JimakuSearchQuery, + RuntimeOptionState, + SubsyncManualRunRequest, + SubsyncMode, + SubsyncResult, +} from "../types"; + +export interface RuntimeOptionsModuleContext { + getAnkiConfig: () => AnkiConnectConfig; + applyAnkiPatch: (patch: Partial) => void; + onOptionsChanged: (options: RuntimeOptionState[]) => void; +} + +export interface AppContext { + runtimeOptions?: RuntimeOptionsModuleContext; + jimaku?: { + getMediaInfo: () => JimakuMediaInfo; + searchEntries: ( + query: JimakuSearchQuery, + ) => Promise>; + listFiles: ( + query: JimakuFilesQuery, + ) => Promise>; + downloadFile: ( + query: JimakuDownloadQuery, + ) => Promise; + }; + subsync?: { + getDefaultMode: () => SubsyncMode; + openManualPicker: () => Promise; + runAuto: () => Promise; + runManual: (request: SubsyncManualRunRequest) => Promise; + showOsd: (message: string) => void; + runWithSpinner: (task: () => Promise, label?: string) => Promise; + }; +} diff --git a/src/core/module-registry.ts b/src/core/module-registry.ts new file mode 100644 index 0000000..72795ad --- /dev/null +++ b/src/core/module-registry.ts @@ -0,0 +1,36 @@ +import { SubminerModule } from "./module"; + +export class ModuleRegistry { + private readonly modules: SubminerModule[] = []; + + register(module: SubminerModule): void { + if (this.modules.some((existing) => existing.id === module.id)) { + throw new Error(`Module already registered: ${module.id}`); + } + this.modules.push(module); + } + + async initAll(context: TContext): Promise { + for (const module of this.modules) { + if (module.init) { + await module.init(context); + } + } + } + + async startAll(): Promise { + for (const module of this.modules) { + if (module.start) { + await module.start(); + } + } + } + + async stopAll(): Promise { + for (const module of [...this.modules].reverse()) { + if (module.stop) { + await module.stop(); + } + } + } +} diff --git a/src/core/module.ts b/src/core/module.ts new file mode 100644 index 0000000..0e69a10 --- /dev/null +++ b/src/core/module.ts @@ -0,0 +1,6 @@ +export interface SubminerModule { + id: string; + init?: (context: TContext) => void | Promise; + start?: () => void | Promise; + stop?: () => void | Promise; +} diff --git a/src/ipc/contract.ts b/src/ipc/contract.ts new file mode 100644 index 0000000..8e2a403 --- /dev/null +++ b/src/ipc/contract.ts @@ -0,0 +1,61 @@ +export const IPC_CHANNELS = { + rendererToMainInvoke: { + getOverlayVisibility: "get-overlay-visibility", + getVisibleOverlayVisibility: "get-visible-overlay-visibility", + getInvisibleOverlayVisibility: "get-invisible-overlay-visibility", + getCurrentSubtitle: "get-current-subtitle", + getCurrentSubtitleAss: "get-current-subtitle-ass", + getMpvSubtitleRenderMetrics: "get-mpv-subtitle-render-metrics", + getSubtitlePosition: "get-subtitle-position", + getSubtitleStyle: "get-subtitle-style", + getMecabStatus: "get-mecab-status", + getKeybindings: "get-keybindings", + getSecondarySubMode: "get-secondary-sub-mode", + getCurrentSecondarySub: "get-current-secondary-sub", + runSubsyncManual: "subsync:run-manual", + getAnkiConnectStatus: "get-anki-connect-status", + runtimeOptionsGet: "runtime-options:get", + runtimeOptionsSet: "runtime-options:set", + runtimeOptionsCycle: "runtime-options:cycle", + kikuBuildMergePreview: "kiku:build-merge-preview", + jimakuGetMediaInfo: "jimaku:get-media-info", + jimakuSearchEntries: "jimaku:search-entries", + jimakuListFiles: "jimaku:list-files", + jimakuDownloadFile: "jimaku:download-file", + }, + rendererToMainSend: { + setIgnoreMouseEvents: "set-ignore-mouse-events", + overlayModalClosed: "overlay:modal-closed", + openYomitanSettings: "open-yomitan-settings", + quitApp: "quit-app", + toggleDevTools: "toggle-dev-tools", + toggleOverlay: "toggle-overlay", + saveSubtitlePosition: "save-subtitle-position", + setMecabEnabled: "set-mecab-enabled", + mpvCommand: "mpv-command", + setAnkiConnectEnabled: "set-anki-connect-enabled", + clearAnkiConnectHistory: "clear-anki-connect-history", + kikuFieldGroupingRespond: "kiku:field-grouping-respond", + }, + mainToRendererEvent: { + subtitleSet: "subtitle:set", + mpvSubVisibility: "mpv:subVisibility", + subtitlePositionSet: "subtitle-position:set", + mpvSubtitleRenderMetricsSet: "mpv-subtitle-render-metrics:set", + subtitleAssSet: "subtitle-ass:set", + overlayDebugVisualizationSet: "overlay-debug-visualization:set", + secondarySubtitleSet: "secondary-subtitle:set", + secondarySubtitleMode: "secondary-subtitle:mode", + subsyncOpenManual: "subsync:open-manual", + kikuFieldGroupingRequest: "kiku:field-grouping-request", + runtimeOptionsChanged: "runtime-options:changed", + runtimeOptionsOpen: "runtime-options:open", + }, +} as const; + +export type RendererToMainInvokeChannel = + (typeof IPC_CHANNELS.rendererToMainInvoke)[keyof typeof IPC_CHANNELS.rendererToMainInvoke]; +export type RendererToMainSendChannel = + (typeof IPC_CHANNELS.rendererToMainSend)[keyof typeof IPC_CHANNELS.rendererToMainSend]; +export type MainToRendererEventChannel = + (typeof IPC_CHANNELS.mainToRendererEvent)[keyof typeof IPC_CHANNELS.mainToRendererEvent]; diff --git a/src/ipc/main-api.ts b/src/ipc/main-api.ts new file mode 100644 index 0000000..f400148 --- /dev/null +++ b/src/ipc/main-api.ts @@ -0,0 +1,19 @@ +import { ipcMain, IpcMainEvent } from "electron"; +import { + RendererToMainInvokeChannel, + RendererToMainSendChannel, +} from "./contract"; + +export function onRendererSend( + channel: RendererToMainSendChannel, + listener: (event: IpcMainEvent, ...args: any[]) => void, +): void { + ipcMain.on(channel, listener); +} + +export function handleRendererInvoke( + channel: RendererToMainInvokeChannel, + handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => unknown, +): void { + ipcMain.handle(channel, handler); +} diff --git a/src/ipc/renderer-api.ts b/src/ipc/renderer-api.ts new file mode 100644 index 0000000..3bfea1d --- /dev/null +++ b/src/ipc/renderer-api.ts @@ -0,0 +1,27 @@ +import { ipcRenderer, IpcRendererEvent } from "electron"; +import { + MainToRendererEventChannel, + RendererToMainInvokeChannel, + RendererToMainSendChannel, +} from "./contract"; + +export function invokeFromRenderer( + channel: RendererToMainInvokeChannel, + ...args: unknown[] +): Promise { + return ipcRenderer.invoke(channel, ...args) as Promise; +} + +export function sendFromRenderer( + channel: RendererToMainSendChannel, + ...args: unknown[] +): void { + ipcRenderer.send(channel, ...args); +} + +export function onMainEvent( + channel: MainToRendererEventChannel, + listener: (event: IpcRendererEvent, ...args: unknown[]) => void, +): void { + ipcRenderer.on(channel, listener); +} diff --git a/src/modules/jimaku/module.ts b/src/modules/jimaku/module.ts new file mode 100644 index 0000000..662da69 --- /dev/null +++ b/src/modules/jimaku/module.ts @@ -0,0 +1,72 @@ +import { AppContext } from "../../core/app-context"; +import { SubminerModule } from "../../core/module"; +import { + JimakuApiResponse, + JimakuDownloadQuery, + JimakuDownloadResult, + JimakuEntry, + JimakuFileEntry, + JimakuFilesQuery, + JimakuMediaInfo, + JimakuSearchQuery, +} from "../../types"; + +export class JimakuModule implements SubminerModule { + readonly id = "jimaku"; + private context: AppContext["jimaku"] | undefined; + + init(context: AppContext): void { + if (!context.jimaku) { + throw new Error("Jimaku context is missing"); + } + this.context = context.jimaku; + } + + getMediaInfo(): JimakuMediaInfo { + if (!this.context) { + return { + title: "", + season: null, + episode: null, + confidence: "low", + filename: "", + rawTitle: "", + }; + } + return this.context.getMediaInfo(); + } + + searchEntries( + query: JimakuSearchQuery, + ): Promise> { + if (!this.context) { + return Promise.resolve({ + ok: false, + error: { error: "Jimaku module not initialized" }, + }); + } + return this.context.searchEntries(query); + } + + listFiles( + query: JimakuFilesQuery, + ): Promise> { + if (!this.context) { + return Promise.resolve({ + ok: false, + error: { error: "Jimaku module not initialized" }, + }); + } + return this.context.listFiles(query); + } + + downloadFile(query: JimakuDownloadQuery): Promise { + if (!this.context) { + return Promise.resolve({ + ok: false, + error: { error: "Jimaku module not initialized" }, + }); + } + return this.context.downloadFile(query); + } +} diff --git a/src/modules/runtime-options/module.ts b/src/modules/runtime-options/module.ts new file mode 100644 index 0000000..2bbf1ab --- /dev/null +++ b/src/modules/runtime-options/module.ts @@ -0,0 +1,61 @@ +import { AppContext } from "../../core/app-context"; +import { SubminerModule } from "../../core/module"; +import { RuntimeOptionsManager } from "../../runtime-options"; +import { + AnkiConnectConfig, + RuntimeOptionApplyResult, + RuntimeOptionId, + RuntimeOptionState, + RuntimeOptionValue, +} from "../../types"; + +export class RuntimeOptionsModule implements SubminerModule { + readonly id = "runtime-options"; + private manager: RuntimeOptionsManager | null = null; + + init(context: AppContext): void { + if (!context.runtimeOptions) { + throw new Error("Runtime options context is missing"); + } + + this.manager = new RuntimeOptionsManager( + context.runtimeOptions.getAnkiConfig, + { + applyAnkiPatch: context.runtimeOptions.applyAnkiPatch, + onOptionsChanged: context.runtimeOptions.onOptionsChanged, + }, + ); + } + + listOptions(): RuntimeOptionState[] { + return this.manager ? this.manager.listOptions() : []; + } + + getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined { + return this.manager?.getOptionValue(id); + } + + setOptionValue( + id: RuntimeOptionId, + value: RuntimeOptionValue, + ): RuntimeOptionApplyResult { + if (!this.manager) { + return { ok: false, error: "Runtime options manager unavailable" }; + } + return this.manager.setOptionValue(id, value); + } + + cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult { + if (!this.manager) { + return { ok: false, error: "Runtime options manager unavailable" }; + } + return this.manager.cycleOption(id, direction); + } + + getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig { + if (!this.manager) { + return baseConfig ? JSON.parse(JSON.stringify(baseConfig)) : {}; + } + return this.manager.getEffectiveAnkiConnectConfig(baseConfig); + } +} diff --git a/src/modules/subsync/module.ts b/src/modules/subsync/module.ts new file mode 100644 index 0000000..250b65a --- /dev/null +++ b/src/modules/subsync/module.ts @@ -0,0 +1,78 @@ +import { AppContext } from "../../core/app-context"; +import { SubminerModule } from "../../core/module"; +import { SubsyncManualRunRequest, SubsyncResult } from "../../types"; + +export class SubsyncModule implements SubminerModule { + readonly id = "subsync"; + private inProgress = false; + private context: AppContext["subsync"] | undefined; + + init(context: AppContext): void { + if (!context.subsync) { + throw new Error("Subsync context is missing"); + } + this.context = context.subsync; + } + + isInProgress(): boolean { + return this.inProgress; + } + + async triggerFromConfig(): Promise { + if (!this.context) { + throw new Error("Subsync module not initialized"); + } + + if (this.inProgress) { + this.context.showOsd("Subsync already running"); + return; + } + + try { + if (this.context.getDefaultMode() === "manual") { + await this.context.openManualPicker(); + this.context.showOsd("Subsync: choose engine and source"); + return; + } + + this.inProgress = true; + const result = await this.context.runWithSpinner( + () => this.context!.runAuto(), + "Subsync: syncing", + ); + this.context.showOsd(result.message); + } catch (error) { + this.context.showOsd(`Subsync failed: ${(error as Error).message}`); + } finally { + this.inProgress = false; + } + } + + async runManual(request: SubsyncManualRunRequest): Promise { + if (!this.context) { + return { ok: false, message: "Subsync module not initialized" }; + } + + if (this.inProgress) { + const busy = "Subsync already running"; + this.context.showOsd(busy); + return { ok: false, message: busy }; + } + + try { + this.inProgress = true; + const result = await this.context.runWithSpinner( + () => this.context!.runManual(request), + "Subsync: syncing", + ); + this.context.showOsd(result.message); + return result; + } catch (error) { + const message = `Subsync failed: ${(error as Error).message}`; + this.context.showOsd(message); + return { ok: false, message }; + } finally { + this.inProgress = false; + } + } +} diff --git a/src/subsync/engines.ts b/src/subsync/engines.ts new file mode 100644 index 0000000..338ede0 --- /dev/null +++ b/src/subsync/engines.ts @@ -0,0 +1,95 @@ +export type SubsyncEngine = "alass" | "ffsubsync"; + +export interface SubsyncCommandResult { + ok: boolean; + code: number | null; + stderr: string; + stdout: string; + error?: string; +} + +export interface SubsyncEngineExecutionContext { + referenceFilePath: string; + videoPath: string; + inputSubtitlePath: string; + outputPath: string; + audioStreamIndex: number | null; + resolveExecutablePath: ( + configuredPath: string, + commandName: string, + ) => string; + resolvedPaths: { + alassPath: string; + ffsubsyncPath: string; + }; + runCommand: (command: string, args: string[]) => Promise; +} + +export interface SubsyncEngineProvider { + engine: SubsyncEngine; + execute: ( + context: SubsyncEngineExecutionContext, + ) => Promise; +} + +type SubsyncEngineProviderFactory = () => SubsyncEngineProvider; + +const subsyncEngineProviderFactories = new Map(); + +export function registerSubsyncEngineProvider( + engine: SubsyncEngine, + factory: SubsyncEngineProviderFactory, +): void { + if (subsyncEngineProviderFactories.has(engine)) { + return; + } + subsyncEngineProviderFactories.set(engine, factory); +} + +export function createSubsyncEngineProvider( + engine: SubsyncEngine, +): SubsyncEngineProvider | null { + const factory = subsyncEngineProviderFactories.get(engine); + if (!factory) return null; + return factory(); +} + +function registerDefaultSubsyncEngineProviders(): void { + registerSubsyncEngineProvider("alass", () => ({ + engine: "alass", + execute: async (context: SubsyncEngineExecutionContext) => { + const alassPath = context.resolveExecutablePath( + context.resolvedPaths.alassPath, + "alass", + ); + return context.runCommand(alassPath, [ + context.referenceFilePath, + context.inputSubtitlePath, + context.outputPath, + ]); + }, + })); + + registerSubsyncEngineProvider("ffsubsync", () => ({ + engine: "ffsubsync", + execute: async (context: SubsyncEngineExecutionContext) => { + const ffsubsyncPath = context.resolveExecutablePath( + context.resolvedPaths.ffsubsyncPath, + "ffsubsync", + ); + const args = [ + context.videoPath, + "-i", + context.inputSubtitlePath, + "-o", + context.outputPath, + ]; + if (context.audioStreamIndex !== null) { + args.push("--reference-stream", `0:${context.audioStreamIndex}`); + } + return context.runCommand(ffsubsyncPath, args); + }, + })); +} + +registerDefaultSubsyncEngineProviders(); diff --git a/src/subtitle/pipeline.ts b/src/subtitle/pipeline.ts new file mode 100644 index 0000000..43df702 --- /dev/null +++ b/src/subtitle/pipeline.ts @@ -0,0 +1,46 @@ +import { TokenMergerProvider } from "../token-mergers"; +import { TokenizerProvider } from "../tokenizers"; +import { SubtitleData } from "../types"; +import { + normalizeDisplayText, + normalizeTokenizerInput, +} from "./stages/normalize"; +import { tokenizeStage } from "./stages/tokenize"; +import { mergeStage } from "./stages/merge"; + +export interface SubtitlePipelineDeps { + getTokenizer: () => TokenizerProvider | null; + getTokenMerger: () => TokenMergerProvider | null; +} + +export class SubtitlePipeline { + private readonly deps: SubtitlePipelineDeps; + + constructor(deps: SubtitlePipelineDeps) { + this.deps = deps; + } + + async process(text: string): Promise { + if (!text) { + return { text, tokens: null }; + } + + const displayText = normalizeDisplayText(text); + if (!displayText) { + return { text, tokens: null }; + } + + const tokenizeText = normalizeTokenizerInput(displayText); + + try { + const tokens = await tokenizeStage(this.deps.getTokenizer(), tokenizeText); + const mergedTokens = mergeStage(this.deps.getTokenMerger(), tokens); + if (!mergedTokens || mergedTokens.length === 0) { + return { text: displayText, tokens: null }; + } + return { text: displayText, tokens: mergedTokens }; + } catch { + return { text: displayText, tokens: null }; + } + } +} diff --git a/src/subtitle/stages/merge.ts b/src/subtitle/stages/merge.ts new file mode 100644 index 0000000..04673b2 --- /dev/null +++ b/src/subtitle/stages/merge.ts @@ -0,0 +1,12 @@ +import { TokenMergerProvider } from "../../token-mergers"; +import { MergedToken, Token } from "../../types"; + +export function mergeStage( + mergerProvider: TokenMergerProvider | null, + tokens: Token[] | null, +): MergedToken[] | null { + if (!mergerProvider || !tokens || tokens.length === 0) { + return null; + } + return mergerProvider.merge(tokens); +} diff --git a/src/subtitle/stages/normalize.ts b/src/subtitle/stages/normalize.ts new file mode 100644 index 0000000..4c49d73 --- /dev/null +++ b/src/subtitle/stages/normalize.ts @@ -0,0 +1,14 @@ +export function normalizeDisplayText(text: string): string { + return text + .replace(/\r\n/g, "\n") + .replace(/\\N/g, "\n") + .replace(/\\n/g, "\n") + .trim(); +} + +export function normalizeTokenizerInput(displayText: string): string { + return displayText + .replace(/\n/g, " ") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/src/subtitle/stages/tokenize.ts b/src/subtitle/stages/tokenize.ts new file mode 100644 index 0000000..9039e2b --- /dev/null +++ b/src/subtitle/stages/tokenize.ts @@ -0,0 +1,12 @@ +import { TokenizerProvider } from "../../tokenizers"; +import { Token } from "../../types"; + +export async function tokenizeStage( + tokenizerProvider: TokenizerProvider | null, + input: string, +): Promise { + if (!tokenizerProvider || !input) { + return null; + } + return tokenizerProvider.tokenize(input); +} diff --git a/src/token-mergers/index.ts b/src/token-mergers/index.ts new file mode 100644 index 0000000..260b843 --- /dev/null +++ b/src/token-mergers/index.ts @@ -0,0 +1,42 @@ +import { mergeTokens as defaultMergeTokens } from "../token-merger"; +import { MergedToken, Token } from "../types"; + +export interface TokenMergerProvider { + id: string; + merge: (tokens: Token[]) => MergedToken[]; +} + +type TokenMergerProviderFactory = () => TokenMergerProvider; + +const tokenMergerProviderFactories = new Map(); + +export function registerTokenMergerProvider( + id: string, + factory: TokenMergerProviderFactory, +): void { + if (tokenMergerProviderFactories.has(id)) { + return; + } + tokenMergerProviderFactories.set(id, factory); +} + +export function getRegisteredTokenMergerProviderIds(): string[] { + return Array.from(tokenMergerProviderFactories.keys()); +} + +export function createTokenMergerProvider( + id = "default", +): TokenMergerProvider | null { + const factory = tokenMergerProviderFactories.get(id); + if (!factory) return null; + return factory(); +} + +function registerDefaultTokenMergerProviders(): void { + registerTokenMergerProvider("default", () => ({ + id: "default", + merge: (tokens: Token[]) => defaultMergeTokens(tokens), + })); +} + +registerDefaultTokenMergerProviders(); diff --git a/src/tokenizers/index.ts b/src/tokenizers/index.ts new file mode 100644 index 0000000..e60543b --- /dev/null +++ b/src/tokenizers/index.ts @@ -0,0 +1,53 @@ +import { MecabTokenizer } from "../mecab-tokenizer"; +import { MecabStatus, Token } from "../types"; + +export interface TokenizerProvider { + id: string; + checkAvailability: () => Promise; + tokenize: (text: string) => Promise; + getStatus: () => MecabStatus; + setEnabled: (enabled: boolean) => void; +} + +type TokenizerProviderFactory = () => TokenizerProvider; + +const tokenizerProviderFactories = new Map(); + +export function registerTokenizerProvider( + id: string, + factory: TokenizerProviderFactory, +): void { + if (tokenizerProviderFactories.has(id)) { + return; + } + tokenizerProviderFactories.set(id, factory); +} + +export function getRegisteredTokenizerProviderIds(): string[] { + return Array.from(tokenizerProviderFactories.keys()); +} + +export function createTokenizerProvider( + id = "mecab", +): TokenizerProvider | null { + const factory = tokenizerProviderFactories.get(id); + if (!factory) { + return null; + } + return factory(); +} + +function registerDefaultTokenizerProviders(): void { + registerTokenizerProvider("mecab", () => { + const mecab = new MecabTokenizer(); + return { + id: "mecab", + checkAvailability: () => mecab.checkAvailability(), + tokenize: (text: string) => mecab.tokenize(text), + getStatus: () => mecab.getStatus(), + setEnabled: (enabled: boolean) => mecab.setEnabled(enabled), + }; + }); +} + +registerDefaultTokenizerProviders(); diff --git a/src/translators/index.ts b/src/translators/index.ts new file mode 100644 index 0000000..64bcb1a --- /dev/null +++ b/src/translators/index.ts @@ -0,0 +1,106 @@ +import axios from "axios"; + +export interface TranslationRequest { + sentence: string; + apiKey: string; + baseUrl: string; + model: string; + targetLanguage: string; + systemPrompt: string; + timeoutMs?: number; +} + +export interface TranslationProvider { + id: string; + translate: (request: TranslationRequest) => Promise; +} + +type TranslationProviderFactory = () => TranslationProvider; + +const translationProviderFactories = new Map(); + +export function registerTranslationProvider( + id: string, + factory: TranslationProviderFactory, +): void { + if (translationProviderFactories.has(id)) { + return; + } + translationProviderFactories.set(id, factory); +} + +export function createTranslationProvider( + id = "openai-compatible", +): TranslationProvider | null { + const factory = translationProviderFactories.get(id); + if (!factory) return null; + return factory(); +} + +function extractAiText(content: unknown): string { + if (typeof content === "string") { + return content.trim(); + } + if (!Array.isArray(content)) { + return ""; + } + const parts: string[] = []; + for (const item of content) { + if ( + item && + typeof item === "object" && + "type" in item && + (item as { type?: unknown }).type === "text" && + "text" in item && + typeof (item as { text?: unknown }).text === "string" + ) { + parts.push((item as { text: string }).text); + } + } + return parts.join("").trim(); +} + +function normalizeOpenAiBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + if (/\/v1$/i.test(trimmed)) { + return trimmed; + } + return `${trimmed}/v1`; +} + +function registerDefaultTranslationProviders(): void { + registerTranslationProvider("openai-compatible", () => ({ + id: "openai-compatible", + translate: async (request: TranslationRequest): Promise => { + const response = await axios.post( + `${normalizeOpenAiBaseUrl(request.baseUrl)}/chat/completions`, + { + model: request.model, + temperature: 0, + messages: [ + { role: "system", content: request.systemPrompt }, + { + role: "user", + content: `Translate this text to ${request.targetLanguage}:\n\n${request.sentence}`, + }, + ], + }, + { + headers: { + Authorization: `Bearer ${request.apiKey}`, + "Content-Type": "application/json", + }, + timeout: request.timeoutMs ?? 15000, + }, + ); + + const content = (response.data as { choices?: unknown[] })?.choices?.[0] as + | { message?: { content?: unknown } } + | undefined; + const translated = extractAiText(content?.message?.content); + return translated || null; + }, + })); +} + +registerDefaultTranslationProviders();