mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
06d0e3ed18
- Pass --macos-menu-shortcuts=no on Darwin so SubMiner bindings reach mpv - Replace queued IPC listener with latest-value variant for subtitle channels - Skip JSONC line/block comments in duplicate-key offset helpers - Preserve configured Anki note model name in selectPreferredNoteFieldModelName - Guard known-words deck rename against collision; add chooseKnownWordsDeckRenameValue - Apply asCssColor on hover token CSS compat reads
347 lines
9.5 KiB
TypeScript
347 lines
9.5 KiB
TypeScript
import {
|
|
applyEdits,
|
|
modify,
|
|
parse as parseJsonc,
|
|
parseTree as parseJsoncTree,
|
|
type Edit,
|
|
type Node as JsoncNode,
|
|
type FormattingOptions,
|
|
type ParseError,
|
|
} from 'jsonc-parser';
|
|
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
|
import type {
|
|
ConfigSettingsField,
|
|
ConfigSettingsPatchOperation,
|
|
ConfigSettingsSnapshot,
|
|
} from '../../types/settings';
|
|
import { resolveConfig } from '../resolve';
|
|
import { getConfigValueAtPath } from './registry';
|
|
|
|
const JSONC_FORMATTING_OPTIONS: FormattingOptions = {
|
|
insertSpaces: true,
|
|
tabSize: 2,
|
|
eol: '\n',
|
|
};
|
|
|
|
export type ConfigSettingsPatchApplyResult =
|
|
| {
|
|
ok: true;
|
|
content: string;
|
|
rawConfig: RawConfig;
|
|
resolvedConfig: ResolvedConfig;
|
|
warnings: ConfigValidationWarning[];
|
|
}
|
|
| {
|
|
ok: false;
|
|
content: string;
|
|
warnings: ConfigValidationWarning[];
|
|
error: string;
|
|
};
|
|
|
|
interface ApplyConfigSettingsPatchOptions {
|
|
content: string;
|
|
operations: ConfigSettingsPatchOperation[];
|
|
previousWarnings: ConfigValidationWarning[];
|
|
}
|
|
|
|
interface BuildConfigSettingsSnapshotOptions {
|
|
configPath: string;
|
|
rawConfig: RawConfig;
|
|
resolvedConfig: ResolvedConfig;
|
|
warnings: ConfigValidationWarning[];
|
|
fields: ConfigSettingsField[];
|
|
}
|
|
|
|
function pathToSegments(path: string): string[] {
|
|
return path.split('.').filter(Boolean);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function pathStartsWith(path: string, prefix: string): boolean {
|
|
return path === prefix || path.startsWith(`${prefix}.`);
|
|
}
|
|
|
|
function warningBelongsToModifiedPath(
|
|
warning: ConfigValidationWarning,
|
|
operation: ConfigSettingsPatchOperation,
|
|
): boolean {
|
|
return (
|
|
pathStartsWith(warning.path, operation.path) || pathStartsWith(operation.path, warning.path)
|
|
);
|
|
}
|
|
|
|
function warningIdentity(warning: ConfigValidationWarning): string {
|
|
return `${warning.path}\n${JSON.stringify(warning.value)}\n${warning.message}`;
|
|
}
|
|
|
|
function parseRawConfig(content: string): RawConfig {
|
|
const errors: ParseError[] = [];
|
|
const parsed = parseJsonc(content || '{}', errors, {
|
|
allowTrailingComma: true,
|
|
disallowComments: false,
|
|
});
|
|
if (errors.length > 0) {
|
|
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
|
|
}
|
|
return isRecord(parsed) ? (parsed as RawConfig) : {};
|
|
}
|
|
|
|
function normalizeContent(content: string): string {
|
|
return content.trim().length > 0 ? content : '{}\n';
|
|
}
|
|
|
|
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
|
|
content = removeDuplicatePropertiesAlongPath(content, operation.path);
|
|
const edits = modify(
|
|
content,
|
|
pathToSegments(operation.path),
|
|
operation.op === 'reset' ? undefined : operation.value,
|
|
{
|
|
formattingOptions: JSONC_FORMATTING_OPTIONS,
|
|
getInsertionIndex: (properties) => properties.length,
|
|
},
|
|
);
|
|
return applyEdits(content, edits);
|
|
}
|
|
|
|
function propertyKey(propertyNode: JsoncNode): string | undefined {
|
|
return propertyNode.children?.[0]?.value;
|
|
}
|
|
|
|
function propertyValue(propertyNode: JsoncNode): JsoncNode | undefined {
|
|
return propertyNode.children?.[1];
|
|
}
|
|
|
|
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
|
|
return node?.type === 'object' ? (node.children ?? []) : [];
|
|
}
|
|
|
|
function isWhitespace(value: string | undefined): boolean {
|
|
return value === ' ' || value === '\t' || value === '\r' || value === '\n';
|
|
}
|
|
|
|
function nextNonWhitespaceOffset(content: string, offset: number): number {
|
|
let index = offset;
|
|
while (index < content.length) {
|
|
if (isWhitespace(content[index])) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (content[index] === '/' && content[index + 1] === '/') {
|
|
index += 2;
|
|
while (index < content.length && content[index] !== '\n') index += 1;
|
|
continue;
|
|
}
|
|
if (content[index] === '/' && content[index + 1] === '*') {
|
|
index += 2;
|
|
while (
|
|
index + 1 < content.length &&
|
|
!(content[index] === '*' && content[index + 1] === '/')
|
|
) {
|
|
index += 1;
|
|
}
|
|
index = Math.min(content.length, index + 2);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return index;
|
|
}
|
|
|
|
function previousNonWhitespaceOffset(content: string, offset: number): number {
|
|
let index = offset;
|
|
while (index >= 0) {
|
|
if (isWhitespace(content[index])) {
|
|
index -= 1;
|
|
continue;
|
|
}
|
|
const lineStart = content.lastIndexOf('\n', index) + 1;
|
|
const linePrefix = content.slice(lineStart, index + 1);
|
|
const lineCommentStart = linePrefix.lastIndexOf('//');
|
|
if (lineCommentStart >= 0 && /^[ \t]*$/.test(linePrefix.slice(0, lineCommentStart))) {
|
|
index = lineStart - 1;
|
|
continue;
|
|
}
|
|
if (content[index] === '/' && content[index - 1] === '*') {
|
|
index -= 2;
|
|
while (index > 0 && !(content[index - 1] === '/' && content[index] === '*')) {
|
|
index -= 1;
|
|
}
|
|
index -= 2;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return index;
|
|
}
|
|
|
|
function lineStartOffset(content: string, offset: number): number {
|
|
return content.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
|
|
}
|
|
|
|
function removalEditForProperty(content: string, propertyNode: JsoncNode): Edit {
|
|
let offset = propertyNode.offset;
|
|
let end = propertyNode.offset + propertyNode.length;
|
|
const next = nextNonWhitespaceOffset(content, end);
|
|
|
|
if (content[next] === ',') {
|
|
end = next + 1;
|
|
const lineStart = lineStartOffset(content, offset);
|
|
if (/^[ \t]*$/.test(content.slice(lineStart, offset))) {
|
|
offset = lineStart;
|
|
}
|
|
} else {
|
|
const previous = previousNonWhitespaceOffset(content, offset - 1);
|
|
if (content[previous] === ',') {
|
|
offset = previous;
|
|
}
|
|
}
|
|
|
|
return {
|
|
offset,
|
|
length: Math.max(0, end - offset),
|
|
content: '',
|
|
};
|
|
}
|
|
|
|
function collectDuplicatePropertyRemovalEdits(content: string, path: string): Edit[] {
|
|
const errors: ParseError[] = [];
|
|
let node = parseJsoncTree(content, errors, {
|
|
allowTrailingComma: true,
|
|
disallowComments: false,
|
|
});
|
|
if (!node || errors.length > 0) {
|
|
return [];
|
|
}
|
|
|
|
const edits: Edit[] = [];
|
|
for (const segment of pathToSegments(path)) {
|
|
const matches = objectProperties(node).filter((property) => propertyKey(property) === segment);
|
|
if (matches.length === 0) {
|
|
break;
|
|
}
|
|
|
|
for (const duplicate of matches.slice(0, -1)) {
|
|
edits.push(removalEditForProperty(content, duplicate));
|
|
}
|
|
|
|
node = propertyValue(matches[matches.length - 1]!);
|
|
}
|
|
|
|
return edits;
|
|
}
|
|
|
|
function applyRemovalEdits(content: string, edits: Edit[]): string {
|
|
return [...edits]
|
|
.sort((left, right) => right.offset - left.offset)
|
|
.reduce(
|
|
(current, edit) =>
|
|
`${current.slice(0, edit.offset)}${edit.content}${current.slice(edit.offset + edit.length)}`,
|
|
content,
|
|
);
|
|
}
|
|
|
|
function removeDuplicatePropertiesAlongPath(content: string, path: string): string {
|
|
const edits = collectDuplicatePropertyRemovalEdits(content, path);
|
|
return edits.length > 0 ? applyRemovalEdits(content, edits) : content;
|
|
}
|
|
|
|
function collectModifiedWarnings(
|
|
warnings: ConfigValidationWarning[],
|
|
operations: ConfigSettingsPatchOperation[],
|
|
previousWarnings: ConfigValidationWarning[],
|
|
): ConfigValidationWarning[] {
|
|
const previous = new Set(previousWarnings.map(warningIdentity));
|
|
return warnings.filter((warning) => {
|
|
if (!operations.some((operation) => warningBelongsToModifiedPath(warning, operation))) {
|
|
return false;
|
|
}
|
|
return !previous.has(warningIdentity(warning));
|
|
});
|
|
}
|
|
|
|
export function applyConfigSettingsPatchToContent(
|
|
options: ApplyConfigSettingsPatchOptions,
|
|
): ConfigSettingsPatchApplyResult {
|
|
let content = normalizeContent(options.content);
|
|
|
|
try {
|
|
parseRawConfig(content);
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
content,
|
|
warnings: [],
|
|
error: error instanceof Error ? error.message : 'Invalid JSONC.',
|
|
};
|
|
}
|
|
|
|
try {
|
|
for (const operation of options.operations) {
|
|
content = applySingleOperation(content, operation);
|
|
}
|
|
|
|
const rawConfig = parseRawConfig(content);
|
|
const { resolved, warnings } = resolveConfig(rawConfig);
|
|
const modifiedWarnings = collectModifiedWarnings(
|
|
warnings,
|
|
options.operations,
|
|
options.previousWarnings,
|
|
);
|
|
if (modifiedWarnings.length > 0) {
|
|
return {
|
|
ok: false,
|
|
content,
|
|
warnings: modifiedWarnings,
|
|
error: 'One or more modified settings failed validation.',
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
content,
|
|
rawConfig,
|
|
resolvedConfig: resolved,
|
|
warnings,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
content,
|
|
warnings: [],
|
|
error: error instanceof Error ? error.message : 'Failed to update config content.',
|
|
};
|
|
}
|
|
}
|
|
|
|
export function buildConfigSettingsSnapshot(
|
|
options: BuildConfigSettingsSnapshotOptions,
|
|
): ConfigSettingsSnapshot {
|
|
const values: Record<string, unknown> = {};
|
|
|
|
for (const field of options.fields) {
|
|
const rawValue = getConfigValueAtPath(options.rawConfig, field.configPath);
|
|
const resolvedValue = getConfigValueAtPath(options.resolvedConfig, field.configPath);
|
|
if (field.secret) {
|
|
values[field.configPath] = {
|
|
configured:
|
|
(typeof rawValue === 'string' && rawValue.length > 0) ||
|
|
(typeof resolvedValue === 'string' && resolvedValue.length > 0),
|
|
};
|
|
continue;
|
|
}
|
|
|
|
values[field.configPath] = structuredClone(rawValue != null ? rawValue : resolvedValue);
|
|
}
|
|
|
|
return {
|
|
configPath: options.configPath,
|
|
fields: options.fields,
|
|
values,
|
|
warnings: options.warnings,
|
|
};
|
|
}
|