mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat(stats): add stats server, API endpoints, config, and Anki integration
- Hono HTTP server with 20+ REST endpoints for stats data - Stats overlay BrowserWindow with toggle keybinding - IPC channel definitions and preload bridge - Stats config section (toggleKey, serverPort, autoStartServer, autoOpenBrowser) - Config resolver for stats section - AnkiConnect proxy endpoints (guiBrowse, notesInfo) - Note ID passthrough in card mining callback chain - Stats CLI command with autoOpenBrowser respect
This commit is contained in:
@@ -78,7 +78,7 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
mpvClient: TMpv;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number) => void;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
@@ -89,6 +89,6 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
mpvClient: TMpv;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => deps.mineSentenceCardCore(options),
|
||||
recordCardsMined: (count: number) => deps.recordCardsMined(count),
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
|
||||
mpvClient: TMpv;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number) => void;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const created = await deps.mineSentenceCardCore({
|
||||
|
||||
111
src/main/runtime/stats-cli-command.test.ts
Normal file
111
src/main/runtime/stats-cli-command.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRunStatsCliCommandHandler } from './stats-cli-command';
|
||||
|
||||
function makeHandler(overrides: Partial<Parameters<typeof createRunStatsCliCommandHandler>[0]> = {}) {
|
||||
const calls: string[] = [];
|
||||
const responses: Array<{ responsePath: string; payload: { ok: boolean; url?: string; error?: string } }> = [];
|
||||
|
||||
const handler = createRunStatsCliCommandHandler({
|
||||
getResolvedConfig: () => ({
|
||||
immersionTracking: { enabled: true },
|
||||
stats: { serverPort: 5175 },
|
||||
}),
|
||||
ensureImmersionTrackerStarted: () => {
|
||||
calls.push('ensureImmersionTrackerStarted');
|
||||
},
|
||||
getImmersionTracker: () => ({ cleanupVocabularyStats: undefined }),
|
||||
ensureStatsServerStarted: () => {
|
||||
calls.push('ensureStatsServerStarted');
|
||||
return 'http://127.0.0.1:5175';
|
||||
},
|
||||
openExternal: async (url) => {
|
||||
calls.push(`openExternal:${url}`);
|
||||
},
|
||||
writeResponse: (responsePath, payload) => {
|
||||
responses.push({ responsePath, payload });
|
||||
},
|
||||
exitAppWithCode: (code) => {
|
||||
calls.push(`exitAppWithCode:${code}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
calls.push(`info:${message}`);
|
||||
},
|
||||
logWarn: (message) => {
|
||||
calls.push(`warn:${message}`);
|
||||
},
|
||||
logError: (message, error) => {
|
||||
calls.push(`error:${message}:${error instanceof Error ? error.message : String(error)}`);
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return { handler, calls, responses };
|
||||
}
|
||||
|
||||
test('stats cli command starts tracker, server, browser, and writes success response', async () => {
|
||||
const { handler, calls, responses } = makeHandler();
|
||||
|
||||
await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureImmersionTrackerStarted',
|
||||
'ensureStatsServerStarted',
|
||||
'openExternal:http://127.0.0.1:5175',
|
||||
'info:Stats dashboard available at http://127.0.0.1:5175',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true, url: 'http://127.0.0.1:5175' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command fails when immersion tracking is disabled', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
getResolvedConfig: () => ({
|
||||
immersionTracking: { enabled: false },
|
||||
stats: { serverPort: 5175 },
|
||||
}),
|
||||
});
|
||||
|
||||
await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial');
|
||||
|
||||
assert.equal(calls.includes('ensureImmersionTrackerStarted'), false);
|
||||
assert.ok(calls.includes('exitAppWithCode:1'));
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: false, error: 'Immersion tracking is disabled in config.' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cli command runs vocab cleanup instead of opening dashboard when cleanup mode is requested', async () => {
|
||||
const { handler, calls, responses } = makeHandler({
|
||||
getImmersionTracker: () => ({
|
||||
cleanupVocabularyStats: async () => ({ scanned: 3, kept: 1, deleted: 2, repaired: 1 }),
|
||||
}),
|
||||
});
|
||||
|
||||
await handler(
|
||||
{
|
||||
statsResponsePath: '/tmp/subminer-stats-response.json',
|
||||
statsCleanup: true,
|
||||
statsCleanupVocab: true,
|
||||
},
|
||||
'initial',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureImmersionTrackerStarted',
|
||||
'info:Stats vocabulary cleanup complete: scanned=3 kept=1 deleted=2 repaired=1',
|
||||
]);
|
||||
assert.deepEqual(responses, [
|
||||
{
|
||||
responsePath: '/tmp/subminer-stats-response.json',
|
||||
payload: { ok: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
99
src/main/runtime/stats-cli-command.ts
Normal file
99
src/main/runtime/stats-cli-command.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { CliArgs, CliCommandSource } from '../../cli/args';
|
||||
import type { VocabularyCleanupSummary } from '../../core/services/immersion-tracker/types';
|
||||
|
||||
type StatsCliConfig = {
|
||||
immersionTracking?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
stats: {
|
||||
serverPort: number;
|
||||
autoOpenBrowser?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type StatsCliCommandResponse = {
|
||||
ok: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function writeStatsCliCommandResponse(
|
||||
responsePath: string,
|
||||
payload: StatsCliCommandResponse,
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export function createRunStatsCliCommandHandler(deps: {
|
||||
getResolvedConfig: () => StatsCliConfig;
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
ensureVocabularyCleanupTokenizerReady?: () => Promise<void> | void;
|
||||
getImmersionTracker: () => { cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary> } | null;
|
||||
ensureStatsServerStarted: () => string;
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
|
||||
exitAppWithCode: (code: number) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, error: unknown) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
const writeResponseSafe = (
|
||||
responsePath: string | undefined,
|
||||
payload: StatsCliCommandResponse,
|
||||
): void => {
|
||||
if (!responsePath) return;
|
||||
try {
|
||||
deps.writeResponse(responsePath, payload);
|
||||
} catch (error) {
|
||||
deps.logWarn(`Failed to write stats response: ${responsePath}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
return async (
|
||||
args: Pick<CliArgs, 'statsResponsePath' | 'statsCleanup' | 'statsCleanupVocab'>,
|
||||
source: CliCommandSource,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const config = deps.getResolvedConfig();
|
||||
if (config.immersionTracking?.enabled === false) {
|
||||
throw new Error('Immersion tracking is disabled in config.');
|
||||
}
|
||||
|
||||
deps.ensureImmersionTrackerStarted();
|
||||
const tracker = deps.getImmersionTracker();
|
||||
if (!tracker) {
|
||||
throw new Error('Immersion tracker failed to initialize.');
|
||||
}
|
||||
|
||||
if (args.statsCleanup) {
|
||||
await deps.ensureVocabularyCleanupTokenizerReady?.();
|
||||
if (!args.statsCleanupVocab || !tracker.cleanupVocabularyStats) {
|
||||
throw new Error('Stats cleanup mode is not available.');
|
||||
}
|
||||
const result = await tracker.cleanupVocabularyStats();
|
||||
deps.logInfo(
|
||||
`Stats vocabulary cleanup complete: scanned=${result.scanned} kept=${result.kept} deleted=${result.deleted} repaired=${result.repaired}`,
|
||||
);
|
||||
writeResponseSafe(args.statsResponsePath, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = deps.ensureStatsServerStarted();
|
||||
if (config.stats.autoOpenBrowser !== false) {
|
||||
await deps.openExternal(url);
|
||||
}
|
||||
deps.logInfo(`Stats dashboard available at ${url}`);
|
||||
writeResponseSafe(args.statsResponsePath, { ok: true, url });
|
||||
} catch (error) {
|
||||
deps.logError('Stats command failed', error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
writeResponseSafe(args.statsResponsePath, { ok: false, error: message });
|
||||
if (source === 'initial') {
|
||||
deps.exitAppWithCode(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user