mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
initial commit
This commit is contained in:
79
src/config/config.test.ts
Normal file
79
src/config/config.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { ConfigService } from "./service";
|
||||
import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY } from "./definitions";
|
||||
import { generateConfigTemplate } from "./template";
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "subminer-config-test-"));
|
||||
}
|
||||
|
||||
test("loads defaults when config is missing", () => {
|
||||
const dir = makeTempDir();
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
|
||||
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
||||
});
|
||||
|
||||
test("parses jsonc and warns/falls back on invalid value", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "config.jsonc"),
|
||||
`{
|
||||
// invalid websocket port
|
||||
"websocket": { "port": "bad" }
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
|
||||
assert.ok(service.getWarnings().some((w) => w.path === "websocket.port"));
|
||||
});
|
||||
|
||||
test("parses invisible overlay config and new global shortcuts", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "config.jsonc"),
|
||||
`{
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I"
|
||||
},
|
||||
"invisibleOverlay": {
|
||||
"startupVisibility": "hidden"
|
||||
},
|
||||
"bind_visible_overlay_to_mpv_sub_visibility": false,
|
||||
"youtubeSubgen": {
|
||||
"primarySubLanguages": ["ja", "jpn", "jp"]
|
||||
}
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, "Alt+Shift+U");
|
||||
assert.equal(config.shortcuts.toggleInvisibleOverlayGlobal, "Alt+Shift+I");
|
||||
assert.equal(config.invisibleOverlay.startupVisibility, "hidden");
|
||||
assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false);
|
||||
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ["ja", "jpn", "jp"]);
|
||||
});
|
||||
|
||||
test("runtime options registry is centralized", () => {
|
||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||
assert.deepEqual(ids, ["anki.autoUpdateNewCards", "anki.kikuFieldGrouping"]);
|
||||
});
|
||||
|
||||
test("template generator includes known keys", () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
assert.match(output, /"ankiConnect":/);
|
||||
assert.match(output, /"websocket":/);
|
||||
assert.match(output, /"youtubeSubgen":/);
|
||||
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
||||
});
|
||||
474
src/config/definitions.ts
Normal file
474
src/config/definitions.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
Config,
|
||||
RawConfig,
|
||||
ResolvedConfig,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionScope,
|
||||
RuntimeOptionValue,
|
||||
RuntimeOptionValueType,
|
||||
} from "../types";
|
||||
|
||||
export type ConfigValueKind =
|
||||
| "boolean"
|
||||
| "number"
|
||||
| "string"
|
||||
| "enum"
|
||||
| "array"
|
||||
| "object";
|
||||
|
||||
export interface RuntimeOptionRegistryEntry {
|
||||
id: RuntimeOptionId;
|
||||
path: string;
|
||||
label: string;
|
||||
scope: RuntimeOptionScope;
|
||||
valueType: RuntimeOptionValueType;
|
||||
allowedValues: RuntimeOptionValue[];
|
||||
defaultValue: RuntimeOptionValue;
|
||||
requiresRestart: boolean;
|
||||
formatValueForOsd: (value: RuntimeOptionValue) => string;
|
||||
toAnkiPatch: (value: RuntimeOptionValue) => Partial<AnkiConnectConfig>;
|
||||
}
|
||||
|
||||
export interface ConfigOptionRegistryEntry {
|
||||
path: string;
|
||||
kind: ConfigValueKind;
|
||||
defaultValue: unknown;
|
||||
description: string;
|
||||
enumValues?: readonly string[];
|
||||
runtime?: RuntimeOptionRegistryEntry;
|
||||
}
|
||||
|
||||
export interface ConfigTemplateSection {
|
||||
title: string;
|
||||
description: string[];
|
||||
key: keyof ResolvedConfig;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export const SPECIAL_COMMANDS = {
|
||||
SUBSYNC_TRIGGER: "__subsync-trigger",
|
||||
RUNTIME_OPTIONS_OPEN: "__runtime-options-open",
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: "__runtime-option-cycle:",
|
||||
REPLAY_SUBTITLE: "__replay-subtitle",
|
||||
PLAY_NEXT_SUBTITLE: "__play-next-subtitle",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig["keybindings"]> = [
|
||||
{ key: "Space", command: ["cycle", "pause"] },
|
||||
{ key: "ArrowRight", command: ["seek", 5] },
|
||||
{ key: "ArrowLeft", command: ["seek", -5] },
|
||||
{ key: "ArrowUp", command: ["seek", 60] },
|
||||
{ key: "ArrowDown", command: ["seek", -60] },
|
||||
{ key: "Shift+KeyH", command: ["sub-seek", -1] },
|
||||
{ key: "Shift+KeyL", command: ["sub-seek", 1] },
|
||||
{ key: "Ctrl+Shift+KeyH", command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
||||
{ key: "Ctrl+Shift+KeyL", command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
|
||||
{ key: "KeyQ", command: ["quit"] },
|
||||
{ key: "Ctrl+KeyW", command: ["quit"] },
|
||||
];
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
keybindings: [],
|
||||
websocket: {
|
||||
enabled: "auto",
|
||||
port: 6677,
|
||||
},
|
||||
texthooker: {
|
||||
openBrowser: true,
|
||||
},
|
||||
ankiConnect: {
|
||||
enabled: false,
|
||||
url: "http://127.0.0.1:8765",
|
||||
pollingRate: 3000,
|
||||
fields: {
|
||||
audio: "ExpressionAudio",
|
||||
image: "Picture",
|
||||
sentence: "Sentence",
|
||||
miscInfo: "MiscInfo",
|
||||
translation: "SelectionText",
|
||||
},
|
||||
ai: {
|
||||
enabled: false,
|
||||
alwaysUseAiTranslation: false,
|
||||
apiKey: "",
|
||||
model: "openai/gpt-4o-mini",
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
targetLanguage: "English",
|
||||
systemPrompt:
|
||||
"You are a translation engine. Return only the translated text with no explanations.",
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageType: "static",
|
||||
imageFormat: "jpg",
|
||||
imageQuality: 92,
|
||||
imageMaxWidth: undefined,
|
||||
imageMaxHeight: undefined,
|
||||
animatedFps: 10,
|
||||
animatedMaxWidth: 640,
|
||||
animatedMaxHeight: undefined,
|
||||
animatedCrf: 35,
|
||||
audioPadding: 0.5,
|
||||
fallbackDuration: 3.0,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {
|
||||
overwriteAudio: true,
|
||||
overwriteImage: true,
|
||||
mediaInsertMode: "append",
|
||||
highlightWord: true,
|
||||
notificationType: "osd",
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
metadata: {
|
||||
pattern: "[SubMiner] %f (%t)",
|
||||
},
|
||||
isLapis: {
|
||||
enabled: false,
|
||||
sentenceCardModel: "Japanese sentences",
|
||||
sentenceCardSentenceField: "Sentence",
|
||||
sentenceCardAudioField: "SentenceAudio",
|
||||
},
|
||||
isKiku: {
|
||||
enabled: false,
|
||||
fieldGrouping: "disabled",
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
toggleVisibleOverlayGlobal: "Alt+Shift+O",
|
||||
toggleInvisibleOverlayGlobal: "Alt+Shift+I",
|
||||
copySubtitle: "CommandOrControl+C",
|
||||
copySubtitleMultiple: "CommandOrControl+Shift+C",
|
||||
updateLastCardFromClipboard: "CommandOrControl+V",
|
||||
triggerFieldGrouping: "CommandOrControl+G",
|
||||
triggerSubsync: "Ctrl+Alt+S",
|
||||
mineSentence: "CommandOrControl+S",
|
||||
mineSentenceMultiple: "CommandOrControl+Shift+S",
|
||||
multiCopyTimeoutMs: 3000,
|
||||
toggleSecondarySub: "CommandOrControl+Shift+V",
|
||||
markAudioCard: "CommandOrControl+Shift+A",
|
||||
openRuntimeOptions: "CommandOrControl+Shift+O",
|
||||
},
|
||||
secondarySub: {
|
||||
secondarySubLanguages: [],
|
||||
autoLoadSecondarySub: false,
|
||||
defaultMode: "hover",
|
||||
},
|
||||
subsync: {
|
||||
defaultMode: "auto",
|
||||
alass_path: "",
|
||||
ffsubsync_path: "",
|
||||
ffmpeg_path: "",
|
||||
},
|
||||
subtitleStyle: {
|
||||
fontFamily:
|
||||
"Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
|
||||
fontSize: 35,
|
||||
fontColor: "#cad3f5",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
backgroundColor: "rgba(54, 58, 79, 0.5)",
|
||||
secondary: {
|
||||
fontSize: 24,
|
||||
fontColor: "#ffffff",
|
||||
backgroundColor: "transparent",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
fontFamily:
|
||||
"Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
|
||||
},
|
||||
},
|
||||
auto_start_overlay: false,
|
||||
bind_visible_overlay_to_mpv_sub_visibility: true,
|
||||
jimaku: {
|
||||
apiBaseUrl: "https://jimaku.cc",
|
||||
languagePreference: "ja",
|
||||
maxEntryResults: 10,
|
||||
},
|
||||
youtubeSubgen: {
|
||||
mode: "automatic",
|
||||
whisperBin: "",
|
||||
whisperModel: "",
|
||||
primarySubLanguages: ["ja", "jpn"],
|
||||
},
|
||||
invisibleOverlay: {
|
||||
startupVisibility: "platform-default",
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect;
|
||||
|
||||
export const RUNTIME_OPTION_REGISTRY: RuntimeOptionRegistryEntry[] = [
|
||||
{
|
||||
id: "anki.autoUpdateNewCards",
|
||||
path: "ankiConnect.behavior.autoUpdateNewCards",
|
||||
label: "Auto Update New Cards",
|
||||
scope: "ankiConnect",
|
||||
valueType: "boolean",
|
||||
allowedValues: [true, false],
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.behavior.autoUpdateNewCards,
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => (value === true ? "On" : "Off"),
|
||||
toAnkiPatch: (value) => ({
|
||||
behavior: { autoUpdateNewCards: value === true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "anki.kikuFieldGrouping",
|
||||
path: "ankiConnect.isKiku.fieldGrouping",
|
||||
label: "Kiku Field Grouping",
|
||||
scope: "ankiConnect",
|
||||
valueType: "enum",
|
||||
allowedValues: ["auto", "manual", "disabled"],
|
||||
defaultValue: "disabled",
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => String(value),
|
||||
toAnkiPatch: (value) => ({
|
||||
isKiku: {
|
||||
fieldGrouping:
|
||||
value === "auto" || value === "manual" || value === "disabled"
|
||||
? value
|
||||
: "disabled",
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
||||
{
|
||||
path: "websocket.enabled",
|
||||
kind: "enum",
|
||||
enumValues: ["auto", "true", "false"],
|
||||
defaultValue: DEFAULT_CONFIG.websocket.enabled,
|
||||
description: "Built-in subtitle websocket server mode.",
|
||||
},
|
||||
{
|
||||
path: "websocket.port",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_CONFIG.websocket.port,
|
||||
description: "Built-in subtitle websocket server port.",
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.enabled",
|
||||
kind: "boolean",
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.enabled,
|
||||
description: "Enable AnkiConnect integration.",
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.pollingRate",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.pollingRate,
|
||||
description: "Polling interval in milliseconds.",
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.behavior.autoUpdateNewCards",
|
||||
kind: "boolean",
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.behavior.autoUpdateNewCards,
|
||||
description: "Automatically update newly added cards.",
|
||||
runtime: RUNTIME_OPTION_REGISTRY[0],
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.isKiku.fieldGrouping",
|
||||
kind: "enum",
|
||||
enumValues: ["auto", "manual", "disabled"],
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
|
||||
description: "Kiku duplicate-card field grouping mode.",
|
||||
runtime: RUNTIME_OPTION_REGISTRY[1],
|
||||
},
|
||||
{
|
||||
path: "subsync.defaultMode",
|
||||
kind: "enum",
|
||||
enumValues: ["auto", "manual"],
|
||||
defaultValue: DEFAULT_CONFIG.subsync.defaultMode,
|
||||
description: "Subsync default mode.",
|
||||
},
|
||||
{
|
||||
path: "shortcuts.multiCopyTimeoutMs",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_CONFIG.shortcuts.multiCopyTimeoutMs,
|
||||
description: "Timeout for multi-copy/mine modes.",
|
||||
},
|
||||
{
|
||||
path: "bind_visible_overlay_to_mpv_sub_visibility",
|
||||
kind: "boolean",
|
||||
defaultValue: DEFAULT_CONFIG.bind_visible_overlay_to_mpv_sub_visibility,
|
||||
description:
|
||||
"Link visible overlay toggles to MPV subtitle visibility (primary and secondary).",
|
||||
},
|
||||
{
|
||||
path: "jimaku.languagePreference",
|
||||
kind: "enum",
|
||||
enumValues: ["ja", "en", "none"],
|
||||
defaultValue: DEFAULT_CONFIG.jimaku.languagePreference,
|
||||
description: "Preferred language used in Jimaku search.",
|
||||
},
|
||||
{
|
||||
path: "jimaku.maxEntryResults",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_CONFIG.jimaku.maxEntryResults,
|
||||
description: "Maximum Jimaku search results returned.",
|
||||
},
|
||||
{
|
||||
path: "youtubeSubgen.mode",
|
||||
kind: "enum",
|
||||
enumValues: ["automatic", "preprocess", "off"],
|
||||
defaultValue: DEFAULT_CONFIG.youtubeSubgen.mode,
|
||||
description: "YouTube subtitle generation mode for the launcher script.",
|
||||
},
|
||||
{
|
||||
path: "youtubeSubgen.whisperBin",
|
||||
kind: "string",
|
||||
defaultValue: DEFAULT_CONFIG.youtubeSubgen.whisperBin,
|
||||
description: "Path to whisper.cpp CLI used as fallback transcription engine.",
|
||||
},
|
||||
{
|
||||
path: "youtubeSubgen.whisperModel",
|
||||
kind: "string",
|
||||
defaultValue: DEFAULT_CONFIG.youtubeSubgen.whisperModel,
|
||||
description: "Path to whisper model used for fallback transcription.",
|
||||
},
|
||||
{
|
||||
path: "youtubeSubgen.primarySubLanguages",
|
||||
kind: "string",
|
||||
defaultValue: DEFAULT_CONFIG.youtubeSubgen.primarySubLanguages.join(","),
|
||||
description:
|
||||
"Comma-separated primary subtitle language priority used by the launcher.",
|
||||
},
|
||||
];
|
||||
|
||||
export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: "Overlay Auto-Start",
|
||||
description: [
|
||||
"When overlay connects to mpv, automatically show overlay and hide mpv subtitles.",
|
||||
],
|
||||
key: "auto_start_overlay",
|
||||
},
|
||||
{
|
||||
title: "Visible Overlay Subtitle Binding",
|
||||
description: [
|
||||
"Control whether visible overlay toggles also toggle MPV subtitle visibility.",
|
||||
"When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.",
|
||||
],
|
||||
key: "bind_visible_overlay_to_mpv_sub_visibility",
|
||||
},
|
||||
{
|
||||
title: "Texthooker Server",
|
||||
description: [
|
||||
"Control whether browser opens automatically for texthooker.",
|
||||
],
|
||||
key: "texthooker",
|
||||
},
|
||||
{
|
||||
title: "WebSocket Server",
|
||||
description: [
|
||||
"Built-in WebSocket server broadcasts subtitle text to connected clients.",
|
||||
"Auto mode disables built-in server if mpv_websocket is detected.",
|
||||
],
|
||||
key: "websocket",
|
||||
},
|
||||
{
|
||||
title: "AnkiConnect Integration",
|
||||
description: ["Automatic Anki updates and media generation options."],
|
||||
key: "ankiConnect",
|
||||
},
|
||||
{
|
||||
title: "Keyboard Shortcuts",
|
||||
description: [
|
||||
"Overlay keyboard shortcuts. Set a shortcut to null to disable.",
|
||||
],
|
||||
key: "shortcuts",
|
||||
},
|
||||
{
|
||||
title: "Invisible Overlay",
|
||||
description: [
|
||||
"Startup behavior for the invisible interactive subtitle mining layer.",
|
||||
],
|
||||
key: "invisibleOverlay",
|
||||
},
|
||||
{
|
||||
title: "Keybindings (MPV Commands)",
|
||||
description: [
|
||||
"Extra keybindings that are merged with built-in defaults.",
|
||||
"Set command to null to disable a default keybinding.",
|
||||
],
|
||||
key: "keybindings",
|
||||
},
|
||||
{
|
||||
title: "Subtitle Appearance",
|
||||
description: ["Primary and secondary subtitle styling."],
|
||||
key: "subtitleStyle",
|
||||
},
|
||||
{
|
||||
title: "Secondary Subtitles",
|
||||
description: [
|
||||
"Dual subtitle track options.",
|
||||
"Used by subminer YouTube subtitle generation as secondary language preferences.",
|
||||
],
|
||||
key: "secondarySub",
|
||||
},
|
||||
{
|
||||
title: "Auto Subtitle Sync",
|
||||
description: ["Subsync engine and executable paths."],
|
||||
key: "subsync",
|
||||
},
|
||||
{
|
||||
title: "Subtitle Position",
|
||||
description: ["Initial vertical subtitle position from the bottom."],
|
||||
key: "subtitlePosition",
|
||||
},
|
||||
{
|
||||
title: "Jimaku",
|
||||
description: ["Jimaku API configuration and defaults."],
|
||||
key: "jimaku",
|
||||
},
|
||||
{
|
||||
title: "YouTube Subtitle Generation",
|
||||
description: [
|
||||
"Defaults for subminer YouTube subtitle extraction/transcription mode.",
|
||||
],
|
||||
key: "youtubeSubgen",
|
||||
},
|
||||
];
|
||||
|
||||
export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig {
|
||||
return JSON.parse(JSON.stringify(config)) as ResolvedConfig;
|
||||
}
|
||||
|
||||
export function deepMergeRawConfig(
|
||||
base: RawConfig,
|
||||
patch: RawConfig,
|
||||
): RawConfig {
|
||||
const clone = JSON.parse(JSON.stringify(base)) as Record<string, unknown>;
|
||||
const patchObject = patch as Record<string, unknown>;
|
||||
|
||||
const mergeInto = (
|
||||
target: Record<string, unknown>,
|
||||
source: Record<string, unknown>,
|
||||
): void => {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
typeof target[key] === "object" &&
|
||||
target[key] !== null &&
|
||||
!Array.isArray(target[key])
|
||||
) {
|
||||
mergeInto(
|
||||
target[key] as Record<string, unknown>,
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mergeInto(clone, patchObject);
|
||||
return clone as RawConfig;
|
||||
}
|
||||
3
src/config/index.ts
Normal file
3
src/config/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./definitions";
|
||||
export * from "./service";
|
||||
export * from "./template";
|
||||
600
src/config/service.ts
Normal file
600
src/config/service.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { parse as parseJsonc } from "jsonc-parser";
|
||||
import {
|
||||
Config,
|
||||
ConfigValidationWarning,
|
||||
RawConfig,
|
||||
ResolvedConfig,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
deepCloneConfig,
|
||||
deepMergeRawConfig,
|
||||
} from "./definitions";
|
||||
|
||||
interface LoadResult {
|
||||
config: RawConfig;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
private readonly configDir: string;
|
||||
private readonly configFileJsonc: string;
|
||||
private readonly configFileJson: string;
|
||||
private rawConfig: RawConfig = {};
|
||||
private resolvedConfig: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG);
|
||||
private warnings: ConfigValidationWarning[] = [];
|
||||
private configPathInUse: string;
|
||||
|
||||
constructor(configDir: string) {
|
||||
this.configDir = configDir;
|
||||
this.configFileJsonc = path.join(configDir, "config.jsonc");
|
||||
this.configFileJson = path.join(configDir, "config.json");
|
||||
this.configPathInUse = this.configFileJsonc;
|
||||
this.reloadConfig();
|
||||
}
|
||||
|
||||
getConfigPath(): string {
|
||||
return this.configPathInUse;
|
||||
}
|
||||
|
||||
getConfig(): ResolvedConfig {
|
||||
return deepCloneConfig(this.resolvedConfig);
|
||||
}
|
||||
|
||||
getRawConfig(): RawConfig {
|
||||
return JSON.parse(JSON.stringify(this.rawConfig)) as RawConfig;
|
||||
}
|
||||
|
||||
getWarnings(): ConfigValidationWarning[] {
|
||||
return [...this.warnings];
|
||||
}
|
||||
|
||||
reloadConfig(): ResolvedConfig {
|
||||
const { config, path: configPath } = this.loadRawConfig();
|
||||
this.rawConfig = config;
|
||||
this.configPathInUse = configPath;
|
||||
const { resolved, warnings } = this.resolveConfig(config);
|
||||
this.resolvedConfig = resolved;
|
||||
this.warnings = warnings;
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
saveRawConfig(config: RawConfig): void {
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
fs.mkdirSync(this.configDir, { recursive: true });
|
||||
}
|
||||
const targetPath = this.configPathInUse.endsWith(".json")
|
||||
? this.configPathInUse
|
||||
: this.configFileJsonc;
|
||||
fs.writeFileSync(targetPath, JSON.stringify(config, null, 2));
|
||||
this.rawConfig = config;
|
||||
this.configPathInUse = targetPath;
|
||||
const { resolved, warnings } = this.resolveConfig(config);
|
||||
this.resolvedConfig = resolved;
|
||||
this.warnings = warnings;
|
||||
}
|
||||
|
||||
patchRawConfig(patch: RawConfig): void {
|
||||
const merged = deepMergeRawConfig(this.getRawConfig(), patch);
|
||||
this.saveRawConfig(merged);
|
||||
}
|
||||
|
||||
private loadRawConfig(): LoadResult {
|
||||
const configPath = fs.existsSync(this.configFileJsonc)
|
||||
? this.configFileJsonc
|
||||
: fs.existsSync(this.configFileJson)
|
||||
? this.configFileJson
|
||||
: this.configFileJsonc;
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return { config: {}, path: configPath };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(configPath, "utf-8");
|
||||
const parsed = configPath.endsWith(".jsonc")
|
||||
? parseJsonc(data)
|
||||
: JSON.parse(data);
|
||||
return {
|
||||
config: isObject(parsed) ? (parsed as Config) : {},
|
||||
path: configPath,
|
||||
};
|
||||
} catch {
|
||||
return { config: {}, path: configPath };
|
||||
}
|
||||
}
|
||||
|
||||
private resolveConfig(raw: RawConfig): {
|
||||
resolved: ResolvedConfig;
|
||||
warnings: ConfigValidationWarning[];
|
||||
} {
|
||||
const warnings: ConfigValidationWarning[] = [];
|
||||
const resolved = deepCloneConfig(DEFAULT_CONFIG);
|
||||
|
||||
const warn = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
fallback: unknown,
|
||||
message: string,
|
||||
): void => {
|
||||
warnings.push({
|
||||
path,
|
||||
value,
|
||||
fallback,
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
const src = isObject(raw) ? raw : {};
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const openBrowser = asBoolean(src.texthooker.openBrowser);
|
||||
if (openBrowser !== undefined) {
|
||||
resolved.texthooker.openBrowser = openBrowser;
|
||||
} else if (src.texthooker.openBrowser !== undefined) {
|
||||
warn(
|
||||
"texthooker.openBrowser",
|
||||
src.texthooker.openBrowser,
|
||||
resolved.texthooker.openBrowser,
|
||||
"Expected boolean.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.websocket)) {
|
||||
const enabled = src.websocket.enabled;
|
||||
if (enabled === "auto" || enabled === true || enabled === false) {
|
||||
resolved.websocket.enabled = enabled;
|
||||
} else if (enabled !== undefined) {
|
||||
warn(
|
||||
"websocket.enabled",
|
||||
enabled,
|
||||
resolved.websocket.enabled,
|
||||
"Expected true, false, or 'auto'.",
|
||||
);
|
||||
}
|
||||
|
||||
const port = asNumber(src.websocket.port);
|
||||
if (port !== undefined && port > 0 && port <= 65535) {
|
||||
resolved.websocket.port = Math.floor(port);
|
||||
} else if (src.websocket.port !== undefined) {
|
||||
warn(
|
||||
"websocket.port",
|
||||
src.websocket.port,
|
||||
resolved.websocket.port,
|
||||
"Expected integer between 1 and 65535.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(src.keybindings)) {
|
||||
resolved.keybindings = src.keybindings.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is { key: string; command: (string | number)[] | null } => {
|
||||
if (!isObject(entry)) return false;
|
||||
if (typeof entry.key !== "string") return false;
|
||||
if (entry.command === null) return true;
|
||||
return Array.isArray(entry.command);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.shortcuts)) {
|
||||
const shortcutKeys = [
|
||||
"toggleVisibleOverlayGlobal",
|
||||
"toggleInvisibleOverlayGlobal",
|
||||
"copySubtitle",
|
||||
"copySubtitleMultiple",
|
||||
"updateLastCardFromClipboard",
|
||||
"triggerFieldGrouping",
|
||||
"triggerSubsync",
|
||||
"mineSentence",
|
||||
"mineSentenceMultiple",
|
||||
"toggleSecondarySub",
|
||||
"markAudioCard",
|
||||
"openRuntimeOptions",
|
||||
] as const;
|
||||
|
||||
for (const key of shortcutKeys) {
|
||||
const value = src.shortcuts[key];
|
||||
if (typeof value === "string" || value === null) {
|
||||
resolved.shortcuts[key] =
|
||||
value as (typeof resolved.shortcuts)[typeof key];
|
||||
} else if (value !== undefined) {
|
||||
warn(
|
||||
`shortcuts.${key}`,
|
||||
value,
|
||||
resolved.shortcuts[key],
|
||||
"Expected string or null.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = asNumber(src.shortcuts.multiCopyTimeoutMs);
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
resolved.shortcuts.multiCopyTimeoutMs = Math.floor(timeout);
|
||||
} else if (src.shortcuts.multiCopyTimeoutMs !== undefined) {
|
||||
warn(
|
||||
"shortcuts.multiCopyTimeoutMs",
|
||||
src.shortcuts.multiCopyTimeoutMs,
|
||||
resolved.shortcuts.multiCopyTimeoutMs,
|
||||
"Expected positive number.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.invisibleOverlay)) {
|
||||
const startupVisibility = src.invisibleOverlay.startupVisibility;
|
||||
if (
|
||||
startupVisibility === "platform-default" ||
|
||||
startupVisibility === "visible" ||
|
||||
startupVisibility === "hidden"
|
||||
) {
|
||||
resolved.invisibleOverlay.startupVisibility = startupVisibility;
|
||||
} else if (startupVisibility !== undefined) {
|
||||
warn(
|
||||
"invisibleOverlay.startupVisibility",
|
||||
startupVisibility,
|
||||
resolved.invisibleOverlay.startupVisibility,
|
||||
"Expected platform-default, visible, or hidden.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.secondarySub)) {
|
||||
if (Array.isArray(src.secondarySub.secondarySubLanguages)) {
|
||||
resolved.secondarySub.secondarySubLanguages =
|
||||
src.secondarySub.secondarySubLanguages.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
const autoLoad = asBoolean(src.secondarySub.autoLoadSecondarySub);
|
||||
if (autoLoad !== undefined) {
|
||||
resolved.secondarySub.autoLoadSecondarySub = autoLoad;
|
||||
}
|
||||
const defaultMode = src.secondarySub.defaultMode;
|
||||
if (
|
||||
defaultMode === "hidden" ||
|
||||
defaultMode === "visible" ||
|
||||
defaultMode === "hover"
|
||||
) {
|
||||
resolved.secondarySub.defaultMode = defaultMode;
|
||||
} else if (defaultMode !== undefined) {
|
||||
warn(
|
||||
"secondarySub.defaultMode",
|
||||
defaultMode,
|
||||
resolved.secondarySub.defaultMode,
|
||||
"Expected hidden, visible, or hover.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.subsync)) {
|
||||
const mode = src.subsync.defaultMode;
|
||||
if (mode === "auto" || mode === "manual") {
|
||||
resolved.subsync.defaultMode = mode;
|
||||
} else if (mode !== undefined) {
|
||||
warn(
|
||||
"subsync.defaultMode",
|
||||
mode,
|
||||
resolved.subsync.defaultMode,
|
||||
"Expected auto or manual.",
|
||||
);
|
||||
}
|
||||
|
||||
const alass = asString(src.subsync.alass_path);
|
||||
if (alass !== undefined) resolved.subsync.alass_path = alass;
|
||||
const ffsubsync = asString(src.subsync.ffsubsync_path);
|
||||
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
|
||||
const ffmpeg = asString(src.subsync.ffmpeg_path);
|
||||
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
|
||||
}
|
||||
|
||||
if (isObject(src.subtitlePosition)) {
|
||||
const y = asNumber(src.subtitlePosition.yPercent);
|
||||
if (y !== undefined) {
|
||||
resolved.subtitlePosition.yPercent = y;
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.jimaku)) {
|
||||
const apiKey = asString(src.jimaku.apiKey);
|
||||
if (apiKey !== undefined) resolved.jimaku.apiKey = apiKey;
|
||||
const apiKeyCommand = asString(src.jimaku.apiKeyCommand);
|
||||
if (apiKeyCommand !== undefined)
|
||||
resolved.jimaku.apiKeyCommand = apiKeyCommand;
|
||||
const apiBaseUrl = asString(src.jimaku.apiBaseUrl);
|
||||
if (apiBaseUrl !== undefined) resolved.jimaku.apiBaseUrl = apiBaseUrl;
|
||||
|
||||
const lang = src.jimaku.languagePreference;
|
||||
if (lang === "ja" || lang === "en" || lang === "none") {
|
||||
resolved.jimaku.languagePreference = lang;
|
||||
} else if (lang !== undefined) {
|
||||
warn(
|
||||
"jimaku.languagePreference",
|
||||
lang,
|
||||
resolved.jimaku.languagePreference,
|
||||
"Expected ja, en, or none.",
|
||||
);
|
||||
}
|
||||
|
||||
const maxEntryResults = asNumber(src.jimaku.maxEntryResults);
|
||||
if (maxEntryResults !== undefined && maxEntryResults > 0) {
|
||||
resolved.jimaku.maxEntryResults = Math.floor(maxEntryResults);
|
||||
} else if (src.jimaku.maxEntryResults !== undefined) {
|
||||
warn(
|
||||
"jimaku.maxEntryResults",
|
||||
src.jimaku.maxEntryResults,
|
||||
resolved.jimaku.maxEntryResults,
|
||||
"Expected positive number.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.youtubeSubgen)) {
|
||||
const mode = src.youtubeSubgen.mode;
|
||||
if (mode === "automatic" || mode === "preprocess" || mode === "off") {
|
||||
resolved.youtubeSubgen.mode = mode;
|
||||
} else if (mode !== undefined) {
|
||||
warn(
|
||||
"youtubeSubgen.mode",
|
||||
mode,
|
||||
resolved.youtubeSubgen.mode,
|
||||
"Expected automatic, preprocess, or off.",
|
||||
);
|
||||
}
|
||||
|
||||
const whisperBin = asString(src.youtubeSubgen.whisperBin);
|
||||
if (whisperBin !== undefined) {
|
||||
resolved.youtubeSubgen.whisperBin = whisperBin;
|
||||
} else if (src.youtubeSubgen.whisperBin !== undefined) {
|
||||
warn(
|
||||
"youtubeSubgen.whisperBin",
|
||||
src.youtubeSubgen.whisperBin,
|
||||
resolved.youtubeSubgen.whisperBin,
|
||||
"Expected string.",
|
||||
);
|
||||
}
|
||||
|
||||
const whisperModel = asString(src.youtubeSubgen.whisperModel);
|
||||
if (whisperModel !== undefined) {
|
||||
resolved.youtubeSubgen.whisperModel = whisperModel;
|
||||
} else if (src.youtubeSubgen.whisperModel !== undefined) {
|
||||
warn(
|
||||
"youtubeSubgen.whisperModel",
|
||||
src.youtubeSubgen.whisperModel,
|
||||
resolved.youtubeSubgen.whisperModel,
|
||||
"Expected string.",
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(src.youtubeSubgen.primarySubLanguages)) {
|
||||
resolved.youtubeSubgen.primarySubLanguages =
|
||||
src.youtubeSubgen.primarySubLanguages.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
} else if (src.youtubeSubgen.primarySubLanguages !== undefined) {
|
||||
warn(
|
||||
"youtubeSubgen.primarySubLanguages",
|
||||
src.youtubeSubgen.primarySubLanguages,
|
||||
resolved.youtubeSubgen.primarySubLanguages,
|
||||
"Expected string array.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (asBoolean(src.auto_start_overlay) !== undefined) {
|
||||
resolved.auto_start_overlay = src.auto_start_overlay as boolean;
|
||||
}
|
||||
|
||||
if (asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined) {
|
||||
resolved.bind_visible_overlay_to_mpv_sub_visibility =
|
||||
src.bind_visible_overlay_to_mpv_sub_visibility as boolean;
|
||||
} else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) {
|
||||
warn(
|
||||
"bind_visible_overlay_to_mpv_sub_visibility",
|
||||
src.bind_visible_overlay_to_mpv_sub_visibility,
|
||||
resolved.bind_visible_overlay_to_mpv_sub_visibility,
|
||||
"Expected boolean.",
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.subtitleStyle)) {
|
||||
resolved.subtitleStyle = {
|
||||
...resolved.subtitleStyle,
|
||||
...(src.subtitleStyle as ResolvedConfig["subtitleStyle"]),
|
||||
secondary: {
|
||||
...resolved.subtitleStyle.secondary,
|
||||
...(isObject(src.subtitleStyle.secondary)
|
||||
? (src.subtitleStyle
|
||||
.secondary as ResolvedConfig["subtitleStyle"]["secondary"])
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isObject(src.ankiConnect)) {
|
||||
const ac = src.ankiConnect;
|
||||
const aiSource = isObject(ac.ai)
|
||||
? ac.ai
|
||||
: isObject(ac.openRouter)
|
||||
? ac.openRouter
|
||||
: {};
|
||||
resolved.ankiConnect = {
|
||||
...resolved.ankiConnect,
|
||||
...(isObject(ac) ? (ac as Partial<ResolvedConfig["ankiConnect"]>) : {}),
|
||||
fields: {
|
||||
...resolved.ankiConnect.fields,
|
||||
...(isObject(ac.fields)
|
||||
? (ac.fields as ResolvedConfig["ankiConnect"]["fields"])
|
||||
: {}),
|
||||
},
|
||||
ai: {
|
||||
...resolved.ankiConnect.ai,
|
||||
...(aiSource as ResolvedConfig["ankiConnect"]["ai"]),
|
||||
},
|
||||
media: {
|
||||
...resolved.ankiConnect.media,
|
||||
...(isObject(ac.media)
|
||||
? (ac.media as ResolvedConfig["ankiConnect"]["media"])
|
||||
: {}),
|
||||
},
|
||||
behavior: {
|
||||
...resolved.ankiConnect.behavior,
|
||||
...(isObject(ac.behavior)
|
||||
? (ac.behavior as ResolvedConfig["ankiConnect"]["behavior"])
|
||||
: {}),
|
||||
},
|
||||
metadata: {
|
||||
...resolved.ankiConnect.metadata,
|
||||
...(isObject(ac.metadata)
|
||||
? (ac.metadata as ResolvedConfig["ankiConnect"]["metadata"])
|
||||
: {}),
|
||||
},
|
||||
isLapis: {
|
||||
...resolved.ankiConnect.isLapis,
|
||||
...(isObject(ac.isLapis)
|
||||
? (ac.isLapis as ResolvedConfig["ankiConnect"]["isLapis"])
|
||||
: {}),
|
||||
},
|
||||
isKiku: {
|
||||
...resolved.ankiConnect.isKiku,
|
||||
...(isObject(ac.isKiku)
|
||||
? (ac.isKiku as ResolvedConfig["ankiConnect"]["isKiku"])
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
const legacy = ac as Record<string, unknown>;
|
||||
const mapLegacy = (
|
||||
key: string,
|
||||
apply: (value: unknown) => void,
|
||||
): void => {
|
||||
if (legacy[key] !== undefined) apply(legacy[key]);
|
||||
};
|
||||
|
||||
mapLegacy("audioField", (value) => {
|
||||
resolved.ankiConnect.fields.audio = value as string;
|
||||
});
|
||||
mapLegacy("imageField", (value) => {
|
||||
resolved.ankiConnect.fields.image = value as string;
|
||||
});
|
||||
mapLegacy("sentenceField", (value) => {
|
||||
resolved.ankiConnect.fields.sentence = value as string;
|
||||
});
|
||||
mapLegacy("miscInfoField", (value) => {
|
||||
resolved.ankiConnect.fields.miscInfo = value as string;
|
||||
});
|
||||
mapLegacy("miscInfoPattern", (value) => {
|
||||
resolved.ankiConnect.metadata.pattern = value as string;
|
||||
});
|
||||
mapLegacy("generateAudio", (value) => {
|
||||
resolved.ankiConnect.media.generateAudio = value as boolean;
|
||||
});
|
||||
mapLegacy("generateImage", (value) => {
|
||||
resolved.ankiConnect.media.generateImage = value as boolean;
|
||||
});
|
||||
mapLegacy("imageType", (value) => {
|
||||
resolved.ankiConnect.media.imageType = value as "static" | "avif";
|
||||
});
|
||||
mapLegacy("imageFormat", (value) => {
|
||||
resolved.ankiConnect.media.imageFormat = value as
|
||||
| "jpg"
|
||||
| "png"
|
||||
| "webp";
|
||||
});
|
||||
mapLegacy("imageQuality", (value) => {
|
||||
resolved.ankiConnect.media.imageQuality = value as number;
|
||||
});
|
||||
mapLegacy("imageMaxWidth", (value) => {
|
||||
resolved.ankiConnect.media.imageMaxWidth = value as number;
|
||||
});
|
||||
mapLegacy("imageMaxHeight", (value) => {
|
||||
resolved.ankiConnect.media.imageMaxHeight = value as number;
|
||||
});
|
||||
mapLegacy("animatedFps", (value) => {
|
||||
resolved.ankiConnect.media.animatedFps = value as number;
|
||||
});
|
||||
mapLegacy("animatedMaxWidth", (value) => {
|
||||
resolved.ankiConnect.media.animatedMaxWidth = value as number;
|
||||
});
|
||||
mapLegacy("animatedMaxHeight", (value) => {
|
||||
resolved.ankiConnect.media.animatedMaxHeight = value as number;
|
||||
});
|
||||
mapLegacy("animatedCrf", (value) => {
|
||||
resolved.ankiConnect.media.animatedCrf = value as number;
|
||||
});
|
||||
mapLegacy("audioPadding", (value) => {
|
||||
resolved.ankiConnect.media.audioPadding = value as number;
|
||||
});
|
||||
mapLegacy("fallbackDuration", (value) => {
|
||||
resolved.ankiConnect.media.fallbackDuration = value as number;
|
||||
});
|
||||
mapLegacy("maxMediaDuration", (value) => {
|
||||
resolved.ankiConnect.media.maxMediaDuration = value as number;
|
||||
});
|
||||
mapLegacy("overwriteAudio", (value) => {
|
||||
resolved.ankiConnect.behavior.overwriteAudio = value as boolean;
|
||||
});
|
||||
mapLegacy("overwriteImage", (value) => {
|
||||
resolved.ankiConnect.behavior.overwriteImage = value as boolean;
|
||||
});
|
||||
mapLegacy("mediaInsertMode", (value) => {
|
||||
resolved.ankiConnect.behavior.mediaInsertMode = value as
|
||||
| "append"
|
||||
| "prepend";
|
||||
});
|
||||
mapLegacy("highlightWord", (value) => {
|
||||
resolved.ankiConnect.behavior.highlightWord = value as boolean;
|
||||
});
|
||||
mapLegacy("notificationType", (value) => {
|
||||
resolved.ankiConnect.behavior.notificationType = value as
|
||||
| "osd"
|
||||
| "system"
|
||||
| "both"
|
||||
| "none";
|
||||
});
|
||||
mapLegacy("autoUpdateNewCards", (value) => {
|
||||
resolved.ankiConnect.behavior.autoUpdateNewCards = value as boolean;
|
||||
});
|
||||
|
||||
if (
|
||||
resolved.ankiConnect.isKiku.fieldGrouping !== "auto" &&
|
||||
resolved.ankiConnect.isKiku.fieldGrouping !== "manual" &&
|
||||
resolved.ankiConnect.isKiku.fieldGrouping !== "disabled"
|
||||
) {
|
||||
warn(
|
||||
"ankiConnect.isKiku.fieldGrouping",
|
||||
resolved.ankiConnect.isKiku.fieldGrouping,
|
||||
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
|
||||
"Expected auto, manual, or disabled.",
|
||||
);
|
||||
resolved.ankiConnect.isKiku.fieldGrouping =
|
||||
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
|
||||
}
|
||||
}
|
||||
|
||||
return { resolved, warnings };
|
||||
}
|
||||
}
|
||||
78
src/config/template.ts
Normal file
78
src/config/template.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ResolvedConfig } from "../types";
|
||||
import {
|
||||
CONFIG_TEMPLATE_SECTIONS,
|
||||
DEFAULT_CONFIG,
|
||||
deepCloneConfig,
|
||||
} from "./definitions";
|
||||
|
||||
function renderValue(value: unknown, indent = 0): string {
|
||||
const pad = " ".repeat(indent);
|
||||
const nextPad = " ".repeat(indent + 2);
|
||||
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return "[]";
|
||||
const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2)}`);
|
||||
return `\n${items.join(",\n")}\n${pad}`.replace(/^/, "[").concat("]");
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>).filter(
|
||||
([, child]) => child !== undefined,
|
||||
);
|
||||
if (entries.length === 0) return "{}";
|
||||
const lines = entries.map(
|
||||
([key, child]) => `${nextPad}${JSON.stringify(key)}: ${renderValue(child, indent + 2)}`,
|
||||
);
|
||||
return `\n${lines.join(",\n")}\n${pad}`.replace(/^/, "{").concat("}");
|
||||
}
|
||||
|
||||
return "null";
|
||||
}
|
||||
|
||||
function renderSection(
|
||||
key: keyof ResolvedConfig,
|
||||
value: unknown,
|
||||
isLast: boolean,
|
||||
comments: string[],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(" // ==========================================");
|
||||
for (const comment of comments) {
|
||||
lines.push(` // ${comment}`);
|
||||
}
|
||||
lines.push(" // ==========================================");
|
||||
lines.push(` ${JSON.stringify(key)}: ${renderValue(value, 2)}${isLast ? "" : ","}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function generateConfigTemplate(config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG)): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("/**");
|
||||
lines.push(" * SubMiner Example Configuration File");
|
||||
lines.push(" *");
|
||||
lines.push(" * This file is auto-generated from src/config/definitions.ts.");
|
||||
lines.push(" * Copy to ~/.config/SubMiner/config.jsonc and edit as needed.");
|
||||
lines.push(" */");
|
||||
lines.push("{");
|
||||
|
||||
CONFIG_TEMPLATE_SECTIONS.forEach((section, index) => {
|
||||
lines.push("");
|
||||
const comments = [section.title, ...section.description, ...(section.notes ?? [])];
|
||||
lines.push(
|
||||
renderSection(
|
||||
section.key,
|
||||
config[section.key],
|
||||
index === CONFIG_TEMPLATE_SECTIONS.length - 1,
|
||||
comments,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
lines.push("}");
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user