mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-11 15:13:33 -07:00
feat(config): add configuration window (#70)
This commit is contained in:
@@ -48,3 +48,144 @@ test('AnkiConnectClient includes action name in retry logs', async () => {
|
||||
console.info = originalInfo;
|
||||
}
|
||||
});
|
||||
|
||||
test('AnkiConnectClient lists decks and note type fields', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
if (body.action === 'deckNames') {
|
||||
return { data: { result: ['Core', 'Mining'], error: null } };
|
||||
}
|
||||
if (body.action === 'modelNames') {
|
||||
return { data: { result: ['Japanese sentences'], error: null } };
|
||||
}
|
||||
if (body.action === 'modelFieldNames') {
|
||||
return { data: { result: ['Expression', 'Sentence'], error: null } };
|
||||
}
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
const typedClient = client as unknown as AnkiConnectClient;
|
||||
|
||||
assert.deepEqual(await typedClient.deckNames(), ['Core', 'Mining']);
|
||||
assert.deepEqual(await typedClient.modelNames(), ['Japanese sentences']);
|
||||
assert.deepEqual(await typedClient.modelFieldNames('Japanese sentences'), [
|
||||
'Expression',
|
||||
'Sentence',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.action),
|
||||
['deckNames', 'modelNames', 'modelFieldNames'],
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient derives field names from sampled notes in a deck', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
if (body.action === 'findNotes') {
|
||||
return { data: { result: [3, 1, 2], error: null } };
|
||||
}
|
||||
if (body.action === 'notesInfo') {
|
||||
return {
|
||||
data: {
|
||||
result: [
|
||||
{ fields: { Sentence: { value: 'x' }, Expression: { value: 'y' } } },
|
||||
{ fields: { Reading: { value: 'z' } } },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining "Current"'),
|
||||
['Expression', 'Reading', 'Sentence'],
|
||||
);
|
||||
assert.deepEqual(calls[0], {
|
||||
action: 'findNotes',
|
||||
params: { query: 'deck:"Mining \\"Current\\""' },
|
||||
});
|
||||
assert.deepEqual(calls[1], {
|
||||
action: 'notesInfo',
|
||||
params: { notes: [3, 1, 2] },
|
||||
});
|
||||
});
|
||||
|
||||
test('AnkiConnectClient treats negative deck note sample sizes as empty samples', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
if (body.action === 'findNotes') {
|
||||
return { data: { result: [3, 1, 2], error: null } };
|
||||
}
|
||||
if (body.action === 'notesInfo') {
|
||||
return {
|
||||
data: {
|
||||
result: [{ fields: { Sentence: { value: 'x' } } }],
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining', -1),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.action),
|
||||
['findNotes'],
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient derives model names from sampled notes in a deck', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
if (body.action === 'findNotes') {
|
||||
return { data: { result: [5, 4], error: null } };
|
||||
}
|
||||
if (body.action === 'notesInfo') {
|
||||
return {
|
||||
data: {
|
||||
result: [{ modelName: 'Lapis Morph' }, { modelName: 'Kiku' }],
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(await (client as unknown as AnkiConnectClient).modelNamesForDeck('Mining'), [
|
||||
'Kiku',
|
||||
'Lapis Morph',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.action),
|
||||
['findNotes', 'notesInfo'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -156,6 +156,76 @@ export class AnkiConnectClient {
|
||||
return (result as number[]) || [];
|
||||
}
|
||||
|
||||
async deckNames(): Promise<string[]> {
|
||||
const result = await this.invoke('deckNames');
|
||||
return Array.isArray(result)
|
||||
? result.filter((value): value is string => typeof value === 'string').sort()
|
||||
: [];
|
||||
}
|
||||
|
||||
async modelNames(): Promise<string[]> {
|
||||
const result = await this.invoke('modelNames');
|
||||
return Array.isArray(result)
|
||||
? result.filter((value): value is string => typeof value === 'string').sort()
|
||||
: [];
|
||||
}
|
||||
|
||||
async modelFieldNames(modelName: string): Promise<string[]> {
|
||||
const result = await this.invoke('modelFieldNames', { modelName });
|
||||
return Array.isArray(result)
|
||||
? result.filter((value): value is string => typeof value === 'string').sort()
|
||||
: [];
|
||||
}
|
||||
|
||||
private async noteInfosForDeck(
|
||||
deckName: string,
|
||||
sampleSize = 100,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const escapedDeckName = deckName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const noteIds = await this.findNotes(`deck:"${escapedDeckName}"`, { maxRetries: 0 });
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const finiteSampleSize = Number.isFinite(sampleSize) ? sampleSize : 0;
|
||||
const normalizedSampleSize = Math.min(
|
||||
noteIds.length,
|
||||
Math.max(0, Math.floor(finiteSampleSize)),
|
||||
);
|
||||
if (normalizedSampleSize === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.notesInfo(noteIds.slice(0, normalizedSampleSize));
|
||||
}
|
||||
|
||||
async fieldNamesForDeck(deckName: string, sampleSize = 100): Promise<string[]> {
|
||||
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
|
||||
const fields = new Set<string>();
|
||||
for (const noteInfo of noteInfos) {
|
||||
const noteFields = noteInfo.fields;
|
||||
if (!noteFields || typeof noteFields !== 'object' || Array.isArray(noteFields)) {
|
||||
continue;
|
||||
}
|
||||
for (const fieldName of Object.keys(noteFields)) {
|
||||
fields.add(fieldName);
|
||||
}
|
||||
}
|
||||
return [...fields].sort();
|
||||
}
|
||||
|
||||
async modelNamesForDeck(deckName: string, sampleSize = 100): Promise<string[]> {
|
||||
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
|
||||
const modelNames = new Set<string>();
|
||||
for (const noteInfo of noteInfos) {
|
||||
const modelName = noteInfo.modelName;
|
||||
if (typeof modelName === 'string' && modelName.length > 0) {
|
||||
modelNames.add(modelName);
|
||||
}
|
||||
}
|
||||
return [...modelNames].sort();
|
||||
}
|
||||
|
||||
async notesInfo(noteIds: number[]): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.invoke('notesInfo', { notes: noteIds });
|
||||
return (result as Record<string, unknown>[]) || [];
|
||||
|
||||
@@ -277,7 +277,8 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
|
||||
private isKnownWordCacheEnabled(): boolean {
|
||||
return this.deps.getConfig().knownWords?.highlightEnabled === true;
|
||||
const config = this.deps.getConfig();
|
||||
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
|
||||
}
|
||||
|
||||
private shouldAddMinedWordsImmediately(): boolean {
|
||||
|
||||
@@ -157,7 +157,8 @@ export class AnkiIntegrationRuntime {
|
||||
}
|
||||
|
||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
||||
const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
|
||||
const wasKnownWordCacheEnabled =
|
||||
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
|
||||
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
|
||||
? this.getKnownWordCacheLifecycleConfig(this.config)
|
||||
: null;
|
||||
@@ -207,7 +208,8 @@ export class AnkiIntegrationRuntime {
|
||||
};
|
||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||
this.deps.onConfigChanged?.(this.config);
|
||||
const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
|
||||
const nextKnownWordCacheEnabled =
|
||||
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
|
||||
|
||||
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
|
||||
if (this.started) {
|
||||
|
||||
+31
-20
@@ -7,7 +7,7 @@ import {
|
||||
isHeadlessInitialCommand,
|
||||
isStandaloneTexthookerCommand,
|
||||
parseArgs,
|
||||
shouldRunSettingsOnlyStartup,
|
||||
shouldRunYomitanOnlyStartup,
|
||||
shouldStartApp,
|
||||
} from './args';
|
||||
|
||||
@@ -66,7 +66,7 @@ test('parseArgs captures update command and internal launcher paths', () => {
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
assert.equal(commandNeedsOverlayRuntime(args), false);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(args), false);
|
||||
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
||||
});
|
||||
|
||||
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
|
||||
@@ -94,6 +94,7 @@ test('parseArgs captures youtube startup forwarding flags', () => {
|
||||
test('parseArgs captures session action forwarding flags', () => {
|
||||
const args = parseArgs([
|
||||
'--toggle-stats-overlay',
|
||||
'--mark-watched',
|
||||
'--open-jimaku',
|
||||
'--open-youtube-picker',
|
||||
'--open-playlist-browser',
|
||||
@@ -110,6 +111,7 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
]);
|
||||
|
||||
assert.equal(args.toggleStatsOverlay, true);
|
||||
assert.equal(args.markWatched, true);
|
||||
assert.equal(args.openJimaku, true);
|
||||
assert.equal(args.openYoutubePicker, true);
|
||||
assert.equal(args.openPlaylistBrowser, true);
|
||||
@@ -206,35 +208,38 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(shouldStartApp(update), true);
|
||||
assert.equal(isHeadlessInitialCommand(update), true);
|
||||
|
||||
const yomitan = parseArgs(['--yomitan']);
|
||||
assert.equal(yomitan.yomitan, true);
|
||||
assert.equal(hasExplicitCommand(yomitan), true);
|
||||
assert.equal(shouldStartApp(yomitan), true);
|
||||
assert.equal(shouldRunYomitanOnlyStartup(yomitan), true);
|
||||
|
||||
const settings = parseArgs(['--settings']);
|
||||
assert.equal(settings.settings, true);
|
||||
assert.equal(hasExplicitCommand(settings), true);
|
||||
assert.equal(shouldStartApp(settings), true);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
|
||||
assert.equal(shouldRunYomitanOnlyStartup(settings), false);
|
||||
assert.equal(commandNeedsOverlayRuntime(settings), false);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(settings), false);
|
||||
|
||||
const configSettings = parseArgs(['--config']);
|
||||
assert.equal(configSettings.configSettings, true);
|
||||
assert.equal(hasExplicitCommand(configSettings), true);
|
||||
assert.equal(shouldStartApp(configSettings), true);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(configSettings), false);
|
||||
assert.equal(commandNeedsOverlayRuntime(configSettings), false);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false);
|
||||
const yomitanWithOverlay = parseArgs(['--yomitan', '--toggle-visible-overlay']);
|
||||
assert.equal(yomitanWithOverlay.yomitan, true);
|
||||
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
|
||||
assert.equal(shouldRunYomitanOnlyStartup(yomitanWithOverlay), false);
|
||||
|
||||
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
|
||||
assert.equal(settingsWithOverlay.settings, true);
|
||||
assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(settingsWithOverlay), false);
|
||||
|
||||
const yomitanAlias = parseArgs(['--yomitan']);
|
||||
assert.equal(yomitanAlias.settings, true);
|
||||
assert.equal(hasExplicitCommand(yomitanAlias), true);
|
||||
assert.equal(shouldStartApp(yomitanAlias), true);
|
||||
const settingsDoesNotEnableYomitan = parseArgs(['--settings']);
|
||||
assert.equal(settingsDoesNotEnableYomitan.yomitan, false);
|
||||
|
||||
const help = parseArgs(['--help']);
|
||||
assert.equal(help.help, true);
|
||||
assert.equal(hasExplicitCommand(help), true);
|
||||
assert.equal(shouldStartApp(help), false);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(help), false);
|
||||
assert.equal(shouldRunYomitanOnlyStartup(help), false);
|
||||
|
||||
const appPing = parseArgs(['--app-ping']);
|
||||
assert.equal(appPing.appPing, true);
|
||||
assert.equal(hasExplicitCommand(appPing), true);
|
||||
assert.equal(shouldStartApp(appPing), false);
|
||||
|
||||
const youtubePlay = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true);
|
||||
@@ -280,6 +285,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
||||
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
||||
|
||||
const markWatched = parseArgs(['--mark-watched']);
|
||||
assert.equal(markWatched.markWatched, true);
|
||||
assert.equal(hasExplicitCommand(markWatched), true);
|
||||
assert.equal(shouldStartApp(markWatched), true);
|
||||
assert.equal(commandNeedsOverlayRuntime(markWatched), true);
|
||||
|
||||
const dictionary = parseArgs(['--dictionary']);
|
||||
assert.equal(dictionary.dictionary, true);
|
||||
assert.equal(hasExplicitCommand(dictionary), true);
|
||||
|
||||
+24
-10
@@ -10,8 +10,8 @@ export interface CliArgs {
|
||||
toggle: boolean;
|
||||
toggleVisibleOverlay: boolean;
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
yomitan: boolean;
|
||||
settings: boolean;
|
||||
configSettings: boolean;
|
||||
setup: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
@@ -28,6 +28,7 @@ export interface CliArgs {
|
||||
triggerSubsync: boolean;
|
||||
markAudioCard: boolean;
|
||||
toggleStatsOverlay: boolean;
|
||||
markWatched: boolean;
|
||||
toggleSubtitleSidebar: boolean;
|
||||
openRuntimeOptions: boolean;
|
||||
openSessionHelp: boolean;
|
||||
@@ -74,6 +75,7 @@ export interface CliArgs {
|
||||
texthooker: boolean;
|
||||
texthookerOpenBrowser: boolean;
|
||||
help: boolean;
|
||||
appPing?: boolean;
|
||||
update?: boolean;
|
||||
updateLauncherPath?: string;
|
||||
updateResponsePath?: string;
|
||||
@@ -115,8 +117,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -133,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
markWatched: false,
|
||||
toggleSubtitleSidebar: false,
|
||||
openRuntimeOptions: false,
|
||||
openSessionHelp: false,
|
||||
@@ -172,6 +175,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
help: false,
|
||||
appPing: false,
|
||||
update: false,
|
||||
updateLauncherPath: undefined,
|
||||
updateResponsePath: undefined,
|
||||
@@ -235,8 +239,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle') args.toggle = true;
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
|
||||
else if (arg === '--config') args.configSettings = true;
|
||||
else if (arg === '--yomitan') args.yomitan = true;
|
||||
else if (arg === '--settings') args.settings = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
else if (arg === '--show') args.show = true;
|
||||
else if (arg === '--hide') args.hide = true;
|
||||
@@ -253,6 +257,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--trigger-subsync') args.triggerSubsync = true;
|
||||
else if (arg === '--mark-audio-card') args.markAudioCard = true;
|
||||
else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = true;
|
||||
else if (arg === '--mark-watched') args.markWatched = true;
|
||||
else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
|
||||
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
|
||||
else if (arg === '--open-session-help') args.openSessionHelp = true;
|
||||
@@ -339,6 +344,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
|
||||
else if (arg === '--texthooker') args.texthooker = true;
|
||||
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
|
||||
else if (arg === '--app-ping') args.appPing = true;
|
||||
else if (arg === '--update') args.update = true;
|
||||
else if (arg.startsWith('--update-launcher-path=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
@@ -488,8 +494,8 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.configSettings ||
|
||||
args.setup ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -506,6 +512,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.markWatched ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
@@ -540,6 +547,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth ||
|
||||
args.texthooker ||
|
||||
args.appPing ||
|
||||
args.update ||
|
||||
args.generateConfig ||
|
||||
args.help
|
||||
@@ -561,8 +569,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.toggle &&
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.yomitan &&
|
||||
!args.settings &&
|
||||
!args.configSettings &&
|
||||
!args.setup &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
@@ -579,6 +587,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.triggerSubsync &&
|
||||
!args.markAudioCard &&
|
||||
!args.toggleStatsOverlay &&
|
||||
!args.markWatched &&
|
||||
!args.toggleSubtitleSidebar &&
|
||||
!args.openRuntimeOptions &&
|
||||
!args.openSessionHelp &&
|
||||
@@ -612,6 +621,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.jellyfinPlay &&
|
||||
!args.jellyfinRemoteAnnounce &&
|
||||
!args.jellyfinPreviewAuth &&
|
||||
!args.appPing &&
|
||||
!args.update &&
|
||||
!args.help &&
|
||||
!args.autoStartOverlay &&
|
||||
@@ -629,8 +639,8 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.configSettings ||
|
||||
args.setup ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
@@ -643,6 +653,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.markWatched ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
@@ -676,16 +687,16 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
return (
|
||||
args.settings &&
|
||||
args.yomitan &&
|
||||
!args.background &&
|
||||
!args.start &&
|
||||
!args.stop &&
|
||||
!args.toggle &&
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.configSettings &&
|
||||
!args.settings &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
!args.setup &&
|
||||
@@ -702,6 +713,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.triggerSubsync &&
|
||||
!args.markAudioCard &&
|
||||
!args.toggleStatsOverlay &&
|
||||
!args.markWatched &&
|
||||
!args.toggleSubtitleSidebar &&
|
||||
!args.openRuntimeOptions &&
|
||||
!args.openSessionHelp &&
|
||||
@@ -737,6 +749,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.jellyfinRemoteAnnounce &&
|
||||
!args.jellyfinPreviewAuth &&
|
||||
!args.texthooker &&
|
||||
!args.appPing &&
|
||||
!args.update &&
|
||||
!args.help &&
|
||||
!args.autoStartOverlay &&
|
||||
@@ -762,6 +775,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.updateLastCardFromClipboard ||
|
||||
args.toggleSecondarySub ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.markWatched ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
|
||||
@@ -22,7 +22,9 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
|
||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--config\s+Open configuration window/);
|
||||
assert.match(output, /--settings\s+Open SubMiner settings window/);
|
||||
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
|
||||
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
assert.match(output, /--dictionary/);
|
||||
|
||||
+3
-2
@@ -24,8 +24,8 @@ ${B}Overlay${R}
|
||||
--toggle-primary-subtitle-bar Toggle primary subtitle bar
|
||||
--show-visible-overlay Show subtitle overlay
|
||||
--hide-visible-overlay Hide subtitle overlay
|
||||
--settings Open Yomitan settings window
|
||||
--config Open configuration window
|
||||
--yomitan Open Yomitan settings window
|
||||
--settings Open SubMiner settings window
|
||||
--setup Open first-run setup window
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
@@ -39,6 +39,7 @@ ${B}Mining${R}
|
||||
--trigger-field-grouping Run Kiku field grouping
|
||||
--trigger-subsync Run subtitle sync
|
||||
--toggle-secondary-sub Cycle secondary subtitle mode
|
||||
--mark-watched Mark current video watched and advance playlist
|
||||
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
|
||||
--open-runtime-options Open runtime options palette
|
||||
--open-session-help Open session help modal
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
getNodeValue,
|
||||
parseTree as parseJsoncTree,
|
||||
type Node as JsoncNode,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
import type { RawConfig } from '../types/config';
|
||||
import type { ConfigSettingsPatchOperation } from '../types/settings';
|
||||
import { DEFAULT_CONFIG } from './definitions';
|
||||
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
|
||||
|
||||
export type LegacyAnkiConnectNPlusOneMigrationResult =
|
||||
| {
|
||||
migrated: true;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}
|
||||
| {
|
||||
migrated: false;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
};
|
||||
|
||||
const LEGACY_N_PLUS_ONE_PATH_MAP = {
|
||||
highlightEnabled: 'ankiConnect.knownWords.highlightEnabled',
|
||||
refreshMinutes: 'ankiConnect.knownWords.refreshMinutes',
|
||||
matchMode: 'ankiConnect.knownWords.matchMode',
|
||||
decks: 'ankiConnect.knownWords.decks',
|
||||
knownWord: 'subtitleStyle.knownWordColor',
|
||||
nPlusOne: 'subtitleStyle.nPlusOneColor',
|
||||
} as const;
|
||||
|
||||
const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
function propertyKey(propertyNode: JsoncNode): string | undefined {
|
||||
return propertyNode.children?.[0]?.value;
|
||||
}
|
||||
|
||||
function propertyValue(propertyNode: JsoncNode | undefined): JsoncNode | undefined {
|
||||
return propertyNode?.children?.[1];
|
||||
}
|
||||
|
||||
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
|
||||
return node?.type === 'object' ? (node.children ?? []) : [];
|
||||
}
|
||||
|
||||
function findLastProperty(node: JsoncNode | undefined, key: string): JsoncNode | undefined {
|
||||
const matches = objectProperties(node).filter((property) => propertyKey(property) === key);
|
||||
return matches.at(-1);
|
||||
}
|
||||
|
||||
function findProperties(node: JsoncNode | undefined, key: string): JsoncNode[] {
|
||||
return objectProperties(node).filter((property) => propertyKey(property) === key);
|
||||
}
|
||||
|
||||
function findValueAtPath(root: JsoncNode | undefined, path: string): JsoncNode | undefined {
|
||||
let node = root;
|
||||
for (const segment of path.split('.')) {
|
||||
node = propertyValue(findLastProperty(node, segment));
|
||||
if (!node) return undefined;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function hasPath(root: JsoncNode | undefined, path: string): boolean {
|
||||
return findValueAtPath(root, path) !== undefined;
|
||||
}
|
||||
|
||||
function normalizeLegacyDecks(value: unknown): unknown {
|
||||
if (!Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading'];
|
||||
const decks = value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const normalized: Record<string, string[]> = {};
|
||||
for (const deck of new Set(decks)) {
|
||||
normalized[deck] = defaultFields;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function asLegacyColor(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const text = value.trim();
|
||||
return hexColorPattern.test(text) ? text : undefined;
|
||||
}
|
||||
|
||||
function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
||||
operations: ConfigSettingsPatchOperation[];
|
||||
hasLegacy: boolean;
|
||||
} {
|
||||
const operations: ConfigSettingsPatchOperation[] = [];
|
||||
const ankiConnect = propertyValue(findLastProperty(root, 'ankiConnect'));
|
||||
const nPlusOneProperties = findProperties(ankiConnect, 'nPlusOne');
|
||||
const nPlusOneObjects = nPlusOneProperties.map(propertyValue).filter(Boolean) as JsoncNode[];
|
||||
const knownWords = propertyValue(findLastProperty(ankiConnect, 'knownWords'));
|
||||
const knownWordsColorNode = propertyValue(findLastProperty(knownWords, 'color'));
|
||||
const knownWordsColor = knownWordsColorNode ? getNodeValue(knownWordsColorNode) : undefined;
|
||||
|
||||
const canonicalNPlusOneValues = new Map<string, unknown>();
|
||||
const legacyValues = new Map<keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, unknown>();
|
||||
let hasLegacy = false;
|
||||
|
||||
for (const nPlusOne of nPlusOneObjects) {
|
||||
for (const property of objectProperties(nPlusOne)) {
|
||||
const key = propertyKey(property);
|
||||
if (!key) continue;
|
||||
const valueNode = propertyValue(property);
|
||||
const value = valueNode ? getNodeValue(valueNode) : undefined;
|
||||
if (key === 'enabled' || key === 'minSentenceWords') {
|
||||
canonicalNPlusOneValues.set(key, value);
|
||||
continue;
|
||||
}
|
||||
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
|
||||
hasLegacy = true;
|
||||
legacyValues.set(key as keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nPlusOneObjects.length > 1) {
|
||||
for (const [key, value] of canonicalNPlusOneValues) {
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path: `ankiConnect.nPlusOne.${key}`,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, path] of Object.entries(LEGACY_N_PLUS_ONE_PATH_MAP) as Array<
|
||||
[keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, string]
|
||||
>) {
|
||||
if (!legacyValues.has(key)) continue;
|
||||
if (!hasPath(root, path)) {
|
||||
const value =
|
||||
key === 'decks' ? normalizeLegacyDecks(legacyValues.get(key)) : legacyValues.get(key);
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path,
|
||||
value,
|
||||
});
|
||||
}
|
||||
operations.push({
|
||||
op: 'reset',
|
||||
path: `ankiConnect.nPlusOne.${key}`,
|
||||
});
|
||||
}
|
||||
|
||||
const legacyKnownWordsColor = asLegacyColor(knownWordsColor);
|
||||
if (legacyKnownWordsColor !== undefined) {
|
||||
hasLegacy = true;
|
||||
if (!hasPath(root, 'subtitleStyle.knownWordColor')) {
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.knownWordColor',
|
||||
value: legacyKnownWordsColor,
|
||||
});
|
||||
}
|
||||
operations.push({
|
||||
op: 'reset',
|
||||
path: 'ankiConnect.knownWords.color',
|
||||
});
|
||||
}
|
||||
|
||||
return { operations, hasLegacy };
|
||||
}
|
||||
|
||||
export function applyLegacyAnkiConnectNPlusOneMigrationToContent(options: {
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}): LegacyAnkiConnectNPlusOneMigrationResult {
|
||||
const errors: ParseError[] = [];
|
||||
const root = parseJsoncTree(options.content || '{}', errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
});
|
||||
if (!root || errors.length > 0) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
const { operations, hasLegacy } = buildLegacyNPlusOneMigrationOperations(root);
|
||||
if (operations.length === 0 && !hasLegacy) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: options.content,
|
||||
operations,
|
||||
previousWarnings: [],
|
||||
});
|
||||
if (!result.ok) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: true,
|
||||
content: result.content,
|
||||
rawConfig: result.rawConfig,
|
||||
};
|
||||
}
|
||||
+250
-36
@@ -12,16 +12,44 @@ import {
|
||||
} from './definitions';
|
||||
import { parseConfigContent } from './parse';
|
||||
import { generateConfigTemplate } from './template';
|
||||
import {
|
||||
buildSubtitleCssDeclarationObject,
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssPath,
|
||||
type SubtitleCssScope,
|
||||
} from '../settings/subtitle-style-css';
|
||||
|
||||
const DEFAULT_SUBTITLE_FONT_FAMILY =
|
||||
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
|
||||
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = 'Inter, Noto Sans, Helvetica Neue, sans-serif';
|
||||
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
|
||||
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)';
|
||||
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-'));
|
||||
}
|
||||
|
||||
function getValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function buildDefaultSubtitleCssDeclarations(scope: SubtitleCssScope): Record<string, string> {
|
||||
const values: Record<string, unknown> = {
|
||||
[getSubtitleCssPath(scope)]: getValueAtPath(DEFAULT_CONFIG, getSubtitleCssPath(scope)),
|
||||
};
|
||||
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
|
||||
values[path] = getValueAtPath(DEFAULT_CONFIG, path);
|
||||
}
|
||||
return buildSubtitleCssDeclarationObject(scope, values);
|
||||
}
|
||||
|
||||
test('loads defaults when config is missing', () => {
|
||||
const dir = makeTempDir();
|
||||
const service = new ConfigService(dir);
|
||||
@@ -61,7 +89,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.startupWarmups.mecab, true);
|
||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
|
||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
|
||||
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
|
||||
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
|
||||
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
|
||||
@@ -73,8 +101,9 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
||||
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
|
||||
assert.equal(config.subtitleSidebar.enabled, true);
|
||||
assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
|
||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
||||
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
|
||||
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
|
||||
assert.equal(config.subtitleStyle.fontWeight, '600');
|
||||
assert.equal(config.subtitleStyle.lineHeight, 1.35);
|
||||
@@ -83,13 +112,19 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.subtitleStyle.fontKerning, 'normal');
|
||||
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
|
||||
assert.equal(config.subtitleStyle.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
|
||||
assert.equal(config.subtitleStyle.paintOrder, '');
|
||||
assert.equal(config.subtitleStyle.WebkitTextStroke, '');
|
||||
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
|
||||
assert.equal(config.subtitleStyle.jlptColors.N4, '#8bd5ca');
|
||||
assert.equal(config.subtitleStyle.secondary.fontFamily, DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY);
|
||||
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
|
||||
assert.equal(config.subtitleStyle.secondary.fontWeight, '600');
|
||||
assert.equal(config.subtitleStyle.secondary.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
|
||||
assert.equal(config.subtitleStyle.secondary.paintOrder, '');
|
||||
assert.equal(config.subtitleStyle.secondary.WebkitTextStroke, '');
|
||||
assert.equal(config.subtitleStyle.secondary.backgroundColor, 'transparent');
|
||||
assert.deepEqual(config.subtitleSidebar.css, {});
|
||||
assert.equal(config.subtitleSidebar.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
|
||||
assert.equal(config.immersionTracking.enabled, true);
|
||||
assert.equal(config.immersionTracking.dbPath, '');
|
||||
assert.equal(config.immersionTracking.batchSize, 25);
|
||||
@@ -113,6 +148,13 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.updates.checkIntervalHours, 24);
|
||||
assert.equal(config.updates.notificationType, 'system');
|
||||
assert.equal(config.updates.channel, 'stable');
|
||||
assert.equal(config.mpv.socketPath, '/tmp/subminer-socket');
|
||||
assert.equal(config.mpv.backend, 'auto');
|
||||
assert.equal(config.mpv.autoStartSubMiner, true);
|
||||
assert.equal(config.mpv.pauseUntilOverlayReady, true);
|
||||
assert.equal(config.mpv.subminerBinaryPath, '');
|
||||
assert.equal(config.mpv.aniskipEnabled, true);
|
||||
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
|
||||
});
|
||||
|
||||
test('parses updates config and warns on invalid values', () => {
|
||||
@@ -181,6 +223,58 @@ test('throws actionable startup parse error for malformed config at construction
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves legacy subtitle appearance options without rewriting config on load', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
const originalContent = `{
|
||||
"subtitleStyle": {
|
||||
"fontSize": 42,
|
||||
"fontColor": "#ffffff",
|
||||
"hoverTokenColor": "#abcdef",
|
||||
"hoverTokenBackgroundColor": "transparent",
|
||||
"css": {
|
||||
"font-size": "44px",
|
||||
"text-wrap": "balance"
|
||||
},
|
||||
"secondary": {
|
||||
"fontSize": 28,
|
||||
"fontColor": "#bbbbbb"
|
||||
}
|
||||
},
|
||||
"subtitleSidebar": {
|
||||
"fontFamily": "M PLUS 1, sans-serif",
|
||||
"fontSize": 18,
|
||||
"textColor": "#dddddd",
|
||||
"timestampColor": "#aaaaaa",
|
||||
"css": {
|
||||
"font-size": "19px"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
|
||||
assert.deepEqual(service.getConfig().subtitleStyle.css, {
|
||||
color: '#ffffff',
|
||||
'font-size': '44px',
|
||||
'--subtitle-hover-token-color': '#abcdef',
|
||||
'--subtitle-hover-token-background-color': 'transparent',
|
||||
'text-wrap': 'balance',
|
||||
});
|
||||
assert.deepEqual(service.getConfig().subtitleStyle.secondary.css, {
|
||||
color: '#bbbbbb',
|
||||
'font-size': '28px',
|
||||
});
|
||||
assert.deepEqual(service.getConfig().subtitleSidebar.css, {
|
||||
'font-family': 'M PLUS 1, sans-serif',
|
||||
color: '#dddddd',
|
||||
'font-size': '19px',
|
||||
'--subtitle-sidebar-timestamp-color': '#aaaaaa',
|
||||
});
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -255,6 +349,70 @@ test('parses texthooker.launchAtStartup and warns on invalid values', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('parses managed mpv plugin runtime settings from config', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"mpv": {
|
||||
"socketPath": "/tmp/custom-subminer.sock",
|
||||
"backend": "x11",
|
||||
"autoStartSubMiner": false,
|
||||
"pauseUntilOverlayReady": false,
|
||||
"subminerBinaryPath": "/opt/SubMiner/SubMiner.AppImage",
|
||||
"aniskipEnabled": false,
|
||||
"aniskipButtonKey": "F8"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
const config = validService.getConfig();
|
||||
assert.equal(config.mpv.socketPath, '/tmp/custom-subminer.sock');
|
||||
assert.equal(config.mpv.backend, 'x11');
|
||||
assert.equal(config.mpv.autoStartSubMiner, false);
|
||||
assert.equal(config.mpv.pauseUntilOverlayReady, false);
|
||||
assert.equal(config.mpv.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(config.mpv.aniskipEnabled, false);
|
||||
assert.equal(config.mpv.aniskipButtonKey, 'F8');
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"mpv": {
|
||||
"socketPath": "",
|
||||
"backend": "weston",
|
||||
"autoStartSubMiner": "yes",
|
||||
"pauseUntilOverlayReady": "no",
|
||||
"subminerBinaryPath": 42,
|
||||
"aniskipEnabled": "disabled",
|
||||
"aniskipButtonKey": ""
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
const invalidConfig = invalidService.getConfig();
|
||||
const warnings = invalidService.getWarnings();
|
||||
assert.equal(invalidConfig.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||
assert.equal(invalidConfig.mpv.backend, DEFAULT_CONFIG.mpv.backend);
|
||||
assert.equal(invalidConfig.mpv.autoStartSubMiner, DEFAULT_CONFIG.mpv.autoStartSubMiner);
|
||||
assert.equal(invalidConfig.mpv.pauseUntilOverlayReady, DEFAULT_CONFIG.mpv.pauseUntilOverlayReady);
|
||||
assert.equal(invalidConfig.mpv.subminerBinaryPath, DEFAULT_CONFIG.mpv.subminerBinaryPath);
|
||||
assert.equal(invalidConfig.mpv.aniskipEnabled, DEFAULT_CONFIG.mpv.aniskipEnabled);
|
||||
assert.equal(invalidConfig.mpv.aniskipButtonKey, DEFAULT_CONFIG.mpv.aniskipButtonKey);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.socketPath'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.backend'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.autoStartSubMiner'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.pauseUntilOverlayReady'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.subminerBinaryPath'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipEnabled'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipButtonKey'));
|
||||
});
|
||||
|
||||
test('parses annotationWebsocket settings and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -1685,6 +1843,7 @@ test('runtime options registry is centralized', () => {
|
||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||
assert.deepEqual(ids, [
|
||||
'anki.autoUpdateNewCards',
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
'subtitle.annotation.nPlusOne',
|
||||
'subtitle.annotation.jlpt',
|
||||
'subtitle.annotation.frequency',
|
||||
@@ -1846,7 +2005,7 @@ test('accepts valid ankiConnect knownWords match mode values', () => {
|
||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||
});
|
||||
|
||||
test('validates ankiConnect knownWords and n+1 color values', () => {
|
||||
test('ignores invalid legacy ankiConnect n+1 color value after migration attempt', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
@@ -1867,17 +2026,16 @@ test('validates ankiConnect knownWords and n+1 color values', () => {
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
|
||||
assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
|
||||
assert.equal(config.subtitleStyle.nPlusOneColor, DEFAULT_CONFIG.subtitleStyle.nPlusOneColor);
|
||||
assert.equal(config.subtitleStyle.knownWordColor, DEFAULT_CONFIG.subtitleStyle.knownWordColor);
|
||||
assert.ok(warnings.every((warning) => warning.path !== 'ankiConnect.nPlusOne.nPlusOne'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
|
||||
});
|
||||
|
||||
test('accepts valid ankiConnect knownWords and n+1 color values', () => {
|
||||
test('resolves legacy ankiConnect n+1 color value without rewriting config', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
const originalContent = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"nPlusOne": "#c6a0f6"
|
||||
@@ -1886,22 +2044,31 @@ test('accepts valid ankiConnect knownWords and n+1 color values', () => {
|
||||
"color": "#a6da95"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6');
|
||||
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
|
||||
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
});
|
||||
|
||||
test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => {
|
||||
test('legacy migration failures are logged and rethrown', () => {
|
||||
const source = fs.readFileSync(path.join(process.cwd(), 'src/config/service.ts'), 'utf-8');
|
||||
const catchBlock = source.match(/catch\s*\(error\)\s*\{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
|
||||
|
||||
assert.ok(catchBlock);
|
||||
assert.match(catchBlock, /legacy config migration failed/);
|
||||
assert.match(catchBlock, /console\.error/);
|
||||
assert.match(catchBlock, /throw error;/);
|
||||
});
|
||||
|
||||
test('resolves legacy ankiConnect nPlusOne known-word settings without rewriting config', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
const originalContent = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"highlightEnabled": true,
|
||||
@@ -1911,32 +2078,50 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
|
||||
"knownWord": "#a6da95"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
|
||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||
assert.deepEqual(config.ankiConnect.knownWords.decks, {
|
||||
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
});
|
||||
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'ankiConnect.nPlusOne.highlightEnabled' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.refreshMinutes' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.matchMode' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.decks' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.knownWord',
|
||||
),
|
||||
);
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
|
||||
});
|
||||
|
||||
test('resolves duplicate ankiConnect nPlusOne objects without rewriting config', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
const originalContent = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"enabled": true,
|
||||
"minSentenceWords": 3
|
||||
},
|
||||
"knownWords": {
|
||||
"highlightEnabled": true
|
||||
},
|
||||
"nPlusOne": {
|
||||
"minSentenceWords": "3"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
});
|
||||
|
||||
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||
@@ -1960,6 +2145,7 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
|
||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||
assert.ok(
|
||||
@@ -2280,9 +2466,9 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"characterDictionary":\s*\{/);
|
||||
assert.match(output, /"preserveLineBreaks": false/);
|
||||
assert.match(output, /"knownWords"\s*:\s*\{/);
|
||||
assert.match(output, /"color": "#a6da95"/);
|
||||
assert.match(output, /"knownWordColor": "#a6da95"/);
|
||||
assert.match(output, /"nPlusOneColor": "#c6a0f6"/);
|
||||
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
||||
assert.match(output, /"nPlusOne": "#c6a0f6"/);
|
||||
assert.match(output, /"minSentenceWords": 3/);
|
||||
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
||||
assert.match(
|
||||
@@ -2385,6 +2571,34 @@ test('template generator includes known keys', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('template generator uses settings CSS declaration paths for appearance fields', () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
const parsed = parseConfigContent('config.example.jsonc', output);
|
||||
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleStyle.css'),
|
||||
buildDefaultSubtitleCssDeclarations('primary'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleStyle.secondary.css'),
|
||||
buildDefaultSubtitleCssDeclarations('secondary'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleSidebar.css'),
|
||||
buildDefaultSubtitleCssDeclarations('sidebar'),
|
||||
);
|
||||
|
||||
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
|
||||
assert.equal(
|
||||
getValueAtPath(parsed, path),
|
||||
undefined,
|
||||
`${path} should be represented by ${getSubtitleCssPath(scope)} in the generated template`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('template generator shows built-in default keybindings in the keybindings array', () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
const parsed = parseConfigContent('config.example.jsonc', output) as {
|
||||
|
||||
@@ -105,7 +105,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
primarySubLanguages: ['ja', 'jpn'],
|
||||
},
|
||||
subsync: {
|
||||
defaultMode: 'auto',
|
||||
alass_path: '',
|
||||
ffsubsync_path: '',
|
||||
ffmpeg_path: '',
|
||||
@@ -116,7 +115,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
mecab: true,
|
||||
yomitanExtension: true,
|
||||
subtitleDictionaries: true,
|
||||
jellyfinRemoteSession: true,
|
||||
jellyfinRemoteSession: false,
|
||||
},
|
||||
updates: {
|
||||
enabled: true,
|
||||
@@ -124,5 +123,5 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
notificationType: 'system',
|
||||
channel: 'stable',
|
||||
},
|
||||
auto_start_overlay: false,
|
||||
auto_start_overlay: true,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
|
||||
|
||||
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
@@ -59,7 +60,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
addMinedWordsImmediately: true,
|
||||
matchMode: 'headword',
|
||||
decks: {},
|
||||
color: '#a6da95',
|
||||
},
|
||||
behavior: {
|
||||
overwriteAudio: true,
|
||||
@@ -70,15 +70,15 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
nPlusOne: {
|
||||
enabled: false,
|
||||
minSentenceWords: 3,
|
||||
nPlusOne: '#c6a0f6',
|
||||
},
|
||||
metadata: {
|
||||
pattern: '[SubMiner] %f (%t)',
|
||||
},
|
||||
isLapis: {
|
||||
enabled: false,
|
||||
sentenceCardModel: 'Japanese sentences',
|
||||
sentenceCardModel: 'Lapis',
|
||||
},
|
||||
isKiku: {
|
||||
enabled: false,
|
||||
@@ -94,6 +94,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
mpv: {
|
||||
executablePath: '',
|
||||
launchMode: 'normal',
|
||||
socketPath: getDefaultMpvSocketPath(),
|
||||
backend: 'auto',
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
subminerBinaryPath: '',
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
anilist: {
|
||||
enabled: false,
|
||||
|
||||
@@ -3,13 +3,14 @@ import { ResolvedConfig } from '../../types/config';
|
||||
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
|
||||
subtitleStyle: {
|
||||
primaryDefaultMode: 'visible',
|
||||
css: {},
|
||||
enableJlpt: false,
|
||||
preserveLineBreaks: false,
|
||||
autoPauseVideoOnHover: true,
|
||||
autoPauseVideoOnYomitanPopup: true,
|
||||
hoverTokenColor: '#f4dbd6',
|
||||
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||
nameMatchEnabled: true,
|
||||
hoverTokenBackgroundColor: 'transparent',
|
||||
nameMatchEnabled: false,
|
||||
nameMatchColor: '#f5bde6',
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 35,
|
||||
@@ -21,6 +22,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision',
|
||||
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
||||
paintOrder: '',
|
||||
WebkitTextStroke: '',
|
||||
fontStyle: 'normal',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'blur(6px)',
|
||||
@@ -43,7 +46,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||
},
|
||||
secondary: {
|
||||
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
||||
css: {},
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 24,
|
||||
fontColor: '#cad3f5',
|
||||
lineHeight: 1.35,
|
||||
@@ -52,6 +56,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision',
|
||||
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
||||
paintOrder: '',
|
||||
WebkitTextStroke: '',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'blur(6px)',
|
||||
fontWeight: '600',
|
||||
@@ -63,13 +69,14 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
autoOpen: false,
|
||||
layout: 'overlay',
|
||||
toggleKey: 'Backslash',
|
||||
pauseVideoOnHover: false,
|
||||
pauseVideoOnHover: true,
|
||||
autoScroll: true,
|
||||
css: {},
|
||||
maxWidth: 420,
|
||||
opacity: 0.95,
|
||||
backgroundColor: 'rgba(73, 77, 100, 0.9)',
|
||||
textColor: '#cad3f5',
|
||||
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 16,
|
||||
timestampColor: '#a5adcb',
|
||||
activeLineColor: '#f5bde6',
|
||||
|
||||
@@ -63,10 +63,9 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'subtitleStyle.jlptColors.N3',
|
||||
'subtitleStyle.jlptColors.N4',
|
||||
'subtitleStyle.jlptColors.N5',
|
||||
'subtitleStyle.knownWordColor',
|
||||
'subtitleStyle.letterSpacing',
|
||||
'subtitleStyle.lineHeight',
|
||||
'subtitleStyle.nPlusOneColor',
|
||||
'subtitleStyle.paintOrder',
|
||||
'subtitleStyle.secondary.backdropFilter',
|
||||
'subtitleStyle.secondary.backgroundColor',
|
||||
'subtitleStyle.secondary.fontColor',
|
||||
@@ -77,11 +76,14 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'subtitleStyle.secondary.fontWeight',
|
||||
'subtitleStyle.secondary.letterSpacing',
|
||||
'subtitleStyle.secondary.lineHeight',
|
||||
'subtitleStyle.secondary.paintOrder',
|
||||
'subtitleStyle.secondary.textRendering',
|
||||
'subtitleStyle.secondary.textShadow',
|
||||
'subtitleStyle.secondary.WebkitTextStroke',
|
||||
'subtitleStyle.secondary.wordSpacing',
|
||||
'subtitleStyle.textRendering',
|
||||
'subtitleStyle.textShadow',
|
||||
'subtitleStyle.WebkitTextStroke',
|
||||
'subtitleStyle.wordSpacing',
|
||||
]);
|
||||
|
||||
@@ -103,6 +105,13 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'mpv.executablePath',
|
||||
'mpv.launchMode',
|
||||
'mpv.socketPath',
|
||||
'mpv.backend',
|
||||
'mpv.autoStartSubMiner',
|
||||
'mpv.pauseUntilOverlayReady',
|
||||
'mpv.subminerBinaryPath',
|
||||
'mpv.aniskipEnabled',
|
||||
'mpv.aniskipButtonKey',
|
||||
'yomitan.externalProfilePath',
|
||||
'immersionTracking.enabled',
|
||||
]) {
|
||||
@@ -112,6 +121,20 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
assert.equal(new Set(paths).size, paths.length);
|
||||
});
|
||||
|
||||
test('known-word annotation color has one public config path', () => {
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
assert.ok(leaves.includes('subtitleStyle.knownWordColor'));
|
||||
assert.ok(!leaves.includes('ankiConnect.knownWords.color'));
|
||||
});
|
||||
|
||||
test('n+1 annotation color has one public config path', () => {
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
|
||||
assert.ok(!leaves.includes('ankiConnect.nPlusOne.color'));
|
||||
});
|
||||
|
||||
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
||||
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
@@ -339,7 +339,8 @@ export function buildCoreConfigOptionRegistry(
|
||||
path: 'auto_start_overlay',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.auto_start_overlay,
|
||||
description: 'Auto-start the subtitle overlay window when SubMiner launches.',
|
||||
description:
|
||||
'Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner.',
|
||||
},
|
||||
{
|
||||
path: 'secondarySub.secondarySubLanguages',
|
||||
@@ -387,13 +388,6 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.annotationWebsocket.port,
|
||||
description: 'Annotated subtitle websocket server port.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.defaultMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['auto', 'manual'],
|
||||
defaultValue: defaultConfig.subsync.defaultMode,
|
||||
description: 'Subsync default mode.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.replace',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -278,6 +278,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
|
||||
description: 'Immediately append newly mined card words into the known-word cache.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.nPlusOne.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
|
||||
description:
|
||||
'Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.nPlusOne.minSentenceWords',
|
||||
kind: 'number',
|
||||
@@ -291,18 +298,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.nPlusOne.nPlusOne',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.nPlusOne.nPlusOne,
|
||||
description: 'Color used for the single N+1 target token highlight.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.knownWords.color',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.color,
|
||||
description: 'Color used for known-word highlights.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isKiku.fieldGrouping',
|
||||
kind: 'enum',
|
||||
@@ -454,6 +449,53 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.mpv.launchMode,
|
||||
description: 'Default window state for SubMiner-managed mpv launches.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.socketPath',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.socketPath,
|
||||
description:
|
||||
'mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.backend',
|
||||
kind: 'enum',
|
||||
enumValues: ['auto', 'hyprland', 'sway', 'x11', 'macos', 'windows'],
|
||||
defaultValue: defaultConfig.mpv.backend,
|
||||
description:
|
||||
'Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.autoStartSubMiner',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.mpv.autoStartSubMiner,
|
||||
description: 'Start SubMiner in the background when SubMiner-managed mpv loads a file.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.pauseUntilOverlayReady',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.mpv.pauseUntilOverlayReady,
|
||||
description:
|
||||
'Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.subminerBinaryPath',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.subminerBinaryPath,
|
||||
description:
|
||||
'Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.aniskipEnabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.mpv.aniskipEnabled,
|
||||
description: 'Enable AniSkip intro detection and skip markers in the bundled mpv plugin.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.aniskipButtonKey',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
||||
description: 'mpv key used to trigger the AniSkip button while the skip marker is visible.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.enabled',
|
||||
kind: 'boolean',
|
||||
@@ -567,7 +609,8 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
},
|
||||
{
|
||||
path: 'discordPresence.presenceStyle',
|
||||
kind: 'string',
|
||||
kind: 'enum',
|
||||
enumValues: ['default', 'meme', 'japanese', 'minimal'],
|
||||
defaultValue: defaultConfig.discordPresence.presenceStyle,
|
||||
description:
|
||||
'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".',
|
||||
|
||||
@@ -13,6 +13,20 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
description:
|
||||
'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.css',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.subtitleStyle.css,
|
||||
description:
|
||||
'CSS declaration object applied to primary subtitles after normal subtitle style defaults.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.secondary.css',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.subtitleStyle.secondary.css,
|
||||
description:
|
||||
'CSS declaration object applied to secondary subtitles after normal subtitle style defaults.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.enableJlpt',
|
||||
kind: 'boolean',
|
||||
@@ -69,6 +83,18 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
description:
|
||||
'Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.knownWordColor',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subtitleStyle.knownWordColor,
|
||||
description: 'Color used for known-word subtitle highlights.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.nPlusOneColor',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subtitleStyle.nPlusOneColor,
|
||||
description: 'Color used for the single N+1 target token subtitle highlight.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.frequencyDictionary.enabled',
|
||||
kind: 'boolean',
|
||||
@@ -155,6 +181,13 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.subtitleSidebar.autoScroll,
|
||||
description: 'Auto-scroll the active subtitle cue into view while playback advances.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleSidebar.css',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.subtitleSidebar.css,
|
||||
description:
|
||||
'CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleSidebar.maxWidth',
|
||||
kind: 'number',
|
||||
|
||||
@@ -20,9 +20,9 @@ export function buildRuntimeOptionRegistry(
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'subtitle.annotation.nPlusOne',
|
||||
id: 'subtitle.annotation.knownWords.highlightEnabled',
|
||||
path: 'ankiConnect.knownWords.highlightEnabled',
|
||||
label: 'N+1 Annotation',
|
||||
label: 'Known Word Annotation',
|
||||
scope: 'subtitle',
|
||||
valueType: 'boolean',
|
||||
allowedValues: [true, false],
|
||||
@@ -35,6 +35,22 @@ export function buildRuntimeOptionRegistry(
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'subtitle.annotation.nPlusOne',
|
||||
path: 'ankiConnect.nPlusOne.enabled',
|
||||
label: 'N+1 Annotation',
|
||||
scope: 'subtitle',
|
||||
valueType: 'boolean',
|
||||
allowedValues: [true, false],
|
||||
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
||||
toAnkiPatch: (value) => ({
|
||||
nPlusOne: {
|
||||
enabled: value === true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'subtitle.annotation.jlpt',
|
||||
path: 'subtitleStyle.enableJlpt',
|
||||
|
||||
@@ -2,9 +2,10 @@ import { ConfigTemplateSection } from './shared';
|
||||
|
||||
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Overlay Auto-Start',
|
||||
title: 'Visible Overlay Auto-Start',
|
||||
description: [
|
||||
'When overlay connects to mpv, automatically show overlay and hide mpv subtitles.',
|
||||
'Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.',
|
||||
'SubMiner can still auto-start in the background when this is false.',
|
||||
],
|
||||
key: 'auto_start_overlay',
|
||||
},
|
||||
@@ -32,6 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Logging',
|
||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
||||
notes: ['Hot-reload: logging.level applies live while SubMiner is running.'],
|
||||
key: 'logging',
|
||||
},
|
||||
{
|
||||
@@ -88,8 +90,9 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
key: 'secondarySub',
|
||||
},
|
||||
{
|
||||
title: 'Auto Subtitle Sync',
|
||||
title: 'Subtitle Sync',
|
||||
description: ['Subsync engine and executable paths.'],
|
||||
notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'],
|
||||
key: 'subsync',
|
||||
},
|
||||
{
|
||||
@@ -126,7 +129,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'AnkiConnect Integration',
|
||||
description: ['Automatic Anki updates and media generation options.'],
|
||||
notes: [
|
||||
'Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.',
|
||||
'Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
|
||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||
'Most other AnkiConnect settings still require restart.',
|
||||
],
|
||||
@@ -135,6 +138,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Jimaku',
|
||||
description: ['Jimaku API configuration and defaults.'],
|
||||
notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'],
|
||||
key: 'jimaku',
|
||||
},
|
||||
{
|
||||
@@ -142,6 +146,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
description: [
|
||||
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
|
||||
],
|
||||
notes: ['Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.'],
|
||||
key: 'youtube',
|
||||
},
|
||||
{
|
||||
@@ -166,7 +171,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'MPV Launcher',
|
||||
description: [
|
||||
'Optional mpv.exe override for Windows playback entry points.',
|
||||
'SubMiner-managed mpv launch and bundled plugin options.',
|
||||
'Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.',
|
||||
'autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.',
|
||||
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
|
||||
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
|
||||
],
|
||||
|
||||
@@ -84,6 +84,29 @@ test('accepts knownWords.addMinedWordsImmediately boolean override', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('enables n+1 for existing configs with known-word highlighting enabled', () => {
|
||||
const { context } = makeContext({
|
||||
knownWords: { highlightEnabled: true },
|
||||
});
|
||||
|
||||
applyAnkiConnectResolution(context);
|
||||
|
||||
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, true);
|
||||
});
|
||||
|
||||
test('keeps explicit n+1 disabled when known-word highlighting is enabled', () => {
|
||||
const { context } = makeContext({
|
||||
knownWords: { highlightEnabled: true },
|
||||
nPlusOne: { enabled: false },
|
||||
});
|
||||
|
||||
applyAnkiConnectResolution(context);
|
||||
|
||||
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, false);
|
||||
});
|
||||
|
||||
test('converts legacy knownWords.decks array to object with default fields', () => {
|
||||
const { context, warnings } = makeContext({
|
||||
knownWords: { decks: ['Core Deck'] },
|
||||
|
||||
@@ -654,7 +654,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
|
||||
|
||||
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
|
||||
const legacyNPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled);
|
||||
if (knownWordsHighlightEnabled !== undefined) {
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
|
||||
} else if (knownWordsConfig.highlightEnabled !== undefined) {
|
||||
@@ -666,23 +665,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||
} else if (legacyNPlusOneHighlightEnabled !== undefined) {
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled = legacyNPlusOneHighlightEnabled;
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.highlightEnabled',
|
||||
nPlusOneConfig.highlightEnabled,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
|
||||
);
|
||||
} else if (nPlusOneConfig.highlightEnabled !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.highlightEnabled',
|
||||
nPlusOneConfig.highlightEnabled,
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||
} else {
|
||||
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
|
||||
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
|
||||
@@ -701,15 +683,10 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
|
||||
const legacyNPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
|
||||
const hasValidKnownWordsRefreshMinutes =
|
||||
knownWordsRefreshMinutes !== undefined &&
|
||||
Number.isInteger(knownWordsRefreshMinutes) &&
|
||||
knownWordsRefreshMinutes > 0;
|
||||
const hasValidLegacyNPlusOneRefreshMinutes =
|
||||
legacyNPlusOneRefreshMinutes !== undefined &&
|
||||
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
|
||||
legacyNPlusOneRefreshMinutes > 0;
|
||||
if (knownWordsRefreshMinutes !== undefined) {
|
||||
if (hasValidKnownWordsRefreshMinutes) {
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
|
||||
@@ -723,25 +700,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||
}
|
||||
} else if (legacyNPlusOneRefreshMinutes !== undefined) {
|
||||
if (hasValidLegacyNPlusOneRefreshMinutes) {
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyNPlusOneRefreshMinutes;
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.refreshMinutes',
|
||||
nPlusOneConfig.refreshMinutes,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
|
||||
);
|
||||
} else {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.refreshMinutes',
|
||||
nPlusOneConfig.refreshMinutes,
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
||||
'Expected a positive integer.',
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||
}
|
||||
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
|
||||
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
|
||||
const hasValidLegacyRefreshMinutes =
|
||||
@@ -789,6 +747,23 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
||||
}
|
||||
|
||||
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
|
||||
if (nPlusOneEnabled !== undefined) {
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
|
||||
} else if (nPlusOneConfig.enabled !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.enabled',
|
||||
nPlusOneConfig.enabled,
|
||||
context.resolved.ankiConnect.nPlusOne.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||
} else if (context.resolved.ankiConnect.knownWords.highlightEnabled === true) {
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = true;
|
||||
} else {
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||
}
|
||||
|
||||
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
|
||||
const hasValidNPlusOneMinSentenceWords =
|
||||
nPlusOneMinSentenceWords !== undefined &&
|
||||
@@ -813,12 +788,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
|
||||
const legacyNPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
|
||||
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
|
||||
const hasValidKnownWordsMatchMode =
|
||||
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
|
||||
const hasValidLegacyNPlusOneMatchMode =
|
||||
legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface';
|
||||
const hasValidLegacyMatchMode =
|
||||
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
|
||||
if (hasValidKnownWordsMatchMode) {
|
||||
@@ -832,25 +804,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.matchMode =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
||||
} else if (legacyNPlusOneMatchMode !== undefined) {
|
||||
if (hasValidLegacyNPlusOneMatchMode) {
|
||||
context.resolved.ankiConnect.knownWords.matchMode = legacyNPlusOneMatchMode;
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.matchMode',
|
||||
nPlusOneConfig.matchMode,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
|
||||
);
|
||||
} else {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.matchMode',
|
||||
nPlusOneConfig.matchMode,
|
||||
context.resolved.ankiConnect.knownWords.matchMode,
|
||||
"Expected 'headword' or 'surface'.",
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.matchMode =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
||||
}
|
||||
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
|
||||
if (hasValidLegacyMatchMode) {
|
||||
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
|
||||
@@ -882,7 +835,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
'Word Reading',
|
||||
];
|
||||
const knownWordsDecks = knownWordsConfig.decks;
|
||||
const legacyNPlusOneDecks = nPlusOneConfig.decks;
|
||||
if (isObject(knownWordsDecks)) {
|
||||
const resolved: Record<string, string[]> = {};
|
||||
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
|
||||
@@ -926,67 +878,31 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
context.resolved.ankiConnect.knownWords.decks,
|
||||
'Expected an object mapping deck names to field arrays.',
|
||||
);
|
||||
} else if (Array.isArray(legacyNPlusOneDecks)) {
|
||||
const normalized = legacyNPlusOneDecks
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
const resolved: Record<string, string[]> = {};
|
||||
for (const deck of new Set(normalized)) {
|
||||
resolved[deck] = DEFAULT_FIELDS;
|
||||
}
|
||||
context.resolved.ankiConnect.knownWords.decks = resolved;
|
||||
if (normalized.length > 0) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.decks',
|
||||
legacyNPlusOneDecks,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.decks,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.decks with object format',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
|
||||
if (nPlusOneHighlightColor !== undefined) {
|
||||
context.resolved.ankiConnect.nPlusOne.nPlusOne = nPlusOneHighlightColor;
|
||||
} else if (nPlusOneConfig.nPlusOne !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.nPlusOne',
|
||||
nPlusOneConfig.nPlusOne,
|
||||
context.resolved.ankiConnect.nPlusOne.nPlusOne,
|
||||
'Expected a hex color value.',
|
||||
);
|
||||
context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne;
|
||||
}
|
||||
const rawSubtitleStyle = isObject(context.src.subtitleStyle)
|
||||
? (context.src.subtitleStyle as Record<string, unknown>)
|
||||
: {};
|
||||
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
|
||||
|
||||
const knownWordsColor = asColor(knownWordsConfig.color);
|
||||
const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
|
||||
if (knownWordsColor !== undefined) {
|
||||
context.resolved.ankiConnect.knownWords.color = knownWordsColor;
|
||||
if (!hasCanonicalKnownWordColor) {
|
||||
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
|
||||
}
|
||||
context.warn(
|
||||
'ankiConnect.knownWords.color',
|
||||
knownWordsConfig.color,
|
||||
context.resolved.subtitleStyle.knownWordColor,
|
||||
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
|
||||
);
|
||||
} else if (knownWordsConfig.color !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.knownWords.color',
|
||||
knownWordsConfig.color,
|
||||
context.resolved.ankiConnect.knownWords.color,
|
||||
context.resolved.subtitleStyle.knownWordColor,
|
||||
'Expected a hex color value.',
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
|
||||
} else if (legacyNPlusOneKnownWordColor !== undefined) {
|
||||
context.resolved.ankiConnect.knownWords.color = legacyNPlusOneKnownWordColor;
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.knownWord',
|
||||
nPlusOneConfig.knownWord,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.color,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.color',
|
||||
);
|
||||
} else if (nPlusOneConfig.knownWord !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.knownWord',
|
||||
nPlusOneConfig.knownWord,
|
||||
context.resolved.ankiConnect.knownWords.color,
|
||||
'Expected a hex color value.',
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -273,13 +273,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -253,6 +253,97 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
`Expected one of: ${MPV_LAUNCH_MODE_VALUES.map((value) => `'${value}'`).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const socketPath = asString(src.mpv.socketPath);
|
||||
if (socketPath !== undefined && socketPath.trim().length > 0) {
|
||||
resolved.mpv.socketPath = socketPath.trim();
|
||||
} else if (src.mpv.socketPath !== undefined) {
|
||||
warn(
|
||||
'mpv.socketPath',
|
||||
src.mpv.socketPath,
|
||||
resolved.mpv.socketPath,
|
||||
'Expected non-empty string.',
|
||||
);
|
||||
}
|
||||
|
||||
const backend = asString(src.mpv.backend);
|
||||
if (
|
||||
backend === 'auto' ||
|
||||
backend === 'hyprland' ||
|
||||
backend === 'sway' ||
|
||||
backend === 'x11' ||
|
||||
backend === 'macos' ||
|
||||
backend === 'windows'
|
||||
) {
|
||||
resolved.mpv.backend = backend;
|
||||
} else if (src.mpv.backend !== undefined) {
|
||||
warn(
|
||||
'mpv.backend',
|
||||
src.mpv.backend,
|
||||
resolved.mpv.backend,
|
||||
'Expected auto, hyprland, sway, x11, macos, or windows.',
|
||||
);
|
||||
}
|
||||
|
||||
const autoStartSubMiner = asBoolean(src.mpv.autoStartSubMiner);
|
||||
if (autoStartSubMiner !== undefined) {
|
||||
resolved.mpv.autoStartSubMiner = autoStartSubMiner;
|
||||
} else if (src.mpv.autoStartSubMiner !== undefined) {
|
||||
warn(
|
||||
'mpv.autoStartSubMiner',
|
||||
src.mpv.autoStartSubMiner,
|
||||
resolved.mpv.autoStartSubMiner,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const pauseUntilOverlayReady = asBoolean(src.mpv.pauseUntilOverlayReady);
|
||||
if (pauseUntilOverlayReady !== undefined) {
|
||||
resolved.mpv.pauseUntilOverlayReady = pauseUntilOverlayReady;
|
||||
} else if (src.mpv.pauseUntilOverlayReady !== undefined) {
|
||||
warn(
|
||||
'mpv.pauseUntilOverlayReady',
|
||||
src.mpv.pauseUntilOverlayReady,
|
||||
resolved.mpv.pauseUntilOverlayReady,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const subminerBinaryPath = asString(src.mpv.subminerBinaryPath);
|
||||
if (subminerBinaryPath !== undefined) {
|
||||
resolved.mpv.subminerBinaryPath = subminerBinaryPath.trim();
|
||||
} else if (src.mpv.subminerBinaryPath !== undefined) {
|
||||
warn(
|
||||
'mpv.subminerBinaryPath',
|
||||
src.mpv.subminerBinaryPath,
|
||||
resolved.mpv.subminerBinaryPath,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
const aniskipEnabled = asBoolean(src.mpv.aniskipEnabled);
|
||||
if (aniskipEnabled !== undefined) {
|
||||
resolved.mpv.aniskipEnabled = aniskipEnabled;
|
||||
} else if (src.mpv.aniskipEnabled !== undefined) {
|
||||
warn(
|
||||
'mpv.aniskipEnabled',
|
||||
src.mpv.aniskipEnabled,
|
||||
resolved.mpv.aniskipEnabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const aniskipButtonKey = asString(src.mpv.aniskipButtonKey);
|
||||
if (aniskipButtonKey !== undefined && aniskipButtonKey.trim().length > 0) {
|
||||
resolved.mpv.aniskipButtonKey = aniskipButtonKey.trim();
|
||||
} else if (src.mpv.aniskipButtonKey !== undefined) {
|
||||
warn(
|
||||
'mpv.aniskipButtonKey',
|
||||
src.mpv.aniskipButtonKey,
|
||||
resolved.mpv.aniskipButtonKey,
|
||||
'Expected non-empty string.',
|
||||
);
|
||||
}
|
||||
} else if (src.mpv !== undefined) {
|
||||
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
|
||||
}
|
||||
|
||||
@@ -10,6 +10,40 @@ import {
|
||||
isObject,
|
||||
} from './shared';
|
||||
|
||||
function asCssDeclarations(value: unknown): Record<string, string> | undefined {
|
||||
if (!isObject(value)) return undefined;
|
||||
|
||||
const declarations: Record<string, string> = {};
|
||||
for (const [property, declarationValue] of Object.entries(value)) {
|
||||
if (typeof declarationValue !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
if (declarationValue.trim().length > 0) {
|
||||
declarations[property] = declarationValue.trim();
|
||||
}
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
const SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY = '--subtitle-hover-token-color';
|
||||
const SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY = '--subtitle-hover-token-background-color';
|
||||
|
||||
function applySubtitleHoverTokenCssCompatibility(
|
||||
subtitleStyle: ResolvedConfig['subtitleStyle'],
|
||||
): void {
|
||||
const hoverTokenColor = asCssColor(subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY]);
|
||||
if (hoverTokenColor !== undefined) {
|
||||
subtitleStyle.hoverTokenColor = hoverTokenColor;
|
||||
}
|
||||
|
||||
const hoverTokenBackgroundColor = asCssColor(
|
||||
subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY],
|
||||
);
|
||||
if (hoverTokenBackgroundColor !== undefined) {
|
||||
subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
@@ -157,6 +191,10 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
||||
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
||||
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
||||
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
||||
const fallbackSubtitleStyleCss = { ...resolved.subtitleStyle.css };
|
||||
const fallbackSubtitleStyleSecondaryCss = { ...resolved.subtitleStyle.secondary.css };
|
||||
const fallbackFrequencyDictionary = {
|
||||
...resolved.subtitleStyle.frequencyDictionary,
|
||||
};
|
||||
@@ -209,6 +247,35 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const css = asCssDeclarations((src.subtitleStyle as { css?: unknown }).css);
|
||||
if (css !== undefined) {
|
||||
resolved.subtitleStyle.css = css;
|
||||
} else if ((src.subtitleStyle as { css?: unknown }).css !== undefined) {
|
||||
resolved.subtitleStyle.css = fallbackSubtitleStyleCss;
|
||||
warn(
|
||||
'subtitleStyle.css',
|
||||
(src.subtitleStyle as { css?: unknown }).css,
|
||||
resolved.subtitleStyle.css,
|
||||
'Expected an object whose values are CSS declaration strings.',
|
||||
);
|
||||
}
|
||||
|
||||
const rawSecondary = isObject(src.subtitleStyle.secondary)
|
||||
? (src.subtitleStyle.secondary as { css?: unknown })
|
||||
: undefined;
|
||||
const secondaryCss = asCssDeclarations(rawSecondary?.css);
|
||||
if (secondaryCss !== undefined) {
|
||||
resolved.subtitleStyle.secondary.css = secondaryCss;
|
||||
} else if (rawSecondary?.css !== undefined) {
|
||||
resolved.subtitleStyle.secondary.css = fallbackSubtitleStyleSecondaryCss;
|
||||
warn(
|
||||
'subtitleStyle.secondary.css',
|
||||
rawSecondary.css,
|
||||
resolved.subtitleStyle.secondary.css,
|
||||
'Expected an object whose values are CSS declaration strings.',
|
||||
);
|
||||
}
|
||||
|
||||
const preserveLineBreaks = asBoolean(
|
||||
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
|
||||
);
|
||||
@@ -301,6 +368,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
applySubtitleHoverTokenCssCompatibility(resolved.subtitleStyle);
|
||||
|
||||
const nameMatchColor = asColor(
|
||||
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
|
||||
);
|
||||
@@ -333,6 +402,34 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const knownWordColor = asColor(
|
||||
(src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor,
|
||||
);
|
||||
if (knownWordColor !== undefined) {
|
||||
resolved.subtitleStyle.knownWordColor = knownWordColor;
|
||||
} else if ((src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor !== undefined) {
|
||||
resolved.subtitleStyle.knownWordColor = fallbackSubtitleStyleKnownWordColor;
|
||||
warn(
|
||||
'subtitleStyle.knownWordColor',
|
||||
(src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor,
|
||||
resolved.subtitleStyle.knownWordColor,
|
||||
'Expected hex color.',
|
||||
);
|
||||
}
|
||||
|
||||
const nPlusOneColor = asColor((src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor);
|
||||
if (nPlusOneColor !== undefined) {
|
||||
resolved.subtitleStyle.nPlusOneColor = nPlusOneColor;
|
||||
} else if ((src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor !== undefined) {
|
||||
resolved.subtitleStyle.nPlusOneColor = fallbackSubtitleStyleNPlusOneColor;
|
||||
warn(
|
||||
'subtitleStyle.nPlusOneColor',
|
||||
(src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor,
|
||||
resolved.subtitleStyle.nPlusOneColor,
|
||||
'Expected hex color.',
|
||||
);
|
||||
}
|
||||
|
||||
const frequencyDictionary = isObject(
|
||||
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
|
||||
)
|
||||
@@ -445,6 +542,19 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
...(src.subtitleSidebar as ResolvedConfig['subtitleSidebar']),
|
||||
};
|
||||
|
||||
const css = asCssDeclarations((src.subtitleSidebar as { css?: unknown }).css);
|
||||
if (css !== undefined) {
|
||||
resolved.subtitleSidebar.css = css;
|
||||
} else if ((src.subtitleSidebar as { css?: unknown }).css !== undefined) {
|
||||
resolved.subtitleSidebar.css = fallback.css;
|
||||
warn(
|
||||
'subtitleSidebar.css',
|
||||
(src.subtitleSidebar as { css?: unknown }).css,
|
||||
resolved.subtitleSidebar.css,
|
||||
'Expected an object whose values are CSS declaration strings.',
|
||||
);
|
||||
}
|
||||
|
||||
const enabled = asBoolean((src.subtitleSidebar as { enabled?: unknown }).enabled);
|
||||
if (enabled !== undefined) {
|
||||
resolved.subtitleSidebar.enabled = enabled;
|
||||
|
||||
@@ -55,6 +55,33 @@ test('subtitleSidebar accepts zero opacity', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleSidebar css declarations accept string declaration maps and warn on invalid values', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleSidebar: {
|
||||
css: {
|
||||
'font-size': '18px',
|
||||
color: '#ffffff',
|
||||
},
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.deepEqual(valid.context.resolved.subtitleSidebar.css, {
|
||||
'font-size': '18px',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleSidebar: {
|
||||
css: {
|
||||
color: 42,
|
||||
} as never,
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.deepEqual(invalid.context.resolved.subtitleSidebar.css, {});
|
||||
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleSidebar.css'));
|
||||
});
|
||||
|
||||
test('subtitleSidebar falls back and warns on invalid values', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleSidebar: {
|
||||
|
||||
@@ -28,6 +28,68 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle css declarations accept string declaration maps and warn on invalid values', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
css: {
|
||||
'font-size': '42px',
|
||||
'text-wrap': 'balance',
|
||||
'--subtitle-hover-token-color': '#c6a0f6',
|
||||
'--subtitle-hover-token-background-color': 'transparent',
|
||||
},
|
||||
secondary: {
|
||||
css: {
|
||||
'text-transform': 'uppercase',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.deepEqual(valid.context.resolved.subtitleStyle.css, {
|
||||
'font-size': '42px',
|
||||
'text-wrap': 'balance',
|
||||
'--subtitle-hover-token-color': '#c6a0f6',
|
||||
'--subtitle-hover-token-background-color': 'transparent',
|
||||
});
|
||||
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenColor, '#c6a0f6');
|
||||
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
|
||||
assert.deepEqual(valid.context.resolved.subtitleStyle.secondary.css, {
|
||||
'text-transform': 'uppercase',
|
||||
});
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
css: {
|
||||
'font-size': 42,
|
||||
} as never,
|
||||
secondary: {
|
||||
css: 'font-size: 28px;' as never,
|
||||
},
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.deepEqual(invalid.context.resolved.subtitleStyle.css, {});
|
||||
assert.deepEqual(invalid.context.resolved.subtitleStyle.secondary.css, {});
|
||||
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.css'));
|
||||
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
|
||||
});
|
||||
|
||||
test('subtitleStyle hover css compatibility ignores invalid color declarations', () => {
|
||||
const { context } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
css: {
|
||||
'--subtitle-hover-token-color': 'purple',
|
||||
'--subtitle-hover-token-background-color': '#363a4fd6',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.equal(context.resolved.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||
assert.equal(context.resolved.subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
|
||||
});
|
||||
|
||||
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
@@ -100,7 +162,7 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, true);
|
||||
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, false);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
@@ -155,6 +217,55 @@ test('subtitleStyle nameMatchColor accepts valid values and warns on invalid', (
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle knownWordColor accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
knownWordColor: '#ed8796',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.equal(valid.context.resolved.subtitleStyle.knownWordColor, '#ed8796');
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
knownWordColor: 'pink',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.equal(invalid.context.resolved.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.ok(
|
||||
invalid.warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.knownWordColor' &&
|
||||
warning.message === 'Expected hex color.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle nPlusOneColor accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nPlusOneColor: '#ed8796',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.equal(valid.context.resolved.subtitleStyle.nPlusOneColor, '#ed8796');
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nPlusOneColor: 'pink',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.equal(invalid.context.resolved.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||
assert.ok(
|
||||
invalid.warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.nPlusOneColor' && warning.message === 'Expected hex color.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
|
||||
+45
-3
@@ -4,6 +4,8 @@ import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/con
|
||||
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
|
||||
import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
|
||||
import { resolveConfig } from './resolve';
|
||||
import { applyLegacyAnkiConnectNPlusOneMigrationToContent } from './anki-connect-nplusone-migration';
|
||||
import { applyLegacySubtitleStyleCssMigrationToContent } from './subtitle-style-css-migration';
|
||||
|
||||
export type ReloadConfigStrictResult =
|
||||
| {
|
||||
@@ -49,7 +51,10 @@ export class ConfigService {
|
||||
if (!loadResult.ok) {
|
||||
throw new ConfigStartupParseError(loadResult.path, loadResult.error);
|
||||
}
|
||||
this.applyResolvedConfig(loadResult.config, loadResult.path);
|
||||
this.applyResolvedConfig(
|
||||
this.migrateLegacyConfig(loadResult.config, loadResult.path),
|
||||
loadResult.path,
|
||||
);
|
||||
}
|
||||
|
||||
getConfigPath(): string {
|
||||
@@ -70,7 +75,7 @@ export class ConfigService {
|
||||
|
||||
reloadConfig(): ResolvedConfig {
|
||||
const { config, path: configPath } = loadRawConfig(this.configPaths);
|
||||
return this.applyResolvedConfig(config, configPath);
|
||||
return this.applyResolvedConfig(this.migrateLegacyConfig(config, configPath), configPath);
|
||||
}
|
||||
|
||||
reloadConfigStrict(): ReloadConfigStrictResult {
|
||||
@@ -80,7 +85,10 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
const { config, path: configPath } = loadResult;
|
||||
const resolvedConfig = this.applyResolvedConfig(config, configPath);
|
||||
const resolvedConfig = this.applyResolvedConfig(
|
||||
this.migrateLegacyConfig(config, configPath),
|
||||
configPath,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
config: resolvedConfig,
|
||||
@@ -113,4 +121,38 @@ export class ConfigService {
|
||||
this.warnings = warnings;
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
private migrateLegacyConfig(config: RawConfig, configPath: string): RawConfig {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
try {
|
||||
let content = fs.readFileSync(configPath, 'utf-8');
|
||||
let rawConfig = config;
|
||||
let migrated = false;
|
||||
for (const applyMigration of [
|
||||
applyLegacyAnkiConnectNPlusOneMigrationToContent,
|
||||
applyLegacySubtitleStyleCssMigrationToContent,
|
||||
]) {
|
||||
const migration = applyMigration({
|
||||
content,
|
||||
rawConfig,
|
||||
});
|
||||
if (!migration.migrated) {
|
||||
continue;
|
||||
}
|
||||
content = migration.content;
|
||||
rawConfig = migration.rawConfig;
|
||||
migrated = true;
|
||||
}
|
||||
if (!migrated) {
|
||||
return rawConfig;
|
||||
}
|
||||
return rawConfig;
|
||||
} catch (error) {
|
||||
console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,70 @@ test('applyConfigSettingsPatchToContent preserves JSONC comments while setting n
|
||||
assert.equal(parsed.subtitleStyle.fontSize, 35);
|
||||
});
|
||||
|
||||
test('applyConfigSettingsPatchToContent updates effective duplicate object path', () => {
|
||||
const input = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"enabled": true
|
||||
},
|
||||
"knownWords": {
|
||||
"highlightEnabled": true
|
||||
},
|
||||
"nPlusOne": {
|
||||
"minSentenceWords": 3
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: input,
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'ankiConnect.nPlusOne.enabled',
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
previousWarnings: [],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const parsed = parse(result.content);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
|
||||
});
|
||||
|
||||
test('applyConfigSettingsPatchToContent removes duplicate properties across JSONC trivia', () => {
|
||||
const input = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"enabled": false
|
||||
} /* old value */ ,
|
||||
// effective value follows
|
||||
"nPlusOne": {
|
||||
"minSentenceWords": 3
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: input,
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'ankiConnect.nPlusOne.enabled',
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
previousWarnings: [],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const parsed = parse(result.content);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
|
||||
});
|
||||
|
||||
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
|
||||
const input = `{
|
||||
"subtitleStyle": {
|
||||
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
applyEdits,
|
||||
modify,
|
||||
parse as parseJsonc,
|
||||
parseTree as parseJsoncTree,
|
||||
type Edit,
|
||||
type Node as JsoncNode,
|
||||
type FormattingOptions,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
@@ -12,7 +15,7 @@ import type {
|
||||
ConfigSettingsSnapshot,
|
||||
} from '../../types/settings';
|
||||
import { resolveConfig } from '../resolve';
|
||||
import { getConfigValueAtPath } from './registry';
|
||||
import { getConfigValueAtPath, SECRET_PATHS } from './registry';
|
||||
|
||||
const JSONC_FORMATTING_OPTIONS: FormattingOptions = {
|
||||
insertSpaces: true,
|
||||
@@ -91,6 +94,7 @@ function normalizeContent(content: string): string {
|
||||
}
|
||||
|
||||
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
|
||||
content = removeDuplicatePropertiesAlongPath(content, operation.path);
|
||||
const edits = modify(
|
||||
content,
|
||||
pathToSegments(operation.path),
|
||||
@@ -103,6 +107,148 @@ function applySingleOperation(content: string, operation: ConfigSettingsPatchOpe
|
||||
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[],
|
||||
@@ -188,7 +334,21 @@ export function buildConfigSettingsSnapshot(
|
||||
continue;
|
||||
}
|
||||
|
||||
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
|
||||
values[field.configPath] = structuredClone(rawValue != null ? rawValue : resolvedValue);
|
||||
}
|
||||
|
||||
for (const secretPath of SECRET_PATHS) {
|
||||
if (Object.hasOwn(values, secretPath)) {
|
||||
continue;
|
||||
}
|
||||
const rawValue = getConfigValueAtPath(options.rawConfig, secretPath);
|
||||
const resolvedValue = getConfigValueAtPath(options.resolvedConfig, secretPath);
|
||||
if (
|
||||
(typeof rawValue === 'string' && rawValue.length > 0) ||
|
||||
(typeof resolvedValue === 'string' && resolvedValue.length > 0)
|
||||
) {
|
||||
values[secretPath] = { configured: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,39 +1,305 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DEFAULT_CONFIG } from '../definitions';
|
||||
import {
|
||||
buildConfigSettingsRegistry,
|
||||
getConfigSettingsCoverage,
|
||||
LEGACY_HIDDEN_CONFIG_PATHS,
|
||||
} from './registry';
|
||||
import { buildConfigSettingsRegistry } from './registry';
|
||||
|
||||
test('config settings registry places hover pause under viewing playback behavior', () => {
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
const hoverPause = fields.find(
|
||||
(field) => field.configPath === 'subtitleStyle.autoPauseVideoOnHover',
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
|
||||
function field(path: string) {
|
||||
const match = fields.find((candidate) => candidate.configPath === path);
|
||||
assert.ok(match, `missing settings field: ${path}`);
|
||||
return match;
|
||||
}
|
||||
|
||||
test('settings registry splits viewing into appearance and behavior categories', () => {
|
||||
assert.equal(field('subtitleStyle.fontSize').category, 'appearance');
|
||||
assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior');
|
||||
assert.equal(field('secondarySub.defaultMode').category, 'behavior');
|
||||
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
|
||||
assert.equal(field('auto_start_overlay').category, 'behavior');
|
||||
assert.equal(field('auto_start_overlay').section, 'Playback Behavior');
|
||||
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
|
||||
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
|
||||
assert.equal(field('mpv.launchMode').category, 'behavior');
|
||||
assert.equal(field('mpv.launchMode').section, 'mpv Playback');
|
||||
assert.ok(
|
||||
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
|
||||
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
|
||||
);
|
||||
|
||||
assert.ok(hoverPause);
|
||||
assert.equal(hoverPause.category, 'viewing');
|
||||
assert.equal(hoverPause.section, 'Playback pause behavior');
|
||||
assert.equal(hoverPause.control, 'boolean');
|
||||
});
|
||||
|
||||
test('config settings registry hides legacy and ignored paths from normal fields', () => {
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
const visiblePaths = new Set(
|
||||
fields.filter((field) => !field.legacyHidden).map((field) => field.configPath),
|
||||
);
|
||||
|
||||
for (const path of LEGACY_HIDDEN_CONFIG_PATHS) {
|
||||
assert.equal(visiblePaths.has(path), false, path);
|
||||
test('settings registry groups playback startup controls under playback behavior', () => {
|
||||
for (const path of [
|
||||
'subtitleStyle.autoPauseVideoOnHover',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
'subtitleSidebar.pauseVideoOnHover',
|
||||
'mpv.autoStartSubMiner',
|
||||
'auto_start_overlay',
|
||||
'mpv.pauseUntilOverlayReady',
|
||||
]) {
|
||||
assert.equal(field(path).category, 'behavior', path);
|
||||
assert.equal(field(path).section, 'Playback Behavior', path);
|
||||
}
|
||||
assert.equal(visiblePaths.has('controller.buttonIndices'), false);
|
||||
});
|
||||
|
||||
test('config settings registry covers canonical defaults or marks explicit raw-only gaps', () => {
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
const coverage = getConfigSettingsCoverage(DEFAULT_CONFIG, fields);
|
||||
|
||||
assert.deepEqual(coverage.uncoveredDefaultPaths, []);
|
||||
test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
|
||||
assert.equal(field('mpv.aniskipButtonKey').category, 'input');
|
||||
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
|
||||
assert.equal(field('mpv.aniskipButtonKey').subsection, 'Playback');
|
||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
|
||||
});
|
||||
|
||||
test('settings registry hides removed modal-only fields', () => {
|
||||
for (const path of [
|
||||
'shortcuts.multiCopyTimeoutMs',
|
||||
'anilist.characterDictionary.profileScope',
|
||||
'jellyfin.directPlayContainers',
|
||||
'jellyfin.remoteControlDeviceName',
|
||||
]) {
|
||||
assert.equal(
|
||||
fields.some((candidate) => candidate.configPath === path),
|
||||
false,
|
||||
path,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry orders websocket server immediately after annotation websocket', () => {
|
||||
const integrationSections = [
|
||||
...new Set(
|
||||
fields
|
||||
.filter((candidate) => candidate.category === 'integrations')
|
||||
.map((candidate) => candidate.section),
|
||||
),
|
||||
];
|
||||
const annotationIndex = integrationSections.indexOf('Annotation WebSocket');
|
||||
assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
|
||||
});
|
||||
|
||||
test('settings registry explains websocket auto mode and keeps it disabled by default', () => {
|
||||
assert.equal(field('websocket.enabled').defaultValue, false);
|
||||
assert.equal(
|
||||
field('websocket.enabled').description,
|
||||
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||
);
|
||||
});
|
||||
|
||||
test('settings registry places immersion tracking after other tracking and app sections', () => {
|
||||
const trackingSections = [
|
||||
...new Set(
|
||||
fields
|
||||
.filter((candidate) => candidate.category === 'tracking-app')
|
||||
.map((candidate) => candidate.section),
|
||||
),
|
||||
];
|
||||
assert.equal(trackingSections.at(-1), 'Immersion tracking');
|
||||
});
|
||||
|
||||
test('settings registry groups annotation display fields by config group', () => {
|
||||
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
|
||||
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
|
||||
assert.equal(field('subtitleStyle.knownWordColor').subsection, 'Known Words');
|
||||
assert.equal(field('subtitleStyle.nPlusOneColor').subsection, 'N+1');
|
||||
assert.equal(field('subtitleStyle.enableJlpt').subsection, 'JLPT');
|
||||
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
|
||||
});
|
||||
|
||||
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
|
||||
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
|
||||
assert.equal(
|
||||
field('subtitleStyle.frequencyDictionary.sourcePath').section,
|
||||
'Frequency Highlighting',
|
||||
);
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
|
||||
});
|
||||
|
||||
test('settings registry exposes mpv aniskip button as an mpv key learn control', () => {
|
||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||
});
|
||||
|
||||
test('settings registry exposes specialized controls for config-assisted inputs', () => {
|
||||
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
|
||||
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
|
||||
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
|
||||
assert.equal(field('keybindings').control, 'mpv-keybindings');
|
||||
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
|
||||
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
|
||||
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
|
||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||
assert.equal(field('subtitleSidebar.css').control, 'css-declarations');
|
||||
assert.equal(field('stats.toggleKey').control, 'key-code');
|
||||
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
||||
});
|
||||
|
||||
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
||||
const primaryVisible = fields
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.section === 'Primary Subtitle Appearance' && !candidate.settingsHidden,
|
||||
)
|
||||
.map((candidate) => candidate.configPath);
|
||||
const secondaryVisible = fields
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.section === 'Secondary Subtitle Appearance' && !candidate.settingsHidden,
|
||||
)
|
||||
.map((candidate) => candidate.configPath);
|
||||
|
||||
assert.deepEqual(primaryVisible, ['subtitleStyle.css']);
|
||||
assert.deepEqual(secondaryVisible, ['subtitleStyle.secondary.css']);
|
||||
assert.equal(field('subtitleStyle.fontSize').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.secondary.fontSize').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.fontColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.backgroundColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.hoverTokenColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.hoverTokenBackgroundColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.paintOrder').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
|
||||
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
|
||||
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
|
||||
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.bandedColors').settingsHidden, false);
|
||||
});
|
||||
|
||||
test('settings registry exposes css declaration editor for subtitle sidebar appearance', () => {
|
||||
const sidebarVisible = fields
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.section === 'Subtitle Sidebar Appearance' && !candidate.settingsHidden,
|
||||
)
|
||||
.map((candidate) => candidate.configPath);
|
||||
|
||||
assert.deepEqual(sidebarVisible, ['subtitleSidebar.css']);
|
||||
assert.equal(field('subtitleSidebar.fontFamily').settingsHidden, true);
|
||||
assert.equal(field('subtitleSidebar.fontSize').settingsHidden, true);
|
||||
assert.equal(field('subtitleSidebar.textColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleSidebar.backgroundColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleSidebar.timestampColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleSidebar.activeLineColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleSidebar.activeLineBackgroundColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleSidebar.hoverLineBackgroundColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleSidebar.enabled').settingsHidden, false);
|
||||
assert.equal(field('subtitleSidebar.layout').settingsHidden, false);
|
||||
});
|
||||
|
||||
test('settings registry routes playback-related integrations into integrations', () => {
|
||||
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
|
||||
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
|
||||
assert.equal(field('subsync.replace').category, 'integrations');
|
||||
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
|
||||
});
|
||||
|
||||
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
||||
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
|
||||
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
|
||||
assert.ok(
|
||||
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') <
|
||||
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'),
|
||||
);
|
||||
assert.ok(
|
||||
fields.findIndex((candidate) => candidate.section === 'AnkiConnect') <
|
||||
fields.findIndex((candidate) => candidate.section === 'AnkiConnect Proxy'),
|
||||
);
|
||||
|
||||
const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
|
||||
assert.deepEqual(
|
||||
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
|
||||
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'],
|
||||
);
|
||||
});
|
||||
|
||||
test('settings registry hides app-managed and inactive config surfaces', () => {
|
||||
const paths = new Set(fields.map((candidate) => candidate.configPath));
|
||||
for (const hiddenPath of [
|
||||
'ai.enabled',
|
||||
'ai.apiKey',
|
||||
'ai.apiKeyCommand',
|
||||
'ai.model',
|
||||
'ai.baseUrl',
|
||||
'ai.systemPrompt',
|
||||
'ai.requestTimeoutMs',
|
||||
'ankiConnect.ai.enabled',
|
||||
'ankiConnect.ai.model',
|
||||
'ankiConnect.ai.systemPrompt',
|
||||
'ankiConnect.fields.translation',
|
||||
'controller.bindings',
|
||||
'controller.preferredGamepadId',
|
||||
'controller.preferredGamepadLabel',
|
||||
'controller.profiles',
|
||||
'youtubeSubgen.whisperBin',
|
||||
'jellyfin.clientVersion',
|
||||
'jellyfin.defaultLibraryId',
|
||||
'jellyfin.deviceId',
|
||||
'jellyfin.clientName',
|
||||
'subtitleSidebar.toggleKey',
|
||||
'jellyfin.recentServers',
|
||||
]) {
|
||||
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
|
||||
}
|
||||
assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary');
|
||||
});
|
||||
|
||||
test('settings registry marks safe live config paths as hot-reloadable', () => {
|
||||
for (const path of [
|
||||
'mpv.aniskipButtonKey',
|
||||
'stats.toggleKey',
|
||||
'stats.markWatchedKey',
|
||||
'logging.level',
|
||||
'youtube.primarySubLanguages',
|
||||
'jimaku.apiBaseUrl',
|
||||
'jimaku.languagePreference',
|
||||
'jimaku.maxEntryResults',
|
||||
'subsync.replace',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||
'ankiConnect.knownWords.matchMode',
|
||||
'ankiConnect.knownWords.decks',
|
||||
'ankiConnect.nPlusOne.enabled',
|
||||
'ankiConnect.nPlusOne.minSentenceWords',
|
||||
'ankiConnect.fields.word',
|
||||
'ankiConnect.fields.audio',
|
||||
'ankiConnect.fields.image',
|
||||
'ankiConnect.fields.sentence',
|
||||
'ankiConnect.fields.miscInfo',
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
'ankiConnect.isKiku.fieldGrouping',
|
||||
]) {
|
||||
assert.equal(field(path).restartBehavior, 'hot-reload', path);
|
||||
}
|
||||
});
|
||||
|
||||
test('settings registry does not expose removed subsync mode option', () => {
|
||||
const paths = new Set(fields.map((candidate) => candidate.configPath));
|
||||
assert.equal(paths.has('subsync.defaultMode'), false);
|
||||
});
|
||||
|
||||
test('settings registry keeps unsafe config siblings restart-required', () => {
|
||||
for (const path of [
|
||||
'stats.serverPort',
|
||||
'ankiConnect.url',
|
||||
'ankiConnect.proxy.enabled',
|
||||
'mpv.socketPath',
|
||||
'websocket.port',
|
||||
]) {
|
||||
assert.equal(field(path).restartBehavior, 'restart', path);
|
||||
}
|
||||
});
|
||||
|
||||
+433
-43
@@ -6,6 +6,10 @@ import type {
|
||||
ConfigSettingsRestartBehavior,
|
||||
} from '../../types/settings';
|
||||
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
|
||||
import {
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssScopeForPath,
|
||||
} from '../../settings/subtitle-style-css';
|
||||
|
||||
type Leaf = {
|
||||
path: string;
|
||||
@@ -46,41 +50,209 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
||||
'ankiConnect.nPlusOne.matchMode',
|
||||
'ankiConnect.nPlusOne.decks',
|
||||
'ankiConnect.nPlusOne.knownWord',
|
||||
'ankiConnect.nPlusOne.nPlusOne',
|
||||
'ankiConnect.knownWords.color',
|
||||
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
||||
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
||||
'ankiConnect.behavior.nPlusOneMatchMode',
|
||||
'ankiConnect.isLapis.sentenceCardSentenceField',
|
||||
'ankiConnect.isLapis.sentenceCardAudioField',
|
||||
'ankiConnect.fields.translation',
|
||||
'controller.bindings',
|
||||
'controller.preferredGamepadId',
|
||||
'controller.preferredGamepadLabel',
|
||||
'controller.profiles',
|
||||
'youtubeSubgen.primarySubLanguages',
|
||||
'anilist.characterDictionary.refreshTtlHours',
|
||||
'anilist.characterDictionary.evictionPolicy',
|
||||
'anilist.characterDictionary.profileScope',
|
||||
'jellyfin.accessToken',
|
||||
'jellyfin.userId',
|
||||
'jellyfin.clientName',
|
||||
'jellyfin.clientVersion',
|
||||
'jellyfin.defaultLibraryId',
|
||||
'jellyfin.deviceId',
|
||||
'jellyfin.directPlayContainers',
|
||||
'jellyfin.remoteControlDeviceName',
|
||||
'controller.buttonIndices',
|
||||
'shortcuts.multiCopyTimeoutMs',
|
||||
'subtitleSidebar.toggleKey',
|
||||
'jellyfin.recentServers',
|
||||
] as const;
|
||||
|
||||
const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const;
|
||||
const EXCLUDED_PREFIXES = [
|
||||
'ai',
|
||||
'ankiConnect.ai',
|
||||
'controller.buttonIndices',
|
||||
'youtubeSubgen',
|
||||
] as const;
|
||||
|
||||
const JSON_OBJECT_FIELDS = new Set([
|
||||
'keybindings',
|
||||
'controller.bindings',
|
||||
'controller.profiles',
|
||||
'ankiConnect.knownWords.decks',
|
||||
'subtitleStyle.css',
|
||||
'subtitleStyle.secondary.css',
|
||||
'subtitleSidebar.css',
|
||||
]);
|
||||
|
||||
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
|
||||
export const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
|
||||
|
||||
const COLOR_SUFFIXES = new Set([
|
||||
'Color',
|
||||
'color',
|
||||
'backgroundColor',
|
||||
'singleColor',
|
||||
'knownWordColor',
|
||||
'nPlusOne',
|
||||
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']);
|
||||
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
|
||||
...getSubtitleCssManagedConfigPaths('primary'),
|
||||
...getSubtitleCssManagedConfigPaths('secondary'),
|
||||
...getSubtitleCssManagedConfigPaths('sidebar'),
|
||||
]);
|
||||
|
||||
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
||||
|
||||
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
|
||||
'appearance',
|
||||
'behavior',
|
||||
'mining-anki',
|
||||
'input',
|
||||
'integrations',
|
||||
'tracking-app',
|
||||
'advanced',
|
||||
];
|
||||
|
||||
const SECTION_ORDER = new Map<string, number>(
|
||||
[
|
||||
'Annotation Display',
|
||||
'Known Words',
|
||||
'N+1',
|
||||
'Frequency Highlighting',
|
||||
'Primary Subtitle Appearance',
|
||||
'Secondary Subtitle Appearance',
|
||||
'Subtitle Sidebar Appearance',
|
||||
'Playback Behavior',
|
||||
'Subtitle Behavior',
|
||||
'Subtitle Sidebar Behavior',
|
||||
'YouTube Playback Settings',
|
||||
'mpv Playback',
|
||||
'Note Fields',
|
||||
'Media Capture',
|
||||
'Kiku/Lapis Features',
|
||||
'Anki AI',
|
||||
'AnkiConnect',
|
||||
'AnkiConnect Proxy',
|
||||
'Jimaku',
|
||||
'Subtitle Sync',
|
||||
'MPV Keybindings',
|
||||
'Overlay Shortcuts',
|
||||
'Controller',
|
||||
'Annotation WebSocket',
|
||||
'WebSocket server',
|
||||
'AniList',
|
||||
'Character Dictionary',
|
||||
'Discord Rich Presence',
|
||||
'Jellyfin',
|
||||
'Texthooker',
|
||||
'Yomitan',
|
||||
'Stats dashboard',
|
||||
'Startup warmups',
|
||||
'Logging',
|
||||
'Updates',
|
||||
'Immersion tracking',
|
||||
].map((section, index) => [section, index]),
|
||||
);
|
||||
|
||||
const PATH_ORDER = new Map<string, number>(
|
||||
[
|
||||
'ankiConnect.enabled',
|
||||
'ankiConnect.proxy.enabled',
|
||||
'ankiConnect.isLapis.enabled',
|
||||
'ankiConnect.isKiku.enabled',
|
||||
'subtitleStyle.fontColor',
|
||||
'subtitleStyle.backgroundColor',
|
||||
'subtitleStyle.hoverTokenColor',
|
||||
'subtitleStyle.hoverTokenBackgroundColor',
|
||||
'subtitleStyle.css',
|
||||
'subtitleStyle.primaryDefaultMode',
|
||||
'subtitleStyle.secondary.fontColor',
|
||||
'subtitleStyle.secondary.backgroundColor',
|
||||
'subtitleStyle.secondary.css',
|
||||
'subtitleSidebar.css',
|
||||
'secondarySub.defaultMode',
|
||||
'secondarySub.secondarySubLanguages',
|
||||
'mpv.autoStartSubMiner',
|
||||
'auto_start_overlay',
|
||||
'mpv.pauseUntilOverlayReady',
|
||||
'mpv.socketPath',
|
||||
'mpv.backend',
|
||||
'mpv.subminerBinaryPath',
|
||||
'mpv.aniskipEnabled',
|
||||
'mpv.launchMode',
|
||||
'mpv.executablePath',
|
||||
'mpv.aniskipButtonKey',
|
||||
].map((path, index) => [path, index]),
|
||||
);
|
||||
|
||||
const SUBSECTION_ORDER = new Map<string, number>(
|
||||
[
|
||||
'Known Words',
|
||||
'N+1',
|
||||
'JLPT',
|
||||
'Frequency Highlighting',
|
||||
'Character Names',
|
||||
'Mining & Clipboard',
|
||||
'Toggle & Visibility',
|
||||
'Open Panels',
|
||||
'Playback',
|
||||
'Default Fold State',
|
||||
].map((subsection, index) => [subsection, index]),
|
||||
);
|
||||
|
||||
const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'ankiConnect.knownWords.highlightEnabled': 'Enabled',
|
||||
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
||||
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||
'shortcuts.openCharacterDictionary': 'Open AniList Override',
|
||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
|
||||
'subtitleStyle.css': 'CSS Declarations',
|
||||
'subtitleStyle.secondary.css': 'CSS Declarations',
|
||||
'subtitleSidebar.css': 'CSS Declarations',
|
||||
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
|
||||
'subtitlePosition.yPercent': 'Subtitle Position',
|
||||
'mpv.executablePath': 'mpv Executable Path',
|
||||
'mpv.subminerBinaryPath': 'SubMiner Binary Path',
|
||||
'mpv.socketPath': 'mpv IPC Socket Path',
|
||||
'mpv.autoStartSubMiner': 'Auto-start SubMiner',
|
||||
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||
};
|
||||
|
||||
const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'ankiConnect.pollingRate':
|
||||
'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.',
|
||||
'ankiConnect.isKiku.enabled':
|
||||
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.',
|
||||
'ankiConnect.isLapis.enabled':
|
||||
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
||||
'ankiConnect.isLapis.sentenceCardModel':
|
||||
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||
'subtitleStyle.css':
|
||||
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||
'subtitleStyle.secondary.css':
|
||||
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
|
||||
'subtitleSidebar.css':
|
||||
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
|
||||
'websocket.enabled':
|
||||
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||
'discordPresence.updateIntervalMs':
|
||||
'Minimum interval between presence payload updates, in milliseconds.',
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -119,6 +291,10 @@ function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] {
|
||||
}
|
||||
|
||||
function humanizePath(path: string): string {
|
||||
const override = LABEL_OVERRIDES[path];
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const key = path.split('.').at(-1) ?? path;
|
||||
const spaced = key
|
||||
.replace(/_/g, ' ')
|
||||
@@ -138,7 +314,29 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||
path === 'subtitleSidebar.pauseVideoOnHover'
|
||||
) {
|
||||
return { category: 'viewing', section: 'Playback pause behavior' };
|
||||
return { category: 'behavior', section: 'Playback Behavior' };
|
||||
}
|
||||
if (path === 'subtitleStyle.preserveLineBreaks') {
|
||||
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||
}
|
||||
if (
|
||||
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
|
||||
path === 'ankiConnect.knownWords.decks' ||
|
||||
path === 'ankiConnect.knownWords.matchMode' ||
|
||||
path === 'ankiConnect.knownWords.refreshMinutes'
|
||||
) {
|
||||
return { category: 'behavior', section: 'Known Words' };
|
||||
}
|
||||
if (path === 'ankiConnect.nPlusOne.minSentenceWords') {
|
||||
return { category: 'behavior', section: 'N+1' };
|
||||
}
|
||||
if (
|
||||
path === 'subtitleStyle.frequencyDictionary.matchMode' ||
|
||||
path === 'subtitleStyle.frequencyDictionary.mode' ||
|
||||
path === 'subtitleStyle.frequencyDictionary.sourcePath' ||
|
||||
path === 'subtitleStyle.frequencyDictionary.topX'
|
||||
) {
|
||||
return { category: 'behavior', section: 'Frequency Highlighting' };
|
||||
}
|
||||
if (
|
||||
path.startsWith('ankiConnect.knownWords.') ||
|
||||
@@ -146,62 +344,87 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
path.startsWith('subtitleStyle.frequencyDictionary.') ||
|
||||
path.startsWith('subtitleStyle.jlptColors.') ||
|
||||
path === 'subtitleStyle.enableJlpt' ||
|
||||
path === 'subtitleStyle.knownWordColor' ||
|
||||
path === 'subtitleStyle.nPlusOneColor' ||
|
||||
path === 'subtitleStyle.nameMatchEnabled' ||
|
||||
path === 'subtitleStyle.nameMatchColor'
|
||||
) {
|
||||
return { category: 'viewing', section: 'Annotation display' };
|
||||
return { category: 'appearance', section: 'Annotation Display' };
|
||||
}
|
||||
if (path.startsWith('subtitleStyle.secondary.')) {
|
||||
return { category: 'viewing', section: 'Secondary subtitle appearance' };
|
||||
return { category: 'appearance', section: 'Secondary Subtitle Appearance' };
|
||||
}
|
||||
if (path === 'subtitleStyle.primaryDefaultMode') {
|
||||
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||
}
|
||||
if (path.startsWith('subtitleStyle.')) {
|
||||
return { category: 'viewing', section: 'Primary subtitle appearance' };
|
||||
return { category: 'appearance', section: 'Primary Subtitle Appearance' };
|
||||
}
|
||||
if (path.startsWith('subtitleSidebar.')) {
|
||||
return { category: 'viewing', section: 'Subtitle sidebar' };
|
||||
const sidebarBehaviorPaths = new Set([
|
||||
'subtitleSidebar.enabled',
|
||||
'subtitleSidebar.autoOpen',
|
||||
'subtitleSidebar.autoScroll',
|
||||
'subtitleSidebar.layout',
|
||||
]);
|
||||
return sidebarBehaviorPaths.has(path)
|
||||
? { category: 'behavior', section: 'Subtitle Sidebar Behavior' }
|
||||
: { category: 'appearance', section: 'Subtitle Sidebar Appearance' };
|
||||
}
|
||||
if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) {
|
||||
return { category: 'viewing', section: 'Subtitle behavior' };
|
||||
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.fields.')) {
|
||||
return { category: 'mining-anki', section: 'Note fields' };
|
||||
return { category: 'mining-anki', section: 'Note Fields' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.media.')) {
|
||||
return { category: 'mining-anki', section: 'Media capture' };
|
||||
return { category: 'mining-anki', section: 'Media Capture' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
||||
return { category: 'mining-anki', section: 'Kiku and Lapis' };
|
||||
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.ai.')) {
|
||||
return { category: 'mining-anki', section: 'Anki AI' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.proxy.')) {
|
||||
return { category: 'mining-anki', section: 'AnkiConnect proxy' };
|
||||
return { category: 'mining-anki', section: 'AnkiConnect Proxy' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.')) {
|
||||
return { category: 'mining-anki', section: 'AnkiConnect' };
|
||||
}
|
||||
if (
|
||||
path.startsWith('mpv.') ||
|
||||
path.startsWith('youtube.') ||
|
||||
path.startsWith('youtubeSubgen.') ||
|
||||
path.startsWith('jimaku.') ||
|
||||
path.startsWith('subsync.')
|
||||
path === 'auto_start_overlay' ||
|
||||
path === 'mpv.autoStartSubMiner' ||
|
||||
path === 'mpv.pauseUntilOverlayReady'
|
||||
) {
|
||||
return { category: 'playback-sources', section: topSection(path) };
|
||||
return { category: 'behavior', section: 'Playback Behavior' };
|
||||
}
|
||||
if (path === 'mpv.aniskipButtonKey') {
|
||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||
}
|
||||
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
|
||||
return { category: 'behavior', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('jimaku.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('subsync.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||
}
|
||||
if (path.startsWith('shortcuts.')) {
|
||||
return { category: 'input', section: 'Overlay shortcuts' };
|
||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||
}
|
||||
if (path === 'keybindings') {
|
||||
return { category: 'input', section: 'MPV keybindings' };
|
||||
return { category: 'input', section: 'MPV Keybindings' };
|
||||
}
|
||||
if (path.startsWith('controller.')) {
|
||||
return { category: 'input', section: 'Controller' };
|
||||
}
|
||||
if (
|
||||
path.startsWith('ai.') ||
|
||||
path.startsWith('anilist.') ||
|
||||
path.startsWith('yomitan.') ||
|
||||
path.startsWith('jellyfin.') ||
|
||||
path.startsWith('discordPresence.') ||
|
||||
@@ -211,13 +434,18 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('anilist.characterDictionary.')) {
|
||||
return { category: 'integrations', section: 'Character Dictionary' };
|
||||
}
|
||||
if (path.startsWith('anilist.')) {
|
||||
return { category: 'integrations', section: 'AniList' };
|
||||
}
|
||||
if (
|
||||
path.startsWith('immersionTracking.') ||
|
||||
path.startsWith('stats.') ||
|
||||
path.startsWith('updates.') ||
|
||||
path.startsWith('startupWarmups.') ||
|
||||
path.startsWith('logging.') ||
|
||||
path === 'auto_start_overlay'
|
||||
path.startsWith('logging.')
|
||||
) {
|
||||
return { category: 'tracking-app', section: topSection(path) };
|
||||
}
|
||||
@@ -235,23 +463,40 @@ function topSection(path: string): string {
|
||||
jimaku: 'Jimaku',
|
||||
jellyfin: 'Jellyfin',
|
||||
logging: 'Logging',
|
||||
mpv: 'mpv launcher',
|
||||
mpv: 'mpv Playback',
|
||||
stats: 'Stats dashboard',
|
||||
startupWarmups: 'Startup warmups',
|
||||
subsync: 'Auto subtitle sync',
|
||||
subsync: 'Subtitle Sync',
|
||||
texthooker: 'Texthooker',
|
||||
updates: 'Updates',
|
||||
websocket: 'WebSocket server',
|
||||
yomitan: 'Yomitan',
|
||||
youtube: 'YouTube playback',
|
||||
youtube: 'YouTube Playback Settings',
|
||||
youtubeSubgen: 'YouTube subtitle generation',
|
||||
auto_start_overlay: 'Overlay startup',
|
||||
auto_start_overlay: 'Playback Behavior',
|
||||
};
|
||||
return labels[top] ?? humanizePath(top);
|
||||
}
|
||||
|
||||
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
||||
if (SECRET_PATHS.has(path)) return 'secret';
|
||||
if (getSubtitleCssScopeForPath(path)) return 'css-declarations';
|
||||
if (path === 'keybindings') return 'mpv-keybindings';
|
||||
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
|
||||
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
|
||||
if (path.startsWith('ankiConnect.fields.')) return 'anki-field';
|
||||
if (path.startsWith('shortcuts.'))
|
||||
return path.endsWith('multiCopyTimeoutMs') ? 'number' : 'keyboard-shortcut';
|
||||
if (path === 'mpv.aniskipButtonKey') return 'mpv-key';
|
||||
if (
|
||||
path === 'subtitleSidebar.toggleKey' ||
|
||||
path === 'stats.toggleKey' ||
|
||||
path === 'stats.markWatchedKey'
|
||||
) {
|
||||
return 'key-code';
|
||||
}
|
||||
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
|
||||
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
|
||||
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
|
||||
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
|
||||
if (Array.isArray(value)) return 'string-list';
|
||||
@@ -266,6 +511,132 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
function subsectionForPath(path: string): string | undefined {
|
||||
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
|
||||
if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
|
||||
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
|
||||
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
|
||||
if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) {
|
||||
return 'JLPT';
|
||||
}
|
||||
if (
|
||||
path === 'subtitleStyle.frequencyDictionary.enabled' ||
|
||||
path === 'subtitleStyle.frequencyDictionary.singleColor' ||
|
||||
path === 'subtitleStyle.frequencyDictionary.bandedColors'
|
||||
) {
|
||||
return 'Frequency Highlighting';
|
||||
}
|
||||
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
|
||||
return 'Character Names';
|
||||
}
|
||||
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
|
||||
return 'Default Fold State';
|
||||
}
|
||||
if (path === 'anilist.characterDictionary.collapsibleSections.characterInformation') {
|
||||
return 'Default Fold State';
|
||||
}
|
||||
if (path === 'anilist.characterDictionary.collapsibleSections.voicedBy') {
|
||||
return 'Default Fold State';
|
||||
}
|
||||
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
||||
return 'Toggle & Visibility';
|
||||
}
|
||||
if (path === 'mpv.aniskipButtonKey') {
|
||||
return 'Playback';
|
||||
}
|
||||
if (path.startsWith('shortcuts.')) {
|
||||
const leaf = path.split('.').at(-1) ?? '';
|
||||
if (
|
||||
leaf === 'copySubtitle' ||
|
||||
leaf === 'copySubtitleMultiple' ||
|
||||
leaf === 'mineSentence' ||
|
||||
leaf === 'mineSentenceMultiple' ||
|
||||
leaf === 'updateLastCardFromClipboard' ||
|
||||
leaf === 'triggerFieldGrouping' ||
|
||||
leaf === 'markAudioCard'
|
||||
) {
|
||||
return 'Mining & Clipboard';
|
||||
}
|
||||
if (
|
||||
leaf === 'toggleVisibleOverlayGlobal' ||
|
||||
leaf === 'toggleSubtitleSidebar' ||
|
||||
leaf === 'toggleSecondarySub' ||
|
||||
leaf === 'toggleStatsOverlay' ||
|
||||
leaf === 'markWatched'
|
||||
) {
|
||||
return 'Toggle & Visibility';
|
||||
}
|
||||
if (
|
||||
leaf === 'openCharacterDictionary' ||
|
||||
leaf === 'openRuntimeOptions' ||
|
||||
leaf === 'openJimaku' ||
|
||||
leaf === 'openSessionHelp' ||
|
||||
leaf === 'openControllerSelect' ||
|
||||
leaf === 'openControllerDebug'
|
||||
) {
|
||||
return 'Open Panels';
|
||||
}
|
||||
if (leaf === 'triggerSubsync') return 'Playback';
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isFeatureToggle(field: ConfigSettingsField): boolean {
|
||||
if (field.control !== 'boolean') return false;
|
||||
const leaf = field.configPath.split('.').at(-1) ?? field.configPath;
|
||||
return (
|
||||
leaf === 'enabled' ||
|
||||
leaf.startsWith('enable') ||
|
||||
leaf.endsWith('Enabled') ||
|
||||
field.label.startsWith('Enable ')
|
||||
);
|
||||
}
|
||||
|
||||
function fieldTypeRank(field: ConfigSettingsField): number {
|
||||
if (field.control !== 'boolean') return 2;
|
||||
return isFeatureToggle(field) ? 0 : 1;
|
||||
}
|
||||
|
||||
function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
|
||||
const category = CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
|
||||
if (category !== 0) return category;
|
||||
|
||||
const section =
|
||||
(SECTION_ORDER.get(a.section) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(SECTION_ORDER.get(b.section) ?? Number.MAX_SAFE_INTEGER);
|
||||
if (section !== 0) return section;
|
||||
|
||||
const sectionName = a.section.localeCompare(b.section);
|
||||
if (sectionName !== 0) return sectionName;
|
||||
|
||||
const aSubOrder =
|
||||
a.subsection === undefined
|
||||
? -1
|
||||
: (SUBSECTION_ORDER.get(a.subsection) ?? Number.MAX_SAFE_INTEGER);
|
||||
const bSubOrder =
|
||||
b.subsection === undefined
|
||||
? -1
|
||||
: (SUBSECTION_ORDER.get(b.subsection) ?? Number.MAX_SAFE_INTEGER);
|
||||
const subsection = aSubOrder - bSubOrder;
|
||||
if (subsection !== 0) return subsection;
|
||||
|
||||
const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? '');
|
||||
if (subsectionName !== 0) return subsectionName;
|
||||
|
||||
const type = fieldTypeRank(a) - fieldTypeRank(b);
|
||||
if (type !== 0) return type;
|
||||
|
||||
const pathOrder =
|
||||
(PATH_ORDER.get(a.configPath) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(PATH_ORDER.get(b.configPath) ?? Number.MAX_SAFE_INTEGER);
|
||||
if (pathOrder !== 0) return pathOrder;
|
||||
|
||||
const label = a.label.localeCompare(b.label);
|
||||
if (label !== 0) return label;
|
||||
return a.configPath.localeCompare(b.configPath);
|
||||
}
|
||||
|
||||
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
if (
|
||||
path === 'keybindings' ||
|
||||
@@ -273,7 +644,29 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
pathStartsWith(path, 'subtitleStyle') ||
|
||||
pathStartsWith(path, 'subtitleSidebar') ||
|
||||
path === 'secondarySub.defaultMode' ||
|
||||
pathStartsWith(path, 'ankiConnect.ai')
|
||||
path === 'ankiConnect.ai.enabled' ||
|
||||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
||||
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
||||
path === 'ankiConnect.knownWords.refreshMinutes' ||
|
||||
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
|
||||
path === 'ankiConnect.knownWords.matchMode' ||
|
||||
path === 'ankiConnect.knownWords.decks' ||
|
||||
path === 'ankiConnect.nPlusOne.enabled' ||
|
||||
path === 'ankiConnect.nPlusOne.minSentenceWords' ||
|
||||
path === 'ankiConnect.fields.word' ||
|
||||
path === 'ankiConnect.fields.audio' ||
|
||||
path === 'ankiConnect.fields.image' ||
|
||||
path === 'ankiConnect.fields.sentence' ||
|
||||
path === 'ankiConnect.fields.miscInfo' ||
|
||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||
path === 'mpv.aniskipButtonKey' ||
|
||||
path === 'stats.toggleKey' ||
|
||||
path === 'stats.markWatchedKey' ||
|
||||
path === 'logging.level' ||
|
||||
path === 'youtube.primarySubLanguages' ||
|
||||
pathStartsWith(path, 'jimaku') ||
|
||||
pathStartsWith(path, 'subsync')
|
||||
) {
|
||||
return 'hot-reload';
|
||||
}
|
||||
@@ -283,13 +676,15 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
||||
const option = OPTION_BY_PATH.get(leaf.path);
|
||||
const { category, section } = categoryAndSection(leaf.path);
|
||||
const description = DESCRIPTION_OVERRIDES[leaf.path] ?? option?.description;
|
||||
return {
|
||||
id: leaf.path,
|
||||
label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path),
|
||||
description: option?.description ?? `${humanizePath(leaf.path)} setting.`,
|
||||
description: description ?? `${humanizePath(leaf.path)} setting.`,
|
||||
configPath: leaf.path,
|
||||
category,
|
||||
section,
|
||||
...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}),
|
||||
control: controlForPath(leaf.path, leaf.value),
|
||||
defaultValue: leaf.value,
|
||||
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
|
||||
@@ -299,6 +694,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
||||
leaf.path.startsWith('immersionTracking.retention.') ||
|
||||
leaf.path.startsWith('youtubeSubgen.'),
|
||||
secret: SECRET_PATHS.has(leaf.path),
|
||||
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -306,13 +702,7 @@ export function buildConfigSettingsRegistry(
|
||||
defaultConfig: ResolvedConfig = DEFAULT_CONFIG,
|
||||
): ConfigSettingsField[] {
|
||||
const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path));
|
||||
return leaves.map(fieldForLeaf).sort((a, b) => {
|
||||
const category = a.category.localeCompare(b.category);
|
||||
if (category !== 0) return category;
|
||||
const section = a.section.localeCompare(b.section);
|
||||
if (section !== 0) return section;
|
||||
return a.configPath.localeCompare(b.configPath);
|
||||
});
|
||||
return leaves.map(fieldForLeaf).sort(compareFields);
|
||||
}
|
||||
|
||||
export function getConfigSettingsCoverage(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { RawConfig } from '../types/config';
|
||||
import type { ConfigSettingsPatchOperation } from '../types/settings';
|
||||
import {
|
||||
buildSubtitleCssDeclarationObject,
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssPath,
|
||||
type SubtitleCssScope,
|
||||
} from '../settings/subtitle-style-css';
|
||||
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
|
||||
|
||||
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
export type LegacySubtitleStyleCssMigrationResult =
|
||||
| {
|
||||
migrated: true;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}
|
||||
| {
|
||||
migrated: false;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function hasPath(root: unknown, path: string): boolean {
|
||||
let current = root;
|
||||
const segments = path.split('.');
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (!isRecord(current) || !Object.hasOwn(current, segment)) {
|
||||
return false;
|
||||
}
|
||||
if (index === segments.length - 1) {
|
||||
return true;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMigratableLegacySubtitleCssValue(path: string, value: unknown): boolean {
|
||||
if (path === 'subtitleStyle.hoverTokenColor') {
|
||||
return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim());
|
||||
}
|
||||
if (path === 'subtitleStyle.hoverTokenBackgroundColor') {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildLegacySubtitleStyleCssMigrationOperations(
|
||||
rawConfig: RawConfig,
|
||||
): ConfigSettingsPatchOperation[] {
|
||||
const operations: ConfigSettingsPatchOperation[] = [];
|
||||
|
||||
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||
const cssPath = getSubtitleCssPath(scope);
|
||||
const values: Record<string, unknown> = {
|
||||
[cssPath]: getValueAtPath(rawConfig, cssPath),
|
||||
};
|
||||
const legacyPaths = getSubtitleCssManagedConfigPaths(scope).filter(
|
||||
(legacyPath) =>
|
||||
hasPath(rawConfig, legacyPath) &&
|
||||
isMigratableLegacySubtitleCssValue(legacyPath, getValueAtPath(rawConfig, legacyPath)),
|
||||
);
|
||||
if (legacyPaths.length === 0) continue;
|
||||
|
||||
for (const legacyPath of legacyPaths) {
|
||||
values[legacyPath] = getValueAtPath(rawConfig, legacyPath);
|
||||
}
|
||||
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path: cssPath,
|
||||
value: buildSubtitleCssDeclarationObject(scope, values),
|
||||
});
|
||||
for (const legacyPath of legacyPaths) {
|
||||
operations.push({ op: 'reset', path: legacyPath });
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
export function applyLegacySubtitleStyleCssMigrationToContent(options: {
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}): LegacySubtitleStyleCssMigrationResult {
|
||||
const operations = buildLegacySubtitleStyleCssMigrationOperations(options.rawConfig);
|
||||
if (operations.length === 0) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: options.content,
|
||||
operations,
|
||||
previousWarnings: [],
|
||||
});
|
||||
if (!result.ok) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: true,
|
||||
content: result.content,
|
||||
rawConfig: result.rawConfig,
|
||||
};
|
||||
}
|
||||
@@ -6,11 +6,18 @@ import {
|
||||
DEFAULT_KEYBINDINGS,
|
||||
deepCloneConfig,
|
||||
} from './definitions';
|
||||
import {
|
||||
buildSubtitleCssDeclarationObject,
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssPath,
|
||||
type SubtitleCssScope,
|
||||
} from '../settings/subtitle-style-css';
|
||||
|
||||
const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
||||
const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
|
||||
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
|
||||
);
|
||||
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||
|
||||
function normalizeCommentText(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
|
||||
@@ -18,7 +25,9 @@ function normalizeCommentText(value: string): string {
|
||||
|
||||
function humanizeKey(key: string): string {
|
||||
const spaced = key
|
||||
.replace(/^--/, '')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.toLowerCase();
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
@@ -42,6 +51,62 @@ function buildInlineOptionComment(path: string, value: unknown): string {
|
||||
return description;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function setValueAtPath(root: unknown, path: string, value: unknown): void {
|
||||
const segments = path.split('.').filter(Boolean);
|
||||
let current = root;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (!isRecord(current)) return;
|
||||
if (index === segments.length - 1) {
|
||||
current[segment] = value;
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
}
|
||||
|
||||
function deleteValueAtPath(root: unknown, path: string): void {
|
||||
const segments = path.split('.').filter(Boolean);
|
||||
let current = root;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (!isRecord(current)) return;
|
||||
if (index === segments.length - 1) {
|
||||
delete current[segment];
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
}
|
||||
|
||||
function foldSubtitleCssManagedDefaults(templateConfig: ResolvedConfig): void {
|
||||
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||
const cssPath = getSubtitleCssPath(scope);
|
||||
const values: Record<string, unknown> = {
|
||||
[cssPath]: getValueAtPath(templateConfig, cssPath),
|
||||
};
|
||||
const managedPaths = getSubtitleCssManagedConfigPaths(scope);
|
||||
for (const managedPath of managedPaths) {
|
||||
values[managedPath] = getValueAtPath(templateConfig, managedPath);
|
||||
}
|
||||
setValueAtPath(templateConfig, cssPath, buildSubtitleCssDeclarationObject(scope, values));
|
||||
for (const managedPath of managedPaths) {
|
||||
deleteValueAtPath(templateConfig, managedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderValue(value: unknown, indent = 0, path = ''): string {
|
||||
const pad = ' '.repeat(indent);
|
||||
const nextPad = ' '.repeat(indent + 2);
|
||||
@@ -106,6 +171,7 @@ function renderSection(
|
||||
|
||||
function createTemplateConfig(config: ResolvedConfig): ResolvedConfig {
|
||||
const templateConfig = deepCloneConfig(config);
|
||||
foldSubtitleCssManagedDefaults(templateConfig);
|
||||
if (templateConfig.keybindings.length === 0) {
|
||||
templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({
|
||||
key: binding.key,
|
||||
|
||||
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
markWatched: false,
|
||||
toggleSubtitleSidebar: false,
|
||||
openRuntimeOptions: false,
|
||||
openSessionHelp: false,
|
||||
@@ -69,6 +70,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
help: false,
|
||||
appPing: false,
|
||||
autoStartOverlay: false,
|
||||
generateConfig: false,
|
||||
backupOverwrite: false,
|
||||
@@ -91,6 +93,9 @@ function createDeps(overrides: Partial<AppLifecycleServiceDeps> = {}) {
|
||||
quitApp: () => {
|
||||
calls.push('quitApp');
|
||||
},
|
||||
exitApp: (code) => {
|
||||
calls.push(`exit:${code}`);
|
||||
},
|
||||
onSecondInstance: () => {},
|
||||
handleCliCommand: () => {},
|
||||
printHelp: () => {
|
||||
@@ -136,3 +141,179 @@ test('startAppLifecycle still acquires lock for startup commands', () => {
|
||||
|
||||
assert.equal(getLockCalls(), 1);
|
||||
});
|
||||
|
||||
test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => {
|
||||
const { deps, calls, getLockCalls } = createDeps({
|
||||
shouldStartApp: () => false,
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ appPing: true }), deps);
|
||||
|
||||
assert.equal(getLockCalls(), 1);
|
||||
assert.deepEqual(calls, ['exit:1']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle app ping exits zero immediately when another instance owns the lock', () => {
|
||||
let lockCalls = 0;
|
||||
const { deps, calls } = createDeps({
|
||||
shouldStartApp: () => false,
|
||||
requestSingleInstanceLock: () => {
|
||||
lockCalls += 1;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ appPing: true }), deps);
|
||||
|
||||
assert.equal(lockCalls, 1);
|
||||
assert.deepEqual(calls, ['exit:0']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle queues second-instance commands until app ready runtime completes', async () => {
|
||||
const handled: string[] = [];
|
||||
let secondInstanceHandler: ((_event: unknown, argv: string[]) => void) | null = null;
|
||||
let readyHandler: (() => Promise<void>) | null = null;
|
||||
let releaseReady: (() => void) | null = null;
|
||||
const readyFinished = new Promise<void>((resolve) => {
|
||||
releaseReady = resolve;
|
||||
});
|
||||
|
||||
const { deps } = createDeps({
|
||||
shouldStartApp: () => true,
|
||||
onSecondInstance: (handler) => {
|
||||
secondInstanceHandler = handler;
|
||||
},
|
||||
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
|
||||
handleCliCommand: (args, source) => {
|
||||
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
|
||||
},
|
||||
whenReady: (handler) => {
|
||||
readyHandler = handler;
|
||||
},
|
||||
onReady: async () => {
|
||||
await readyFinished;
|
||||
handled.push('ready');
|
||||
},
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ background: true }), deps);
|
||||
|
||||
const runSecondInstance = (argv: string[]) => {
|
||||
assert.ok(secondInstanceHandler);
|
||||
(secondInstanceHandler as (_event: unknown, argv: string[]) => void)({}, argv);
|
||||
};
|
||||
const runReady = () => {
|
||||
assert.ok(readyHandler);
|
||||
return (readyHandler as () => Promise<void>)();
|
||||
};
|
||||
|
||||
runSecondInstance(['SubMiner', '--start']);
|
||||
assert.deepEqual(handled, []);
|
||||
|
||||
const readyRun = runReady();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(handled, []);
|
||||
|
||||
assert.ok(releaseReady);
|
||||
(releaseReady as () => void)();
|
||||
await readyRun;
|
||||
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start']);
|
||||
|
||||
runSecondInstance(['SubMiner', '--start']);
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle routes control socket commands through the second-instance queue', async () => {
|
||||
const handled: string[] = [];
|
||||
let controlArgvHandler: ((argv: string[]) => void) | null = null;
|
||||
let readyHandler: (() => Promise<void>) | null = null;
|
||||
let releaseReady: (() => void) | null = null;
|
||||
const readyFinished = new Promise<void>((resolve) => {
|
||||
releaseReady = resolve;
|
||||
});
|
||||
|
||||
const { deps } = createDeps({
|
||||
shouldStartApp: () => true,
|
||||
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
|
||||
handleCliCommand: (args, source) => {
|
||||
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
|
||||
},
|
||||
startControlServer: (handler) => {
|
||||
controlArgvHandler = handler;
|
||||
return () => {
|
||||
handled.push('control-close');
|
||||
};
|
||||
},
|
||||
whenReady: (handler) => {
|
||||
readyHandler = handler;
|
||||
},
|
||||
onReady: async () => {
|
||||
await readyFinished;
|
||||
handled.push('ready');
|
||||
},
|
||||
});
|
||||
|
||||
let willQuitHandler: (() => void) | null = null;
|
||||
deps.onWillQuit = (handler) => {
|
||||
willQuitHandler = handler;
|
||||
};
|
||||
|
||||
startAppLifecycle(makeArgs({ background: true }), deps);
|
||||
|
||||
assert.ok(controlArgvHandler);
|
||||
(controlArgvHandler as (argv: string[]) => void)(['--start']);
|
||||
assert.deepEqual(handled, []);
|
||||
|
||||
assert.ok(readyHandler);
|
||||
const readyRun = (readyHandler as () => Promise<void>)();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(handled, []);
|
||||
|
||||
assert.ok(releaseReady);
|
||||
(releaseReady as () => void)();
|
||||
await readyRun;
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start']);
|
||||
|
||||
assert.ok(willQuitHandler);
|
||||
(willQuitHandler as () => void)();
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
|
||||
let windowAllClosedHandler: (() => void) | null = null;
|
||||
const { deps, calls } = createDeps({
|
||||
shouldStartApp: () => true,
|
||||
isDarwinPlatform: () => true,
|
||||
shouldQuitOnWindowAllClosed: () => true,
|
||||
onWindowAllClosed: (handler) => {
|
||||
windowAllClosedHandler = handler;
|
||||
},
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ settings: true }), deps);
|
||||
|
||||
const handler = windowAllClosedHandler as (() => void) | null;
|
||||
assert.ok(handler);
|
||||
handler();
|
||||
assert.deepEqual(calls, ['quitApp']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle quits macOS setup-only launch when all windows close', () => {
|
||||
let windowAllClosedHandler: (() => void) | null = null;
|
||||
const { deps, calls } = createDeps({
|
||||
shouldStartApp: () => true,
|
||||
isDarwinPlatform: () => true,
|
||||
shouldQuitOnWindowAllClosed: () => true,
|
||||
onWindowAllClosed: (handler) => {
|
||||
windowAllClosedHandler = handler;
|
||||
},
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ setup: true }), deps);
|
||||
|
||||
const handler = windowAllClosedHandler as (() => void) | null;
|
||||
assert.ok(handler);
|
||||
handler();
|
||||
assert.deepEqual(calls, ['quitApp']);
|
||||
});
|
||||
|
||||
@@ -8,10 +8,12 @@ export interface AppLifecycleServiceDeps {
|
||||
parseArgs: (argv: string[]) => CliArgs;
|
||||
requestSingleInstanceLock: () => boolean;
|
||||
quitApp: () => void;
|
||||
exitApp: (code: number) => void;
|
||||
onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void;
|
||||
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
|
||||
printHelp: () => void;
|
||||
logNoRunningInstance: () => void;
|
||||
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||
whenReady: (handler: () => Promise<void>) => void;
|
||||
onWindowAllClosed: (handler: () => void) => void;
|
||||
onWillQuit: (handler: () => void) => void;
|
||||
@@ -27,6 +29,7 @@ export interface AppLifecycleServiceDeps {
|
||||
interface AppLike {
|
||||
requestSingleInstanceLock: () => boolean;
|
||||
quit: () => void;
|
||||
exit?: (exitCode?: number) => void;
|
||||
on: (...args: any[]) => unknown;
|
||||
whenReady: () => Promise<void>;
|
||||
}
|
||||
@@ -39,6 +42,7 @@ export interface AppLifecycleDepsRuntimeOptions {
|
||||
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
|
||||
printHelp: () => void;
|
||||
logNoRunningInstance: () => void;
|
||||
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||
onReady: () => Promise<void>;
|
||||
onWillQuitCleanup: () => void;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
@@ -54,12 +58,21 @@ export function createAppLifecycleDepsRuntime(
|
||||
parseArgs: options.parseArgs,
|
||||
requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(),
|
||||
quitApp: () => options.app.quit(),
|
||||
exitApp: (code) => {
|
||||
if (options.app.exit) {
|
||||
options.app.exit(code);
|
||||
return;
|
||||
}
|
||||
process.exitCode = code;
|
||||
options.app.quit();
|
||||
},
|
||||
onSecondInstance: (handler) => {
|
||||
options.app.on('second-instance', handler as (...args: unknown[]) => void);
|
||||
},
|
||||
handleCliCommand: options.handleCliCommand,
|
||||
printHelp: options.printHelp,
|
||||
logNoRunningInstance: options.logNoRunningInstance,
|
||||
startControlServer: options.startControlServer,
|
||||
whenReady: (handler) => {
|
||||
options.app
|
||||
.whenReady()
|
||||
@@ -94,17 +107,52 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
}
|
||||
|
||||
const gotTheLock = deps.requestSingleInstanceLock();
|
||||
if (initialArgs.appPing) {
|
||||
deps.exitApp(gotTheLock ? 1 : 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!gotTheLock) {
|
||||
deps.quitApp();
|
||||
return;
|
||||
}
|
||||
|
||||
deps.onSecondInstance((_event, argv) => {
|
||||
let appReadyRuntimeComplete = false;
|
||||
const pendingSecondInstanceCommands: CliArgs[] = [];
|
||||
let stopControlServer: (() => void) | null = null;
|
||||
const handleSecondInstanceCommand = (args: CliArgs): void => {
|
||||
try {
|
||||
deps.handleCliCommand(deps.parseArgs(argv), 'second-instance');
|
||||
deps.handleCliCommand(args, 'second-instance');
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle second-instance CLI command:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const flushPendingSecondInstanceCommands = (): void => {
|
||||
while (pendingSecondInstanceCommands.length > 0) {
|
||||
const nextArgs = pendingSecondInstanceCommands.shift();
|
||||
if (nextArgs) {
|
||||
handleSecondInstanceCommand(nextArgs);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchSecondInstanceArgv = (argv: string[]): void => {
|
||||
try {
|
||||
const nextArgs = deps.parseArgs(argv);
|
||||
if (!appReadyRuntimeComplete) {
|
||||
pendingSecondInstanceCommands.push(nextArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
handleSecondInstanceCommand(nextArgs);
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle second-instance CLI command:', error);
|
||||
}
|
||||
};
|
||||
|
||||
deps.onSecondInstance((_event, argv) => {
|
||||
dispatchSecondInstanceArgv(argv);
|
||||
});
|
||||
|
||||
if (!deps.shouldStartApp(initialArgs)) {
|
||||
@@ -117,17 +165,30 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
stopControlServer = deps.startControlServer?.(dispatchSecondInstanceArgv) ?? null;
|
||||
} catch (error) {
|
||||
logger.error('Failed to start app control socket:', error);
|
||||
}
|
||||
|
||||
deps.whenReady(async () => {
|
||||
await deps.onReady();
|
||||
appReadyRuntimeComplete = true;
|
||||
flushPendingSecondInstanceCommands();
|
||||
});
|
||||
|
||||
deps.onWindowAllClosed(() => {
|
||||
if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) {
|
||||
if (
|
||||
deps.shouldQuitOnWindowAllClosed() &&
|
||||
(!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup)
|
||||
) {
|
||||
deps.quitApp();
|
||||
}
|
||||
});
|
||||
|
||||
deps.onWillQuit(() => {
|
||||
stopControlServer?.();
|
||||
stopControlServer = null;
|
||||
deps.onWillQuitCleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CliArgs } from '../../cli/args';
|
||||
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
|
||||
import {
|
||||
CliCommandServiceDeps,
|
||||
createCliCommandDepsRuntime,
|
||||
handleCliCommand,
|
||||
} from './cli-command';
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
@@ -15,8 +19,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -32,6 +36,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
markWatched: false,
|
||||
toggleSubtitleSidebar: false,
|
||||
refreshKnownWords: false,
|
||||
openRuntimeOptions: false,
|
||||
@@ -500,6 +505,132 @@ test('handleCliCommand applies socket path and connects on start', () => {
|
||||
assert.ok(calls.includes('connectMpvClient'));
|
||||
});
|
||||
|
||||
test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
|
||||
const calls: string[] = [];
|
||||
const client = {
|
||||
setSocketPath: (socketPath: string) => {
|
||||
calls.push(`setSocketPath:${socketPath}`);
|
||||
},
|
||||
connect: () => {
|
||||
calls.push('connect');
|
||||
},
|
||||
reconnect: () => {
|
||||
calls.push('reconnect');
|
||||
},
|
||||
};
|
||||
const deps = createCliCommandDepsRuntime({
|
||||
mpv: {
|
||||
getSocketPath: () => '/tmp/runtime.sock',
|
||||
setSocketPath: () => {},
|
||||
getClient: () => client,
|
||||
showOsd: () => {},
|
||||
},
|
||||
texthooker: {
|
||||
service: { isRunning: () => false, start: () => {} },
|
||||
getPort: () => 5174,
|
||||
setPort: () => {},
|
||||
getWebsocketUrl: () => undefined,
|
||||
shouldOpenBrowser: () => false,
|
||||
openInBrowser: () => {},
|
||||
},
|
||||
overlay: {
|
||||
isInitialized: () => true,
|
||||
initialize: () => {},
|
||||
toggleVisible: () => {},
|
||||
togglePrimarySubtitleBar: () => {},
|
||||
setVisible: () => {},
|
||||
},
|
||||
mining: {
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
mineSentenceCard: async () => {},
|
||||
startPendingMineSentenceMultiple: () => {},
|
||||
updateLastCardFromClipboard: async () => {},
|
||||
refreshKnownWords: async () => {},
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
},
|
||||
anilist: {
|
||||
getStatus: () => ({
|
||||
tokenStatus: 'not_checked',
|
||||
tokenSource: 'none',
|
||||
tokenMessage: null,
|
||||
tokenResolvedAt: null,
|
||||
tokenErrorAt: null,
|
||||
queuePending: 0,
|
||||
queueReady: 0,
|
||||
queueDeadLetter: 0,
|
||||
queueLastAttemptAt: null,
|
||||
queueLastError: null,
|
||||
}),
|
||||
clearToken: () => {},
|
||||
openSetup: () => {},
|
||||
getQueueStatus: () => ({
|
||||
pending: 0,
|
||||
ready: 0,
|
||||
deadLetter: 0,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
}),
|
||||
retryQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
dictionary: {
|
||||
generate: async () => ({
|
||||
zipPath: '/tmp/test.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 0,
|
||||
}),
|
||||
getSelection: async () => ({
|
||||
seriesKey: 'test',
|
||||
guessTitle: null,
|
||||
current: null,
|
||||
override: null,
|
||||
candidates: [],
|
||||
}),
|
||||
setSelection: async () => ({
|
||||
ok: true,
|
||||
seriesKey: 'test',
|
||||
selected: { id: 1, title: 'Test', episodes: null },
|
||||
staleMediaIds: [],
|
||||
}),
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: () => {},
|
||||
runStatsCommand: async () => {},
|
||||
runCommand: async () => {},
|
||||
},
|
||||
ui: {
|
||||
openFirstRunSetup: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
},
|
||||
app: {
|
||||
stop: () => {},
|
||||
hasMainWindow: () => true,
|
||||
runUpdateCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
},
|
||||
dispatchSessionAction: async () => {},
|
||||
getMultiCopyTimeoutMs: () => 2500,
|
||||
schedule: () => undefined,
|
||||
log: () => {},
|
||||
logDebug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
deps.setMpvClientSocketPath('/tmp/runtime.sock');
|
||||
deps.connectMpvClient();
|
||||
|
||||
assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
|
||||
});
|
||||
|
||||
test('handleCliCommand warns when texthooker port override used while running', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
isTexthookerRunning: () => true,
|
||||
@@ -585,8 +716,8 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
||||
args: Partial<CliArgs>;
|
||||
expected: string;
|
||||
}> = [
|
||||
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
||||
{ args: { configSettings: true }, expected: 'openConfigSettingsWindow' },
|
||||
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
||||
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
|
||||
{
|
||||
args: { showVisibleOverlay: true },
|
||||
expected: 'setVisibleOverlayVisible:true',
|
||||
@@ -607,6 +738,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
||||
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
|
||||
{ args: { togglePrimarySubtitleBar: true }, expected: 'togglePrimarySubtitleBar' },
|
||||
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
|
||||
{ args: { markWatched: true }, expected: 'dispatchSessionAction' },
|
||||
{
|
||||
args: { openRuntimeOptions: true },
|
||||
expected: 'openRuntimeOptionsPalette',
|
||||
@@ -653,6 +785,22 @@ test('handleCliCommand dispatches cycle-runtime-option session action', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test('handleCliCommand dispatches mark-watched session action', async () => {
|
||||
let request: unknown = null;
|
||||
const { deps } = createDeps({
|
||||
dispatchSessionAction: async (nextRequest) => {
|
||||
request = nextRequest;
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ markWatched: true }), 'initial', deps);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(request, {
|
||||
actionId: 'markWatched',
|
||||
});
|
||||
});
|
||||
|
||||
test('handleCliCommand logs AniList status details', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
|
||||
|
||||
@@ -115,6 +115,7 @@ export interface CliCommandServiceDeps {
|
||||
interface MpvClientLike {
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
connect: () => void;
|
||||
reconnect?: () => void;
|
||||
}
|
||||
|
||||
interface TexthookerServiceLike {
|
||||
@@ -235,6 +236,10 @@ export function createCliCommandDepsRuntime(
|
||||
connectMpvClient: () => {
|
||||
const client = options.mpv.getClient();
|
||||
if (!client) return;
|
||||
if (client.reconnect) {
|
||||
client.reconnect();
|
||||
return;
|
||||
}
|
||||
client.connect();
|
||||
},
|
||||
isTexthookerRunning: () => options.texthooker.service.isRunning(),
|
||||
@@ -386,9 +391,9 @@ export function handleCliCommand(
|
||||
} else if (args.setup) {
|
||||
deps.openFirstRunSetup(true);
|
||||
deps.logDebug('Opened first-run setup flow.');
|
||||
} else if (args.settings) {
|
||||
} else if (args.yomitan) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.configSettings) {
|
||||
} else if (args.settings) {
|
||||
deps.openConfigSettingsWindow();
|
||||
} else if (args.show || args.showVisibleOverlay) {
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
@@ -469,6 +474,8 @@ export function handleCliCommand(
|
||||
'toggleStatsOverlay',
|
||||
'Stats toggle failed',
|
||||
);
|
||||
} else if (args.markWatched) {
|
||||
dispatchCliSessionAction({ actionId: 'markWatched' }, 'markWatched', 'Mark watched failed');
|
||||
} else if (args.toggleSubtitleSidebar) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'toggleSubtitleSidebar' },
|
||||
|
||||
@@ -18,6 +18,80 @@ test('classifyConfigHotReloadDiff separates hot and restart-required fields', ()
|
||||
assert.deepEqual(diff.restartRequiredFields, ['websocket']);
|
||||
});
|
||||
|
||||
test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => {
|
||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
next.mpv.aniskipButtonKey = 'F8';
|
||||
next.stats.toggleKey = 'F8';
|
||||
next.stats.markWatchedKey = 'F9';
|
||||
next.logging.level = 'debug';
|
||||
next.youtube.primarySubLanguages = ['ja', 'en'];
|
||||
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
|
||||
next.subsync.replace = !prev.subsync.replace;
|
||||
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
|
||||
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
|
||||
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
|
||||
next.ankiConnect.knownWords.addMinedWordsImmediately =
|
||||
!prev.ankiConnect.knownWords.addMinedWordsImmediately;
|
||||
next.ankiConnect.knownWords.matchMode =
|
||||
prev.ankiConnect.knownWords.matchMode === 'headword' ? 'surface' : 'headword';
|
||||
next.ankiConnect.knownWords.decks = { Anime: ['Mining'] };
|
||||
next.ankiConnect.nPlusOne.enabled = !prev.ankiConnect.nPlusOne.enabled;
|
||||
next.ankiConnect.nPlusOne.minSentenceWords = prev.ankiConnect.nPlusOne.minSentenceWords + 1;
|
||||
next.ankiConnect.fields.word = 'Vocabulary';
|
||||
next.ankiConnect.fields.audio = 'SentenceAudioCustom';
|
||||
next.ankiConnect.fields.image = 'ScreenshotCustom';
|
||||
next.ankiConnect.fields.sentence = 'SentenceCustom';
|
||||
next.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
|
||||
next.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
|
||||
next.ankiConnect.isKiku.fieldGrouping =
|
||||
prev.ankiConnect.isKiku.fieldGrouping === 'auto' ? 'manual' : 'auto';
|
||||
|
||||
const diff = classifyConfigHotReloadDiff(prev, next);
|
||||
|
||||
assert.deepEqual(
|
||||
new Set(diff.hotReloadFields),
|
||||
new Set([
|
||||
'stats.toggleKey',
|
||||
'mpv.aniskipButtonKey',
|
||||
'stats.markWatchedKey',
|
||||
'logging.level',
|
||||
'youtube.primarySubLanguages',
|
||||
'jimaku.maxEntryResults',
|
||||
'subsync.replace',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||
'ankiConnect.knownWords.matchMode',
|
||||
'ankiConnect.knownWords.decks',
|
||||
'ankiConnect.nPlusOne.enabled',
|
||||
'ankiConnect.nPlusOne.minSentenceWords',
|
||||
'ankiConnect.fields.word',
|
||||
'ankiConnect.fields.audio',
|
||||
'ankiConnect.fields.image',
|
||||
'ankiConnect.fields.sentence',
|
||||
'ankiConnect.fields.miscInfo',
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
'ankiConnect.isKiku.fieldGrouping',
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(diff.restartRequiredFields, []);
|
||||
});
|
||||
|
||||
test('classifyConfigHotReloadDiff keeps unsafe nested siblings restart-required', () => {
|
||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
next.stats.serverPort = prev.stats.serverPort + 1;
|
||||
next.ankiConnect.url = 'http://127.0.0.1:9999';
|
||||
next.ankiConnect.ai.model = 'openrouter/new-model';
|
||||
|
||||
const diff = classifyConfigHotReloadDiff(prev, next);
|
||||
|
||||
assert.deepEqual(diff.hotReloadFields, []);
|
||||
assert.deepEqual(diff.restartRequiredFields, ['ankiConnect', 'stats']);
|
||||
});
|
||||
|
||||
test('config hot reload runtime debounces rapid watch events', () => {
|
||||
let watchedChangeCallback: (() => void) | null = null;
|
||||
const pendingTimers = new Map<number, () => void>();
|
||||
|
||||
@@ -29,27 +29,85 @@ function isEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
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 collectChangedPaths(prev: unknown, next: unknown, prefix = ''): string[] {
|
||||
if (isEqual(prev, next)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!isRecord(prev) || !isRecord(next)) {
|
||||
return prefix ? [prefix] : [];
|
||||
}
|
||||
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
return [...keys].flatMap((key) =>
|
||||
collectChangedPaths(prev[key], next[key], prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const;
|
||||
|
||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'secondarySub.defaultMode',
|
||||
'mpv.aniskipButtonKey',
|
||||
'ankiConnect.ai.enabled',
|
||||
'stats.toggleKey',
|
||||
'stats.markWatchedKey',
|
||||
'logging.level',
|
||||
'youtube.primarySubLanguages',
|
||||
'jimaku',
|
||||
'subsync',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||
'ankiConnect.knownWords.matchMode',
|
||||
'ankiConnect.knownWords.decks',
|
||||
'ankiConnect.nPlusOne.enabled',
|
||||
'ankiConnect.nPlusOne.minSentenceWords',
|
||||
'ankiConnect.fields.word',
|
||||
'ankiConnect.fields.audio',
|
||||
'ankiConnect.fields.image',
|
||||
'ankiConnect.fields.sentence',
|
||||
'ankiConnect.fields.miscInfo',
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
'ankiConnect.isKiku.fieldGrouping',
|
||||
] as const;
|
||||
|
||||
function hotReloadFieldForChangedPath(path: string): string | null {
|
||||
for (const root of HOT_RELOAD_ROOTS) {
|
||||
if (pathStartsWith(path, root)) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
for (const hotPath of HOT_RELOAD_EXACT_OR_PREFIX_PATHS) {
|
||||
if (pathStartsWith(path, hotPath)) {
|
||||
return hotPath === 'jimaku' || hotPath === 'subsync' ? path : hotPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff {
|
||||
const hotReloadFields: string[] = [];
|
||||
const restartRequiredFields: string[] = [];
|
||||
const hotReloadFieldSet = new Set<string>();
|
||||
const changedPaths = collectChangedPaths(prev, next);
|
||||
|
||||
if (!isEqual(prev.subtitleStyle, next.subtitleStyle)) {
|
||||
hotReloadFields.push('subtitleStyle');
|
||||
}
|
||||
if (!isEqual(prev.keybindings, next.keybindings)) {
|
||||
hotReloadFields.push('keybindings');
|
||||
}
|
||||
if (!isEqual(prev.shortcuts, next.shortcuts)) {
|
||||
hotReloadFields.push('shortcuts');
|
||||
}
|
||||
if (!isEqual(prev.subtitleSidebar, next.subtitleSidebar)) {
|
||||
hotReloadFields.push('subtitleSidebar');
|
||||
}
|
||||
if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) {
|
||||
hotReloadFields.push('secondarySub.defaultMode');
|
||||
}
|
||||
if (!isEqual(prev.ankiConnect.ai, next.ankiConnect.ai)) {
|
||||
hotReloadFields.push('ankiConnect.ai');
|
||||
for (const path of changedPaths) {
|
||||
const hotReloadField = hotReloadFieldForChangedPath(path);
|
||||
if (hotReloadField) {
|
||||
hotReloadFieldSet.add(hotReloadField);
|
||||
}
|
||||
}
|
||||
|
||||
const keys = new Set([
|
||||
@@ -67,37 +125,16 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'secondarySub') {
|
||||
const normalizedPrev = {
|
||||
...prev.secondarySub,
|
||||
defaultMode: next.secondarySub.defaultMode,
|
||||
};
|
||||
if (!isEqual(normalizedPrev, next.secondarySub)) {
|
||||
restartRequiredFields.push('secondarySub');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'ankiConnect') {
|
||||
const normalizedPrev = {
|
||||
...prev.ankiConnect,
|
||||
ai: {
|
||||
enabled: next.ankiConnect.ai.enabled,
|
||||
model: prev.ankiConnect.ai.model,
|
||||
systemPrompt: prev.ankiConnect.ai.systemPrompt,
|
||||
},
|
||||
};
|
||||
if (!isEqual(normalizedPrev, next.ankiConnect)) {
|
||||
restartRequiredFields.push('ankiConnect');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isEqual(prev[key], next[key])) {
|
||||
const changedPathsForKey = changedPaths.filter((path) => pathStartsWith(path, String(key)));
|
||||
const hasRestartRequiredChange = changedPathsForKey.some(
|
||||
(path) => !hotReloadFieldForChangedPath(path),
|
||||
);
|
||||
if (hasRestartRequiredChange) {
|
||||
restartRequiredFields.push(String(key));
|
||||
}
|
||||
}
|
||||
|
||||
hotReloadFields.push(...hotReloadFieldSet);
|
||||
return { hotReloadFields, restartRequiredFields };
|
||||
}
|
||||
|
||||
|
||||
@@ -907,64 +907,68 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrendsDashboard supports 365d range and caps day buckets at 365', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
withMockNowMs('1772395200000', () => {
|
||||
try {
|
||||
ensureSchema(db);
|
||||
test(
|
||||
'getTrendsDashboard supports 365d range and caps day buckets at 365',
|
||||
{ timeout: 20_000 },
|
||||
() => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
withMockNowMs('1772395200000', () => {
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/365d-trends.mkv', {
|
||||
canonicalTitle: '365d Trends',
|
||||
sourcePath: '/tmp/365d-trends.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: '365d Trends',
|
||||
canonicalTitle: '365d Trends',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: '365d-trends.mkv',
|
||||
parsedTitle: '365d Trends',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'test',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/365d-trends.mkv', {
|
||||
canonicalTitle: '365d Trends',
|
||||
sourcePath: '/tmp/365d-trends.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: '365d Trends',
|
||||
canonicalTitle: '365d Trends',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: '365d-trends.mkv',
|
||||
parsedTitle: '365d Trends',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'test',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
|
||||
const insertDailyRollup = db.prepare(
|
||||
`
|
||||
const insertDailyRollup = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
// Seed 400 distinct rollup days so we can prove the 365d range caps at 365.
|
||||
const latestRollupDay = 20513;
|
||||
const createdAtMs = '1772395200000';
|
||||
for (let offset = 0; offset < 400; offset += 1) {
|
||||
const rollupDay = latestRollupDay - offset;
|
||||
insertDailyRollup.run(rollupDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
|
||||
);
|
||||
// Seed 400 distinct rollup days so we can prove the 365d range caps at 365.
|
||||
const latestRollupDay = 20513;
|
||||
const createdAtMs = '1772395200000';
|
||||
for (let offset = 0; offset < 400; offset += 1) {
|
||||
const rollupDay = latestRollupDay - offset;
|
||||
insertDailyRollup.run(rollupDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
|
||||
}
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '365d', 'day');
|
||||
|
||||
assert.equal(dashboard.activity.watchTime.length, 365);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '365d', 'day');
|
||||
|
||||
assert.equal(dashboard.activity.watchTime.length, 365);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
||||
const dbPath = makeDbPath();
|
||||
|
||||
@@ -32,6 +32,12 @@ class FakeSocket extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
class ManualCloseSocket extends FakeSocket {
|
||||
override destroy(): void {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
|
||||
@@ -203,12 +209,15 @@ test('MpvSocketTransport ignores connect requests while already connecting or co
|
||||
});
|
||||
|
||||
test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => {
|
||||
const events: string[] = [];
|
||||
const transport = new MpvSocketTransport({
|
||||
socketPath: '/tmp/mpv.sock',
|
||||
onConnect: () => {},
|
||||
onData: () => {},
|
||||
onError: () => {},
|
||||
onClose: () => {},
|
||||
onClose: () => {
|
||||
events.push('close');
|
||||
},
|
||||
socketFactory: () => new FakeSocket() as unknown as net.Socket,
|
||||
});
|
||||
|
||||
@@ -220,4 +229,45 @@ test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () =
|
||||
assert.equal(transport.isConnected, false);
|
||||
assert.equal(transport.isConnecting, false);
|
||||
assert.equal(transport.getSocket(), null);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test('MpvSocketTransport ignores stale socket events after shutdown and reconnect', async () => {
|
||||
const events: string[] = [];
|
||||
const sockets: ManualCloseSocket[] = [];
|
||||
const transport = new MpvSocketTransport({
|
||||
socketPath: '/tmp/mpv.sock',
|
||||
onConnect: () => {
|
||||
events.push('connect');
|
||||
},
|
||||
onData: () => {
|
||||
events.push('data');
|
||||
},
|
||||
onError: () => {
|
||||
events.push('error');
|
||||
},
|
||||
onClose: () => {
|
||||
events.push('close');
|
||||
},
|
||||
socketFactory: () => {
|
||||
const socket = new ManualCloseSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as net.Socket;
|
||||
},
|
||||
});
|
||||
|
||||
transport.connect();
|
||||
await wait();
|
||||
transport.shutdown();
|
||||
transport.connect();
|
||||
await wait();
|
||||
const eventsBeforeStaleSocket = [...events];
|
||||
|
||||
sockets[0]!.emit('data', Buffer.from('{}'));
|
||||
sockets[0]!.emit('error', new Error('stale'));
|
||||
sockets[0]!.emit('close');
|
||||
|
||||
assert.deepEqual(events, eventsBeforeStaleSocket);
|
||||
assert.equal(transport.isConnected, true);
|
||||
assert.equal(transport.getSocket(), sockets[1]);
|
||||
});
|
||||
|
||||
@@ -105,32 +105,37 @@ export class MpvSocketTransport {
|
||||
}
|
||||
|
||||
this.connecting = true;
|
||||
this.socketRef = this.socketFactory();
|
||||
this.socket = this.socketRef;
|
||||
const socket = this.socketFactory();
|
||||
this.socketRef = socket;
|
||||
this.socket = socket;
|
||||
|
||||
this.socketRef.on('connect', () => {
|
||||
socket.on('connect', () => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.callbacks.onConnect();
|
||||
});
|
||||
|
||||
this.socketRef.on('data', (data: Buffer) => {
|
||||
socket.on('data', (data: Buffer) => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.callbacks.onData(data);
|
||||
});
|
||||
|
||||
this.socketRef.on('error', (error: Error) => {
|
||||
socket.on('error', (error: Error) => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.callbacks.onError(error);
|
||||
});
|
||||
|
||||
this.socketRef.on('close', () => {
|
||||
socket.on('close', () => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.callbacks.onClose();
|
||||
});
|
||||
|
||||
this.socketRef.connect(this.socketPath);
|
||||
socket.connect(this.socketPath);
|
||||
}
|
||||
|
||||
send(payload: MpvSocketMessagePayload): boolean {
|
||||
@@ -144,13 +149,14 @@ export class MpvSocketTransport {
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.socketRef) {
|
||||
this.socketRef.destroy();
|
||||
}
|
||||
const socket = this.socketRef;
|
||||
this.socketRef = null;
|
||||
this.socket = null;
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
if (socket) {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
getSocket(): net.Socket | null {
|
||||
|
||||
@@ -168,6 +168,37 @@ test('MpvIpcClient connect logs connect-request at debug level', () => {
|
||||
assert.equal(requestLogs.length, 1);
|
||||
});
|
||||
|
||||
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const calls: string[] = [];
|
||||
const connectionChanges: boolean[] = [];
|
||||
const resolved: unknown[] = [];
|
||||
client.on('connection-change', ({ connected }) => {
|
||||
connectionChanges.push(connected);
|
||||
});
|
||||
(client as any).connected = true;
|
||||
(client as any).connecting = false;
|
||||
(client as any).socket = {};
|
||||
(client as any).pendingRequests.set(10, (message: unknown) => {
|
||||
resolved.push(message);
|
||||
});
|
||||
(client as any).transport.shutdown = () => {
|
||||
calls.push('shutdown');
|
||||
};
|
||||
(client as any).transport.connect = () => {
|
||||
calls.push('connect');
|
||||
};
|
||||
|
||||
client.reconnect();
|
||||
|
||||
assert.deepEqual(calls, ['shutdown', 'connect']);
|
||||
assert.equal(client.connected, false);
|
||||
assert.equal((client as any).connecting, true);
|
||||
assert.equal((client as any).socket, null);
|
||||
assert.deepEqual(connectionChanges, [false]);
|
||||
assert.deepEqual(resolved, [{ request_id: 10, error: 'disconnected' }]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient failPendingRequests resolves outstanding requests as disconnected', () => {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const resolved: unknown[] = [];
|
||||
@@ -385,6 +416,41 @@ test('MpvIpcClient connect does not force primary subtitle visibility from bindi
|
||||
assert.equal(hasPrimaryVisibilityMutation, false);
|
||||
});
|
||||
|
||||
test('MpvIpcClient snapshots current subtitles before connection side effects can hide them', () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
(client as any).send = (command: unknown) => {
|
||||
commands.push(command);
|
||||
return true;
|
||||
};
|
||||
client.on('connection-change', ({ connected }) => {
|
||||
if (connected) {
|
||||
client.setSubVisibility(false);
|
||||
}
|
||||
});
|
||||
|
||||
const callbacks = (client as any).transport.callbacks;
|
||||
callbacks.onConnect();
|
||||
|
||||
const firstSubTextSnapshot = commands.findIndex((command) => {
|
||||
const args = (command as { command?: unknown[] }).command;
|
||||
return Array.isArray(args) && args[0] === 'get_property' && args[1] === 'sub-text';
|
||||
});
|
||||
const firstPrimaryHide = commands.findIndex((command) => {
|
||||
const args = (command as { command?: unknown[] }).command;
|
||||
return (
|
||||
Array.isArray(args) &&
|
||||
args[0] === 'set_property' &&
|
||||
args[1] === 'sub-visibility' &&
|
||||
(args[2] === false || args[2] === 'no')
|
||||
);
|
||||
});
|
||||
|
||||
assert.notEqual(firstSubTextSnapshot, -1);
|
||||
assert.notEqual(firstPrimaryHide, -1);
|
||||
assert.ok(firstSubTextSnapshot < firstPrimaryHide);
|
||||
});
|
||||
|
||||
test('MpvIpcClient setSubVisibility writes compatibility commands for visibility toggle', () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
|
||||
@@ -186,12 +186,12 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.socket = this.transport.getSocket();
|
||||
this.emit('connection-change', { connected: true });
|
||||
this.reconnectAttempt = 0;
|
||||
this.hasConnectedOnce = true;
|
||||
this.setSecondarySubVisibility(false);
|
||||
subscribeToMpvProperties(this.send.bind(this));
|
||||
requestMpvInitialState(this.send.bind(this));
|
||||
this.emit('connection-change', { connected: true });
|
||||
|
||||
const shouldAutoStart =
|
||||
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true;
|
||||
@@ -275,6 +275,21 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.transport.connect();
|
||||
}
|
||||
|
||||
reconnect(): void {
|
||||
logger.debug('MPV IPC reconnect requested.');
|
||||
const wasConnected = this.connected;
|
||||
this.transport.shutdown();
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.socket = null;
|
||||
this.playbackPaused = null;
|
||||
if (wasConnected) {
|
||||
this.emit('connection-change', { connected: false });
|
||||
}
|
||||
this.failPendingRequests();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.reconnectAttempt = scheduleMpvReconnect({
|
||||
attempt: this.reconnectAttempt,
|
||||
|
||||
@@ -11,29 +11,49 @@ type WindowTrackerStub = {
|
||||
isTargetWindowMinimized?: () => boolean;
|
||||
};
|
||||
|
||||
function createMainWindowRecorder() {
|
||||
function createMainWindowRecorder(options: { emitShowImmediately?: boolean } = {}) {
|
||||
const emitShowImmediately = options.emitShowImmediately ?? true;
|
||||
const calls: string[] = [];
|
||||
const listeners = new Map<string, Array<() => void>>();
|
||||
let visible = false;
|
||||
let focused = false;
|
||||
let opacity = 1;
|
||||
let contentReady = true;
|
||||
const emit = (event: string): void => {
|
||||
const handlers = listeners.get(event) ?? [];
|
||||
listeners.delete(event);
|
||||
for (const handler of handlers) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
const emitShow = (): void => {
|
||||
visible = true;
|
||||
emit('show');
|
||||
};
|
||||
const window = {
|
||||
webContents: {},
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => visible,
|
||||
isFocused: () => focused,
|
||||
once: (event: string, handler: () => void) => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), handler]);
|
||||
},
|
||||
hide: () => {
|
||||
visible = false;
|
||||
focused = false;
|
||||
calls.push('hide');
|
||||
},
|
||||
show: () => {
|
||||
visible = true;
|
||||
calls.push('show');
|
||||
if (emitShowImmediately) {
|
||||
emitShow();
|
||||
}
|
||||
},
|
||||
showInactive: () => {
|
||||
visible = true;
|
||||
calls.push('show-inactive');
|
||||
if (emitShowImmediately) {
|
||||
emitShow();
|
||||
}
|
||||
},
|
||||
focus: () => {
|
||||
focused = true;
|
||||
@@ -68,6 +88,7 @@ function createMainWindowRecorder() {
|
||||
window,
|
||||
calls,
|
||||
getOpacity: () => opacity,
|
||||
emitShow,
|
||||
setContentReady: (nextContentReady: boolean) => {
|
||||
contentReady = nextContentReady;
|
||||
(
|
||||
@@ -216,6 +237,88 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
|
||||
assert.ok(!calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay reapplies bounds after first show', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call === 'update-bounds' || call === 'show'),
|
||||
['update-bounds', 'show', 'update-bounds'],
|
||||
);
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay queues only one first-show bounds refresh', () => {
|
||||
const { window, calls, emitShow } = createMainWindowRecorder({ emitShowImmediately: false });
|
||||
let width = 1280;
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width, height: 720 }),
|
||||
};
|
||||
const run = () =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: (geometry: { width: number }) => {
|
||||
calls.push(`update-bounds:${geometry.width}`);
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
run();
|
||||
width = 1440;
|
||||
run();
|
||||
emitShow();
|
||||
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call.startsWith('update-bounds:')),
|
||||
['update-bounds:1280', 'update-bounds:1440', 'update-bounds:1440'],
|
||||
);
|
||||
});
|
||||
|
||||
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
|
||||
@@ -8,6 +8,7 @@ const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
|
||||
BrowserWindow,
|
||||
ReturnType<typeof setTimeout>
|
||||
>();
|
||||
const pendingFirstShowBoundsRefreshGeometry = new WeakMap<BrowserWindow, WindowGeometry>();
|
||||
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
|
||||
const opacityCapableWindow = window as BrowserWindow & {
|
||||
setOpacity?: (opacity: number) => void;
|
||||
@@ -270,6 +271,32 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.markOverlayLoadingOsdShown?.();
|
||||
};
|
||||
|
||||
const refreshNonNativeOverlayBoundsAfterFirstShow = (geometry: WindowGeometry | null): void => {
|
||||
if (
|
||||
geometry === null ||
|
||||
args.isMacOSPlatform ||
|
||||
args.isWindowsPlatform ||
|
||||
mainWindow.isVisible()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (pendingFirstShowBoundsRefreshGeometry.has(mainWindow)) {
|
||||
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
|
||||
return;
|
||||
}
|
||||
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
|
||||
mainWindow.once('show', () => {
|
||||
const pendingGeometry = pendingFirstShowBoundsRefreshGeometry.get(mainWindow);
|
||||
pendingFirstShowBoundsRefreshGeometry.delete(mainWindow);
|
||||
if (mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return;
|
||||
}
|
||||
if (pendingGeometry) {
|
||||
args.updateVisibleOverlayBounds(pendingGeometry);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!args.visibleOverlayVisible) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.resetOverlayLoadingOsdSuppression?.();
|
||||
@@ -298,6 +325,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
const geometry = args.windowTracker.getGeometry();
|
||||
if (geometry) {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
refreshNonNativeOverlayBoundsAfterFirstShow(geometry);
|
||||
}
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||
|
||||
@@ -14,6 +14,33 @@ test('overlay window config explicitly disables renderer sandbox for preload com
|
||||
assert.equal(options.webPreferences?.backgroundThrottling, false);
|
||||
});
|
||||
|
||||
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
try {
|
||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
yomitanSession: null,
|
||||
});
|
||||
const modalOptions = buildOverlayWindowOptions('modal', {
|
||||
isDev: false,
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(visibleOptions.resizable, true);
|
||||
assert.equal(modalOptions.resizable, false);
|
||||
} finally {
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows visible overlay window config does not start as always-on-top', () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export function buildOverlayWindowOptions(
|
||||
): BrowserWindowConstructorOptions {
|
||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
|
||||
const shouldAllowCompositorResize = process.platform === 'linux' && kind === 'visible';
|
||||
|
||||
return {
|
||||
show: false,
|
||||
@@ -29,7 +30,7 @@ export function buildOverlayWindowOptions(
|
||||
frame: false,
|
||||
alwaysOnTop: shouldStartAlwaysOnTop,
|
||||
skipTaskbar: true,
|
||||
resizable: false,
|
||||
resizable: shouldAllowCompositorResize,
|
||||
hasShadow: false,
|
||||
focusable: true,
|
||||
acceptFirstMouse: true,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { dispatchSessionAction, type SessionActionExecutorDeps } from './session-actions';
|
||||
|
||||
function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
const deps: SessionActionExecutorDeps = {
|
||||
toggleStatsOverlay: () => calls.push('stats'),
|
||||
toggleVisibleOverlay: () => calls.push('visible'),
|
||||
copyCurrentSubtitle: () => calls.push('copy'),
|
||||
copySubtitleCount: (count) => calls.push(`copy:${count}`),
|
||||
updateLastCardFromClipboard: async () => {
|
||||
calls.push('update');
|
||||
},
|
||||
triggerFieldGrouping: async () => {
|
||||
calls.push('field-grouping');
|
||||
},
|
||||
triggerSubsyncFromConfig: async () => {
|
||||
calls.push('subsync');
|
||||
},
|
||||
mineSentenceCard: async () => {
|
||||
calls.push('mine');
|
||||
},
|
||||
mineSentenceCount: (count) => calls.push(`mine:${count}`),
|
||||
toggleSecondarySub: () => calls.push('secondary'),
|
||||
toggleSubtitleSidebar: () => calls.push('sidebar'),
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('audio');
|
||||
},
|
||||
markActiveVideoWatched: async () => {
|
||||
calls.push('mark-watched');
|
||||
return true;
|
||||
},
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openSessionHelp: () => calls.push('session-help'),
|
||||
openCharacterDictionary: () => calls.push('character-dictionary'),
|
||||
openControllerSelect: () => calls.push('controller-select'),
|
||||
openControllerDebug: () => calls.push('controller-debug'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube');
|
||||
},
|
||||
openPlaylistBrowser: () => {
|
||||
calls.push('playlist');
|
||||
},
|
||||
replayCurrentSubtitle: () => calls.push('replay'),
|
||||
playNextSubtitle: () => calls.push('play-next'),
|
||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||
calls.push(`shift:${direction}`);
|
||||
},
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
playNextPlaylistItem: () => calls.push('playlist-next'),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
...overrides,
|
||||
};
|
||||
return { calls, deps };
|
||||
}
|
||||
|
||||
test('dispatchSessionAction marks watched and advances playlist after success', async () => {
|
||||
const { calls, deps } = createDeps();
|
||||
|
||||
await dispatchSessionAction({ actionId: 'markWatched' }, deps);
|
||||
|
||||
assert.deepEqual(calls, ['mark-watched', 'osd:Marked as watched', 'playlist-next']);
|
||||
});
|
||||
|
||||
test('dispatchSessionAction does not advance playlist when mark watched no-ops', async () => {
|
||||
const { calls, deps } = createDeps({
|
||||
markActiveVideoWatched: async () => {
|
||||
calls.push('mark-watched');
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchSessionAction({ actionId: 'markWatched' }, deps);
|
||||
|
||||
assert.deepEqual(calls, ['mark-watched']);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export interface SessionActionExecutorDeps {
|
||||
toggleSecondarySub: () => void;
|
||||
toggleSubtitleSidebar: () => void;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
markActiveVideoWatched: () => Promise<boolean>;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openSessionHelp: () => void;
|
||||
openCharacterDictionary: () => void;
|
||||
@@ -27,6 +28,7 @@ export interface SessionActionExecutorDeps {
|
||||
playNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
playNextPlaylistItem: () => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}
|
||||
|
||||
@@ -80,6 +82,14 @@ export async function dispatchSessionAction(
|
||||
case 'markAudioCard':
|
||||
await deps.markLastCardAsAudioCard();
|
||||
return;
|
||||
case 'markWatched': {
|
||||
const marked = await deps.markActiveVideoWatched();
|
||||
if (marked) {
|
||||
deps.showMpvOsd('Marked as watched');
|
||||
deps.playNextPlaylistItem();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'openRuntimeOptions':
|
||||
deps.openRuntimeOptionsPalette();
|
||||
return;
|
||||
|
||||
@@ -375,3 +375,64 @@ test('compileSessionBindings includes stats toggle in the shared session binding
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('compileSessionBindings includes mark-watched in the shared session binding artifact', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
keybindings: [],
|
||||
statsMarkWatchedKey: 'Ctrl+Shift+KeyW',
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.equal(result.warnings.length, 0);
|
||||
assert.deepEqual(result.bindings, [
|
||||
{
|
||||
sourcePath: 'stats.markWatchedKey',
|
||||
originalKey: 'Ctrl+Shift+KeyW',
|
||||
key: {
|
||||
code: 'KeyW',
|
||||
modifiers: ['ctrl', 'shift'],
|
||||
},
|
||||
actionType: 'session-action',
|
||||
actionId: 'markWatched',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('compileSessionBindings wires every configured shortcut key into the shared artifact', () => {
|
||||
const shortcutKeys: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>> = [
|
||||
'toggleVisibleOverlayGlobal',
|
||||
'copySubtitle',
|
||||
'copySubtitleMultiple',
|
||||
'updateLastCardFromClipboard',
|
||||
'triggerFieldGrouping',
|
||||
'triggerSubsync',
|
||||
'mineSentence',
|
||||
'mineSentenceMultiple',
|
||||
'toggleSecondarySub',
|
||||
'markAudioCard',
|
||||
'openCharacterDictionary',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openSessionHelp',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
'toggleSubtitleSidebar',
|
||||
];
|
||||
const shortcuts = createShortcuts();
|
||||
shortcutKeys.forEach((key, index) => {
|
||||
shortcuts[key] = `Ctrl+Alt+F${index + 1}`;
|
||||
});
|
||||
|
||||
const result = compileSessionBindings({
|
||||
shortcuts,
|
||||
keybindings: [],
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(
|
||||
result.bindings.map((binding) => binding.sourcePath).sort(),
|
||||
shortcutKeys.map((key) => `shortcuts.${key}`).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ type CompileSessionBindingsInput = {
|
||||
keybindings: Keybinding[];
|
||||
shortcuts: ConfiguredShortcuts;
|
||||
statsToggleKey?: string | null;
|
||||
statsMarkWatchedKey?: string | null;
|
||||
platform: PlatformKeyModel;
|
||||
rawConfig?: ResolvedConfig | null;
|
||||
};
|
||||
@@ -353,6 +354,8 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
|
||||
input.rawConfig?.shortcuts as Record<string, unknown> | undefined
|
||||
)?.toggleVisibleOverlayGlobal;
|
||||
const statsToggleKey = input.statsToggleKey ?? input.rawConfig?.stats?.toggleKey ?? null;
|
||||
const statsMarkWatchedKey =
|
||||
input.statsMarkWatchedKey ?? input.rawConfig?.stats?.markWatchedKey ?? null;
|
||||
|
||||
if (legacyToggleVisibleOverlayGlobal !== undefined) {
|
||||
warnings.push({
|
||||
@@ -419,6 +422,33 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
|
||||
}
|
||||
}
|
||||
|
||||
if (statsMarkWatchedKey) {
|
||||
const parsed = parseDomKeyString(statsMarkWatchedKey, input.platform);
|
||||
if (!parsed.key) {
|
||||
warnings.push({
|
||||
kind: 'unsupported',
|
||||
path: 'stats.markWatchedKey',
|
||||
value: statsMarkWatchedKey,
|
||||
message: parsed.message ?? 'Unsupported stats mark-watched key syntax.',
|
||||
});
|
||||
} else {
|
||||
const binding: CompiledSessionActionBinding = {
|
||||
sourcePath: 'stats.markWatchedKey',
|
||||
originalKey: statsMarkWatchedKey,
|
||||
key: parsed.key,
|
||||
actionType: 'session-action',
|
||||
actionId: 'markWatched',
|
||||
};
|
||||
const signature = getSessionKeySpecSignature(parsed.key);
|
||||
const draft = candidates.get(signature) ?? [];
|
||||
draft.push({
|
||||
binding,
|
||||
actionFingerprint: getBindingFingerprint(binding),
|
||||
});
|
||||
candidates.set(signature, draft);
|
||||
}
|
||||
}
|
||||
|
||||
input.keybindings.forEach((binding, index) => {
|
||||
if (!binding.command) return;
|
||||
const parsed = parseDomKeyString(binding.key, input.platform);
|
||||
|
||||
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
markWatched: false,
|
||||
toggleSubtitleSidebar: false,
|
||||
openRuntimeOptions: false,
|
||||
openSessionHelp: false,
|
||||
|
||||
@@ -41,7 +41,6 @@ function makeDeps(
|
||||
return {
|
||||
getMpvClient: () => mpvClient,
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath: '/usr/bin/alass',
|
||||
ffsubsyncPath: '/usr/bin/ffsubsync',
|
||||
ffmpegPath: '/usr/bin/ffmpeg',
|
||||
@@ -68,7 +67,7 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
|
||||
assert.deepEqual(osd, ['Subsync already running']);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig opens manual picker in manual mode', async () => {
|
||||
test('triggerSubsyncFromConfig opens manual picker', async () => {
|
||||
const osd: string[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
let inProgressState: boolean | null = null;
|
||||
@@ -92,6 +91,31 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () =>
|
||||
assert.equal(inProgressState, false);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig does not run automatic sync', async () => {
|
||||
const osd: string[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
let spinnerRan = false;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
runWithSubsyncSpinner: async <T>(task: () => Promise<T>) => {
|
||||
spinnerRan = true;
|
||||
return task();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
assert.equal(spinnerRan, false);
|
||||
assert.deepEqual(osd, ['Subsync: choose engine and source']);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
|
||||
let payloadTrackCount = 0;
|
||||
|
||||
@@ -161,14 +185,14 @@ test('runSubsyncManual requires a source track for alass', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig reports path validation failures', async () => {
|
||||
test('triggerSubsyncFromConfig does not validate sync tool paths before manual selection', async () => {
|
||||
const osd: string[] = [];
|
||||
const inProgress: boolean[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'auto',
|
||||
alassPath: '/missing/alass',
|
||||
ffsubsyncPath: '/missing/ffsubsync',
|
||||
ffmpegPath: '/missing/ffmpeg',
|
||||
@@ -176,16 +200,18 @@ test('triggerSubsyncFromConfig reports path validation failures', async () => {
|
||||
setSubsyncInProgress: (value) => {
|
||||
inProgress.push(value);
|
||||
},
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(inProgress, [true, false]);
|
||||
assert.ok(
|
||||
osd.some((line) => line.startsWith('Subsync failed: Configured ffmpeg executable not found')),
|
||||
);
|
||||
assert.deepEqual(inProgress, [false]);
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
assert.deepEqual(osd, ['Subsync: choose engine and source']);
|
||||
});
|
||||
|
||||
function writeExecutableScript(filePath: string, content: string): void {
|
||||
@@ -260,7 +286,6 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
@@ -326,7 +351,6 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
@@ -382,7 +406,6 @@ test('runSubsyncManual reports ffsubsync command failures with details', async (
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
@@ -448,7 +471,6 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
@@ -520,7 +542,6 @@ test('runSubsyncManual keeps internal alass source file alive until sync finishe
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
@@ -577,7 +598,6 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
SubsyncResolvedConfig,
|
||||
} from '../../subsync/utils';
|
||||
import { isRemoteMediaPath } from '../../jimaku/utils';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
const logger = createLogger('main:subsync');
|
||||
|
||||
interface FileExtractionResult {
|
||||
path: string;
|
||||
@@ -340,57 +337,6 @@ function validateFfsubsyncReference(videoPath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function runSubsyncAutoInternal(deps: SubsyncCoreDeps): Promise<SubsyncResult> {
|
||||
const client = getMpvClientForSubsync(deps);
|
||||
const context = await gatherSubsyncContext(client);
|
||||
const resolved = deps.getResolvedConfig();
|
||||
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
|
||||
|
||||
if (context.secondaryTrack) {
|
||||
let secondaryExtraction: FileExtractionResult | null = null;
|
||||
try {
|
||||
secondaryExtraction = await extractSubtitleTrackToFile(
|
||||
ffmpegPath,
|
||||
context.videoPath,
|
||||
context.secondaryTrack,
|
||||
);
|
||||
const alassResult = await subsyncToReference(
|
||||
'alass',
|
||||
secondaryExtraction.path,
|
||||
context,
|
||||
resolved,
|
||||
client,
|
||||
);
|
||||
if (alassResult.ok) {
|
||||
return alassResult;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Auto alass sync failed, trying ffsubsync fallback:', error);
|
||||
} finally {
|
||||
if (secondaryExtraction) {
|
||||
cleanupTemporaryFile(secondaryExtraction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
|
||||
if (!ffsubsyncPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'No secondary subtitle for alass and ffsubsync not configured',
|
||||
};
|
||||
}
|
||||
try {
|
||||
validateFfsubsyncReference(context.videoPath);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
|
||||
}
|
||||
|
||||
export async function runSubsyncManual(
|
||||
request: SubsyncManualRunRequest,
|
||||
deps: SubsyncCoreDeps,
|
||||
@@ -448,17 +394,9 @@ export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDep
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = deps.getResolvedConfig();
|
||||
try {
|
||||
if (resolved.defaultMode === 'manual') {
|
||||
await openSubsyncManualPicker(deps);
|
||||
deps.showMpvOsd('Subsync: choose engine and source');
|
||||
return;
|
||||
}
|
||||
|
||||
deps.setSubsyncInProgress(true);
|
||||
const result = await deps.runWithSubsyncSpinner(() => runSubsyncAutoInternal(deps));
|
||||
deps.showMpvOsd(result.message);
|
||||
await openSubsyncManualPicker(deps);
|
||||
deps.showMpvOsd('Subsync: choose engine and source');
|
||||
} catch (error) {
|
||||
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
|
||||
} finally {
|
||||
|
||||
@@ -217,6 +217,38 @@ test('serializeSubtitleWebsocketMessage emits structured token api payload', ()
|
||||
});
|
||||
});
|
||||
|
||||
test('serializeSubtitleWebsocketMessage can force plain subtitle payloads', () => {
|
||||
const payload: SubtitleData = {
|
||||
text: '無事',
|
||||
tokens: [
|
||||
{
|
||||
surface: '無事',
|
||||
reading: 'ぶじ',
|
||||
headword: '無事',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
jlptLevel: 'N2',
|
||||
frequencyRank: 745,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions, {
|
||||
payloadMode: 'plain',
|
||||
});
|
||||
|
||||
assert.deepEqual(JSON.parse(raw), {
|
||||
version: 1,
|
||||
text: '無事',
|
||||
sentence: '無事',
|
||||
tokens: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('serializeInitialSubtitleWebsocketMessage keeps annotated current subtitle content', () => {
|
||||
const payload: SubtitleData = {
|
||||
text: 'ignored fallback',
|
||||
|
||||
@@ -18,6 +18,12 @@ export type SubtitleWebsocketFrequencyOptions = {
|
||||
mode: 'single' | 'banded';
|
||||
};
|
||||
|
||||
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
|
||||
|
||||
type SubtitleWebsocketMessageOptions = {
|
||||
payloadMode?: SubtitleWebsocketPayloadMode;
|
||||
};
|
||||
|
||||
type SerializedSubtitleToken = Pick<
|
||||
MergedToken,
|
||||
| 'surface'
|
||||
@@ -198,7 +204,17 @@ export function serializeSubtitleMarkup(
|
||||
export function serializeSubtitleWebsocketMessage(
|
||||
payload: SubtitleData,
|
||||
options: SubtitleWebsocketFrequencyOptions,
|
||||
messageOptions: SubtitleWebsocketMessageOptions = {},
|
||||
): string {
|
||||
if (messageOptions.payloadMode === 'plain') {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
text: payload.text,
|
||||
sentence: escapeHtml(payload.text).replaceAll('\n', '<br>'),
|
||||
tokens: [],
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
text: payload.text,
|
||||
@@ -210,18 +226,21 @@ export function serializeSubtitleWebsocketMessage(
|
||||
export function serializeInitialSubtitleWebsocketMessage(
|
||||
payload: SubtitleData | null,
|
||||
options: SubtitleWebsocketFrequencyOptions,
|
||||
messageOptions: SubtitleWebsocketMessageOptions = {},
|
||||
): string | null {
|
||||
if (!payload || !payload.text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return serializeSubtitleWebsocketMessage(payload, options);
|
||||
return serializeSubtitleWebsocketMessage(payload, options, messageOptions);
|
||||
}
|
||||
|
||||
export class SubtitleWebSocket {
|
||||
private server: WebSocket.Server | null = null;
|
||||
private latestMessage = '';
|
||||
|
||||
public constructor(private readonly payloadMode: SubtitleWebsocketPayloadMode = 'annotated') {}
|
||||
|
||||
public isRunning(): boolean {
|
||||
return this.server !== null;
|
||||
}
|
||||
@@ -247,6 +266,7 @@ export class SubtitleWebSocket {
|
||||
const currentMessage = serializeInitialSubtitleWebsocketMessage(
|
||||
getCurrentSubtitleData(),
|
||||
getFrequencyOptions(),
|
||||
{ payloadMode: this.payloadMode },
|
||||
);
|
||||
if (currentMessage) {
|
||||
ws.send(currentMessage);
|
||||
@@ -262,7 +282,9 @@ export class SubtitleWebSocket {
|
||||
|
||||
public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
|
||||
if (!this.server) return;
|
||||
const message = serializeSubtitleWebsocketMessage(payload, options);
|
||||
const message = serializeSubtitleWebsocketMessage(payload, options, {
|
||||
payloadMode: this.payloadMode,
|
||||
});
|
||||
this.latestMessage = message;
|
||||
for (const client of this.server.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface TokenizerServiceDeps {
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: (text: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getKnownWordsEnabled?: () => boolean;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
getNPlusOneEnabled?: () => boolean;
|
||||
getJlptEnabled?: () => boolean;
|
||||
@@ -74,6 +75,7 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: (text: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getKnownWordsEnabled?: () => boolean;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
getNPlusOneEnabled?: () => boolean;
|
||||
getJlptEnabled?: () => boolean;
|
||||
@@ -88,6 +90,7 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
}
|
||||
|
||||
interface TokenizerAnnotationOptions {
|
||||
knownWordsEnabled: boolean;
|
||||
nPlusOneEnabled: boolean;
|
||||
jlptEnabled: boolean;
|
||||
nameMatchEnabled: boolean;
|
||||
@@ -119,18 +122,28 @@ function getKnownWordLookup(
|
||||
deps: TokenizerServiceDeps,
|
||||
options: TokenizerAnnotationOptions,
|
||||
): (text: string) => boolean {
|
||||
if (!options.nPlusOneEnabled) {
|
||||
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
|
||||
return () => false;
|
||||
}
|
||||
return deps.isKnownWord;
|
||||
}
|
||||
|
||||
function needsMecabPosEnrichment(options: TokenizerAnnotationOptions): boolean {
|
||||
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
|
||||
return (
|
||||
options.knownWordsEnabled ||
|
||||
options.nPlusOneEnabled ||
|
||||
options.jlptEnabled ||
|
||||
options.frequencyEnabled
|
||||
);
|
||||
}
|
||||
|
||||
function hasAnyAnnotationEnabled(options: TokenizerAnnotationOptions): boolean {
|
||||
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
|
||||
return (
|
||||
options.knownWordsEnabled ||
|
||||
options.nPlusOneEnabled ||
|
||||
options.jlptEnabled ||
|
||||
options.frequencyEnabled
|
||||
);
|
||||
}
|
||||
|
||||
async function enrichTokensWithMecabAsync(
|
||||
@@ -211,6 +224,7 @@ export function createTokenizerDepsRuntime(
|
||||
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
|
||||
isKnownWord: options.isKnownWord,
|
||||
getKnownWordMatchMode: options.getKnownWordMatchMode,
|
||||
getKnownWordsEnabled: options.getKnownWordsEnabled,
|
||||
getJlptLevel: options.getJlptLevel,
|
||||
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
||||
getJlptEnabled: options.getJlptEnabled,
|
||||
@@ -662,8 +676,12 @@ function applyFrequencyRanks(
|
||||
}
|
||||
|
||||
function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOptions {
|
||||
const nPlusOneEnabled = deps.getNPlusOneEnabled?.() !== false;
|
||||
return {
|
||||
nPlusOneEnabled: deps.getNPlusOneEnabled?.() !== false,
|
||||
knownWordsEnabled: deps.getKnownWordsEnabled
|
||||
? deps.getKnownWordsEnabled() !== false
|
||||
: nPlusOneEnabled,
|
||||
nPlusOneEnabled,
|
||||
jlptEnabled: deps.getJlptEnabled?.() !== false,
|
||||
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
|
||||
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
|
||||
|
||||
@@ -56,6 +56,50 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
|
||||
assert.equal(surfaceResult[0]?.isKnown, false);
|
||||
});
|
||||
|
||||
test('annotateTokens marks known words when N+1 is disabled', () => {
|
||||
const tokens = [
|
||||
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
|
||||
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
|
||||
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '私' || text === '猫',
|
||||
}),
|
||||
{ nPlusOneEnabled: false, knownWordsEnabled: true },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[1]?.isKnown, true);
|
||||
assert.equal(result[1]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[2]?.isKnown, false);
|
||||
assert.equal(result[2]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens hides known-word marks while still using known words for N+1', () => {
|
||||
const tokens = [
|
||||
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
|
||||
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
|
||||
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '私' || text === '猫',
|
||||
}),
|
||||
{ nPlusOneEnabled: true, knownWordsEnabled: false, minSentenceWordsForNPlusOne: 3 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[1]?.isKnown, false);
|
||||
assert.equal(result[2]?.isKnown, false);
|
||||
assert.equal(result[2]?.isNPlusOneTarget, true);
|
||||
});
|
||||
|
||||
test('annotateTokens falls back to reading for known-word matches when headword lookup misses', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
@@ -122,6 +166,35 @@ test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 ex
|
||||
assert.equal(result[3]?.frequencyRank, 11);
|
||||
});
|
||||
|
||||
test('annotateTokens keeps frequency for determiner-led content noun compounds', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'その場',
|
||||
headword: 'その場',
|
||||
reading: 'そのば',
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '連体詞|名詞',
|
||||
pos2: '*|一般',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
frequencyRank: 879,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === 'その場',
|
||||
getJlptLevel: (text) => (text === 'その場' ? 'N4' : null),
|
||||
}),
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.frequencyRank, 879);
|
||||
assert.equal(result[0]?.jlptLevel, 'N4');
|
||||
});
|
||||
|
||||
test('annotateTokens preserves existing frequency rank when frequency is enabled', () => {
|
||||
const tokens = [makeToken({ surface: '猫', headword: '猫', frequencyRank: 42 })];
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface AnnotationStageDeps {
|
||||
}
|
||||
|
||||
export interface AnnotationStageOptions {
|
||||
knownWordsEnabled?: boolean;
|
||||
nPlusOneEnabled?: boolean;
|
||||
nameMatchEnabled?: boolean;
|
||||
jlptEnabled?: boolean;
|
||||
@@ -188,6 +189,35 @@ function shouldAllowHonorificPrefixNounFrequency(token: MergedToken): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAllowDeterminerLedNounFrequency(
|
||||
normalizedPos1: string,
|
||||
normalizedPos2: string,
|
||||
pos1Exclusions: ReadonlySet<string>,
|
||||
pos2Exclusions: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const pos1Parts = splitNormalizedTagParts(normalizedPos1);
|
||||
if (pos1Parts.length < 2 || pos1Parts[0] !== '連体詞') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos2Parts = splitNormalizedTagParts(normalizedPos2);
|
||||
if (!isExcludedComponent(pos1Parts[0], pos2Parts[0], pos1Exclusions, pos2Exclusions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const componentCount = Math.max(pos1Parts.length, pos2Parts.length);
|
||||
for (let index = 1; index < componentCount; index += 1) {
|
||||
if (
|
||||
pos1Parts[index] === '名詞' &&
|
||||
!isExcludedComponent(pos1Parts[index], pos2Parts[index], pos1Exclusions, pos2Exclusions)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isFrequencyExcludedByPos(
|
||||
token: MergedToken,
|
||||
pos1Exclusions: ReadonlySet<string>,
|
||||
@@ -207,12 +237,19 @@ function isFrequencyExcludedByPos(
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
);
|
||||
const allowDeterminerLedNounToken = shouldAllowDeterminerLedNounFrequency(
|
||||
normalizedPos1,
|
||||
normalizedPos2,
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
);
|
||||
const allowOrdinalPrefixNounToken = shouldAllowOrdinalPrefixNounFrequency(token);
|
||||
const allowHonorificPrefixNounToken = shouldAllowHonorificPrefixNounFrequency(token);
|
||||
|
||||
if (
|
||||
isExcludedByTagSet(normalizedPos1, pos1Exclusions) &&
|
||||
!allowContentLedMergedToken &&
|
||||
!allowDeterminerLedNounToken &&
|
||||
!allowOrdinalPrefixNounToken &&
|
||||
!allowHonorificPrefixNounToken
|
||||
) {
|
||||
@@ -222,6 +259,7 @@ function isFrequencyExcludedByPos(
|
||||
if (
|
||||
isExcludedByTagSet(normalizedPos2, pos2Exclusions) &&
|
||||
!allowContentLedMergedToken &&
|
||||
!allowDeterminerLedNounToken &&
|
||||
!allowOrdinalPrefixNounToken &&
|
||||
!allowHonorificPrefixNounToken
|
||||
) {
|
||||
@@ -632,13 +670,16 @@ export function annotateTokens(
|
||||
): MergedToken[] {
|
||||
const pos1Exclusions = resolvePos1Exclusions(options);
|
||||
const pos2Exclusions = resolvePos2Exclusions(options);
|
||||
const knownWordsEnabled = options.knownWordsEnabled !== false;
|
||||
const nPlusOneEnabled = options.nPlusOneEnabled !== false;
|
||||
const nameMatchEnabled = options.nameMatchEnabled !== false;
|
||||
const frequencyEnabled = options.frequencyEnabled !== false;
|
||||
const jlptEnabled = options.jlptEnabled !== false;
|
||||
const shouldComputeKnownStatus = knownWordsEnabled || nPlusOneEnabled;
|
||||
const nPlusOneKnownStatuses: boolean[] = [];
|
||||
|
||||
// Single pass: compute known word status, frequency filtering, and JLPT level together
|
||||
const annotated = tokens.map((token) => {
|
||||
const annotated = tokens.map((token, index) => {
|
||||
if (
|
||||
sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
|
||||
pos1Exclusions,
|
||||
@@ -649,6 +690,7 @@ export function annotateTokens(
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
});
|
||||
nPlusOneKnownStatuses[index] = false;
|
||||
return {
|
||||
...strippedToken,
|
||||
isKnown: false,
|
||||
@@ -656,9 +698,10 @@ export function annotateTokens(
|
||||
}
|
||||
|
||||
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
||||
const isKnown = nPlusOneEnabled
|
||||
const isKnownForMatching = shouldComputeKnownStatus
|
||||
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
|
||||
: false;
|
||||
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
||||
|
||||
const frequencyRank =
|
||||
frequencyEnabled && !prioritizedNameMatch
|
||||
@@ -672,7 +715,7 @@ export function annotateTokens(
|
||||
|
||||
return {
|
||||
...token,
|
||||
isKnown,
|
||||
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
||||
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
|
||||
frequencyRank,
|
||||
jlptLevel,
|
||||
@@ -691,13 +734,21 @@ export function annotateTokens(
|
||||
? minSentenceWordsForNPlusOne
|
||||
: 3;
|
||||
|
||||
const nPlusOneMarked = markNPlusOneTargets(
|
||||
annotated,
|
||||
sanitizedMinSentenceWordsForNPlusOne,
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
options.sourceText,
|
||||
);
|
||||
const nPlusOneMarked = nPlusOneEnabled
|
||||
? markNPlusOneTargets(
|
||||
annotated.map((token, index) => ({
|
||||
...token,
|
||||
isKnown: nPlusOneKnownStatuses[index] ?? false,
|
||||
})),
|
||||
sanitizedMinSentenceWordsForNPlusOne,
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
options.sourceText,
|
||||
).map((token, index) => ({
|
||||
...annotated[index]!,
|
||||
isNPlusOneTarget: token.isNPlusOneTarget,
|
||||
}))
|
||||
: annotated;
|
||||
|
||||
if (!nameMatchEnabled) {
|
||||
return nPlusOneMarked;
|
||||
|
||||
@@ -76,3 +76,28 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => {
|
||||
assert.equal(resolved.openRuntimeOptions, '9');
|
||||
assert.equal(resolved.openCharacterDictionary, 'Ctrl+Shift+A');
|
||||
});
|
||||
|
||||
test('preserves null shortcut overrides so defaults can be disabled', () => {
|
||||
const config: Config = {
|
||||
shortcuts: {
|
||||
copySubtitle: null,
|
||||
openJimaku: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
},
|
||||
};
|
||||
const defaults: Config = {
|
||||
shortcuts: {
|
||||
copySubtitle: 'Ctrl+KeyC',
|
||||
openJimaku: 'Ctrl+Shift+KeyJ',
|
||||
toggleSubtitleSidebar: 'Backslash',
|
||||
openRuntimeOptions: 'Digit9',
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveConfiguredShortcuts(config, defaults);
|
||||
|
||||
assert.equal(resolved.copySubtitle, null);
|
||||
assert.equal(resolved.openJimaku, null);
|
||||
assert.equal(resolved.toggleSubtitleSidebar, null);
|
||||
assert.equal(resolved.openRuntimeOptions, '9');
|
||||
});
|
||||
|
||||
@@ -26,77 +26,44 @@ export function resolveConfiguredShortcuts(
|
||||
defaultConfig: Config,
|
||||
): ConfiguredShortcuts {
|
||||
const isAnkiConnectDisabled = config.ankiConnect?.enabled === false;
|
||||
type ShortcutKey = keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'> &
|
||||
keyof NonNullable<Config['shortcuts']>;
|
||||
|
||||
const normalizeShortcut = (value: string | null | undefined): string | null | undefined => {
|
||||
if (typeof value !== 'string') return value;
|
||||
return value.replace(/\bKey([A-Z])\b/g, '$1').replace(/\bDigit([0-9])\b/g, '$1');
|
||||
};
|
||||
|
||||
const shortcutValue = (key: ShortcutKey): string | null | undefined =>
|
||||
Object.prototype.hasOwnProperty.call(config.shortcuts ?? {}, key)
|
||||
? config.shortcuts?.[key]
|
||||
: defaultConfig.shortcuts?.[key];
|
||||
|
||||
return {
|
||||
toggleVisibleOverlayGlobal: normalizeShortcut(
|
||||
config.shortcuts?.toggleVisibleOverlayGlobal ??
|
||||
defaultConfig.shortcuts?.toggleVisibleOverlayGlobal,
|
||||
),
|
||||
copySubtitle: normalizeShortcut(
|
||||
config.shortcuts?.copySubtitle ?? defaultConfig.shortcuts?.copySubtitle,
|
||||
),
|
||||
copySubtitleMultiple: normalizeShortcut(
|
||||
config.shortcuts?.copySubtitleMultiple ?? defaultConfig.shortcuts?.copySubtitleMultiple,
|
||||
),
|
||||
toggleVisibleOverlayGlobal: normalizeShortcut(shortcutValue('toggleVisibleOverlayGlobal')),
|
||||
copySubtitle: normalizeShortcut(shortcutValue('copySubtitle')),
|
||||
copySubtitleMultiple: normalizeShortcut(shortcutValue('copySubtitleMultiple')),
|
||||
updateLastCardFromClipboard: normalizeShortcut(
|
||||
isAnkiConnectDisabled
|
||||
? null
|
||||
: (config.shortcuts?.updateLastCardFromClipboard ??
|
||||
defaultConfig.shortcuts?.updateLastCardFromClipboard),
|
||||
isAnkiConnectDisabled ? null : shortcutValue('updateLastCardFromClipboard'),
|
||||
),
|
||||
triggerFieldGrouping: normalizeShortcut(
|
||||
isAnkiConnectDisabled
|
||||
? null
|
||||
: (config.shortcuts?.triggerFieldGrouping ?? defaultConfig.shortcuts?.triggerFieldGrouping),
|
||||
),
|
||||
triggerSubsync: normalizeShortcut(
|
||||
config.shortcuts?.triggerSubsync ?? defaultConfig.shortcuts?.triggerSubsync,
|
||||
),
|
||||
mineSentence: normalizeShortcut(
|
||||
isAnkiConnectDisabled
|
||||
? null
|
||||
: (config.shortcuts?.mineSentence ?? defaultConfig.shortcuts?.mineSentence),
|
||||
isAnkiConnectDisabled ? null : shortcutValue('triggerFieldGrouping'),
|
||||
),
|
||||
triggerSubsync: normalizeShortcut(shortcutValue('triggerSubsync')),
|
||||
mineSentence: normalizeShortcut(isAnkiConnectDisabled ? null : shortcutValue('mineSentence')),
|
||||
mineSentenceMultiple: normalizeShortcut(
|
||||
isAnkiConnectDisabled
|
||||
? null
|
||||
: (config.shortcuts?.mineSentenceMultiple ?? defaultConfig.shortcuts?.mineSentenceMultiple),
|
||||
isAnkiConnectDisabled ? null : shortcutValue('mineSentenceMultiple'),
|
||||
),
|
||||
multiCopyTimeoutMs:
|
||||
config.shortcuts?.multiCopyTimeoutMs ?? defaultConfig.shortcuts?.multiCopyTimeoutMs ?? 5000,
|
||||
toggleSecondarySub: normalizeShortcut(
|
||||
config.shortcuts?.toggleSecondarySub ?? defaultConfig.shortcuts?.toggleSecondarySub,
|
||||
),
|
||||
markAudioCard: normalizeShortcut(
|
||||
isAnkiConnectDisabled
|
||||
? null
|
||||
: (config.shortcuts?.markAudioCard ?? defaultConfig.shortcuts?.markAudioCard),
|
||||
),
|
||||
openCharacterDictionary: normalizeShortcut(
|
||||
config.shortcuts?.openCharacterDictionary ?? defaultConfig.shortcuts?.openCharacterDictionary,
|
||||
),
|
||||
openRuntimeOptions: normalizeShortcut(
|
||||
config.shortcuts?.openRuntimeOptions ?? defaultConfig.shortcuts?.openRuntimeOptions,
|
||||
),
|
||||
openJimaku: normalizeShortcut(
|
||||
config.shortcuts?.openJimaku ?? defaultConfig.shortcuts?.openJimaku,
|
||||
),
|
||||
openSessionHelp: normalizeShortcut(
|
||||
config.shortcuts?.openSessionHelp ?? defaultConfig.shortcuts?.openSessionHelp,
|
||||
),
|
||||
openControllerSelect: normalizeShortcut(
|
||||
config.shortcuts?.openControllerSelect ?? defaultConfig.shortcuts?.openControllerSelect,
|
||||
),
|
||||
openControllerDebug: normalizeShortcut(
|
||||
config.shortcuts?.openControllerDebug ?? defaultConfig.shortcuts?.openControllerDebug,
|
||||
),
|
||||
toggleSubtitleSidebar: normalizeShortcut(
|
||||
config.shortcuts?.toggleSubtitleSidebar ?? defaultConfig.shortcuts?.toggleSubtitleSidebar,
|
||||
),
|
||||
toggleSecondarySub: normalizeShortcut(shortcutValue('toggleSecondarySub')),
|
||||
markAudioCard: normalizeShortcut(isAnkiConnectDisabled ? null : shortcutValue('markAudioCard')),
|
||||
openCharacterDictionary: normalizeShortcut(shortcutValue('openCharacterDictionary')),
|
||||
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
||||
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
||||
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
|
||||
toggleSubtitleSidebar: normalizeShortcut(shortcutValue('toggleSubtitleSidebar')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
hasTransportedStartupArgs,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
||||
@@ -55,6 +56,56 @@ test('normalizeStartupArgv defaults no-arg Windows startup to --start only', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeStartupArgv uses transported AppImage args instead of raw Electron args', () => {
|
||||
assert.deepEqual(
|
||||
normalizeStartupArgv(['SubMiner.AppImage', '--background'], {
|
||||
SUBMINER_APP_ARGC: '2',
|
||||
SUBMINER_APP_ARG_0: '--stop',
|
||||
SUBMINER_APP_ARG_1: '--socket',
|
||||
}),
|
||||
['SubMiner.AppImage', '--stop', '--socket'],
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeStartupArgv defaults empty transported AppImage args to background startup', () => {
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeStartupArgv(['SubMiner.AppImage', '--background'], {
|
||||
SUBMINER_APP_ARGC: '0',
|
||||
}),
|
||||
['SubMiner.AppImage', '--start', '--background'],
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeStartupArgv defaults passive-only transported AppImage args to background startup', () => {
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeStartupArgv(['SubMiner.AppImage'], {
|
||||
SUBMINER_APP_ARGC: '2',
|
||||
SUBMINER_APP_ARG_0: '--password-store',
|
||||
SUBMINER_APP_ARG_1: 'gnome-libsecret',
|
||||
}),
|
||||
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('hasTransportedStartupArgs detects env-carried app args', () => {
|
||||
assert.equal(hasTransportedStartupArgs({ SUBMINER_APP_ARGC: '1' }), true);
|
||||
assert.equal(hasTransportedStartupArgs({}), false);
|
||||
});
|
||||
|
||||
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
|
||||
|
||||
@@ -7,6 +7,9 @@ const BACKGROUND_ARG = '--background';
|
||||
const START_ARG = '--start';
|
||||
const PASSWORD_STORE_ARG = '--password-store';
|
||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||
const MAX_TRANSPORTED_APP_ARGS = 256;
|
||||
const APP_NAME = 'SubMiner';
|
||||
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||
'--alang',
|
||||
@@ -83,9 +86,46 @@ function parseCliArgs(argv: string[]): CliArgs {
|
||||
return parseArgs(argv);
|
||||
}
|
||||
|
||||
export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean {
|
||||
return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string';
|
||||
}
|
||||
|
||||
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
|
||||
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
|
||||
if (rawCount === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const count = Number(rawCount);
|
||||
if (!Number.isInteger(count) || count < 0 || count > MAX_TRANSPORTED_APP_ARGS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const args: string[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const value = env[`${TRANSPORTED_APP_ARG_PREFIX}${index}`];
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
args.push(value);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): string[] {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return argv;
|
||||
|
||||
const transportedArgs = readTransportedStartupArgs(env);
|
||||
if (transportedArgs) {
|
||||
if (removePassiveStartupArgs(transportedArgs).length === 0) {
|
||||
if (process.platform === 'win32') {
|
||||
return [argv[0] ?? APP_NAME, ...transportedArgs, START_ARG];
|
||||
}
|
||||
return [argv[0] ?? APP_NAME, ...transportedArgs, START_ARG, BACKGROUND_ARG];
|
||||
}
|
||||
return [argv[0] ?? APP_NAME, ...transportedArgs];
|
||||
}
|
||||
|
||||
const effectiveArgs = removePassiveStartupArgs(argv.slice(1));
|
||||
if (effectiveArgs.length === 0) {
|
||||
if (process.platform === 'win32') {
|
||||
|
||||
+3
-1
@@ -13,6 +13,7 @@ import {
|
||||
sanitizeBackgroundEnv,
|
||||
sanitizeHelpEnv,
|
||||
sanitizeLaunchMpvEnv,
|
||||
hasTransportedStartupArgs,
|
||||
shouldDetachBackgroundLaunch,
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
@@ -184,7 +185,8 @@ registerFatalErrorHandlers({
|
||||
});
|
||||
|
||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||
const child = spawn(process.execPath, process.argv.slice(1), {
|
||||
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
||||
const child = spawn(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: sanitizeBackgroundEnv(process.env),
|
||||
|
||||
+339
-62
@@ -21,7 +21,6 @@ import {
|
||||
clipboard,
|
||||
globalShortcut,
|
||||
ipcMain,
|
||||
net,
|
||||
shell,
|
||||
protocol,
|
||||
Extension,
|
||||
@@ -35,6 +34,8 @@ import {
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||
import { getAppControlSocketPath } from './shared/app-control';
|
||||
import {
|
||||
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
||||
@@ -91,7 +92,7 @@ protocol.registerSchemesAsPrivileged([
|
||||
]);
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { MecabTokenizer } from './mecab-tokenizer';
|
||||
@@ -104,6 +105,7 @@ import type {
|
||||
RuntimeOptionState,
|
||||
SessionActionDispatchRequest,
|
||||
SecondarySubMode,
|
||||
SubtitleCue,
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
UpdateChannel,
|
||||
@@ -140,6 +142,7 @@ import {
|
||||
} from './cli/args';
|
||||
import { printHelp } from './cli/help';
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
|
||||
import { AnkiConnectClient } from './anki-connect';
|
||||
import {
|
||||
getStartupModeFlags,
|
||||
shouldRefreshAnilistOnConfigReload,
|
||||
@@ -165,6 +168,7 @@ import {
|
||||
rememberAnilistAttemptedUpdateKey,
|
||||
} from './main/runtime/domains/anilist';
|
||||
import { DEFAULT_MIN_WATCH_RATIO } from './shared/watch-threshold';
|
||||
import { shouldShowTexthookerTrayEntry } from './main/runtime/tray-main-actions';
|
||||
import {
|
||||
createApplyJellyfinMpvDefaultsHandler,
|
||||
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
|
||||
@@ -310,6 +314,7 @@ import {
|
||||
importYomitanDictionaryFromZip,
|
||||
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
|
||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||
isOverlayWindowContentReady,
|
||||
jellyfinTicksToSecondsRuntime,
|
||||
listJellyfinItemsRuntime,
|
||||
listJellyfinLibrariesRuntime,
|
||||
@@ -361,6 +366,8 @@ import {
|
||||
createYoutubePrimarySubtitleNotificationRuntime,
|
||||
} from './main/runtime/youtube-primary-subtitle-notification';
|
||||
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate';
|
||||
import { selectAutoplayStartupCue } from './main/runtime/autoplay-subtitle-primer';
|
||||
import { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release';
|
||||
import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection';
|
||||
import {
|
||||
buildFirstRunSetupHtml,
|
||||
@@ -375,7 +382,6 @@ import {
|
||||
detectInstalledMpvPlugin,
|
||||
removeLegacyMpvPluginCandidates,
|
||||
resolvePackagedRuntimePluginPath,
|
||||
syncInstalledFirstRunPluginBinaryPath,
|
||||
} from './main/runtime/first-run-setup-plugin';
|
||||
import {
|
||||
applyWindowsMpvShortcuts,
|
||||
@@ -401,6 +407,7 @@ import {
|
||||
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
|
||||
import {
|
||||
shouldEnsureTrayOnStartupForInitialArgs,
|
||||
shouldQuitOnMpvShutdownForTrayState,
|
||||
shouldQuitOnWindowAllClosedForTrayState,
|
||||
} from './main/runtime/startup-tray-policy';
|
||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||
@@ -494,12 +501,13 @@ import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/
|
||||
import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion';
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
|
||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||
import {
|
||||
createElectronAppUpdater,
|
||||
isNativeUpdaterSupported,
|
||||
} from './main/runtime/update/app-updater';
|
||||
import { createElectronNetFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter';
|
||||
import { createCurlFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter';
|
||||
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
|
||||
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
|
||||
import {
|
||||
@@ -536,6 +544,7 @@ import {
|
||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
||||
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||
import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload';
|
||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||
@@ -607,6 +616,7 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
|
||||
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
|
||||
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
|
||||
const TRAY_TOOLTIP = 'SubMiner';
|
||||
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
|
||||
const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js');
|
||||
|
||||
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
|
||||
@@ -664,14 +674,18 @@ const texthookerService = new Texthooker(() => {
|
||||
const characterDictionaryEnabled =
|
||||
config.anilist.characterDictionary.enabled &&
|
||||
yomitanProfilePolicy.isCharacterDictionaryEnabled();
|
||||
const knownAndNPlusOneEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
const knownWordColoringEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
config.ankiConnect.knownWords.highlightEnabled,
|
||||
);
|
||||
const nPlusOneColoringEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
config.ankiConnect.nPlusOne.enabled,
|
||||
);
|
||||
|
||||
return {
|
||||
enableKnownWordColoring: knownAndNPlusOneEnabled,
|
||||
enableNPlusOneColoring: knownAndNPlusOneEnabled,
|
||||
enableKnownWordColoring: knownWordColoringEnabled,
|
||||
enableNPlusOneColoring: nPlusOneColoringEnabled,
|
||||
enableNameMatchColoring: config.subtitleStyle.nameMatchEnabled && characterDictionaryEnabled,
|
||||
enableFrequencyColoring: getRuntimeBooleanOption(
|
||||
'subtitle.annotation.frequency',
|
||||
@@ -682,8 +696,8 @@ const texthookerService = new Texthooker(() => {
|
||||
config.subtitleStyle.enableJlpt,
|
||||
),
|
||||
characterDictionaryEnabled,
|
||||
knownWordColor: config.ankiConnect.knownWords.color,
|
||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
||||
knownWordColor: config.subtitleStyle.knownWordColor,
|
||||
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
|
||||
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
||||
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
|
||||
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
|
||||
@@ -722,6 +736,7 @@ type BootServices = MainBootServicesResult<
|
||||
{
|
||||
requestSingleInstanceLock: () => boolean;
|
||||
quit: () => void;
|
||||
exit: (code?: number) => void;
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
||||
whenReady: () => Promise<void>;
|
||||
}
|
||||
@@ -778,7 +793,7 @@ const bootServices = createMainBootServices({
|
||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||
error: (message: string, details?: unknown) => console.error(message, details),
|
||||
}),
|
||||
createSubtitleWebSocket: () => new SubtitleWebSocket(),
|
||||
createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode),
|
||||
createLogger,
|
||||
createMainRuntimeRegistry,
|
||||
createOverlayManager,
|
||||
@@ -1091,6 +1106,13 @@ const autoplayReadyGate = createAutoplayReadyGate({
|
||||
signalPluginAutoplayReady: () => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
isSignalTargetReady: () => {
|
||||
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||
return true;
|
||||
}
|
||||
const overlayWindow = overlayManager.getMainWindow();
|
||||
return Boolean(overlayWindow && isOverlayWindowContentReady(overlayWindow));
|
||||
},
|
||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
});
|
||||
@@ -1222,6 +1244,7 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
||||
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) =>
|
||||
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
|
||||
},
|
||||
getMpvPluginRuntimeConfig(),
|
||||
),
|
||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
||||
@@ -1232,6 +1255,21 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
||||
clearScheduled: (timer) => clearTimeout(timer),
|
||||
});
|
||||
|
||||
function getMpvPluginRuntimeConfig() {
|
||||
const config = getResolvedConfig();
|
||||
return {
|
||||
socketPath: appState.mpvSocketPath,
|
||||
binaryPath: config.mpv.subminerBinaryPath,
|
||||
backend: config.mpv.backend,
|
||||
autoStart: config.mpv.autoStartSubMiner,
|
||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||
aniskipEnabled: config.mpv.aniskipEnabled,
|
||||
aniskipButtonKey: config.mpv.aniskipButtonKey,
|
||||
};
|
||||
}
|
||||
|
||||
let firstRunSetupMessage: string | null = null;
|
||||
const resolveWindowsMpvShortcutRuntimePaths = () =>
|
||||
resolveWindowsMpvShortcutPaths({
|
||||
@@ -1248,12 +1286,6 @@ const createCommandLineLauncherRuntimeOptions = () => ({
|
||||
resourcesPath: process.resourcesPath,
|
||||
appExePath: process.execPath,
|
||||
});
|
||||
syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
binaryPath: process.execPath,
|
||||
});
|
||||
const firstRunSetupService = createFirstRunSetupService({
|
||||
platform: process.platform,
|
||||
configDir: CONFIG_DIR,
|
||||
@@ -1570,6 +1602,7 @@ function emitSubtitlePayload(payload: SubtitleData): void {
|
||||
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
||||
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
||||
});
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
|
||||
subtitlePrefetchService?.resume();
|
||||
}
|
||||
const buildSubtitleProcessingControllerMainDepsHandler =
|
||||
@@ -1593,6 +1626,88 @@ let lastObservedTimePos = 0;
|
||||
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
|
||||
null;
|
||||
const SEEK_THRESHOLD_SECONDS = 3;
|
||||
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
|
||||
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
||||
|
||||
function getCurrentAutoplayMediaPath(): string | null {
|
||||
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
|
||||
}
|
||||
|
||||
function isCurrentAutoplayMediaPath(mediaPath: string): boolean {
|
||||
return getCurrentAutoplayMediaPath() === mediaPath;
|
||||
}
|
||||
|
||||
function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean {
|
||||
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
|
||||
return false;
|
||||
}
|
||||
autoplaySubtitlePrimedMediaPath = mediaPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
|
||||
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||
return false;
|
||||
}
|
||||
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
appState.currentSubText = text;
|
||||
const rawPayload = withCurrentSubtitleTiming({ text, tokens: null });
|
||||
appState.currentSubtitleData = rawPayload;
|
||||
broadcastToOverlayWindows('subtitle:set', rawPayload);
|
||||
subtitlePrefetchService?.pause();
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
|
||||
const client = appState.mpvClient;
|
||||
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subTextRaw = await client.requestProperty('sub-text').catch((error) => {
|
||||
logger.debug(
|
||||
`[autoplay-subtitle-prime] failed to read sub-text: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
emitAutoplayPrimedSubtitle(mediaPath, text);
|
||||
}
|
||||
|
||||
async function primeAutoplaySubtitleFromParsedCues(
|
||||
mediaPath: string,
|
||||
cues: SubtitleCue[],
|
||||
): Promise<void> {
|
||||
if (
|
||||
cues.length === 0 ||
|
||||
autoplaySubtitlePrimedMediaPath === mediaPath ||
|
||||
!isCurrentAutoplayMediaPath(mediaPath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = appState.mpvClient;
|
||||
const timePosRaw = await client?.requestProperty('time-pos').catch(() => null);
|
||||
const currentTimeSeconds = Number(
|
||||
timePosRaw ?? client?.currentTimePos ?? lastObservedTimePos ?? 0,
|
||||
);
|
||||
const cue = selectAutoplayStartupCue(
|
||||
cues,
|
||||
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
||||
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
|
||||
);
|
||||
if (!cue) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
|
||||
}
|
||||
|
||||
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||
if (subtitlePrefetchRefreshTimer) {
|
||||
@@ -1625,6 +1740,16 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
||||
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
||||
appState.activeParsedSubtitleCues = cues ?? [];
|
||||
appState.activeParsedSubtitleSource = sourceKey;
|
||||
const mediaPath = getCurrentAutoplayMediaPath();
|
||||
if (mediaPath && cues?.length) {
|
||||
void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => {
|
||||
logger.debug(
|
||||
`[autoplay-subtitle-prime] failed to prime from parsed cues: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
@@ -1772,6 +1897,18 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
||||
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
|
||||
}
|
||||
},
|
||||
invalidateTokenizationCache: () => {
|
||||
subtitleProcessingController.invalidateTokenizationCache();
|
||||
},
|
||||
refreshSubtitlePrefetch: () => {
|
||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||
},
|
||||
refreshCurrentSubtitle: () => {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
},
|
||||
setLogLevel: (level) => {
|
||||
setLogLevel(level, 'config');
|
||||
},
|
||||
},
|
||||
);
|
||||
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(
|
||||
@@ -1813,7 +1950,9 @@ const configSettingsRuntime = createConfigSettingsRuntime({
|
||||
getConfig: () => configService.getConfig(),
|
||||
getWarnings: () => configService.getWarnings(),
|
||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||
applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config),
|
||||
onHotReloadApplied: applyConfigHotReloadDiff,
|
||||
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
|
||||
createAnkiClient: (url) => new AnkiConnectClient(url),
|
||||
getSettingsWindow: () => appState.configSettingsWindow,
|
||||
setSettingsWindow: (window) => {
|
||||
appState.configSettingsWindow = window as BrowserWindow | null;
|
||||
@@ -2570,7 +2709,11 @@ function getResolvedConfig() {
|
||||
}
|
||||
|
||||
function getRuntimeBooleanOption(
|
||||
id: 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency',
|
||||
id:
|
||||
| 'subtitle.annotation.knownWords.highlightEnabled'
|
||||
| 'subtitle.annotation.nPlusOne'
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency',
|
||||
fallback: boolean,
|
||||
): boolean {
|
||||
const value = appState.runtimeOptionsManager?.getOptionValue(id);
|
||||
@@ -2579,9 +2722,13 @@ function getRuntimeBooleanOption(
|
||||
|
||||
function shouldInitializeMecabForAnnotations(): boolean {
|
||||
const config = getResolvedConfig();
|
||||
const knownWordsEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
config.ankiConnect.knownWords.highlightEnabled,
|
||||
);
|
||||
const nPlusOneEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
config.ankiConnect.knownWords.highlightEnabled,
|
||||
config.ankiConnect.nPlusOne.enabled,
|
||||
);
|
||||
const jlptEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.jlpt',
|
||||
@@ -2591,7 +2738,7 @@ function shouldInitializeMecabForAnnotations(): boolean {
|
||||
'subtitle.annotation.frequency',
|
||||
config.subtitleStyle.frequencyDictionary.enabled,
|
||||
);
|
||||
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -2623,6 +2770,7 @@ const {
|
||||
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
|
||||
platform: process.platform,
|
||||
execPath: process.execPath,
|
||||
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
|
||||
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
||||
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
|
||||
removeSocketPath: (socketPath) => {
|
||||
@@ -2928,6 +3076,16 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'open-config-settings') {
|
||||
const opened = openConfigSettingsWindow();
|
||||
firstRunSetupMessage = opened
|
||||
? 'Opened SubMiner settings.'
|
||||
: 'SubMiner settings are unavailable.';
|
||||
if (opened) {
|
||||
return { skipRender: true };
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'refresh') {
|
||||
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
@@ -3399,8 +3557,9 @@ const {
|
||||
stopConfigHotReload: () => configHotReloadRuntime.stop(),
|
||||
restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(),
|
||||
restoreMpvSubVisibility: () => {
|
||||
restoreOverlayMpvSubtitles();
|
||||
restoreOverlayMpvSubtitles({ force: true });
|
||||
},
|
||||
isAppReady: () => app.isReady(),
|
||||
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
|
||||
stopSubtitleWebsocket: () => {
|
||||
subtitleWsService.stop();
|
||||
@@ -4030,6 +4189,9 @@ async function ensureYoutubePlaybackRuntimeReady(): Promise<void> {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
}
|
||||
|
||||
let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined) => void) | null =
|
||||
null;
|
||||
|
||||
const {
|
||||
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
|
||||
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
|
||||
@@ -4055,6 +4217,17 @@ const {
|
||||
reportJellyfinRemoteStopped: () => {
|
||||
void reportJellyfinRemoteStopped();
|
||||
},
|
||||
onMpvConnected: () => {
|
||||
if (appState.sessionBindingsInitialized) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
'subminer-reload-session-bindings',
|
||||
]);
|
||||
}
|
||||
if (appState.currentSubText.trim()) {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
}
|
||||
},
|
||||
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
|
||||
recordAnilistMediaDuration: (durationSec) => {
|
||||
recordAnilistMediaDuration(durationSec);
|
||||
@@ -4080,6 +4253,27 @@ const {
|
||||
tokenizeSubtitleForImmersion: async (text): Promise<SubtitleData | null> =>
|
||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
|
||||
updateCurrentMediaPath: (path) => {
|
||||
const normalizedPath = path.trim();
|
||||
const previousPath = appState.currentMediaPath?.trim() || null;
|
||||
if ((normalizedPath || null) !== previousPath) {
|
||||
const resetSubtitlePayload = { text: '', tokens: null };
|
||||
const frequencyDictionary = getResolvedConfig().subtitleStyle.frequencyDictionary;
|
||||
const frequencyOptions = {
|
||||
enabled: frequencyDictionary.enabled,
|
||||
topX: frequencyDictionary.topX,
|
||||
mode: frequencyDictionary.mode,
|
||||
};
|
||||
autoplaySubtitlePrimedMediaPath = null;
|
||||
lastObservedTimePos = 0;
|
||||
appState.currentSubText = '';
|
||||
appState.currentSubAssText = '';
|
||||
appState.currentSubtitleData = null;
|
||||
appState.activeParsedSubtitleCues = [];
|
||||
appState.activeParsedSubtitleSource = null;
|
||||
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
|
||||
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
}
|
||||
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
||||
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||
@@ -4089,7 +4283,8 @@ const {
|
||||
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
||||
if (path) {
|
||||
ensureImmersionTrackerStarted();
|
||||
// Delay slightly to allow MPV's track-list to be populated.
|
||||
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
|
||||
// Retry after a short delay because MPV can populate track-list after path.
|
||||
subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500);
|
||||
}
|
||||
mediaRuntime.updateCurrentMediaPath(path);
|
||||
@@ -4113,15 +4308,7 @@ const {
|
||||
syncImmersionMediaState: () => {
|
||||
immersionMediaRuntime.syncFromCurrentMediaState();
|
||||
},
|
||||
signalAutoplayReadyIfWarm: () => {
|
||||
if (!isTokenizationWarmupReady()) {
|
||||
return;
|
||||
}
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(
|
||||
{ text: '__warm__', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
},
|
||||
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
|
||||
return;
|
||||
@@ -4185,7 +4372,12 @@ const {
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
|
||||
appState.reconnectTimer = timer;
|
||||
},
|
||||
shouldQuitOnMpvShutdown: () => appState.initialArgs?.managedPlayback === true,
|
||||
shouldQuitOnMpvShutdown: () =>
|
||||
shouldQuitOnMpvShutdownForTrayState({
|
||||
managedPlayback: appState.initialArgs?.managedPlayback === true,
|
||||
backgroundMode: appState.backgroundMode,
|
||||
hasTray: Boolean(appTray),
|
||||
}),
|
||||
requestAppQuit: () => requestAppQuit(),
|
||||
},
|
||||
updateMpvSubtitleRenderMetricsMainDeps: {
|
||||
@@ -4222,10 +4414,15 @@ const {
|
||||
getKnownWordMatchMode: () =>
|
||||
appState.ankiIntegration?.getKnownWordMatchMode() ??
|
||||
getResolvedConfig().ankiConnect.knownWords.matchMode,
|
||||
getKnownWordsEnabled: () =>
|
||||
getRuntimeBooleanOption(
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
||||
),
|
||||
getNPlusOneEnabled: () =>
|
||||
getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
||||
getResolvedConfig().ankiConnect.nPlusOne.enabled,
|
||||
),
|
||||
getMinSentenceWordsForNPlusOne: () =>
|
||||
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
|
||||
@@ -4250,15 +4447,11 @@ const {
|
||||
getFrequencyRank: (text) => appState.frequencyRankLookup(text),
|
||||
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
|
||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||
onTokenizationReady: (text) => {
|
||||
onTokenizationReady: () => {
|
||||
currentMediaTokenizationGate.markReady(
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||
);
|
||||
startupOsdSequencer.markTokenizationReady();
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(
|
||||
{ text, tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
},
|
||||
},
|
||||
createTokenizerRuntimeDeps: (deps) =>
|
||||
@@ -4335,6 +4528,22 @@ const {
|
||||
},
|
||||
},
|
||||
});
|
||||
signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => isTokenizationWarmupReady(),
|
||||
startTokenizationWarmups: async () => {
|
||||
await startTokenizationWarmups();
|
||||
},
|
||||
getCurrentMediaPath: () =>
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||
primeCurrentSubtitle: (mediaPath) => primeCurrentSubtitleForAutoplay(mediaPath),
|
||||
signalAutoplayReady: () => {
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(
|
||||
{ text: '__warm__', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
},
|
||||
warn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
||||
|
||||
function createMpvClientRuntimeService(): MpvIpcClient {
|
||||
@@ -4634,6 +4843,7 @@ function compileCurrentSessionBindings(): {
|
||||
keybindings: appState.keybindings,
|
||||
shortcuts: getConfiguredShortcuts(),
|
||||
statsToggleKey: getResolvedConfig().stats.toggleKey,
|
||||
statsMarkWatchedKey: getResolvedConfig().stats.markWatchedKey,
|
||||
platform: resolveSessionBindingPlatform(),
|
||||
rawConfig: getResolvedConfig(),
|
||||
});
|
||||
@@ -4693,26 +4903,19 @@ flushPendingMpvLogWrites = () => {
|
||||
|
||||
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
|
||||
let updateService: ReturnType<typeof createUpdateService> | null = null;
|
||||
const electronNetFetch = createElectronNetFetch({
|
||||
fetch: (url, init) => net.fetch(url, init as RequestInit),
|
||||
});
|
||||
const globalFetchForUpdater = createGlobalFetch();
|
||||
const curlFetch = createCurlFetch();
|
||||
|
||||
function createNativeUpdaterHttpExecutor() {
|
||||
if (process.platform === 'darwin') {
|
||||
return createCurlHttpExecutor();
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return createFetchHttpExecutor();
|
||||
}
|
||||
return undefined;
|
||||
return createCurlHttpExecutor();
|
||||
}
|
||||
|
||||
function getFetchForUpdater() {
|
||||
if (process.platform === 'win32') {
|
||||
return globalFetchForUpdater;
|
||||
}
|
||||
return electronNetFetch;
|
||||
if (process.platform === 'win32') return globalFetchForUpdater;
|
||||
return curlFetch;
|
||||
}
|
||||
|
||||
async function updateLauncherFromSelectedRelease(
|
||||
@@ -4759,11 +4962,8 @@ function getUpdateService() {
|
||||
isPackaged: app.isPackaged,
|
||||
log: (message) => logger.info(message),
|
||||
getChannel: () => getResolvedConfig().updates.channel,
|
||||
configureHttpExecutor:
|
||||
process.platform === 'darwin' || process.platform === 'win32'
|
||||
? createNativeUpdaterHttpExecutor
|
||||
: undefined,
|
||||
disableDifferentialDownload: process.platform === 'darwin' || process.platform === 'win32',
|
||||
configureHttpExecutor: createNativeUpdaterHttpExecutor,
|
||||
disableDifferentialDownload: true,
|
||||
isNativeUpdaterSupported: () =>
|
||||
isNativeUpdaterSupported({
|
||||
platform: process.platform,
|
||||
@@ -4775,7 +4975,37 @@ function getUpdateService() {
|
||||
});
|
||||
const updateDialogPresenter = createUpdateDialogPresenter({
|
||||
platform: process.platform,
|
||||
focusApp: () => app.focus({ steal: true }),
|
||||
focusApp: async () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.focus({ steal: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await app.dock?.show();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to show macOS dock before update dialog', error);
|
||||
}
|
||||
// app.focus({ steal: true }) alone does not reliably activate the process
|
||||
// when SubMiner was reached via `subminer -u` (single-instance forwarding
|
||||
// from a CLI-spawned child). osascript's `activate` uses LaunchServices,
|
||||
// which is the only path that reliably brings the running app forward.
|
||||
await new Promise<void>((resolve) => {
|
||||
execFile(
|
||||
'/usr/bin/osascript',
|
||||
['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`],
|
||||
{ timeout: 2000 },
|
||||
(error) => {
|
||||
if (error) {
|
||||
logger.warn(
|
||||
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
app.focus({ steal: true });
|
||||
},
|
||||
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||
});
|
||||
updateService = createUpdateService({
|
||||
@@ -5134,6 +5364,18 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
toggleSecondarySub: () => handleCycleSecondarySubMode(),
|
||||
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
|
||||
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
||||
markActiveVideoWatched: async () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
const marked = (await appState.immersionTracker?.markActiveVideoWatched()) ?? false;
|
||||
if (marked) {
|
||||
try {
|
||||
await maybeRunAnilistPostWatchUpdate({ force: true });
|
||||
} catch (error) {
|
||||
logger.warn('Failed to run AniList post-watch update after manual watched mark:', error);
|
||||
}
|
||||
}
|
||||
return marked;
|
||||
},
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openSessionHelp: () => openSessionHelpOverlay(),
|
||||
@@ -5155,6 +5397,8 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
(text) => showMpvOsd(text),
|
||||
);
|
||||
},
|
||||
playNextPlaylistItem: () =>
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
}
|
||||
@@ -5242,8 +5486,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
quitApp: () => requestAppQuit(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
tokenizeCurrentSubtitle: async () =>
|
||||
withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
|
||||
tokenizeCurrentSubtitle: async () => {
|
||||
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
||||
return resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: appState.currentSubText,
|
||||
currentSubtitleData: appState.currentSubtitleData,
|
||||
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
|
||||
tokenizeSubtitle: tokenizeSubtitleForCurrent
|
||||
? (text) => tokenizeSubtitleForCurrent(text)
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||
getSubtitleSidebarSnapshot: async () => {
|
||||
@@ -5556,6 +5809,16 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
|
||||
handleCliCommand(nextArgs, source),
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
|
||||
startControlServer: (handleArgv: (argv: string[]) => void) => {
|
||||
const server = startAppControlServer({
|
||||
socketPath: getAppControlSocketPath({ configDir: CONFIG_DIR }),
|
||||
platform: process.platform,
|
||||
handleArgv,
|
||||
logDebug: (message) => logger.debug(message),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
return () => server.close();
|
||||
},
|
||||
onReady: runAppReadyRuntimeWithFatalReporting,
|
||||
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
|
||||
@@ -5580,7 +5843,7 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
|
||||
enforceUnsupportedWaylandMode(args);
|
||||
},
|
||||
shouldStartApp: (args: CliArgs) => shouldStartApp(args),
|
||||
getDefaultSocketPath: () => getDefaultSocketPath(),
|
||||
getDefaultSocketPath: () => getResolvedConfig().mpv.socketPath || getDefaultSocketPath(),
|
||||
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||
configDir: CONFIG_DIR,
|
||||
defaultConfig: DEFAULT_CONFIG,
|
||||
@@ -5646,7 +5909,13 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
||||
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
onWindowContentReady: () => {
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
if (appState.currentSubText.trim()) {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
}
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
},
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
@@ -5697,12 +5966,11 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||
openTexthookerInBrowser: () =>
|
||||
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
|
||||
showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false,
|
||||
showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()),
|
||||
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
||||
isJellyfinConfigured: () =>
|
||||
@@ -5769,6 +6037,15 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
||||
setLoadInFlight: (promise) => {
|
||||
yomitanLoadInFlight = promise;
|
||||
},
|
||||
onYomitanExtensionLoaded: () => {
|
||||
const reloaded = reloadOverlayWindowsForYomitanContentScripts(
|
||||
getOverlayWindows(),
|
||||
(message, error) => logger.warn(message, error),
|
||||
);
|
||||
if (reloaded > 0) {
|
||||
logger.debug(`Reloaded ${reloaded} overlay window(s) after Yomitan extension load.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
runtimeRegistry.overlay.createOverlayRuntimeBootstrapHandlers({
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
|
||||
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
|
||||
printHelp: () => void;
|
||||
logNoRunningInstance: () => void;
|
||||
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||
onReady: () => Promise<void>;
|
||||
onWillQuitCleanup: () => void;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
@@ -73,6 +74,7 @@ export function createAppLifecycleRuntimeDeps(
|
||||
handleCliCommand: params.handleCliCommand,
|
||||
printHelp: params.printHelp,
|
||||
logNoRunningInstance: params.logNoRunningInstance,
|
||||
startControlServer: params.startControlServer,
|
||||
onReady: params.onReady,
|
||||
onWillQuitCleanup: params.onWillQuitCleanup,
|
||||
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
|
||||
|
||||
@@ -6,6 +6,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
type MockAppLifecycleApp = {
|
||||
requestSingleInstanceLock: () => boolean;
|
||||
quit: () => void;
|
||||
exit: (code?: number) => void;
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp;
|
||||
whenReady: () => Promise<void>;
|
||||
};
|
||||
@@ -20,7 +21,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
{ targetPath: string },
|
||||
{ targetPath: string },
|
||||
{ targetPath: string },
|
||||
{ kind: string },
|
||||
{ kind: string; payloadMode: 'plain' | 'annotated' },
|
||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||
{ registry: boolean },
|
||||
{ getMainWindow: () => null; getModalWindow: () => null },
|
||||
@@ -54,6 +55,9 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
setPathValue = value;
|
||||
},
|
||||
quit: () => {},
|
||||
exit: (code?: number) => {
|
||||
calls.push(`exit:${code ?? 0}`);
|
||||
},
|
||||
on: (event: string) => {
|
||||
appOnCalls.push(event);
|
||||
return {};
|
||||
@@ -72,7 +76,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||
createSubtitleWebSocket: () => ({ kind: 'ws' }),
|
||||
createSubtitleWebSocket: (payloadMode) => ({ kind: 'ws', payloadMode }),
|
||||
createLogger: (scope) =>
|
||||
({
|
||||
scope,
|
||||
@@ -111,6 +115,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
assert.deepEqual(services.anilistUpdateQueue, {
|
||||
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
|
||||
});
|
||||
assert.deepEqual(services.subtitleWsService, { kind: 'ws', payloadMode: 'plain' });
|
||||
assert.deepEqual(services.annotationSubtitleWsService, {
|
||||
kind: 'ws',
|
||||
payloadMode: 'annotated',
|
||||
});
|
||||
assert.deepEqual(services.appState, {
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
texthookerPort: 5174,
|
||||
@@ -123,8 +132,9 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
services.appLifecycleApp.on('second-instance', () => {}),
|
||||
services.appLifecycleApp,
|
||||
);
|
||||
services.appLifecycleApp.exit(7);
|
||||
assert.deepEqual(appOnCalls, ['ready']);
|
||||
assert.equal(secondInstanceHandlerRegistered, true);
|
||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);
|
||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
|
||||
assert.equal(setPathValue, '/tmp/subminer-config');
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ConfigStartupParseError } from '../../config';
|
||||
export interface AppLifecycleShape {
|
||||
requestSingleInstanceLock: () => boolean;
|
||||
quit: () => void;
|
||||
exit: (code?: number) => void;
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
||||
whenReady: () => Promise<void>;
|
||||
}
|
||||
@@ -50,6 +51,7 @@ export interface MainBootServicesParams<
|
||||
app: {
|
||||
setPath: (name: string, value: string) => void;
|
||||
quit: () => void;
|
||||
exit: (code?: number) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
|
||||
on: Function;
|
||||
whenReady: () => Promise<void>;
|
||||
@@ -62,7 +64,7 @@ export interface MainBootServicesParams<
|
||||
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
||||
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
|
||||
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
|
||||
createSubtitleWebSocket: () => TSubtitleWebSocket;
|
||||
createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => TSubtitleWebSocket;
|
||||
createLogger: (scope: string) => TLogger & {
|
||||
warn: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
@@ -203,8 +205,8 @@ export function createMainBootServices<
|
||||
const anilistUpdateQueue = params.createAnilistUpdateQueue(
|
||||
params.joinPath(userDataPath, 'anilist-retry-queue.json'),
|
||||
);
|
||||
const subtitleWsService = params.createSubtitleWebSocket();
|
||||
const annotationSubtitleWsService = params.createSubtitleWebSocket();
|
||||
const subtitleWsService = params.createSubtitleWebSocket('plain');
|
||||
const annotationSubtitleWsService = params.createSubtitleWebSocket('annotated');
|
||||
const logger = params.createLogger('main');
|
||||
const runtimeRegistry = params.createMainRuntimeRegistry();
|
||||
const overlayManager = params.createOverlayManager();
|
||||
@@ -260,6 +262,7 @@ export function createMainBootServices<
|
||||
requestSingleInstanceLock: () =>
|
||||
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
|
||||
quit: () => params.app.quit(),
|
||||
exit: (code?: number) => params.app.exit(code),
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => {
|
||||
if (event === 'second-instance') {
|
||||
params.registerSecondInstanceHandlerEarly(
|
||||
|
||||
@@ -9,22 +9,40 @@ import * as earlySingleInstance from './early-single-instance';
|
||||
|
||||
function createFakeApp(lockValue = true) {
|
||||
let requestCalls = 0;
|
||||
let secondInstanceListener: ((_event: unknown, argv: string[]) => void) | null = null;
|
||||
let requestData: unknown = null;
|
||||
let secondInstanceListener:
|
||||
| ((
|
||||
_event: unknown,
|
||||
argv: string[],
|
||||
workingDirectory?: string,
|
||||
additionalData?: unknown,
|
||||
) => void)
|
||||
| null = null;
|
||||
|
||||
return {
|
||||
app: {
|
||||
requestSingleInstanceLock: () => {
|
||||
requestSingleInstanceLock: (additionalData?: unknown) => {
|
||||
requestCalls += 1;
|
||||
requestData = additionalData ?? null;
|
||||
return lockValue;
|
||||
},
|
||||
on: (_event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => {
|
||||
on: (
|
||||
_event: 'second-instance',
|
||||
listener: (
|
||||
_event: unknown,
|
||||
argv: string[],
|
||||
workingDirectory?: string,
|
||||
additionalData?: unknown,
|
||||
) => void,
|
||||
) => {
|
||||
secondInstanceListener = listener;
|
||||
},
|
||||
},
|
||||
emitSecondInstance: (argv: string[]) => {
|
||||
secondInstanceListener?.({}, argv);
|
||||
emitSecondInstance: (argv: string[], additionalData?: unknown) => {
|
||||
secondInstanceListener?.({}, argv, '/tmp', additionalData);
|
||||
},
|
||||
getRequestCalls: () => requestCalls,
|
||||
getRequestData: () => requestData,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +74,23 @@ test('registerSecondInstanceHandlerEarly replays queued argv and forwards new ev
|
||||
]);
|
||||
});
|
||||
|
||||
test('requestSingleInstanceLockEarly sends normalized argv through second-instance data', () => {
|
||||
resetEarlySingleInstanceStateForTests();
|
||||
const fake = createFakeApp(true);
|
||||
const primaryArgv = ['SubMiner.AppImage', '--start'];
|
||||
const transportedArgv = ['SubMiner.AppImage', '--stop'];
|
||||
const calls: string[][] = [];
|
||||
|
||||
assert.equal(requestSingleInstanceLockEarly(fake.app, primaryArgv), true);
|
||||
registerSecondInstanceHandlerEarly(fake.app, (_event, argv) => {
|
||||
calls.push(argv);
|
||||
});
|
||||
fake.emitSecondInstance(['SubMiner.AppImage'], { subminerArgv: transportedArgv });
|
||||
|
||||
assert.deepEqual(fake.getRequestData(), { subminerArgv: primaryArgv });
|
||||
assert.deepEqual(calls, [transportedArgv]);
|
||||
});
|
||||
|
||||
test('stats daemon args bypass the normal single-instance lock path', () => {
|
||||
const shouldBypass = (
|
||||
earlySingleInstance as typeof earlySingleInstance & {
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
interface ElectronSecondInstanceAppLike {
|
||||
requestSingleInstanceLock: () => boolean;
|
||||
on: (event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => unknown;
|
||||
requestSingleInstanceLock: (additionalData?: Record<string, unknown>) => boolean;
|
||||
on: (
|
||||
event: 'second-instance',
|
||||
listener: (
|
||||
_event: unknown,
|
||||
argv: string[],
|
||||
workingDirectory?: string,
|
||||
additionalData?: unknown,
|
||||
) => void,
|
||||
) => unknown;
|
||||
}
|
||||
|
||||
const SECOND_INSTANCE_ARGV_KEY = 'subminerArgv';
|
||||
|
||||
export function shouldBypassSingleInstanceLockForArgv(argv: readonly string[]): boolean {
|
||||
return argv.includes('--stats-background') || argv.includes('--stats-stop');
|
||||
}
|
||||
@@ -12,10 +22,24 @@ let secondInstanceListenerAttached = false;
|
||||
const secondInstanceArgvHistory: string[][] = [];
|
||||
const secondInstanceHandlers = new Set<(_event: unknown, argv: string[]) => void>();
|
||||
|
||||
function normalizeSecondInstanceArgv(argv: string[], additionalData: unknown): string[] {
|
||||
if (
|
||||
additionalData &&
|
||||
typeof additionalData === 'object' &&
|
||||
Array.isArray((additionalData as { subminerArgv?: unknown }).subminerArgv) &&
|
||||
(additionalData as { subminerArgv: unknown[] }).subminerArgv.every(
|
||||
(value) => typeof value === 'string',
|
||||
)
|
||||
) {
|
||||
return [...(additionalData as { subminerArgv: string[] }).subminerArgv];
|
||||
}
|
||||
return [...argv];
|
||||
}
|
||||
|
||||
function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void {
|
||||
if (secondInstanceListenerAttached) return;
|
||||
app.on('second-instance', (event, argv) => {
|
||||
const clonedArgv = [...argv];
|
||||
app.on('second-instance', (event, argv, _workingDirectory, additionalData) => {
|
||||
const clonedArgv = normalizeSecondInstanceArgv(argv, additionalData);
|
||||
secondInstanceArgvHistory.push(clonedArgv);
|
||||
for (const handler of secondInstanceHandlers) {
|
||||
handler(event, [...clonedArgv]);
|
||||
@@ -24,12 +48,17 @@ function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void
|
||||
secondInstanceListenerAttached = true;
|
||||
}
|
||||
|
||||
export function requestSingleInstanceLockEarly(app: ElectronSecondInstanceAppLike): boolean {
|
||||
export function requestSingleInstanceLockEarly(
|
||||
app: ElectronSecondInstanceAppLike,
|
||||
argv: readonly string[] = process.argv,
|
||||
): boolean {
|
||||
attachSecondInstanceListener(app);
|
||||
if (cachedSingleInstanceLock !== null) {
|
||||
return cachedSingleInstanceLock;
|
||||
}
|
||||
cachedSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
cachedSingleInstanceLock = app.requestSingleInstanceLock({
|
||||
[SECOND_INSTANCE_ARGV_KEY]: [...argv],
|
||||
});
|
||||
return cachedSingleInstanceLock;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function readMainSource(): string {
|
||||
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
|
||||
}
|
||||
|
||||
test('manual watched session action starts immersion tracker before marking watched', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/markActiveVideoWatched:\s*async\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\}\s*,/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /ensureImmersionTrackerStarted\(\);/);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('ensureImmersionTrackerStarted();') <
|
||||
actionBlock.indexOf('markActiveVideoWatched()'),
|
||||
);
|
||||
});
|
||||
|
||||
test('media path changes clear rendered subtitle state', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /appState\.currentSubText = '';/);
|
||||
assert.match(actionBlock, /appState\.currentSubAssText = '';/);
|
||||
assert.match(actionBlock, /appState\.currentSubtitleData = null;/);
|
||||
assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/);
|
||||
assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/);
|
||||
assert.match(actionBlock, /lastObservedTimePos = 0;/);
|
||||
assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/);
|
||||
assert.match(actionBlock, /subtitleWsService\.broadcast\(/);
|
||||
assert.match(actionBlock, /annotationSubtitleWsService\.broadcast\(/);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('appState.currentSubtitleData = null;') <
|
||||
actionBlock.indexOf("broadcastToOverlayWindows('subtitle:set'"),
|
||||
);
|
||||
});
|
||||
|
||||
test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||
const source = readMainSource();
|
||||
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
||||
assert.equal((source.match(/socketPath: appState\.mpvSocketPath/g) ?? []).length, 1);
|
||||
assert.equal(
|
||||
(source.match(/binaryPath: getResolvedConfig\(\)\.mpv\.subminerBinaryPath/g) ?? []).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { sendAppControlCommand } from '../../shared/app-control-client';
|
||||
import { startAppControlServer } from './app-control-server';
|
||||
|
||||
async function waitForSocketPath(socketPath: string): Promise<void> {
|
||||
const timeoutMs = 1000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(socketPath)) return;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`Timed out waiting for control socket ${socketPath} after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
test('app control server dispatches argv requests and replies ok', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
|
||||
const socketPath = path.join(dir, 'control.sock');
|
||||
const received: string[][] = [];
|
||||
const server = startAppControlServer({
|
||||
socketPath,
|
||||
platform: 'linux',
|
||||
handleArgv: (argv) => {
|
||||
received.push(argv);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForSocketPath(socketPath);
|
||||
const result = await sendAppControlCommand(['--start', '--socket', '/tmp/mpv.sock'], {
|
||||
socketPath,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: true });
|
||||
assert.deepEqual(received, [['--start', '--socket', '/tmp/mpv.sock']]);
|
||||
} finally {
|
||||
server.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('app control server rejects requests larger than 64KB by UTF-8 byte length', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
|
||||
const socketPath = path.join(dir, 'control.sock');
|
||||
const received: string[][] = [];
|
||||
const server = startAppControlServer({
|
||||
socketPath,
|
||||
platform: 'linux',
|
||||
handleArgv: (argv) => {
|
||||
received.push(argv);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForSocketPath(socketPath);
|
||||
const result = await sendAppControlCommand(
|
||||
Array.from({ length: 4 }, () => 'あ'.repeat(6000)),
|
||||
{
|
||||
socketPath,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { ok: false, error: 'App control request too large' });
|
||||
assert.deepEqual(received, []);
|
||||
} finally {
|
||||
server.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('app control server logs and closes errored client sockets', () => {
|
||||
const originalCreateServer = net.createServer;
|
||||
let socketHandler: ((socket: net.Socket) => void) | null = null;
|
||||
const fakeServer = new EventEmitter() as net.Server;
|
||||
fakeServer.listen = (() => fakeServer) as net.Server['listen'];
|
||||
fakeServer.close = ((callback?: (err?: Error) => void) => {
|
||||
callback?.();
|
||||
return fakeServer;
|
||||
}) as net.Server['close'];
|
||||
const received: string[][] = [];
|
||||
const warnings: Array<{ message: string; error?: unknown }> = [];
|
||||
|
||||
try {
|
||||
net.createServer = ((handler?: (socket: net.Socket) => void) => {
|
||||
socketHandler = handler ?? null;
|
||||
return fakeServer;
|
||||
}) as typeof net.createServer;
|
||||
|
||||
const server = startAppControlServer({
|
||||
socketPath: '\\\\.\\pipe\\subminer-test-control',
|
||||
platform: 'win32',
|
||||
handleArgv: (argv) => {
|
||||
received.push(argv);
|
||||
},
|
||||
logWarn: (message, error) => {
|
||||
warnings.push({ message, error });
|
||||
},
|
||||
});
|
||||
|
||||
const error = new Error('client reset');
|
||||
let destroyed = false;
|
||||
const socket = new EventEmitter() as net.Socket;
|
||||
socket.destroy = (() => {
|
||||
destroyed = true;
|
||||
return socket;
|
||||
}) as net.Socket['destroy'];
|
||||
|
||||
const handler = socketHandler as ((socket: net.Socket) => void) | null;
|
||||
assert.ok(handler);
|
||||
handler(socket);
|
||||
socket.emit('error', error);
|
||||
socket.emit('data', Buffer.from('{"argv":["--start"]}\n'));
|
||||
|
||||
assert.equal(destroyed, true);
|
||||
assert.deepEqual(received, []);
|
||||
assert.deepEqual(warnings, [{ message: 'App control client socket error.', error }]);
|
||||
|
||||
server.close();
|
||||
} finally {
|
||||
net.createServer = originalCreateServer;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
encodeAppControlResponse,
|
||||
parseAppControlRequestLine,
|
||||
type AppControlResponse,
|
||||
} from '../../shared/app-control';
|
||||
|
||||
export interface AppControlServerOptions {
|
||||
socketPath: string;
|
||||
platform?: NodeJS.Platform;
|
||||
handleArgv: (argv: string[]) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
logWarn?: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export interface AppControlServerHandle {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function prepareSocketPath(socketPath: string, platform: NodeJS.Platform): void {
|
||||
if (platform === 'win32') return;
|
||||
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
}
|
||||
|
||||
function cleanupSocketPath(socketPath: string, platform: NodeJS.Platform): void {
|
||||
if (platform === 'win32') return;
|
||||
try {
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function writeResponse(socket: net.Socket, response: AppControlResponse): void {
|
||||
socket.end(encodeAppControlResponse(response));
|
||||
}
|
||||
|
||||
export function startAppControlServer(options: AppControlServerOptions): AppControlServerHandle {
|
||||
const platform = options.platform ?? process.platform;
|
||||
prepareSocketPath(options.socketPath, platform);
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
let buffer = '';
|
||||
let byteCount = 0;
|
||||
let handled = false;
|
||||
|
||||
socket.on('error', (error) => {
|
||||
if (handled) return;
|
||||
handled = true;
|
||||
options.logWarn?.('App control client socket error.', error);
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
if (handled) return;
|
||||
byteCount += chunk.length;
|
||||
buffer += chunk.toString('utf8');
|
||||
if (byteCount > 65536) {
|
||||
handled = true;
|
||||
writeResponse(socket, { ok: false, error: 'App control request too large' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newlineIndex = buffer.indexOf('\n');
|
||||
if (newlineIndex < 0) return;
|
||||
handled = true;
|
||||
|
||||
try {
|
||||
const request = parseAppControlRequestLine(buffer.slice(0, newlineIndex));
|
||||
options.handleArgv(request.argv);
|
||||
writeResponse(socket, { ok: true });
|
||||
} catch (error) {
|
||||
options.logWarn?.('Failed to handle app control command.', error);
|
||||
writeResponse(socket, {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
options.logWarn?.(`App control socket failed: ${options.socketPath}`, error);
|
||||
});
|
||||
server.listen(options.socketPath, () => {
|
||||
options.logDebug?.(`App control socket listening: ${options.socketPath}`);
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
close: () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
cleanupSocketPath(options.socketPath, platform);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
stopConfigHotReload: () => calls.push('stop-config'),
|
||||
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
isAppReady: () => true,
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
@@ -102,6 +103,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
isAppReady: () => true,
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
@@ -148,3 +150,51 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('cleanup deps builder skips global shortcut cleanup before app ready', () => {
|
||||
const calls: string[] = [];
|
||||
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
isAppReady: () => false,
|
||||
unregisterAllGlobalShortcuts: () => {
|
||||
throw new Error('globalShortcut cannot be used before the app is ready');
|
||||
},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||
getMainOverlayWindow: () => null,
|
||||
clearMainOverlayWindow: () => {},
|
||||
getModalOverlayWindow: () => null,
|
||||
clearModalOverlayWindow: () => {},
|
||||
getYomitanParserWindow: () => null,
|
||||
clearYomitanParserState: () => {},
|
||||
getWindowTracker: () => null,
|
||||
flushMpvLog: () => {},
|
||||
getMpvSocket: () => null,
|
||||
getReconnectTimer: () => null,
|
||||
clearReconnectTimerRef: () => {},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getImmersionTracker: () => null,
|
||||
clearImmersionTracker: () => {},
|
||||
getAnkiIntegration: () => null,
|
||||
getAnilistSetupWindow: () => null,
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
getFirstRunSetupWindow: () => null,
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
||||
cleanup();
|
||||
|
||||
assert.deepEqual(calls, ['destroy-tray']);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
stopConfigHotReload: () => void;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
isAppReady: () => boolean;
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
@@ -63,7 +64,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
stopConfigHotReload: () => deps.stopConfigHotReload(),
|
||||
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
|
||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||
unregisterAllGlobalShortcuts: () => {
|
||||
if (!deps.isAppReady()) return;
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
},
|
||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
|
||||
@@ -143,3 +143,92 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let targetReady = false;
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
isSignalTargetReady: () => targetReady,
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
|
||||
targetReady = true;
|
||||
gate.flushPendingAutoplayReadySignal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'script-message'),
|
||||
[['script-message', 'subminer-autoplay-ready']],
|
||||
);
|
||||
assert.equal(
|
||||
commands.some(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate drops deferred readiness after media changes before flush', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let targetReady = false;
|
||||
let currentMediaPath = '/media/video-1.mkv';
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => currentMediaPath,
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
isSignalTargetReady: () => targetReady,
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
currentMediaPath = '/media/video-2.mkv';
|
||||
targetReady = true;
|
||||
gate.flushPendingAutoplayReadySignal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export type AutoplayReadyGateDeps = {
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
signalPluginAutoplayReady: () => void;
|
||||
isSignalTargetReady?: () => boolean;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logDebug: (message: string) => void;
|
||||
};
|
||||
@@ -21,12 +22,23 @@ export type AutoplayReadyGateDeps = {
|
||||
export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
let autoPlayReadySignalMediaPath: string | null = null;
|
||||
let autoPlayReadySignalGeneration = 0;
|
||||
let pendingAutoplayReadySignal: {
|
||||
mediaPath: string;
|
||||
payload: SubtitleData;
|
||||
options?: { forceWhilePaused?: boolean };
|
||||
} | null = null;
|
||||
|
||||
const invalidatePendingAutoplayReadyFallbacks = (): void => {
|
||||
autoPlayReadySignalMediaPath = null;
|
||||
pendingAutoplayReadySignal = null;
|
||||
autoPlayReadySignalGeneration += 1;
|
||||
};
|
||||
|
||||
const isSignalTargetReady = (): boolean => deps.isSignalTargetReady?.() ?? true;
|
||||
|
||||
const getSignalMediaPath = (): string =>
|
||||
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||
|
||||
const maybeSignalPluginAutoplayReady = (
|
||||
payload: SubtitleData,
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
@@ -39,8 +51,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaPath =
|
||||
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||
const mediaPath = getSignalMediaPath();
|
||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||
const releaseRetryDelayMs = 200;
|
||||
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||
@@ -104,16 +115,42 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
};
|
||||
|
||||
if (duplicateMediaSignal) {
|
||||
pendingAutoplayReadySignal = null;
|
||||
return;
|
||||
}
|
||||
if (!isSignalTargetReady()) {
|
||||
pendingAutoplayReadySignal = { mediaPath, payload, options };
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] deferred until signal target is ready for media ${mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAutoplayReadySignal = null;
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
attemptRelease(playbackGeneration, 0);
|
||||
};
|
||||
|
||||
const flushPendingAutoplayReadySignal = (): void => {
|
||||
if (!pendingAutoplayReadySignal || !isSignalTargetReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingSignal = pendingAutoplayReadySignal;
|
||||
pendingAutoplayReadySignal = null;
|
||||
if (getSignalMediaPath() !== pendingSignal.mediaPath) {
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] dropped deferred signal for stale media ${pendingSignal.mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
maybeSignalPluginAutoplayReady(pendingSignal.payload, pendingSignal.options);
|
||||
};
|
||||
|
||||
return {
|
||||
flushPendingAutoplayReadySignal,
|
||||
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
|
||||
invalidatePendingAutoplayReadyFallbacks,
|
||||
maybeSignalPluginAutoplayReady,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||
|
||||
test('selectAutoplayStartupCue returns the active cue at the current time', () => {
|
||||
assert.deepEqual(
|
||||
selectAutoplayStartupCue(
|
||||
[
|
||||
{ startTime: 1, endTime: 3, text: 'first' },
|
||||
{ startTime: 4, endTime: 5, text: 'second' },
|
||||
],
|
||||
2,
|
||||
1,
|
||||
),
|
||||
{ startTime: 1, endTime: 3, text: 'first' },
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAutoplayStartupCue returns the next imminent cue before playback starts', () => {
|
||||
assert.deepEqual(
|
||||
selectAutoplayStartupCue(
|
||||
[
|
||||
{ startTime: 1.2, endTime: 3, text: 'first' },
|
||||
{ startTime: 4, endTime: 5, text: 'second' },
|
||||
],
|
||||
0,
|
||||
2,
|
||||
),
|
||||
{ startTime: 1.2, endTime: 3, text: 'first' },
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAutoplayStartupCue clamps negative current time to startup', () => {
|
||||
assert.deepEqual(
|
||||
selectAutoplayStartupCue([{ startTime: 0, endTime: 1, text: 'startup' }], -0.5, 0),
|
||||
{ startTime: 0, endTime: 1, text: 'startup' },
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAutoplayStartupCue does not reveal far future subtitle text', () => {
|
||||
assert.equal(
|
||||
selectAutoplayStartupCue([{ startTime: 12, endTime: 15, text: 'later' }], 0, 2),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAutoplayStartupCue skips blank cues', () => {
|
||||
assert.deepEqual(
|
||||
selectAutoplayStartupCue(
|
||||
[
|
||||
{ startTime: 0, endTime: 1, text: ' ' },
|
||||
{ startTime: 0.5, endTime: 2, text: 'visible' },
|
||||
],
|
||||
0.75,
|
||||
1,
|
||||
),
|
||||
{ startTime: 0.5, endTime: 2, text: 'visible' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { SubtitleCue } from '../../types';
|
||||
|
||||
export function selectAutoplayStartupCue(
|
||||
cues: SubtitleCue[],
|
||||
currentTimeSeconds: number,
|
||||
lookaheadSeconds: number,
|
||||
): SubtitleCue | null {
|
||||
const currentTime = Math.max(0, Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0);
|
||||
const lookahead = Math.max(0, Number.isFinite(lookaheadSeconds) ? lookaheadSeconds : 0);
|
||||
const latestStartTime = currentTime + lookahead;
|
||||
|
||||
for (const cue of cues) {
|
||||
if (!cue.text.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (cue.startTime <= currentTime && cue.endTime > currentTime) {
|
||||
return cue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const cue of cues) {
|
||||
if (!cue.text.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (cue.startTime >= currentTime && cue.startTime <= latestStartTime) {
|
||||
return cue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAutoplayTokenizationWarmRelease } from './autoplay-tokenization-warm-release';
|
||||
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test('autoplay tokenization warm release signals immediately when warmups are ready', () => {
|
||||
const calls: string[] = [];
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => true,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
|
||||
assert.deepEqual(calls, ['signal']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release primes subtitles before waiting for warmups', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveWarmup!: () => void;
|
||||
const warmup = new Promise<void>((resolve) => {
|
||||
resolveWarmup = resolve;
|
||||
});
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => false,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
await warmup;
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
primeCurrentSubtitle: () => {
|
||||
calls.push('prime');
|
||||
},
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(calls, ['prime', 'warmup']);
|
||||
|
||||
resolveWarmup();
|
||||
await warmup;
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(calls, ['prime', 'warmup', 'signal']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release waits for subtitle priming before signaling ready media', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolvePrime!: () => void;
|
||||
const prime = new Promise<void>((resolve) => {
|
||||
resolvePrime = resolve;
|
||||
});
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => true,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
primeCurrentSubtitle: () => {
|
||||
calls.push('prime');
|
||||
return prime;
|
||||
},
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ['prime']);
|
||||
|
||||
resolvePrime();
|
||||
await prime;
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ['prime', 'signal']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release waits for warmups before signaling current media', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveWarmup!: () => void;
|
||||
const warmup = new Promise<void>((resolve) => {
|
||||
resolveWarmup = resolve;
|
||||
});
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => false,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
await warmup;
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(calls, ['warmup']);
|
||||
|
||||
resolveWarmup();
|
||||
await warmup;
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ['warmup', 'signal']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release skips stale media after warmup resolves', async () => {
|
||||
const calls: string[] = [];
|
||||
let currentMediaPath = '/tmp/video-2.mkv';
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => false,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
},
|
||||
getCurrentMediaPath: () => currentMediaPath,
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video-1.mkv');
|
||||
await Promise.resolve();
|
||||
currentMediaPath = '/tmp/video-3.mkv';
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ['warmup']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release skips signaling when current media is cleared', () => {
|
||||
const calls: string[] = [];
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => true,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
},
|
||||
getCurrentMediaPath: () => null,
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
function normalizeMediaPath(mediaPath: string | null | undefined): string | null {
|
||||
if (typeof mediaPath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = mediaPath.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function createAutoplayTokenizationWarmRelease(deps: {
|
||||
isTokenizationWarmupReady: () => boolean;
|
||||
startTokenizationWarmups: () => Promise<void>;
|
||||
getCurrentMediaPath: () => string | null | undefined;
|
||||
primeCurrentSubtitle?: (mediaPath: string) => void | Promise<void>;
|
||||
signalAutoplayReady: () => void;
|
||||
warn: (message: string, error: unknown) => void;
|
||||
}): (mediaPath: string | null | undefined) => void {
|
||||
const signalIfCurrent = (mediaPath: string): void => {
|
||||
const currentMediaPath = normalizeMediaPath(deps.getCurrentMediaPath());
|
||||
if (!currentMediaPath || currentMediaPath !== mediaPath) {
|
||||
return;
|
||||
}
|
||||
deps.signalAutoplayReady();
|
||||
};
|
||||
|
||||
const primeSubtitleForRelease = (mediaPath: string): Promise<void> | null => {
|
||||
if (!deps.primeCurrentSubtitle) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Promise.resolve(deps.primeCurrentSubtitle(mediaPath)).catch((error) => {
|
||||
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (mediaPath) => {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
if (!normalizedPath) {
|
||||
return;
|
||||
}
|
||||
const primePromise = primeSubtitleForRelease(normalizedPath);
|
||||
if (deps.isTokenizationWarmupReady()) {
|
||||
if (!primePromise) {
|
||||
signalIfCurrent(normalizedPath);
|
||||
return;
|
||||
}
|
||||
void primePromise.then(() => {
|
||||
signalIfCurrent(normalizedPath);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const warmupPromise = deps.startTokenizationWarmups();
|
||||
const readinessPromise = primePromise
|
||||
? Promise.all([primePromise, warmupPromise]).then(() => {})
|
||||
: warmupPromise;
|
||||
void readinessPromise
|
||||
.then(() => {
|
||||
signalIfCurrent(normalizedPath);
|
||||
})
|
||||
.catch((error) => {
|
||||
deps.warn('Startup tokenization warmup failed before autoplay readiness release:', error);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -137,10 +137,13 @@ export function composeMpvRuntimeHandlers<
|
||||
const shouldInitializeMecabForAnnotations = (): boolean => {
|
||||
const nPlusOneEnabled =
|
||||
options.tokenizer.buildTokenizerDepsMainDeps.getNPlusOneEnabled?.() !== false;
|
||||
const knownWordsEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled
|
||||
? options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled() !== false
|
||||
: nPlusOneEnabled;
|
||||
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
|
||||
const frequencyEnabled =
|
||||
options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false;
|
||||
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||
};
|
||||
const shouldWarmupAnnotationDictionaries = (): boolean => {
|
||||
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
|
||||
|
||||
@@ -18,6 +18,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
isAppReady: () => true,
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const calls: string[] = [];
|
||||
const ankiPatches: Array<{ enabled: boolean }> = [];
|
||||
const ankiPatches: unknown[] = [];
|
||||
const sessionBindingWarnings: string[][] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
@@ -25,7 +25,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`),
|
||||
applyAnkiRuntimeConfigPatch: (patch) => {
|
||||
ankiPatches.push({ enabled: patch.ai });
|
||||
ankiPatches.push(patch);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
|
||||
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
|
||||
assert.ok(calls.includes('broadcast:config:hot-reload:object'));
|
||||
assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]);
|
||||
assert.deepEqual(ankiPatches, [{ ai: config.ankiConnect.ai.enabled }]);
|
||||
assert.equal(sessionBindingWarnings.length, 1);
|
||||
assert.ok(
|
||||
sessionBindingWarnings[0]?.some((message) =>
|
||||
@@ -57,6 +57,87 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and logging changes', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.ankiConnect.behavior.autoUpdateNewCards = false;
|
||||
config.ankiConnect.knownWords.highlightEnabled = true;
|
||||
config.ankiConnect.knownWords.refreshMinutes = 90;
|
||||
config.ankiConnect.knownWords.decks = { Anime: ['Mining'] };
|
||||
config.ankiConnect.nPlusOne.enabled = true;
|
||||
config.ankiConnect.nPlusOne.minSentenceWords = 4;
|
||||
config.ankiConnect.fields.word = 'Expression';
|
||||
config.ankiConnect.fields.audio = 'SentenceAudioCustom';
|
||||
config.ankiConnect.fields.image = 'ScreenshotCustom';
|
||||
config.ankiConnect.fields.sentence = 'SentenceCustom';
|
||||
config.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
|
||||
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
|
||||
config.ankiConnect.isKiku.fieldGrouping = 'manual';
|
||||
config.logging.level = 'debug';
|
||||
const calls: string[] = [];
|
||||
const ankiPatches: unknown[] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => calls.push('set:keybindings'),
|
||||
setSessionBindings: () => calls.push('set:session-bindings'),
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
||||
setSecondarySubMode: () => calls.push('set:secondary'),
|
||||
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
||||
applyAnkiRuntimeConfigPatch: (patch) => {
|
||||
calls.push('anki:patch');
|
||||
ankiPatches.push(patch);
|
||||
},
|
||||
invalidateTokenizationCache: () => calls.push('invalidate:tokens'),
|
||||
refreshSubtitlePrefetch: () => calls.push('refresh:prefetch'),
|
||||
refreshCurrentSubtitle: () => calls.push('refresh:subtitle'),
|
||||
setLogLevel: (level) => calls.push(`log:${level}`),
|
||||
});
|
||||
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: [
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.decks',
|
||||
'ankiConnect.nPlusOne.enabled',
|
||||
'ankiConnect.nPlusOne.minSentenceWords',
|
||||
'ankiConnect.fields.word',
|
||||
'ankiConnect.fields.audio',
|
||||
'ankiConnect.fields.image',
|
||||
'ankiConnect.fields.sentence',
|
||||
'ankiConnect.fields.miscInfo',
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
'ankiConnect.isKiku.fieldGrouping',
|
||||
'logging.level',
|
||||
],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(ankiPatches, [
|
||||
{
|
||||
behavior: { autoUpdateNewCards: false },
|
||||
knownWords: config.ankiConnect.knownWords,
|
||||
nPlusOne: config.ankiConnect.nPlusOne,
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
audio: 'SentenceAudioCustom',
|
||||
image: 'ScreenshotCustom',
|
||||
sentence: 'SentenceCustom',
|
||||
miscInfo: 'MiscInfoCustom',
|
||||
},
|
||||
isLapis: { sentenceCardModel: 'Sentence Card Custom' },
|
||||
isKiku: { fieldGrouping: 'manual' },
|
||||
},
|
||||
]);
|
||||
assert.ok(calls.includes('invalidate:tokens'));
|
||||
assert.ok(calls.includes('refresh:prefetch'));
|
||||
assert.ok(calls.includes('refresh:subtitle'));
|
||||
assert.ok(calls.includes('log:debug'));
|
||||
assert.ok(calls.includes('broadcast:config:hot-reload'));
|
||||
});
|
||||
|
||||
test('buildConfigHotReloadPayload includes independent primary subtitle mode', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.subtitleStyle.primaryDefaultMode = 'hover';
|
||||
@@ -68,6 +149,48 @@ test('buildConfigHotReloadPayload includes independent primary subtitle mode', (
|
||||
assert.equal(payload.secondarySubMode, 'hidden');
|
||||
});
|
||||
|
||||
test('buildConfigHotReloadPayload reflects added, removed, and remapped session bindings', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.stats.markWatchedKey = 'Ctrl+Shift+KeyW';
|
||||
config.shortcuts.openJimaku = null;
|
||||
config.keybindings = [
|
||||
{ key: 'KeyF', command: null },
|
||||
{ key: 'Ctrl+Alt+KeyM', command: ['show-text', 'custom'] },
|
||||
];
|
||||
|
||||
const payload = buildConfigHotReloadPayload(config);
|
||||
|
||||
assert.equal(
|
||||
payload.sessionBindings.some(
|
||||
(binding) =>
|
||||
binding.sourcePath === 'stats.markWatchedKey' &&
|
||||
binding.originalKey === 'Ctrl+Shift+KeyW' &&
|
||||
binding.actionType === 'session-action' &&
|
||||
binding.actionId === 'markWatched',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
payload.sessionBindings.some(
|
||||
(binding) =>
|
||||
binding.originalKey === 'Ctrl+Alt+KeyM' &&
|
||||
binding.actionType === 'mpv-command' &&
|
||||
binding.command.join(' ') === 'show-text custom',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
payload.sessionBindings.some((binding) => binding.originalKey === 'KeyF'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
payload.sessionBindings.some(
|
||||
(binding) => binding.actionType === 'session-action' && binding.actionId === 'openJimaku',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { compileSessionBindings } from '../../core/services/session-bindings';
|
||||
import { resolveKeybindings } from '../../core/utils/keybindings';
|
||||
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
|
||||
import type { AnkiConnectConfig } from '../../types/anki';
|
||||
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
|
||||
|
||||
type ConfigHotReloadAppliedDeps = {
|
||||
@@ -14,9 +15,11 @@ type ConfigHotReloadAppliedDeps = {
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
applyAnkiRuntimeConfigPatch: (patch: {
|
||||
ai: ResolvedConfig['ankiConnect']['ai']['enabled'];
|
||||
}) => void;
|
||||
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) => void;
|
||||
invalidateTokenizationCache?: () => void;
|
||||
refreshSubtitlePrefetch?: () => void;
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
||||
};
|
||||
|
||||
type ConfigHotReloadMessageDeps = {
|
||||
@@ -30,8 +33,8 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
||||
}
|
||||
return {
|
||||
...config.subtitleStyle,
|
||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
||||
knownWordColor: config.ankiConnect.knownWords.color,
|
||||
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
|
||||
knownWordColor: config.subtitleStyle.knownWordColor,
|
||||
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
||||
enableJlpt: config.subtitleStyle.enableJlpt,
|
||||
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
|
||||
@@ -44,6 +47,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
|
||||
keybindings,
|
||||
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
|
||||
statsToggleKey: config.stats.toggleKey,
|
||||
statsMarkWatchedKey: config.stats.markWatchedKey,
|
||||
platform:
|
||||
process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux',
|
||||
rawConfig: config,
|
||||
@@ -59,6 +63,70 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
|
||||
};
|
||||
}
|
||||
|
||||
function hasAnyHotReloadField(diff: ConfigHotReloadDiff, prefixes: string[]): boolean {
|
||||
return diff.hotReloadFields.some((field) =>
|
||||
prefixes.some((prefix) => field === prefix || field.startsWith(`${prefix}.`)),
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnkiRuntimeConfigPatch(
|
||||
diff: ConfigHotReloadDiff,
|
||||
config: ResolvedConfig,
|
||||
): Partial<AnkiConnectConfig> | null {
|
||||
const patch: Partial<AnkiConnectConfig> = {};
|
||||
|
||||
if (diff.hotReloadFields.includes('ankiConnect.ai')) {
|
||||
patch.ai = config.ankiConnect.ai.enabled;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.ai.enabled')) {
|
||||
patch.ai = config.ankiConnect.ai.enabled;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.behavior.autoUpdateNewCards')) {
|
||||
patch.behavior = { autoUpdateNewCards: config.ankiConnect.behavior.autoUpdateNewCards };
|
||||
}
|
||||
if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) {
|
||||
patch.knownWords = config.ankiConnect.knownWords;
|
||||
}
|
||||
if (hasAnyHotReloadField(diff, ['ankiConnect.nPlusOne'])) {
|
||||
patch.nPlusOne = config.ankiConnect.nPlusOne;
|
||||
}
|
||||
const fieldPatch: NonNullable<AnkiConnectConfig['fields']> = {};
|
||||
if (diff.hotReloadFields.includes('ankiConnect.fields.word')) {
|
||||
fieldPatch.word = config.ankiConnect.fields.word;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.fields.audio')) {
|
||||
fieldPatch.audio = config.ankiConnect.fields.audio;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.fields.image')) {
|
||||
fieldPatch.image = config.ankiConnect.fields.image;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.fields.sentence')) {
|
||||
fieldPatch.sentence = config.ankiConnect.fields.sentence;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.fields.miscInfo')) {
|
||||
fieldPatch.miscInfo = config.ankiConnect.fields.miscInfo;
|
||||
}
|
||||
if (Object.keys(fieldPatch).length > 0) {
|
||||
patch.fields = fieldPatch;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.isLapis.sentenceCardModel')) {
|
||||
patch.isLapis = { sentenceCardModel: config.ankiConnect.isLapis.sentenceCardModel };
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
|
||||
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
|
||||
}
|
||||
|
||||
return Object.keys(patch).length > 0 ? patch : null;
|
||||
}
|
||||
|
||||
function hasAnnotationRuntimeHotReload(diff: ConfigHotReloadDiff): boolean {
|
||||
return hasAnyHotReloadField(diff, [
|
||||
'ankiConnect.knownWords',
|
||||
'ankiConnect.nPlusOne',
|
||||
'ankiConnect.fields.word',
|
||||
]);
|
||||
}
|
||||
|
||||
export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadAppliedDeps) {
|
||||
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
|
||||
const payload = buildConfigHotReloadPayload(config);
|
||||
@@ -74,8 +142,19 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.includes('ankiConnect.ai')) {
|
||||
deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai.enabled });
|
||||
const ankiPatch = buildAnkiRuntimeConfigPatch(diff, config);
|
||||
if (ankiPatch) {
|
||||
deps.applyAnkiRuntimeConfigPatch(ankiPatch);
|
||||
}
|
||||
|
||||
if (hasAnnotationRuntimeHotReload(diff)) {
|
||||
deps.invalidateTokenizationCache?.();
|
||||
deps.refreshSubtitlePrefetch?.();
|
||||
deps.refreshCurrentSubtitle?.();
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.includes('logging.level')) {
|
||||
deps.setLogLevel?.(config.logging.level);
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ConfigHotReloadRuntimeDeps,
|
||||
} from '../../core/services/config-hot-reload';
|
||||
import type { ReloadConfigStrictResult } from '../../config';
|
||||
import type { AnkiConnectConfig } from '../../types/anki';
|
||||
import type {
|
||||
ConfigHotReloadPayload,
|
||||
ConfigValidationWarning,
|
||||
@@ -69,9 +70,11 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
applyAnkiRuntimeConfigPatch: (patch: {
|
||||
ai: ResolvedConfig['ankiConnect']['ai']['enabled'];
|
||||
}) => void;
|
||||
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) => void;
|
||||
invalidateTokenizationCache?: () => void;
|
||||
refreshSubtitlePrefetch?: () => void;
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
||||
@@ -84,8 +87,12 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
|
||||
deps.broadcastToOverlayWindows(channel, payload),
|
||||
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai']['enabled'] }) =>
|
||||
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) =>
|
||||
deps.applyAnkiRuntimeConfigPatch(patch),
|
||||
invalidateTokenizationCache: () => deps.invalidateTokenizationCache?.(),
|
||||
refreshSubtitlePrefetch: () => deps.refreshSubtitlePrefetch?.(),
|
||||
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
|
||||
setLogLevel: (level: ResolvedConfig['logging']['level']) => deps.setLogLevel?.(level),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ const fields: ConfigSettingsField[] = [
|
||||
label: 'Launch mode',
|
||||
description: 'Launch mode setting.',
|
||||
configPath: 'mpv.launchMode',
|
||||
category: 'playback-sources',
|
||||
section: 'mpv launcher',
|
||||
category: 'behavior',
|
||||
section: 'mpv Playback',
|
||||
control: 'select',
|
||||
defaultValue: 'windowed',
|
||||
restartBehavior: 'restart',
|
||||
|
||||
@@ -3,16 +3,17 @@ import path from 'node:path';
|
||||
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
|
||||
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
||||
import type {
|
||||
ConfigSettingsAnkiListResult,
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsSaveResult,
|
||||
ConfigSettingsSnapshot,
|
||||
} from '../../types/settings';
|
||||
import type { ReloadConfigStrictResult } from '../../config';
|
||||
import { classifyConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
||||
import {
|
||||
classifyConfigHotReloadDiff,
|
||||
type ConfigHotReloadDiff,
|
||||
} from '../../core/services/config-hot-reload';
|
||||
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
|
||||
createSaveConfigSettingsPatchHandler,
|
||||
type ConfigSettingsHotReloadDiff,
|
||||
} from './config-settings-save';
|
||||
import {
|
||||
createOpenConfigSettingsWindowHandler,
|
||||
type ConfigSettingsWindowLike,
|
||||
@@ -28,6 +29,19 @@ export interface ConfigSettingsIpcChannels {
|
||||
saveConfigSettingsPatch: string;
|
||||
openConfigSettingsFile: string;
|
||||
openConfigSettingsWindow: string;
|
||||
getConfigSettingsAnkiDeckNames: string;
|
||||
getConfigSettingsAnkiDeckFieldNames: string;
|
||||
getConfigSettingsAnkiDeckModelNames: string;
|
||||
getConfigSettingsAnkiModelNames: string;
|
||||
getConfigSettingsAnkiModelFieldNames: 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> {
|
||||
@@ -37,12 +51,14 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
|
||||
getConfig(): ResolvedConfig;
|
||||
getWarnings(): ConfigValidationWarning[];
|
||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||
applyHotReload(diff: ConfigHotReloadDiff, config: ResolvedConfig): void;
|
||||
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
|
||||
getSettingsWindow(): TWindow | null;
|
||||
setSettingsWindow(window: TWindow | null): void;
|
||||
createSettingsWindow(): TWindow;
|
||||
settingsHtmlPath: string;
|
||||
openPath(path: string): Promise<string>;
|
||||
defaultAnkiConnectUrl: string;
|
||||
createAnkiClient(url: string): ConfigSettingsAnkiClient;
|
||||
ipcMain: ConfigSettingsIpcMainLike;
|
||||
ipcChannels: ConfigSettingsIpcChannels;
|
||||
log?: (message: string) => void;
|
||||
@@ -111,8 +127,8 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }),
|
||||
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
||||
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
|
||||
applyHotReload: (diff, config) => deps.applyHotReload(diff, config),
|
||||
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
|
||||
onHotReloadApplied: deps.onHotReloadApplied,
|
||||
});
|
||||
|
||||
function ensureConfigFileExists(): string {
|
||||
@@ -142,6 +158,36 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
};
|
||||
}
|
||||
|
||||
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 registerHandlers(): void {
|
||||
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
|
||||
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
|
||||
@@ -155,6 +201,39 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
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.');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,7 +14,7 @@ function snapshot(): ConfigSettingsSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
test('config settings save applies hot-reloadable diff live', () => {
|
||||
test('config settings save returns hot-reloadable diff for watcher path', () => {
|
||||
const calls: string[] = [];
|
||||
const previous = DEFAULT_CONFIG;
|
||||
const next: ResolvedConfig = {
|
||||
@@ -46,7 +46,6 @@ test('config settings save applies hot-reloadable diff live', () => {
|
||||
hotReloadFields: ['subtitleStyle'],
|
||||
restartRequiredFields: [],
|
||||
}),
|
||||
applyHotReload: (diff) => calls.push(`hot:${diff.hotReloadFields.join(',')}`),
|
||||
getRestartRequiredSections: () => [],
|
||||
});
|
||||
|
||||
@@ -62,11 +61,81 @@ test('config settings save applies hot-reloadable diff live', () => {
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.match(written, /autoPauseVideoOnHover/);
|
||||
assert.deepEqual(calls, ['write', 'hot:subtitleStyle']);
|
||||
assert.deepEqual(calls, ['write']);
|
||||
assert.deepEqual(result.hotReloadFields, ['subtitleStyle']);
|
||||
assert.deepEqual(result.restartRequiredFields, []);
|
||||
});
|
||||
|
||||
test('config settings save immediately applies hot-reloadable subtitle CSS changes', () => {
|
||||
const previous = DEFAULT_CONFIG;
|
||||
const next: ResolvedConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
subtitleStyle: {
|
||||
...DEFAULT_CONFIG.subtitleStyle,
|
||||
css: {
|
||||
'font-size': '50px',
|
||||
},
|
||||
secondary: {
|
||||
...DEFAULT_CONFIG.subtitleStyle.secondary,
|
||||
css: {
|
||||
'font-size': '28px',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const applied: Array<{
|
||||
hotReloadFields: string[];
|
||||
config: ResolvedConfig;
|
||||
}> = [];
|
||||
const save = createSaveConfigSettingsPatchHandler({
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
getCurrentConfig: () => previous,
|
||||
getWarnings: () => [],
|
||||
getSnapshot: () => snapshot(),
|
||||
fileExists: () => true,
|
||||
readText: () => '{}',
|
||||
writeTextAtomically: () => {},
|
||||
reloadConfigStrict: (): ReloadConfigStrictResult => ({
|
||||
ok: true,
|
||||
config: next,
|
||||
warnings: [],
|
||||
path: '/tmp/config.jsonc',
|
||||
}),
|
||||
classifyDiff: () => ({
|
||||
hotReloadFields: ['subtitleStyle'],
|
||||
restartRequiredFields: [],
|
||||
}),
|
||||
getRestartRequiredSections: () => [],
|
||||
onHotReloadApplied: (diff, config) => {
|
||||
applied.push({
|
||||
hotReloadFields: diff.hotReloadFields,
|
||||
config,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = save({
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.css',
|
||||
value: { 'font-size': '50px' },
|
||||
},
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.secondary.css',
|
||||
value: { 'font-size': '28px' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(applied.length, 1);
|
||||
assert.deepEqual(applied[0]?.hotReloadFields, ['subtitleStyle']);
|
||||
assert.equal(applied[0]?.config.subtitleStyle.css['font-size'], '50px');
|
||||
assert.equal(applied[0]?.config.subtitleStyle.secondary.css['font-size'], '28px');
|
||||
});
|
||||
|
||||
test('config settings save returns restart-required sections without applying hot reload', () => {
|
||||
const calls: string[] = [];
|
||||
const previous = DEFAULT_CONFIG;
|
||||
@@ -95,7 +164,6 @@ test('config settings save returns restart-required sections without applying ho
|
||||
hotReloadFields: [],
|
||||
restartRequiredFields: ['mpv'],
|
||||
}),
|
||||
applyHotReload: () => calls.push('hot'),
|
||||
getRestartRequiredSections: () => ['mpv launcher'],
|
||||
});
|
||||
|
||||
@@ -130,9 +198,6 @@ test('config settings save restores previous file content when strict reload fai
|
||||
classifyDiff: () => {
|
||||
throw new Error('Should not classify invalid config.');
|
||||
},
|
||||
applyHotReload: () => {
|
||||
throw new Error('Should not hot reload invalid config.');
|
||||
},
|
||||
getRestartRequiredSections: () => [],
|
||||
});
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ export interface ConfigSettingsSaveDeps {
|
||||
deleteFile?(path: string): void;
|
||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
|
||||
applyHotReload(diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig): void;
|
||||
getRestartRequiredSections(restartRequiredFields: string[]): string[];
|
||||
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
|
||||
}
|
||||
|
||||
export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDeps) {
|
||||
@@ -64,12 +64,17 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
|
||||
deps.writeTextAtomically(configPath, candidate.content);
|
||||
const reloadResult = deps.reloadConfigStrict();
|
||||
if (!reloadResult.ok) {
|
||||
if (hadExistingConfig) {
|
||||
deps.writeTextAtomically(configPath, content);
|
||||
} else if (deps.deleteFile) {
|
||||
deps.deleteFile(configPath);
|
||||
} else {
|
||||
deps.writeTextAtomically(configPath, content);
|
||||
try {
|
||||
if (hadExistingConfig) {
|
||||
deps.writeTextAtomically(configPath, content);
|
||||
} else if (deps.deleteFile) {
|
||||
deps.deleteFile(configPath);
|
||||
} else {
|
||||
deps.writeTextAtomically(configPath, content);
|
||||
}
|
||||
deps.reloadConfigStrict();
|
||||
} catch {
|
||||
// Best-effort rollback; preserve original reload error for caller.
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
@@ -83,7 +88,7 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
|
||||
|
||||
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
deps.applyHotReload(diff, reloadResult.config);
|
||||
deps.onHotReloadApplied?.(diff, reloadResult.config);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -27,7 +27,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
|
||||
const window = deps.createSettingsWindow();
|
||||
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.log?.(`Failed to load configuration settings window: ${message}`);
|
||||
deps.log?.(`Failed to load settings window: ${message}`);
|
||||
deps.setSettingsWindow(null);
|
||||
window.destroy?.();
|
||||
});
|
||||
|
||||
@@ -41,18 +41,16 @@ test('current media tokenization gate returns immediately for ready media', asyn
|
||||
await gate.waitUntilReady('/tmp/video-1.mkv');
|
||||
});
|
||||
|
||||
test('current media tokenization gate stays ready for later media after first warmup', async () => {
|
||||
test('current media tokenization gate treats later media as ready after warmup completes', async () => {
|
||||
const gate = createCurrentMediaTokenizationGate();
|
||||
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
|
||||
gate.markReady('/tmp/video-1.mkv');
|
||||
gate.updateCurrentMediaPath('/tmp/video-2.mkv');
|
||||
|
||||
let resolved = false;
|
||||
const waitPromise = gate.waitUntilReady('/tmp/video-2.mkv').then(() => {
|
||||
await gate.waitUntilReady('/tmp/video-2.mkv').then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(resolved, true);
|
||||
await waitPromise;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import { resolveCurrentSubtitleForRenderer } from './current-subtitle-snapshot';
|
||||
|
||||
function withTiming(payload: SubtitleData): SubtitleData {
|
||||
return {
|
||||
...payload,
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
};
|
||||
}
|
||||
|
||||
test('renderer current subtitle snapshot reuses cached payload for first paint', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: '字幕',
|
||||
currentSubtitleData: { text: '字幕', tokens: [{ text: '字' } as never] },
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
});
|
||||
|
||||
assert.equal(payload.text, '字幕');
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.deepEqual(payload.tokens, [{ text: '字' }]);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot does not block on tokenizer for empty text', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: '',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
});
|
||||
|
||||
assert.equal(payload.text, '');
|
||||
assert.equal(payload.tokens, null);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot falls back to raw text for uncached subtitles', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: 'まだキャッシュされていない字幕',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
});
|
||||
|
||||
assert.equal(payload.text, 'まだキャッシュされていない字幕');
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.equal(payload.tokens, null);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot tokenizes uncached subtitles when tokenizer is available', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: '新しい字幕',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '新' } as never] }),
|
||||
});
|
||||
|
||||
assert.equal(payload.text, '新しい字幕');
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.deepEqual(payload.tokens, [{ text: '新' }]);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
currentSubText: string;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
|
||||
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
|
||||
}): Promise<SubtitleData> {
|
||||
if (deps.currentSubtitleData?.text === deps.currentSubText) {
|
||||
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
|
||||
}
|
||||
|
||||
if (!deps.currentSubText.trim()) {
|
||||
return deps.withCurrentSubtitleTiming({
|
||||
text: deps.currentSubText,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
|
||||
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
|
||||
if (tokenized) {
|
||||
return deps.withCurrentSubtitleTiming(tokenized);
|
||||
}
|
||||
|
||||
return deps.withCurrentSubtitleTiming({
|
||||
text: deps.currentSubText,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
removeLegacyMpvPluginCandidates,
|
||||
resolvePackagedFirstRunPluginAssets,
|
||||
resolvePackagedRuntimePluginPath,
|
||||
syncInstalledFirstRunPluginBinaryPath,
|
||||
} from './first-run-setup-plugin';
|
||||
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
|
||||
|
||||
@@ -66,66 +65,6 @@ test('resolvePackagedRuntimePluginPath returns packaged plugin entrypoint', () =
|
||||
});
|
||||
});
|
||||
|
||||
test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing installs', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
||||
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
installPaths.pluginConfigPath,
|
||||
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
|
||||
const result = syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: 'linux',
|
||||
homeDir,
|
||||
xdgConfigHome,
|
||||
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
updated: true,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
});
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'binary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overrides', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
||||
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
installPaths.pluginConfigPath,
|
||||
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
|
||||
const result = syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: 'linux',
|
||||
homeDir,
|
||||
xdgConfigHome,
|
||||
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
updated: false,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
});
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv config location on macOS', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
|
||||
import type { MpvInstallPaths } from '../../shared/setup-state';
|
||||
|
||||
export interface InstalledFirstRunPluginCandidate {
|
||||
path: string;
|
||||
@@ -27,51 +27,6 @@ export interface LegacyMpvPluginRemovalResult {
|
||||
failedPaths: Array<{ path: string; message: string }>;
|
||||
}
|
||||
|
||||
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const updated = content.replace(/^socket_path=.*$/m, 'socket_path=\\\\.\\pipe\\subminer-socket');
|
||||
if (updated !== content) {
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePluginConfigValue(value: string): string {
|
||||
return value.replace(/[\r\n]/g, '').trim();
|
||||
}
|
||||
|
||||
function upsertPluginConfigLine(content: string, key: string, value: string): string {
|
||||
const normalizedValue = sanitizePluginConfigValue(value);
|
||||
const line = `${key}=${normalizedValue}`;
|
||||
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (pattern.test(content)) {
|
||||
return content.replace(pattern, line);
|
||||
}
|
||||
|
||||
const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
|
||||
return `${content}${suffix}${line}\n`;
|
||||
}
|
||||
|
||||
function rewriteInstalledPluginBinaryPath(configPath: string, binaryPath: string): boolean {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const updated = upsertPluginConfigLine(content, 'binary_path', binaryPath);
|
||||
if (updated === content) {
|
||||
return false;
|
||||
}
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
function readInstalledPluginBinaryPath(configPath: string): string | null {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const match = content.match(/^binary_path=(.*)$/m);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rawValue = match[1] ?? '';
|
||||
const value = sanitizePluginConfigValue(rawValue);
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function resolvePackagedFirstRunPluginAssets(deps: {
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
@@ -338,36 +293,3 @@ export async function removeLegacyMpvPluginCandidates(options: {
|
||||
failedPaths,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncInstalledFirstRunPluginBinaryPath(options: {
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
binaryPath: string;
|
||||
}): { updated: boolean; configPath: string | null } {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
options.platform,
|
||||
options.homeDir,
|
||||
options.xdgConfigHome,
|
||||
);
|
||||
if (!installPaths.supported || !fs.existsSync(installPaths.pluginConfigPath)) {
|
||||
return { updated: false, configPath: null };
|
||||
}
|
||||
|
||||
const configuredBinaryPath = readInstalledPluginBinaryPath(installPaths.pluginConfigPath);
|
||||
if (configuredBinaryPath) {
|
||||
return { updated: false, configPath: installPaths.pluginConfigPath };
|
||||
}
|
||||
|
||||
const updated = rewriteInstalledPluginBinaryPath(
|
||||
installPaths.pluginConfigPath,
|
||||
options.binaryPath,
|
||||
);
|
||||
if (options.platform === 'win32') {
|
||||
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
|
||||
}
|
||||
return {
|
||||
updated,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -47,6 +47,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
markWatched: false,
|
||||
toggleSubtitleSidebar: false,
|
||||
openRuntimeOptions: false,
|
||||
openSessionHelp: false,
|
||||
@@ -121,12 +122,12 @@ function createCommandLineLauncherSnapshot(
|
||||
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, configSettings: true })), false);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, settings: true })), false);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ yomitan: true })), false);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
|
||||
});
|
||||
|
||||
@@ -155,6 +156,7 @@ test('shouldAutoOpenFirstRunSetup treats session and stats startup commands as e
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openControllerDebug: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, markWatched: true })), false);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, stats: true })), false);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinSubtitleUrlsOnly: true })),
|
||||
|
||||
@@ -71,8 +71,8 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.launchMpv ||
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.configSettings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
@@ -90,6 +90,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.markWatched ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
|
||||
@@ -59,10 +59,15 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
assert.match(html, /SubMiner setup/);
|
||||
assert.doesNotMatch(html, /Install legacy mpv plugin/);
|
||||
assert.doesNotMatch(html, /action=install-plugin/);
|
||||
assert.match(html, /Ready/);
|
||||
assert.doesNotMatch(html, /mpv runtime plugin/);
|
||||
assert.doesNotMatch(html, /Bundled ready/);
|
||||
assert.match(html, /Managed mpv launches use the bundled runtime plugin\./);
|
||||
assert.doesNotMatch(html, /Managed mpv launches use the bundled runtime plugin\./);
|
||||
assert.match(html, /Open Yomitan Settings/);
|
||||
assert.match(html, /Open SubMiner Settings/);
|
||||
assert.match(
|
||||
html,
|
||||
/action=open-yomitan-settings'">Open Yomitan Settings<\/button>\s*<button class="ghost" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=refresh'">Refresh status<\/button>\s*<button onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=open-config-settings'">Open SubMiner Settings<\/button>\s*<button class="primary" disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/,
|
||||
);
|
||||
assert.match(html, /Finish setup/);
|
||||
assert.match(html, /disabled/);
|
||||
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
|
||||
@@ -70,7 +75,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
assert.match(html, /box-sizing:\s*border-box;/);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
|
||||
test('buildFirstRunSetupHtml omits bundled mpv plugin readiness when already installed', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
configReady: true,
|
||||
dictionaryCount: 1,
|
||||
@@ -94,10 +99,11 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
||||
|
||||
assert.doesNotMatch(html, /Reinstall mpv plugin/);
|
||||
assert.doesNotMatch(html, /action=install-plugin/);
|
||||
assert.doesNotMatch(html, /mpv runtime plugin/);
|
||||
assert.match(html, /mpv executable path/);
|
||||
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
|
||||
assert.match(html, /aria-label="Path to mpv\.exe"/);
|
||||
assert.match(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
|
||||
assert.doesNotMatch(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
|
||||
@@ -124,7 +130,8 @@ test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirm
|
||||
});
|
||||
|
||||
assert.match(html, /Legacy mpv plugin/);
|
||||
assert.match(html, /Legacy detected/);
|
||||
assert.doesNotMatch(html, /mpv runtime plugin/);
|
||||
assert.match(html, /Found/);
|
||||
assert.match(html, /\/tmp\/mpv\/scripts\/subminer/);
|
||||
assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/);
|
||||
assert.match(html, /Remove legacy mpv plugin/);
|
||||
@@ -251,6 +258,12 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||
action: 'remove-legacy-plugin',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=open-config-settings'),
|
||||
{
|
||||
action: 'open-config-settings',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
|
||||
null,
|
||||
@@ -542,6 +555,89 @@ test('opening first-run setup skips rendering if window is destroyed after snaps
|
||||
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']);
|
||||
});
|
||||
|
||||
test('first-run setup action can skip rerender after launching another window', async () => {
|
||||
const calls: string[] = [];
|
||||
let navigateHandler: ((event: unknown, url: string) => void) | undefined;
|
||||
const handler = createOpenFirstRunSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () =>
|
||||
({
|
||||
webContents: {
|
||||
on: (_event: 'will-navigate', callback: (event: unknown, url: string) => void) => {
|
||||
navigateHandler = callback;
|
||||
},
|
||||
},
|
||||
loadURL: async () => {
|
||||
calls.push('load');
|
||||
},
|
||||
on: () => {},
|
||||
isDestroyed: () => false,
|
||||
close: () => {},
|
||||
show: () => calls.push('show'),
|
||||
focus: () => calls.push('focus'),
|
||||
}) as never,
|
||||
getSetupSnapshot: async () => ({
|
||||
configReady: true,
|
||||
dictionaryCount: 1,
|
||||
canFinish: true,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
mpvExecutablePath: '',
|
||||
mpvExecutablePathStatus: 'blank',
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: null,
|
||||
}),
|
||||
buildSetupHtml: () => '<html></html>',
|
||||
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
|
||||
handleAction: async () => {
|
||||
calls.push('action');
|
||||
return { skipRender: true };
|
||||
},
|
||||
markSetupInProgress: async () => {
|
||||
calls.push('in-progress');
|
||||
},
|
||||
markSetupCancelled: async () => undefined,
|
||||
isSetupCompleted: () => true,
|
||||
shouldQuitWhenClosedIncomplete: () => false,
|
||||
quitApp: () => {},
|
||||
clearSetupWindow: () => {},
|
||||
setSetupWindow: () => {
|
||||
calls.push('set');
|
||||
},
|
||||
encodeURIComponent: (value) => value,
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
handler();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
navigateHandler?.(
|
||||
{ preventDefault: () => calls.push('preventDefault') },
|
||||
'subminer://first-run-setup?action=open-config-settings',
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'set',
|
||||
'show',
|
||||
'focus',
|
||||
'in-progress',
|
||||
'load',
|
||||
'show',
|
||||
'focus',
|
||||
'preventDefault',
|
||||
'action',
|
||||
]);
|
||||
});
|
||||
|
||||
test('closing incomplete first-run setup quits app outside background mode', async () => {
|
||||
const calls: string[] = [];
|
||||
let closedHandler: (() => void) | undefined;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user