mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 04:49:49 -07:00
295 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|