Files
SubMiner/src/main/runtime/config-settings-runtime.ts
T

295 lines
10 KiB
TypeScript

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<string[]>;
fieldNamesForDeck(deckName: string): Promise<string[]>;
modelNamesForDeck(deckName: string): Promise<string[]>;
modelNames(): Promise<string[]>;
modelFieldNames(modelName: string): Promise<string[]>;
}
export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowLike> {
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<string>;
defaultAnkiConnectUrl: string;
createAnkiClient(url: string): ConfigSettingsAnkiClient;
getYomitanAnkiDeckName?: () => Promise<string | null | undefined>;
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<string>();
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<TWindow extends ConfigSettingsWindowLike>(
deps: ConfigSettingsRuntimeDeps<TWindow>,
) {
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<string[]>,
): Promise<ConfigSettingsAnkiListResult> {
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<ConfigSettingsAnkiDeckResult> {
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,
};
}