import fs from 'node:fs'; import path from 'node:path'; import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit'; import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config'; import type { ConfigSettingsAnkiDeckResult, ConfigSettingsAnkiListResult, ConfigSettingsField, ConfigSettingsSaveResult, ConfigSettingsSnapshot, } from '../../types/settings'; import type { ReloadConfigStrictResult } from '../../config'; import { classifyConfigHotReloadDiff } from '../../core/services/config-hot-reload'; import { createSaveConfigSettingsPatchHandler, type ConfigSettingsHotReloadDiff, } from './config-settings-save'; import { createOpenConfigSettingsWindowHandler, type ConfigSettingsWindowLike, } from './config-settings-window'; import { isConfigSettingsPatch } from './config-settings-ipc'; export interface ConfigSettingsIpcMainLike { handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown; } export interface ConfigSettingsIpcChannels { getConfigSettingsSnapshot: string; saveConfigSettingsPatch: string; openConfigSettingsFile: string; openConfigSettingsWindow: string; getConfigSettingsAnkiDeckNames: string; getConfigSettingsAnkiDeckFieldNames: string; getConfigSettingsAnkiDeckModelNames: string; getConfigSettingsAnkiModelNames: string; getConfigSettingsAnkiModelFieldNames: string; getConfigSettingsYomitanAnkiDeckName: string; } export interface ConfigSettingsAnkiClient { deckNames(): Promise; fieldNamesForDeck(deckName: string): Promise; modelNamesForDeck(deckName: string): Promise; modelNames(): Promise; modelFieldNames(modelName: string): Promise; } export interface ConfigSettingsRuntimeDeps { fields: ConfigSettingsField[]; getConfigPath(): string; getRawConfig(): RawConfig; getConfig(): ResolvedConfig; getWarnings(): ConfigValidationWarning[]; reloadConfigStrict(): ReloadConfigStrictResult; onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void; getSettingsWindow(): TWindow | null; setSettingsWindow(window: TWindow | null): void; createSettingsWindow(): TWindow; settingsHtmlPath: string; promoteSettingsWindowAboveOverlay?: (window: TWindow) => void; openPath(path: string): Promise; defaultAnkiConnectUrl: string; createAnkiClient(url: string): ConfigSettingsAnkiClient; getYomitanAnkiDeckName?: () => Promise; ipcMain: ConfigSettingsIpcMainLike; ipcChannels: ConfigSettingsIpcChannels; log?: (message: string) => void; } export function writeTextFileAtomically(targetPath: string, content: string): void { fs.mkdirSync(path.dirname(targetPath), { recursive: true }); const tempPath = path.join( path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp`, ); try { fs.writeFileSync(tempPath, content, 'utf-8'); fs.renameSync(tempPath, targetPath); } catch (error) { try { fs.rmSync(tempPath, { force: true }); } catch { // Best effort cleanup after a failed atomic write. } throw error; } } function getRestartRequiredSettingsSections( fields: readonly ConfigSettingsField[], restartRequiredFields: string[], ): string[] { const sections = new Set(); for (const field of fields) { if ( restartRequiredFields.some( (restartField) => field.configPath === restartField || field.configPath.startsWith(`${restartField}.`) || restartField.startsWith(`${field.configPath}.`), ) ) { sections.add(field.section); } } return [...sections].sort(); } export function createConfigSettingsRuntime( deps: ConfigSettingsRuntimeDeps, ) { function getSnapshot(): ConfigSettingsSnapshot { return buildConfigSettingsSnapshot({ configPath: deps.getConfigPath(), rawConfig: deps.getRawConfig(), resolvedConfig: deps.getConfig(), warnings: deps.getWarnings(), fields: deps.fields, }); } const savePatch = createSaveConfigSettingsPatchHandler({ getConfigPath: () => deps.getConfigPath(), getCurrentConfig: () => deps.getConfig(), getWarnings: () => deps.getWarnings(), getSnapshot, fileExists: (targetPath) => fs.existsSync(targetPath), readText: (targetPath) => fs.readFileSync(targetPath, 'utf-8'), writeTextAtomically: (targetPath, content) => writeTextFileAtomically(targetPath, content), deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }), reloadConfigStrict: () => deps.reloadConfigStrict(), classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next), getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields), onHotReloadApplied: deps.onHotReloadApplied, }); function ensureConfigFileExists(): string { const configPath = deps.getConfigPath(); if (!fs.existsSync(configPath)) { writeTextFileAtomically(configPath, '{}\n'); } return configPath; } const openWindow = createOpenConfigSettingsWindowHandler({ getSettingsWindow: deps.getSettingsWindow, setSettingsWindow: deps.setSettingsWindow, createSettingsWindow: deps.createSettingsWindow, settingsHtmlPath: deps.settingsHtmlPath, promoteSettingsWindowAboveOverlay: deps.promoteSettingsWindowAboveOverlay, log: deps.log, }); function invalidPatchResult(): ConfigSettingsSaveResult { return { ok: false, warnings: [], error: 'Invalid config settings patch.', hotReloadFields: [], restartRequiredFields: [], restartRequiredSections: [], }; } function getAnkiConnectUrl(draftUrl: unknown): string { return typeof draftUrl === 'string' && draftUrl.trim().length > 0 ? draftUrl.trim() : deps.getConfig().ankiConnect.url || deps.defaultAnkiConnectUrl; } async function getAnkiList( draftUrl: unknown, lookup: (client: ConfigSettingsAnkiClient) => Promise, ): Promise { try { const client = deps.createAnkiClient(getAnkiConnectUrl(draftUrl)); return { ok: true, values: await lookup(client) }; } catch (error) { return { ok: false, values: [], error: error instanceof Error ? error.message : 'Failed to query AnkiConnect.', }; } } function invalidAnkiListResult(error: string): ConfigSettingsAnkiListResult { return { ok: false, values: [], error, }; } function persistInferredYomitanDeckIfEmpty(deckName: string): void { const normalizedDeckName = deckName.trim(); const configuredDeckName = deps.getConfig().ankiConnect?.deck?.trim() ?? ''; if (!normalizedDeckName || configuredDeckName) { return; } const result = savePatch({ operations: [ { op: 'set', path: 'ankiConnect.deck', value: normalizedDeckName, }, ], }); if (!result.ok) { deps.log?.( `Failed to persist inferred Yomitan Anki deck: ${result.error ?? 'unknown error'}`, ); } } async function getYomitanAnkiDeckName(): Promise { if (!deps.getYomitanAnkiDeckName) { return { ok: true, value: '' }; } try { const value = await deps.getYomitanAnkiDeckName(); const deckName = typeof value === 'string' ? value.trim() : ''; persistInferredYomitanDeckIfEmpty(deckName); return { ok: true, value: deckName }; } catch (error) { return { ok: false, value: '', error: error instanceof Error ? error.message : 'Failed to query Yomitan.', }; } } function registerHandlers(): void { deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot()); deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => { if (!isConfigSettingsPatch(patch, deps.fields)) { return invalidPatchResult(); } return savePatch(patch); }); deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsFile, async () => { const openError = await deps.openPath(ensureConfigFileExists()); return openError.length === 0; }); deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow()); deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiDeckNames, (_event, draftUrl) => getAnkiList(draftUrl, (client) => client.deckNames()), ); deps.ipcMain.handle( deps.ipcChannels.getConfigSettingsAnkiDeckFieldNames, (_event, deckName, draftUrl) => { const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : ''; return normalizedDeckName ? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(normalizedDeckName)) : invalidAnkiListResult('Deck name is required.'); }, ); deps.ipcMain.handle( deps.ipcChannels.getConfigSettingsAnkiDeckModelNames, (_event, deckName, draftUrl) => { const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : ''; return normalizedDeckName ? getAnkiList(draftUrl, (client) => client.modelNamesForDeck(normalizedDeckName)) : invalidAnkiListResult('Deck name is required.'); }, ); deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiModelNames, (_event, draftUrl) => getAnkiList(draftUrl, (client) => client.modelNames()), ); deps.ipcMain.handle( deps.ipcChannels.getConfigSettingsAnkiModelFieldNames, (_event, modelName, draftUrl) => { const normalizedModelName = typeof modelName === 'string' ? modelName.trim() : ''; return normalizedModelName ? getAnkiList(draftUrl, (client) => client.modelFieldNames(normalizedModelName)) : invalidAnkiListResult('Note type is required.'); }, ); deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsYomitanAnkiDeckName, () => getYomitanAnkiDeckName(), ); } return { getSnapshot, savePatch, openWindow, registerHandlers, }; }