mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
Enhance AniList character dictionary sync and subtitle features (#15)
This commit is contained in:
@@ -222,9 +222,11 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis
|
||||
);
|
||||
|
||||
const privateState = integration as unknown as {
|
||||
proxyServer: unknown | null;
|
||||
runtime: {
|
||||
proxyServer: unknown | null;
|
||||
};
|
||||
};
|
||||
assert.equal(privateState.proxyServer, null);
|
||||
assert.equal(privateState.runtime.proxyServer, null);
|
||||
});
|
||||
|
||||
test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ import { FieldGroupingService } from './anki-integration/field-grouping';
|
||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||
import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
|
||||
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
|
||||
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
|
||||
|
||||
const log = createLogger('anki').child('integration');
|
||||
|
||||
@@ -113,8 +114,6 @@ export class AnkiIntegration {
|
||||
private timingTracker: SubtitleTimingTracker;
|
||||
private config: AnkiConnectConfig;
|
||||
private pollingRunner!: PollingRunner;
|
||||
private proxyServer: AnkiConnectProxyServer | null = null;
|
||||
private started = false;
|
||||
private previousNoteIds = new Set<number>();
|
||||
private mpvClient: MpvClient;
|
||||
private osdCallback: ((text: string) => void) | null = null;
|
||||
@@ -135,6 +134,7 @@ export class AnkiIntegration {
|
||||
private fieldGroupingService: FieldGroupingService;
|
||||
private noteUpdateWorkflow: NoteUpdateWorkflow;
|
||||
private fieldGroupingWorkflow: FieldGroupingWorkflow;
|
||||
private runtime: AnkiIntegrationRuntime;
|
||||
|
||||
constructor(
|
||||
config: AnkiConnectConfig,
|
||||
@@ -148,7 +148,7 @@ export class AnkiIntegration {
|
||||
}) => Promise<KikuFieldGroupingChoice>,
|
||||
knownWordCacheStatePath?: string,
|
||||
) {
|
||||
this.config = this.normalizeConfig(config);
|
||||
this.config = normalizeAnkiIntegrationConfig(config);
|
||||
this.client = new AnkiConnectClient(this.config.url!);
|
||||
this.mediaGenerator = new MediaGenerator();
|
||||
this.timingTracker = timingTracker;
|
||||
@@ -163,6 +163,7 @@ export class AnkiIntegration {
|
||||
this.fieldGroupingService = this.createFieldGroupingService();
|
||||
this.noteUpdateWorkflow = this.createNoteUpdateWorkflow();
|
||||
this.fieldGroupingWorkflow = this.createFieldGroupingWorkflow();
|
||||
this.runtime = this.createRuntime(config);
|
||||
}
|
||||
|
||||
private createFieldGroupingMergeCollaborator(): FieldGroupingMergeCollaborator {
|
||||
@@ -182,75 +183,6 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeConfig(config: AnkiConnectConfig): AnkiConnectConfig {
|
||||
const resolvedUrl =
|
||||
typeof config.url === 'string' && config.url.trim().length > 0
|
||||
? config.url.trim()
|
||||
: DEFAULT_ANKI_CONNECT_CONFIG.url;
|
||||
const proxySource =
|
||||
config.proxy && typeof config.proxy === 'object'
|
||||
? (config.proxy as NonNullable<AnkiConnectConfig['proxy']>)
|
||||
: {};
|
||||
const normalizedProxyPort =
|
||||
typeof proxySource.port === 'number' &&
|
||||
Number.isInteger(proxySource.port) &&
|
||||
proxySource.port >= 1 &&
|
||||
proxySource.port <= 65535
|
||||
? proxySource.port
|
||||
: DEFAULT_ANKI_CONNECT_CONFIG.proxy?.port;
|
||||
const normalizedProxyHost =
|
||||
typeof proxySource.host === 'string' && proxySource.host.trim().length > 0
|
||||
? proxySource.host.trim()
|
||||
: DEFAULT_ANKI_CONNECT_CONFIG.proxy?.host;
|
||||
const normalizedProxyUpstreamUrl =
|
||||
typeof proxySource.upstreamUrl === 'string' && proxySource.upstreamUrl.trim().length > 0
|
||||
? proxySource.upstreamUrl.trim()
|
||||
: resolvedUrl;
|
||||
|
||||
return {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG,
|
||||
...config,
|
||||
url: resolvedUrl,
|
||||
fields: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.fields,
|
||||
...(config.fields ?? {}),
|
||||
},
|
||||
proxy: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.proxy,
|
||||
...(config.proxy ?? {}),
|
||||
enabled: proxySource.enabled === true,
|
||||
host: normalizedProxyHost,
|
||||
port: normalizedProxyPort,
|
||||
upstreamUrl: normalizedProxyUpstreamUrl,
|
||||
},
|
||||
ai: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
|
||||
...(config.openRouter ?? {}),
|
||||
...(config.ai ?? {}),
|
||||
},
|
||||
media: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.media,
|
||||
...(config.media ?? {}),
|
||||
},
|
||||
behavior: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.behavior,
|
||||
...(config.behavior ?? {}),
|
||||
},
|
||||
metadata: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.metadata,
|
||||
...(config.metadata ?? {}),
|
||||
},
|
||||
isLapis: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.isLapis,
|
||||
...(config.isLapis ?? {}),
|
||||
},
|
||||
isKiku: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
||||
...(config.isKiku ?? {}),
|
||||
},
|
||||
} as AnkiConnectConfig;
|
||||
}
|
||||
|
||||
private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
|
||||
return new KnownWordCacheManager({
|
||||
client: {
|
||||
@@ -302,11 +234,20 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
private getOrCreateProxyServer(): AnkiConnectProxyServer {
|
||||
if (!this.proxyServer) {
|
||||
this.proxyServer = this.createProxyServer();
|
||||
}
|
||||
return this.proxyServer;
|
||||
private createRuntime(initialConfig: AnkiConnectConfig): AnkiIntegrationRuntime {
|
||||
return new AnkiIntegrationRuntime({
|
||||
initialConfig,
|
||||
pollingRunner: this.pollingRunner,
|
||||
knownWordCache: this.knownWordCache,
|
||||
proxyServerFactory: () => this.createProxyServer(),
|
||||
logInfo: (message, ...args) => log.info(message, ...args),
|
||||
logWarn: (message, ...args) => log.warn(message, ...args),
|
||||
logError: (message, ...args) => log.error(message, ...args),
|
||||
onConfigChanged: (nextConfig) => {
|
||||
this.config = nextConfig;
|
||||
this.client = new AnkiConnectClient(nextConfig.url!);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private createCardCreationService(): CardCreationService {
|
||||
@@ -517,14 +458,6 @@ export class AnkiIntegration {
|
||||
return this.config.nPlusOne?.highlightEnabled === true;
|
||||
}
|
||||
|
||||
private startKnownWordCacheLifecycle(): void {
|
||||
this.knownWordCache.startLifecycle();
|
||||
}
|
||||
|
||||
private stopKnownWordCacheLifecycle(): void {
|
||||
this.knownWordCache.stopLifecycle();
|
||||
}
|
||||
|
||||
private getConfiguredAnkiTags(): string[] {
|
||||
if (!Array.isArray(this.config.tags)) {
|
||||
return [];
|
||||
@@ -606,64 +539,12 @@ export class AnkiIntegration {
|
||||
};
|
||||
}
|
||||
|
||||
private isProxyTransportEnabled(config: AnkiConnectConfig = this.config): boolean {
|
||||
return config.proxy?.enabled === true;
|
||||
}
|
||||
|
||||
private getTransportConfigKey(config: AnkiConnectConfig = this.config): string {
|
||||
if (this.isProxyTransportEnabled(config)) {
|
||||
return [
|
||||
'proxy',
|
||||
config.proxy?.host ?? '',
|
||||
String(config.proxy?.port ?? ''),
|
||||
config.proxy?.upstreamUrl ?? '',
|
||||
].join(':');
|
||||
}
|
||||
return ['polling', String(config.pollingRate ?? DEFAULT_ANKI_CONNECT_CONFIG.pollingRate)].join(
|
||||
':',
|
||||
);
|
||||
}
|
||||
|
||||
private startTransport(): void {
|
||||
if (this.isProxyTransportEnabled()) {
|
||||
const proxyHost = this.config.proxy?.host ?? '127.0.0.1';
|
||||
const proxyPort = this.config.proxy?.port ?? 8766;
|
||||
const upstreamUrl = this.config.proxy?.upstreamUrl ?? this.config.url ?? '';
|
||||
this.getOrCreateProxyServer().start({
|
||||
host: proxyHost,
|
||||
port: proxyPort,
|
||||
upstreamUrl,
|
||||
});
|
||||
log.info(
|
||||
`Starting AnkiConnect integration with local proxy: http://${proxyHost}:${proxyPort} -> ${upstreamUrl}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('Starting AnkiConnect integration with polling rate:', this.config.pollingRate);
|
||||
this.pollingRunner.start();
|
||||
}
|
||||
|
||||
private stopTransport(): void {
|
||||
this.pollingRunner.stop();
|
||||
this.proxyServer?.stop();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
this.startKnownWordCacheLifecycle();
|
||||
this.startTransport();
|
||||
this.started = true;
|
||||
this.runtime.start();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopTransport();
|
||||
this.stopKnownWordCacheLifecycle();
|
||||
this.started = false;
|
||||
log.info('Stopped AnkiConnect integration');
|
||||
this.runtime.stop();
|
||||
}
|
||||
|
||||
private async processNewCard(
|
||||
@@ -1216,58 +1097,7 @@ export class AnkiIntegration {
|
||||
}
|
||||
|
||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
||||
const wasEnabled = this.config.nPlusOne?.highlightEnabled === true;
|
||||
const previousTransportKey = this.getTransportConfigKey(this.config);
|
||||
|
||||
const mergedConfig: AnkiConnectConfig = {
|
||||
...this.config,
|
||||
...patch,
|
||||
nPlusOne:
|
||||
patch.nPlusOne !== undefined
|
||||
? {
|
||||
...(this.config.nPlusOne ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne),
|
||||
...patch.nPlusOne,
|
||||
}
|
||||
: this.config.nPlusOne,
|
||||
fields:
|
||||
patch.fields !== undefined
|
||||
? { ...this.config.fields, ...patch.fields }
|
||||
: this.config.fields,
|
||||
media:
|
||||
patch.media !== undefined ? { ...this.config.media, ...patch.media } : this.config.media,
|
||||
behavior:
|
||||
patch.behavior !== undefined
|
||||
? { ...this.config.behavior, ...patch.behavior }
|
||||
: this.config.behavior,
|
||||
proxy:
|
||||
patch.proxy !== undefined ? { ...this.config.proxy, ...patch.proxy } : this.config.proxy,
|
||||
metadata:
|
||||
patch.metadata !== undefined
|
||||
? { ...this.config.metadata, ...patch.metadata }
|
||||
: this.config.metadata,
|
||||
isLapis:
|
||||
patch.isLapis !== undefined
|
||||
? { ...this.config.isLapis, ...patch.isLapis }
|
||||
: this.config.isLapis,
|
||||
isKiku:
|
||||
patch.isKiku !== undefined
|
||||
? { ...this.config.isKiku, ...patch.isKiku }
|
||||
: this.config.isKiku,
|
||||
};
|
||||
this.config = this.normalizeConfig(mergedConfig);
|
||||
|
||||
if (wasEnabled && this.config.nPlusOne?.highlightEnabled === false) {
|
||||
this.stopKnownWordCacheLifecycle();
|
||||
this.knownWordCache.clearKnownWordCacheState();
|
||||
} else {
|
||||
this.startKnownWordCacheLifecycle();
|
||||
}
|
||||
|
||||
const nextTransportKey = this.getTransportConfigKey(this.config);
|
||||
if (this.started && previousTransportKey !== nextTransportKey) {
|
||||
this.stopTransport();
|
||||
this.startTransport();
|
||||
}
|
||||
this.runtime.applyRuntimeConfigPatch(patch);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
|
||||
@@ -80,7 +80,7 @@ export class FieldGroupingWorkflow {
|
||||
|
||||
async handleManual(
|
||||
originalNoteId: number,
|
||||
newNoteId: number,
|
||||
_newNoteId: number,
|
||||
newNoteInfo: FieldGroupingWorkflowNoteInfo,
|
||||
): Promise<boolean> {
|
||||
const callback = await this.resolveFieldGroupingCallback();
|
||||
|
||||
108
src/anki-integration/runtime.test.ts
Normal file
108
src/anki-integration/runtime.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import type { AnkiConnectConfig } from '../types';
|
||||
import { AnkiIntegrationRuntime } from './runtime';
|
||||
|
||||
function createRuntime(
|
||||
config: Partial<AnkiConnectConfig> = {},
|
||||
overrides: Partial<ConstructorParameters<typeof AnkiIntegrationRuntime>[0]> = {},
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = new AnkiIntegrationRuntime({
|
||||
initialConfig: config as AnkiConnectConfig,
|
||||
pollingRunner: {
|
||||
start: () => calls.push('polling:start'),
|
||||
stop: () => calls.push('polling:stop'),
|
||||
},
|
||||
knownWordCache: {
|
||||
startLifecycle: () => calls.push('known:start'),
|
||||
stopLifecycle: () => calls.push('known:stop'),
|
||||
clearKnownWordCacheState: () => calls.push('known:clear'),
|
||||
},
|
||||
proxyServerFactory: () => ({
|
||||
start: ({ host, port, upstreamUrl }) =>
|
||||
calls.push(`proxy:start:${host}:${port}:${upstreamUrl}`),
|
||||
stop: () => calls.push('proxy:stop'),
|
||||
}),
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
logError: () => undefined,
|
||||
onConfigChanged: () => undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return { runtime, calls };
|
||||
}
|
||||
|
||||
test('AnkiIntegrationRuntime normalizes url and proxy defaults', () => {
|
||||
const { runtime } = createRuntime({
|
||||
url: ' http://anki.local:8765 ',
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: ' 0.0.0.0 ',
|
||||
port: 7001,
|
||||
upstreamUrl: ' ',
|
||||
},
|
||||
});
|
||||
|
||||
const normalized = runtime.getConfig();
|
||||
|
||||
assert.equal(normalized.url, 'http://anki.local:8765');
|
||||
assert.equal(normalized.proxy?.enabled, true);
|
||||
assert.equal(normalized.proxy?.host, '0.0.0.0');
|
||||
assert.equal(normalized.proxy?.port, 7001);
|
||||
assert.equal(normalized.proxy?.upstreamUrl, 'http://anki.local:8765');
|
||||
assert.equal(
|
||||
normalized.media?.fallbackDuration,
|
||||
DEFAULT_ANKI_CONNECT_CONFIG.media.fallbackDuration,
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled', () => {
|
||||
const { runtime, calls } = createRuntime({
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 9999,
|
||||
upstreamUrl: 'http://upstream:8765',
|
||||
},
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
|
||||
assert.deepEqual(calls, ['known:start', 'proxy:start:127.0.0.1:9999:http://upstream:8765']);
|
||||
});
|
||||
|
||||
test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => {
|
||||
const { runtime, calls } = createRuntime({
|
||||
nPlusOne: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
pollingRate: 250,
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
calls.length = 0;
|
||||
|
||||
runtime.applyRuntimeConfigPatch({
|
||||
nPlusOne: {
|
||||
highlightEnabled: false,
|
||||
},
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8766,
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'known:stop',
|
||||
'known:clear',
|
||||
'polling:stop',
|
||||
'proxy:start:127.0.0.1:8766:http://127.0.0.1:8765',
|
||||
]);
|
||||
});
|
||||
232
src/anki-integration/runtime.ts
Normal file
232
src/anki-integration/runtime.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import type { AnkiConnectConfig } from '../types';
|
||||
|
||||
export interface AnkiIntegrationRuntimeProxyServer {
|
||||
start(options: { host: string; port: number; upstreamUrl: string }): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
interface AnkiIntegrationRuntimeDeps {
|
||||
initialConfig: AnkiConnectConfig;
|
||||
pollingRunner: {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
};
|
||||
knownWordCache: {
|
||||
startLifecycle(): void;
|
||||
stopLifecycle(): void;
|
||||
clearKnownWordCacheState(): void;
|
||||
};
|
||||
proxyServerFactory: () => AnkiIntegrationRuntimeProxyServer;
|
||||
logInfo: (message: string, ...args: unknown[]) => void;
|
||||
logWarn: (message: string, ...args: unknown[]) => void;
|
||||
logError: (message: string, ...args: unknown[]) => void;
|
||||
onConfigChanged?: (config: AnkiConnectConfig) => void;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiConnectConfig {
|
||||
const resolvedUrl = trimToNonEmptyString(config.url) ?? DEFAULT_ANKI_CONNECT_CONFIG.url;
|
||||
const proxySource =
|
||||
config.proxy && typeof config.proxy === 'object'
|
||||
? (config.proxy as NonNullable<AnkiConnectConfig['proxy']>)
|
||||
: {};
|
||||
const normalizedProxyPort =
|
||||
typeof proxySource.port === 'number' &&
|
||||
Number.isInteger(proxySource.port) &&
|
||||
proxySource.port >= 1 &&
|
||||
proxySource.port <= 65535
|
||||
? proxySource.port
|
||||
: DEFAULT_ANKI_CONNECT_CONFIG.proxy?.port;
|
||||
const normalizedProxyHost =
|
||||
trimToNonEmptyString(proxySource.host) ?? DEFAULT_ANKI_CONNECT_CONFIG.proxy?.host;
|
||||
const normalizedProxyUpstreamUrl = trimToNonEmptyString(proxySource.upstreamUrl) ?? resolvedUrl;
|
||||
|
||||
return {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG,
|
||||
...config,
|
||||
url: resolvedUrl,
|
||||
fields: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.fields,
|
||||
...(config.fields ?? {}),
|
||||
},
|
||||
proxy: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.proxy,
|
||||
...(config.proxy ?? {}),
|
||||
enabled: proxySource.enabled === true,
|
||||
host: normalizedProxyHost,
|
||||
port: normalizedProxyPort,
|
||||
upstreamUrl: normalizedProxyUpstreamUrl,
|
||||
},
|
||||
ai: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
|
||||
...(config.openRouter ?? {}),
|
||||
...(config.ai ?? {}),
|
||||
},
|
||||
media: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.media,
|
||||
...(config.media ?? {}),
|
||||
},
|
||||
behavior: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.behavior,
|
||||
...(config.behavior ?? {}),
|
||||
},
|
||||
metadata: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.metadata,
|
||||
...(config.metadata ?? {}),
|
||||
},
|
||||
isLapis: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.isLapis,
|
||||
...(config.isLapis ?? {}),
|
||||
},
|
||||
isKiku: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
||||
...(config.isKiku ?? {}),
|
||||
},
|
||||
} as AnkiConnectConfig;
|
||||
}
|
||||
|
||||
export class AnkiIntegrationRuntime {
|
||||
private config: AnkiConnectConfig;
|
||||
private proxyServer: AnkiIntegrationRuntimeProxyServer | null = null;
|
||||
private started = false;
|
||||
|
||||
constructor(private readonly deps: AnkiIntegrationRuntimeDeps) {
|
||||
this.config = normalizeAnkiIntegrationConfig(deps.initialConfig);
|
||||
}
|
||||
|
||||
getConfig(): AnkiConnectConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
this.deps.knownWordCache.startLifecycle();
|
||||
this.startTransport();
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopTransport();
|
||||
this.deps.knownWordCache.stopLifecycle();
|
||||
this.started = false;
|
||||
this.deps.logInfo('Stopped AnkiConnect integration');
|
||||
}
|
||||
|
||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
||||
const wasKnownWordCacheEnabled = this.config.nPlusOne?.highlightEnabled === true;
|
||||
const previousTransportKey = this.getTransportConfigKey(this.config);
|
||||
|
||||
const mergedConfig: AnkiConnectConfig = {
|
||||
...this.config,
|
||||
...patch,
|
||||
nPlusOne:
|
||||
patch.nPlusOne !== undefined
|
||||
? {
|
||||
...(this.config.nPlusOne ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne),
|
||||
...patch.nPlusOne,
|
||||
}
|
||||
: this.config.nPlusOne,
|
||||
fields:
|
||||
patch.fields !== undefined
|
||||
? { ...this.config.fields, ...patch.fields }
|
||||
: this.config.fields,
|
||||
media:
|
||||
patch.media !== undefined ? { ...this.config.media, ...patch.media } : this.config.media,
|
||||
behavior:
|
||||
patch.behavior !== undefined
|
||||
? { ...this.config.behavior, ...patch.behavior }
|
||||
: this.config.behavior,
|
||||
proxy:
|
||||
patch.proxy !== undefined ? { ...this.config.proxy, ...patch.proxy } : this.config.proxy,
|
||||
metadata:
|
||||
patch.metadata !== undefined
|
||||
? { ...this.config.metadata, ...patch.metadata }
|
||||
: this.config.metadata,
|
||||
isLapis:
|
||||
patch.isLapis !== undefined
|
||||
? { ...this.config.isLapis, ...patch.isLapis }
|
||||
: this.config.isLapis,
|
||||
isKiku:
|
||||
patch.isKiku !== undefined
|
||||
? { ...this.config.isKiku, ...patch.isKiku }
|
||||
: this.config.isKiku,
|
||||
};
|
||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||
this.deps.onConfigChanged?.(this.config);
|
||||
|
||||
if (wasKnownWordCacheEnabled && this.config.nPlusOne?.highlightEnabled === false) {
|
||||
this.deps.knownWordCache.stopLifecycle();
|
||||
this.deps.knownWordCache.clearKnownWordCacheState();
|
||||
} else {
|
||||
this.deps.knownWordCache.startLifecycle();
|
||||
}
|
||||
|
||||
const nextTransportKey = this.getTransportConfigKey(this.config);
|
||||
if (this.started && previousTransportKey !== nextTransportKey) {
|
||||
this.stopTransport();
|
||||
this.startTransport();
|
||||
}
|
||||
}
|
||||
|
||||
getOrCreateProxyServer(): AnkiIntegrationRuntimeProxyServer {
|
||||
if (!this.proxyServer) {
|
||||
this.proxyServer = this.deps.proxyServerFactory();
|
||||
}
|
||||
return this.proxyServer;
|
||||
}
|
||||
|
||||
private isProxyTransportEnabled(config: AnkiConnectConfig = this.config): boolean {
|
||||
return config.proxy?.enabled === true;
|
||||
}
|
||||
|
||||
private getTransportConfigKey(config: AnkiConnectConfig = this.config): string {
|
||||
if (this.isProxyTransportEnabled(config)) {
|
||||
return [
|
||||
'proxy',
|
||||
config.proxy?.host ?? '',
|
||||
String(config.proxy?.port ?? ''),
|
||||
config.proxy?.upstreamUrl ?? '',
|
||||
].join(':');
|
||||
}
|
||||
return ['polling', String(config.pollingRate ?? DEFAULT_ANKI_CONNECT_CONFIG.pollingRate)].join(
|
||||
':',
|
||||
);
|
||||
}
|
||||
|
||||
private startTransport(): void {
|
||||
if (this.isProxyTransportEnabled()) {
|
||||
const proxyHost = this.config.proxy?.host ?? '127.0.0.1';
|
||||
const proxyPort = this.config.proxy?.port ?? 8766;
|
||||
const upstreamUrl = this.config.proxy?.upstreamUrl ?? this.config.url ?? '';
|
||||
this.getOrCreateProxyServer().start({
|
||||
host: proxyHost,
|
||||
port: proxyPort,
|
||||
upstreamUrl,
|
||||
});
|
||||
this.deps.logInfo(
|
||||
`Starting AnkiConnect integration with local proxy: http://${proxyHost}:${proxyPort} -> ${upstreamUrl}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.deps.logInfo(
|
||||
'Starting AnkiConnect integration with polling rate:',
|
||||
this.config.pollingRate,
|
||||
);
|
||||
this.deps.pollingRunner.start();
|
||||
}
|
||||
|
||||
private stopTransport(): void {
|
||||
this.deps.pollingRunner.stop();
|
||||
this.proxyServer?.stop();
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
|
||||
assert.equal(shouldStartApp(anilistRetryQueue), false);
|
||||
|
||||
const dictionary = parseArgs(['--dictionary']);
|
||||
assert.equal(dictionary.dictionary, true);
|
||||
assert.equal(hasExplicitCommand(dictionary), true);
|
||||
assert.equal(shouldStartApp(dictionary), true);
|
||||
const dictionaryTarget = parseArgs(['--dictionary', '--dictionary-target', '/tmp/example.mkv']);
|
||||
assert.equal(dictionaryTarget.dictionary, true);
|
||||
assert.equal(dictionaryTarget.dictionaryTarget, '/tmp/example.mkv');
|
||||
|
||||
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
|
||||
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
||||
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
||||
@@ -161,4 +169,9 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(background.background, true);
|
||||
assert.equal(hasExplicitCommand(background), true);
|
||||
assert.equal(shouldStartApp(background), true);
|
||||
|
||||
const setup = parseArgs(['--setup']);
|
||||
assert.equal((setup as typeof setup & { setup?: boolean }).setup, true);
|
||||
assert.equal(hasExplicitCommand(setup), true);
|
||||
assert.equal(shouldStartApp(setup), true);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface CliArgs {
|
||||
toggle: boolean;
|
||||
toggleVisibleOverlay: boolean;
|
||||
settings: boolean;
|
||||
setup: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
showVisibleOverlay: boolean;
|
||||
@@ -24,6 +25,8 @@ export interface CliArgs {
|
||||
anilistLogout: boolean;
|
||||
anilistSetup: boolean;
|
||||
anilistRetryQueue: boolean;
|
||||
dictionary: boolean;
|
||||
dictionaryTarget?: string;
|
||||
jellyfin: boolean;
|
||||
jellyfinLogin: boolean;
|
||||
jellyfinLogout: boolean;
|
||||
@@ -69,6 +72,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
@@ -88,6 +92,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
@@ -122,6 +127,7 @@ 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 === '--settings' || arg === '--yomitan') args.settings = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
else if (arg === '--show') args.show = true;
|
||||
else if (arg === '--hide') args.hide = true;
|
||||
else if (arg === '--show-visible-overlay') args.showVisibleOverlay = true;
|
||||
@@ -141,7 +147,14 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--anilist-logout') args.anilistLogout = true;
|
||||
else if (arg === '--anilist-setup') args.anilistSetup = true;
|
||||
else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
|
||||
else if (arg === '--jellyfin') args.jellyfin = true;
|
||||
else if (arg === '--dictionary') args.dictionary = true;
|
||||
else if (arg.startsWith('--dictionary-target=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.dictionaryTarget = value;
|
||||
} else if (arg === '--dictionary-target') {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.dictionaryTarget = value;
|
||||
} else if (arg === '--jellyfin') args.jellyfin = true;
|
||||
else if (arg === '--jellyfin-login') args.jellyfinLogin = true;
|
||||
else if (arg === '--jellyfin-logout') args.jellyfinLogout = true;
|
||||
else if (arg === '--jellyfin-libraries') args.jellyfinLibraries = true;
|
||||
@@ -288,6 +301,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.settings ||
|
||||
args.setup ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
@@ -307,6 +321,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.anilistLogout ||
|
||||
args.anilistSetup ||
|
||||
args.anilistRetryQueue ||
|
||||
args.dictionary ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinLogin ||
|
||||
args.jellyfinLogout ||
|
||||
@@ -330,6 +345,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.settings ||
|
||||
args.setup ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.mineSentence ||
|
||||
@@ -340,6 +356,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.openRuntimeOptions ||
|
||||
args.dictionary ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinPlay ||
|
||||
args.texthooker
|
||||
@@ -359,6 +376,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
!args.setup &&
|
||||
!args.showVisibleOverlay &&
|
||||
!args.hideVisibleOverlay &&
|
||||
!args.copySubtitle &&
|
||||
@@ -376,6 +394,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.anilistLogout &&
|
||||
!args.anilistSetup &&
|
||||
!args.anilistRetryQueue &&
|
||||
!args.dictionary &&
|
||||
!args.jellyfin &&
|
||||
!args.jellyfinLogin &&
|
||||
!args.jellyfinLogout &&
|
||||
|
||||
@@ -18,8 +18,11 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /--help\s+Show this help/);
|
||||
assert.match(output, /default: 7777/);
|
||||
assert.match(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
assert.match(output, /--dictionary/);
|
||||
assert.match(output, /--dictionary-target/);
|
||||
assert.match(output, /--jellyfin\s+Open Jellyfin setup window/);
|
||||
assert.match(output, /--jellyfin-login/);
|
||||
assert.match(output, /--jellyfin-subtitles/);
|
||||
|
||||
@@ -20,6 +20,7 @@ ${B}Overlay${R}
|
||||
--show-visible-overlay Show subtitle overlay
|
||||
--hide-visible-overlay Hide subtitle overlay
|
||||
--settings Open Yomitan settings window
|
||||
--setup Open first-run setup window
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
${B}Mining${R}
|
||||
@@ -40,6 +41,8 @@ ${B}AniList${R}
|
||||
--anilist-status Show token and retry queue status
|
||||
--anilist-logout Clear stored AniList token
|
||||
--anilist-retry-queue Retry next queued update
|
||||
--dictionary Generate character dictionary ZIP for current anime
|
||||
--dictionary-target ${D}PATH${R} Override dictionary source path (file or directory)
|
||||
|
||||
${B}Jellyfin${R}
|
||||
--jellyfin Open Jellyfin setup window
|
||||
|
||||
@@ -16,9 +16,20 @@ test('loads defaults when config is missing', () => {
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
|
||||
assert.equal(config.annotationWebsocket.enabled, DEFAULT_CONFIG.annotationWebsocket.enabled);
|
||||
assert.equal(config.annotationWebsocket.port, DEFAULT_CONFIG.annotationWebsocket.port);
|
||||
assert.equal(config.texthooker.launchAtStartup, true);
|
||||
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
||||
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
|
||||
assert.equal(config.anilist.enabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.enabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
|
||||
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
|
||||
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||
assert.equal(config.anilist.characterDictionary.profileScope, 'all');
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
|
||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||
@@ -123,6 +134,88 @@ test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('parses texthooker.launchAtStartup and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"texthooker": {
|
||||
"launchAtStartup": false
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().texthooker.launchAtStartup, false);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"texthooker": {
|
||||
"launchAtStartup": "yes"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().texthooker.launchAtStartup,
|
||||
DEFAULT_CONFIG.texthooker.launchAtStartup,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService.getWarnings().some((warning) => warning.path === 'texthooker.launchAtStartup'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses annotationWebsocket settings and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"annotationWebsocket": {
|
||||
"enabled": false,
|
||||
"port": 7788
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().annotationWebsocket.enabled, false);
|
||||
assert.equal(validService.getConfig().annotationWebsocket.port, 7788);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"annotationWebsocket": {
|
||||
"enabled": "yes",
|
||||
"port": "bad"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().annotationWebsocket.enabled,
|
||||
DEFAULT_CONFIG.annotationWebsocket.enabled,
|
||||
);
|
||||
assert.equal(
|
||||
invalidService.getConfig().annotationWebsocket.port,
|
||||
DEFAULT_CONFIG.annotationWebsocket.port,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService.getWarnings().some((warning) => warning.path === 'annotationWebsocket.enabled'),
|
||||
);
|
||||
assert.ok(
|
||||
invalidService.getWarnings().some((warning) => warning.path === 'annotationWebsocket.port'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -237,6 +330,47 @@ test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.nameMatchColor and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"nameMatchColor": "#eed49f"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(
|
||||
((validService.getConfig().subtitleStyle as unknown as Record<string, unknown>)
|
||||
.nameMatchColor ?? null) as string | null,
|
||||
'#eed49f',
|
||||
);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"nameMatchColor": "pink"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
((invalidService.getConfig().subtitleStyle as unknown as Record<string, unknown>)
|
||||
.nameMatchColor ?? null) as string | null,
|
||||
'#f5bde6',
|
||||
);
|
||||
assert.ok(
|
||||
invalidService.getWarnings().some((warning) => warning.path === 'subtitleStyle.nameMatchColor'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -275,6 +409,44 @@ test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"nameMatchEnabled": false
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().subtitleStyle.nameMatchEnabled, false);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"nameMatchEnabled": "no"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().subtitleStyle.nameMatchEnabled,
|
||||
DEFAULT_CONFIG.subtitleStyle.nameMatchEnabled,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'subtitleStyle.nameMatchEnabled'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses anilist.enabled and warns for invalid value', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -298,6 +470,78 @@ test('parses anilist.enabled and warns for invalid value', () => {
|
||||
assert.equal(service.getConfig().anilist.enabled, true);
|
||||
});
|
||||
|
||||
test('parses anilist.characterDictionary config with clamping and enum validation', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"anilist": {
|
||||
"characterDictionary": {
|
||||
"enabled": true,
|
||||
"refreshTtlHours": 0,
|
||||
"maxLoaded": 1000,
|
||||
"evictionPolicy": "remove",
|
||||
"profileScope": "everywhere"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.anilist.characterDictionary.enabled, true);
|
||||
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1);
|
||||
assert.equal(config.anilist.characterDictionary.maxLoaded, 20);
|
||||
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||
assert.equal(config.anilist.characterDictionary.profileScope, 'all');
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === 'anilist.characterDictionary.refreshTtlHours'),
|
||||
);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.maxLoaded'));
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === 'anilist.characterDictionary.evictionPolicy'),
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === 'anilist.characterDictionary.profileScope'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses anilist.characterDictionary.collapsibleSections booleans and warns on invalid values', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"anilist": {
|
||||
"characterDictionary": {
|
||||
"collapsibleSections": {
|
||||
"description": true,
|
||||
"characterInformation": "yes",
|
||||
"voicedBy": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, true);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, true);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'anilist.characterDictionary.collapsibleSections.characterInformation',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses jellyfin remote control fields', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -721,6 +965,10 @@ test('warning emission order is deterministic across reloads', () => {
|
||||
"enabled": "sometimes",
|
||||
"port": -1
|
||||
},
|
||||
"annotationWebsocket": {
|
||||
"enabled": "sometimes",
|
||||
"port": -1
|
||||
},
|
||||
"logging": {
|
||||
"level": "trace"
|
||||
}
|
||||
@@ -737,7 +985,14 @@ test('warning emission order is deterministic across reloads', () => {
|
||||
assert.deepEqual(secondWarnings, firstWarnings);
|
||||
assert.deepEqual(
|
||||
firstWarnings.map((warning) => warning.path),
|
||||
['unknownFeature', 'websocket.enabled', 'websocket.port', 'logging.level'],
|
||||
[
|
||||
'unknownFeature',
|
||||
'websocket.enabled',
|
||||
'websocket.port',
|
||||
'annotationWebsocket.enabled',
|
||||
'annotationWebsocket.port',
|
||||
'logging.level',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1292,6 +1547,7 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"discordPresence":/);
|
||||
assert.match(output, /"startupWarmups":/);
|
||||
assert.match(output, /"youtubeSubgen":/);
|
||||
assert.match(output, /"characterDictionary":\s*\{/);
|
||||
assert.match(output, /"preserveLineBreaks": false/);
|
||||
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
||||
assert.match(output, /"nPlusOne": "#c6a0f6"/);
|
||||
@@ -1306,8 +1562,17 @@ test('template generator includes known keys', () => {
|
||||
output,
|
||||
/"enabled": "auto",? \/\/ Built-in subtitle websocket server mode\. Values: auto \| true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
|
||||
);
|
||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||
assert.match(
|
||||
output,
|
||||
/"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"launchAtStartup": true,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
annotationWebsocket,
|
||||
logging,
|
||||
texthooker,
|
||||
shortcuts,
|
||||
@@ -39,6 +40,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
annotationWebsocket,
|
||||
logging,
|
||||
texthooker,
|
||||
ankiConnect,
|
||||
|
||||
@@ -5,6 +5,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'subtitlePosition'
|
||||
| 'keybindings'
|
||||
| 'websocket'
|
||||
| 'annotationWebsocket'
|
||||
| 'logging'
|
||||
| 'texthooker'
|
||||
| 'shortcuts'
|
||||
@@ -19,10 +20,15 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
enabled: 'auto',
|
||||
port: 6677,
|
||||
},
|
||||
annotationWebsocket: {
|
||||
enabled: true,
|
||||
port: 6678,
|
||||
},
|
||||
logging: {
|
||||
level: 'info',
|
||||
},
|
||||
texthooker: {
|
||||
launchAtStartup: true,
|
||||
openBrowser: true,
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -86,6 +86,18 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
anilist: {
|
||||
enabled: false,
|
||||
accessToken: '',
|
||||
characterDictionary: {
|
||||
enabled: false,
|
||||
refreshTtlHours: 168,
|
||||
maxLoaded: 3,
|
||||
evictionPolicy: 'delete',
|
||||
profileScope: 'all',
|
||||
collapsibleSections: {
|
||||
description: false,
|
||||
characterInformation: false,
|
||||
voicedBy: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
jellyfin: {
|
||||
enabled: false,
|
||||
|
||||
@@ -8,6 +8,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
||||
autoPauseVideoOnYomitanPopup: false,
|
||||
hoverTokenColor: '#f4dbd6',
|
||||
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||
nameMatchEnabled: true,
|
||||
nameMatchColor: '#f5bde6',
|
||||
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 35,
|
||||
fontColor: '#cad3f5',
|
||||
@@ -37,7 +39,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
||||
mode: 'single',
|
||||
matchMode: 'headword',
|
||||
singleColor: '#f5a97f',
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||
},
|
||||
secondary: {
|
||||
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
||||
|
||||
@@ -18,10 +18,13 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
|
||||
for (const requiredPath of [
|
||||
'logging.level',
|
||||
'annotationWebsocket.enabled',
|
||||
'startupWarmups.lowPowerMode',
|
||||
'subtitleStyle.enableJlpt',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
'ankiConnect.enabled',
|
||||
'anilist.characterDictionary.enabled',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'immersionTracking.enabled',
|
||||
]) {
|
||||
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
||||
@@ -34,6 +37,7 @@ test('config template sections include expected domains and unique keys', () =>
|
||||
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
'websocket',
|
||||
'annotationWebsocket',
|
||||
'startupWarmups',
|
||||
'subtitleStyle',
|
||||
'ankiConnect',
|
||||
|
||||
@@ -12,6 +12,12 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.logging.level,
|
||||
description: 'Minimum log level for runtime logging.',
|
||||
},
|
||||
{
|
||||
path: 'texthooker.launchAtStartup',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.texthooker.launchAtStartup,
|
||||
description: 'Launch texthooker server automatically when SubMiner starts.',
|
||||
},
|
||||
{
|
||||
path: 'websocket.enabled',
|
||||
kind: 'enum',
|
||||
@@ -25,6 +31,18 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.websocket.port,
|
||||
description: 'Built-in subtitle websocket server port.',
|
||||
},
|
||||
{
|
||||
path: 'annotationWebsocket.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.annotationWebsocket.enabled,
|
||||
description: 'Annotated subtitle websocket server enabled state.',
|
||||
},
|
||||
{
|
||||
path: 'annotationWebsocket.port',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.annotationWebsocket.port,
|
||||
description: 'Annotated subtitle websocket server port.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.defaultMode',
|
||||
kind: 'enum',
|
||||
|
||||
@@ -135,6 +135,64 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.enabled,
|
||||
description:
|
||||
'Enable automatic Yomitan character dictionary sync for currently watched AniList media.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.refreshTtlHours',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.refreshTtlHours,
|
||||
description:
|
||||
'Legacy setting; merged character dictionary retention is now usage-based and this value is ignored.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.maxLoaded',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.maxLoaded,
|
||||
description:
|
||||
'Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.evictionPolicy',
|
||||
kind: 'enum',
|
||||
enumValues: ['disable', 'delete'],
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.evictionPolicy,
|
||||
description:
|
||||
'Legacy setting; merged character dictionary eviction is usage-based and this value is ignored.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.profileScope',
|
||||
kind: 'enum',
|
||||
enumValues: ['all', 'active'],
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.profileScope,
|
||||
description: 'Yomitan profile scope for dictionary enable/disable updates.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.collapsibleSections.description',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.collapsibleSections.description,
|
||||
description:
|
||||
'Open the Description section by default in character dictionary glossary entries.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.collapsibleSections.characterInformation',
|
||||
kind: 'boolean',
|
||||
defaultValue:
|
||||
defaultConfig.anilist.characterDictionary.collapsibleSections.characterInformation,
|
||||
description:
|
||||
'Open the Character Information section by default in character dictionary glossary entries.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.collapsibleSections.voicedBy',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.collapsibleSections.voicedBy,
|
||||
description:
|
||||
'Open the Voiced by section by default in character dictionary glossary entries.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.enabled',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -47,6 +47,20 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.subtitleStyle.hoverTokenBackgroundColor,
|
||||
description: 'CSS color used for hovered subtitle token background highlight in mpv.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.nameMatchEnabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.subtitleStyle.nameMatchEnabled,
|
||||
description:
|
||||
'Enable subtitle token coloring for matches from the SubMiner character dictionary.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.nameMatchColor',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subtitleStyle.nameMatchColor,
|
||||
description:
|
||||
'Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.frequencyDictionary.enabled',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -10,7 +10,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
},
|
||||
{
|
||||
title: 'Texthooker Server',
|
||||
description: ['Control whether browser opens automatically for texthooker.'],
|
||||
description: ['Configure texthooker startup launch and browser opening behavior.'],
|
||||
key: 'texthooker',
|
||||
},
|
||||
{
|
||||
@@ -21,6 +21,14 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
],
|
||||
key: 'websocket',
|
||||
},
|
||||
{
|
||||
title: 'Annotation WebSocket',
|
||||
description: [
|
||||
'Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients.',
|
||||
'Independent from websocket.auto and defaults to port 6678.',
|
||||
],
|
||||
key: 'annotationWebsocket',
|
||||
},
|
||||
{
|
||||
title: 'Logging',
|
||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
||||
@@ -104,7 +112,11 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
},
|
||||
{
|
||||
title: 'Anilist',
|
||||
description: ['Anilist API credentials and update behavior.'],
|
||||
description: [
|
||||
'Anilist API credentials and update behavior.',
|
||||
'Includes optional auto-sync for a merged MRU-based character dictionary in bundled Yomitan.',
|
||||
'Character dictionaries are keyed by AniList media ID (no season/franchise merge).',
|
||||
],
|
||||
key: 'anilist',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||
if (launchAtStartup !== undefined) {
|
||||
resolved.texthooker.launchAtStartup = launchAtStartup;
|
||||
} else if (src.texthooker.launchAtStartup !== undefined) {
|
||||
warn(
|
||||
'texthooker.launchAtStartup',
|
||||
src.texthooker.launchAtStartup,
|
||||
resolved.texthooker.launchAtStartup,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const openBrowser = asBoolean(src.texthooker.openBrowser);
|
||||
if (openBrowser !== undefined) {
|
||||
resolved.texthooker.openBrowser = openBrowser;
|
||||
@@ -44,6 +56,32 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.annotationWebsocket)) {
|
||||
const enabled = asBoolean(src.annotationWebsocket.enabled);
|
||||
if (enabled !== undefined) {
|
||||
resolved.annotationWebsocket.enabled = enabled;
|
||||
} else if (src.annotationWebsocket.enabled !== undefined) {
|
||||
warn(
|
||||
'annotationWebsocket.enabled',
|
||||
src.annotationWebsocket.enabled,
|
||||
resolved.annotationWebsocket.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const port = asNumber(src.annotationWebsocket.port);
|
||||
if (port !== undefined && port > 0 && port <= 65535) {
|
||||
resolved.annotationWebsocket.port = Math.floor(port);
|
||||
} else if (src.annotationWebsocket.port !== undefined) {
|
||||
warn(
|
||||
'annotationWebsocket.port',
|
||||
src.annotationWebsocket.port,
|
||||
resolved.annotationWebsocket.port,
|
||||
'Expected integer between 1 and 65535.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.logging)) {
|
||||
const logLevel = asString(src.logging.level);
|
||||
if (
|
||||
|
||||
@@ -23,6 +23,140 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.anilist.characterDictionary)) {
|
||||
const characterDictionary = src.anilist.characterDictionary;
|
||||
|
||||
const dictionaryEnabled = asBoolean(characterDictionary.enabled);
|
||||
if (dictionaryEnabled !== undefined) {
|
||||
resolved.anilist.characterDictionary.enabled = dictionaryEnabled;
|
||||
} else if (characterDictionary.enabled !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary.enabled',
|
||||
characterDictionary.enabled,
|
||||
resolved.anilist.characterDictionary.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const refreshTtlHours = asNumber(characterDictionary.refreshTtlHours);
|
||||
if (refreshTtlHours !== undefined) {
|
||||
const normalized = Math.min(24 * 365, Math.max(1, Math.floor(refreshTtlHours)));
|
||||
if (normalized !== refreshTtlHours) {
|
||||
warn(
|
||||
'anilist.characterDictionary.refreshTtlHours',
|
||||
characterDictionary.refreshTtlHours,
|
||||
normalized,
|
||||
'Out of range; clamped to 1..8760 hours.',
|
||||
);
|
||||
}
|
||||
resolved.anilist.characterDictionary.refreshTtlHours = normalized;
|
||||
} else if (characterDictionary.refreshTtlHours !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary.refreshTtlHours',
|
||||
characterDictionary.refreshTtlHours,
|
||||
resolved.anilist.characterDictionary.refreshTtlHours,
|
||||
'Expected number.',
|
||||
);
|
||||
}
|
||||
|
||||
const maxLoaded = asNumber(characterDictionary.maxLoaded);
|
||||
if (maxLoaded !== undefined) {
|
||||
const normalized = Math.min(20, Math.max(1, Math.floor(maxLoaded)));
|
||||
if (normalized !== maxLoaded) {
|
||||
warn(
|
||||
'anilist.characterDictionary.maxLoaded',
|
||||
characterDictionary.maxLoaded,
|
||||
normalized,
|
||||
'Out of range; clamped to 1..20.',
|
||||
);
|
||||
}
|
||||
resolved.anilist.characterDictionary.maxLoaded = normalized;
|
||||
} else if (characterDictionary.maxLoaded !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary.maxLoaded',
|
||||
characterDictionary.maxLoaded,
|
||||
resolved.anilist.characterDictionary.maxLoaded,
|
||||
'Expected number.',
|
||||
);
|
||||
}
|
||||
|
||||
const evictionPolicyRaw = asString(characterDictionary.evictionPolicy);
|
||||
if (evictionPolicyRaw !== undefined) {
|
||||
const evictionPolicy = evictionPolicyRaw.trim().toLowerCase();
|
||||
if (evictionPolicy === 'disable' || evictionPolicy === 'delete') {
|
||||
resolved.anilist.characterDictionary.evictionPolicy = evictionPolicy;
|
||||
} else {
|
||||
warn(
|
||||
'anilist.characterDictionary.evictionPolicy',
|
||||
characterDictionary.evictionPolicy,
|
||||
resolved.anilist.characterDictionary.evictionPolicy,
|
||||
"Expected one of: 'disable', 'delete'.",
|
||||
);
|
||||
}
|
||||
} else if (characterDictionary.evictionPolicy !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary.evictionPolicy',
|
||||
characterDictionary.evictionPolicy,
|
||||
resolved.anilist.characterDictionary.evictionPolicy,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
const profileScopeRaw = asString(characterDictionary.profileScope);
|
||||
if (profileScopeRaw !== undefined) {
|
||||
const profileScope = profileScopeRaw.trim().toLowerCase();
|
||||
if (profileScope === 'all' || profileScope === 'active') {
|
||||
resolved.anilist.characterDictionary.profileScope = profileScope;
|
||||
} else {
|
||||
warn(
|
||||
'anilist.characterDictionary.profileScope',
|
||||
characterDictionary.profileScope,
|
||||
resolved.anilist.characterDictionary.profileScope,
|
||||
"Expected one of: 'all', 'active'.",
|
||||
);
|
||||
}
|
||||
} else if (characterDictionary.profileScope !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary.profileScope',
|
||||
characterDictionary.profileScope,
|
||||
resolved.anilist.characterDictionary.profileScope,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(characterDictionary.collapsibleSections)) {
|
||||
const collapsibleSections = characterDictionary.collapsibleSections;
|
||||
const keys = ['description', 'characterInformation', 'voicedBy'] as const;
|
||||
for (const key of keys) {
|
||||
const value = asBoolean(collapsibleSections[key]);
|
||||
if (value !== undefined) {
|
||||
resolved.anilist.characterDictionary.collapsibleSections[key] = value;
|
||||
} else if (collapsibleSections[key] !== undefined) {
|
||||
warn(
|
||||
`anilist.characterDictionary.collapsibleSections.${key}`,
|
||||
collapsibleSections[key],
|
||||
resolved.anilist.characterDictionary.collapsibleSections[key],
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (characterDictionary.collapsibleSections !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary.collapsibleSections',
|
||||
characterDictionary.collapsibleSections,
|
||||
resolved.anilist.characterDictionary.collapsibleSections,
|
||||
'Expected object.',
|
||||
);
|
||||
}
|
||||
} else if (src.anilist.characterDictionary !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary',
|
||||
src.anilist.characterDictionary,
|
||||
resolved.anilist.characterDictionary,
|
||||
'Expected object.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.jellyfin)) {
|
||||
|
||||
@@ -62,3 +62,45 @@ test('discordPresence invalid values warn and keep defaults', () => {
|
||||
assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs'));
|
||||
assert.ok(warnedPaths.includes('discordPresence.debounceMs'));
|
||||
});
|
||||
|
||||
test('anilist character dictionary fields are parsed, clamped, and enum-validated', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
anilist: {
|
||||
characterDictionary: {
|
||||
enabled: true,
|
||||
refreshTtlHours: 0,
|
||||
maxLoaded: 99,
|
||||
evictionPolicy: 'purge' as never,
|
||||
profileScope: 'global' as never,
|
||||
collapsibleSections: {
|
||||
description: true,
|
||||
characterInformation: 'invalid' as never,
|
||||
voicedBy: true,
|
||||
} as never,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
applyIntegrationConfig(context);
|
||||
|
||||
assert.equal(context.resolved.anilist.characterDictionary.enabled, true);
|
||||
assert.equal(context.resolved.anilist.characterDictionary.refreshTtlHours, 1);
|
||||
assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20);
|
||||
assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||
assert.equal(context.resolved.anilist.characterDictionary.profileScope, 'all');
|
||||
assert.equal(context.resolved.anilist.characterDictionary.collapsibleSections.description, true);
|
||||
assert.equal(
|
||||
context.resolved.anilist.characterDictionary.collapsibleSections.characterInformation,
|
||||
false,
|
||||
);
|
||||
assert.equal(context.resolved.anilist.characterDictionary.collapsibleSections.voicedBy, true);
|
||||
|
||||
const warnedPaths = warnings.map((warning) => warning.path);
|
||||
assert.ok(warnedPaths.includes('anilist.characterDictionary.refreshTtlHours'));
|
||||
assert.ok(warnedPaths.includes('anilist.characterDictionary.maxLoaded'));
|
||||
assert.ok(warnedPaths.includes('anilist.characterDictionary.evictionPolicy'));
|
||||
assert.ok(warnedPaths.includes('anilist.characterDictionary.profileScope'));
|
||||
assert.ok(
|
||||
warnedPaths.includes('anilist.characterDictionary.collapsibleSections.characterInformation'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,6 +105,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
||||
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
||||
const fallbackFrequencyDictionary = {
|
||||
...resolved.subtitleStyle.frequencyDictionary,
|
||||
};
|
||||
@@ -228,6 +230,38 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const nameMatchColor = asColor(
|
||||
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
|
||||
);
|
||||
const nameMatchEnabled = asBoolean(
|
||||
(src.subtitleStyle as { nameMatchEnabled?: unknown }).nameMatchEnabled,
|
||||
);
|
||||
if (nameMatchEnabled !== undefined) {
|
||||
resolved.subtitleStyle.nameMatchEnabled = nameMatchEnabled;
|
||||
} else if (
|
||||
(src.subtitleStyle as { nameMatchEnabled?: unknown }).nameMatchEnabled !== undefined
|
||||
) {
|
||||
resolved.subtitleStyle.nameMatchEnabled = fallbackSubtitleStyleNameMatchEnabled;
|
||||
warn(
|
||||
'subtitleStyle.nameMatchEnabled',
|
||||
(src.subtitleStyle as { nameMatchEnabled?: unknown }).nameMatchEnabled,
|
||||
resolved.subtitleStyle.nameMatchEnabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
if (nameMatchColor !== undefined) {
|
||||
resolved.subtitleStyle.nameMatchColor = nameMatchColor;
|
||||
} else if ((src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor !== undefined) {
|
||||
resolved.subtitleStyle.nameMatchColor = fallbackSubtitleStyleNameMatchColor;
|
||||
warn(
|
||||
'subtitleStyle.nameMatchColor',
|
||||
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
|
||||
resolved.subtitleStyle.nameMatchColor,
|
||||
'Expected hex color.',
|
||||
);
|
||||
}
|
||||
|
||||
const frequencyDictionary = isObject(
|
||||
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
|
||||
)
|
||||
|
||||
@@ -66,6 +66,70 @@ test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', (
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nameMatchEnabled: 'invalid' as unknown as boolean,
|
||||
},
|
||||
});
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, true);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.nameMatchEnabled' &&
|
||||
warning.message === 'Expected boolean.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle frequencyDictionary defaults to the teal fourth band color', () => {
|
||||
const { context } = createResolveContext({});
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.deepEqual(context.resolved.subtitleStyle.frequencyDictionary.bandedColors, [
|
||||
'#ed8796',
|
||||
'#f5a97f',
|
||||
'#f9e2af',
|
||||
'#8bd5ca',
|
||||
'#8aadf4',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitleStyle nameMatchColor accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nameMatchColor: '#f5bde6',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.equal(
|
||||
(valid.context.resolved.subtitleStyle as { nameMatchColor?: string }).nameMatchColor,
|
||||
'#f5bde6',
|
||||
);
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nameMatchColor: 'pink',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.equal(
|
||||
(invalid.context.resolved.subtitleStyle as { nameMatchColor?: string }).nameMatchColor,
|
||||
'#f5bde6',
|
||||
);
|
||||
assert.ok(
|
||||
invalid.warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.nameMatchColor' &&
|
||||
warning.message === 'Expected hex color.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as childProcess from 'child_process';
|
||||
|
||||
import { guessAnilistMediaInfo, updateAnilistPostWatchProgress } from './anilist-updater';
|
||||
|
||||
@@ -12,67 +11,27 @@ function createJsonResponse(payload: unknown): Response {
|
||||
}
|
||||
|
||||
test('guessAnilistMediaInfo uses guessit output when available', async () => {
|
||||
const originalExecFile = childProcess.execFile;
|
||||
(
|
||||
childProcess as unknown as {
|
||||
execFile: typeof childProcess.execFile;
|
||||
}
|
||||
).execFile = ((...args: unknown[]) => {
|
||||
const callback = args[args.length - 1];
|
||||
const cb =
|
||||
typeof callback === 'function'
|
||||
? (callback as (error: Error | null, stdout: string, stderr: string) => void)
|
||||
: null;
|
||||
cb?.(null, JSON.stringify({ title: 'Guessit Title', episode: 7 }), '');
|
||||
return {} as childProcess.ChildProcess;
|
||||
}) as typeof childProcess.execFile;
|
||||
|
||||
try {
|
||||
const result = await guessAnilistMediaInfo('/tmp/demo.mkv', null);
|
||||
assert.deepEqual(result, {
|
||||
title: 'Guessit Title',
|
||||
episode: 7,
|
||||
source: 'guessit',
|
||||
});
|
||||
} finally {
|
||||
(
|
||||
childProcess as unknown as {
|
||||
execFile: typeof childProcess.execFile;
|
||||
}
|
||||
).execFile = originalExecFile;
|
||||
}
|
||||
const result = await guessAnilistMediaInfo('/tmp/demo.mkv', null, {
|
||||
runGuessit: async () => JSON.stringify({ title: 'Guessit Title', episode: 7 }),
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
title: 'Guessit Title',
|
||||
episode: 7,
|
||||
source: 'guessit',
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo falls back to parser when guessit fails', async () => {
|
||||
const originalExecFile = childProcess.execFile;
|
||||
(
|
||||
childProcess as unknown as {
|
||||
execFile: typeof childProcess.execFile;
|
||||
}
|
||||
).execFile = ((...args: unknown[]) => {
|
||||
const callback = args[args.length - 1];
|
||||
const cb =
|
||||
typeof callback === 'function'
|
||||
? (callback as (error: Error | null, stdout: string, stderr: string) => void)
|
||||
: null;
|
||||
cb?.(new Error('guessit not found'), '', '');
|
||||
return {} as childProcess.ChildProcess;
|
||||
}) as typeof childProcess.execFile;
|
||||
|
||||
try {
|
||||
const result = await guessAnilistMediaInfo('/tmp/My Anime S01E03.mkv', null);
|
||||
assert.deepEqual(result, {
|
||||
title: 'My Anime',
|
||||
episode: 3,
|
||||
source: 'fallback',
|
||||
});
|
||||
} finally {
|
||||
(
|
||||
childProcess as unknown as {
|
||||
execFile: typeof childProcess.execFile;
|
||||
}
|
||||
).execFile = originalExecFile;
|
||||
}
|
||||
const result = await guessAnilistMediaInfo('/tmp/My Anime S01E03.mkv', null, {
|
||||
runGuessit: async () => {
|
||||
throw new Error('guessit not found');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
title: 'My Anime',
|
||||
episode: 3,
|
||||
source: 'fallback',
|
||||
});
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress updates progress when behind', async () => {
|
||||
|
||||
@@ -72,6 +72,10 @@ function runGuessit(target: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
type GuessAnilistMediaInfoDeps = {
|
||||
runGuessit: (target: string) => Promise<string>;
|
||||
};
|
||||
|
||||
function firstString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
@@ -177,12 +181,13 @@ function pickBestSearchResult(
|
||||
export async function guessAnilistMediaInfo(
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
deps: GuessAnilistMediaInfoDeps = { runGuessit },
|
||||
): Promise<AnilistMediaGuess | null> {
|
||||
const target = mediaPath ?? mediaTitle;
|
||||
|
||||
if (target && target.trim().length > 0) {
|
||||
try {
|
||||
const stdout = await runGuessit(target);
|
||||
const stdout = await deps.runGuessit(target);
|
||||
const parsed = JSON.parse(stdout) as Record<string, unknown>;
|
||||
const title = firstString(parsed.title);
|
||||
const episode = firstPositiveInteger(parsed.episode);
|
||||
|
||||
@@ -11,6 +11,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
@@ -30,6 +31,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
|
||||
@@ -4,7 +4,8 @@ import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
|
||||
|
||||
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
const deps: AppReadyRuntimeDeps = {
|
||||
const deps = {
|
||||
ensureDefaultConfigBootstrap: () => calls.push('ensureDefaultConfigBootstrap'),
|
||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
||||
resolveKeybindings: () => calls.push('resolveKeybindings'),
|
||||
createMpvClient: () => calls.push('createMpvClient'),
|
||||
@@ -20,8 +21,13 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`),
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 9001,
|
||||
defaultAnnotationWebsocketPort: 6678,
|
||||
defaultTexthookerPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => true,
|
||||
startSubtitleWebsocket: (port) => calls.push(`startSubtitleWebsocket:${port}`),
|
||||
startAnnotationWebsocket: (port) => calls.push(`startAnnotationWebsocket:${port}`),
|
||||
startTexthooker: (port, websocketUrl) =>
|
||||
calls.push(`startTexthooker:${port}:${websocketUrl ?? ''}`),
|
||||
log: (message) => calls.push(`log:${message}`),
|
||||
createMecabTokenizerAndCheck: async () => {
|
||||
calls.push('createMecabTokenizerAndCheck');
|
||||
@@ -34,6 +40,9 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('loadYomitanExtension');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('handleFirstRunSetup');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {
|
||||
calls.push('prewarmSubtitleDictionaries');
|
||||
},
|
||||
@@ -42,12 +51,13 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`setVisibleOverlayVisible:${visible}`),
|
||||
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
|
||||
handleInitialArgs: () => calls.push('handleInitialArgs'),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
now: () => 1000,
|
||||
...overrides,
|
||||
};
|
||||
} as AppReadyRuntimeDeps;
|
||||
return { deps, calls };
|
||||
}
|
||||
|
||||
@@ -56,8 +66,14 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
});
|
||||
await runAppReadyRuntime(deps);
|
||||
assert.ok(calls.includes('ensureDefaultConfigBootstrap'));
|
||||
assert.ok(calls.includes('startSubtitleWebsocket:9001'));
|
||||
assert.ok(calls.includes('startAnnotationWebsocket:6678'));
|
||||
assert.ok(calls.includes('setVisibleOverlayVisible:true'));
|
||||
assert.ok(calls.includes('initializeOverlayRuntime'));
|
||||
assert.ok(
|
||||
calls.indexOf('setVisibleOverlayVisible:true') < calls.indexOf('initializeOverlayRuntime'),
|
||||
);
|
||||
assert.ok(calls.includes('startBackgroundWarmups'));
|
||||
assert.ok(
|
||||
calls.includes(
|
||||
@@ -66,6 +82,46 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
|
||||
);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime starts texthooker on startup when enabled in config', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
websocket: { enabled: 'auto' },
|
||||
secondarySub: {},
|
||||
texthooker: { launchAtStartup: true },
|
||||
}),
|
||||
});
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.ok(calls.includes('startTexthooker:5174:ws://127.0.0.1:6678'));
|
||||
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
|
||||
assert.ok(
|
||||
calls.indexOf('createMpvClient') < calls.indexOf('startTexthooker:5174:ws://127.0.0.1:6678'),
|
||||
);
|
||||
assert.ok(
|
||||
calls.indexOf('startTexthooker:5174:ws://127.0.0.1:6678') < calls.indexOf('handleInitialArgs'),
|
||||
);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
websocket: { enabled: 'auto' },
|
||||
annotationWebsocket: { enabled: true, port: 6678 },
|
||||
secondarySub: {},
|
||||
texthooker: { launchAtStartup: true },
|
||||
}),
|
||||
hasMpvWebsocketPlugin: () => true,
|
||||
});
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.equal(calls.includes('startSubtitleWebsocket:9001'), false);
|
||||
assert.ok(calls.includes('startAnnotationWebsocket:6678'));
|
||||
assert.ok(calls.includes('startTexthooker:5174:ws://127.0.0.1:6678'));
|
||||
assert.ok(calls.includes('log:mpv_websocket detected, skipping built-in WebSocket server'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns true', async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
shouldSkipHeavyStartup: () => true,
|
||||
@@ -97,6 +153,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.equal(calls.includes('ensureDefaultConfigBootstrap'), true);
|
||||
assert.equal(calls.includes('reloadConfig'), false);
|
||||
assert.equal(calls.includes('getResolvedConfig'), false);
|
||||
assert.equal(calls.includes('getConfigWarnings'), false);
|
||||
@@ -111,7 +168,10 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
||||
assert.equal(calls.includes('logConfigWarning'), false);
|
||||
assert.equal(calls.includes('handleInitialArgs'), true);
|
||||
assert.equal(calls.includes('loadYomitanExtension'), true);
|
||||
assert.equal(calls.includes('handleFirstRunSetup'), true);
|
||||
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
|
||||
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleFirstRunSetup'));
|
||||
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
@@ -30,6 +31,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
@@ -95,6 +97,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
openFirstRunSetup: () => {
|
||||
calls.push('openFirstRunSetup');
|
||||
},
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
calls.push(`setVisibleOverlayVisible:${visible}`);
|
||||
},
|
||||
@@ -163,6 +168,13 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
calls.push('retryAnilistQueue');
|
||||
return { ok: true, message: 'AniList retry processed.' };
|
||||
},
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/anilist-1.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 10,
|
||||
}),
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('runJellyfinCommand');
|
||||
},
|
||||
@@ -221,6 +233,16 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand opens first-run setup window for --setup', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
handleCliCommand(makeArgs({ setup: true }), 'initial', deps);
|
||||
|
||||
assert.ok(calls.includes('openFirstRunSetup'));
|
||||
assert.ok(calls.includes('log:Opened first-run setup flow.'));
|
||||
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
|
||||
});
|
||||
|
||||
test('handleCliCommand applies cli log level for second-instance commands', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
setLogLevel: (level) => {
|
||||
@@ -396,6 +418,52 @@ test('handleCliCommand runs AniList retry command', async () => {
|
||||
assert.ok(calls.includes('log:AniList retry processed.'));
|
||||
});
|
||||
|
||||
test('handleCliCommand runs dictionary generation command', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
hasMainWindow: () => false,
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/anilist-9253.zip',
|
||||
fromCache: true,
|
||||
mediaId: 9253,
|
||||
mediaTitle: 'STEINS;GATE',
|
||||
entryCount: 314,
|
||||
}),
|
||||
});
|
||||
handleCliCommand(makeArgs({ dictionary: true }), 'initial', deps);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.ok(calls.includes('log:Generating character dictionary for current anime...'));
|
||||
assert.ok(
|
||||
calls.includes('log:Character dictionary cache hit: AniList 9253 (STEINS;GATE), entries=314'),
|
||||
);
|
||||
assert.ok(calls.includes('log:Dictionary ZIP: /tmp/anilist-9253.zip'));
|
||||
assert.ok(calls.includes('stopApp'));
|
||||
});
|
||||
|
||||
test('handleCliCommand forwards --dictionary-target to dictionary runtime', async () => {
|
||||
let receivedTarget: string | undefined;
|
||||
const { deps } = createDeps({
|
||||
generateCharacterDictionary: async (targetPath?: string) => {
|
||||
receivedTarget = targetPath;
|
||||
return {
|
||||
zipPath: '/tmp/anilist-100.zip',
|
||||
fromCache: false,
|
||||
mediaId: 100,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(
|
||||
makeArgs({ dictionary: true, dictionaryTarget: '/tmp/example-video.mkv' }),
|
||||
'initial',
|
||||
deps,
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(receivedTarget, '/tmp/example-video.mkv');
|
||||
});
|
||||
|
||||
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
|
||||
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
|
||||
{ start: true },
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface CliCommandServiceDeps {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -53,6 +54,13 @@ export interface CliCommandServiceDeps {
|
||||
lastError: string | null;
|
||||
};
|
||||
retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>;
|
||||
generateCharacterDictionary: (targetPath?: string) => Promise<{
|
||||
zipPath: string;
|
||||
fromCache: boolean;
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
}>;
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
printHelp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
@@ -108,6 +116,7 @@ interface MiningCliRuntime {
|
||||
}
|
||||
|
||||
interface UiCliRuntime {
|
||||
openFirstRunSetup: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -134,6 +143,15 @@ export interface CliCommandDepsRuntimeOptions {
|
||||
overlay: OverlayCliRuntime;
|
||||
mining: MiningCliRuntime;
|
||||
anilist: AnilistCliRuntime;
|
||||
dictionary: {
|
||||
generate: (targetPath?: string) => Promise<{
|
||||
zipPath: string;
|
||||
fromCache: boolean;
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
}>;
|
||||
};
|
||||
jellyfin: {
|
||||
openSetup: () => void;
|
||||
runCommand: (args: CliArgs) => Promise<void>;
|
||||
@@ -179,6 +197,7 @@ export function createCliCommandDepsRuntime(
|
||||
isOverlayRuntimeInitialized: options.overlay.isInitialized,
|
||||
initializeOverlayRuntime: options.overlay.initialize,
|
||||
toggleVisibleOverlay: options.overlay.toggleVisible,
|
||||
openFirstRunSetup: options.ui.openFirstRunSetup,
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
options.schedule(() => {
|
||||
options.ui.openYomitanSettings();
|
||||
@@ -202,6 +221,7 @@ export function createCliCommandDepsRuntime(
|
||||
openJellyfinSetup: options.jellyfin.openSetup,
|
||||
getAnilistQueueStatus: options.anilist.getQueueStatus,
|
||||
retryAnilistQueue: options.anilist.retryQueueNow,
|
||||
generateCharacterDictionary: options.dictionary.generate,
|
||||
runJellyfinCommand: options.jellyfin.runCommand,
|
||||
printHelp: options.ui.printHelp,
|
||||
hasMainWindow: options.app.hasMainWindow,
|
||||
@@ -239,51 +259,10 @@ export function handleCliCommand(
|
||||
deps.setLogLevel?.(args.logLevel);
|
||||
}
|
||||
|
||||
const hasNonStartAction =
|
||||
args.stop ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.settings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
args.hideVisibleOverlay ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.mineSentence ||
|
||||
args.mineSentenceMultiple ||
|
||||
args.updateLastCardFromClipboard ||
|
||||
args.refreshKnownWords ||
|
||||
args.toggleSecondarySub ||
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.openRuntimeOptions ||
|
||||
args.anilistStatus ||
|
||||
args.anilistLogout ||
|
||||
args.anilistSetup ||
|
||||
args.anilistRetryQueue ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinLogin ||
|
||||
args.jellyfinLogout ||
|
||||
args.jellyfinLibraries ||
|
||||
args.jellyfinItems ||
|
||||
args.jellyfinSubtitles ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.texthooker ||
|
||||
args.help;
|
||||
const ignoreStartOnly =
|
||||
source === 'second-instance' &&
|
||||
args.start &&
|
||||
!hasNonStartAction &&
|
||||
deps.isOverlayRuntimeInitialized();
|
||||
if (ignoreStartOnly) {
|
||||
deps.log('Ignoring --start because SubMiner is already running.');
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay;
|
||||
const ignoreSecondInstanceStart =
|
||||
source === 'second-instance' && args.start && deps.isOverlayRuntimeInitialized();
|
||||
const shouldStart =
|
||||
(!ignoreSecondInstanceStart && args.start) || args.toggle || args.toggleVisibleOverlay;
|
||||
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
|
||||
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
|
||||
|
||||
@@ -306,6 +285,10 @@ export function handleCliCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ignoreSecondInstanceStart) {
|
||||
deps.log('Ignoring --start because SubMiner is already running.');
|
||||
}
|
||||
|
||||
if (shouldInitializeOverlayRuntime && !deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
@@ -319,6 +302,9 @@ export function handleCliCommand(
|
||||
|
||||
if (args.toggle || args.toggleVisibleOverlay) {
|
||||
deps.toggleVisibleOverlay();
|
||||
} else if (args.setup) {
|
||||
deps.openFirstRunSetup();
|
||||
deps.log('Opened first-run setup flow.');
|
||||
} else if (args.settings) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.show || args.showVisibleOverlay) {
|
||||
@@ -402,6 +388,29 @@ export function handleCliCommand(
|
||||
} else if (args.jellyfin) {
|
||||
deps.openJellyfinSetup();
|
||||
deps.log('Opened Jellyfin setup flow.');
|
||||
} else if (args.dictionary) {
|
||||
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
||||
deps.log('Generating character dictionary for current anime...');
|
||||
deps
|
||||
.generateCharacterDictionary(args.dictionaryTarget)
|
||||
.then((result) => {
|
||||
const cacheLabel = result.fromCache ? 'cache hit' : 'generated';
|
||||
deps.log(
|
||||
`Character dictionary ${cacheLabel}: AniList ${result.mediaId} (${result.mediaTitle}), entries=${result.entryCount}`,
|
||||
);
|
||||
deps.log(`Dictionary ZIP: ${result.zipPath}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
deps.error('generateCharacterDictionary failed:', error);
|
||||
deps.warn(
|
||||
`Dictionary generation failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (shouldStopAfterRun) {
|
||||
deps.stopApp();
|
||||
}
|
||||
});
|
||||
} else if (args.anilistRetryQueue) {
|
||||
const queueStatus = deps.getAnilistQueueStatus();
|
||||
deps.log(
|
||||
|
||||
@@ -27,6 +27,12 @@ const DatabaseSync: DatabaseSyncCtor | null = (() => {
|
||||
})();
|
||||
const testIfSqlite = DatabaseSync ? test : test.skip;
|
||||
|
||||
if (!DatabaseSync) {
|
||||
console.warn(
|
||||
'Skipping SQLite-backed immersion tracker persistence tests in this runtime; run `bun run test:immersion:sqlite` for real DB coverage.',
|
||||
);
|
||||
}
|
||||
|
||||
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
|
||||
|
||||
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
|
||||
|
||||
@@ -23,6 +23,12 @@ const DatabaseSync: DatabaseSyncCtor | null = (() => {
|
||||
})();
|
||||
const testIfSqlite = DatabaseSync ? test : test.skip;
|
||||
|
||||
if (!DatabaseSync) {
|
||||
console.warn(
|
||||
'Skipping SQLite-backed immersion tracker storage/session tests in this runtime; run `bun run test:immersion:sqlite` for real DB coverage.',
|
||||
);
|
||||
}
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-storage-session-'));
|
||||
return path.join(dir, 'immersion.sqlite');
|
||||
|
||||
@@ -315,7 +315,7 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
lookup_hits, pause_count, pause_ms, seek_forward_count,
|
||||
seek_backward_count, media_buffer_events, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`),
|
||||
eventInsertStmt: db.prepare(`
|
||||
@@ -323,7 +323,7 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms,
|
||||
words_delta, cards_delta, payload_json, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`),
|
||||
wordUpsertStmt: db.prepare(`
|
||||
|
||||
@@ -30,6 +30,15 @@ export {
|
||||
export { openYomitanSettingsWindow } from './yomitan-settings';
|
||||
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
|
||||
export { clearYomitanParserCachesForWindow } from './tokenizer/yomitan-parser-runtime';
|
||||
export {
|
||||
deleteYomitanDictionaryByTitle,
|
||||
getYomitanDictionaryInfo,
|
||||
getYomitanSettingsFull,
|
||||
importYomitanDictionaryFromZip,
|
||||
removeYomitanDictionarySettings,
|
||||
setYomitanSettingsFull,
|
||||
upsertYomitanDictionarySettings,
|
||||
} from './tokenizer/yomitan-parser-runtime';
|
||||
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
|
||||
export { createSubtitleProcessingController } from './subtitle-processing-controller';
|
||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||
|
||||
@@ -38,6 +38,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
mpvSendCommand: (command) => {
|
||||
sentCommands.push(command);
|
||||
},
|
||||
resolveProxyCommandOsd: async () => null,
|
||||
isMpvConnected: () => true,
|
||||
hasRuntimeOptionsManager: () => true,
|
||||
...overrides,
|
||||
@@ -52,30 +53,39 @@ test('handleMpvCommandFromIpc forwards regular mpv commands', () => {
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for subtitle position keybinding proxies', () => {
|
||||
test('handleMpvCommandFromIpc emits osd for subtitle position keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['add', 'sub-pos', 1], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['add', 'sub-pos', 1]]);
|
||||
assert.deepEqual(osd, ['Subtitle position: ${sub-pos}']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for primary subtitle track keybinding proxies', () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
test('handleMpvCommandFromIpc emits resolved osd for primary subtitle track keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions({
|
||||
resolveProxyCommandOsd: async () => 'Subtitle track: Internal #3 - Japanese (active)',
|
||||
});
|
||||
handleMpvCommandFromIpc(['cycle', 'sid'], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['cycle', 'sid']]);
|
||||
assert.deepEqual(osd, ['Subtitle track: ${sid}']);
|
||||
assert.deepEqual(osd, ['Subtitle track: Internal #3 - Japanese (active)']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for secondary subtitle track keybinding proxies', () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
test('handleMpvCommandFromIpc emits resolved osd for secondary subtitle track keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions({
|
||||
resolveProxyCommandOsd: async () =>
|
||||
'Secondary subtitle track: External #8 - English Commentary',
|
||||
});
|
||||
handleMpvCommandFromIpc(['set_property', 'secondary-sid', 'auto'], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['set_property', 'secondary-sid', 'auto']]);
|
||||
assert.deepEqual(osd, ['Secondary subtitle track: ${secondary-sid}']);
|
||||
assert.deepEqual(osd, ['Secondary subtitle track: External #8 - English Commentary']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for subtitle delay keybinding proxies', () => {
|
||||
test('handleMpvCommandFromIpc emits osd for subtitle delay keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
|
||||
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
mpvPlayNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
mpvSendCommand: (command: (string | number)[]) => void;
|
||||
resolveProxyCommandOsd?: (command: (string | number)[]) => Promise<string | null>;
|
||||
isMpvConnected: () => boolean;
|
||||
hasRuntimeOptionsManager: () => boolean;
|
||||
}
|
||||
@@ -36,7 +37,7 @@ const MPV_PROPERTY_COMMANDS = new Set([
|
||||
'multiply',
|
||||
]);
|
||||
|
||||
function resolveProxyCommandOsd(command: (string | number)[]): string | null {
|
||||
function resolveProxyCommandOsdTemplate(command: (string | number)[]): string | null {
|
||||
const operation = typeof command[0] === 'string' ? command[0] : '';
|
||||
const property = typeof command[1] === 'string' ? command[1] : '';
|
||||
if (!MPV_PROPERTY_COMMANDS.has(operation)) return null;
|
||||
@@ -55,6 +56,25 @@ function resolveProxyCommandOsd(command: (string | number)[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function showResolvedProxyCommandOsd(
|
||||
command: (string | number)[],
|
||||
options: HandleMpvCommandFromIpcOptions,
|
||||
): void {
|
||||
const template = resolveProxyCommandOsdTemplate(command);
|
||||
if (!template) return;
|
||||
|
||||
const emit = async () => {
|
||||
try {
|
||||
const resolved = await options.resolveProxyCommandOsd?.(command);
|
||||
options.showMpvOsd(resolved || template);
|
||||
} catch {
|
||||
options.showMpvOsd(template);
|
||||
}
|
||||
};
|
||||
|
||||
void emit();
|
||||
}
|
||||
|
||||
export function handleMpvCommandFromIpc(
|
||||
command: (string | number)[],
|
||||
options: HandleMpvCommandFromIpcOptions,
|
||||
@@ -103,10 +123,7 @@ export function handleMpvCommandFromIpc(
|
||||
options.mpvPlayNextSubtitle();
|
||||
} else {
|
||||
options.mpvSendCommand(command);
|
||||
const osd = resolveProxyCommandOsd(command);
|
||||
if (osd) {
|
||||
options.showMpvOsd(osd);
|
||||
}
|
||||
showResolvedProxyCommandOsd(command, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,22 @@ test('showMpvOsdRuntime sends show-text when connected', () => {
|
||||
assert.deepEqual(commands, [['show-text', 'hello', '3000']]);
|
||||
});
|
||||
|
||||
test('showMpvOsdRuntime enables property expansion for placeholder-based messages', () => {
|
||||
const commands: (string | number)[][] = [];
|
||||
showMpvOsdRuntime(
|
||||
{
|
||||
connected: true,
|
||||
send: ({ command }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
},
|
||||
'Subtitle delay: ${sub-delay}',
|
||||
);
|
||||
assert.deepEqual(commands, [
|
||||
['expand-properties', 'show-text', 'Subtitle delay: ${sub-delay}', '3000'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('showMpvOsdRuntime logs fallback when disconnected', () => {
|
||||
const logs: string[] = [];
|
||||
showMpvOsdRuntime(
|
||||
|
||||
@@ -453,3 +453,46 @@ test('MpvIpcClient updates current audio stream index from track list', async ()
|
||||
|
||||
assert.equal(client.currentAudioStreamIndex, 11);
|
||||
});
|
||||
|
||||
test('MpvIpcClient playNextSubtitle preserves a manual paused state', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
(client as any).send = (payload: unknown) => {
|
||||
commands.push(payload);
|
||||
return true;
|
||||
};
|
||||
(client as any).pendingPauseAtSubEnd = true;
|
||||
(client as any).pauseAtTime = 42;
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'pause',
|
||||
data: true,
|
||||
});
|
||||
|
||||
client.playNextSubtitle();
|
||||
|
||||
assert.equal((client as any).pendingPauseAtSubEnd, false);
|
||||
assert.equal((client as any).pauseAtTime, null);
|
||||
assert.deepEqual(commands, [{ command: ['sub-seek', 1] }]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient playNextSubtitle still auto-pauses at end while already playing', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
(client as any).send = (payload: unknown) => {
|
||||
commands.push(payload);
|
||||
return true;
|
||||
};
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'pause',
|
||||
data: false,
|
||||
});
|
||||
|
||||
client.playNextSubtitle();
|
||||
|
||||
assert.equal((client as any).pendingPauseAtSubEnd, true);
|
||||
assert.deepEqual(commands, [{ command: ['sub-seek', 1] }]);
|
||||
});
|
||||
|
||||
@@ -53,7 +53,10 @@ export function showMpvOsdRuntime(
|
||||
fallbackLog: (text: string) => void = (line) => logger.info(line),
|
||||
): void {
|
||||
if (mpvClient && mpvClient.connected) {
|
||||
mpvClient.send({ command: ['show-text', text, '3000'] });
|
||||
const command = text.includes('${')
|
||||
? ['expand-properties', 'show-text', text, '3000']
|
||||
: ['show-text', text, '3000'];
|
||||
mpvClient.send({ command });
|
||||
return;
|
||||
}
|
||||
fallbackLog(`OSD (MPV not connected): ${text}`);
|
||||
@@ -161,6 +164,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
osdDimensions: null,
|
||||
};
|
||||
private previousSecondarySubVisibility: boolean | null = null;
|
||||
private playbackPaused: boolean | null = null;
|
||||
private pauseAtTime: number | null = null;
|
||||
private pendingPauseAtSubEnd = false;
|
||||
private nextDynamicRequestId = 1000;
|
||||
@@ -207,6 +211,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.socket = null;
|
||||
this.playbackPaused = null;
|
||||
this.emit('connection-change', { connected: false });
|
||||
this.failPendingRequests();
|
||||
this.scheduleReconnect();
|
||||
@@ -310,6 +315,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.emit('time-pos-change', payload);
|
||||
},
|
||||
emitPauseChange: (payload) => {
|
||||
this.playbackPaused = payload.paused;
|
||||
this.emit('pause-change', payload);
|
||||
},
|
||||
emitSecondarySubtitleChange: (payload) => {
|
||||
@@ -492,6 +498,12 @@ export class MpvIpcClient implements MpvClient {
|
||||
}
|
||||
|
||||
playNextSubtitle(): void {
|
||||
if (this.playbackPaused === true) {
|
||||
this.pendingPauseAtSubEnd = false;
|
||||
this.pauseAtTime = null;
|
||||
this.send({ command: ['sub-seek', 1] });
|
||||
return;
|
||||
}
|
||||
this.pendingPauseAtSubEnd = true;
|
||||
this.send({ command: ['sub-seek', 1] });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
@@ -30,6 +31,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
|
||||
@@ -69,6 +69,13 @@ export function runStartupBootstrapRuntime(
|
||||
}
|
||||
|
||||
interface AppReadyConfigLike {
|
||||
annotationWebsocket?: {
|
||||
enabled?: boolean;
|
||||
port?: number;
|
||||
};
|
||||
texthooker?: {
|
||||
launchAtStartup?: boolean;
|
||||
};
|
||||
secondarySub?: {
|
||||
defaultMode?: SecondarySubMode;
|
||||
};
|
||||
@@ -92,6 +99,7 @@ interface AppReadyConfigLike {
|
||||
}
|
||||
|
||||
export interface AppReadyRuntimeDeps {
|
||||
ensureDefaultConfigBootstrap: () => void;
|
||||
loadSubtitlePosition: () => void;
|
||||
resolveKeybindings: () => void;
|
||||
createMpvClient: () => void;
|
||||
@@ -104,18 +112,24 @@ export interface AppReadyRuntimeDeps {
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
defaultSecondarySubMode: SecondarySubMode;
|
||||
defaultWebsocketPort: number;
|
||||
defaultAnnotationWebsocketPort: number;
|
||||
defaultTexthookerPort: number;
|
||||
hasMpvWebsocketPlugin: () => boolean;
|
||||
startSubtitleWebsocket: (port: number) => void;
|
||||
startAnnotationWebsocket: (port: number) => void;
|
||||
startTexthooker: (port: number, websocketUrl?: string) => void;
|
||||
log: (message: string) => void;
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
createSubtitleTimingTracker: () => void;
|
||||
createImmersionTracker?: () => void;
|
||||
startJellyfinRemoteSession?: () => Promise<void>;
|
||||
loadYomitanExtension: () => Promise<void>;
|
||||
handleFirstRunSetup: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries?: () => Promise<void>;
|
||||
startBackgroundWarmups: () => void;
|
||||
texthookerOnlyMode: boolean;
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
handleInitialArgs: () => void;
|
||||
logDebug?: (message: string) => void;
|
||||
@@ -168,8 +182,10 @@ export function isAutoUpdateEnabledRuntime(
|
||||
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const startupStartedAtMs = now();
|
||||
deps.ensureDefaultConfigBootstrap();
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await deps.loadYomitanExtension();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
return;
|
||||
}
|
||||
@@ -178,6 +194,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await deps.loadYomitanExtension();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
return;
|
||||
@@ -209,6 +226,11 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
const wsConfig = config.websocket || {};
|
||||
const wsEnabled = wsConfig.enabled ?? 'auto';
|
||||
const wsPort = wsConfig.port || deps.defaultWebsocketPort;
|
||||
const annotationWsConfig = config.annotationWebsocket || {};
|
||||
const annotationWsEnabled = annotationWsConfig.enabled !== false;
|
||||
const annotationWsPort = annotationWsConfig.port || deps.defaultAnnotationWebsocketPort;
|
||||
const texthookerPort = deps.defaultTexthookerPort;
|
||||
let texthookerWebsocketUrl: string | undefined;
|
||||
|
||||
if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
|
||||
deps.startSubtitleWebsocket(wsPort);
|
||||
@@ -216,6 +238,17 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
deps.log('mpv_websocket detected, skipping built-in WebSocket server');
|
||||
}
|
||||
|
||||
if (annotationWsEnabled) {
|
||||
deps.startAnnotationWebsocket(annotationWsPort);
|
||||
texthookerWebsocketUrl = `ws://127.0.0.1:${annotationWsPort}`;
|
||||
} else if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
|
||||
texthookerWebsocketUrl = `ws://127.0.0.1:${wsPort}`;
|
||||
}
|
||||
|
||||
if (config.texthooker?.launchAtStartup !== false) {
|
||||
deps.startTexthooker(texthookerPort, texthookerWebsocketUrl);
|
||||
}
|
||||
|
||||
deps.createSubtitleTimingTracker();
|
||||
if (deps.createImmersionTracker) {
|
||||
deps.log('Runtime ready: immersion tracker startup deferred until first media activity.');
|
||||
@@ -226,11 +259,14 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
if (deps.texthookerOnlyMode) {
|
||||
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
||||
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
deps.initializeOverlayRuntime();
|
||||
} else {
|
||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||
}
|
||||
|
||||
await deps.loadYomitanExtension();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
}
|
||||
|
||||
@@ -276,6 +276,59 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
|
||||
assert.equal(outputPath, path.join(tmpDir, 'episode.ja_retimed.srt'));
|
||||
});
|
||||
|
||||
test('runSubsyncManual reports ffsubsync command failures with details', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-failure-'));
|
||||
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
||||
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||
const videoPath = path.join(tmpDir, 'video.mkv');
|
||||
const primaryPath = path.join(tmpDir, 'primary.srt');
|
||||
|
||||
fs.writeFileSync(videoPath, 'video');
|
||||
fs.writeFileSync(primaryPath, 'sub');
|
||||
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
|
||||
writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n');
|
||||
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\necho "reference audio missing" >&2\nexit 1\n');
|
||||
|
||||
const deps = makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: () => {},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'path') return videoPath;
|
||||
if (name === 'sid') return 1;
|
||||
if (name === 'secondary-sid') return null;
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
type: 'sub',
|
||||
selected: true,
|
||||
external: true,
|
||||
'external-filename': primaryPath,
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.message.startsWith('ffsubsync synchronization failed'), true);
|
||||
assert.match(result.message, /code=1/);
|
||||
assert.match(result.message, /reference audio missing/);
|
||||
});
|
||||
|
||||
test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
|
||||
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { serializeSubtitleMarkup, serializeSubtitleWebsocketMessage } from './subtitle-ws';
|
||||
import {
|
||||
serializeInitialSubtitleWebsocketMessage,
|
||||
serializeSubtitleMarkup,
|
||||
serializeSubtitleWebsocketMessage,
|
||||
} from './subtitle-ws';
|
||||
import { PartOfSpeech, type SubtitleData } from '../../types';
|
||||
|
||||
const frequencyOptions = {
|
||||
@@ -78,6 +82,51 @@ test('serializeSubtitleMarkup includes known, n+1, jlpt, and frequency classes',
|
||||
assert.match(markup, /word word-frequency-band-1/);
|
||||
});
|
||||
|
||||
test('serializeSubtitleMarkup preserves tooltip attrs and name-match precedence', () => {
|
||||
const payload: SubtitleData = {
|
||||
text: 'ignored',
|
||||
tokens: [
|
||||
{
|
||||
surface: '無事',
|
||||
reading: 'ぶじ',
|
||||
headword: '無事',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
jlptLevel: 'N2',
|
||||
frequencyRank: 745,
|
||||
},
|
||||
{
|
||||
surface: 'アレクシア',
|
||||
reading: 'あれくしあ',
|
||||
headword: 'アレクシア',
|
||||
startPos: 2,
|
||||
endPos: 7,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
isNameMatch: true,
|
||||
frequencyRank: 12,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const markup = serializeSubtitleMarkup(payload, frequencyOptions);
|
||||
assert.match(
|
||||
markup,
|
||||
/<span class="word word-known word-jlpt-n2" data-reading="ぶじ" data-headword="無事" data-frequency-rank="745" data-jlpt-level="N2">無事<\/span>/,
|
||||
);
|
||||
assert.match(
|
||||
markup,
|
||||
/<span class="word word-name-match" data-reading="あれくしあ" data-headword="アレクシア" data-frequency-rank="12">アレクシア<\/span>/,
|
||||
);
|
||||
assert.doesNotMatch(markup, /word-name-match word-known|word-known word-name-match/);
|
||||
});
|
||||
|
||||
test('serializeSubtitleWebsocketMessage emits sentence payload', () => {
|
||||
const payload: SubtitleData = {
|
||||
text: '字幕',
|
||||
@@ -85,5 +134,101 @@ test('serializeSubtitleWebsocketMessage emits sentence payload', () => {
|
||||
};
|
||||
|
||||
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions);
|
||||
assert.deepEqual(JSON.parse(raw), { sentence: '字幕' });
|
||||
assert.deepEqual(JSON.parse(raw), {
|
||||
version: 1,
|
||||
text: '字幕',
|
||||
sentence: '字幕',
|
||||
tokens: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('serializeSubtitleWebsocketMessage emits structured token api payload', () => {
|
||||
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);
|
||||
assert.deepEqual(JSON.parse(raw), {
|
||||
version: 1,
|
||||
text: '無事',
|
||||
sentence:
|
||||
'<span class="word word-known word-jlpt-n2" data-reading="ぶじ" data-headword="無事" data-frequency-rank="745" data-jlpt-level="N2">無事</span>',
|
||||
tokens: [
|
||||
{
|
||||
surface: '無事',
|
||||
reading: 'ぶじ',
|
||||
headword: '無事',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
isNameMatch: false,
|
||||
jlptLevel: 'N2',
|
||||
frequencyRank: 745,
|
||||
className: 'word word-known word-jlpt-n2',
|
||||
frequencyRankLabel: '745',
|
||||
jlptLevelLabel: 'N2',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('serializeInitialSubtitleWebsocketMessage keeps annotated current subtitle content', () => {
|
||||
const payload: SubtitleData = {
|
||||
text: 'ignored fallback',
|
||||
tokens: [
|
||||
{
|
||||
surface: '既知',
|
||||
reading: '',
|
||||
headword: '',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const raw = serializeInitialSubtitleWebsocketMessage(payload, frequencyOptions);
|
||||
assert.deepEqual(JSON.parse(raw ?? ''), {
|
||||
version: 1,
|
||||
text: 'ignored fallback',
|
||||
sentence: '<span class="word word-known">既知</span>',
|
||||
tokens: [
|
||||
{
|
||||
surface: '既知',
|
||||
reading: '',
|
||||
headword: '',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
isNameMatch: false,
|
||||
className: 'word word-known',
|
||||
frequencyRankLabel: null,
|
||||
jlptLevelLabel: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,26 @@ export type SubtitleWebsocketFrequencyOptions = {
|
||||
mode: 'single' | 'banded';
|
||||
};
|
||||
|
||||
type SerializedSubtitleToken = Pick<
|
||||
MergedToken,
|
||||
| 'surface'
|
||||
| 'reading'
|
||||
| 'headword'
|
||||
| 'startPos'
|
||||
| 'endPos'
|
||||
| 'partOfSpeech'
|
||||
| 'isMerged'
|
||||
| 'isKnown'
|
||||
| 'isNPlusOneTarget'
|
||||
| 'frequencyRank'
|
||||
| 'jlptLevel'
|
||||
> & {
|
||||
isNameMatch: boolean;
|
||||
className: string;
|
||||
frequencyRankLabel: string | null;
|
||||
jlptLevelLabel: string | null;
|
||||
};
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replaceAll('&', '&')
|
||||
@@ -46,11 +66,29 @@ function computeFrequencyClass(
|
||||
return 'word-frequency-single';
|
||||
}
|
||||
|
||||
function getFrequencyRankLabel(
|
||||
token: MergedToken,
|
||||
options: SubtitleWebsocketFrequencyOptions,
|
||||
): string | null {
|
||||
if (!options.enabled) return null;
|
||||
if (typeof token.frequencyRank !== 'number' || !Number.isFinite(token.frequencyRank)) return null;
|
||||
|
||||
const rank = Math.max(1, Math.floor(token.frequencyRank));
|
||||
const topX = Math.max(1, Math.floor(options.topX));
|
||||
return rank <= topX ? String(rank) : null;
|
||||
}
|
||||
|
||||
function getJlptLevelLabel(token: MergedToken): string | null {
|
||||
return token.jlptLevel ?? null;
|
||||
}
|
||||
|
||||
function computeWordClass(token: MergedToken, options: SubtitleWebsocketFrequencyOptions): string {
|
||||
const classes = ['word'];
|
||||
|
||||
if (token.isNPlusOneTarget) {
|
||||
classes.push('word-n-plus-one');
|
||||
} else if (token.isNameMatch) {
|
||||
classes.push('word-name-match');
|
||||
} else if (token.isKnown) {
|
||||
classes.push('word-known');
|
||||
}
|
||||
@@ -59,7 +97,7 @@ function computeWordClass(token: MergedToken, options: SubtitleWebsocketFrequenc
|
||||
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
|
||||
}
|
||||
|
||||
if (!token.isKnown && !token.isNPlusOneTarget) {
|
||||
if (!token.isKnown && !token.isNPlusOneTarget && !token.isNameMatch) {
|
||||
const frequencyClass = computeFrequencyClass(token, options);
|
||||
if (frequencyClass) {
|
||||
classes.push(frequencyClass);
|
||||
@@ -69,6 +107,55 @@ function computeWordClass(token: MergedToken, options: SubtitleWebsocketFrequenc
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
function serializeWordDataAttributes(
|
||||
token: MergedToken,
|
||||
options: SubtitleWebsocketFrequencyOptions,
|
||||
): string {
|
||||
const attributes: string[] = [];
|
||||
|
||||
if (token.reading) {
|
||||
attributes.push(`data-reading="${escapeHtml(token.reading)}"`);
|
||||
}
|
||||
if (token.headword) {
|
||||
attributes.push(`data-headword="${escapeHtml(token.headword)}"`);
|
||||
}
|
||||
|
||||
const frequencyRankLabel = getFrequencyRankLabel(token, options);
|
||||
if (frequencyRankLabel) {
|
||||
attributes.push(`data-frequency-rank="${escapeHtml(frequencyRankLabel)}"`);
|
||||
}
|
||||
|
||||
const jlptLevelLabel = getJlptLevelLabel(token);
|
||||
if (jlptLevelLabel) {
|
||||
attributes.push(`data-jlpt-level="${escapeHtml(jlptLevelLabel)}"`);
|
||||
}
|
||||
|
||||
return attributes.length > 0 ? ` ${attributes.join(' ')}` : '';
|
||||
}
|
||||
|
||||
function serializeSubtitleToken(
|
||||
token: MergedToken,
|
||||
options: SubtitleWebsocketFrequencyOptions,
|
||||
): SerializedSubtitleToken {
|
||||
return {
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
startPos: token.startPos,
|
||||
endPos: token.endPos,
|
||||
partOfSpeech: token.partOfSpeech,
|
||||
isMerged: token.isMerged,
|
||||
isKnown: token.isKnown,
|
||||
isNPlusOneTarget: token.isNPlusOneTarget,
|
||||
isNameMatch: token.isNameMatch ?? false,
|
||||
jlptLevel: token.jlptLevel,
|
||||
frequencyRank: token.frequencyRank,
|
||||
className: computeWordClass(token, options),
|
||||
frequencyRankLabel: getFrequencyRankLabel(token, options),
|
||||
jlptLevelLabel: getJlptLevelLabel(token),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeSubtitleMarkup(
|
||||
payload: SubtitleData,
|
||||
options: SubtitleWebsocketFrequencyOptions,
|
||||
@@ -80,11 +167,12 @@ export function serializeSubtitleMarkup(
|
||||
const chunks: string[] = [];
|
||||
for (const token of payload.tokens) {
|
||||
const klass = computeWordClass(token, options);
|
||||
const attrs = serializeWordDataAttributes(token, options);
|
||||
const parts = token.surface.split('\n');
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
const part = parts[index];
|
||||
if (part) {
|
||||
chunks.push(`<span class="${klass}">${escapeHtml(part)}</span>`);
|
||||
chunks.push(`<span class="${klass}"${attrs}>${escapeHtml(part)}</span>`);
|
||||
}
|
||||
if (index < parts.length - 1) {
|
||||
chunks.push('<br>');
|
||||
@@ -99,7 +187,23 @@ export function serializeSubtitleWebsocketMessage(
|
||||
payload: SubtitleData,
|
||||
options: SubtitleWebsocketFrequencyOptions,
|
||||
): string {
|
||||
return JSON.stringify({ sentence: serializeSubtitleMarkup(payload, options) });
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
text: payload.text,
|
||||
sentence: serializeSubtitleMarkup(payload, options),
|
||||
tokens: payload.tokens?.map((token) => serializeSubtitleToken(token, options)) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeInitialSubtitleWebsocketMessage(
|
||||
payload: SubtitleData | null,
|
||||
options: SubtitleWebsocketFrequencyOptions,
|
||||
): string | null {
|
||||
if (!payload || !payload.text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return serializeSubtitleWebsocketMessage(payload, options);
|
||||
}
|
||||
|
||||
export class SubtitleWebSocket {
|
||||
@@ -114,7 +218,11 @@ export class SubtitleWebSocket {
|
||||
return (this.server?.clients.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
public start(port: number, getCurrentSubtitleText: () => string): void {
|
||||
public start(
|
||||
port: number,
|
||||
getCurrentSubtitleData: () => SubtitleData | null,
|
||||
getFrequencyOptions: () => SubtitleWebsocketFrequencyOptions,
|
||||
): void {
|
||||
this.server = new WebSocket.Server({ port, host: '127.0.0.1' });
|
||||
|
||||
this.server.on('connection', (ws: WebSocket) => {
|
||||
@@ -124,9 +232,12 @@ export class SubtitleWebSocket {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentText = getCurrentSubtitleText();
|
||||
if (currentText) {
|
||||
ws.send(JSON.stringify({ sentence: currentText }));
|
||||
const currentMessage = serializeInitialSubtitleWebsocketMessage(
|
||||
getCurrentSubtitleData(),
|
||||
getFrequencyOptions(),
|
||||
);
|
||||
if (currentMessage) {
|
||||
ws.send(currentMessage);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
27
src/core/services/texthooker.test.ts
Normal file
27
src/core/services/texthooker.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { injectTexthookerBootstrapHtml } from './texthooker';
|
||||
|
||||
test('injectTexthookerBootstrapHtml injects websocket bootstrap before head close', () => {
|
||||
const html = '<html><head><title>Texthooker</title></head><body></body></html>';
|
||||
|
||||
const actual = injectTexthookerBootstrapHtml(html, 'ws://127.0.0.1:6678');
|
||||
|
||||
assert.match(
|
||||
actual,
|
||||
/window\.localStorage\.setItem\('bannou-texthooker-websocketUrl', "ws:\/\/127\.0\.0\.1:6678"\)/,
|
||||
);
|
||||
assert.ok(actual.indexOf('</script></head>') !== -1);
|
||||
assert.ok(actual.includes('bannou-texthooker-websocketUrl'));
|
||||
assert.ok(!actual.includes('bannou-texthooker-enableKnownWordColoring'));
|
||||
assert.ok(!actual.includes('bannou-texthooker-enableNPlusOneColoring'));
|
||||
assert.ok(!actual.includes('bannou-texthooker-enableNameMatchColoring'));
|
||||
assert.ok(!actual.includes('bannou-texthooker-enableFrequencyColoring'));
|
||||
assert.ok(!actual.includes('bannou-texthooker-enableJlptColoring'));
|
||||
});
|
||||
|
||||
test('injectTexthookerBootstrapHtml leaves html unchanged without websocketUrl', () => {
|
||||
const html = '<html><head></head><body></body></html>';
|
||||
|
||||
assert.equal(injectTexthookerBootstrapHtml(html), html);
|
||||
});
|
||||
@@ -5,6 +5,22 @@ import { createLogger } from '../../logger';
|
||||
|
||||
const logger = createLogger('main:texthooker');
|
||||
|
||||
export function injectTexthookerBootstrapHtml(html: string, websocketUrl?: string): string {
|
||||
if (!websocketUrl) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const bootstrapScript = `<script>window.localStorage.setItem('bannou-texthooker-websocketUrl', ${JSON.stringify(
|
||||
websocketUrl,
|
||||
)});</script>`;
|
||||
|
||||
if (html.includes('</head>')) {
|
||||
return html.replace('</head>', `${bootstrapScript}</head>`);
|
||||
}
|
||||
|
||||
return `${bootstrapScript}${html}`;
|
||||
}
|
||||
|
||||
export class Texthooker {
|
||||
private server: http.Server | null = null;
|
||||
|
||||
@@ -12,7 +28,11 @@ export class Texthooker {
|
||||
return this.server !== null;
|
||||
}
|
||||
|
||||
public start(port: number): http.Server | null {
|
||||
public start(port: number, websocketUrl?: string): http.Server | null {
|
||||
if (this.server) {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
const texthookerPath = this.getTexthookerPath();
|
||||
if (!texthookerPath) {
|
||||
logger.error('texthooker-ui not found');
|
||||
@@ -42,8 +62,12 @@ export class Texthooker {
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const responseData =
|
||||
urlPath === '/' || urlPath === '/index.html'
|
||||
? Buffer.from(injectTexthookerBootstrapHtml(data.toString('utf-8'), websocketUrl))
|
||||
: data;
|
||||
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'text/plain' });
|
||||
res.end(data);
|
||||
res.end(responseData);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,31 +24,40 @@ interface YomitanTokenInput {
|
||||
surface: string;
|
||||
reading?: string;
|
||||
headword?: string;
|
||||
isNameMatch?: boolean;
|
||||
}
|
||||
|
||||
function makeDepsFromYomitanTokens(
|
||||
tokens: YomitanTokenInput[],
|
||||
overrides: Partial<TokenizerServiceDeps> = {},
|
||||
): TokenizerServiceDeps {
|
||||
let cursor = 0;
|
||||
return makeDeps({
|
||||
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
|
||||
getYomitanParserWindow: () =>
|
||||
({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async () => [
|
||||
{
|
||||
source: 'scanning-parser',
|
||||
index: 0,
|
||||
content: tokens.map((token) => [
|
||||
{
|
||||
text: token.surface,
|
||||
reading: token.reading ?? token.surface,
|
||||
headwords: [[{ term: token.headword ?? token.surface }]],
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
executeJavaScript: async (script: string) => {
|
||||
if (script.includes('getTermFrequencies')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
cursor = 0;
|
||||
return tokens.map((token) => {
|
||||
const startPos = cursor;
|
||||
const endPos = startPos + token.surface.length;
|
||||
cursor = endPos;
|
||||
return {
|
||||
surface: token.surface,
|
||||
reading: token.reading ?? token.surface,
|
||||
headword: token.headword ?? token.surface,
|
||||
startPos,
|
||||
endPos,
|
||||
isNameMatch: token.isNameMatch ?? false,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
}) as unknown as Electron.BrowserWindow,
|
||||
...overrides,
|
||||
@@ -108,6 +117,20 @@ test('tokenizeSubtitle assigns JLPT level to parsed Yomitan tokens', async () =>
|
||||
assert.equal(result.tokens?.[0]?.jlptLevel, 'N5');
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle preserves Yomitan name-match metadata on tokens', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'アクアです',
|
||||
makeDepsFromYomitanTokens([
|
||||
{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true },
|
||||
{ surface: 'です', reading: 'です', headword: 'です' },
|
||||
]),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 2);
|
||||
assert.equal((result.tokens?.[0] as { isNameMatch?: boolean } | undefined)?.isNameMatch, true);
|
||||
assert.equal((result.tokens?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle caches JLPT lookups across repeated tokens', async () => {
|
||||
let lookupCalls = 0;
|
||||
const result = await tokenizeSubtitle(
|
||||
@@ -182,6 +205,69 @@ test('tokenizeSubtitle applies frequency dictionary ranks', async () => {
|
||||
assert.equal(result.tokens?.[1]?.frequencyRank, 1200);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle uses left-to-right yomitan scanning to keep full katakana name tokens', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'カズマ 魔王軍',
|
||||
makeDeps({
|
||||
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
|
||||
getYomitanParserWindow: () =>
|
||||
({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
if (script.includes('getTermFrequencies')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
surface: 'カズマ',
|
||||
reading: 'かずま',
|
||||
headword: 'カズマ',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
},
|
||||
{
|
||||
surface: '魔王軍',
|
||||
reading: 'まおうぐん',
|
||||
headword: '魔王軍',
|
||||
startPos: 4,
|
||||
endPos: 7,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
}) as unknown as Electron.BrowserWindow,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
result.tokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
startPos: token.startPos,
|
||||
endPos: token.endPos,
|
||||
})),
|
||||
[
|
||||
{
|
||||
surface: 'カズマ',
|
||||
reading: 'かずま',
|
||||
headword: 'カズマ',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
},
|
||||
{
|
||||
surface: '魔王軍',
|
||||
reading: 'まおうぐん',
|
||||
headword: '魔王軍',
|
||||
startPos: 4,
|
||||
endPos: 7,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle loads frequency ranks from Yomitan installed dictionaries', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'猫',
|
||||
@@ -1165,6 +1251,30 @@ test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async
|
||||
assert.equal(result.tokens, null);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle collapses zero-width separators before Yomitan parse request', async () => {
|
||||
let parseInput = '';
|
||||
const result = await tokenizeSubtitle(
|
||||
'キリキリと\u200bかかってこい\nこのヘナチョコ冒険者どもめが!',
|
||||
makeDeps({
|
||||
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
|
||||
getYomitanParserWindow: () =>
|
||||
({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
parseInput = script;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
}) as unknown as Electron.BrowserWindow,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(parseInput, /キリキリと かかってこい このヘナチョコ冒険者どもめが!/);
|
||||
assert.equal(result.text, 'キリキリと\u200bかかってこい\nこのヘナチョコ冒険者どもめが!');
|
||||
assert.equal(result.tokens, null);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle returns null tokens when Yomitan parsing is unavailable', async () => {
|
||||
const result = await tokenizeSubtitle('猫です', makeDeps());
|
||||
|
||||
@@ -1751,9 +1861,9 @@ test('tokenizeSubtitle keeps parsing explicit by scanning-parser source only', a
|
||||
assert.equal(result.tokens?.[4]?.frequencyRank, 1500);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle still assigns frequency to non-known Yomitan tokens', async () => {
|
||||
test('tokenizeSubtitle still assigns frequency to non-known multi-character Yomitan tokens', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'小園に',
|
||||
'小園友達',
|
||||
makeDeps({
|
||||
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
|
||||
getYomitanParserWindow: () =>
|
||||
@@ -1774,9 +1884,9 @@ test('tokenizeSubtitle still assigns frequency to non-known Yomitan tokens', asy
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'に',
|
||||
reading: 'に',
|
||||
headwords: [[{ term: 'に' }]],
|
||||
text: '友達',
|
||||
reading: 'ともだち',
|
||||
headwords: [[{ term: '友達' }]],
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -1785,7 +1895,7 @@ test('tokenizeSubtitle still assigns frequency to non-known Yomitan tokens', asy
|
||||
},
|
||||
}) as unknown as Electron.BrowserWindow,
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: (text) => (text === '小園' ? 75 : text === 'に' ? 3000 : null),
|
||||
getFrequencyRank: (text) => (text === '小園' ? 75 : text === '友達' ? 3000 : null),
|
||||
isKnownWord: (text) => text === '小園',
|
||||
}),
|
||||
);
|
||||
@@ -2525,6 +2635,21 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque
|
||||
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle excludes single-kana merged tokens from frequency highlighting', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'た',
|
||||
makeDepsFromYomitanTokens([{ surface: 'た', reading: 'た', headword: 'た' }], {
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: (text) => (text === 'た' ? 17 : null),
|
||||
getMinSentenceWordsForNPlusOne: () => 1,
|
||||
tokenizeWithMecab: async () => null,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle excludes merged function/content token from frequency highlighting but keeps N+1', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'になれば',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Token,
|
||||
FrequencyDictionaryLookup,
|
||||
JlptLevel,
|
||||
PartOfSpeech,
|
||||
} from '../../types';
|
||||
import {
|
||||
DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG,
|
||||
@@ -18,9 +19,8 @@ import {
|
||||
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
|
||||
resolveAnnotationPos2ExclusionSet,
|
||||
} from '../../token-pos2-exclusions';
|
||||
import { selectYomitanParseTokens } from './tokenizer/parser-selection-stage';
|
||||
import {
|
||||
requestYomitanParseResults,
|
||||
requestYomitanScanTokens,
|
||||
requestYomitanTermFrequencies,
|
||||
} from './tokenizer/yomitan-parser-runtime';
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface TokenizerServiceDeps {
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
getNPlusOneEnabled?: () => boolean;
|
||||
getJlptEnabled?: () => boolean;
|
||||
getNameMatchEnabled?: () => boolean;
|
||||
getFrequencyDictionaryEnabled?: () => boolean;
|
||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||
@@ -73,6 +74,7 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
getNPlusOneEnabled?: () => boolean;
|
||||
getJlptEnabled?: () => boolean;
|
||||
getNameMatchEnabled?: () => boolean;
|
||||
getFrequencyDictionaryEnabled?: () => boolean;
|
||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||
@@ -85,6 +87,7 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
interface TokenizerAnnotationOptions {
|
||||
nPlusOneEnabled: boolean;
|
||||
jlptEnabled: boolean;
|
||||
nameMatchEnabled: boolean;
|
||||
frequencyEnabled: boolean;
|
||||
frequencyMatchMode: FrequencyDictionaryMatchMode;
|
||||
minSentenceWordsForNPlusOne: number | undefined;
|
||||
@@ -106,6 +109,7 @@ const DEFAULT_ANNOTATION_POS1_EXCLUSIONS = resolveAnnotationPos1ExclusionSet(
|
||||
const DEFAULT_ANNOTATION_POS2_EXCLUSIONS = resolveAnnotationPos2ExclusionSet(
|
||||
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
|
||||
);
|
||||
const INVISIBLE_SEPARATOR_PATTERN = /[\u200b\u2060\ufeff]/g;
|
||||
|
||||
function getKnownWordLookup(
|
||||
deps: TokenizerServiceDeps,
|
||||
@@ -189,6 +193,7 @@ export function createTokenizerDepsRuntime(
|
||||
getJlptLevel: options.getJlptLevel,
|
||||
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
||||
getJlptEnabled: options.getJlptEnabled,
|
||||
getNameMatchEnabled: options.getNameMatchEnabled,
|
||||
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
||||
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
||||
getFrequencyRank: options.getFrequencyRank,
|
||||
@@ -263,6 +268,7 @@ function isKanaChar(char: string): boolean {
|
||||
return (
|
||||
(code >= 0x3041 && code <= 0x3096) ||
|
||||
(code >= 0x309b && code <= 0x309f) ||
|
||||
code === 0x30fc ||
|
||||
(code >= 0x30a0 && code <= 0x30fa) ||
|
||||
(code >= 0x30fd && code <= 0x30ff)
|
||||
);
|
||||
@@ -295,6 +301,11 @@ function normalizeYomitanMergedReading(token: MergedToken): string {
|
||||
function normalizeSelectedYomitanTokens(tokens: MergedToken[]): MergedToken[] {
|
||||
return tokens.map((token) => ({
|
||||
...token,
|
||||
partOfSpeech: token.partOfSpeech ?? PartOfSpeech.other,
|
||||
isMerged: token.isMerged ?? true,
|
||||
isKnown: token.isKnown ?? false,
|
||||
isNPlusOneTarget: token.isNPlusOneTarget ?? false,
|
||||
isNameMatch: token.isNameMatch ?? false,
|
||||
reading: normalizeYomitanMergedReading(token),
|
||||
}));
|
||||
}
|
||||
@@ -454,6 +465,7 @@ function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOp
|
||||
return {
|
||||
nPlusOneEnabled: deps.getNPlusOneEnabled?.() !== false,
|
||||
jlptEnabled: deps.getJlptEnabled?.() !== false,
|
||||
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
|
||||
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
|
||||
frequencyMatchMode: deps.getFrequencyDictionaryMatchMode?.() ?? 'headword',
|
||||
minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(),
|
||||
@@ -467,20 +479,28 @@ async function parseWithYomitanInternalParser(
|
||||
deps: TokenizerServiceDeps,
|
||||
options: TokenizerAnnotationOptions,
|
||||
): Promise<MergedToken[] | null> {
|
||||
const parseResults = await requestYomitanParseResults(text, deps, logger);
|
||||
if (!parseResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedTokens = selectYomitanParseTokens(
|
||||
parseResults,
|
||||
getKnownWordLookup(deps, options),
|
||||
deps.getKnownWordMatchMode(),
|
||||
);
|
||||
const selectedTokens = await requestYomitanScanTokens(text, deps, logger, {
|
||||
includeNameMatchMetadata: options.nameMatchEnabled,
|
||||
});
|
||||
if (!selectedTokens || selectedTokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const normalizedSelectedTokens = normalizeSelectedYomitanTokens(selectedTokens);
|
||||
const normalizedSelectedTokens = normalizeSelectedYomitanTokens(
|
||||
selectedTokens.map(
|
||||
(token): MergedToken => ({
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
startPos: token.startPos,
|
||||
endPos: token.endPos,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: true,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
isNameMatch: token.isNameMatch ?? false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (deps.getYomitanGroupDebugEnabled?.() === true) {
|
||||
logSelectedYomitanGroups(text, normalizedSelectedTokens);
|
||||
@@ -553,7 +573,11 @@ export async function tokenizeSubtitle(
|
||||
return { text, tokens: null };
|
||||
}
|
||||
|
||||
const tokenizeText = displayText.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const tokenizeText = displayText
|
||||
.replace(INVISIBLE_SEPARATOR_PATTERN, ' ')
|
||||
.replace(/\n/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const annotationOptions = getAnnotationOptions(deps);
|
||||
|
||||
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
|
||||
|
||||
@@ -252,12 +252,12 @@ test('annotateTokens applies configured pos1 exclusions to both frequency and N+
|
||||
test('annotateTokens allows previously default-excluded pos1 when removed from effective set', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'は',
|
||||
headword: 'は',
|
||||
surface: 'まで',
|
||||
headword: 'まで',
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
pos1: '助詞',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
endPos: 2,
|
||||
frequencyRank: 8,
|
||||
}),
|
||||
];
|
||||
@@ -314,6 +314,52 @@ test('annotateTokens excludes likely kana SFX tokens from frequency when POS tag
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens excludes single hiragana and katakana tokens from frequency when POS tags are missing', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'た',
|
||||
reading: 'た',
|
||||
headword: 'た',
|
||||
pos1: '',
|
||||
pos2: '',
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
frequencyRank: 21,
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
}),
|
||||
makeToken({
|
||||
surface: 'ア',
|
||||
reading: 'ア',
|
||||
headword: 'ア',
|
||||
pos1: '',
|
||||
pos2: '',
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
frequencyRank: 22,
|
||||
startPos: 1,
|
||||
endPos: 2,
|
||||
}),
|
||||
makeToken({
|
||||
surface: '山',
|
||||
reading: 'やま',
|
||||
headword: '山',
|
||||
pos1: '',
|
||||
pos2: '',
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
frequencyRank: 23,
|
||||
startPos: 2,
|
||||
endPos: 3,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps(), {
|
||||
minSentenceWordsForNPlusOne: 1,
|
||||
});
|
||||
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[1]?.frequencyRank, undefined);
|
||||
assert.equal(result[2]?.frequencyRank, 23);
|
||||
});
|
||||
|
||||
test('annotateTokens keeps frequency when mecab tags classify token as content-bearing', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
|
||||
@@ -103,6 +103,10 @@ function isFrequencyExcludedByPos(
|
||||
pos1Exclusions: ReadonlySet<string>,
|
||||
pos2Exclusions: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (isSingleKanaFrequencyNoiseToken(token.surface)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedPos1 = normalizePos1Tag(token.pos1);
|
||||
const hasPos1 = normalizedPos1.length > 0;
|
||||
if (isExcludedByTagSet(normalizedPos1, pos1Exclusions)) {
|
||||
@@ -231,6 +235,7 @@ function isKanaChar(char: string): boolean {
|
||||
return (
|
||||
(code >= 0x3041 && code <= 0x3096) ||
|
||||
(code >= 0x309b && code <= 0x309f) ||
|
||||
code === 0x30fc ||
|
||||
(code >= 0x30a0 && code <= 0x30fa) ||
|
||||
(code >= 0x30fd && code <= 0x30ff)
|
||||
);
|
||||
@@ -362,6 +367,20 @@ function isLikelyFrequencyNoiseToken(token: MergedToken): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSingleKanaFrequencyNoiseToken(text: string | undefined): boolean {
|
||||
if (typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chars = [...normalized];
|
||||
return chars.length === 1 && isKanaChar(chars[0]!);
|
||||
}
|
||||
|
||||
function isJlptEligibleToken(token: MergedToken): boolean {
|
||||
if (token.pos1 && shouldIgnoreJlptForMecabPos1(token.pos1)) {
|
||||
return false;
|
||||
|
||||
@@ -127,3 +127,88 @@ test('drops scanning parser tokens which have no dictionary headword', () => {
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('prefers the longest dictionary headword across merged segments', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
[
|
||||
{ text: 'バニ', reading: 'ばに', headword: 'バニ' },
|
||||
{ text: 'ール', reading: 'ーる', headword: 'バニール' },
|
||||
],
|
||||
]),
|
||||
];
|
||||
|
||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
assert.deepEqual(
|
||||
tokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
})),
|
||||
[
|
||||
{
|
||||
surface: 'バニール',
|
||||
reading: 'ばにーる',
|
||||
headword: 'バニール',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the first headword when later segments are standalone words', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
[
|
||||
{ text: '猫', reading: 'ねこ', headword: '猫' },
|
||||
{ text: 'です', reading: 'です', headword: 'です' },
|
||||
],
|
||||
]),
|
||||
];
|
||||
|
||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
assert.deepEqual(
|
||||
tokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
})),
|
||||
[
|
||||
{
|
||||
surface: '猫です',
|
||||
reading: 'ねこです',
|
||||
headword: '猫',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('merges trailing katakana continuation without headword into previous token', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
[{ text: 'カズ', reading: 'かず', headword: 'カズマ' }],
|
||||
[{ text: 'マ', reading: 'ま' }],
|
||||
[{ text: '魔王軍', reading: 'まおうぐん', headword: '魔王軍' }],
|
||||
]),
|
||||
];
|
||||
|
||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
assert.deepEqual(
|
||||
tokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
})),
|
||||
[
|
||||
{
|
||||
surface: 'カズマ',
|
||||
reading: 'かずま',
|
||||
headword: 'カズマ',
|
||||
},
|
||||
{
|
||||
surface: '魔王軍',
|
||||
reading: 'まおうぐん',
|
||||
headword: '魔王軍',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ function isKanaChar(char: string): boolean {
|
||||
return (
|
||||
(code >= 0x3041 && code <= 0x3096) ||
|
||||
(code >= 0x309b && code <= 0x309f) ||
|
||||
code === 0x30fc ||
|
||||
(code >= 0x30a0 && code <= 0x30fa) ||
|
||||
(code >= 0x30fd && code <= 0x30ff)
|
||||
);
|
||||
@@ -111,6 +112,51 @@ function extractYomitanHeadword(segment: YomitanParseSegment): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function selectMergedHeadword(
|
||||
firstHeadword: string,
|
||||
expandedHeadwords: string[],
|
||||
surface: string,
|
||||
): string {
|
||||
if (expandedHeadwords.length > 0) {
|
||||
const exactSurfaceMatch = expandedHeadwords.find((headword) => headword === surface);
|
||||
if (exactSurfaceMatch) {
|
||||
return exactSurfaceMatch;
|
||||
}
|
||||
|
||||
return expandedHeadwords.reduce((best, current) => {
|
||||
if (current.length !== best.length) {
|
||||
return current.length > best.length ? current : best;
|
||||
}
|
||||
return best;
|
||||
});
|
||||
}
|
||||
|
||||
if (!firstHeadword) {
|
||||
return '';
|
||||
}
|
||||
return firstHeadword;
|
||||
}
|
||||
|
||||
function isKanaOnlyText(text: string): boolean {
|
||||
return text.length > 0 && Array.from(text).every((char) => isKanaChar(char));
|
||||
}
|
||||
|
||||
function shouldMergeKanaContinuation(
|
||||
previousToken: MergedToken | undefined,
|
||||
continuationSurface: string,
|
||||
): previousToken is MergedToken {
|
||||
if (!previousToken || !continuationSurface || !isKanaOnlyText(continuationSurface)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!previousToken.headword || previousToken.headword.length <= previousToken.surface.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appendedSurface = previousToken.surface + continuationSurface;
|
||||
return previousToken.headword.startsWith(appendedSurface);
|
||||
}
|
||||
|
||||
export function mapYomitanParseResultItemToMergedTokens(
|
||||
parseResult: YomitanParseResultItem,
|
||||
isKnownWord: (text: string) => boolean,
|
||||
@@ -140,7 +186,8 @@ export function mapYomitanParseResultItemToMergedTokens(
|
||||
|
||||
let combinedSurface = '';
|
||||
let combinedReading = '';
|
||||
let combinedHeadword = '';
|
||||
let firstHeadword = '';
|
||||
const expandedHeadwords: string[] = [];
|
||||
|
||||
for (const segment of line) {
|
||||
const segmentText = segment.text;
|
||||
@@ -152,8 +199,14 @@ export function mapYomitanParseResultItemToMergedTokens(
|
||||
if (typeof segment.reading === 'string') {
|
||||
combinedReading += segment.reading;
|
||||
}
|
||||
if (!combinedHeadword) {
|
||||
combinedHeadword = extractYomitanHeadword(segment);
|
||||
const segmentHeadword = extractYomitanHeadword(segment);
|
||||
if (segmentHeadword) {
|
||||
if (!firstHeadword) {
|
||||
firstHeadword = segmentHeadword;
|
||||
}
|
||||
if (segmentHeadword.length > segmentText.length) {
|
||||
expandedHeadwords.push(segmentHeadword);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +217,20 @@ export function mapYomitanParseResultItemToMergedTokens(
|
||||
const start = charOffset;
|
||||
const end = start + combinedSurface.length;
|
||||
charOffset = end;
|
||||
const combinedHeadword = selectMergedHeadword(
|
||||
firstHeadword,
|
||||
expandedHeadwords,
|
||||
combinedSurface,
|
||||
);
|
||||
if (!combinedHeadword) {
|
||||
const previousToken = tokens[tokens.length - 1];
|
||||
if (shouldMergeKanaContinuation(previousToken, combinedSurface)) {
|
||||
previousToken.surface += combinedSurface;
|
||||
previousToken.reading += combinedReading;
|
||||
previousToken.endPos = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
// No dictionary-backed headword for this merged unit; skip it entirely so
|
||||
// downstream keyboard/frequency/JLPT flows only operate on lookup-backed tokens.
|
||||
continue;
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import test from 'node:test';
|
||||
import * as vm from 'node:vm';
|
||||
import {
|
||||
requestYomitanParseResults,
|
||||
getYomitanDictionaryInfo,
|
||||
importYomitanDictionaryFromZip,
|
||||
deleteYomitanDictionaryByTitle,
|
||||
removeYomitanDictionarySettings,
|
||||
requestYomitanScanTokens,
|
||||
requestYomitanTermFrequencies,
|
||||
syncYomitanDefaultAnkiServer,
|
||||
upsertYomitanDictionarySettings,
|
||||
} from './yomitan-parser-runtime';
|
||||
|
||||
function createDeps(executeJavaScript: (script: string) => Promise<unknown>) {
|
||||
function createDeps(
|
||||
executeJavaScript: (script: string) => Promise<unknown>,
|
||||
options?: {
|
||||
createYomitanExtensionWindow?: (pageName: string) => Promise<unknown>;
|
||||
},
|
||||
) {
|
||||
const parserWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
@@ -22,9 +36,44 @@ function createDeps(executeJavaScript: (script: string) => Promise<unknown>) {
|
||||
setYomitanParserReadyPromise: () => undefined,
|
||||
getYomitanParserInitPromise: () => null,
|
||||
setYomitanParserInitPromise: () => undefined,
|
||||
createYomitanExtensionWindow: options?.createYomitanExtensionWindow as never,
|
||||
};
|
||||
}
|
||||
|
||||
async function runInjectedYomitanScript(
|
||||
script: string,
|
||||
handler: (action: string, params: unknown) => unknown,
|
||||
): Promise<unknown> {
|
||||
return await vm.runInNewContext(script, {
|
||||
chrome: {
|
||||
runtime: {
|
||||
lastError: null,
|
||||
sendMessage: (
|
||||
payload: { action?: string; params?: unknown },
|
||||
callback: (response: { result?: unknown; error?: { message?: string } }) => void,
|
||||
) => {
|
||||
try {
|
||||
callback({ result: handler(payload.action ?? '', payload.params) });
|
||||
} catch (error) {
|
||||
callback({ error: { message: (error as Error).message } });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
Array,
|
||||
Error,
|
||||
JSON,
|
||||
Map,
|
||||
Math,
|
||||
Number,
|
||||
Object,
|
||||
Promise,
|
||||
RegExp,
|
||||
Set,
|
||||
String,
|
||||
});
|
||||
}
|
||||
|
||||
test('syncYomitanDefaultAnkiServer updates default profile server when script reports update', async () => {
|
||||
let scriptValue = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
@@ -389,7 +438,7 @@ test('requestYomitanTermFrequencies caches repeated term+reading lookups', async
|
||||
assert.equal(frequencyCalls, 1);
|
||||
});
|
||||
|
||||
test('requestYomitanParseResults disables Yomitan MeCab parser path', async () => {
|
||||
test('requestYomitanScanTokens uses left-to-right termsFind scanning instead of parseText', async () => {
|
||||
const scripts: string[] = [];
|
||||
const deps = createDeps(async (script) => {
|
||||
scripts.push(script);
|
||||
@@ -405,15 +454,517 @@ test('requestYomitanParseResults disables Yomitan MeCab parser path', async () =
|
||||
],
|
||||
};
|
||||
}
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
surface: 'カズマ',
|
||||
reading: 'かずま',
|
||||
headword: 'カズマ',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const result = await requestYomitanParseResults('猫です', deps, {
|
||||
const result = await requestYomitanScanTokens('カズマ', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, []);
|
||||
const parseScript = scripts.find((script) => script.includes('parseText'));
|
||||
assert.ok(parseScript, 'expected parseText request script');
|
||||
assert.match(parseScript ?? '', /useMecabParser:\s*false/);
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
surface: 'カズマ',
|
||||
reading: 'かずま',
|
||||
headword: 'カズマ',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
},
|
||||
]);
|
||||
const scannerScript = scripts.find((script) => script.includes('termsFind'));
|
||||
assert.ok(scannerScript, 'expected termsFind scanning request script');
|
||||
assert.doesNotMatch(scannerScript ?? '', /parseText/);
|
||||
assert.match(scannerScript ?? '', /matchType:\s*"exact"/);
|
||||
assert.match(scannerScript ?? '', /deinflect:\s*true/);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens marks tokens backed by SubMiner character dictionary entries', async () => {
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
surface: 'アクア',
|
||||
reading: 'あくあ',
|
||||
headword: 'アクア',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
isNameMatch: true,
|
||||
},
|
||||
{
|
||||
surface: 'です',
|
||||
reading: 'です',
|
||||
headword: 'です',
|
||||
startPos: 3,
|
||||
endPos: 5,
|
||||
isNameMatch: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const result = await requestYomitanScanTokens('アクアです', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(result?.length, 2);
|
||||
assert.equal((result?.[0] as { isNameMatch?: boolean } | undefined)?.isNameMatch, true);
|
||||
assert.equal((result?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens skips name-match work when disabled', async () => {
|
||||
let scannerScript = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('termsFind')) {
|
||||
scannerScript = script;
|
||||
}
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
surface: 'アクア',
|
||||
reading: 'あくあ',
|
||||
headword: 'アクア',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const result = await requestYomitanScanTokens(
|
||||
'アクア',
|
||||
deps,
|
||||
{ error: () => undefined },
|
||||
{ includeNameMatchMetadata: false },
|
||||
);
|
||||
|
||||
assert.equal(result?.length, 1);
|
||||
assert.equal((result?.[0] as { isNameMatch?: boolean } | undefined)?.isNameMatch, undefined);
|
||||
assert.match(scannerScript, /const includeNameMatchMetadata = false;/);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens marks grouped entries when SubMiner dictionary alias only exists on definitions', async () => {
|
||||
let scannerScript = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('termsFind')) {
|
||||
scannerScript = script;
|
||||
return [];
|
||||
}
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await requestYomitanScanTokens(
|
||||
'カズマ',
|
||||
deps,
|
||||
{ error: () => undefined },
|
||||
{ includeNameMatchMetadata: true },
|
||||
);
|
||||
|
||||
assert.match(scannerScript, /getPreferredHeadword/);
|
||||
|
||||
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
|
||||
if (action === 'termsFind') {
|
||||
const text = (params as { text?: string } | undefined)?.text;
|
||||
if (text === 'カズマ') {
|
||||
return {
|
||||
originalTextLength: 3,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
dictionaryAlias: '',
|
||||
headwords: [
|
||||
{
|
||||
term: 'カズマ',
|
||||
reading: 'かずま',
|
||||
sources: [{ originalText: 'カズマ', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
definitions: [
|
||||
{ dictionary: 'JMdict', dictionaryAlias: 'JMdict' },
|
||||
{
|
||||
dictionary: 'SubMiner Character Dictionary (AniList 130298)',
|
||||
dictionaryAlias: 'SubMiner Character Dictionary (AniList 130298)',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { originalTextLength: 0, dictionaryEntries: [] };
|
||||
}
|
||||
throw new Error(`unexpected action: ${action}`);
|
||||
});
|
||||
|
||||
assert.equal(Array.isArray(result), true);
|
||||
assert.equal((result as { length?: number } | null)?.length, 1);
|
||||
assert.equal((result as Array<{ surface?: string }>)[0]?.surface, 'カズマ');
|
||||
assert.equal((result as Array<{ headword?: string }>)[0]?.headword, 'カズマ');
|
||||
assert.equal((result as Array<{ startPos?: number }>)[0]?.startPos, 0);
|
||||
assert.equal((result as Array<{ endPos?: number }>)[0]?.endPos, 3);
|
||||
assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens skips fallback fragments without exact primary source matches', async () => {
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return await runInjectedYomitanScript(script, (action, params) => {
|
||||
if (action !== 'termsFind') {
|
||||
throw new Error(`unexpected action: ${action}`);
|
||||
}
|
||||
|
||||
const text = (params as { text?: string } | undefined)?.text ?? '';
|
||||
if (text.startsWith('だが ')) {
|
||||
return {
|
||||
originalTextLength: 2,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: 'だが',
|
||||
reading: 'だが',
|
||||
sources: [{ originalText: 'だが', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.startsWith('それでも')) {
|
||||
return {
|
||||
originalTextLength: 4,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: 'それでも',
|
||||
reading: 'それでも',
|
||||
sources: [{ originalText: 'それでも', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.startsWith('届かぬ')) {
|
||||
return {
|
||||
originalTextLength: 3,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: '届く',
|
||||
reading: 'とどく',
|
||||
sources: [{ originalText: '届かぬ', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.startsWith('高み')) {
|
||||
return {
|
||||
originalTextLength: 2,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: '高み',
|
||||
reading: 'たかみ',
|
||||
sources: [{ originalText: '高み', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.startsWith('があった')) {
|
||||
return {
|
||||
originalTextLength: 2,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: 'があ',
|
||||
reading: '',
|
||||
sources: [{ originalText: 'が', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (text.startsWith('あった')) {
|
||||
return {
|
||||
originalTextLength: 3,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: 'ある',
|
||||
reading: 'ある',
|
||||
sources: [{ originalText: 'あった', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { originalTextLength: 0, dictionaryEntries: [] };
|
||||
});
|
||||
});
|
||||
|
||||
const result = await requestYomitanScanTokens('だが それでも届かぬ高みがあった', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result?.map((token) => ({
|
||||
surface: token.surface,
|
||||
headword: token.headword,
|
||||
startPos: token.startPos,
|
||||
endPos: token.endPos,
|
||||
})),
|
||||
[
|
||||
{
|
||||
surface: 'だが',
|
||||
headword: 'だが',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
},
|
||||
{
|
||||
surface: 'それでも',
|
||||
headword: 'それでも',
|
||||
startPos: 3,
|
||||
endPos: 7,
|
||||
},
|
||||
{
|
||||
surface: '届かぬ',
|
||||
headword: '届く',
|
||||
startPos: 7,
|
||||
endPos: 10,
|
||||
},
|
||||
{
|
||||
surface: '高み',
|
||||
headword: '高み',
|
||||
startPos: 10,
|
||||
endPos: 12,
|
||||
},
|
||||
{
|
||||
surface: 'あった',
|
||||
headword: 'ある',
|
||||
startPos: 13,
|
||||
endPos: 16,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('getYomitanDictionaryInfo requests dictionary info via backend action', async () => {
|
||||
let scriptValue = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
scriptValue = script;
|
||||
return [{ title: 'SubMiner Character Dictionary (AniList 130298)', revision: '1' }];
|
||||
});
|
||||
|
||||
const dictionaries = await getYomitanDictionaryInfo(deps, { error: () => undefined });
|
||||
assert.equal(dictionaries.length, 1);
|
||||
assert.equal(dictionaries[0]?.title, 'SubMiner Character Dictionary (AniList 130298)');
|
||||
assert.match(scriptValue, /getDictionaryInfo/);
|
||||
});
|
||||
|
||||
test('dictionary settings helpers upsert and remove dictionary entries without reordering', async () => {
|
||||
const scripts: string[] = [];
|
||||
const optionsFull = {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
dictionaries: [
|
||||
{
|
||||
name: 'Jitendex',
|
||||
alias: 'Jitendex',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'SubMiner Character Dictionary (AniList 1)',
|
||||
alias: 'SubMiner Character Dictionary (AniList 1)',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const deps = createDeps(async (script) => {
|
||||
scripts.push(script);
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return JSON.parse(JSON.stringify(optionsFull));
|
||||
}
|
||||
if (script.includes('setAllSettings')) {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const title = 'SubMiner Character Dictionary (AniList 1)';
|
||||
const upserted = await upsertYomitanDictionarySettings(title, 'all', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
const removed = await removeYomitanDictionarySettings(title, 'all', 'delete', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(upserted, true);
|
||||
assert.equal(removed, true);
|
||||
const setCalls = scripts.filter((script) => script.includes('setAllSettings')).length;
|
||||
assert.equal(setCalls, 2);
|
||||
|
||||
const upsertScript = scripts.find(
|
||||
(script) =>
|
||||
script.includes('setAllSettings') &&
|
||||
script.includes('"SubMiner Character Dictionary (AniList 1)"'),
|
||||
);
|
||||
assert.ok(upsertScript);
|
||||
const jitendexOffset = upsertScript?.indexOf('"Jitendex"') ?? -1;
|
||||
const subMinerOffset = upsertScript?.indexOf('"SubMiner Character Dictionary (AniList 1)"') ?? -1;
|
||||
assert.equal(jitendexOffset >= 0, true);
|
||||
assert.equal(subMinerOffset >= 0, true);
|
||||
assert.equal(jitendexOffset < subMinerOffset, true);
|
||||
assert.match(upsertScript ?? '', /"enabled":true/);
|
||||
});
|
||||
|
||||
test('importYomitanDictionaryFromZip uses settings automation bridge instead of custom backend action', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
|
||||
const zipPath = path.join(tempDir, 'dict.zip');
|
||||
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
|
||||
|
||||
const scripts: string[] = [];
|
||||
const settingsWindow = {
|
||||
isDestroyed: () => false,
|
||||
destroy: () => undefined,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
scripts.push(script);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const deps = createDeps(async () => true, {
|
||||
createYomitanExtensionWindow: async (pageName: string) => {
|
||||
assert.equal(pageName, 'settings.html');
|
||||
return settingsWindow;
|
||||
},
|
||||
});
|
||||
|
||||
const imported = await importYomitanDictionaryFromZip(zipPath, deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(imported, true);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('__subminerYomitanSettingsAutomation')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('importDictionaryArchiveBase64')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('subminerImportDictionary')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of custom backend action', async () => {
|
||||
const scripts: string[] = [];
|
||||
const settingsWindow = {
|
||||
isDestroyed: () => false,
|
||||
destroy: () => undefined,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
scripts.push(script);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const deps = createDeps(async () => true, {
|
||||
createYomitanExtensionWindow: async (pageName: string) => {
|
||||
assert.equal(pageName, 'settings.html');
|
||||
return settingsWindow;
|
||||
},
|
||||
});
|
||||
|
||||
const deleted = await deleteYomitanDictionaryByTitle(
|
||||
'SubMiner Character Dictionary (AniList 130298)',
|
||||
deps,
|
||||
{ error: () => undefined },
|
||||
);
|
||||
|
||||
assert.equal(deleted, true);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('__subminerYomitanSettingsAutomation')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('deleteDictionary')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
scripts.some((script) => script.includes('subminerDeleteDictionary')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { BrowserWindow, Extension } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||
|
||||
interface LoggerLike {
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
@@ -13,6 +16,12 @@ interface YomitanParserRuntimeDeps {
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
createYomitanExtensionWindow?: (pageName: string) => Promise<BrowserWindow | null>;
|
||||
}
|
||||
|
||||
export interface YomitanDictionaryInfo {
|
||||
title: string;
|
||||
revision?: string | number;
|
||||
}
|
||||
|
||||
export interface YomitanTermFrequency {
|
||||
@@ -30,6 +39,15 @@ export interface YomitanTermReadingPair {
|
||||
reading: string | null;
|
||||
}
|
||||
|
||||
export interface YomitanScanToken {
|
||||
surface: string;
|
||||
reading: string;
|
||||
headword: string;
|
||||
startPos: number;
|
||||
endPos: number;
|
||||
isNameMatch?: boolean;
|
||||
}
|
||||
|
||||
interface YomitanProfileMetadata {
|
||||
profileIndex: number;
|
||||
scanLength: number;
|
||||
@@ -48,6 +66,22 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object');
|
||||
}
|
||||
|
||||
function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(entry) =>
|
||||
isObject(entry) &&
|
||||
typeof entry.surface === 'string' &&
|
||||
typeof entry.reading === 'string' &&
|
||||
typeof entry.headword === 'string' &&
|
||||
typeof entry.startPos === 'number' &&
|
||||
typeof entry.endPos === 'number' &&
|
||||
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function makeTermReadingCacheKey(term: string, reading: string | null): string {
|
||||
return `${term}\u0000${reading ?? ''}`;
|
||||
}
|
||||
@@ -489,6 +523,392 @@ async function ensureYomitanParserWindow(
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
async function createYomitanExtensionWindow(
|
||||
pageName: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<BrowserWindow | null> {
|
||||
if (typeof deps.createYomitanExtensionWindow === 'function') {
|
||||
return await deps.createYomitanExtensionWindow(pageName);
|
||||
}
|
||||
|
||||
const electron = await import('electron');
|
||||
const yomitanExt = deps.getYomitanExt();
|
||||
if (!yomitanExt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { BrowserWindow, session } = electron;
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
session: session.defaultSession,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
window.webContents.once('did-finish-load', () => resolve());
|
||||
window.webContents.once('did-fail-load', (_event, _errorCode, errorDescription) => {
|
||||
reject(new Error(errorDescription));
|
||||
});
|
||||
void window
|
||||
.loadURL(`chrome-extension://${yomitanExt.id}/${pageName}`)
|
||||
.catch((error: Error) => reject(error));
|
||||
});
|
||||
return window;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to create hidden Yomitan ${pageName} window: ${(err as Error).message}`);
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeYomitanSettingsAutomation<T>(
|
||||
script: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<T | null> {
|
||||
const settingsWindow = await createYomitanExtensionWindow('settings.html', deps, logger);
|
||||
if (!settingsWindow || settingsWindow.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await settingsWindow.webContents.executeJavaScript(
|
||||
`
|
||||
(async () => {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (Date.now() < deadline) {
|
||||
if (globalThis.__subminerYomitanSettingsAutomation?.ready === true) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error("Yomitan settings automation bridge did not become ready");
|
||||
})();
|
||||
`,
|
||||
true,
|
||||
);
|
||||
|
||||
return (await settingsWindow.webContents.executeJavaScript(script, true)) as T;
|
||||
} catch (err) {
|
||||
logger.error('Failed to drive Yomitan settings automation:', (err as Error).message);
|
||||
return null;
|
||||
} finally {
|
||||
if (!settingsWindow.isDestroyed()) {
|
||||
settingsWindow.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const YOMITAN_SCANNING_HELPERS = String.raw`
|
||||
const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
|
||||
const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
|
||||
const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc;
|
||||
const KATAKANA_SMALL_KA_CODE_POINT = 0x30f5;
|
||||
const KATAKANA_SMALL_KE_CODE_POINT = 0x30f6;
|
||||
const KANA_RANGES = [[0x3040, 0x309f], [0x30a0, 0x30ff]];
|
||||
const JAPANESE_RANGES = [[0x3040, 0x30ff], [0x3400, 0x9fff]];
|
||||
function isCodePointInRange(codePoint, range) { return codePoint >= range[0] && codePoint <= range[1]; }
|
||||
function isCodePointInRanges(codePoint, ranges) { return ranges.some((range) => isCodePointInRange(codePoint, range)); }
|
||||
function isCodePointKana(codePoint) { return isCodePointInRanges(codePoint, KANA_RANGES); }
|
||||
function isCodePointJapanese(codePoint) { return isCodePointInRanges(codePoint, JAPANESE_RANGES); }
|
||||
function createFuriganaSegment(text, reading) { return {text, reading}; }
|
||||
function getProlongedHiragana(previousCharacter) {
|
||||
switch (previousCharacter) {
|
||||
case "あ": case "か": case "が": case "さ": case "ざ": case "た": case "だ": case "な": case "は": case "ば": case "ぱ": case "ま": case "や": case "ら": case "わ": case "ぁ": case "ゃ": case "ゎ": return "あ";
|
||||
case "い": case "き": case "ぎ": case "し": case "じ": case "ち": case "ぢ": case "に": case "ひ": case "び": case "ぴ": case "み": case "り": case "ぃ": return "い";
|
||||
case "う": case "く": case "ぐ": case "す": case "ず": case "つ": case "づ": case "ぬ": case "ふ": case "ぶ": case "ぷ": case "む": case "ゆ": case "る": case "ぅ": case "ゅ": return "う";
|
||||
case "え": case "け": case "げ": case "せ": case "ぜ": case "て": case "で": case "ね": case "へ": case "べ": case "ぺ": case "め": case "れ": case "ぇ": return "え";
|
||||
case "お": case "こ": case "ご": case "そ": case "ぞ": case "と": case "ど": case "の": case "ほ": case "ぼ": case "ぽ": case "も": case "よ": case "ろ": case "を": case "ぉ": case "ょ": return "う";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
function getFuriganaKanaSegments(text, reading) {
|
||||
const newSegments = [];
|
||||
let start = 0;
|
||||
let state = (reading[0] === text[0]);
|
||||
for (let i = 1; i < text.length; ++i) {
|
||||
const newState = (reading[i] === text[i]);
|
||||
if (state === newState) { continue; }
|
||||
newSegments.push(createFuriganaSegment(text.substring(start, i), state ? '' : reading.substring(start, i)));
|
||||
state = newState;
|
||||
start = i;
|
||||
}
|
||||
newSegments.push(createFuriganaSegment(text.substring(start), state ? '' : reading.substring(start)));
|
||||
return newSegments;
|
||||
}
|
||||
function convertKatakanaToHiragana(text, keepProlongedSoundMarks = false) {
|
||||
let result = '';
|
||||
const offset = (HIRAGANA_CONVERSION_RANGE[0] - KATAKANA_CONVERSION_RANGE[0]);
|
||||
for (let char of text) {
|
||||
const codePoint = char.codePointAt(0);
|
||||
switch (codePoint) {
|
||||
case KATAKANA_SMALL_KA_CODE_POINT:
|
||||
case KATAKANA_SMALL_KE_CODE_POINT:
|
||||
break;
|
||||
case KANA_PROLONGED_SOUND_MARK_CODE_POINT:
|
||||
if (!keepProlongedSoundMarks && result.length > 0) {
|
||||
const char2 = getProlongedHiragana(result[result.length - 1]);
|
||||
if (char2 !== null) { char = char2; }
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (isCodePointInRange(codePoint, KATAKANA_CONVERSION_RANGE)) {
|
||||
char = String.fromCodePoint(codePoint + offset);
|
||||
}
|
||||
break;
|
||||
}
|
||||
result += char;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function segmentizeFurigana(reading, readingNormalized, groups, groupsStart) {
|
||||
const groupCount = groups.length - groupsStart;
|
||||
if (groupCount <= 0) { return reading.length === 0 ? [] : null; }
|
||||
const group = groups[groupsStart];
|
||||
const {isKana, text} = group;
|
||||
if (isKana) {
|
||||
if (group.textNormalized !== null && readingNormalized.startsWith(group.textNormalized)) {
|
||||
const segments = segmentizeFurigana(reading.substring(text.length), readingNormalized.substring(text.length), groups, groupsStart + 1);
|
||||
if (segments !== null) {
|
||||
if (reading.startsWith(text)) { segments.unshift(createFuriganaSegment(text, '')); }
|
||||
else { segments.unshift(...getFuriganaKanaSegments(text, reading)); }
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
let result = null;
|
||||
for (let i = reading.length; i >= text.length; --i) {
|
||||
const segments = segmentizeFurigana(reading.substring(i), readingNormalized.substring(i), groups, groupsStart + 1);
|
||||
if (segments !== null) {
|
||||
if (result !== null) { return null; }
|
||||
segments.unshift(createFuriganaSegment(text, reading.substring(0, i)));
|
||||
result = segments;
|
||||
}
|
||||
if (groupCount === 1) { break; }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function distributeFurigana(term, reading) {
|
||||
if (reading === term) { return [createFuriganaSegment(term, '')]; }
|
||||
const groups = [];
|
||||
let groupPre = null;
|
||||
let isKanaPre = null;
|
||||
for (const c of term) {
|
||||
const isKana = isCodePointKana(c.codePointAt(0));
|
||||
if (isKana === isKanaPre) { groupPre.text += c; }
|
||||
else {
|
||||
groupPre = {isKana, text: c, textNormalized: null};
|
||||
groups.push(groupPre);
|
||||
isKanaPre = isKana;
|
||||
}
|
||||
}
|
||||
for (const group of groups) {
|
||||
if (group.isKana) { group.textNormalized = convertKatakanaToHiragana(group.text); }
|
||||
}
|
||||
const segments = segmentizeFurigana(reading, convertKatakanaToHiragana(reading), groups, 0);
|
||||
return segments !== null ? segments : [createFuriganaSegment(term, reading)];
|
||||
}
|
||||
function getStemLength(text1, text2) {
|
||||
const minLength = Math.min(text1.length, text2.length);
|
||||
if (minLength === 0) { return 0; }
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const char1 = text1.codePointAt(i);
|
||||
const char2 = text2.codePointAt(i);
|
||||
if (char1 !== char2) { break; }
|
||||
const charLength = String.fromCodePoint(char1).length;
|
||||
i += charLength;
|
||||
if (i >= minLength) {
|
||||
if (i > minLength) { i -= charLength; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
function distributeFuriganaInflected(term, reading, source) {
|
||||
const termNormalized = convertKatakanaToHiragana(term);
|
||||
const readingNormalized = convertKatakanaToHiragana(reading);
|
||||
const sourceNormalized = convertKatakanaToHiragana(source);
|
||||
let mainText = term;
|
||||
let stemLength = getStemLength(termNormalized, sourceNormalized);
|
||||
const readingStemLength = getStemLength(readingNormalized, sourceNormalized);
|
||||
if (readingStemLength > 0 && readingStemLength >= stemLength) {
|
||||
mainText = reading;
|
||||
stemLength = readingStemLength;
|
||||
reading = source.substring(0, stemLength) + reading.substring(stemLength);
|
||||
}
|
||||
const segments = [];
|
||||
if (stemLength > 0) {
|
||||
mainText = source.substring(0, stemLength) + mainText.substring(stemLength);
|
||||
const segments2 = distributeFurigana(mainText, reading);
|
||||
let consumed = 0;
|
||||
for (const segment of segments2) {
|
||||
const start = consumed;
|
||||
consumed += segment.text.length;
|
||||
if (consumed < stemLength) { segments.push(segment); }
|
||||
else if (consumed === stemLength) { segments.push(segment); break; }
|
||||
else {
|
||||
if (start < stemLength) { segments.push(createFuriganaSegment(mainText.substring(start, stemLength), '')); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stemLength < source.length) {
|
||||
const remainder = source.substring(stemLength);
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.reading.length === 0) { last.text += remainder; }
|
||||
else { segments.push(createFuriganaSegment(remainder, '')); }
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
function getPreferredHeadword(dictionaryEntries, token) {
|
||||
function appendDictionaryNames(target, value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
const candidates = [
|
||||
value.dictionary,
|
||||
value.dictionaryName,
|
||||
value.name,
|
||||
value.title,
|
||||
value.dictionaryTitle,
|
||||
value.dictionaryAlias
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
target.push(candidate.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
function getDictionaryEntryNames(entry) {
|
||||
const names = [];
|
||||
appendDictionaryNames(names, entry);
|
||||
for (const definition of entry?.definitions || []) {
|
||||
appendDictionaryNames(names, definition);
|
||||
}
|
||||
for (const frequency of entry?.frequencies || []) {
|
||||
appendDictionaryNames(names, frequency);
|
||||
}
|
||||
for (const pronunciation of entry?.pronunciations || []) {
|
||||
appendDictionaryNames(names, pronunciation);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
function isNameDictionaryEntry(entry) {
|
||||
if (!includeNameMatchMetadata || !entry || typeof entry !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return getDictionaryEntryNames(entry).some((name) => name.startsWith("SubMiner Character Dictionary"));
|
||||
}
|
||||
function hasExactPrimarySource(headword, token) {
|
||||
for (const src of headword.sources || []) {
|
||||
if (src.originalText !== token) { continue; }
|
||||
if (!src.isPrimary) { continue; }
|
||||
if (src.matchType !== 'exact') { continue; }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let matchedNameDictionary = false;
|
||||
if (includeNameMatchMetadata) {
|
||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
||||
if (!isNameDictionaryEntry(dictionaryEntry)) { continue; }
|
||||
for (const headword of dictionaryEntry.headwords || []) {
|
||||
if (!hasExactPrimarySource(headword, token)) { continue; }
|
||||
matchedNameDictionary = true;
|
||||
break;
|
||||
}
|
||||
if (matchedNameDictionary) { break; }
|
||||
}
|
||||
}
|
||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
||||
for (const headword of dictionaryEntry.headwords || []) {
|
||||
if (!hasExactPrimarySource(headword, token)) { continue; }
|
||||
return {
|
||||
term: headword.term,
|
||||
reading: headword.reading,
|
||||
isNameMatch: matchedNameDictionary || isNameDictionaryEntry(dictionaryEntry)
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
|
||||
function buildYomitanScanningScript(
|
||||
text: string,
|
||||
profileIndex: number,
|
||||
scanLength: number,
|
||||
includeNameMatchMetadata: boolean,
|
||||
): string {
|
||||
return `
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object") {
|
||||
reject(new Error("Invalid response from Yomitan backend"));
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||
return;
|
||||
}
|
||||
resolve(response.result);
|
||||
});
|
||||
});
|
||||
${YOMITAN_SCANNING_HELPERS}
|
||||
const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'};
|
||||
const text = ${JSON.stringify(text)};
|
||||
const details = {matchType: "exact", deinflect: true};
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const codePoint = text.codePointAt(i);
|
||||
const character = String.fromCodePoint(codePoint);
|
||||
const substring = text.substring(i, i + ${scanLength});
|
||||
const result = await invoke("termsFind", { text: substring, details, optionsContext: { index: ${profileIndex} } });
|
||||
const dictionaryEntries = Array.isArray(result?.dictionaryEntries) ? result.dictionaryEntries : [];
|
||||
const originalTextLength = typeof result?.originalTextLength === "number" ? result.originalTextLength : 0;
|
||||
if (dictionaryEntries.length > 0 && originalTextLength > 0 && (originalTextLength !== character.length || isCodePointJapanese(codePoint))) {
|
||||
const source = substring.substring(0, originalTextLength);
|
||||
const preferredHeadword = getPreferredHeadword(dictionaryEntries, source);
|
||||
if (preferredHeadword && typeof preferredHeadword.term === "string") {
|
||||
const reading = typeof preferredHeadword.reading === "string" ? preferredHeadword.reading : "";
|
||||
const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source);
|
||||
tokens.push({
|
||||
surface: segments.map((segment) => segment.text).join("") || source,
|
||||
reading: segments.map((segment) => typeof segment.reading === "string" ? segment.reading : "").join(""),
|
||||
headword: preferredHeadword.term,
|
||||
startPos: i,
|
||||
endPos: i + originalTextLength,
|
||||
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
|
||||
});
|
||||
i += originalTextLength;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += character.length;
|
||||
}
|
||||
return tokens;
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
export async function requestYomitanParseResults(
|
||||
text: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
@@ -583,6 +1003,61 @@ export async function requestYomitanParseResults(
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestYomitanScanTokens(
|
||||
text: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
options?: {
|
||||
includeNameMatchMetadata?: boolean;
|
||||
},
|
||||
): Promise<YomitanScanToken[] | null> {
|
||||
const yomitanExt = deps.getYomitanExt();
|
||||
if (!text || !yomitanExt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isReady = await ensureYomitanParserWindow(deps, logger);
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = await requestYomitanProfileMetadata(parserWindow, logger);
|
||||
const profileIndex = metadata?.profileIndex ?? 0;
|
||||
const scanLength = metadata?.scanLength ?? DEFAULT_YOMITAN_SCAN_LENGTH;
|
||||
|
||||
try {
|
||||
const rawResult = await parserWindow.webContents.executeJavaScript(
|
||||
buildYomitanScanningScript(
|
||||
text,
|
||||
profileIndex,
|
||||
scanLength,
|
||||
options?.includeNameMatchMetadata === true,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (isScanTokenArray(rawResult)) {
|
||||
return rawResult;
|
||||
}
|
||||
if (Array.isArray(rawResult)) {
|
||||
const selectedTokens = selectYomitanParseTokens(rawResult, () => false, 'headword');
|
||||
return (
|
||||
selectedTokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
startPos: token.startPos,
|
||||
endPos: token.endPos,
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
logger.error('Yomitan scanner request failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchYomitanTermFrequencies(
|
||||
parserWindow: BrowserWindow,
|
||||
termReadingList: YomitanTermReadingPair[],
|
||||
@@ -963,3 +1438,325 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string): string {
|
||||
return `
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object") {
|
||||
reject(new Error("Invalid response from Yomitan backend"));
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||
return;
|
||||
}
|
||||
resolve(response.result);
|
||||
});
|
||||
});
|
||||
|
||||
return await invoke(${actionLiteral}, ${paramsLiteral});
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
async function invokeYomitanBackendAction<T>(
|
||||
action: string,
|
||||
params: unknown,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<T | null> {
|
||||
const isReady = await ensureYomitanParserWindow(deps, logger);
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const script = buildYomitanInvokeScript(
|
||||
JSON.stringify(action),
|
||||
params === undefined ? 'undefined' : JSON.stringify(params),
|
||||
);
|
||||
|
||||
try {
|
||||
return (await parserWindow.webContents.executeJavaScript(script, true)) as T;
|
||||
} catch (err) {
|
||||
logger.error(`Yomitan backend action failed (${action}):`, (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultDictionarySettings(name: string, enabled: boolean): Record<string, unknown> {
|
||||
return {
|
||||
name,
|
||||
alias: name,
|
||||
enabled,
|
||||
allowSecondarySearches: false,
|
||||
definitionsCollapsible: 'not-collapsible',
|
||||
partsOfSpeechFilter: true,
|
||||
useDeinflections: true,
|
||||
styles: '',
|
||||
};
|
||||
}
|
||||
|
||||
function getTargetProfileIndices(
|
||||
optionsFull: Record<string, unknown>,
|
||||
profileScope: 'all' | 'active',
|
||||
): number[] {
|
||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||
if (profileScope === 'active') {
|
||||
const profileCurrent =
|
||||
typeof optionsFull.profileCurrent === 'number' && Number.isFinite(optionsFull.profileCurrent)
|
||||
? Math.max(0, Math.floor(optionsFull.profileCurrent))
|
||||
: 0;
|
||||
return profileCurrent < profiles.length ? [profileCurrent] : [];
|
||||
}
|
||||
return profiles.map((_profile, index) => index);
|
||||
}
|
||||
|
||||
export async function getYomitanDictionaryInfo(
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<YomitanDictionaryInfo[]> {
|
||||
const result = await invokeYomitanBackendAction<unknown>(
|
||||
'getDictionaryInfo',
|
||||
undefined,
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
if (!Array.isArray(result)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result
|
||||
.filter((entry): entry is Record<string, unknown> => isObject(entry))
|
||||
.map((entry) => {
|
||||
const title = typeof entry.title === 'string' ? entry.title.trim() : '';
|
||||
const revision = entry.revision;
|
||||
return {
|
||||
title,
|
||||
revision:
|
||||
typeof revision === 'string' || typeof revision === 'number' ? revision : undefined,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.title.length > 0);
|
||||
}
|
||||
|
||||
export async function getYomitanSettingsFull(
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const result = await invokeYomitanBackendAction<unknown>(
|
||||
'optionsGetFull',
|
||||
undefined,
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
return isObject(result) ? result : null;
|
||||
}
|
||||
|
||||
export async function setYomitanSettingsFull(
|
||||
value: Record<string, unknown>,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
source = 'subminer',
|
||||
): Promise<boolean> {
|
||||
const result = await invokeYomitanBackendAction<unknown>(
|
||||
'setAllSettings',
|
||||
{ value, source },
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
export async function importYomitanDictionaryFromZip(
|
||||
zipPath: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<boolean> {
|
||||
const normalizedZipPath = zipPath.trim();
|
||||
if (!normalizedZipPath || !fs.existsSync(normalizedZipPath)) {
|
||||
logger.error(`Dictionary ZIP not found: ${zipPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const archiveBase64 = fs.readFileSync(normalizedZipPath).toString('base64');
|
||||
const script = `
|
||||
(async () => {
|
||||
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64(
|
||||
${JSON.stringify(archiveBase64)},
|
||||
${JSON.stringify(path.basename(normalizedZipPath))}
|
||||
);
|
||||
return true;
|
||||
})();
|
||||
`;
|
||||
const result = await invokeYomitanSettingsAutomation<boolean>(script, deps, logger);
|
||||
return result === true;
|
||||
}
|
||||
|
||||
export async function deleteYomitanDictionaryByTitle(
|
||||
dictionaryTitle: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<boolean> {
|
||||
const normalizedTitle = dictionaryTitle.trim();
|
||||
if (!normalizedTitle) {
|
||||
return false;
|
||||
}
|
||||
const result = await invokeYomitanSettingsAutomation<boolean>(
|
||||
`
|
||||
(async () => {
|
||||
await globalThis.__subminerYomitanSettingsAutomation.deleteDictionary(
|
||||
${JSON.stringify(normalizedTitle)}
|
||||
);
|
||||
return true;
|
||||
})();
|
||||
`,
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
return result === true;
|
||||
}
|
||||
|
||||
export async function upsertYomitanDictionarySettings(
|
||||
dictionaryTitle: string,
|
||||
profileScope: 'all' | 'active',
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<boolean> {
|
||||
const normalizedTitle = dictionaryTitle.trim();
|
||||
if (!normalizedTitle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const optionsFull = await getYomitanSettingsFull(deps, logger);
|
||||
if (!optionsFull) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||
const indices = getTargetProfileIndices(optionsFull, profileScope);
|
||||
let changed = false;
|
||||
|
||||
for (const index of indices) {
|
||||
const profile = profiles[index];
|
||||
if (!isObject(profile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isObject(profile.options)) {
|
||||
profile.options = {};
|
||||
}
|
||||
const profileOptions = profile.options as Record<string, unknown>;
|
||||
if (!Array.isArray(profileOptions.dictionaries)) {
|
||||
profileOptions.dictionaries = [];
|
||||
}
|
||||
|
||||
const dictionaries = profileOptions.dictionaries as unknown[];
|
||||
const existingIndex = dictionaries.findIndex(
|
||||
(entry) =>
|
||||
isObject(entry) &&
|
||||
typeof (entry as { name?: unknown }).name === 'string' &&
|
||||
(entry as { name: string }).name.trim() === normalizedTitle,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const existing = dictionaries[existingIndex] as Record<string, unknown>;
|
||||
if (existing.enabled !== true) {
|
||||
existing.enabled = true;
|
||||
changed = true;
|
||||
}
|
||||
if (typeof existing.alias !== 'string' || existing.alias.trim().length === 0) {
|
||||
existing.alias = normalizedTitle;
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
dictionaries.push(createDefaultDictionarySettings(normalizedTitle, true));
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await setYomitanSettingsFull(optionsFull, deps, logger);
|
||||
}
|
||||
|
||||
export async function removeYomitanDictionarySettings(
|
||||
dictionaryTitle: string,
|
||||
profileScope: 'all' | 'active',
|
||||
mode: 'delete' | 'disable',
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<boolean> {
|
||||
const normalizedTitle = dictionaryTitle.trim();
|
||||
if (!normalizedTitle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const optionsFull = await getYomitanSettingsFull(deps, logger);
|
||||
if (!optionsFull) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||
const indices = getTargetProfileIndices(optionsFull, profileScope);
|
||||
let changed = false;
|
||||
|
||||
for (const index of indices) {
|
||||
const profile = profiles[index];
|
||||
if (!isObject(profile) || !isObject(profile.options)) {
|
||||
continue;
|
||||
}
|
||||
const profileOptions = profile.options as Record<string, unknown>;
|
||||
if (!Array.isArray(profileOptions.dictionaries)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dictionaries = profileOptions.dictionaries as unknown[];
|
||||
if (mode === 'delete') {
|
||||
const before = dictionaries.length;
|
||||
profileOptions.dictionaries = dictionaries.filter(
|
||||
(entry) =>
|
||||
!(
|
||||
isObject(entry) &&
|
||||
typeof (entry as { name?: unknown }).name === 'string' &&
|
||||
(entry as { name: string }).name.trim() === normalizedTitle
|
||||
),
|
||||
);
|
||||
if ((profileOptions.dictionaries as unknown[]).length !== before) {
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of dictionaries) {
|
||||
if (
|
||||
!isObject(entry) ||
|
||||
typeof (entry as { name?: unknown }).name !== 'string' ||
|
||||
(entry as { name: string }).name.trim() !== normalizedTitle
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const dictionaryEntry = entry as Record<string, unknown>;
|
||||
if (dictionaryEntry.enabled !== false) {
|
||||
dictionaryEntry.enabled = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await setYomitanSettingsFull(optionsFull, deps, logger);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -17,6 +18,41 @@ function readManifestVersion(manifestPath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function hashDirectoryContents(dirPath: string): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hash = createHash('sha256');
|
||||
const queue = [''];
|
||||
while (queue.length > 0) {
|
||||
const relativeDir = queue.shift()!;
|
||||
const absoluteDir = path.join(dirPath, relativeDir);
|
||||
const entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
const normalizedRelativePath = relativePath.split(path.sep).join('/');
|
||||
hash.update(normalizedRelativePath);
|
||||
if (entry.isDirectory()) {
|
||||
queue.push(relativePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
hash.update(fs.readFileSync(path.join(dirPath, relativePath)));
|
||||
}
|
||||
}
|
||||
|
||||
return hash.digest('hex');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
|
||||
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
|
||||
try {
|
||||
@@ -49,5 +85,35 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
const sourceHash = hashDirectoryContents(sourceDir);
|
||||
const targetHash = hashDirectoryContents(targetDir);
|
||||
return sourceHash === null || targetHash === null || sourceHash !== targetHash;
|
||||
}
|
||||
|
||||
export function ensureExtensionCopy(
|
||||
sourceDir: string,
|
||||
userDataPath: string,
|
||||
): {
|
||||
targetDir: string;
|
||||
copied: boolean;
|
||||
} {
|
||||
if (process.platform === 'win32') {
|
||||
return { targetDir: sourceDir, copied: false };
|
||||
}
|
||||
|
||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||
|
||||
let shouldCopy = !fs.existsSync(targetDir);
|
||||
if (!shouldCopy) {
|
||||
shouldCopy = hashDirectoryContents(sourceDir) !== hashDirectoryContents(targetDir);
|
||||
}
|
||||
|
||||
if (shouldCopy) {
|
||||
fs.mkdirSync(extensionsRoot, { recursive: true });
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
fs.cpSync(sourceDir, targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
return { targetDir, copied: shouldCopy };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
|
||||
import { ensureExtensionCopy, shouldCopyYomitanExtension } from './yomitan-extension-copy';
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
@@ -12,41 +16,69 @@ function writeFile(filePath: string, content: string): void {
|
||||
}
|
||||
|
||||
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
|
||||
const tempRoot = makeTempDir('subminer-yomitan-copy-');
|
||||
const sourceDir = path.join(tempRoot, 'source');
|
||||
const targetDir = path.join(tempRoot, 'target');
|
||||
|
||||
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||
|
||||
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||
|
||||
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'source-popup-main');
|
||||
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'target-popup-main');
|
||||
|
||||
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), true);
|
||||
});
|
||||
|
||||
test('shouldCopyYomitanExtension skips copy when versions and watched scripts match', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
|
||||
test('shouldCopyYomitanExtension skips copy when extension contents match', () => {
|
||||
const tempRoot = makeTempDir('subminer-yomitan-copy-');
|
||||
const sourceDir = path.join(tempRoot, 'source');
|
||||
const targetDir = path.join(tempRoot, 'target');
|
||||
|
||||
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||
|
||||
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||
|
||||
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
|
||||
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
|
||||
|
||||
writeFile(path.join(sourceDir, 'js', 'display', 'display.js'), 'same-display');
|
||||
writeFile(path.join(targetDir, 'js', 'display', 'display.js'), 'same-display');
|
||||
|
||||
writeFile(path.join(sourceDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
|
||||
writeFile(path.join(targetDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
|
||||
|
||||
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), false);
|
||||
});
|
||||
|
||||
test('ensureExtensionCopy refreshes copied extension when display files change', () => {
|
||||
const sourceRoot = makeTempDir('subminer-yomitan-src-');
|
||||
const userDataRoot = makeTempDir('subminer-yomitan-user-');
|
||||
|
||||
const sourceDir = path.join(sourceRoot, 'yomitan');
|
||||
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
|
||||
|
||||
fs.mkdirSync(path.join(sourceDir, 'js', 'display'), { recursive: true });
|
||||
fs.mkdirSync(path.join(targetDir, 'js', 'display'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||
fs.writeFileSync(
|
||||
path.join(sourceDir, 'js', 'display', 'structured-content-generator.js'),
|
||||
'new display code',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
|
||||
'old display code',
|
||||
);
|
||||
|
||||
const result = ensureExtensionCopy(sourceDir, userDataRoot);
|
||||
|
||||
assert.equal(result.targetDir, targetDir);
|
||||
assert.equal(result.copied, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
|
||||
'utf8',
|
||||
),
|
||||
'new display code',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { BrowserWindow, Extension, session } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '../../logger';
|
||||
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
|
||||
import { ensureExtensionCopy } from './yomitan-extension-copy';
|
||||
import {
|
||||
getYomitanExtensionSearchPaths,
|
||||
resolveExistingYomitanExtensionPath,
|
||||
} from './yomitan-extension-paths';
|
||||
|
||||
const logger = createLogger('main:yomitan-extension-loader');
|
||||
|
||||
export interface YomitanExtensionLoaderDeps {
|
||||
userDataPath: string;
|
||||
extensionPath?: string;
|
||||
getYomitanParserWindow: () => BrowserWindow | null;
|
||||
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||
@@ -15,52 +19,28 @@ export interface YomitanExtensionLoaderDeps {
|
||||
setYomitanExtension: (extension: Extension | null) => void;
|
||||
}
|
||||
|
||||
function ensureExtensionCopy(sourceDir: string, userDataPath: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
return sourceDir;
|
||||
}
|
||||
|
||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||
|
||||
const shouldCopy = shouldCopyYomitanExtension(sourceDir, targetDir);
|
||||
|
||||
if (shouldCopy) {
|
||||
fs.mkdirSync(extensionsRoot, { recursive: true });
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
fs.cpSync(sourceDir, targetDir, { recursive: true });
|
||||
logger.info(`Copied yomitan extension to ${targetDir}`);
|
||||
}
|
||||
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
export async function loadYomitanExtension(
|
||||
deps: YomitanExtensionLoaderDeps,
|
||||
): Promise<Extension | null> {
|
||||
const searchPaths = [
|
||||
path.join(__dirname, '..', '..', 'vendor', 'yomitan'),
|
||||
path.join(__dirname, '..', '..', '..', 'vendor', 'yomitan'),
|
||||
path.join(process.resourcesPath, 'yomitan'),
|
||||
'/usr/share/SubMiner/yomitan',
|
||||
path.join(deps.userDataPath, 'yomitan'),
|
||||
];
|
||||
|
||||
let extPath: string | null = null;
|
||||
for (const p of searchPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
extPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const searchPaths = getYomitanExtensionSearchPaths({
|
||||
explicitPath: deps.extensionPath,
|
||||
moduleDir: __dirname,
|
||||
resourcesPath: process.resourcesPath,
|
||||
userDataPath: deps.userDataPath,
|
||||
});
|
||||
let extPath = resolveExistingYomitanExtensionPath(searchPaths, fs.existsSync);
|
||||
|
||||
if (!extPath) {
|
||||
logger.error('Yomitan extension not found in any search path');
|
||||
logger.error('Install Yomitan to one of:', searchPaths);
|
||||
logger.error('Run `bun run build:yomitan` or install Yomitan to one of:', searchPaths);
|
||||
return null;
|
||||
}
|
||||
|
||||
extPath = ensureExtensionCopy(extPath, deps.userDataPath);
|
||||
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
|
||||
if (extensionCopy.copied) {
|
||||
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
|
||||
}
|
||||
extPath = extensionCopy.targetDir;
|
||||
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
if (parserWindow && !parserWindow.isDestroyed()) {
|
||||
|
||||
50
src/core/services/yomitan-extension-paths.test.ts
Normal file
50
src/core/services/yomitan-extension-paths.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getYomitanExtensionSearchPaths,
|
||||
resolveExistingYomitanExtensionPath,
|
||||
} from './yomitan-extension-paths';
|
||||
|
||||
test('getYomitanExtensionSearchPaths prioritizes generated build output before packaged fallbacks', () => {
|
||||
const searchPaths = getYomitanExtensionSearchPaths({
|
||||
cwd: '/repo',
|
||||
moduleDir: '/repo/dist/core/services',
|
||||
resourcesPath: '/opt/SubMiner/resources',
|
||||
userDataPath: '/Users/kyle/.config/SubMiner',
|
||||
});
|
||||
|
||||
assert.deepEqual(searchPaths, [
|
||||
path.join('/repo', 'build', 'yomitan'),
|
||||
path.join('/opt/SubMiner/resources', 'yomitan'),
|
||||
'/usr/share/SubMiner/yomitan',
|
||||
path.join('/Users/kyle/.config/SubMiner', 'yomitan'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveExistingYomitanExtensionPath returns first manifest-backed candidate', () => {
|
||||
const existing = new Set<string>([
|
||||
path.join('/repo', 'build', 'yomitan', 'manifest.json'),
|
||||
path.join('/repo', 'vendor', 'subminer-yomitan', 'ext', 'manifest.json'),
|
||||
]);
|
||||
|
||||
const resolved = resolveExistingYomitanExtensionPath(
|
||||
[
|
||||
path.join('/repo', 'build', 'yomitan'),
|
||||
path.join('/repo', 'vendor', 'subminer-yomitan', 'ext'),
|
||||
],
|
||||
(candidate) => existing.has(candidate),
|
||||
);
|
||||
|
||||
assert.equal(resolved, path.join('/repo', 'build', 'yomitan'));
|
||||
});
|
||||
|
||||
test('resolveExistingYomitanExtensionPath ignores source tree without built manifest', () => {
|
||||
const resolved = resolveExistingYomitanExtensionPath(
|
||||
[path.join('/repo', 'vendor', 'subminer-yomitan', 'ext')],
|
||||
() => false,
|
||||
);
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
60
src/core/services/yomitan-extension-paths.ts
Normal file
60
src/core/services/yomitan-extension-paths.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export interface YomitanExtensionPathOptions {
|
||||
explicitPath?: string;
|
||||
cwd?: string;
|
||||
moduleDir?: string;
|
||||
resourcesPath?: string;
|
||||
userDataPath?: string;
|
||||
}
|
||||
|
||||
function pushUnique(values: string[], candidate: string | null | undefined): void {
|
||||
if (!candidate || values.includes(candidate)) {
|
||||
return;
|
||||
}
|
||||
values.push(candidate);
|
||||
}
|
||||
|
||||
export function getYomitanExtensionSearchPaths(
|
||||
options: YomitanExtensionPathOptions = {},
|
||||
): string[] {
|
||||
const searchPaths: string[] = [];
|
||||
|
||||
pushUnique(searchPaths, options.explicitPath ? path.resolve(options.explicitPath) : null);
|
||||
pushUnique(searchPaths, options.cwd ? path.resolve(options.cwd, 'build', 'yomitan') : null);
|
||||
pushUnique(
|
||||
searchPaths,
|
||||
options.moduleDir
|
||||
? path.resolve(options.moduleDir, '..', '..', '..', 'build', 'yomitan')
|
||||
: null,
|
||||
);
|
||||
pushUnique(
|
||||
searchPaths,
|
||||
options.resourcesPath ? path.join(options.resourcesPath, 'yomitan') : null,
|
||||
);
|
||||
pushUnique(searchPaths, '/usr/share/SubMiner/yomitan');
|
||||
pushUnique(searchPaths, options.userDataPath ? path.join(options.userDataPath, 'yomitan') : null);
|
||||
|
||||
return searchPaths;
|
||||
}
|
||||
|
||||
export function resolveExistingYomitanExtensionPath(
|
||||
searchPaths: string[],
|
||||
existsSync: (path: string) => boolean = fs.existsSync,
|
||||
): string | null {
|
||||
for (const candidate of searchPaths) {
|
||||
if (existsSync(path.join(candidate, 'manifest.json'))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveYomitanExtensionPath(
|
||||
options: YomitanExtensionPathOptions = {},
|
||||
existsSync: (path: string) => boolean = fs.existsSync,
|
||||
): string | null {
|
||||
return resolveExistingYomitanExtensionPath(getYomitanExtensionSearchPaths(options), existsSync);
|
||||
}
|
||||
235
src/core/services/yomitan-structured-content-generator.test.ts
Normal file
235
src/core/services/yomitan-structured-content-generator.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { resolveYomitanExtensionPath } from './yomitan-extension-paths';
|
||||
|
||||
class FakeStyle {
|
||||
private values = new Map<string, string>();
|
||||
|
||||
set width(value: string) {
|
||||
this.values.set('width', value);
|
||||
}
|
||||
|
||||
get width(): string {
|
||||
return this.values.get('width') ?? '';
|
||||
}
|
||||
|
||||
set height(value: string) {
|
||||
this.values.set('height', value);
|
||||
}
|
||||
|
||||
get height(): string {
|
||||
return this.values.get('height') ?? '';
|
||||
}
|
||||
|
||||
set border(value: string) {
|
||||
this.values.set('border', value);
|
||||
}
|
||||
|
||||
set borderRadius(value: string) {
|
||||
this.values.set('borderRadius', value);
|
||||
}
|
||||
|
||||
set paddingTop(value: string) {
|
||||
this.values.set('paddingTop', value);
|
||||
}
|
||||
|
||||
setProperty(name: string, value: string): void {
|
||||
this.values.set(name, value);
|
||||
}
|
||||
|
||||
removeProperty(name: string): void {
|
||||
this.values.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNode {
|
||||
public childNodes: Array<FakeNode | FakeTextNode> = [];
|
||||
public className = '';
|
||||
public dataset: Record<string, string> = {};
|
||||
public style = new FakeStyle();
|
||||
public textContent: string | null = null;
|
||||
public title = '';
|
||||
public href = '';
|
||||
public rel = '';
|
||||
public target = '';
|
||||
public width = 0;
|
||||
public height = 0;
|
||||
public parentNode: FakeNode | null = null;
|
||||
|
||||
constructor(public readonly tagName: string) {}
|
||||
|
||||
appendChild(node: FakeNode | FakeTextNode): FakeNode | FakeTextNode {
|
||||
if (node instanceof FakeNode) {
|
||||
node.parentNode = this;
|
||||
}
|
||||
this.childNodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
addEventListener(): void {}
|
||||
|
||||
closest(selector: string): FakeNode | null {
|
||||
if (!selector.startsWith('.')) {
|
||||
return null;
|
||||
}
|
||||
const className = selector.slice(1);
|
||||
let current: FakeNode | null = this;
|
||||
while (current) {
|
||||
if (current.className === className) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
removeAttribute(name: string): void {
|
||||
if (name === 'src') {
|
||||
return;
|
||||
}
|
||||
if (name === 'href') {
|
||||
this.href = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeImageElement extends FakeNode {
|
||||
public onload: (() => void) | null = null;
|
||||
public onerror: ((error: unknown) => void) | null = null;
|
||||
private _src = '';
|
||||
|
||||
constructor() {
|
||||
super('img');
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this._src = value;
|
||||
this.onload?.();
|
||||
}
|
||||
|
||||
get src(): string {
|
||||
return this._src;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeCanvasElement extends FakeNode {
|
||||
constructor() {
|
||||
super('canvas');
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTextNode {
|
||||
constructor(public readonly data: string) {}
|
||||
}
|
||||
|
||||
class FakeDocument {
|
||||
createElement(tagName: string): FakeNode {
|
||||
if (tagName === 'img') {
|
||||
return new FakeImageElement();
|
||||
}
|
||||
if (tagName === 'canvas') {
|
||||
return new FakeCanvasElement();
|
||||
}
|
||||
return new FakeNode(tagName);
|
||||
}
|
||||
|
||||
createTextNode(data: string): FakeTextNode {
|
||||
return new FakeTextNode(data);
|
||||
}
|
||||
}
|
||||
|
||||
function findFirstByClass(node: FakeNode, className: string): FakeNode | null {
|
||||
if (node.className === className) {
|
||||
return node;
|
||||
}
|
||||
for (const child of node.childNodes) {
|
||||
if (child instanceof FakeNode) {
|
||||
const result = findFirstByClass(child, className);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test('StructuredContentGenerator uses direct img loading for popup glossary images', async () => {
|
||||
const yomitanRoot = resolveYomitanExtensionPath({ cwd: process.cwd() });
|
||||
assert.ok(yomitanRoot, 'Run `bun run build:yomitan` before Yomitan integration tests.');
|
||||
|
||||
const { DisplayContentManager } = await import(
|
||||
pathToFileURL(path.join(yomitanRoot, 'js', 'display', 'display-content-manager.js')).href
|
||||
);
|
||||
const { StructuredContentGenerator } = await import(
|
||||
pathToFileURL(path.join(yomitanRoot, 'js', 'display', 'structured-content-generator.js')).href
|
||||
);
|
||||
|
||||
const createObjectURLCalls: string[] = [];
|
||||
const revokeObjectURLCalls: string[] = [];
|
||||
const originalHtmlImageElement = globalThis.HTMLImageElement;
|
||||
const originalHtmlCanvasElement = globalThis.HTMLCanvasElement;
|
||||
const originalCreateObjectURL = URL.createObjectURL;
|
||||
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||
globalThis.HTMLImageElement = FakeImageElement as unknown as typeof HTMLImageElement;
|
||||
globalThis.HTMLCanvasElement = FakeCanvasElement as unknown as typeof HTMLCanvasElement;
|
||||
URL.createObjectURL = (_blob: Blob) => {
|
||||
const value = 'blob:test-image';
|
||||
createObjectURLCalls.push(value);
|
||||
return value;
|
||||
};
|
||||
URL.revokeObjectURL = (value: string) => {
|
||||
revokeObjectURLCalls.push(value);
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = new DisplayContentManager({
|
||||
application: {
|
||||
api: {
|
||||
getMedia: async () => [
|
||||
{
|
||||
content: Buffer.from('png-bytes').toString('base64'),
|
||||
mediaType: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const generator = new StructuredContentGenerator(manager, new FakeDocument(), {
|
||||
devicePixelRatio: 1,
|
||||
navigator: { userAgent: 'Mozilla/5.0' },
|
||||
});
|
||||
|
||||
const node = generator.createDefinitionImage(
|
||||
{
|
||||
tag: 'img',
|
||||
path: 'img/test.png',
|
||||
width: 8,
|
||||
height: 11,
|
||||
title: 'Alpha',
|
||||
background: true,
|
||||
},
|
||||
'SubMiner Character Dictionary',
|
||||
) as FakeNode;
|
||||
|
||||
await manager.executeMediaRequests();
|
||||
|
||||
const imageNode = findFirstByClass(node, 'gloss-image');
|
||||
assert.ok(imageNode);
|
||||
assert.equal(imageNode.tagName, 'img');
|
||||
assert.equal((imageNode as FakeImageElement).src, 'blob:test-image');
|
||||
assert.equal(node.dataset.imageLoadState, 'loaded');
|
||||
assert.equal(node.dataset.hasImage, 'true');
|
||||
assert.deepEqual(createObjectURLCalls, ['blob:test-image']);
|
||||
|
||||
manager.unloadAll();
|
||||
assert.deepEqual(revokeObjectURLCalls, ['blob:test-image']);
|
||||
} finally {
|
||||
globalThis.HTMLImageElement = originalHtmlImageElement;
|
||||
globalThis.HTMLCanvasElement = originalHtmlCanvasElement;
|
||||
URL.createObjectURL = originalCreateObjectURL;
|
||||
URL.revokeObjectURL = originalRevokeObjectURL;
|
||||
}
|
||||
});
|
||||
70
src/dead-architecture-cleanup.test.ts
Normal file
70
src/dead-architecture-cleanup.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEAD_MODULE_PATHS = [
|
||||
'src/translators/index.ts',
|
||||
'src/subsync/engines.ts',
|
||||
'src/subtitle/pipeline.ts',
|
||||
'src/subtitle/stages/merge.ts',
|
||||
'src/subtitle/stages/normalize.ts',
|
||||
'src/subtitle/stages/normalize.test.ts',
|
||||
'src/subtitle/stages/tokenize.ts',
|
||||
'src/tokenizers/index.ts',
|
||||
'src/token-mergers/index.ts',
|
||||
] as const;
|
||||
|
||||
const FORBIDDEN_IMPORT_PATTERNS = [
|
||||
/from ['"]\.\.?\/tokenizers['"]/,
|
||||
/from ['"]\.\.?\/token-mergers['"]/,
|
||||
/from ['"]\.\.?\/subtitle\/pipeline['"]/,
|
||||
/from ['"]\.\.?\/subsync\/engines['"]/,
|
||||
/from ['"]\.\.?\/translators['"]/,
|
||||
] as const;
|
||||
|
||||
function readWorkspaceFile(relativePath: string): string {
|
||||
return fs.readFileSync(path.join(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function collectSourceFiles(rootDir: string): string[] {
|
||||
const absoluteRoot = path.join(process.cwd(), rootDir);
|
||||
const out: string[] = [];
|
||||
|
||||
const visit = (currentDir: string) => {
|
||||
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!fullPath.endsWith('.ts') && !fullPath.endsWith('.tsx')) {
|
||||
continue;
|
||||
}
|
||||
out.push(path.relative(process.cwd(), fullPath).replaceAll('\\', '/'));
|
||||
}
|
||||
};
|
||||
|
||||
visit(absoluteRoot);
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
test('dead registry and pipeline modules stay removed from the repository', () => {
|
||||
for (const relativePath of DEAD_MODULE_PATHS) {
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(process.cwd(), relativePath)),
|
||||
false,
|
||||
`${relativePath} should stay deleted`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('live source tree no longer imports dead registry and pipeline modules', () => {
|
||||
for (const relativePath of collectSourceFiles('src')) {
|
||||
const source = readWorkspaceFile(relativePath);
|
||||
for (const pattern of FORBIDDEN_IMPORT_PATTERNS) {
|
||||
assert.doesNotMatch(source, pattern, `${relativePath} should not import ${pattern.source}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
82
src/generate-config-example.test.ts
Normal file
82
src/generate-config-example.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
resolveConfigExampleOutputPaths,
|
||||
writeConfigExampleArtifacts,
|
||||
} from './generate-config-example';
|
||||
|
||||
function createWorkspace(name: string): string {
|
||||
const baseDir = path.join(process.cwd(), '.tmp', 'generate-config-example-test');
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
return fs.mkdtempSync(path.join(baseDir, `${name}-`));
|
||||
}
|
||||
|
||||
test('resolveConfigExampleOutputPaths includes sibling docs repo and never local docs/public', () => {
|
||||
const workspace = createWorkspace('with-docs-repo');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
const docsRepoRoot = path.join(workspace, 'subminer-docs');
|
||||
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
fs.mkdirSync(docsRepoRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
const outputPaths = resolveConfigExampleOutputPaths({ cwd: projectRoot });
|
||||
|
||||
assert.deepEqual(outputPaths, [
|
||||
path.join(projectRoot, 'config.example.jsonc'),
|
||||
path.join(docsRepoRoot, 'public', 'config.example.jsonc'),
|
||||
]);
|
||||
assert.equal(
|
||||
outputPaths.includes(path.join(projectRoot, 'docs', 'public', 'config.example.jsonc')),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveConfigExampleOutputPaths stays repo-local when sibling docs repo is absent', () => {
|
||||
const workspace = createWorkspace('without-docs-repo');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
const outputPaths = resolveConfigExampleOutputPaths({ cwd: projectRoot });
|
||||
|
||||
assert.deepEqual(outputPaths, [path.join(projectRoot, 'config.example.jsonc')]);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writeConfigExampleArtifacts creates parent directories for resolved outputs', () => {
|
||||
const workspace = createWorkspace('write-artifacts');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
const docsRepoRoot = path.join(workspace, 'subminer-docs');
|
||||
const template = '{\n "ok": true\n}\n';
|
||||
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
fs.mkdirSync(docsRepoRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
const writtenPaths = writeConfigExampleArtifacts(template, {
|
||||
cwd: projectRoot,
|
||||
deps: { log: () => {} },
|
||||
});
|
||||
|
||||
assert.deepEqual(writtenPaths, [
|
||||
path.join(projectRoot, 'config.example.jsonc'),
|
||||
path.join(docsRepoRoot, 'public', 'config.example.jsonc'),
|
||||
]);
|
||||
assert.equal(fs.readFileSync(path.join(projectRoot, 'config.example.jsonc'), 'utf8'), template);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(docsRepoRoot, 'public', 'config.example.jsonc'), 'utf8'),
|
||||
template,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -2,18 +2,62 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { DEFAULT_CONFIG, generateConfigTemplate } from './config';
|
||||
|
||||
function main(): void {
|
||||
const template = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
const outputPaths = [
|
||||
path.join(process.cwd(), 'config.example.jsonc'),
|
||||
path.join(process.cwd(), 'docs', 'public', 'config.example.jsonc'),
|
||||
];
|
||||
type ConfigExampleFsDeps = {
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
|
||||
log?: (message: string) => void;
|
||||
};
|
||||
|
||||
for (const outputPath of outputPaths) {
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, template, 'utf-8');
|
||||
console.log(`Generated ${outputPath}`);
|
||||
export function resolveConfigExampleOutputPaths(options?: {
|
||||
cwd?: string;
|
||||
docsRepoName?: string;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
}): string[] {
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
const existsSync = options?.existsSync ?? fs.existsSync;
|
||||
const docsRepoName = options?.docsRepoName ?? 'subminer-docs';
|
||||
const outputPaths = [path.join(cwd, 'config.example.jsonc')];
|
||||
const docsRepoRoot = path.resolve(cwd, '..', docsRepoName);
|
||||
|
||||
if (existsSync(docsRepoRoot)) {
|
||||
outputPaths.push(path.join(docsRepoRoot, 'public', 'config.example.jsonc'));
|
||||
}
|
||||
|
||||
return outputPaths;
|
||||
}
|
||||
|
||||
main();
|
||||
export function writeConfigExampleArtifacts(
|
||||
template: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
docsRepoName?: string;
|
||||
deps?: ConfigExampleFsDeps;
|
||||
},
|
||||
): string[] {
|
||||
const mkdirSync = options?.deps?.mkdirSync ?? fs.mkdirSync;
|
||||
const writeFileSync = options?.deps?.writeFileSync ?? fs.writeFileSync;
|
||||
const log = options?.deps?.log ?? console.log;
|
||||
const outputPaths = resolveConfigExampleOutputPaths({
|
||||
cwd: options?.cwd,
|
||||
docsRepoName: options?.docsRepoName,
|
||||
existsSync: options?.deps?.existsSync,
|
||||
});
|
||||
|
||||
for (const outputPath of outputPaths) {
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, template, 'utf-8');
|
||||
log(`Generated ${outputPath}`);
|
||||
}
|
||||
|
||||
return outputPaths;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const template = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
writeConfigExampleArtifacts(template);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
normalizeStartupArgv,
|
||||
sanitizeHelpEnv,
|
||||
sanitizeStartupEnv,
|
||||
sanitizeBackgroundEnv,
|
||||
shouldDetachBackgroundLaunch,
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('normalizeStartupArgv defaults no-arg startup to --start --background', () => {
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--start',
|
||||
'--background',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
normalizeStartupArgv(['SubMiner.AppImage', '--password-store', 'gnome-libsecret'], {}),
|
||||
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
|
||||
);
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--background'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--background',
|
||||
'--start',
|
||||
]);
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--help'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--help',
|
||||
]);
|
||||
});
|
||||
|
||||
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
|
||||
@@ -14,6 +37,14 @@ test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], { ELECTRON_RUN_AS_NODE: '1' }), false);
|
||||
});
|
||||
|
||||
test('sanitizeStartupEnv suppresses warnings and lsfg layer', () => {
|
||||
const env = sanitizeStartupEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
});
|
||||
assert.equal(env.NODE_NO_WARNINGS, '1');
|
||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
||||
});
|
||||
|
||||
test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => {
|
||||
const env = sanitizeHelpEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { CliArgs, parseArgs, shouldStartApp } from './cli/args';
|
||||
|
||||
const BACKGROUND_ARG = '--background';
|
||||
const START_ARG = '--start';
|
||||
const PASSWORD_STORE_ARG = '--password-store';
|
||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||
|
||||
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
|
||||
@@ -9,10 +11,54 @@ function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
|
||||
}
|
||||
}
|
||||
|
||||
function removePassiveStartupArgs(argv: string[]): string[] {
|
||||
const filtered: string[] = [];
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg) continue;
|
||||
|
||||
if (arg === PASSWORD_STORE_ARG) {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('--')) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith(`${PASSWORD_STORE_ARG}=`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
filtered.push(arg);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function parseCliArgs(argv: string[]): CliArgs {
|
||||
return parseArgs(argv);
|
||||
}
|
||||
|
||||
export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): string[] {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return argv;
|
||||
|
||||
const effectiveArgs = removePassiveStartupArgs(argv.slice(1));
|
||||
if (effectiveArgs.length === 0) {
|
||||
return [...argv, START_ARG, BACKGROUND_ARG];
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveArgs.length === 1 &&
|
||||
effectiveArgs[0] === BACKGROUND_ARG &&
|
||||
!argv.includes(START_ARG)
|
||||
) {
|
||||
return [...argv, START_ARG];
|
||||
}
|
||||
|
||||
return argv;
|
||||
}
|
||||
|
||||
export function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||
if (!argv.includes(BACKGROUND_ARG)) return false;
|
||||
@@ -26,7 +72,7 @@ export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessE
|
||||
return args.help && !shouldStartApp(args);
|
||||
}
|
||||
|
||||
export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env = { ...baseEnv };
|
||||
if (!env.NODE_NO_WARNINGS) {
|
||||
env.NODE_NO_WARNINGS = '1';
|
||||
@@ -35,8 +81,12 @@ export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return env;
|
||||
}
|
||||
|
||||
export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return sanitizeStartupEnv(baseEnv);
|
||||
}
|
||||
|
||||
export function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env = sanitizeHelpEnv(baseEnv);
|
||||
const env = sanitizeStartupEnv(baseEnv);
|
||||
env[BACKGROUND_CHILD_ENV] = '1';
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { printHelp } from './cli/help';
|
||||
import {
|
||||
normalizeStartupArgv,
|
||||
sanitizeStartupEnv,
|
||||
sanitizeBackgroundEnv,
|
||||
sanitizeHelpEnv,
|
||||
shouldDetachBackgroundLaunch,
|
||||
@@ -9,6 +11,21 @@ import {
|
||||
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
|
||||
function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
|
||||
if (sanitizedEnv.NODE_NO_WARNINGS) {
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
}
|
||||
|
||||
if (sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
process.env.VK_INSTANCE_LAYERS = sanitizedEnv.VK_INSTANCE_LAYERS;
|
||||
} else {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
}
|
||||
|
||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||
|
||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||
const child = spawn(process.execPath, process.argv.slice(1), {
|
||||
detached: true,
|
||||
|
||||
722
src/main.ts
722
src/main.ts
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
|
||||
}
|
||||
|
||||
export interface AppReadyRuntimeDepsFactoryInput {
|
||||
ensureDefaultConfigBootstrap: AppReadyRuntimeDeps['ensureDefaultConfigBootstrap'];
|
||||
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
|
||||
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
|
||||
createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
|
||||
@@ -30,8 +31,12 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
||||
setSecondarySubMode: AppReadyRuntimeDeps['setSecondarySubMode'];
|
||||
defaultSecondarySubMode: AppReadyRuntimeDeps['defaultSecondarySubMode'];
|
||||
defaultWebsocketPort: AppReadyRuntimeDeps['defaultWebsocketPort'];
|
||||
defaultAnnotationWebsocketPort: AppReadyRuntimeDeps['defaultAnnotationWebsocketPort'];
|
||||
defaultTexthookerPort: AppReadyRuntimeDeps['defaultTexthookerPort'];
|
||||
hasMpvWebsocketPlugin: AppReadyRuntimeDeps['hasMpvWebsocketPlugin'];
|
||||
startSubtitleWebsocket: AppReadyRuntimeDeps['startSubtitleWebsocket'];
|
||||
startAnnotationWebsocket: AppReadyRuntimeDeps['startAnnotationWebsocket'];
|
||||
startTexthooker: AppReadyRuntimeDeps['startTexthooker'];
|
||||
log: AppReadyRuntimeDeps['log'];
|
||||
setLogLevel: AppReadyRuntimeDeps['setLogLevel'];
|
||||
createMecabTokenizerAndCheck: AppReadyRuntimeDeps['createMecabTokenizerAndCheck'];
|
||||
@@ -39,10 +44,12 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
||||
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
|
||||
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
|
||||
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
|
||||
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
|
||||
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
|
||||
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
|
||||
texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode'];
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
|
||||
setVisibleOverlayVisible: AppReadyRuntimeDeps['setVisibleOverlayVisible'];
|
||||
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
|
||||
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
|
||||
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
|
||||
@@ -74,6 +81,7 @@ export function createAppReadyRuntimeDeps(
|
||||
params: AppReadyRuntimeDepsFactoryInput,
|
||||
): AppReadyRuntimeDeps {
|
||||
return {
|
||||
ensureDefaultConfigBootstrap: params.ensureDefaultConfigBootstrap,
|
||||
loadSubtitlePosition: params.loadSubtitlePosition,
|
||||
resolveKeybindings: params.resolveKeybindings,
|
||||
createMpvClient: params.createMpvClient,
|
||||
@@ -85,8 +93,12 @@ export function createAppReadyRuntimeDeps(
|
||||
setSecondarySubMode: params.setSecondarySubMode,
|
||||
defaultSecondarySubMode: params.defaultSecondarySubMode,
|
||||
defaultWebsocketPort: params.defaultWebsocketPort,
|
||||
defaultAnnotationWebsocketPort: params.defaultAnnotationWebsocketPort,
|
||||
defaultTexthookerPort: params.defaultTexthookerPort,
|
||||
hasMpvWebsocketPlugin: params.hasMpvWebsocketPlugin,
|
||||
startSubtitleWebsocket: params.startSubtitleWebsocket,
|
||||
startAnnotationWebsocket: params.startAnnotationWebsocket,
|
||||
startTexthooker: params.startTexthooker,
|
||||
log: params.log,
|
||||
setLogLevel: params.setLogLevel,
|
||||
createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck,
|
||||
@@ -94,11 +106,13 @@ export function createAppReadyRuntimeDeps(
|
||||
createImmersionTracker: params.createImmersionTracker,
|
||||
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
|
||||
loadYomitanExtension: params.loadYomitanExtension,
|
||||
handleFirstRunSetup: params.handleFirstRunSetup,
|
||||
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups: params.startBackgroundWarmups,
|
||||
texthookerOnlyMode: params.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig:
|
||||
params.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
setVisibleOverlayVisible: params.setVisibleOverlayVisible,
|
||||
initializeOverlayRuntime: params.initializeOverlayRuntime,
|
||||
handleInitialArgs: params.handleInitialArgs,
|
||||
onCriticalConfigErrors: params.onCriticalConfigErrors,
|
||||
|
||||
2047
src/main/character-dictionary-runtime.test.ts
Normal file
2047
src/main/character-dictionary-runtime.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1807
src/main/character-dictionary-runtime.ts
Normal file
1807
src/main/character-dictionary-runtime.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -33,6 +34,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate'];
|
||||
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||
openYomitanSettings: () => void;
|
||||
@@ -94,11 +96,15 @@ function createCliCommandDepsFromContext(
|
||||
getQueueStatus: context.getAnilistQueueStatus,
|
||||
retryQueueNow: context.retryAnilistQueueNow,
|
||||
},
|
||||
dictionary: {
|
||||
generate: context.generateCharacterDictionary,
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: context.openJellyfinSetup,
|
||||
runCommand: context.runJellyfinCommand,
|
||||
},
|
||||
ui: {
|
||||
openFirstRunSetup: context.openFirstRunSetup,
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
||||
|
||||
@@ -151,11 +151,15 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
getQueueStatus: CliCommandDepsRuntimeOptions['anilist']['getQueueStatus'];
|
||||
retryQueueNow: CliCommandDepsRuntimeOptions['anilist']['retryQueueNow'];
|
||||
};
|
||||
dictionary: {
|
||||
generate: CliCommandDepsRuntimeOptions['dictionary']['generate'];
|
||||
};
|
||||
jellyfin: {
|
||||
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
|
||||
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
|
||||
};
|
||||
ui: {
|
||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
||||
@@ -182,6 +186,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
|
||||
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
|
||||
resolveProxyCommandOsd?: HandleMpvCommandFromIpcOptions['resolveProxyCommandOsd'];
|
||||
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
|
||||
hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions['hasRuntimeOptionsManager'];
|
||||
}
|
||||
@@ -296,11 +301,15 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
getQueueStatus: params.anilist.getQueueStatus,
|
||||
retryQueueNow: params.anilist.retryQueueNow,
|
||||
},
|
||||
dictionary: {
|
||||
generate: params.dictionary.generate,
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: params.jellyfin.openSetup,
|
||||
runCommand: params.jellyfin.runCommand,
|
||||
},
|
||||
ui: {
|
||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||
@@ -331,6 +340,7 @@ export function createMpvCommandRuntimeServiceDeps(
|
||||
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
|
||||
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
|
||||
mpvSendCommand: params.mpvSendCommand,
|
||||
resolveProxyCommandOsd: params.resolveProxyCommandOsd,
|
||||
isMpvConnected: params.isMpvConnected,
|
||||
hasRuntimeOptionsManager: params.hasRuntimeOptionsManager,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../types';
|
||||
import { handleMpvCommandFromIpc } from '../core/services';
|
||||
import { createMpvCommandRuntimeServiceDeps } from './dependencies';
|
||||
import { SPECIAL_COMMANDS } from '../config';
|
||||
import { resolveProxyCommandOsdRuntime } from './runtime/mpv-proxy-osd';
|
||||
|
||||
type MpvPropertyClientLike = {
|
||||
connected: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export interface MpvCommandFromIpcRuntimeDeps {
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
@@ -12,6 +18,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
||||
playNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
getMpvClient: () => MpvPropertyClientLike | null;
|
||||
isMpvConnected: () => boolean;
|
||||
hasRuntimeOptionsManager: () => boolean;
|
||||
}
|
||||
@@ -33,6 +40,8 @@ export function handleMpvCommandFromIpcRuntime(
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||
mpvSendCommand: deps.sendMpvCommand,
|
||||
resolveProxyCommandOsd: (nextCommand) =>
|
||||
resolveProxyCommandOsdRuntime(nextCommand, deps.getMpvClient),
|
||||
isMpvConnected: deps.isMpvConnected,
|
||||
hasRuntimeOptionsManager: deps.hasRuntimeOptionsManager,
|
||||
}),
|
||||
|
||||
@@ -22,6 +22,8 @@ function createMockWindow(): MockWindow & {
|
||||
isFocused: () => boolean;
|
||||
getURL: () => string;
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => void;
|
||||
moveTop: () => void;
|
||||
getShowCount: () => number;
|
||||
getHideCount: () => number;
|
||||
show: () => void;
|
||||
@@ -59,6 +61,8 @@ function createMockWindow(): MockWindow & {
|
||||
setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => {
|
||||
state.ignoreMouseEvents = ignore;
|
||||
},
|
||||
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
|
||||
moveTop: () => {},
|
||||
getShowCount: () => state.showCount,
|
||||
getHideCount: () => state.hideCount,
|
||||
show: () => {
|
||||
@@ -100,6 +104,27 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'visible', {
|
||||
get: () => state.visible,
|
||||
set: (value: boolean) => {
|
||||
state.visible = value;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'focused', {
|
||||
get: () => state.focused,
|
||||
set: (value: boolean) => {
|
||||
state.focused = value;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'webContentsFocused', {
|
||||
get: () => state.webContentsFocused,
|
||||
set: (value: boolean) => {
|
||||
state.webContentsFocused = value;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'url', {
|
||||
get: () => state.url,
|
||||
set: (value: string) => {
|
||||
@@ -318,7 +343,7 @@ test('notifyOverlayModalOpened enables input on visible main overlay window when
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(state, [true]);
|
||||
assert.deepEqual(state, [true]);
|
||||
assert.equal(mainWindow.ignoreMouseEvents, false);
|
||||
assert.equal(mainWindow.isFocused(), true);
|
||||
assert.equal(mainWindow.webContentsFocused, true);
|
||||
@@ -400,7 +425,7 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
|
||||
});
|
||||
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
assert.equal(window.ignoreMouseEvents, true);
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
|
||||
runtime.notifyOverlayModalOpened('jimaku');
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
|
||||
@@ -59,7 +59,7 @@ export function createOverlayModalRuntimeService(
|
||||
const getTargetOverlayWindow = (): BrowserWindow | null => {
|
||||
const visibleMainWindow = deps.getMainWindow();
|
||||
|
||||
if (visibleMainWindow && !visibleMainWindow.isDestroyed()) {
|
||||
if (visibleMainWindow && !visibleMainWindow.isDestroyed() && visibleMainWindow.isVisible()) {
|
||||
return visibleMainWindow;
|
||||
}
|
||||
return null;
|
||||
@@ -221,7 +221,13 @@ export function createOverlayModalRuntimeService(
|
||||
showModalWindow(modalWindow);
|
||||
}
|
||||
|
||||
sendOrQueueForWindow(modalWindow, sendNow);
|
||||
sendOrQueueForWindow(modalWindow, (window) => {
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
} else {
|
||||
window.webContents.send(channel, payload);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ test('register subminer protocol client main deps builder maps callbacks', () =>
|
||||
execPath: '/tmp/electron',
|
||||
resolvePath: (value) => `/abs/${value}`,
|
||||
setAsDefaultProtocolClient: () => true,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.isDefaultApp(), true);
|
||||
|
||||
@@ -60,6 +60,6 @@ export function createBuildRegisterSubminerProtocolClientMainDepsHandler(
|
||||
resolvePath: (value: string) => deps.resolvePath(value),
|
||||
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) =>
|
||||
deps.setAsDefaultProtocolClient(scheme, path, args),
|
||||
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
|
||||
logDebug: (message: string, details?: unknown) => deps.logDebug(message, details),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,9 +56,26 @@ test('createRegisterSubminerProtocolClientHandler registers default app entry',
|
||||
calls.push(`register:${String(args?.[0])}`);
|
||||
return true;
|
||||
},
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
});
|
||||
|
||||
register();
|
||||
assert.deepEqual(calls, ['register:/resolved/./entry.js']);
|
||||
});
|
||||
|
||||
test('createRegisterSubminerProtocolClientHandler keeps unsupported registration at debug level', () => {
|
||||
const calls: string[] = [];
|
||||
const register = createRegisterSubminerProtocolClientHandler({
|
||||
isDefaultApp: () => false,
|
||||
getArgv: () => ['SubMiner.AppImage'],
|
||||
execPath: '/tmp/SubMiner.AppImage',
|
||||
resolvePath: (value) => value,
|
||||
setAsDefaultProtocolClient: () => false,
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
});
|
||||
|
||||
register();
|
||||
assert.deepEqual(calls, [
|
||||
'debug:Failed to register default protocol handler for subminer:// URLs',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ export function createRegisterSubminerProtocolClientHandler(deps: {
|
||||
execPath: string;
|
||||
resolvePath: (value: string) => string;
|
||||
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) => boolean;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
logDebug: (message: string, details?: unknown) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
try {
|
||||
@@ -78,10 +78,10 @@ export function createRegisterSubminerProtocolClientHandler(deps: {
|
||||
])
|
||||
: deps.setAsDefaultProtocolClient('subminer');
|
||||
if (!success) {
|
||||
deps.logWarn('Failed to register default protocol handler for subminer:// URLs');
|
||||
deps.logDebug('Failed to register default protocol handler for subminer:// URLs');
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logWarn('Failed to register subminer:// protocol handler', error);
|
||||
deps.logDebug('Failed to register subminer:// protocol handler', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createBuildAppReadyRuntimeMainDepsHandler } from './app-ready-main-deps
|
||||
test('app-ready main deps builder returns mapped app-ready runtime deps', async () => {
|
||||
const calls: string[] = [];
|
||||
const onReady = createBuildAppReadyRuntimeMainDepsHandler({
|
||||
ensureDefaultConfigBootstrap: () => calls.push('bootstrap-config'),
|
||||
loadSubtitlePosition: () => calls.push('load-subtitle-position'),
|
||||
resolveKeybindings: () => calls.push('resolve-keybindings'),
|
||||
createMpvClient: () => calls.push('create-mpv-client'),
|
||||
@@ -16,8 +17,12 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
setSecondarySubMode: () => calls.push('set-secondary-sub-mode'),
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
defaultAnnotationWebsocketPort: 6678,
|
||||
defaultTexthookerPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => calls.push('start-ws'),
|
||||
startAnnotationWebsocket: () => calls.push('start-annotation-ws'),
|
||||
startTexthooker: () => calls.push('start-texthooker'),
|
||||
log: () => calls.push('log'),
|
||||
setLogLevel: () => calls.push('set-log-level'),
|
||||
createMecabTokenizerAndCheck: async () => {
|
||||
@@ -31,12 +36,16 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('handle-first-run-setup');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {
|
||||
calls.push('prewarm-dicts');
|
||||
},
|
||||
startBackgroundWarmups: () => calls.push('start-warmups'),
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||
setVisibleOverlayVisible: () => calls.push('set-visible-overlay'),
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
handleInitialArgs: () => calls.push('handle-initial-args'),
|
||||
onCriticalConfigErrors: () => {
|
||||
@@ -48,6 +57,8 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
|
||||
assert.equal(onReady.defaultSecondarySubMode, 'hover');
|
||||
assert.equal(onReady.defaultWebsocketPort, 5174);
|
||||
assert.equal(onReady.defaultAnnotationWebsocketPort, 6678);
|
||||
assert.equal(onReady.defaultTexthookerPort, 5174);
|
||||
assert.equal(onReady.texthookerOnlyMode, false);
|
||||
assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true);
|
||||
assert.equal(onReady.now?.(), 123);
|
||||
@@ -56,8 +67,11 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
onReady.createMpvClient();
|
||||
await onReady.createMecabTokenizerAndCheck();
|
||||
await onReady.loadYomitanExtension();
|
||||
await onReady.handleFirstRunSetup();
|
||||
await onReady.prewarmSubtitleDictionaries?.();
|
||||
onReady.startBackgroundWarmups();
|
||||
onReady.startTexthooker(5174);
|
||||
onReady.setVisibleOverlayVisible(true);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'load-subtitle-position',
|
||||
@@ -65,7 +79,10 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
'create-mpv-client',
|
||||
'create-mecab',
|
||||
'load-yomitan',
|
||||
'handle-first-run-setup',
|
||||
'prewarm-dicts',
|
||||
'start-warmups',
|
||||
'start-texthooker',
|
||||
'set-visible-overlay',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppReadyRuntimeDepsFactoryInput } from '../app-lifecycle';
|
||||
|
||||
export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeDepsFactoryInput) {
|
||||
return (): AppReadyRuntimeDepsFactoryInput => ({
|
||||
ensureDefaultConfigBootstrap: deps.ensureDefaultConfigBootstrap,
|
||||
loadSubtitlePosition: deps.loadSubtitlePosition,
|
||||
resolveKeybindings: deps.resolveKeybindings,
|
||||
createMpvClient: deps.createMpvClient,
|
||||
@@ -13,8 +14,12 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
||||
setSecondarySubMode: deps.setSecondarySubMode,
|
||||
defaultSecondarySubMode: deps.defaultSecondarySubMode,
|
||||
defaultWebsocketPort: deps.defaultWebsocketPort,
|
||||
defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort,
|
||||
defaultTexthookerPort: deps.defaultTexthookerPort,
|
||||
hasMpvWebsocketPlugin: deps.hasMpvWebsocketPlugin,
|
||||
startSubtitleWebsocket: deps.startSubtitleWebsocket,
|
||||
startAnnotationWebsocket: deps.startAnnotationWebsocket,
|
||||
startTexthooker: deps.startTexthooker,
|
||||
log: deps.log,
|
||||
setLogLevel: deps.setLogLevel,
|
||||
createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck,
|
||||
@@ -22,10 +27,12 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
||||
createImmersionTracker: deps.createImmersionTracker,
|
||||
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
|
||||
loadYomitanExtension: deps.loadYomitanExtension,
|
||||
handleFirstRunSetup: deps.handleFirstRunSetup,
|
||||
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups: deps.startBackgroundWarmups,
|
||||
texthookerOnlyMode: deps.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: deps.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
|
||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||
handleInitialArgs: deps.handleInitialArgs,
|
||||
onCriticalConfigErrors: deps.onCriticalConfigErrors,
|
||||
|
||||
269
src/main/runtime/character-dictionary-auto-sync.test.ts
Normal file
269
src/main/runtime/character-dictionary-auto-sync.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import test from 'node:test';
|
||||
import { createCharacterDictionaryAutoSyncRuntimeService } from './character-dictionary-auto-sync';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
|
||||
}
|
||||
|
||||
test('auto sync imports merged dictionary and persists MRU state', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const imported: string[] = [];
|
||||
const deleted: string[] = [];
|
||||
const upserts: Array<{ title: string; scope: 'all' | 'active' }> = [];
|
||||
const mergedBuilds: number[][] = [];
|
||||
const logs: string[] = [];
|
||||
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 130298,
|
||||
mediaTitle: 'The Eminence in Shadow',
|
||||
entryCount: 2544,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async (mediaIds) => {
|
||||
mergedBuilds.push([...mediaIds]);
|
||||
return {
|
||||
zipPath: '/tmp/subminer-character-dictionary.zip',
|
||||
revision: 'rev-1',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 2544,
|
||||
};
|
||||
},
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
imported.push(zipPath);
|
||||
importedRevision = 'rev-1';
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async (dictionaryTitle) => {
|
||||
deleted.push(dictionaryTitle);
|
||||
importedRevision = null;
|
||||
return true;
|
||||
},
|
||||
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
|
||||
upserts.push({ title: dictionaryTitle, scope: profileScope });
|
||||
return true;
|
||||
},
|
||||
now: () => 1000,
|
||||
logInfo: (message) => {
|
||||
logs.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(mergedBuilds, [[130298]]);
|
||||
assert.deepEqual(imported, ['/tmp/subminer-character-dictionary.zip']);
|
||||
assert.deepEqual(deleted, []);
|
||||
assert.deepEqual(upserts, [{ title: 'SubMiner Character Dictionary', scope: 'all' }]);
|
||||
|
||||
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
|
||||
activeMediaIds: number[];
|
||||
mergedRevision: string | null;
|
||||
mergedDictionaryTitle: string | null;
|
||||
};
|
||||
assert.deepEqual(state.activeMediaIds, [130298]);
|
||||
assert.equal(state.mergedRevision, 'rev-1');
|
||||
assert.equal(state.mergedDictionaryTitle, 'SubMiner Character Dictionary');
|
||||
assert.deepEqual(logs, [
|
||||
'[dictionary:auto-sync] syncing current anime snapshot',
|
||||
'[dictionary:auto-sync] active AniList media set: 130298',
|
||||
'[dictionary:auto-sync] rebuilding merged dictionary for active anime set',
|
||||
'[dictionary:auto-sync] importing merged dictionary: /tmp/subminer-character-dictionary.zip',
|
||||
'[dictionary:auto-sync] applying Yomitan settings for SubMiner Character Dictionary',
|
||||
'[dictionary:auto-sync] synced AniList 130298: SubMiner Character Dictionary (2544 entries)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto sync skips rebuild/import on unchanged revisit when merged dictionary is current', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const mergedBuilds: number[][] = [];
|
||||
const imports: string[] = [];
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 7,
|
||||
mediaTitle: 'Frieren',
|
||||
entryCount: 100,
|
||||
fromCache: true,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async (mediaIds) => {
|
||||
mergedBuilds.push([...mediaIds]);
|
||||
return {
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-7',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 100,
|
||||
};
|
||||
},
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
imports.push(zipPath);
|
||||
importedRevision = 'rev-7';
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(mergedBuilds, [[7]]);
|
||||
assert.deepEqual(imports, ['/tmp/merged.zip']);
|
||||
});
|
||||
|
||||
test('auto sync rebuilds merged dictionary when MRU order changes', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const sequence = [1, 2, 1];
|
||||
const mergedBuilds: number[][] = [];
|
||||
const deleted: string[] = [];
|
||||
let importedRevision: string | null = null;
|
||||
let runIndex = 0;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => {
|
||||
const mediaId = sequence[Math.min(runIndex, sequence.length - 1)]!;
|
||||
runIndex += 1;
|
||||
return {
|
||||
mediaId,
|
||||
mediaTitle: `Title ${mediaId}`,
|
||||
entryCount: 10,
|
||||
fromCache: true,
|
||||
updatedAt: mediaId,
|
||||
};
|
||||
},
|
||||
buildMergedDictionary: async (mediaIds) => {
|
||||
mergedBuilds.push([...mediaIds]);
|
||||
const revision = `rev-${mediaIds.join('-')}`;
|
||||
return {
|
||||
zipPath: `/tmp/${revision}.zip`,
|
||||
revision,
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: mediaIds.length * 10,
|
||||
};
|
||||
},
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
importedRevision = path.basename(zipPath, '.zip');
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async (dictionaryTitle) => {
|
||||
deleted.push(dictionaryTitle);
|
||||
importedRevision = null;
|
||||
return true;
|
||||
},
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(mergedBuilds, [[1], [2, 1], [1, 2]]);
|
||||
assert.ok(deleted.length >= 2);
|
||||
});
|
||||
|
||||
test('auto sync evicts least recently used media from merged set', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const sequence = [1, 2, 3, 4];
|
||||
const mergedBuilds: number[][] = [];
|
||||
let runIndex = 0;
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => {
|
||||
const mediaId = sequence[Math.min(runIndex, sequence.length - 1)]!;
|
||||
runIndex += 1;
|
||||
return {
|
||||
mediaId,
|
||||
mediaTitle: `Title ${mediaId}`,
|
||||
entryCount: 10,
|
||||
fromCache: true,
|
||||
updatedAt: mediaId,
|
||||
};
|
||||
},
|
||||
buildMergedDictionary: async (mediaIds) => {
|
||||
mergedBuilds.push([...mediaIds]);
|
||||
const revision = `rev-${mediaIds.join('-')}`;
|
||||
return {
|
||||
zipPath: `/tmp/${revision}.zip`,
|
||||
revision,
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: mediaIds.length * 10,
|
||||
};
|
||||
},
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
importedRevision = path.basename(zipPath, '.zip');
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => {
|
||||
importedRevision = null;
|
||||
return true;
|
||||
},
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => Date.now(),
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(mergedBuilds, [[1], [2, 1], [3, 2, 1], [4, 3, 2]]);
|
||||
|
||||
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
|
||||
activeMediaIds: number[];
|
||||
};
|
||||
assert.deepEqual(state.activeMediaIds, [4, 3, 2]);
|
||||
});
|
||||
257
src/main/runtime/character-dictionary-auto-sync.ts
Normal file
257
src/main/runtime/character-dictionary-auto-sync.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { AnilistCharacterDictionaryProfileScope } from '../../types';
|
||||
import type {
|
||||
CharacterDictionarySnapshotResult,
|
||||
MergedCharacterDictionaryBuildResult,
|
||||
} from '../character-dictionary-runtime';
|
||||
|
||||
type AutoSyncState = {
|
||||
activeMediaIds: number[];
|
||||
mergedRevision: string | null;
|
||||
mergedDictionaryTitle: string | null;
|
||||
};
|
||||
|
||||
type AutoSyncDictionaryInfo = {
|
||||
title: string;
|
||||
revision?: string | number;
|
||||
};
|
||||
|
||||
export interface CharacterDictionaryAutoSyncConfig {
|
||||
enabled: boolean;
|
||||
maxLoaded: number;
|
||||
profileScope: AnilistCharacterDictionaryProfileScope;
|
||||
}
|
||||
|
||||
export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
||||
userDataPath: string;
|
||||
getConfig: () => CharacterDictionaryAutoSyncConfig;
|
||||
getOrCreateCurrentSnapshot: (targetPath?: string) => Promise<CharacterDictionarySnapshotResult>;
|
||||
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
||||
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
|
||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||
upsertYomitanDictionarySettings: (
|
||||
dictionaryTitle: string,
|
||||
profileScope: AnilistCharacterDictionaryProfileScope,
|
||||
) => Promise<boolean>;
|
||||
now: () => number;
|
||||
schedule?: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
clearSchedule?: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
operationTimeoutMs?: number;
|
||||
logInfo?: (message: string) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
}
|
||||
|
||||
function ensureDir(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readAutoSyncState(statePath: string): AutoSyncState {
|
||||
try {
|
||||
const raw = fs.readFileSync(statePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<AutoSyncState>;
|
||||
const activeMediaIds = Array.isArray(parsed.activeMediaIds)
|
||||
? parsed.activeMediaIds
|
||||
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||||
.map((value) => Math.max(1, Math.floor(value)))
|
||||
.filter((value, index, all) => all.indexOf(value) === index)
|
||||
: [];
|
||||
return {
|
||||
activeMediaIds,
|
||||
mergedRevision:
|
||||
typeof parsed.mergedRevision === 'string' && parsed.mergedRevision.length > 0
|
||||
? parsed.mergedRevision
|
||||
: null,
|
||||
mergedDictionaryTitle:
|
||||
typeof parsed.mergedDictionaryTitle === 'string' && parsed.mergedDictionaryTitle.length > 0
|
||||
? parsed.mergedDictionaryTitle
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
activeMediaIds: [],
|
||||
mergedRevision: null,
|
||||
mergedDictionaryTitle: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function writeAutoSyncState(statePath: string, state: AutoSyncState): void {
|
||||
ensureDir(path.dirname(statePath));
|
||||
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function arraysEqual(left: number[], right: number[]): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (left[i] !== right[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
deps: CharacterDictionaryAutoSyncRuntimeDeps,
|
||||
): {
|
||||
scheduleSync: () => void;
|
||||
runSyncNow: () => Promise<void>;
|
||||
} {
|
||||
const dictionariesDir = path.join(deps.userDataPath, 'character-dictionaries');
|
||||
const statePath = path.join(dictionariesDir, 'auto-sync-state.json');
|
||||
const schedule = deps.schedule ?? ((fn, delayMs) => setTimeout(fn, delayMs));
|
||||
const clearSchedule = deps.clearSchedule ?? ((timer) => clearTimeout(timer));
|
||||
const debounceMs = 800;
|
||||
const operationTimeoutMs = Math.max(1, Math.floor(deps.operationTimeoutMs ?? 7_000));
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let syncInFlight = false;
|
||||
let runQueued = false;
|
||||
|
||||
const withOperationTimeout = async <T>(label: string, promise: Promise<T>): Promise<T> => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${operationTimeoutMs}ms`));
|
||||
}, operationTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runSyncOnce = async (): Promise<void> => {
|
||||
const config = deps.getConfig();
|
||||
if (!config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.logInfo?.('[dictionary:auto-sync] syncing current anime snapshot');
|
||||
const snapshot = await deps.getOrCreateCurrentSnapshot();
|
||||
const state = readAutoSyncState(statePath);
|
||||
const nextActiveMediaIds = [
|
||||
snapshot.mediaId,
|
||||
...state.activeMediaIds.filter((mediaId) => mediaId !== snapshot.mediaId),
|
||||
].slice(0, Math.max(1, Math.floor(config.maxLoaded)));
|
||||
deps.logInfo?.(
|
||||
`[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds.join(', ')}`,
|
||||
);
|
||||
|
||||
const retainedChanged = !arraysEqual(nextActiveMediaIds, state.activeMediaIds);
|
||||
let merged: MergedCharacterDictionaryBuildResult | null = null;
|
||||
if (
|
||||
retainedChanged ||
|
||||
!state.mergedRevision ||
|
||||
!state.mergedDictionaryTitle ||
|
||||
!snapshot.fromCache
|
||||
) {
|
||||
deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set');
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
|
||||
}
|
||||
|
||||
const dictionaryTitle = merged?.dictionaryTitle ?? state.mergedDictionaryTitle;
|
||||
const revision = merged?.revision ?? state.mergedRevision;
|
||||
if (!dictionaryTitle || !revision) {
|
||||
throw new Error('Merged character dictionary state is incomplete.');
|
||||
}
|
||||
|
||||
const dictionaryInfo = await withOperationTimeout(
|
||||
'getYomitanDictionaryInfo',
|
||||
deps.getYomitanDictionaryInfo(),
|
||||
);
|
||||
const existing = dictionaryInfo.find((entry) => entry.title === dictionaryTitle) ?? null;
|
||||
const existingRevision =
|
||||
existing && (typeof existing.revision === 'string' || typeof existing.revision === 'number')
|
||||
? String(existing.revision)
|
||||
: null;
|
||||
const shouldImport =
|
||||
merged !== null ||
|
||||
existing === null ||
|
||||
existingRevision === null ||
|
||||
existingRevision !== revision;
|
||||
|
||||
if (shouldImport) {
|
||||
if (existing !== null) {
|
||||
await withOperationTimeout(
|
||||
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||
deps.deleteYomitanDictionary(dictionaryTitle),
|
||||
);
|
||||
}
|
||||
if (merged === null) {
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
|
||||
}
|
||||
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
|
||||
const imported = await withOperationTimeout(
|
||||
`importYomitanDictionary(${path.basename(merged.zipPath)})`,
|
||||
deps.importYomitanDictionary(merged.zipPath),
|
||||
);
|
||||
if (!imported) {
|
||||
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
deps.logInfo?.(`[dictionary:auto-sync] applying Yomitan settings for ${dictionaryTitle}`);
|
||||
await withOperationTimeout(
|
||||
`upsertYomitanDictionarySettings(${dictionaryTitle})`,
|
||||
deps.upsertYomitanDictionarySettings(dictionaryTitle, config.profileScope),
|
||||
);
|
||||
|
||||
writeAutoSyncState(statePath, {
|
||||
activeMediaIds: nextActiveMediaIds,
|
||||
mergedRevision: merged?.revision ?? revision,
|
||||
mergedDictionaryTitle: merged?.dictionaryTitle ?? dictionaryTitle,
|
||||
});
|
||||
deps.logInfo?.(
|
||||
`[dictionary:auto-sync] synced AniList ${snapshot.mediaId}: ${dictionaryTitle} (${snapshot.entryCount} entries)`,
|
||||
);
|
||||
};
|
||||
|
||||
const enqueueSync = (): void => {
|
||||
runQueued = true;
|
||||
if (syncInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncInFlight = true;
|
||||
void (async () => {
|
||||
while (runQueued) {
|
||||
runQueued = false;
|
||||
try {
|
||||
await runSyncOnce();
|
||||
} catch (error) {
|
||||
deps.logWarn?.(
|
||||
`[dictionary:auto-sync] sync failed: ${(error as Error)?.message ?? String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
syncInFlight = false;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
scheduleSync: () => {
|
||||
const config = deps.getConfig();
|
||||
if (!config.enabled) {
|
||||
return;
|
||||
}
|
||||
if (debounceTimer !== null) {
|
||||
clearSchedule(debounceTimer);
|
||||
}
|
||||
debounceTimer = schedule(() => {
|
||||
debounceTimer = null;
|
||||
enqueueSync();
|
||||
}, debounceMs);
|
||||
},
|
||||
runSyncNow: async () => {
|
||||
await runSyncOnce();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => calls.push('init'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
|
||||
copyCurrentSubtitle: () => calls.push('copy'),
|
||||
startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`),
|
||||
@@ -46,6 +47,13 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/anilist-1.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 10,
|
||||
}),
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -32,6 +33,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup'];
|
||||
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -60,6 +62,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
isOverlayInitialized: deps.isOverlayInitialized,
|
||||
initializeOverlay: deps.initializeOverlay,
|
||||
toggleVisibleOverlay: deps.toggleVisibleOverlay,
|
||||
openFirstRunSetup: deps.openFirstRunSetup,
|
||||
setVisibleOverlay: deps.setVisibleOverlay,
|
||||
copyCurrentSubtitle: deps.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: deps.startPendingMultiCopy,
|
||||
@@ -76,6 +79,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
openJellyfinSetup: deps.openJellyfinSetup,
|
||||
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||
@@ -20,6 +20,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
|
||||
@@ -53,6 +54,13 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
lastError: null,
|
||||
}),
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/anilist-1.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 10,
|
||||
}),
|
||||
runJellyfinCommand: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
|
||||
@@ -23,6 +23,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
openFirstRunSetupWindow: () => calls.push('open-setup'),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
|
||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||
@@ -70,6 +71,13 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
lastError: null,
|
||||
}),
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/anilist-1.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 10,
|
||||
}),
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
@@ -100,10 +108,11 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
assert.equal(deps.shouldOpenBrowser(), true);
|
||||
deps.showOsd('hello');
|
||||
deps.initializeOverlay();
|
||||
deps.openFirstRunSetup();
|
||||
deps.setVisibleOverlay(true);
|
||||
deps.printHelp();
|
||||
|
||||
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'set-visible:true', 'help']);
|
||||
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'open-setup', 'set-visible:true', 'help']);
|
||||
|
||||
const retry = await deps.retryAnilistQueueNow();
|
||||
assert.deepEqual(retry, { ok: true, message: 'ok' });
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -37,6 +38,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
openJellyfinSetupWindow: () => void;
|
||||
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
||||
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
@@ -70,6 +72,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
initializeOverlay: () => deps.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||
openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
|
||||
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
|
||||
@@ -87,6 +90,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
openJellyfinSetup: () => deps.openJellyfinSetupWindow(),
|
||||
getAnilistQueueStatus: () => deps.getAnilistQueueStatus(),
|
||||
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
|
||||
generateCharacterDictionary: (targetPath?: string) =>
|
||||
deps.generateCharacterDictionary(targetPath),
|
||||
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
|
||||
@@ -24,6 +24,7 @@ function createDeps() {
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
openFirstRunSetup: () => {},
|
||||
setVisibleOverlay: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
@@ -40,6 +41,13 @@ function createDeps() {
|
||||
openJellyfinSetup: () => {},
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/anilist-1.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 1,
|
||||
}),
|
||||
runJellyfinCommand: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
|
||||
@@ -21,6 +21,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -37,6 +38,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
openJellyfinSetup: CliCommandRuntimeServiceContext['openJellyfinSetup'];
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -72,6 +74,7 @@ export function createCliCommandContext(
|
||||
isOverlayInitialized: deps.isOverlayInitialized,
|
||||
initializeOverlay: deps.initializeOverlay,
|
||||
toggleVisibleOverlay: deps.toggleVisibleOverlay,
|
||||
openFirstRunSetup: deps.openFirstRunSetup,
|
||||
setVisibleOverlay: deps.setVisibleOverlay,
|
||||
copyCurrentSubtitle: deps.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: deps.startPendingMultiCopy,
|
||||
@@ -88,6 +91,7 @@ export function createCliCommandContext(
|
||||
openJellyfinSetup: deps.openJellyfinSetup,
|
||||
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseClipboardVideoPath } from '../../core/services';
|
||||
import { parseClipboardVideoPath } from '../../core/services/overlay-drop';
|
||||
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
|
||||
@@ -29,7 +29,7 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
|
||||
execPath: process.execPath,
|
||||
resolvePath: (value) => value,
|
||||
setAsDefaultProtocolClient: () => true,
|
||||
logWarn: () => {},
|
||||
logDebug: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
|
||||
},
|
||||
},
|
||||
appReadyRuntimeMainDeps: {
|
||||
ensureDefaultConfigBootstrap: () => {},
|
||||
loadSubtitlePosition: () => {},
|
||||
resolveKeybindings: () => {},
|
||||
createMpvClient: () => {},
|
||||
@@ -37,17 +38,23 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
|
||||
setSecondarySubMode: () => {},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
defaultAnnotationWebsocketPort: 6678,
|
||||
defaultTexthookerPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
createSubtitleTimingTracker: () => {},
|
||||
loadYomitanExtension: async () => {},
|
||||
handleFirstRunSetup: async () => {},
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
initializeOverlayRuntime: () => {},
|
||||
handleInitialArgs: () => {},
|
||||
logDebug: () => {},
|
||||
|
||||
@@ -22,10 +22,13 @@ type RequiredMpvInputKeys = keyof ComposerInputs<
|
||||
MpvRuntimeComposerOptions<FakeMpvClient, FakeTokenizerDeps, FakeTokenizedSubtitle>
|
||||
>;
|
||||
|
||||
type _anilistHasNotifyDeps = Assert<IsAssignable<'notifyDeps', RequiredAnilistSetupInputKeys>>;
|
||||
type _jellyfinHasGetMpvClient = Assert<IsAssignable<'getMpvClient', RequiredJellyfinInputKeys>>;
|
||||
type _ipcHasRegistration = Assert<IsAssignable<'registration', RequiredIpcInputKeys>>;
|
||||
type _mpvHasTokenizer = Assert<IsAssignable<'tokenizer', RequiredMpvInputKeys>>;
|
||||
const contractAssertions = [
|
||||
true as Assert<IsAssignable<'notifyDeps', RequiredAnilistSetupInputKeys>>,
|
||||
true as Assert<IsAssignable<'getMpvClient', RequiredJellyfinInputKeys>>,
|
||||
true as Assert<IsAssignable<'registration', RequiredIpcInputKeys>>,
|
||||
true as Assert<IsAssignable<'tokenizer', RequiredMpvInputKeys>>,
|
||||
];
|
||||
void contractAssertions;
|
||||
|
||||
// @ts-expect-error missing required notifyDeps should fail compile-time contract
|
||||
const anilistMissingRequired: AnilistSetupComposerOptions = {
|
||||
|
||||
@@ -16,6 +16,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
getMpvClient: () => null,
|
||||
isMpvConnected: () => false,
|
||||
hasRuntimeOptionsManager: () => true,
|
||||
},
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { RuntimeOptionsManager } from '../../runtime-options';
|
||||
import type { JimakuApiResponse, JimakuLanguagePreference, ResolvedConfig } from '../../types';
|
||||
import {
|
||||
isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig as shouldAutoInitializeOverlayRuntimeFromConfigCore,
|
||||
} from '../../core/services/startup';
|
||||
import {
|
||||
getJimakuLanguagePreference as getJimakuLanguagePreferenceCore,
|
||||
getJimakuMaxEntryResults as getJimakuMaxEntryResultsCore,
|
||||
isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore,
|
||||
jimakuFetchJson as jimakuFetchJsonCore,
|
||||
resolveJimakuApiKey as resolveJimakuApiKeyCore,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig as shouldAutoInitializeOverlayRuntimeFromConfigCore,
|
||||
} from '../../core/services';
|
||||
} from '../../core/services/jimaku';
|
||||
|
||||
export type ConfigDerivedRuntimeDeps = {
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
||||
import { resolveKeybindings } from '../../core/utils';
|
||||
import { resolveKeybindings } from '../../core/utils/keybindings';
|
||||
import { DEFAULT_KEYBINDINGS } from '../../config';
|
||||
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
|
||||
|
||||
@@ -24,6 +24,7 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
||||
...config.subtitleStyle,
|
||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
||||
knownWordColor: config.ankiConnect.nPlusOne.knownWord,
|
||||
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
||||
enableJlpt: config.subtitleStyle.enableJlpt,
|
||||
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
|
||||
};
|
||||
|
||||
106
src/main/runtime/first-run-setup-plugin.test.ts
Normal file
106
src/main/runtime/first-run-setup-plugin.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
installFirstRunPluginToDefaultLocation,
|
||||
resolvePackagedFirstRunPluginAssets,
|
||||
} from './first-run-setup-plugin';
|
||||
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
|
||||
|
||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-plugin-test-'));
|
||||
const result = fn(dir);
|
||||
if (result instanceof Promise) {
|
||||
return result.finally(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
test('resolvePackagedFirstRunPluginAssets finds packaged plugin assets', () => {
|
||||
withTempDir((root) => {
|
||||
const resourcesPath = path.join(root, 'resources');
|
||||
const pluginRoot = path.join(resourcesPath, 'plugin');
|
||||
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- plugin');
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
|
||||
|
||||
const resolved = resolvePackagedFirstRunPluginAssets({
|
||||
dirname: path.join(root, 'dist', 'main', 'runtime'),
|
||||
appPath: path.join(root, 'app'),
|
||||
resourcesPath,
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
pluginDirSource: path.join(pluginRoot, 'subminer'),
|
||||
pluginConfigSource: path.join(pluginRoot, 'subminer.conf'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('installFirstRunPluginToDefaultLocation installs plugin and backs up existing files', () => {
|
||||
withTempDir((root) => {
|
||||
const resourcesPath = path.join(root, 'resources');
|
||||
const pluginRoot = path.join(resourcesPath, 'plugin');
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
||||
|
||||
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
|
||||
|
||||
fs.mkdirSync(installPaths.pluginDir, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(path.join(installPaths.pluginDir, 'old.lua'), '-- old plugin');
|
||||
fs.writeFileSync(installPaths.pluginConfigPath, 'old=true\n');
|
||||
|
||||
const result = installFirstRunPluginToDefaultLocation({
|
||||
platform: 'linux',
|
||||
homeDir,
|
||||
xdgConfigHome,
|
||||
dirname: path.join(root, 'dist', 'main', 'runtime'),
|
||||
appPath: path.join(root, 'app'),
|
||||
resourcesPath,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.pluginInstallStatus, 'installed');
|
||||
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(installPaths.pluginDir, 'main.lua'), 'utf8'),
|
||||
'-- packaged plugin',
|
||||
);
|
||||
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n');
|
||||
|
||||
const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir);
|
||||
const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir);
|
||||
assert.equal(
|
||||
scriptsDirEntries.some((entry) => entry.startsWith('subminer.bak.')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
scriptOptsEntries.some((entry) => entry.startsWith('subminer.conf.bak.')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('installFirstRunPluginToDefaultLocation reports unsupported platforms', () => {
|
||||
const result = installFirstRunPluginToDefaultLocation({
|
||||
platform: 'win32',
|
||||
homeDir: '/tmp/home',
|
||||
xdgConfigHome: '/tmp/xdg',
|
||||
dirname: '/tmp/dist/main/runtime',
|
||||
appPath: '/tmp/app',
|
||||
resourcesPath: '/tmp/resources',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.pluginInstallStatus, 'failed');
|
||||
assert.match(result.message, /not supported/i);
|
||||
});
|
||||
100
src/main/runtime/first-run-setup-plugin.ts
Normal file
100
src/main/runtime/first-run-setup-plugin.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
|
||||
import type { PluginInstallResult } from './first-run-setup-service';
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString().replaceAll(':', '-');
|
||||
}
|
||||
|
||||
function backupExistingPath(targetPath: string): void {
|
||||
if (!fs.existsSync(targetPath)) return;
|
||||
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
|
||||
}
|
||||
|
||||
export function resolvePackagedFirstRunPluginAssets(deps: {
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
joinPath?: (...parts: string[]) => string;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
}): { pluginDirSource: string; pluginConfigSource: string } | null {
|
||||
const joinPath = deps.joinPath ?? path.join;
|
||||
const existsSync = deps.existsSync ?? fs.existsSync;
|
||||
const roots = [
|
||||
joinPath(deps.resourcesPath, 'plugin'),
|
||||
joinPath(deps.resourcesPath, 'app.asar', 'plugin'),
|
||||
joinPath(deps.appPath, 'plugin'),
|
||||
joinPath(deps.dirname, '..', 'plugin'),
|
||||
joinPath(deps.dirname, '..', '..', 'plugin'),
|
||||
];
|
||||
|
||||
for (const root of roots) {
|
||||
const pluginDirSource = joinPath(root, 'subminer');
|
||||
const pluginConfigSource = joinPath(root, 'subminer.conf');
|
||||
if (existsSync(pluginDirSource) && existsSync(pluginConfigSource)) {
|
||||
return { pluginDirSource, pluginConfigSource };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectInstalledFirstRunPlugin(
|
||||
installPaths: MpvInstallPaths,
|
||||
deps?: { existsSync?: (candidate: string) => boolean },
|
||||
): boolean {
|
||||
const existsSync = deps?.existsSync ?? fs.existsSync;
|
||||
return existsSync(installPaths.pluginDir) && existsSync(installPaths.pluginConfigPath);
|
||||
}
|
||||
|
||||
export function installFirstRunPluginToDefaultLocation(options: {
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
}): PluginInstallResult {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
options.platform,
|
||||
options.homeDir,
|
||||
options.xdgConfigHome,
|
||||
);
|
||||
if (!installPaths.supported) {
|
||||
return {
|
||||
ok: false,
|
||||
pluginInstallStatus: 'failed',
|
||||
pluginInstallPathSummary: installPaths.mpvConfigDir,
|
||||
message: 'Automatic mpv plugin install is not supported on this platform yet.',
|
||||
};
|
||||
}
|
||||
|
||||
const assets = resolvePackagedFirstRunPluginAssets({
|
||||
dirname: options.dirname,
|
||||
appPath: options.appPath,
|
||||
resourcesPath: options.resourcesPath,
|
||||
});
|
||||
if (!assets) {
|
||||
return {
|
||||
ok: false,
|
||||
pluginInstallStatus: 'failed',
|
||||
pluginInstallPathSummary: installPaths.mpvConfigDir,
|
||||
message: 'Packaged mpv plugin assets were not found.',
|
||||
};
|
||||
}
|
||||
|
||||
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
|
||||
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
|
||||
backupExistingPath(installPaths.pluginDir);
|
||||
backupExistingPath(installPaths.pluginConfigPath);
|
||||
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
|
||||
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: installPaths.mpvConfigDir,
|
||||
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`,
|
||||
};
|
||||
}
|
||||
171
src/main/runtime/first-run-setup-service.test.ts
Normal file
171
src/main/runtime/first-run-setup-service.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service';
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
|
||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
|
||||
const result = fn(dir);
|
||||
if (result instanceof Promise) {
|
||||
return result.finally(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
start: false,
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
hideVisibleOverlay: false,
|
||||
copySubtitle: false,
|
||||
copySubtitleMultiple: false,
|
||||
mineSentence: false,
|
||||
mineSentenceMultiple: false,
|
||||
updateLastCardFromClipboard: false,
|
||||
refreshKnownWords: false,
|
||||
toggleSecondarySub: false,
|
||||
triggerFieldGrouping: false,
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
openRuntimeOptions: false,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinLibraries: false,
|
||||
jellyfinItems: false,
|
||||
jellyfinSubtitles: false,
|
||||
jellyfinSubtitleUrlsOnly: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinRemoteAnnounce: false,
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
generateConfig: false,
|
||||
backupOverwrite: false,
|
||||
debug: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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({ background: true, jellyfinRemoteAnnounce: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
|
||||
});
|
||||
|
||||
test('setup service auto-completes legacy installs with config and dictionaries', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 2,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
message: 'installed',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const snapshot = await service.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.state.status, 'completed');
|
||||
assert.equal(snapshot.state.completionSource, 'legacy_auto_detected');
|
||||
assert.equal(snapshot.dictionaryCount, 2);
|
||||
assert.equal(snapshot.canFinish, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service requires explicit finish for incomplete installs and supports plugin skip/install', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
let dictionaryCount = 0;
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => dictionaryCount,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
message: 'installed',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const initial = await service.ensureSetupStateInitialized();
|
||||
assert.equal(initial.state.status, 'incomplete');
|
||||
assert.equal(initial.canFinish, false);
|
||||
|
||||
const skipped = await service.skipPluginInstall();
|
||||
assert.equal(skipped.state.pluginInstallStatus, 'skipped');
|
||||
|
||||
const installed = await service.installMpvPlugin();
|
||||
assert.equal(installed.state.pluginInstallStatus, 'installed');
|
||||
assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv');
|
||||
|
||||
dictionaryCount = 1;
|
||||
const refreshed = await service.refreshStatus();
|
||||
assert.equal(refreshed.canFinish, true);
|
||||
|
||||
const completed = await service.markSetupCompleted();
|
||||
assert.equal(completed.state.status, 'completed');
|
||||
assert.equal(completed.state.completionSource, 'user');
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service marks cancelled when popup closes before completion', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
message: 'ok',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
await service.ensureSetupStateInitialized();
|
||||
await service.markSetupInProgress();
|
||||
const cancelled = await service.markSetupCancelled();
|
||||
assert.equal(cancelled.state.status, 'cancelled');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user