mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
Compare commits
2 Commits
2398f5c030
...
deae61b211
| Author | SHA1 | Date | |
|---|---|---|---|
| deae61b211 | |||
|
44959ed282
|
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anki
|
||||||
|
|
||||||
|
- Prevented video startup from crashing when another process already owns the configured AnkiConnect proxy port, and added a notification explaining how to resolve the conflict.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: app
|
area: app
|
||||||
|
|
||||||
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: on quit, the AppImage runtime unmounted the FUSE squashfs while Chromium utility children (notably the network service) were still shutting down, killing them with SIGBUS. Background launches (`--background`, used by the mpv plugin and the launcher) now run through a small supervisor that mounts the AppImage via `--appimage-mount`, executes `AppRun` from that mount, and releases the mount only after no process is still executing from it. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to restore the old direct launch.
|
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: the short-lived background bootstrap spawned a Chromium GPU child that outlived it (surviving `app.exit`) and died with SIGBUS at session end when the bootstrap's FUSE mount was finally released. The bootstrap now runs with the GPU in-process so it leaves no children behind, and the detached app's mount remains supervised until its Chromium children finish. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to disable the detached-app mount supervisor.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Validated nested and legacy AnkiConnect settings after splitting the resolver, preserving valid modern overrides while warning and falling back for invalid primitive values.
|
||||||
|
- Hardened stats routes against malformed IDs and static paths, stalled AniList searches, word-mining media collisions, missing Yomitan bridges, and throwing timing observers.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# Domain Ownership
|
# Domain Ownership
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-05-23
|
Last verified: 2026-07-15
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: you need to find the owner module for a behavior or test surface
|
Read when: you need to find the owner module for a behavior or test surface
|
||||||
|
|
||||||
@@ -16,7 +16,9 @@ Read when: you need to find the owner module for a behavior or test surface
|
|||||||
|
|
||||||
## Product / Integration Domains
|
## Product / Integration Domains
|
||||||
|
|
||||||
- Config system: `src/config/`
|
- Config system: `src/config/`; Anki resolution is composed by
|
||||||
|
`src/config/resolve/anki-connect.ts` from focused resolvers in
|
||||||
|
`src/config/resolve/anki-connect/`
|
||||||
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
||||||
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
||||||
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
|
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
|
||||||
@@ -26,7 +28,9 @@ Read when: you need to find the owner module for a behavior or test surface
|
|||||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
- Stats app: `stats/`
|
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||||
|
in `src/core/services/stats-server/`
|
||||||
|
- Stats SPA: `stats/`
|
||||||
- Public docs site: `docs-site/`
|
- Public docs site: `docs-site/`
|
||||||
|
|
||||||
## Shared Contract Entry Points
|
## Shared Contract Entry Points
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { once } from 'node:events';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@@ -465,6 +467,66 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis
|
|||||||
assert.equal(privateState.runtime.proxyServer, null);
|
assert.equal(privateState.runtime.proxyServer, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration reports an occupied proxy address through its notification seam', async () => {
|
||||||
|
const occupiedServer = http.createServer();
|
||||||
|
occupiedServer.listen(0, '127.0.0.1');
|
||||||
|
await once(occupiedServer, 'listening');
|
||||||
|
const occupiedAddress = occupiedServer.address();
|
||||||
|
assert.ok(occupiedAddress && typeof occupiedAddress === 'object');
|
||||||
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-proxy-collision-'));
|
||||||
|
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||||
|
const integration = new AnkiIntegration(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
url: 'http://127.0.0.1:8765',
|
||||||
|
proxy: {
|
||||||
|
enabled: true,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: occupiedAddress.port,
|
||||||
|
upstreamUrl: 'http://127.0.0.1:8765',
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
notificationType: 'overlay',
|
||||||
|
},
|
||||||
|
knownWords: {
|
||||||
|
highlightEnabled: false,
|
||||||
|
},
|
||||||
|
nPlusOne: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
path.join(stateDir, 'known-words-cache.json'),
|
||||||
|
{},
|
||||||
|
undefined,
|
||||||
|
(payload) => {
|
||||||
|
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
integration.start();
|
||||||
|
await integration.waitUntilReady();
|
||||||
|
|
||||||
|
assert.deepEqual(overlayNotifications, [
|
||||||
|
{
|
||||||
|
title: 'SubMiner',
|
||||||
|
body: `AnkiConnect proxy unavailable because http://127.0.0.1:${occupiedAddress.port} is already in use. Change ankiConnect.proxy.port or stop the process using that address.`,
|
||||||
|
variant: 'info',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
integration.stop();
|
||||||
|
occupiedServer.close();
|
||||||
|
await once(occupiedServer, 'close');
|
||||||
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => {
|
test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => {
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -461,6 +461,7 @@ export class AnkiIntegration {
|
|||||||
getDeck: () => this.config.deck,
|
getDeck: () => this.config.deck,
|
||||||
findNotes: async (query, options) =>
|
findNotes: async (query, options) =>
|
||||||
(await this.client.findNotes(query, options)) as number[],
|
(await this.client.findNotes(query, options)) as number[],
|
||||||
|
notifyUnavailable: (message) => this.showStatusNotification(message),
|
||||||
logInfo: (message, ...args) => log.info(message, ...args),
|
logInfo: (message, ...args) => log.info(message, ...args),
|
||||||
logWarn: (message, ...args) => log.warn(message, ...args),
|
logWarn: (message, ...args) => log.warn(message, ...args),
|
||||||
logError: (message, ...args) => log.error(message, ...args),
|
logError: (message, ...args) => log.error(message, ...args),
|
||||||
|
|||||||
@@ -543,3 +543,41 @@ test('proxy detects self-referential loop configuration', () => {
|
|||||||
|
|
||||||
assert.equal(result, true);
|
assert.equal(result, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('proxy continues without a local listener when its address is already bound', async () => {
|
||||||
|
const occupiedServer = http.createServer();
|
||||||
|
occupiedServer.listen(0, '127.0.0.1');
|
||||||
|
await once(occupiedServer, 'listening');
|
||||||
|
const occupiedAddress = occupiedServer.address();
|
||||||
|
assert.ok(occupiedAddress && typeof occupiedAddress === 'object');
|
||||||
|
const info: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async () => undefined,
|
||||||
|
logInfo: (message) => info.push(message),
|
||||||
|
logWarn: (message, ...args) => warnings.push([message, ...args].join(' ')),
|
||||||
|
logError: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
proxy.start({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: occupiedAddress.port,
|
||||||
|
upstreamUrl: 'http://127.0.0.1:8765',
|
||||||
|
});
|
||||||
|
|
||||||
|
await proxy.waitUntilReady();
|
||||||
|
|
||||||
|
assert.equal(proxy.isRunning, false);
|
||||||
|
assert.deepEqual(warnings, [
|
||||||
|
`[anki-proxy] Local proxy unavailable because http://127.0.0.1:${occupiedAddress.port} is already in use; continuing without it. Change ankiConnect.proxy.port or stop the process using that address.`,
|
||||||
|
]);
|
||||||
|
proxy.stop();
|
||||||
|
assert.deepEqual(info, []);
|
||||||
|
} finally {
|
||||||
|
proxy.stop();
|
||||||
|
occupiedServer.close();
|
||||||
|
await once(occupiedServer, 'close');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface AnkiConnectProxyServerDeps {
|
|||||||
logInfo: (message: string, ...args: unknown[]) => void;
|
logInfo: (message: string, ...args: unknown[]) => void;
|
||||||
logWarn: (message: string, ...args: unknown[]) => void;
|
logWarn: (message: string, ...args: unknown[]) => void;
|
||||||
logError: (message: string, ...args: unknown[]) => void;
|
logError: (message: string, ...args: unknown[]) => void;
|
||||||
|
notifyUnavailable?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnkiConnectProxyServer {
|
export class AnkiConnectProxyServer {
|
||||||
@@ -78,7 +79,23 @@ export class AnkiConnectProxyServer {
|
|||||||
void this.handleRequest(req, res, options.upstreamUrl);
|
void this.handleRequest(req, res, options.upstreamUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const server = this.server;
|
||||||
this.server.on('error', (error) => {
|
this.server.on('error', (error) => {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'EADDRINUSE') {
|
||||||
|
this.resolveReady?.();
|
||||||
|
this.resolveReady = null;
|
||||||
|
this.rejectReady = null;
|
||||||
|
if (this.server === server) {
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
this.deps.logWarn(
|
||||||
|
`[anki-proxy] Local proxy unavailable because http://${options.host}:${options.port} is already in use; continuing without it. Change ankiConnect.proxy.port or stop the process using that address.`,
|
||||||
|
);
|
||||||
|
this.deps.notifyUnavailable?.(
|
||||||
|
`AnkiConnect proxy unavailable because http://${options.host}:${options.port} is already in use. Change ankiConnect.proxy.port or stop the process using that address.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.rejectReady?.(error as Error);
|
this.rejectReady?.(error as Error);
|
||||||
this.resolveReady = null;
|
this.resolveReady = null;
|
||||||
this.rejectReady = null;
|
this.rejectReady = null;
|
||||||
|
|||||||
@@ -112,8 +112,20 @@ function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
|||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const valueNode = propertyValue(property);
|
const valueNode = propertyValue(property);
|
||||||
const value = valueNode ? getNodeValue(valueNode) : undefined;
|
const value = valueNode ? getNodeValue(valueNode) : undefined;
|
||||||
if (key === 'enabled' || key === 'minSentenceWords') {
|
if (key === 'enabled') {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
canonicalNPlusOneValues.set(key, value);
|
canonicalNPlusOneValues.set(key, value);
|
||||||
|
} else {
|
||||||
|
canonicalNPlusOneValues.delete(key);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'minSentenceWords') {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
|
||||||
|
canonicalNPlusOneValues.set(key, value);
|
||||||
|
} else {
|
||||||
|
canonicalNPlusOneValues.delete(key);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
|
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
|
||||||
|
|||||||
@@ -2462,6 +2462,37 @@ test('resolves duplicate ankiConnect nPlusOne objects without rewriting config',
|
|||||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('later invalid duplicate nPlusOne values supersede earlier valid values', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
const configPath = path.join(dir, 'config.jsonc');
|
||||||
|
const originalContent = `{
|
||||||
|
"ankiConnect": {
|
||||||
|
"nPlusOne": {
|
||||||
|
"enabled": true,
|
||||||
|
"minSentenceWords": 4
|
||||||
|
},
|
||||||
|
"nPlusOne": {
|
||||||
|
"enabled": "yes",
|
||||||
|
"minSentenceWords": "4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
const warnings = service.getWarnings();
|
||||||
|
|
||||||
|
assert.equal(config.ankiConnect.nPlusOne.enabled, DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled);
|
||||||
|
assert.equal(
|
||||||
|
config.ankiConnect.nPlusOne.minSentenceWords,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords,
|
||||||
|
);
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.enabled'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.minSentenceWords'));
|
||||||
|
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||||
|
});
|
||||||
|
|
||||||
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions';
|
import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions';
|
||||||
import { createWarningCollector } from '../warnings';
|
import { createWarningCollector } from '../warnings';
|
||||||
import { applyAnkiConnectResolution } from './anki-connect';
|
import { applyAnkiConnectResolution } from './anki-connect';
|
||||||
|
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
||||||
import type { ResolveContext } from './context';
|
import type { ResolveContext } from './context';
|
||||||
|
|
||||||
function makeContext(ankiConnect: unknown): {
|
function makeContext(ankiConnect: unknown): {
|
||||||
@@ -39,6 +40,90 @@ test('modern invalid knownWords.highlightEnabled warns modern key and does not f
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('invalid modern known-words primitive values warn and keep defaults', () => {
|
||||||
|
const { context, warnings } = makeContext({
|
||||||
|
knownWords: {
|
||||||
|
refreshMinutes: 'daily',
|
||||||
|
matchMode: false,
|
||||||
|
},
|
||||||
|
nPlusOne: {
|
||||||
|
minSentenceWords: 'three',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applyAnkiConnectResolution(context);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.knownWords.matchMode,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.nPlusOne.minSentenceWords,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
warnings.map((warning) => warning.path),
|
||||||
|
[
|
||||||
|
'ankiConnect.knownWords.refreshMinutes',
|
||||||
|
'ankiConnect.nPlusOne.minSentenceWords',
|
||||||
|
'ankiConnect.knownWords.matchMode',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid legacy known-words primitive values warn and keep defaults', () => {
|
||||||
|
const { context, warnings } = makeContext({
|
||||||
|
behavior: {
|
||||||
|
nPlusOneHighlightEnabled: 'yes',
|
||||||
|
nPlusOneRefreshMinutes: 'daily',
|
||||||
|
nPlusOneMatchMode: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applyAnkiConnectResolution(context);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.knownWords.highlightEnabled,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.knownWords.matchMode,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
warnings.map((warning) => warning.path),
|
||||||
|
[
|
||||||
|
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
||||||
|
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
||||||
|
'ankiConnect.behavior.nPlusOneMatchMode',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('known-words resolution can run independently from other Anki domains', () => {
|
||||||
|
const { context, warnings } = makeContext({
|
||||||
|
knownWords: { highlightEnabled: true },
|
||||||
|
proxy: { port: -1 },
|
||||||
|
});
|
||||||
|
const ankiConnect = context.src.ankiConnect as Record<string, unknown>;
|
||||||
|
|
||||||
|
applyAnkiKnownWordsResolution(context, ankiConnect, {});
|
||||||
|
|
||||||
|
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
|
||||||
|
assert.equal(
|
||||||
|
warnings.some((warning) => warning.path.startsWith('ankiConnect.proxy')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('normalizes ankiConnect tags by trimming and deduping', () => {
|
test('normalizes ankiConnect tags by trimming and deduping', () => {
|
||||||
const { context, warnings } = makeContext({
|
const { context, warnings } = makeContext({
|
||||||
tags: [' SubMiner ', 'Mining', 'SubMiner', ' Mining '],
|
tags: [' SubMiner ', 'Mining', 'SubMiner', ' Mining '],
|
||||||
@@ -177,6 +262,40 @@ test('accepts ankiConnect.media.syncAnimatedImageToWordAudio override', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('invalid modern Anki subtrees warn and keep resolved defaults', () => {
|
||||||
|
const { context, warnings } = makeContext({
|
||||||
|
fields: { word: 7 },
|
||||||
|
media: { generateAudio: 'yes' },
|
||||||
|
behavior: { overwriteAudio: 'yes' },
|
||||||
|
metadata: { pattern: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
applyAnkiConnectResolution(context);
|
||||||
|
|
||||||
|
assert.equal(context.resolved.ankiConnect.fields.word, DEFAULT_CONFIG.ankiConnect.fields.word);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.media.generateAudio,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.generateAudio,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.behavior.overwriteAudio,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.behavior.overwriteAudio,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.metadata.pattern,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.metadata.pattern,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
warnings.map((warning) => warning.path),
|
||||||
|
[
|
||||||
|
'ankiConnect.fields.word',
|
||||||
|
'ankiConnect.media.generateAudio',
|
||||||
|
'ankiConnect.behavior.overwriteAudio',
|
||||||
|
'ankiConnect.metadata.pattern',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('maps legacy ankiConnect.wordField to modern ankiConnect.fields.word', () => {
|
test('maps legacy ankiConnect.wordField to modern ankiConnect.fields.word', () => {
|
||||||
const { context, warnings } = makeContext({
|
const { context, warnings } = makeContext({
|
||||||
wordField: 'TargetWordLegacy',
|
wordField: 'TargetWordLegacy',
|
||||||
|
|||||||
@@ -1,952 +1,25 @@
|
|||||||
import { DEFAULT_CONFIG } from '../definitions';
|
|
||||||
import type { ResolveContext } from './context';
|
import type { ResolveContext } from './context';
|
||||||
import { isNotificationType, type NotificationType } from '../../types/notification';
|
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
||||||
import { asBoolean, asColor, asNumber, asString, isObject } from './shared';
|
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
||||||
|
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
||||||
function asNotificationType(value: unknown): NotificationType | undefined {
|
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
||||||
return isNotificationType(value) ? value : undefined;
|
import { applyAnkiModernResolution } from './anki-connect/modern';
|
||||||
}
|
import { isObject } from './shared';
|
||||||
|
|
||||||
export function applyAnkiConnectResolution(context: ResolveContext): void {
|
export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||||
if (!isObject(context.src.ankiConnect)) {
|
if (!isObject(context.src.ankiConnect)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ac = context.src.ankiConnect;
|
const ankiConnect = context.src.ankiConnect;
|
||||||
const behavior = isObject(ac.behavior) ? (ac.behavior as Record<string, unknown>) : {};
|
const behavior = isObject(ankiConnect.behavior) ? ankiConnect.behavior : {};
|
||||||
const fields = isObject(ac.fields) ? (ac.fields as Record<string, unknown>) : {};
|
const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {};
|
||||||
const media = isObject(ac.media) ? (ac.media as Record<string, unknown>) : {};
|
const media = isObject(ankiConnect.media) ? ankiConnect.media : {};
|
||||||
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
|
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
|
||||||
const proxy = isObject(ac.proxy) ? (ac.proxy as Record<string, unknown>) : {};
|
|
||||||
const legacyKeys = new Set([
|
|
||||||
'wordField',
|
|
||||||
'audioField',
|
|
||||||
'imageField',
|
|
||||||
'sentenceField',
|
|
||||||
'miscInfoField',
|
|
||||||
'miscInfoPattern',
|
|
||||||
'generateAudio',
|
|
||||||
'generateImage',
|
|
||||||
'imageType',
|
|
||||||
'imageFormat',
|
|
||||||
'imageQuality',
|
|
||||||
'imageMaxWidth',
|
|
||||||
'imageMaxHeight',
|
|
||||||
'animatedFps',
|
|
||||||
'animatedMaxWidth',
|
|
||||||
'animatedMaxHeight',
|
|
||||||
'animatedCrf',
|
|
||||||
'syncAnimatedImageToWordAudio',
|
|
||||||
'audioPadding',
|
|
||||||
'fallbackDuration',
|
|
||||||
'maxMediaDuration',
|
|
||||||
'overwriteAudio',
|
|
||||||
'overwriteImage',
|
|
||||||
'mediaInsertMode',
|
|
||||||
'highlightWord',
|
|
||||||
'notificationType',
|
|
||||||
'autoUpdateNewCards',
|
|
||||||
]);
|
|
||||||
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
|
|
||||||
Object.prototype.hasOwnProperty.call(obj, key);
|
|
||||||
|
|
||||||
const {
|
initializeAnkiConnectResolution(context, ankiConnect);
|
||||||
knownWords: _knownWordsConfigFromAnkiConnect,
|
applyAnkiModernResolution(context, ankiConnect, behavior, media);
|
||||||
nPlusOne: _nPlusOneConfigFromAnkiConnect,
|
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
||||||
ai: _ankiAiConfig,
|
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
||||||
...ankiConnectWithoutKnownWordsOrNPlusOne
|
applyAnkiKikuResolution(context);
|
||||||
} = ac as Record<string, unknown>;
|
|
||||||
const ankiConnectWithoutLegacy = Object.fromEntries(
|
|
||||||
Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !legacyKeys.has(key)),
|
|
||||||
);
|
|
||||||
|
|
||||||
context.resolved.ankiConnect = {
|
|
||||||
...context.resolved.ankiConnect,
|
|
||||||
...(isObject(ankiConnectWithoutLegacy)
|
|
||||||
? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>)
|
|
||||||
: {}),
|
|
||||||
fields: {
|
|
||||||
...context.resolved.ankiConnect.fields,
|
|
||||||
...(isObject(ac.fields)
|
|
||||||
? (ac.fields as (typeof context.resolved)['ankiConnect']['fields'])
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
media: {
|
|
||||||
...context.resolved.ankiConnect.media,
|
|
||||||
...(isObject(ac.media)
|
|
||||||
? (ac.media as (typeof context.resolved)['ankiConnect']['media'])
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
knownWords: {
|
|
||||||
...context.resolved.ankiConnect.knownWords,
|
|
||||||
},
|
|
||||||
behavior: {
|
|
||||||
...context.resolved.ankiConnect.behavior,
|
|
||||||
...(isObject(ac.behavior)
|
|
||||||
? (ac.behavior as (typeof context.resolved)['ankiConnect']['behavior'])
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
proxy: {
|
|
||||||
...context.resolved.ankiConnect.proxy,
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
...context.resolved.ankiConnect.metadata,
|
|
||||||
...(isObject(ac.metadata)
|
|
||||||
? (ac.metadata as (typeof context.resolved)['ankiConnect']['metadata'])
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
isLapis: {
|
|
||||||
...context.resolved.ankiConnect.isLapis,
|
|
||||||
},
|
|
||||||
isKiku: {
|
|
||||||
...context.resolved.ankiConnect.isKiku,
|
|
||||||
...(isObject(ac.isKiku)
|
|
||||||
? (ac.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (hasOwn(media, 'mirrorMpvVolume')) {
|
|
||||||
const parsed = asBoolean(media.mirrorMpvVolume);
|
|
||||||
if (parsed === undefined) {
|
|
||||||
context.resolved.ankiConnect.media.mirrorMpvVolume =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.media.mirrorMpvVolume;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.media.mirrorMpvVolume',
|
|
||||||
media.mirrorMpvVolume,
|
|
||||||
context.resolved.ankiConnect.media.mirrorMpvVolume,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.media.mirrorMpvVolume = parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasOwn(behavior, 'notificationType')) {
|
|
||||||
const parsed = asNotificationType(behavior.notificationType);
|
|
||||||
if (parsed === undefined) {
|
|
||||||
context.resolved.ankiConnect.behavior.notificationType =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.behavior.notificationType;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.behavior.notificationType',
|
|
||||||
behavior.notificationType,
|
|
||||||
context.resolved.ankiConnect.behavior.notificationType,
|
|
||||||
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.behavior.notificationType = parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isObject(ac.isLapis)) {
|
|
||||||
const lapisEnabled = asBoolean(ac.isLapis.enabled);
|
|
||||||
if (lapisEnabled !== undefined) {
|
|
||||||
context.resolved.ankiConnect.isLapis.enabled = lapisEnabled;
|
|
||||||
} else if (ac.isLapis.enabled !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.isLapis.enabled',
|
|
||||||
ac.isLapis.enabled,
|
|
||||||
context.resolved.ankiConnect.isLapis.enabled,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentenceCardModel = asString(ac.isLapis.sentenceCardModel);
|
|
||||||
if (sentenceCardModel !== undefined) {
|
|
||||||
context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel;
|
|
||||||
} else if (ac.isLapis.sentenceCardModel !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.isLapis.sentenceCardModel',
|
|
||||||
ac.isLapis.sentenceCardModel,
|
|
||||||
context.resolved.ankiConnect.isLapis.sentenceCardModel,
|
|
||||||
'Expected string.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ac.isLapis.sentenceCardSentenceField !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.isLapis.sentenceCardSentenceField',
|
|
||||||
ac.isLapis.sentenceCardSentenceField,
|
|
||||||
'Sentence',
|
|
||||||
'Deprecated key; sentence-card sentence field is fixed to Sentence.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ac.isLapis.sentenceCardAudioField !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.isLapis.sentenceCardAudioField',
|
|
||||||
ac.isLapis.sentenceCardAudioField,
|
|
||||||
'SentenceAudio',
|
|
||||||
'Deprecated key; sentence-card audio field is fixed to SentenceAudio.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (ac.isLapis !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.isLapis',
|
|
||||||
ac.isLapis,
|
|
||||||
context.resolved.ankiConnect.isLapis,
|
|
||||||
'Expected object.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isObject(ac.proxy)) {
|
|
||||||
const proxyEnabled = asBoolean(proxy.enabled);
|
|
||||||
if (proxyEnabled !== undefined) {
|
|
||||||
context.resolved.ankiConnect.proxy.enabled = proxyEnabled;
|
|
||||||
} else if (proxy.enabled !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.proxy.enabled',
|
|
||||||
proxy.enabled,
|
|
||||||
context.resolved.ankiConnect.proxy.enabled,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxyHost = asString(proxy.host);
|
|
||||||
if (proxyHost !== undefined && proxyHost.trim().length > 0) {
|
|
||||||
context.resolved.ankiConnect.proxy.host = proxyHost.trim();
|
|
||||||
} else if (proxy.host !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.proxy.host',
|
|
||||||
proxy.host,
|
|
||||||
context.resolved.ankiConnect.proxy.host,
|
|
||||||
'Expected non-empty string.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxyUpstreamUrl = asString(proxy.upstreamUrl);
|
|
||||||
if (proxyUpstreamUrl !== undefined && proxyUpstreamUrl.trim().length > 0) {
|
|
||||||
context.resolved.ankiConnect.proxy.upstreamUrl = proxyUpstreamUrl.trim();
|
|
||||||
} else if (proxy.upstreamUrl !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.proxy.upstreamUrl',
|
|
||||||
proxy.upstreamUrl,
|
|
||||||
context.resolved.ankiConnect.proxy.upstreamUrl,
|
|
||||||
'Expected non-empty string.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxyPort = asNumber(proxy.port);
|
|
||||||
if (
|
|
||||||
proxyPort !== undefined &&
|
|
||||||
Number.isInteger(proxyPort) &&
|
|
||||||
proxyPort >= 1 &&
|
|
||||||
proxyPort <= 65535
|
|
||||||
) {
|
|
||||||
context.resolved.ankiConnect.proxy.port = proxyPort;
|
|
||||||
} else if (proxy.port !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.proxy.port',
|
|
||||||
proxy.port,
|
|
||||||
context.resolved.ankiConnect.proxy.port,
|
|
||||||
'Expected integer between 1 and 65535.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (ac.proxy !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.proxy',
|
|
||||||
ac.proxy,
|
|
||||||
context.resolved.ankiConnect.proxy,
|
|
||||||
'Expected object.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isObject(ac.ai)) {
|
|
||||||
const aiEnabled = asBoolean(ac.ai.enabled);
|
|
||||||
if (aiEnabled !== undefined) {
|
|
||||||
context.resolved.ankiConnect.ai.enabled = aiEnabled;
|
|
||||||
} else if (ac.ai.enabled !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.ai.enabled',
|
|
||||||
ac.ai.enabled,
|
|
||||||
context.resolved.ankiConnect.ai.enabled,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiModel = asString(ac.ai.model);
|
|
||||||
if (aiModel !== undefined) {
|
|
||||||
context.resolved.ankiConnect.ai.model = aiModel;
|
|
||||||
} else if (ac.ai.model !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.ai.model',
|
|
||||||
ac.ai.model,
|
|
||||||
context.resolved.ankiConnect.ai.model,
|
|
||||||
'Expected string.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiSystemPrompt = asString(ac.ai.systemPrompt);
|
|
||||||
if (aiSystemPrompt !== undefined) {
|
|
||||||
context.resolved.ankiConnect.ai.systemPrompt = aiSystemPrompt;
|
|
||||||
} else if (ac.ai.systemPrompt !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.ai.systemPrompt',
|
|
||||||
ac.ai.systemPrompt,
|
|
||||||
context.resolved.ankiConnect.ai.systemPrompt,
|
|
||||||
'Expected string.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const aiEnabled = asBoolean(ac.ai);
|
|
||||||
if (aiEnabled !== undefined) {
|
|
||||||
context.resolved.ankiConnect.ai.enabled = aiEnabled;
|
|
||||||
} else if (ac.ai !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.ai',
|
|
||||||
ac.ai,
|
|
||||||
context.resolved.ankiConnect.ai.enabled,
|
|
||||||
'Expected boolean or object.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(ac.tags)) {
|
|
||||||
const normalizedTags = ac.tags
|
|
||||||
.filter((entry): entry is string => typeof entry === 'string')
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter((entry) => entry.length > 0);
|
|
||||||
if (normalizedTags.length === ac.tags.length) {
|
|
||||||
context.resolved.ankiConnect.tags = [...new Set(normalizedTags)];
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.tags',
|
|
||||||
ac.tags,
|
|
||||||
context.resolved.ankiConnect.tags,
|
|
||||||
'Expected an array of non-empty strings.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (ac.tags !== undefined) {
|
|
||||||
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.tags',
|
|
||||||
ac.tags,
|
|
||||||
context.resolved.ankiConnect.tags,
|
|
||||||
'Expected an array of strings.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacy = ac as Record<string, unknown>;
|
|
||||||
const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => {
|
|
||||||
const parsed = asNumber(value);
|
|
||||||
if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
};
|
|
||||||
const asPositiveInteger = (value: unknown): number | undefined => {
|
|
||||||
const parsed = asNumber(value);
|
|
||||||
if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
};
|
|
||||||
const asPositiveNumber = (value: unknown): number | undefined => {
|
|
||||||
const parsed = asNumber(value);
|
|
||||||
if (parsed === undefined || parsed <= 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
};
|
|
||||||
const asNonNegativeNumber = (value: unknown): number | undefined => {
|
|
||||||
const parsed = asNumber(value);
|
|
||||||
if (parsed === undefined || parsed < 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
};
|
|
||||||
const asImageType = (value: unknown): 'static' | 'avif' | undefined => {
|
|
||||||
return value === 'static' || value === 'avif' ? value : undefined;
|
|
||||||
};
|
|
||||||
const asImageFormat = (value: unknown): 'jpg' | 'png' | 'webp' | undefined => {
|
|
||||||
return value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined;
|
|
||||||
};
|
|
||||||
const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => {
|
|
||||||
return value === 'append' || value === 'prepend' ? value : undefined;
|
|
||||||
};
|
|
||||||
const mapLegacy = <T>(
|
|
||||||
key: string,
|
|
||||||
parse: (value: unknown) => T | undefined,
|
|
||||||
apply: (value: T) => void,
|
|
||||||
fallback: unknown,
|
|
||||||
message: string,
|
|
||||||
): void => {
|
|
||||||
const value = legacy[key];
|
|
||||||
if (value === undefined) return;
|
|
||||||
const parsed = parse(value);
|
|
||||||
if (parsed === undefined) {
|
|
||||||
context.warn(`ankiConnect.${key}`, value, fallback, message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
apply(parsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!hasOwn(fields, 'audio')) {
|
|
||||||
mapLegacy(
|
|
||||||
'audioField',
|
|
||||||
asString,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.fields.audio = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.fields.audio,
|
|
||||||
'Expected string.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(fields, 'word')) {
|
|
||||||
mapLegacy(
|
|
||||||
'wordField',
|
|
||||||
asString,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.fields.word = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.fields.word,
|
|
||||||
'Expected string.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(fields, 'image')) {
|
|
||||||
mapLegacy(
|
|
||||||
'imageField',
|
|
||||||
asString,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.fields.image = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.fields.image,
|
|
||||||
'Expected string.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(fields, 'sentence')) {
|
|
||||||
mapLegacy(
|
|
||||||
'sentenceField',
|
|
||||||
asString,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.fields.sentence = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.fields.sentence,
|
|
||||||
'Expected string.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(fields, 'miscInfo')) {
|
|
||||||
mapLegacy(
|
|
||||||
'miscInfoField',
|
|
||||||
asString,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.fields.miscInfo = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.fields.miscInfo,
|
|
||||||
'Expected string.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(metadata, 'pattern')) {
|
|
||||||
mapLegacy(
|
|
||||||
'miscInfoPattern',
|
|
||||||
asString,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.metadata.pattern = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.metadata.pattern,
|
|
||||||
'Expected string.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'generateAudio')) {
|
|
||||||
mapLegacy(
|
|
||||||
'generateAudio',
|
|
||||||
asBoolean,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.generateAudio = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.generateAudio,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'generateImage')) {
|
|
||||||
mapLegacy(
|
|
||||||
'generateImage',
|
|
||||||
asBoolean,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.generateImage = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.generateImage,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'imageType')) {
|
|
||||||
mapLegacy(
|
|
||||||
'imageType',
|
|
||||||
asImageType,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.imageType = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.imageType,
|
|
||||||
"Expected 'static' or 'avif'.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'imageFormat')) {
|
|
||||||
mapLegacy(
|
|
||||||
'imageFormat',
|
|
||||||
asImageFormat,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.imageFormat = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.imageFormat,
|
|
||||||
"Expected 'jpg', 'png', or 'webp'.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'imageQuality')) {
|
|
||||||
mapLegacy(
|
|
||||||
'imageQuality',
|
|
||||||
(value) => asIntegerInRange(value, 1, 100),
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.imageQuality = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.imageQuality,
|
|
||||||
'Expected integer between 1 and 100.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'imageMaxWidth')) {
|
|
||||||
mapLegacy(
|
|
||||||
'imageMaxWidth',
|
|
||||||
asPositiveInteger,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.imageMaxWidth = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.imageMaxWidth,
|
|
||||||
'Expected positive integer.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'imageMaxHeight')) {
|
|
||||||
mapLegacy(
|
|
||||||
'imageMaxHeight',
|
|
||||||
asPositiveInteger,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.imageMaxHeight = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.imageMaxHeight,
|
|
||||||
'Expected positive integer.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'animatedFps')) {
|
|
||||||
mapLegacy(
|
|
||||||
'animatedFps',
|
|
||||||
(value) => asIntegerInRange(value, 1, 60),
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.animatedFps = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.animatedFps,
|
|
||||||
'Expected integer between 1 and 60.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'animatedMaxWidth')) {
|
|
||||||
mapLegacy(
|
|
||||||
'animatedMaxWidth',
|
|
||||||
asPositiveInteger,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.animatedMaxWidth = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.animatedMaxWidth,
|
|
||||||
'Expected positive integer.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'animatedMaxHeight')) {
|
|
||||||
mapLegacy(
|
|
||||||
'animatedMaxHeight',
|
|
||||||
asPositiveInteger,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.animatedMaxHeight = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.animatedMaxHeight,
|
|
||||||
'Expected positive integer.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'animatedCrf')) {
|
|
||||||
mapLegacy(
|
|
||||||
'animatedCrf',
|
|
||||||
(value) => asIntegerInRange(value, 0, 63),
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.animatedCrf = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.animatedCrf,
|
|
||||||
'Expected integer between 0 and 63.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'syncAnimatedImageToWordAudio')) {
|
|
||||||
mapLegacy(
|
|
||||||
'syncAnimatedImageToWordAudio',
|
|
||||||
asBoolean,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'audioPadding')) {
|
|
||||||
mapLegacy(
|
|
||||||
'audioPadding',
|
|
||||||
asNonNegativeNumber,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.audioPadding = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.audioPadding,
|
|
||||||
'Expected non-negative number.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'fallbackDuration')) {
|
|
||||||
mapLegacy(
|
|
||||||
'fallbackDuration',
|
|
||||||
asPositiveNumber,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.fallbackDuration = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.fallbackDuration,
|
|
||||||
'Expected positive number.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(media, 'maxMediaDuration')) {
|
|
||||||
mapLegacy(
|
|
||||||
'maxMediaDuration',
|
|
||||||
asNonNegativeNumber,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.maxMediaDuration = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.media.maxMediaDuration,
|
|
||||||
'Expected non-negative number.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(behavior, 'overwriteAudio')) {
|
|
||||||
mapLegacy(
|
|
||||||
'overwriteAudio',
|
|
||||||
asBoolean,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.behavior.overwriteAudio = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.behavior.overwriteAudio,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(behavior, 'overwriteImage')) {
|
|
||||||
mapLegacy(
|
|
||||||
'overwriteImage',
|
|
||||||
asBoolean,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.behavior.overwriteImage = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.behavior.overwriteImage,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(behavior, 'mediaInsertMode')) {
|
|
||||||
mapLegacy(
|
|
||||||
'mediaInsertMode',
|
|
||||||
asMediaInsertMode,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.behavior.mediaInsertMode = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.behavior.mediaInsertMode,
|
|
||||||
"Expected 'append' or 'prepend'.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(behavior, 'highlightWord')) {
|
|
||||||
mapLegacy(
|
|
||||||
'highlightWord',
|
|
||||||
asBoolean,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.behavior.highlightWord = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.behavior.highlightWord,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(behavior, 'notificationType')) {
|
|
||||||
mapLegacy(
|
|
||||||
'notificationType',
|
|
||||||
asNotificationType,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.behavior.notificationType = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.behavior.notificationType,
|
|
||||||
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
|
|
||||||
mapLegacy(
|
|
||||||
'autoUpdateNewCards',
|
|
||||||
asBoolean,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.behavior.autoUpdateNewCards = value;
|
|
||||||
},
|
|
||||||
context.resolved.ankiConnect.behavior.autoUpdateNewCards,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownWordsConfig = isObject(ac.knownWords)
|
|
||||||
? (ac.knownWords as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
|
|
||||||
|
|
||||||
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
|
|
||||||
if (knownWordsHighlightEnabled !== undefined) {
|
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
|
|
||||||
} else if (knownWordsConfig.highlightEnabled !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.knownWords.highlightEnabled',
|
|
||||||
knownWordsConfig.highlightEnabled,
|
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
|
||||||
} else {
|
|
||||||
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
|
|
||||||
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
|
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
|
||||||
legacyBehaviorNPlusOneHighlightEnabled;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
|
||||||
behavior.nPlusOneHighlightEnabled,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
|
||||||
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
|
|
||||||
const hasValidKnownWordsRefreshMinutes =
|
|
||||||
knownWordsRefreshMinutes !== undefined &&
|
|
||||||
Number.isInteger(knownWordsRefreshMinutes) &&
|
|
||||||
knownWordsRefreshMinutes > 0;
|
|
||||||
if (knownWordsRefreshMinutes !== undefined) {
|
|
||||||
if (hasValidKnownWordsRefreshMinutes) {
|
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
|
|
||||||
} else {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.knownWords.refreshMinutes',
|
|
||||||
knownWordsConfig.refreshMinutes,
|
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
|
||||||
'Expected a positive integer.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
|
||||||
}
|
|
||||||
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
|
|
||||||
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
|
|
||||||
const hasValidLegacyRefreshMinutes =
|
|
||||||
legacyBehaviorNPlusOneRefreshMinutes !== undefined &&
|
|
||||||
Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) &&
|
|
||||||
legacyBehaviorNPlusOneRefreshMinutes > 0;
|
|
||||||
if (hasValidLegacyRefreshMinutes) {
|
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
|
||||||
behavior.nPlusOneRefreshMinutes,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
|
|
||||||
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
|
||||||
behavior.nPlusOneRefreshMinutes,
|
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
|
||||||
'Expected a positive integer.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownWordsAddMinedWordsImmediately = asBoolean(knownWordsConfig.addMinedWordsImmediately);
|
|
||||||
if (knownWordsAddMinedWordsImmediately !== undefined) {
|
|
||||||
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
|
|
||||||
knownWordsAddMinedWordsImmediately;
|
|
||||||
} else if (knownWordsConfig.addMinedWordsImmediately !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
|
||||||
knownWordsConfig.addMinedWordsImmediately,
|
|
||||||
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
|
|
||||||
if (nPlusOneEnabled !== undefined) {
|
|
||||||
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
|
|
||||||
} else if (nPlusOneConfig.enabled !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.enabled',
|
|
||||||
nPlusOneConfig.enabled,
|
|
||||||
context.resolved.ankiConnect.nPlusOne.enabled,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
|
|
||||||
const hasValidNPlusOneMinSentenceWords =
|
|
||||||
nPlusOneMinSentenceWords !== undefined &&
|
|
||||||
Number.isInteger(nPlusOneMinSentenceWords) &&
|
|
||||||
nPlusOneMinSentenceWords > 0;
|
|
||||||
if (nPlusOneMinSentenceWords !== undefined) {
|
|
||||||
if (hasValidNPlusOneMinSentenceWords) {
|
|
||||||
context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords;
|
|
||||||
} else {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.minSentenceWords',
|
|
||||||
nPlusOneConfig.minSentenceWords,
|
|
||||||
context.resolved.ankiConnect.nPlusOne.minSentenceWords,
|
|
||||||
'Expected a positive integer.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
|
|
||||||
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
|
|
||||||
const hasValidKnownWordsMatchMode =
|
|
||||||
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
|
|
||||||
const hasValidLegacyMatchMode =
|
|
||||||
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
|
|
||||||
if (hasValidKnownWordsMatchMode) {
|
|
||||||
context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode;
|
|
||||||
} else if (knownWordsMatchMode !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.knownWords.matchMode',
|
|
||||||
knownWordsConfig.matchMode,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
|
||||||
"Expected 'headword' or 'surface'.",
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.matchMode =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
|
||||||
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
|
|
||||||
if (hasValidLegacyMatchMode) {
|
|
||||||
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.behavior.nPlusOneMatchMode',
|
|
||||||
behavior.nPlusOneMatchMode,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
|
||||||
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.behavior.nPlusOneMatchMode',
|
|
||||||
behavior.nPlusOneMatchMode,
|
|
||||||
context.resolved.ankiConnect.knownWords.matchMode,
|
|
||||||
"Expected 'headword' or 'surface'.",
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.matchMode =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.knownWords.matchMode =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_FIELDS = [
|
|
||||||
DEFAULT_CONFIG.ankiConnect.fields.word,
|
|
||||||
'Word',
|
|
||||||
'Reading',
|
|
||||||
'Word Reading',
|
|
||||||
];
|
|
||||||
const knownWordsDecks = knownWordsConfig.decks;
|
|
||||||
if (isObject(knownWordsDecks)) {
|
|
||||||
const resolved: Record<string, string[]> = {};
|
|
||||||
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
|
|
||||||
const deckName = deck.trim();
|
|
||||||
if (!deckName) continue;
|
|
||||||
if (Array.isArray(fields) && fields.every((f) => typeof f === 'string')) {
|
|
||||||
resolved[deckName] = (fields as string[]).map((f) => f.trim()).filter((f) => f.length > 0);
|
|
||||||
} else {
|
|
||||||
context.warn(
|
|
||||||
`ankiConnect.knownWords.decks["${deckName}"]`,
|
|
||||||
fields,
|
|
||||||
DEFAULT_FIELDS,
|
|
||||||
'Expected an array of field name strings.',
|
|
||||||
);
|
|
||||||
resolved[deckName] = DEFAULT_FIELDS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context.resolved.ankiConnect.knownWords.decks = resolved;
|
|
||||||
} else if (Array.isArray(knownWordsDecks)) {
|
|
||||||
const normalized = knownWordsDecks
|
|
||||||
.filter((entry): entry is string => typeof entry === 'string')
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter((entry) => entry.length > 0);
|
|
||||||
const resolved: Record<string, string[]> = {};
|
|
||||||
for (const deck of new Set(normalized)) {
|
|
||||||
resolved[deck] = DEFAULT_FIELDS;
|
|
||||||
}
|
|
||||||
context.resolved.ankiConnect.knownWords.decks = resolved;
|
|
||||||
if (normalized.length > 0) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.knownWords.decks',
|
|
||||||
knownWordsDecks,
|
|
||||||
resolved,
|
|
||||||
'Legacy array format is deprecated; use object format: { "Deck Name": ["Field1", "Field2"] }',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (knownWordsDecks !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.knownWords.decks',
|
|
||||||
knownWordsDecks,
|
|
||||||
context.resolved.ankiConnect.knownWords.decks,
|
|
||||||
'Expected an object mapping deck names to field arrays.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawSubtitleStyle = isObject(context.src.subtitleStyle)
|
|
||||||
? (context.src.subtitleStyle as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
|
|
||||||
|
|
||||||
const knownWordsColor = asColor(knownWordsConfig.color);
|
|
||||||
if (knownWordsColor !== undefined) {
|
|
||||||
if (!hasCanonicalKnownWordColor) {
|
|
||||||
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
|
|
||||||
}
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.knownWords.color',
|
|
||||||
knownWordsConfig.color,
|
|
||||||
context.resolved.subtitleStyle.knownWordColor,
|
|
||||||
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
|
|
||||||
);
|
|
||||||
} else if (knownWordsConfig.color !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.knownWords.color',
|
|
||||||
knownWordsConfig.color,
|
|
||||||
context.resolved.subtitleStyle.knownWordColor,
|
|
||||||
'Expected a hex color value.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' &&
|
|
||||||
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' &&
|
|
||||||
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled'
|
|
||||||
) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.isKiku.fieldGrouping',
|
|
||||||
context.resolved.ankiConnect.isKiku.fieldGrouping,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
|
|
||||||
'Expected auto, manual, or disabled.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.isKiku.fieldGrouping =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { asBoolean, asString, isObject } from '../shared';
|
||||||
|
|
||||||
|
export function applyAiResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (isObject(ankiConnect.ai)) {
|
||||||
|
const aiEnabled = asBoolean(ankiConnect.ai.enabled);
|
||||||
|
if (aiEnabled !== undefined) {
|
||||||
|
context.resolved.ankiConnect.ai.enabled = aiEnabled;
|
||||||
|
} else if (ankiConnect.ai.enabled !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.ai.enabled',
|
||||||
|
ankiConnect.ai.enabled,
|
||||||
|
context.resolved.ankiConnect.ai.enabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiModel = asString(ankiConnect.ai.model);
|
||||||
|
if (aiModel !== undefined) {
|
||||||
|
context.resolved.ankiConnect.ai.model = aiModel;
|
||||||
|
} else if (ankiConnect.ai.model !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.ai.model',
|
||||||
|
ankiConnect.ai.model,
|
||||||
|
context.resolved.ankiConnect.ai.model,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiSystemPrompt = asString(ankiConnect.ai.systemPrompt);
|
||||||
|
if (aiSystemPrompt !== undefined) {
|
||||||
|
context.resolved.ankiConnect.ai.systemPrompt = aiSystemPrompt;
|
||||||
|
} else if (ankiConnect.ai.systemPrompt !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.ai.systemPrompt',
|
||||||
|
ankiConnect.ai.systemPrompt,
|
||||||
|
context.resolved.ankiConnect.ai.systemPrompt,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const aiEnabled = asBoolean(ankiConnect.ai);
|
||||||
|
if (aiEnabled !== undefined) {
|
||||||
|
context.resolved.ankiConnect.ai.enabled = aiEnabled;
|
||||||
|
} else if (ankiConnect.ai !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.ai',
|
||||||
|
ankiConnect.ai,
|
||||||
|
context.resolved.ankiConnect.ai.enabled,
|
||||||
|
'Expected boolean or object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { isObject } from '../shared';
|
||||||
|
|
||||||
|
const LEGACY_KEYS = new Set([
|
||||||
|
'wordField',
|
||||||
|
'audioField',
|
||||||
|
'imageField',
|
||||||
|
'sentenceField',
|
||||||
|
'miscInfoField',
|
||||||
|
'miscInfoPattern',
|
||||||
|
'generateAudio',
|
||||||
|
'generateImage',
|
||||||
|
'imageType',
|
||||||
|
'imageFormat',
|
||||||
|
'imageQuality',
|
||||||
|
'imageMaxWidth',
|
||||||
|
'imageMaxHeight',
|
||||||
|
'animatedFps',
|
||||||
|
'animatedMaxWidth',
|
||||||
|
'animatedMaxHeight',
|
||||||
|
'animatedCrf',
|
||||||
|
'syncAnimatedImageToWordAudio',
|
||||||
|
'audioPadding',
|
||||||
|
'fallbackDuration',
|
||||||
|
'maxMediaDuration',
|
||||||
|
'overwriteAudio',
|
||||||
|
'overwriteImage',
|
||||||
|
'mediaInsertMode',
|
||||||
|
'highlightWord',
|
||||||
|
'notificationType',
|
||||||
|
'autoUpdateNewCards',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function initializeAnkiConnectResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const {
|
||||||
|
knownWords: _knownWordsConfigFromAnkiConnect,
|
||||||
|
nPlusOne: _nPlusOneConfigFromAnkiConnect,
|
||||||
|
ai: _ankiAiConfig,
|
||||||
|
...ankiConnectWithoutKnownWordsOrNPlusOne
|
||||||
|
} = ankiConnect;
|
||||||
|
const ankiConnectWithoutLegacy = Object.fromEntries(
|
||||||
|
Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !LEGACY_KEYS.has(key)),
|
||||||
|
);
|
||||||
|
|
||||||
|
context.resolved.ankiConnect = {
|
||||||
|
...context.resolved.ankiConnect,
|
||||||
|
...(isObject(ankiConnectWithoutLegacy)
|
||||||
|
? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>)
|
||||||
|
: {}),
|
||||||
|
fields: {
|
||||||
|
...context.resolved.ankiConnect.fields,
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
...context.resolved.ankiConnect.media,
|
||||||
|
},
|
||||||
|
knownWords: {
|
||||||
|
...context.resolved.ankiConnect.knownWords,
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
...context.resolved.ankiConnect.behavior,
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
...context.resolved.ankiConnect.proxy,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
...context.resolved.ankiConnect.metadata,
|
||||||
|
},
|
||||||
|
isLapis: {
|
||||||
|
...context.resolved.ankiConnect.isLapis,
|
||||||
|
},
|
||||||
|
isKiku: {
|
||||||
|
...context.resolved.ankiConnect.isKiku,
|
||||||
|
...(isObject(ankiConnect.isKiku)
|
||||||
|
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
|
||||||
|
export function applyAnkiKikuResolution(context: ResolveContext): void {
|
||||||
|
if (
|
||||||
|
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' &&
|
||||||
|
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' &&
|
||||||
|
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled'
|
||||||
|
) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.isKiku.fieldGrouping',
|
||||||
|
context.resolved.ankiConnect.isKiku.fieldGrouping,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
|
||||||
|
'Expected auto, manual, or disabled.',
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.isKiku.fieldGrouping =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { asBoolean, asColor, asNumber, asString, isObject } from '../shared';
|
||||||
|
import { hasOwn } from './shared';
|
||||||
|
|
||||||
|
export function applyAnkiKnownWordsResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
behavior: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const knownWordsConfig = isObject(ankiConnect.knownWords) ? ankiConnect.knownWords : {};
|
||||||
|
const nPlusOneConfig = isObject(ankiConnect.nPlusOne) ? ankiConnect.nPlusOne : {};
|
||||||
|
|
||||||
|
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
|
||||||
|
if (knownWordsHighlightEnabled !== undefined) {
|
||||||
|
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
|
||||||
|
} else if (hasOwn(knownWordsConfig, 'highlightEnabled')) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.knownWords.highlightEnabled',
|
||||||
|
knownWordsConfig.highlightEnabled,
|
||||||
|
context.resolved.ankiConnect.knownWords.highlightEnabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||||
|
} else {
|
||||||
|
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
|
||||||
|
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
|
||||||
|
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||||
|
legacyBehaviorNPlusOneHighlightEnabled;
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
||||||
|
behavior.nPlusOneHighlightEnabled,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
||||||
|
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
|
||||||
|
);
|
||||||
|
} else if (hasOwn(behavior, 'nPlusOneHighlightEnabled')) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
||||||
|
behavior.nPlusOneHighlightEnabled,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||||
|
} else {
|
||||||
|
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
|
||||||
|
const hasValidKnownWordsRefreshMinutes =
|
||||||
|
knownWordsRefreshMinutes !== undefined &&
|
||||||
|
Number.isInteger(knownWordsRefreshMinutes) &&
|
||||||
|
knownWordsRefreshMinutes > 0;
|
||||||
|
if (hasOwn(knownWordsConfig, 'refreshMinutes')) {
|
||||||
|
if (hasValidKnownWordsRefreshMinutes) {
|
||||||
|
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
|
||||||
|
} else {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.knownWords.refreshMinutes',
|
||||||
|
knownWordsConfig.refreshMinutes,
|
||||||
|
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
||||||
|
'Expected a positive integer.',
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||||
|
}
|
||||||
|
} else if (hasOwn(behavior, 'nPlusOneRefreshMinutes')) {
|
||||||
|
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
|
||||||
|
const hasValidLegacyRefreshMinutes =
|
||||||
|
legacyBehaviorNPlusOneRefreshMinutes !== undefined &&
|
||||||
|
Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) &&
|
||||||
|
legacyBehaviorNPlusOneRefreshMinutes > 0;
|
||||||
|
if (hasValidLegacyRefreshMinutes) {
|
||||||
|
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes;
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
||||||
|
behavior.nPlusOneRefreshMinutes,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
|
||||||
|
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
||||||
|
behavior.nPlusOneRefreshMinutes,
|
||||||
|
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
||||||
|
'Expected a positive integer.',
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownWordsAddMinedWordsImmediately = asBoolean(knownWordsConfig.addMinedWordsImmediately);
|
||||||
|
if (knownWordsAddMinedWordsImmediately !== undefined) {
|
||||||
|
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
|
||||||
|
knownWordsAddMinedWordsImmediately;
|
||||||
|
} else if (knownWordsConfig.addMinedWordsImmediately !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||||
|
knownWordsConfig.addMinedWordsImmediately,
|
||||||
|
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
||||||
|
} else {
|
||||||
|
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
|
||||||
|
if (nPlusOneEnabled !== undefined) {
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
|
||||||
|
} else if (nPlusOneConfig.enabled !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.nPlusOne.enabled',
|
||||||
|
nPlusOneConfig.enabled,
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||||
|
} else {
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
|
||||||
|
const hasValidNPlusOneMinSentenceWords =
|
||||||
|
nPlusOneMinSentenceWords !== undefined &&
|
||||||
|
Number.isInteger(nPlusOneMinSentenceWords) &&
|
||||||
|
nPlusOneMinSentenceWords > 0;
|
||||||
|
if (hasOwn(nPlusOneConfig, 'minSentenceWords')) {
|
||||||
|
if (hasValidNPlusOneMinSentenceWords) {
|
||||||
|
context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords;
|
||||||
|
} else {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.nPlusOne.minSentenceWords',
|
||||||
|
nPlusOneConfig.minSentenceWords,
|
||||||
|
context.resolved.ankiConnect.nPlusOne.minSentenceWords,
|
||||||
|
'Expected a positive integer.',
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
|
||||||
|
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
|
||||||
|
const hasValidKnownWordsMatchMode =
|
||||||
|
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
|
||||||
|
const hasValidLegacyMatchMode =
|
||||||
|
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
|
||||||
|
if (hasValidKnownWordsMatchMode) {
|
||||||
|
context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode;
|
||||||
|
} else if (hasOwn(knownWordsConfig, 'matchMode')) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.knownWords.matchMode',
|
||||||
|
knownWordsConfig.matchMode,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
||||||
|
"Expected 'headword' or 'surface'.",
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.knownWords.matchMode =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
||||||
|
} else if (hasOwn(behavior, 'nPlusOneMatchMode')) {
|
||||||
|
if (hasValidLegacyMatchMode) {
|
||||||
|
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.behavior.nPlusOneMatchMode',
|
||||||
|
behavior.nPlusOneMatchMode,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
||||||
|
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.behavior.nPlusOneMatchMode',
|
||||||
|
behavior.nPlusOneMatchMode,
|
||||||
|
context.resolved.ankiConnect.knownWords.matchMode,
|
||||||
|
"Expected 'headword' or 'surface'.",
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.knownWords.matchMode =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
context.resolved.ankiConnect.knownWords.matchMode =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading'];
|
||||||
|
const knownWordsDecks = knownWordsConfig.decks;
|
||||||
|
if (isObject(knownWordsDecks)) {
|
||||||
|
const resolved: Record<string, string[]> = {};
|
||||||
|
for (const [deck, fields] of Object.entries(knownWordsDecks)) {
|
||||||
|
const deckName = deck.trim();
|
||||||
|
if (!deckName) continue;
|
||||||
|
if (Array.isArray(fields) && fields.every((field) => typeof field === 'string')) {
|
||||||
|
resolved[deckName] = fields
|
||||||
|
.map((field) => field.trim())
|
||||||
|
.filter((field) => field.length > 0);
|
||||||
|
} else {
|
||||||
|
context.warn(
|
||||||
|
`ankiConnect.knownWords.decks["${deckName}"]`,
|
||||||
|
fields,
|
||||||
|
defaultFields,
|
||||||
|
'Expected an array of field name strings.',
|
||||||
|
);
|
||||||
|
resolved[deckName] = defaultFields;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.resolved.ankiConnect.knownWords.decks = resolved;
|
||||||
|
} else if (Array.isArray(knownWordsDecks)) {
|
||||||
|
const normalized = knownWordsDecks
|
||||||
|
.filter((entry): entry is string => typeof entry === 'string')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
const resolved: Record<string, string[]> = {};
|
||||||
|
for (const deck of new Set(normalized)) {
|
||||||
|
resolved[deck] = defaultFields;
|
||||||
|
}
|
||||||
|
context.resolved.ankiConnect.knownWords.decks = resolved;
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.knownWords.decks',
|
||||||
|
knownWordsDecks,
|
||||||
|
resolved,
|
||||||
|
'Legacy array format is deprecated; use object format: { "Deck Name": ["Field1", "Field2"] }',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (knownWordsDecks !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.knownWords.decks',
|
||||||
|
knownWordsDecks,
|
||||||
|
context.resolved.ankiConnect.knownWords.decks,
|
||||||
|
'Expected an object mapping deck names to field arrays.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSubtitleStyle = isObject(context.src.subtitleStyle) ? context.src.subtitleStyle : {};
|
||||||
|
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
|
||||||
|
|
||||||
|
const knownWordsColor = asColor(knownWordsConfig.color);
|
||||||
|
if (knownWordsColor !== undefined) {
|
||||||
|
if (!hasCanonicalKnownWordColor) {
|
||||||
|
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
|
||||||
|
}
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.knownWords.color',
|
||||||
|
knownWordsConfig.color,
|
||||||
|
context.resolved.subtitleStyle.knownWordColor,
|
||||||
|
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
|
||||||
|
);
|
||||||
|
} else if (knownWordsConfig.color !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.knownWords.color',
|
||||||
|
knownWordsConfig.color,
|
||||||
|
context.resolved.subtitleStyle.knownWordColor,
|
||||||
|
'Expected a hex color value.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { asBoolean, asString, isObject } from '../shared';
|
||||||
|
|
||||||
|
export function applyLapisResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (isObject(ankiConnect.isLapis)) {
|
||||||
|
const lapisEnabled = asBoolean(ankiConnect.isLapis.enabled);
|
||||||
|
if (lapisEnabled !== undefined) {
|
||||||
|
context.resolved.ankiConnect.isLapis.enabled = lapisEnabled;
|
||||||
|
} else if (ankiConnect.isLapis.enabled !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.isLapis.enabled',
|
||||||
|
ankiConnect.isLapis.enabled,
|
||||||
|
context.resolved.ankiConnect.isLapis.enabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentenceCardModel = asString(ankiConnect.isLapis.sentenceCardModel);
|
||||||
|
if (sentenceCardModel !== undefined) {
|
||||||
|
context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel;
|
||||||
|
} else if (ankiConnect.isLapis.sentenceCardModel !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.isLapis.sentenceCardModel',
|
||||||
|
ankiConnect.isLapis.sentenceCardModel,
|
||||||
|
context.resolved.ankiConnect.isLapis.sentenceCardModel,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ankiConnect.isLapis.sentenceCardSentenceField !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.isLapis.sentenceCardSentenceField',
|
||||||
|
ankiConnect.isLapis.sentenceCardSentenceField,
|
||||||
|
'Sentence',
|
||||||
|
'Deprecated key; sentence-card sentence field is fixed to Sentence.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ankiConnect.isLapis.sentenceCardAudioField !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.isLapis.sentenceCardAudioField',
|
||||||
|
ankiConnect.isLapis.sentenceCardAudioField,
|
||||||
|
'SentenceAudio',
|
||||||
|
'Deprecated key; sentence-card audio field is fixed to SentenceAudio.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (ankiConnect.isLapis !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.isLapis',
|
||||||
|
ankiConnect.isLapis,
|
||||||
|
context.resolved.ankiConnect.isLapis,
|
||||||
|
'Expected object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { asBoolean, asNumber, asString } from '../shared';
|
||||||
|
import { asNotificationType, hasOwn } from './shared';
|
||||||
|
|
||||||
|
export function applyAnkiLegacyResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
legacy: Record<string, unknown>,
|
||||||
|
behavior: Record<string, unknown>,
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
media: Record<string, unknown>,
|
||||||
|
metadata: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
const asPositiveInteger = (value: unknown): number | undefined => {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
const asPositiveNumber = (value: unknown): number | undefined => {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
if (parsed === undefined || parsed <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
const asNonNegativeNumber = (value: unknown): number | undefined => {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
if (parsed === undefined || parsed < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
const asImageType = (value: unknown): 'static' | 'avif' | undefined => {
|
||||||
|
return value === 'static' || value === 'avif' ? value : undefined;
|
||||||
|
};
|
||||||
|
const asImageFormat = (value: unknown): 'jpg' | 'png' | 'webp' | undefined => {
|
||||||
|
return value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined;
|
||||||
|
};
|
||||||
|
const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => {
|
||||||
|
return value === 'append' || value === 'prepend' ? value : undefined;
|
||||||
|
};
|
||||||
|
const mapLegacy = <T>(
|
||||||
|
key: string,
|
||||||
|
parse: (value: unknown) => T | undefined,
|
||||||
|
apply: (value: T) => void,
|
||||||
|
fallback: unknown,
|
||||||
|
message: string,
|
||||||
|
): void => {
|
||||||
|
const value = legacy[key];
|
||||||
|
if (value === undefined) return;
|
||||||
|
const parsed = parse(value);
|
||||||
|
if (parsed === undefined) {
|
||||||
|
context.warn(`ankiConnect.${key}`, value, fallback, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!hasOwn(fields, 'audio')) {
|
||||||
|
mapLegacy(
|
||||||
|
'audioField',
|
||||||
|
asString,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.fields.audio = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.fields.audio,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(fields, 'word')) {
|
||||||
|
mapLegacy(
|
||||||
|
'wordField',
|
||||||
|
asString,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.fields.word = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.fields.word,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(fields, 'image')) {
|
||||||
|
mapLegacy(
|
||||||
|
'imageField',
|
||||||
|
asString,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.fields.image = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.fields.image,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(fields, 'sentence')) {
|
||||||
|
mapLegacy(
|
||||||
|
'sentenceField',
|
||||||
|
asString,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.fields.sentence = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.fields.sentence,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(fields, 'miscInfo')) {
|
||||||
|
mapLegacy(
|
||||||
|
'miscInfoField',
|
||||||
|
asString,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.fields.miscInfo = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.fields.miscInfo,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(metadata, 'pattern')) {
|
||||||
|
mapLegacy(
|
||||||
|
'miscInfoPattern',
|
||||||
|
asString,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.metadata.pattern = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.metadata.pattern,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'generateAudio')) {
|
||||||
|
mapLegacy(
|
||||||
|
'generateAudio',
|
||||||
|
asBoolean,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.generateAudio = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.generateAudio,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'generateImage')) {
|
||||||
|
mapLegacy(
|
||||||
|
'generateImage',
|
||||||
|
asBoolean,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.generateImage = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.generateImage,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'imageType')) {
|
||||||
|
mapLegacy(
|
||||||
|
'imageType',
|
||||||
|
asImageType,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.imageType = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.imageType,
|
||||||
|
"Expected 'static' or 'avif'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'imageFormat')) {
|
||||||
|
mapLegacy(
|
||||||
|
'imageFormat',
|
||||||
|
asImageFormat,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.imageFormat = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.imageFormat,
|
||||||
|
"Expected 'jpg', 'png', or 'webp'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'imageQuality')) {
|
||||||
|
mapLegacy(
|
||||||
|
'imageQuality',
|
||||||
|
(value) => asIntegerInRange(value, 1, 100),
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.imageQuality = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.imageQuality,
|
||||||
|
'Expected integer between 1 and 100.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'imageMaxWidth')) {
|
||||||
|
mapLegacy(
|
||||||
|
'imageMaxWidth',
|
||||||
|
asPositiveInteger,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.imageMaxWidth = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.imageMaxWidth,
|
||||||
|
'Expected positive integer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'imageMaxHeight')) {
|
||||||
|
mapLegacy(
|
||||||
|
'imageMaxHeight',
|
||||||
|
asPositiveInteger,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.imageMaxHeight = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.imageMaxHeight,
|
||||||
|
'Expected positive integer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'animatedFps')) {
|
||||||
|
mapLegacy(
|
||||||
|
'animatedFps',
|
||||||
|
(value) => asIntegerInRange(value, 1, 60),
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.animatedFps = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.animatedFps,
|
||||||
|
'Expected integer between 1 and 60.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'animatedMaxWidth')) {
|
||||||
|
mapLegacy(
|
||||||
|
'animatedMaxWidth',
|
||||||
|
asPositiveInteger,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.animatedMaxWidth = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.animatedMaxWidth,
|
||||||
|
'Expected positive integer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'animatedMaxHeight')) {
|
||||||
|
mapLegacy(
|
||||||
|
'animatedMaxHeight',
|
||||||
|
asPositiveInteger,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.animatedMaxHeight = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.animatedMaxHeight,
|
||||||
|
'Expected positive integer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'animatedCrf')) {
|
||||||
|
mapLegacy(
|
||||||
|
'animatedCrf',
|
||||||
|
(value) => asIntegerInRange(value, 0, 63),
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.animatedCrf = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.animatedCrf,
|
||||||
|
'Expected integer between 0 and 63.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'syncAnimatedImageToWordAudio')) {
|
||||||
|
mapLegacy(
|
||||||
|
'syncAnimatedImageToWordAudio',
|
||||||
|
asBoolean,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'audioPadding')) {
|
||||||
|
mapLegacy(
|
||||||
|
'audioPadding',
|
||||||
|
asNonNegativeNumber,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.audioPadding = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.audioPadding,
|
||||||
|
'Expected non-negative number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'fallbackDuration')) {
|
||||||
|
mapLegacy(
|
||||||
|
'fallbackDuration',
|
||||||
|
asPositiveNumber,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.fallbackDuration = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.fallbackDuration,
|
||||||
|
'Expected positive number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(media, 'maxMediaDuration')) {
|
||||||
|
mapLegacy(
|
||||||
|
'maxMediaDuration',
|
||||||
|
asNonNegativeNumber,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.maxMediaDuration = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.media.maxMediaDuration,
|
||||||
|
'Expected non-negative number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(behavior, 'overwriteAudio')) {
|
||||||
|
mapLegacy(
|
||||||
|
'overwriteAudio',
|
||||||
|
asBoolean,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior.overwriteAudio = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.behavior.overwriteAudio,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(behavior, 'overwriteImage')) {
|
||||||
|
mapLegacy(
|
||||||
|
'overwriteImage',
|
||||||
|
asBoolean,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior.overwriteImage = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.behavior.overwriteImage,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(behavior, 'mediaInsertMode')) {
|
||||||
|
mapLegacy(
|
||||||
|
'mediaInsertMode',
|
||||||
|
asMediaInsertMode,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior.mediaInsertMode = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.behavior.mediaInsertMode,
|
||||||
|
"Expected 'append' or 'prepend'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(behavior, 'highlightWord')) {
|
||||||
|
mapLegacy(
|
||||||
|
'highlightWord',
|
||||||
|
asBoolean,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior.highlightWord = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.behavior.highlightWord,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(behavior, 'notificationType')) {
|
||||||
|
mapLegacy(
|
||||||
|
'notificationType',
|
||||||
|
asNotificationType,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior.notificationType = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.behavior.notificationType,
|
||||||
|
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
|
||||||
|
mapLegacy(
|
||||||
|
'autoUpdateNewCards',
|
||||||
|
asBoolean,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior.autoUpdateNewCards = value;
|
||||||
|
},
|
||||||
|
context.resolved.ankiConnect.behavior.autoUpdateNewCards,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { asBoolean } from '../shared';
|
||||||
|
import { applyModernValue } from './modern-value';
|
||||||
|
import { asNotificationType } from './shared';
|
||||||
|
|
||||||
|
export function applyModernBehaviorResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
behavior: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
for (const key of [
|
||||||
|
'overwriteAudio',
|
||||||
|
'overwriteImage',
|
||||||
|
'highlightWord',
|
||||||
|
'autoUpdateNewCards',
|
||||||
|
] as const) {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
behavior,
|
||||||
|
key,
|
||||||
|
`ankiConnect.behavior.${key}`,
|
||||||
|
asBoolean,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.behavior[key],
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior[key] = value;
|
||||||
|
},
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
behavior,
|
||||||
|
'mediaInsertMode',
|
||||||
|
'ankiConnect.behavior.mediaInsertMode',
|
||||||
|
(value) => (value === 'append' || value === 'prepend' ? value : undefined),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior.mediaInsertMode = value;
|
||||||
|
},
|
||||||
|
"Expected 'append' or 'prepend'.",
|
||||||
|
);
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
behavior,
|
||||||
|
'notificationType',
|
||||||
|
'ankiConnect.behavior.notificationType',
|
||||||
|
asNotificationType,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.behavior.notificationType,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.behavior.notificationType = value;
|
||||||
|
},
|
||||||
|
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { asString } from '../shared';
|
||||||
|
import { applyModernValue } from './modern-value';
|
||||||
|
|
||||||
|
export function applyModernFieldsResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
fields,
|
||||||
|
key,
|
||||||
|
`ankiConnect.fields.${key}`,
|
||||||
|
asString,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.fields[key],
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.fields[key] = value;
|
||||||
|
},
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { asBoolean } from '../shared';
|
||||||
|
import {
|
||||||
|
applyModernValue,
|
||||||
|
asIntegerInRange,
|
||||||
|
asNonNegativeInteger,
|
||||||
|
asNonNegativeNumber,
|
||||||
|
asPositiveNumber,
|
||||||
|
} from './modern-value';
|
||||||
|
|
||||||
|
export function applyModernMediaResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
media: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
for (const key of [
|
||||||
|
'generateAudio',
|
||||||
|
'generateImage',
|
||||||
|
'syncAnimatedImageToWordAudio',
|
||||||
|
'normalizeAudio',
|
||||||
|
'mirrorMpvVolume',
|
||||||
|
] as const) {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
key,
|
||||||
|
`ankiConnect.media.${key}`,
|
||||||
|
asBoolean,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media[key],
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media[key] = value;
|
||||||
|
},
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'imageType',
|
||||||
|
'ankiConnect.media.imageType',
|
||||||
|
(value) => (value === 'static' || value === 'avif' ? value : undefined),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.imageType,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.imageType = value;
|
||||||
|
},
|
||||||
|
"Expected 'static' or 'avif'.",
|
||||||
|
);
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'imageFormat',
|
||||||
|
'ankiConnect.media.imageFormat',
|
||||||
|
(value) => (value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.imageFormat,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.imageFormat = value;
|
||||||
|
},
|
||||||
|
"Expected 'jpg', 'png', or 'webp'.",
|
||||||
|
);
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'imageQuality',
|
||||||
|
'ankiConnect.media.imageQuality',
|
||||||
|
(value) => asIntegerInRange(value, 1, 100),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.imageQuality,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.imageQuality = value;
|
||||||
|
},
|
||||||
|
'Expected integer between 1 and 100.',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const key of [
|
||||||
|
'imageMaxWidth',
|
||||||
|
'imageMaxHeight',
|
||||||
|
'animatedMaxWidth',
|
||||||
|
'animatedMaxHeight',
|
||||||
|
] as const) {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
key,
|
||||||
|
`ankiConnect.media.${key}`,
|
||||||
|
asNonNegativeInteger,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media[key] ?? 0,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media[key] = value;
|
||||||
|
},
|
||||||
|
'Expected non-negative integer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'animatedFps',
|
||||||
|
'ankiConnect.media.animatedFps',
|
||||||
|
(value) => asIntegerInRange(value, 1, 60),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.animatedFps,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.animatedFps = value;
|
||||||
|
},
|
||||||
|
'Expected integer between 1 and 60.',
|
||||||
|
);
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'animatedCrf',
|
||||||
|
'ankiConnect.media.animatedCrf',
|
||||||
|
(value) => asIntegerInRange(value, 0, 63),
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.animatedCrf,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.animatedCrf = value;
|
||||||
|
},
|
||||||
|
'Expected integer between 0 and 63.',
|
||||||
|
);
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
'audioPadding',
|
||||||
|
'ankiConnect.media.audioPadding',
|
||||||
|
asNonNegativeNumber,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.audioPadding,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.audioPadding = value;
|
||||||
|
},
|
||||||
|
'Expected non-negative number.',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
media,
|
||||||
|
key,
|
||||||
|
`ankiConnect.media.${key}`,
|
||||||
|
asPositiveNumber,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media[key],
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media[key] = value;
|
||||||
|
},
|
||||||
|
'Expected positive number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { asString } from '../shared';
|
||||||
|
import { applyModernValue } from './modern-value';
|
||||||
|
|
||||||
|
export function applyModernMetadataResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
metadata: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
applyModernValue(
|
||||||
|
context,
|
||||||
|
metadata,
|
||||||
|
'pattern',
|
||||||
|
'ankiConnect.metadata.pattern',
|
||||||
|
asString,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.metadata.pattern,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.metadata.pattern = value;
|
||||||
|
},
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { asNumber } from '../shared';
|
||||||
|
import { hasOwn } from './shared';
|
||||||
|
|
||||||
|
export function asIntegerInRange(value: unknown, min: number, max: number): number | undefined {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
return parsed !== undefined && Number.isInteger(parsed) && parsed >= min && parsed <= max
|
||||||
|
? parsed
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asNonNegativeInteger(value: unknown): number | undefined {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
return parsed !== undefined && Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asPositiveNumber(value: unknown): number | undefined {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
return parsed !== undefined && parsed > 0 ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asNonNegativeNumber(value: unknown): number | undefined {
|
||||||
|
const parsed = asNumber(value);
|
||||||
|
return parsed !== undefined && parsed >= 0 ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyModernValue<T>(
|
||||||
|
context: ResolveContext,
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
path: string,
|
||||||
|
parse: (value: unknown) => T | undefined,
|
||||||
|
fallback: T,
|
||||||
|
apply: (value: T) => void,
|
||||||
|
message: string,
|
||||||
|
): void {
|
||||||
|
if (!hasOwn(source, key)) return;
|
||||||
|
const raw = source[key];
|
||||||
|
const parsed = parse(raw);
|
||||||
|
if (parsed === undefined) {
|
||||||
|
apply(fallback);
|
||||||
|
context.warn(path, raw, fallback, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply(parsed);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { isObject } from '../shared';
|
||||||
|
import { applyAiResolution } from './ai';
|
||||||
|
import { applyLapisResolution } from './lapis';
|
||||||
|
import { applyModernBehaviorResolution } from './modern-behavior';
|
||||||
|
import { applyModernFieldsResolution } from './modern-fields';
|
||||||
|
import { applyModernMediaResolution } from './modern-media';
|
||||||
|
import { applyModernMetadataResolution } from './modern-metadata';
|
||||||
|
import { applyProxyResolution } from './proxy';
|
||||||
|
import { applyTagsResolution } from './tags';
|
||||||
|
|
||||||
|
export function applyAnkiModernResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
behavior: Record<string, unknown>,
|
||||||
|
media: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {};
|
||||||
|
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
|
||||||
|
|
||||||
|
applyModernFieldsResolution(context, fields);
|
||||||
|
applyModernMediaResolution(context, media);
|
||||||
|
applyModernBehaviorResolution(context, behavior);
|
||||||
|
applyModernMetadataResolution(context, metadata);
|
||||||
|
applyLapisResolution(context, ankiConnect);
|
||||||
|
applyProxyResolution(context, ankiConnect);
|
||||||
|
applyAiResolution(context, ankiConnect);
|
||||||
|
applyTagsResolution(context, ankiConnect);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { asBoolean, asNumber, asString, isObject } from '../shared';
|
||||||
|
|
||||||
|
export function applyProxyResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (isObject(ankiConnect.proxy)) {
|
||||||
|
const proxy = ankiConnect.proxy;
|
||||||
|
const proxyEnabled = asBoolean(proxy.enabled);
|
||||||
|
if (proxyEnabled !== undefined) {
|
||||||
|
context.resolved.ankiConnect.proxy.enabled = proxyEnabled;
|
||||||
|
} else if (proxy.enabled !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.proxy.enabled',
|
||||||
|
proxy.enabled,
|
||||||
|
context.resolved.ankiConnect.proxy.enabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyHost = asString(proxy.host);
|
||||||
|
if (proxyHost !== undefined && proxyHost.trim().length > 0) {
|
||||||
|
context.resolved.ankiConnect.proxy.host = proxyHost.trim();
|
||||||
|
} else if (proxy.host !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.proxy.host',
|
||||||
|
proxy.host,
|
||||||
|
context.resolved.ankiConnect.proxy.host,
|
||||||
|
'Expected non-empty string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyUpstreamUrl = asString(proxy.upstreamUrl);
|
||||||
|
if (proxyUpstreamUrl !== undefined && proxyUpstreamUrl.trim().length > 0) {
|
||||||
|
context.resolved.ankiConnect.proxy.upstreamUrl = proxyUpstreamUrl.trim();
|
||||||
|
} else if (proxy.upstreamUrl !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.proxy.upstreamUrl',
|
||||||
|
proxy.upstreamUrl,
|
||||||
|
context.resolved.ankiConnect.proxy.upstreamUrl,
|
||||||
|
'Expected non-empty string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyPort = asNumber(proxy.port);
|
||||||
|
if (
|
||||||
|
proxyPort !== undefined &&
|
||||||
|
Number.isInteger(proxyPort) &&
|
||||||
|
proxyPort >= 1 &&
|
||||||
|
proxyPort <= 65535
|
||||||
|
) {
|
||||||
|
context.resolved.ankiConnect.proxy.port = proxyPort;
|
||||||
|
} else if (proxy.port !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.proxy.port',
|
||||||
|
proxy.port,
|
||||||
|
context.resolved.ankiConnect.proxy.port,
|
||||||
|
'Expected integer between 1 and 65535.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (ankiConnect.proxy !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.proxy',
|
||||||
|
ankiConnect.proxy,
|
||||||
|
context.resolved.ankiConnect.proxy,
|
||||||
|
'Expected object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { isNotificationType, type NotificationType } from '../../../types/notification';
|
||||||
|
|
||||||
|
export function asNotificationType(value: unknown): NotificationType | undefined {
|
||||||
|
return isNotificationType(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasOwn(obj: Record<string, unknown>, key: string): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
|
||||||
|
export function applyTagsResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (Array.isArray(ankiConnect.tags)) {
|
||||||
|
const normalizedTags = ankiConnect.tags
|
||||||
|
.filter((entry): entry is string => typeof entry === 'string')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
if (normalizedTags.length === ankiConnect.tags.length) {
|
||||||
|
context.resolved.ankiConnect.tags = [...new Set(normalizedTags)];
|
||||||
|
} else {
|
||||||
|
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.tags',
|
||||||
|
ankiConnect.tags,
|
||||||
|
context.resolved.ankiConnect.tags,
|
||||||
|
'Expected an array of non-empty strings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (ankiConnect.tags !== undefined) {
|
||||||
|
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.tags',
|
||||||
|
ankiConnect.tags,
|
||||||
|
context.resolved.ankiConnect.tags,
|
||||||
|
'Expected an array of strings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createStatsMiningContext } from '../stats-server/mining-support.js';
|
||||||
|
|
||||||
|
test('mining timing observer errors do not replace successful phase results', async () => {
|
||||||
|
const { timeMiningPhase } = createStatsMiningContext({
|
||||||
|
onMiningTiming: () => {
|
||||||
|
throw new Error('observer failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await timeMiningPhase('word', 'test', async () => 42);
|
||||||
|
|
||||||
|
assert.equal(result, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mining timing observer errors do not replace phase errors', async () => {
|
||||||
|
const phaseError = new Error('phase failed');
|
||||||
|
const { timeMiningPhase } = createStatsMiningContext({
|
||||||
|
onMiningTiming: () => {
|
||||||
|
throw new Error('observer failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
timeMiningPhase('word', 'test', async () => {
|
||||||
|
throw phaseError;
|
||||||
|
}),
|
||||||
|
(error: unknown) => error === phaseError,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from 'node:test';
|
||||||
|
import {
|
||||||
|
registerStatsAnalyticsRoutes,
|
||||||
|
registerStatsIntegrationRoutes,
|
||||||
|
registerStatsLibraryRoutes,
|
||||||
|
registerStatsMiningRoutes,
|
||||||
|
registerStatsStaticRoutes,
|
||||||
|
} from '../stats-server/routes.js';
|
||||||
|
|
||||||
|
describe('stats server route groups', () => {
|
||||||
|
test('exposes focused registrars for createStatsApp composition', () => {
|
||||||
|
for (const registrar of [
|
||||||
|
registerStatsAnalyticsRoutes,
|
||||||
|
registerStatsLibraryRoutes,
|
||||||
|
registerStatsIntegrationRoutes,
|
||||||
|
registerStatsMiningRoutes,
|
||||||
|
registerStatsStaticRoutes,
|
||||||
|
]) {
|
||||||
|
assert.equal(typeof registrar, 'function');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -900,9 +900,11 @@ describe('stats server API routes', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await app.request('/api/stats/vocabulary?excludePos=particle,auxiliary');
|
const res = await app.request(
|
||||||
|
'/api/stats/vocabulary?excludePos=particle,%20auxiliary,%20,%20noun%20',
|
||||||
|
);
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary']]);
|
assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary', 'noun']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/stats/vocabulary returns POS fields', async () => {
|
it('GET /api/stats/vocabulary returns POS fields', async () => {
|
||||||
@@ -1001,6 +1003,36 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(res.status, 404);
|
assert.equal(res.status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PATCH /api/stats/anime/:animeId/anilist accepts only positive integer AniList ids', async () => {
|
||||||
|
const assignments: Array<{ animeId: number; body: unknown }> = [];
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
reassignAnimeAnilist: async (animeId: number, body: unknown) => {
|
||||||
|
assignments.push({ animeId, body });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const anilistId of [-1, 0, 1.5, '12', true, undefined]) {
|
||||||
|
const res = await app.request('/api/stats/anime/1/anilist', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ anilistId }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 400, `accepted invalid AniList id: ${String(anilistId)}`);
|
||||||
|
}
|
||||||
|
assert.deepEqual(assignments, []);
|
||||||
|
|
||||||
|
const body = { anilistId: 21_802, titleRomaji: 'Little Witch Academia' };
|
||||||
|
const res = await app.request('/api/stats/anime/1/anilist', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(assignments, [{ animeId: 1, body }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /api/stats/anime/:animeId/cover returns cover art', async () => {
|
it('GET /api/stats/anime/:animeId/cover returns cover art', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/anime/1/cover');
|
const res = await app.request('/api/stats/anime/1/cover');
|
||||||
@@ -1348,6 +1380,86 @@ describe('stats server API routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
let yomitanCalls = 0;
|
||||||
|
let mediaCalls = 0;
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
addYomitanNote: async () => {
|
||||||
|
yomitanCalls += 1;
|
||||||
|
return 777;
|
||||||
|
},
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => {
|
||||||
|
mediaCalls += 1;
|
||||||
|
return Buffer.from('audio');
|
||||||
|
},
|
||||||
|
generateScreenshot: async () => {
|
||||||
|
mediaCalls += 1;
|
||||||
|
return Buffer.from('image');
|
||||||
|
},
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: ' ',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(yomitanCalls, 0);
|
||||||
|
assert.equal(mediaCalls, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card does not start word media without a Yomitan bridge', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
let mediaCalls = 0;
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => {
|
||||||
|
mediaCalls += 1;
|
||||||
|
return Buffer.from('audio');
|
||||||
|
},
|
||||||
|
generateScreenshot: async () => {
|
||||||
|
mediaCalls += 1;
|
||||||
|
return Buffer.from('image');
|
||||||
|
},
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: '猫',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 500);
|
||||||
|
assert.equal(mediaCalls, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => {
|
it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
@@ -2155,7 +2267,7 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
||||||
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
|
||||||
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
|
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2279,8 +2391,8 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const fields = updateRequest?.params?.note?.fields ?? {};
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
|
||||||
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+\.jpg">$/);
|
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.jpg">$/);
|
||||||
assert.equal(fields.ExpressionAudio, undefined);
|
assert.equal(fields.ExpressionAudio, undefined);
|
||||||
assert.equal(fields.SelectionText, undefined);
|
assert.equal(fields.SelectionText, undefined);
|
||||||
});
|
});
|
||||||
@@ -2335,8 +2447,8 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const fields = updateRequest?.params?.note?.fields ?? {};
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
|
||||||
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+\.avif">$/);
|
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.avif">$/);
|
||||||
assert.equal(fields.ExpressionAudio, undefined);
|
assert.equal(fields.ExpressionAudio, undefined);
|
||||||
assert.equal(fields.SelectionText, undefined);
|
assert.equal(fields.SelectionText, undefined);
|
||||||
});
|
});
|
||||||
@@ -2492,7 +2604,7 @@ Aligned English subtitle
|
|||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
assert.match(
|
assert.match(
|
||||||
updateRequest?.params?.note?.fields?.Voice ?? '',
|
updateRequest?.params?.note?.fields?.Voice ?? '',
|
||||||
/^\[sound:subminer_audio_\d+\.mp3\]$/,
|
/^\[sound:subminer_audio_\d+_12345\.mp3\]$/,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ notesInfoFields: null },
|
{ notesInfoFields: null },
|
||||||
@@ -2580,7 +2692,11 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
||||||
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_12345\.mp3\]$/);
|
||||||
|
assert.match(
|
||||||
|
updateRequest?.params?.note?.fields?.Picture ?? '',
|
||||||
|
/^<img src="subminer_image_\d+_12345\.jpg">$/,
|
||||||
|
);
|
||||||
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
|
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2670,6 +2786,26 @@ Aligned English subtitle
|
|||||||
assert.deepEqual(await res.json(), { ok: true });
|
assert.deepEqual(await res.json(), { ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/stats/sessions rejects non-safe or fractional session ids', async () => {
|
||||||
|
let deleteCalls = 0;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
deleteSessions: async () => {
|
||||||
|
deleteCalls += 1;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/sessions', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"sessionIds":[1.5,1e309,9007199254740992]}',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(deleteCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
|
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
||||||
@@ -2694,8 +2830,10 @@ Aligned English subtitle
|
|||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let acquireCalls = 0;
|
let acquireCalls = 0;
|
||||||
let recordCalls = 0;
|
let recordCalls = 0;
|
||||||
globalThis.fetch = (async () =>
|
let requestSignal: AbortSignal | null | undefined;
|
||||||
new Response(
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
requestSignal = init?.signal;
|
||||||
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
data: {
|
data: {
|
||||||
Page: {
|
Page: {
|
||||||
@@ -2707,7 +2845,8 @@ Aligned English subtitle
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
|
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
|
||||||
},
|
},
|
||||||
)) as typeof fetch;
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const app = createStatsApp(createMockTracker(), {
|
const app = createStatsApp(createMockTracker(), {
|
||||||
@@ -2725,6 +2864,7 @@ Aligned English subtitle
|
|||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(acquireCalls, 1);
|
assert.equal(acquireCalls, 1);
|
||||||
assert.equal(recordCalls, 1);
|
assert.equal(recordCalls, 1);
|
||||||
|
assert.ok(requestSignal instanceof AbortSignal);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
@@ -2923,6 +3063,15 @@ Aligned English subtitle
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns not found for malformed percent-encoded asset paths', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const app = createStatsApp(createMockTracker(), { staticDir: dir });
|
||||||
|
const res = await app.request('/assets/%');
|
||||||
|
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('fetches and serves missing cover art on demand', async () => {
|
it('fetches and serves missing cover art on demand', async () => {
|
||||||
let ensureCalls = 0;
|
let ensureCalls = 0;
|
||||||
let hasCover = false;
|
let hasCover = false;
|
||||||
|
|||||||
+20
-1436
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
|||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { statsJson } from '../../../types/stats-http-contract.js';
|
||||||
|
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
||||||
|
import {
|
||||||
|
enrichSessionsWithKnownWordMetrics,
|
||||||
|
loadKnownWordsSet,
|
||||||
|
parseEventTypesQuery,
|
||||||
|
parseIntQuery,
|
||||||
|
parseTrendFillEmpty,
|
||||||
|
parseTrendGroupBy,
|
||||||
|
parseTrendRange,
|
||||||
|
} from './route-support.js';
|
||||||
|
|
||||||
|
export function registerStatsAnalyticsRoutes(
|
||||||
|
app: Hono,
|
||||||
|
tracker: ImmersionTrackerService,
|
||||||
|
options?: { knownWordCachePath?: string },
|
||||||
|
): void {
|
||||||
|
app.get('/api/stats/overview', async (c) => {
|
||||||
|
const [rawSessions, rollups, hints] = await Promise.all([
|
||||||
|
tracker.getSessionSummaries(5),
|
||||||
|
tracker.getDailyRollups(14),
|
||||||
|
tracker.getQueryHints(),
|
||||||
|
]);
|
||||||
|
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||||
|
tracker,
|
||||||
|
rawSessions,
|
||||||
|
options?.knownWordCachePath,
|
||||||
|
);
|
||||||
|
return c.json(statsJson('overview', { sessions, rollups, hints }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/daily-rollups', async (c) => {
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 60, 500);
|
||||||
|
const rollups = await tracker.getDailyRollups(limit);
|
||||||
|
return c.json(statsJson('dailyRollups', rollups));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/monthly-rollups', async (c) => {
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 24, 120);
|
||||||
|
const rollups = await tracker.getMonthlyRollups(limit);
|
||||||
|
return c.json(statsJson('monthlyRollups', rollups));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/streak-calendar', async (c) => {
|
||||||
|
const days = parseIntQuery(c.req.query('days'), 90, 365);
|
||||||
|
return c.json(statsJson('streakCalendar', await tracker.getStreakCalendar(days)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/trends/episodes-per-day', async (c) => {
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||||
|
return c.json(statsJson('episodesPerDay', await tracker.getEpisodesPerDay(limit)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/trends/new-anime-per-day', async (c) => {
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||||
|
return c.json(statsJson('newAnimePerDay', await tracker.getNewAnimePerDay(limit)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/trends/watch-time-per-anime', async (c) => {
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||||
|
return c.json(statsJson('watchTimePerAnime', await tracker.getWatchTimePerAnime(limit)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/trends/dashboard', async (c) => {
|
||||||
|
const range = parseTrendRange(c.req.query('range'));
|
||||||
|
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
|
||||||
|
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
|
||||||
|
return c.json(
|
||||||
|
statsJson('trendsDashboard', await tracker.getTrendsDashboard(range, groupBy, fillEmpty)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/sessions', async (c) => {
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||||
|
const rawSessions = await tracker.getSessionSummaries(limit);
|
||||||
|
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||||
|
tracker,
|
||||||
|
rawSessions,
|
||||||
|
options?.knownWordCachePath,
|
||||||
|
);
|
||||||
|
return c.json(statsJson('sessions', sessions));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/sessions/:id/timeline', async (c) => {
|
||||||
|
const id = parseIntQuery(c.req.param('id'), 0);
|
||||||
|
if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400);
|
||||||
|
const rawLimit = c.req.query('limit');
|
||||||
|
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
|
||||||
|
const timeline = await tracker.getSessionTimeline(id, limit);
|
||||||
|
return c.json(statsJson('sessionTimeline', timeline));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/sessions/:id/events', async (c) => {
|
||||||
|
const id = parseIntQuery(c.req.param('id'), 0);
|
||||||
|
if (id <= 0) return c.json(statsJson('sessionEvents', []), 400);
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
|
||||||
|
const eventTypes = parseEventTypesQuery(c.req.query('types'));
|
||||||
|
const events = await tracker.getSessionEvents(id, limit, eventTypes);
|
||||||
|
return c.json(statsJson('sessionEvents', events));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
|
||||||
|
const id = parseIntQuery(c.req.param('id'), 0);
|
||||||
|
if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400);
|
||||||
|
|
||||||
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set<string>();
|
||||||
|
|
||||||
|
// Get per-line word occurrences for the session.
|
||||||
|
const wordsByLine = await tracker.getSessionWordsByLine(id);
|
||||||
|
|
||||||
|
// Build cumulative filtered occurrence counts per recorded line index.
|
||||||
|
// The stats UI uses line-count progress to align this series with the session
|
||||||
|
// timeline, so preserve the stored line position rather than compressing gaps.
|
||||||
|
const totalLineGroups = new Map<number, number>();
|
||||||
|
const knownLineGroups = new Map<number, number>();
|
||||||
|
for (const row of wordsByLine) {
|
||||||
|
totalLineGroups.set(
|
||||||
|
row.lineIndex,
|
||||||
|
(totalLineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount,
|
||||||
|
);
|
||||||
|
if (knownWordsSet.has(row.headword)) {
|
||||||
|
knownLineGroups.set(
|
||||||
|
row.lineIndex,
|
||||||
|
(knownLineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLineIndex = Math.max(...totalLineGroups.keys(), ...knownLineGroups.keys(), -1);
|
||||||
|
let knownWordsSeen = 0;
|
||||||
|
let totalWordsSeen = 0;
|
||||||
|
const knownByLinesSeen: Array<{
|
||||||
|
linesSeen: number;
|
||||||
|
knownWordsSeen: number;
|
||||||
|
totalWordsSeen: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let lineIdx = 0; lineIdx <= maxLineIndex; lineIdx += 1) {
|
||||||
|
knownWordsSeen += knownLineGroups.get(lineIdx) ?? 0;
|
||||||
|
totalWordsSeen += totalLineGroups.get(lineIdx) ?? 0;
|
||||||
|
knownByLinesSeen.push({
|
||||||
|
linesSeen: lineIdx,
|
||||||
|
knownWordsSeen,
|
||||||
|
totalWordsSeen,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(statsJson('sessionKnownWordsTimeline', knownByLinesSeen));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import type { Hono } from 'hono';
|
||||||
|
import type { AnkiConnectConfig } from '../../../types.js';
|
||||||
|
import {
|
||||||
|
statsJson,
|
||||||
|
type StatsAnilistSearchResult,
|
||||||
|
type StatsAnkiBrowseResponse,
|
||||||
|
} from '../../../types/stats-http-contract.js';
|
||||||
|
import type { AnilistRateLimiter } from '../anilist/rate-limiter.js';
|
||||||
|
import { registerStatsCoverRoutes } from '../stats-cover-routes.js';
|
||||||
|
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
||||||
|
import {
|
||||||
|
buildAnkiNotePreview,
|
||||||
|
countKnownWords,
|
||||||
|
enrichSessionsWithKnownWordMetrics,
|
||||||
|
loadKnownWordsSet,
|
||||||
|
parseIntQuery,
|
||||||
|
} from './route-support.js';
|
||||||
|
|
||||||
|
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
|
||||||
|
const ANILIST_FETCH_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
|
export function registerStatsIntegrationRoutes(
|
||||||
|
app: Hono,
|
||||||
|
tracker: ImmersionTrackerService,
|
||||||
|
options?: {
|
||||||
|
knownWordCachePath?: string;
|
||||||
|
ankiConnectConfig?: AnkiConnectConfig;
|
||||||
|
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
|
||||||
|
anilistRateLimiter?: AnilistRateLimiter;
|
||||||
|
resolveAnkiNoteId?: (noteId: number) => number;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
|
||||||
|
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
|
||||||
|
app.get('/api/stats/anilist/search', async (c) => {
|
||||||
|
const query = (c.req.query('q') ?? '').trim();
|
||||||
|
if (!query) return c.json(statsJson('anilistSearch', []));
|
||||||
|
try {
|
||||||
|
await options?.anilistRateLimiter?.acquire();
|
||||||
|
const res = await fetch('https://graphql.anilist.co', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(ANILIST_FETCH_TIMEOUT_MS),
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `query ($search: String!) {
|
||||||
|
Page(perPage: 10) {
|
||||||
|
media(search: $search, type: ANIME) {
|
||||||
|
id
|
||||||
|
episodes
|
||||||
|
season
|
||||||
|
seasonYear
|
||||||
|
description(asHtml: false)
|
||||||
|
coverImage { large medium }
|
||||||
|
title { romaji english native }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables: { search: query },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
options?.anilistRateLimiter?.recordResponse(res.headers);
|
||||||
|
if (res.status === 429) {
|
||||||
|
return c.json(statsJson('anilistSearch', []));
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
data?: { Page?: { media?: StatsAnilistSearchResult[] } };
|
||||||
|
};
|
||||||
|
return c.json(statsJson('anilistSearch', json.data?.Page?.media ?? []));
|
||||||
|
} catch {
|
||||||
|
return c.json(statsJson('anilistSearch', []));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/known-words', (c) => {
|
||||||
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||||
|
if (!knownWordsSet) return c.json(statsJson('knownWords', []));
|
||||||
|
return c.json(statsJson('knownWords', [...knownWordsSet]));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/known-words-summary', async (c) => {
|
||||||
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||||
|
if (!knownWordsSet) {
|
||||||
|
return c.json(statsJson('knownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }));
|
||||||
|
}
|
||||||
|
const headwords = await tracker.getAllDistinctHeadwords();
|
||||||
|
return c.json(statsJson('knownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
|
||||||
|
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||||
|
if (animeId <= 0) {
|
||||||
|
return c.json(
|
||||||
|
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||||
|
if (!knownWordsSet) {
|
||||||
|
return c.json(
|
||||||
|
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const headwords = await tracker.getAnimeDistinctHeadwords(animeId);
|
||||||
|
return c.json(statsJson('animeKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
|
||||||
|
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||||
|
if (videoId <= 0) {
|
||||||
|
return c.json(
|
||||||
|
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||||
|
if (!knownWordsSet) {
|
||||||
|
return c.json(
|
||||||
|
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const headwords = await tracker.getMediaDistinctHeadwords(videoId);
|
||||||
|
return c.json(statsJson('mediaKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
|
||||||
|
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||||
|
if (animeId <= 0) return c.body(null, 400);
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
if (
|
||||||
|
typeof body?.anilistId !== 'number' ||
|
||||||
|
!Number.isInteger(body.anilistId) ||
|
||||||
|
body.anilistId <= 0
|
||||||
|
) {
|
||||||
|
return c.body(null, 400);
|
||||||
|
}
|
||||||
|
await tracker.reassignAnimeAnilist(animeId, body);
|
||||||
|
return c.json(statsJson('reassignAnimeAnilist', { ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
registerStatsCoverRoutes(app, tracker);
|
||||||
|
|
||||||
|
app.get('/api/stats/episode/:videoId/detail', async (c) => {
|
||||||
|
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||||
|
if (videoId <= 0) return c.body(null, 400);
|
||||||
|
const rawSessions = await tracker.getEpisodeSessions(videoId);
|
||||||
|
const words = await tracker.getEpisodeWords(videoId);
|
||||||
|
const cardEvents = await tracker.getEpisodeCardEvents(videoId);
|
||||||
|
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||||
|
tracker,
|
||||||
|
rawSessions,
|
||||||
|
options?.knownWordCachePath,
|
||||||
|
);
|
||||||
|
return c.json(statsJson('episodeDetail', { sessions, words, cardEvents }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/stats/anki/browse', async (c) => {
|
||||||
|
const noteId = parseIntQuery(c.req.query('noteId'), 0);
|
||||||
|
if (noteId <= 0) return c.body(null, 400);
|
||||||
|
const ankiConfig = getAnkiConnectConfig();
|
||||||
|
try {
|
||||||
|
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'guiBrowse',
|
||||||
|
version: 6,
|
||||||
|
params: { query: `nid:${noteId}` },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = (await response.json()) as StatsAnkiBrowseResponse;
|
||||||
|
return c.json(statsJson('ankiBrowse', result));
|
||||||
|
} catch {
|
||||||
|
return c.json(statsJson('error', { error: 'Failed to reach AnkiConnect' }), 502);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/stats/anki/notesInfo', async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const noteIds: number[] = Array.isArray(body?.noteIds)
|
||||||
|
? body.noteIds.filter(
|
||||||
|
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', []));
|
||||||
|
const resolvedNoteIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
noteIds.map((noteId) => {
|
||||||
|
const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId);
|
||||||
|
return Number.isInteger(resolvedNoteId) && (resolvedNoteId as number) > 0
|
||||||
|
? (resolvedNoteId as number)
|
||||||
|
: noteId;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const ankiConfig = getAnkiConnectConfig();
|
||||||
|
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'notesInfo',
|
||||||
|
version: 6,
|
||||||
|
params: { notes: resolvedNoteIds },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = (await response.json()) as {
|
||||||
|
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
||||||
|
};
|
||||||
|
return c.json(
|
||||||
|
statsJson(
|
||||||
|
'ankiNotesInfo',
|
||||||
|
(result.result ?? []).map((note) => ({
|
||||||
|
...note,
|
||||||
|
preview: buildAnkiNotePreview(note.fields, ankiConfig),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return c.json(statsJson('ankiNotesInfo', []), 502);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { statsJson } from '../../../types/stats-http-contract.js';
|
||||||
|
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
||||||
|
import {
|
||||||
|
buildSentenceSearchOptions,
|
||||||
|
enrichSessionsWithKnownWordMetrics,
|
||||||
|
parseBooleanQuery,
|
||||||
|
parseExcludedWordsBody,
|
||||||
|
parseIntQuery,
|
||||||
|
} from './route-support.js';
|
||||||
|
|
||||||
|
export function registerStatsLibraryRoutes(
|
||||||
|
app: Hono,
|
||||||
|
tracker: ImmersionTrackerService,
|
||||||
|
options?: {
|
||||||
|
knownWordCachePath?: string;
|
||||||
|
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
app.get('/api/stats/vocabulary', async (c) => {
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||||
|
const excludePos = c.req
|
||||||
|
.query('excludePos')
|
||||||
|
?.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const vocab = await tracker.getVocabularyStats(limit, excludePos);
|
||||||
|
return c.json(statsJson('vocabulary', vocab));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/excluded-words', async (c) => {
|
||||||
|
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/stats/excluded-words', async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const words = parseExcludedWordsBody(body);
|
||||||
|
if (!words) return c.body(null, 400);
|
||||||
|
await tracker.replaceStatsExcludedWords(words);
|
||||||
|
return c.json(statsJson('setExcludedWords', { ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/vocabulary/occurrences', async (c) => {
|
||||||
|
const headword = (c.req.query('headword') ?? '').trim();
|
||||||
|
const word = (c.req.query('word') ?? '').trim();
|
||||||
|
const reading = (c.req.query('reading') ?? '').trim();
|
||||||
|
if (!headword || !word) {
|
||||||
|
return c.json(statsJson('wordOccurrences', []), 400);
|
||||||
|
}
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||||
|
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
|
||||||
|
const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset);
|
||||||
|
return c.json(statsJson('wordOccurrences', occurrences));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/sentences/search', async (c) => {
|
||||||
|
const query = (c.req.query('q') ?? '').trim();
|
||||||
|
if (!query) return c.json(statsJson('sentenceSearch', []));
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
|
||||||
|
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
|
||||||
|
const searchOptions = await buildSentenceSearchOptions(
|
||||||
|
query,
|
||||||
|
searchByHeadword,
|
||||||
|
options?.resolveSentenceSearchHeadwords,
|
||||||
|
);
|
||||||
|
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
|
||||||
|
return c.json(statsJson('sentenceSearch', rows));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/kanji', async (c) => {
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||||
|
const kanji = await tracker.getKanjiStats(limit);
|
||||||
|
return c.json(statsJson('kanji', kanji));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/kanji/occurrences', async (c) => {
|
||||||
|
const kanji = (c.req.query('kanji') ?? '').trim();
|
||||||
|
if (!kanji) {
|
||||||
|
return c.json(statsJson('kanjiOccurrences', []), 400);
|
||||||
|
}
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||||
|
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
|
||||||
|
const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset);
|
||||||
|
return c.json(statsJson('kanjiOccurrences', occurrences));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
|
||||||
|
const wordId = parseIntQuery(c.req.param('wordId'), 0);
|
||||||
|
if (wordId <= 0) return c.body(null, 400);
|
||||||
|
const detail = await tracker.getWordDetail(wordId);
|
||||||
|
if (!detail) return c.body(null, 404);
|
||||||
|
const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
|
||||||
|
const similarWords = await tracker.getSimilarWords(wordId);
|
||||||
|
return c.json(statsJson('wordDetail', { detail, animeAppearances, similarWords }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
|
||||||
|
const kanjiId = parseIntQuery(c.req.param('kanjiId'), 0);
|
||||||
|
if (kanjiId <= 0) return c.body(null, 400);
|
||||||
|
const detail = await tracker.getKanjiDetail(kanjiId);
|
||||||
|
if (!detail) return c.body(null, 404);
|
||||||
|
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
|
||||||
|
const words = await tracker.getKanjiWords(kanjiId);
|
||||||
|
return c.json(statsJson('kanjiDetail', { detail, animeAppearances, words }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/media', async (c) => {
|
||||||
|
const library = await tracker.getMediaLibrary();
|
||||||
|
return c.json(statsJson('mediaLibrary', library));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/media/:videoId', async (c) => {
|
||||||
|
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||||
|
if (videoId <= 0) return c.json(statsJson('error', null), 400);
|
||||||
|
const [detail, rawSessions, rollups] = await Promise.all([
|
||||||
|
tracker.getMediaDetail(videoId),
|
||||||
|
tracker.getMediaSessions(videoId, 100),
|
||||||
|
tracker.getMediaDailyRollups(videoId, 90),
|
||||||
|
]);
|
||||||
|
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||||
|
tracker,
|
||||||
|
rawSessions,
|
||||||
|
options?.knownWordCachePath,
|
||||||
|
);
|
||||||
|
return c.json(statsJson('mediaDetail', { detail, sessions, rollups }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/anime', async (c) => {
|
||||||
|
const rows = await tracker.getAnimeLibrary();
|
||||||
|
return c.json(statsJson('animeLibrary', rows));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/anime/:animeId', async (c) => {
|
||||||
|
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||||
|
if (animeId <= 0) return c.body(null, 400);
|
||||||
|
const detail = await tracker.getAnimeDetail(animeId);
|
||||||
|
if (!detail) return c.body(null, 404);
|
||||||
|
const [episodes, anilistEntries] = await Promise.all([
|
||||||
|
tracker.getAnimeEpisodes(animeId),
|
||||||
|
tracker.getAnimeAnilistEntries(animeId),
|
||||||
|
]);
|
||||||
|
return c.json(statsJson('animeDetail', { detail, episodes, anilistEntries }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/anime/:animeId/words', async (c) => {
|
||||||
|
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 50, 200);
|
||||||
|
if (animeId <= 0) return c.body(null, 400);
|
||||||
|
return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/anime/:animeId/rollups', async (c) => {
|
||||||
|
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||||
|
if (animeId <= 0) return c.body(null, 400);
|
||||||
|
return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/stats/media/:videoId/watched', async (c) => {
|
||||||
|
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||||
|
if (videoId <= 0) return c.body(null, 400);
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const watched = typeof body?.watched === 'boolean' ? body.watched : true;
|
||||||
|
await tracker.setVideoWatched(videoId, watched);
|
||||||
|
return c.json(statsJson('setVideoWatched', { ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/stats/sessions', async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const ids = Array.isArray(body?.sessionIds)
|
||||||
|
? body.sessionIds.filter(
|
||||||
|
(id: unknown): id is number => Number.isSafeInteger(id) && (id as number) > 0,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (ids.length === 0) return c.body(null, 400);
|
||||||
|
await tracker.deleteSessions(ids);
|
||||||
|
return c.json(statsJson('deleteSessions', { ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/stats/sessions/:sessionId', async (c) => {
|
||||||
|
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
|
||||||
|
if (sessionId <= 0) return c.body(null, 400);
|
||||||
|
await tracker.deleteSession(sessionId);
|
||||||
|
return c.json(statsJson('deleteSession', { ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/stats/media/:videoId', async (c) => {
|
||||||
|
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||||
|
if (videoId <= 0) return c.body(null, 400);
|
||||||
|
await tracker.deleteVideo(videoId);
|
||||||
|
return c.json(statsJson('deleteVideo', { ok: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
import { AnkiConnectClient } from '../../../anki-connect.js';
|
||||||
|
import { getConfiguredWordFieldName } from '../../../anki-field-config.js';
|
||||||
|
import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/animated-image-sync.js';
|
||||||
|
import { MediaGenerator } from '../../../media-generator.js';
|
||||||
|
import { statsJson } from '../../../types/stats-http-contract.js';
|
||||||
|
import {
|
||||||
|
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||||
|
resolveSecondarySubtitleTextFromSidecar,
|
||||||
|
} from '../secondary-subtitle-sidecar.js';
|
||||||
|
import {
|
||||||
|
applyStatsWordAndSentenceCardFields,
|
||||||
|
createStatsMiningContext,
|
||||||
|
getStatsDirectMiningAudioFieldNames,
|
||||||
|
getStatsWordMiningAudioFieldName,
|
||||||
|
resolveStatsNoteFieldName,
|
||||||
|
shouldUseStatsLapisKikuCardFields,
|
||||||
|
statsMiningLogger,
|
||||||
|
type StatsMiningRouteOptions,
|
||||||
|
type StatsServerNoteInfo,
|
||||||
|
} from './mining-support.js';
|
||||||
|
|
||||||
|
export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteOptions): void {
|
||||||
|
const {
|
||||||
|
getAnkiConnectConfig,
|
||||||
|
getEffectiveMiningDeckName,
|
||||||
|
getSecondarySubtitleLanguages,
|
||||||
|
getStatsMiningAlassPath,
|
||||||
|
timeMiningPhase,
|
||||||
|
} = createStatsMiningContext(options);
|
||||||
|
app.post('/api/stats/mine-card', async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const sourcePath = typeof body?.sourcePath === 'string' ? body.sourcePath.trim() : '';
|
||||||
|
const startMs = typeof body?.startMs === 'number' ? body.startMs : NaN;
|
||||||
|
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
|
||||||
|
const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
|
||||||
|
const word = typeof body?.word === 'string' ? body.word.trim() : '';
|
||||||
|
const bodySecondaryText =
|
||||||
|
typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
|
||||||
|
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
|
||||||
|
const rawMode = c.req.query('mode');
|
||||||
|
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
|
||||||
|
|
||||||
|
if (
|
||||||
|
!sourcePath ||
|
||||||
|
!sentence ||
|
||||||
|
(mode === 'word' && !word) ||
|
||||||
|
!Number.isFinite(startMs) ||
|
||||||
|
!Number.isFinite(endMs)
|
||||||
|
) {
|
||||||
|
return c.json(
|
||||||
|
statsJson('mineCard', {
|
||||||
|
error: 'sourcePath, sentence, startMs, and endMs are required',
|
||||||
|
}),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (endMs <= startMs) {
|
||||||
|
return c.json(statsJson('mineCard', { error: 'endMs must be greater than startMs' }), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(sourcePath)) {
|
||||||
|
return c.json(statsJson('mineCard', { error: 'File not found' }), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ankiConfig = getAnkiConnectConfig();
|
||||||
|
if (!ankiConfig) {
|
||||||
|
return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500);
|
||||||
|
}
|
||||||
|
const addYomitanNote = options?.addYomitanNote;
|
||||||
|
if (mode === 'word' && !addYomitanNote) {
|
||||||
|
return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 500);
|
||||||
|
}
|
||||||
|
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
|
||||||
|
let retimedSecondaryText = '';
|
||||||
|
if (mode === 'sentence' && !bodySecondaryText) {
|
||||||
|
try {
|
||||||
|
retimedSecondaryText = await (
|
||||||
|
options?.resolveRetimedSecondarySubtitleText ??
|
||||||
|
resolveRetimedSecondarySubtitleTextFromSidecar
|
||||||
|
)({
|
||||||
|
sourcePath,
|
||||||
|
startMs,
|
||||||
|
endMs,
|
||||||
|
languages: secondarySubtitleLanguages,
|
||||||
|
alassPath: getStatsMiningAlassPath(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
statsMiningLogger.warn(
|
||||||
|
'Failed to resolve retimed secondary subtitle for stats mining:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const secondaryText =
|
||||||
|
bodySecondaryText ||
|
||||||
|
retimedSecondaryText ||
|
||||||
|
resolveSecondarySubtitleTextFromSidecar({
|
||||||
|
sourcePath,
|
||||||
|
startMs,
|
||||||
|
endMs,
|
||||||
|
languages: secondarySubtitleLanguages,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
|
||||||
|
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
||||||
|
|
||||||
|
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||||
|
const normalizeAudio = ankiConfig.media?.normalizeAudio !== false;
|
||||||
|
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||||
|
|
||||||
|
const startSec = startMs / 1000;
|
||||||
|
const endSec = endMs / 1000;
|
||||||
|
const rawDuration = endSec - startSec;
|
||||||
|
const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec;
|
||||||
|
|
||||||
|
const highlightedSentence = word
|
||||||
|
? sentence.replace(
|
||||||
|
new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
|
||||||
|
`<b>${word}</b>`,
|
||||||
|
)
|
||||||
|
: sentence;
|
||||||
|
|
||||||
|
const generateAudio = ankiConfig.media?.generateAudio !== false;
|
||||||
|
const generateImage = ankiConfig.media?.generateImage !== false && mode !== 'audio';
|
||||||
|
const imageType = ankiConfig.media?.imageType ?? 'static';
|
||||||
|
const syncAnimatedImageToWordAudio =
|
||||||
|
imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false;
|
||||||
|
|
||||||
|
const audioPromise = generateAudio
|
||||||
|
? timeMiningPhase(mode, 'generateAudio', () =>
|
||||||
|
mediaGen.generateAudio(
|
||||||
|
sourcePath,
|
||||||
|
startSec,
|
||||||
|
clampedEndSec,
|
||||||
|
audioPadding,
|
||||||
|
null,
|
||||||
|
normalizeAudio,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Promise.resolve(null);
|
||||||
|
|
||||||
|
const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => {
|
||||||
|
if (!generateImage) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageType === 'avif') {
|
||||||
|
return timeMiningPhase(mode, 'generateAnimatedImage', () =>
|
||||||
|
mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
|
||||||
|
fps: ankiConfig.media?.animatedFps ?? 10,
|
||||||
|
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
|
||||||
|
maxHeight: ankiConfig.media?.animatedMaxHeight,
|
||||||
|
crf: ankiConfig.media?.animatedCrf ?? 35,
|
||||||
|
leadingStillDuration: animatedLeadInSeconds,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const midpointSec = (startSec + clampedEndSec) / 2;
|
||||||
|
return timeMiningPhase(mode, 'generateScreenshot', () =>
|
||||||
|
mediaGen.generateScreenshot(sourcePath, midpointSec, {
|
||||||
|
format: ankiConfig.media?.imageFormat ?? 'jpg',
|
||||||
|
quality: ankiConfig.media?.imageQuality ?? 92,
|
||||||
|
maxWidth: ankiConfig.media?.imageMaxWidth,
|
||||||
|
maxHeight: ankiConfig.media?.imageMaxHeight,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const imagePromise =
|
||||||
|
mode === 'word' && syncAnimatedImageToWordAudio
|
||||||
|
? Promise.resolve<Buffer | null>(null)
|
||||||
|
: createImagePromise();
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
let noteId: number;
|
||||||
|
let effectiveDeckNamePromise: Promise<string> | null = null;
|
||||||
|
const getEffectiveDeckNameForRequest = (): Promise<string> => {
|
||||||
|
effectiveDeckNamePromise ??= getEffectiveMiningDeckName(ankiConfig);
|
||||||
|
return effectiveDeckNamePromise;
|
||||||
|
};
|
||||||
|
const moveNoteToConfiguredDeck = async (id: number): Promise<void> => {
|
||||||
|
const deckName = await getEffectiveDeckNameForRequest();
|
||||||
|
if (!deckName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cardIds = await timeMiningPhase(mode, 'findCards', () =>
|
||||||
|
client.findCards(`nid:${id}`),
|
||||||
|
);
|
||||||
|
await timeMiningPhase(mode, 'changeDeck', () => client.changeDeck(cardIds, deckName));
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`deck: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'word') {
|
||||||
|
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
||||||
|
timeMiningPhase(
|
||||||
|
'word',
|
||||||
|
'addYomitanNote',
|
||||||
|
() => addYomitanNote!(word),
|
||||||
|
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
|
||||||
|
),
|
||||||
|
audioPromise,
|
||||||
|
imagePromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
|
||||||
|
return c.json(
|
||||||
|
statsJson('mineCard', {
|
||||||
|
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
|
||||||
|
}),
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
noteId = yomitanResult.value;
|
||||||
|
await moveNoteToConfiguredDeck(noteId);
|
||||||
|
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||||
|
if (audioResult.status === 'rejected')
|
||||||
|
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||||
|
if (imageResult.status === 'rejected')
|
||||||
|
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||||
|
|
||||||
|
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||||
|
let noteInfo: StatsServerNoteInfo | null = null;
|
||||||
|
if (
|
||||||
|
audioBuffer ||
|
||||||
|
(syncAnimatedImageToWordAudio && generateImage) ||
|
||||||
|
shouldUseStatsLapisKikuCardFields(ankiConfig)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||||
|
noteInfo = noteInfoResult[0] ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||||
|
errors.push(`image: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||||
|
try {
|
||||||
|
const animatedLeadInSeconds = noteInfo
|
||||||
|
? await resolveAnimatedImageLeadInSeconds({
|
||||||
|
config: ankiConfig,
|
||||||
|
noteInfo,
|
||||||
|
resolveConfiguredFieldName: (candidateNoteInfo, ...preferredNames) =>
|
||||||
|
resolveStatsNoteFieldName(candidateNoteInfo, ...preferredNames),
|
||||||
|
retrieveMediaFileBase64: (filename) => client.retrieveMediaFile(filename),
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
|
imageBuffer = await createImagePromise(animatedLeadInSeconds);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`image: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (generateAudio && !audioBuffer && audioResult.status === 'fulfilled') {
|
||||||
|
errors.push('audio: no audio generated');
|
||||||
|
}
|
||||||
|
if (generateImage && !imageBuffer) {
|
||||||
|
errors.push('image: no image generated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaFields: Record<string, string> = {};
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
||||||
|
const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo);
|
||||||
|
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||||
|
|
||||||
|
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||||
|
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
||||||
|
|
||||||
|
if (audioBuffer) {
|
||||||
|
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
||||||
|
try {
|
||||||
|
await timeMiningPhase('word', 'uploadAudio', () =>
|
||||||
|
client.storeMediaFile(audioFilename, audioBuffer),
|
||||||
|
);
|
||||||
|
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`audio upload: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageBuffer) {
|
||||||
|
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||||
|
const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`;
|
||||||
|
try {
|
||||||
|
await timeMiningPhase('word', 'uploadImage', () =>
|
||||||
|
client.storeMediaFile(imageFilename, imageBuffer),
|
||||||
|
);
|
||||||
|
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`image upload: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
|
||||||
|
if (miscInfoFieldName) {
|
||||||
|
const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)';
|
||||||
|
const filenameWithExt = videoTitle || basename(sourcePath);
|
||||||
|
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
||||||
|
const totalMs = Math.floor(startMs);
|
||||||
|
const totalSec2 = Math.floor(totalMs / 1000);
|
||||||
|
const hours = String(Math.floor(totalSec2 / 3600)).padStart(2, '0');
|
||||||
|
const minutes = String(Math.floor((totalSec2 % 3600) / 60)).padStart(2, '0');
|
||||||
|
const secs = String(totalSec2 % 60).padStart(2, '0');
|
||||||
|
const ms = String(totalMs % 1000).padStart(3, '0');
|
||||||
|
mediaFields[miscInfoFieldName] = pattern
|
||||||
|
.replace(/%f/g, filenameWithoutExt)
|
||||||
|
.replace(/%F/g, filenameWithExt)
|
||||||
|
.replace(/%t/g, `${hours}:${minutes}:${secs}`)
|
||||||
|
.replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`)
|
||||||
|
.replace(/<br>/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(mediaFields).length > 0) {
|
||||||
|
try {
|
||||||
|
await timeMiningPhase('word', 'updateNoteFields', () =>
|
||||||
|
client.updateNoteFields(noteId, mediaFields),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`update fields: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
|
||||||
|
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
||||||
|
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
|
||||||
|
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||||
|
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
|
||||||
|
|
||||||
|
const fields: Record<string, string> = {
|
||||||
|
[sentenceFieldName]: mode === 'sentence' ? sentence : highlightedSentence,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'sentence' && secondaryText) {
|
||||||
|
fields[translationFieldName] = secondaryText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
|
||||||
|
if (mode === 'sentence') {
|
||||||
|
fields[wordFieldName] = sentence;
|
||||||
|
} else if (word) {
|
||||||
|
fields[wordFieldName] = word;
|
||||||
|
}
|
||||||
|
if (mode === 'sentence') {
|
||||||
|
fields['IsSentenceCard'] = 'x';
|
||||||
|
} else if (mode === 'audio') {
|
||||||
|
fields['IsAudioCard'] = 'x';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
|
||||||
|
const tags = ankiConfig.tags ?? ['SubMiner'];
|
||||||
|
|
||||||
|
const addNotePromise = timeMiningPhase(
|
||||||
|
mode,
|
||||||
|
'addNote',
|
||||||
|
async () =>
|
||||||
|
client.addNote((await getEffectiveDeckNameForRequest()) || 'Default', model, fields, tags),
|
||||||
|
(id) => ({
|
||||||
|
noteId: id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([
|
||||||
|
audioPromise,
|
||||||
|
imagePromise,
|
||||||
|
addNotePromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||||
|
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||||
|
if (audioResult.status === 'rejected')
|
||||||
|
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||||
|
if (imageResult.status === 'rejected')
|
||||||
|
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||||
|
|
||||||
|
if (addNoteResult.status === 'rejected') {
|
||||||
|
return c.json(
|
||||||
|
statsJson('mineCard', {
|
||||||
|
error: `Failed to add note: ${(addNoteResult.reason as Error).message}`,
|
||||||
|
}),
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
noteId = addNoteResult.value;
|
||||||
|
await moveNoteToConfiguredDeck(noteId);
|
||||||
|
|
||||||
|
const mediaFields: Record<string, string> = {};
|
||||||
|
const timestamp = Date.now();
|
||||||
|
let noteInfo: StatsServerNoteInfo | null = null;
|
||||||
|
if (audioBuffer) {
|
||||||
|
try {
|
||||||
|
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||||
|
noteInfo = noteInfoResult[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
noteInfo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioBuffer) {
|
||||||
|
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
||||||
|
try {
|
||||||
|
await timeMiningPhase(mode, 'uploadAudio', () =>
|
||||||
|
client.storeMediaFile(audioFilename, audioBuffer),
|
||||||
|
);
|
||||||
|
const audioValue = `[sound:${audioFilename}]`;
|
||||||
|
for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo, mode)) {
|
||||||
|
mediaFields[fieldName] = audioValue;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`audio upload: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageBuffer) {
|
||||||
|
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||||
|
const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`;
|
||||||
|
try {
|
||||||
|
await timeMiningPhase(mode, 'uploadImage', () =>
|
||||||
|
client.storeMediaFile(imageFilename, imageBuffer),
|
||||||
|
);
|
||||||
|
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`image upload: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (miscInfoFieldName) {
|
||||||
|
const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)';
|
||||||
|
const filenameWithExt = videoTitle || basename(sourcePath);
|
||||||
|
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
||||||
|
const totalMs = Math.floor(startMs);
|
||||||
|
const totalSec = Math.floor(totalMs / 1000);
|
||||||
|
const hours = String(Math.floor(totalSec / 3600)).padStart(2, '0');
|
||||||
|
const minutes = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0');
|
||||||
|
const secs = String(totalSec % 60).padStart(2, '0');
|
||||||
|
const ms = String(totalMs % 1000).padStart(3, '0');
|
||||||
|
const miscInfo = pattern
|
||||||
|
.replace(/%f/g, filenameWithoutExt)
|
||||||
|
.replace(/%F/g, filenameWithExt)
|
||||||
|
.replace(/%t/g, `${hours}:${minutes}:${secs}`)
|
||||||
|
.replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`)
|
||||||
|
.replace(/<br>/g, '\n');
|
||||||
|
mediaFields[miscInfoFieldName] = miscInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(mediaFields).length > 0) {
|
||||||
|
try {
|
||||||
|
await timeMiningPhase(mode, 'updateNoteFields', () =>
|
||||||
|
client.updateNoteFields(noteId, mediaFields),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`update fields: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import type { MediaGenerator } from '../../../media-generator.js';
|
||||||
|
import type { AnkiConnectConfig } from '../../../types.js';
|
||||||
|
import { createLogger } from '../../../logger.js';
|
||||||
|
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
|
||||||
|
|
||||||
|
export type StatsServerNoteInfo = {
|
||||||
|
noteId: number;
|
||||||
|
fields: Record<string, { value: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatsServerMediaGenerator = {
|
||||||
|
generateAudio: (...args: Parameters<MediaGenerator['generateAudio']>) => Promise<Buffer | null>;
|
||||||
|
generateScreenshot: (
|
||||||
|
...args: Parameters<MediaGenerator['generateScreenshot']>
|
||||||
|
) => Promise<Buffer | null>;
|
||||||
|
generateAnimatedImage: (
|
||||||
|
...args: Parameters<MediaGenerator['generateAnimatedImage']>
|
||||||
|
) => Promise<Buffer | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatsMiningTimingEvent = {
|
||||||
|
mode: 'word' | 'sentence' | 'audio';
|
||||||
|
phase: string;
|
||||||
|
elapsedMs: number;
|
||||||
|
noteId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatsMiningRouteOptions = {
|
||||||
|
ankiConnectConfig?: AnkiConnectConfig;
|
||||||
|
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
|
||||||
|
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||||
|
secondarySubtitleLanguages?: string[];
|
||||||
|
getSecondarySubtitleLanguages?: () => string[] | undefined;
|
||||||
|
statsMiningAlassPath?: string;
|
||||||
|
getStatsMiningAlassPath?: () => string | null | undefined;
|
||||||
|
resolveRetimedSecondarySubtitleText?: (
|
||||||
|
input: RetimedSecondarySubtitleInput,
|
||||||
|
) => Promise<string> | string;
|
||||||
|
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||||
|
createMediaGenerator?: () => StatsServerMediaGenerator;
|
||||||
|
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
|
||||||
|
nowMs?: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatsMiningLogger = {
|
||||||
|
debug: (message: string, ...meta: unknown[]) => void;
|
||||||
|
warn: (message: string, ...meta: unknown[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statsMiningLogger: StatsMiningLogger = createLogger('stats:mining');
|
||||||
|
|
||||||
|
export function resolveStatsNoteFieldName(
|
||||||
|
noteInfo: StatsServerNoteInfo,
|
||||||
|
...preferredNames: (string | undefined)[]
|
||||||
|
): string | null {
|
||||||
|
for (const preferredName of preferredNames) {
|
||||||
|
if (!preferredName) continue;
|
||||||
|
const resolved = Object.keys(noteInfo.fields).find(
|
||||||
|
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (resolved) return resolved;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const fieldName of fieldNames) {
|
||||||
|
const normalized = fieldName?.trim();
|
||||||
|
if (!normalized) continue;
|
||||||
|
const key = normalized.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(normalized);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatsWordMiningAudioFieldName(
|
||||||
|
ankiConfig: AnkiConnectConfig,
|
||||||
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
|
): string {
|
||||||
|
return (
|
||||||
|
(noteInfo
|
||||||
|
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', ankiConfig.fields?.audio)
|
||||||
|
: null) ??
|
||||||
|
ankiConfig.fields?.audio ??
|
||||||
|
'ExpressionAudio'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig): boolean {
|
||||||
|
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyStatsWordAndSentenceCardFields(
|
||||||
|
fields: Record<string, string>,
|
||||||
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
|
ankiConfig: AnkiConnectConfig,
|
||||||
|
): void {
|
||||||
|
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
|
||||||
|
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
|
||||||
|
if (!wordAndSentenceFlag) return;
|
||||||
|
|
||||||
|
fields[wordAndSentenceFlag] = 'x';
|
||||||
|
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) fields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatsDirectMiningAudioFieldNames(
|
||||||
|
ankiConfig: AnkiConnectConfig,
|
||||||
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
|
mode: 'sentence' | 'audio',
|
||||||
|
): string[] {
|
||||||
|
const configuredAudioField = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
||||||
|
if (!ankiConfig.isLapis?.enabled && !ankiConfig.isKiku?.enabled) {
|
||||||
|
return [configuredAudioField];
|
||||||
|
}
|
||||||
|
const sentenceAudioField = noteInfo
|
||||||
|
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', configuredAudioField)
|
||||||
|
: 'SentenceAudio';
|
||||||
|
const expressionAudioField = noteInfo
|
||||||
|
? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
|
||||||
|
: configuredAudioField;
|
||||||
|
return mode === 'sentence'
|
||||||
|
? uniqueFieldNames(sentenceAudioField)
|
||||||
|
: uniqueFieldNames(sentenceAudioField, expressionAudioField);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatsMiningContext(options?: StatsMiningRouteOptions) {
|
||||||
|
const nowMs = options?.nowMs ?? (() => Date.now());
|
||||||
|
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
|
||||||
|
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
|
||||||
|
const getSecondarySubtitleLanguages = (): string[] =>
|
||||||
|
options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? [];
|
||||||
|
const getStatsMiningAlassPath = (): string | null | undefined =>
|
||||||
|
options?.getStatsMiningAlassPath?.() ?? options?.statsMiningAlassPath;
|
||||||
|
const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise<string> => {
|
||||||
|
const configuredDeckName = ankiConfig.deck?.trim() ?? '';
|
||||||
|
if (configuredDeckName) return configuredDeckName;
|
||||||
|
try {
|
||||||
|
const yomitanDeckName = await options?.getYomitanAnkiDeckName?.();
|
||||||
|
return typeof yomitanDeckName === 'string' ? yomitanDeckName.trim() : '';
|
||||||
|
} catch (error) {
|
||||||
|
statsMiningLogger.warn(
|
||||||
|
'Failed to resolve Yomitan Anki deck for stats mining:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const recordMiningTiming = (event: StatsMiningTimingEvent): void => {
|
||||||
|
try {
|
||||||
|
options?.onMiningTiming?.(event);
|
||||||
|
} catch {
|
||||||
|
// Timing observers must not affect mining execution.
|
||||||
|
}
|
||||||
|
statsMiningLogger.debug(
|
||||||
|
`[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`,
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const timeMiningPhase = async <T>(
|
||||||
|
mode: StatsMiningTimingEvent['mode'],
|
||||||
|
phase: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
details?: (value: T) => Partial<StatsMiningTimingEvent>,
|
||||||
|
): Promise<T> => {
|
||||||
|
const startedAtMs = nowMs();
|
||||||
|
try {
|
||||||
|
const value = await fn();
|
||||||
|
recordMiningTiming({
|
||||||
|
mode,
|
||||||
|
phase,
|
||||||
|
elapsedMs: nowMs() - startedAtMs,
|
||||||
|
...details?.(value),
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
recordMiningTiming({ mode, phase, elapsedMs: nowMs() - startedAtMs });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAnkiConnectConfig,
|
||||||
|
getEffectiveMiningDeckName,
|
||||||
|
getSecondarySubtitleLanguages,
|
||||||
|
getStatsMiningAlassPath,
|
||||||
|
timeMiningPhase,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { extname, resolve, sep } from 'node:path';
|
||||||
|
import {
|
||||||
|
getConfiguredSentenceFieldName,
|
||||||
|
getConfiguredTranslationFieldName,
|
||||||
|
getConfiguredWordFieldName,
|
||||||
|
getPreferredNoteFieldValue,
|
||||||
|
} from '../../../anki-field-config.js';
|
||||||
|
import type { AnkiConnectConfig } from '../../../types.js';
|
||||||
|
import type { StatsExcludedWord } from '../../../types/stats-wire.js';
|
||||||
|
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
||||||
|
import { splitSentenceSearchTerms } from '../immersion-tracker/query-lexical.js';
|
||||||
|
|
||||||
|
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.mjs': 'text/javascript; charset=utf-8',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseIntQuery(
|
||||||
|
raw: string | undefined,
|
||||||
|
fallback: number,
|
||||||
|
maxLimit?: number,
|
||||||
|
): number {
|
||||||
|
if (raw === undefined) return fallback;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return fallback;
|
||||||
|
const parsed = Math.floor(n);
|
||||||
|
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
|
||||||
|
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
|
||||||
|
? raw
|
||||||
|
: '30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
||||||
|
return raw === 'month' ? 'month' : 'day';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTrendFillEmpty(raw: string | undefined): boolean {
|
||||||
|
return raw !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const parsed = raw
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => Number.parseInt(entry.trim(), 10))
|
||||||
|
.filter((entry) => Number.isInteger(entry) && entry > 0);
|
||||||
|
return parsed.length > 0 ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | null {
|
||||||
|
if (!body || typeof body !== 'object' || !Array.isArray((body as { words?: unknown }).words)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words: StatsExcludedWord[] = [];
|
||||||
|
for (const row of (body as { words: unknown[] }).words) {
|
||||||
|
if (!row || typeof row !== 'object') return null;
|
||||||
|
const { headword, word, reading } = row as Record<string, unknown>;
|
||||||
|
if (typeof headword !== 'string' || typeof word !== 'string' || typeof reading !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
words.push({ headword, word, reading });
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
|
||||||
|
if (!cachePath || !existsSync(cachePath)) return null;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as {
|
||||||
|
version?: number;
|
||||||
|
words?: string[];
|
||||||
|
notes?: Record<string, Array<{ word?: unknown; reading?: unknown }>>;
|
||||||
|
};
|
||||||
|
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) {
|
||||||
|
return new Set(raw.words);
|
||||||
|
}
|
||||||
|
if (raw.version === 3 && raw.notes && typeof raw.notes === 'object') {
|
||||||
|
const words = new Set<string>();
|
||||||
|
for (const entries of Object.values(raw.notes)) {
|
||||||
|
if (!Array.isArray(entries)) continue;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry && typeof entry.word === 'string' && entry.word) words.add(entry.word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Treat an unreadable cache as unavailable.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countKnownWords(
|
||||||
|
headwords: string[],
|
||||||
|
knownWordsSet: Set<string>,
|
||||||
|
): { totalUniqueWords: number; knownWordCount: number } {
|
||||||
|
let knownWordCount = 0;
|
||||||
|
for (const headword of headwords) {
|
||||||
|
if (knownWordsSet.has(headword)) knownWordCount += 1;
|
||||||
|
}
|
||||||
|
return { totalUniqueWords: headwords.length, knownWordCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKnownWordRate(knownWordsSeen: number, tokensSeen: number): number {
|
||||||
|
if (!Number.isFinite(knownWordsSeen) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) return 0;
|
||||||
|
return Number(((knownWordsSeen / tokensSeen) * 100).toFixed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeFilteredWordOccurrences(
|
||||||
|
wordsByLine: Array<{ lineIndex: number; headword: string; occurrenceCount: number }>,
|
||||||
|
knownWordsSet: Set<string>,
|
||||||
|
): { knownWordsSeen: number; totalWordsSeen: number } {
|
||||||
|
let knownWordsSeen = 0;
|
||||||
|
let totalWordsSeen = 0;
|
||||||
|
for (const row of wordsByLine) {
|
||||||
|
totalWordsSeen += row.occurrenceCount;
|
||||||
|
if (knownWordsSet.has(row.headword)) knownWordsSeen += row.occurrenceCount;
|
||||||
|
}
|
||||||
|
return { knownWordsSeen, totalWordsSeen };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichSessionsWithKnownWordMetrics<
|
||||||
|
Session extends { sessionId: number; tokensSeen: number },
|
||||||
|
>(
|
||||||
|
tracker: ImmersionTrackerService,
|
||||||
|
sessions: Session[],
|
||||||
|
knownWordsCachePath?: string,
|
||||||
|
): Promise<Array<Session & { knownWordsSeen: number; knownWordRate: number }>> {
|
||||||
|
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
|
||||||
|
if (!knownWordsSet) {
|
||||||
|
return sessions.map((session) => ({ ...session, knownWordsSeen: 0, knownWordRate: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
sessions.map(async (session) => {
|
||||||
|
let knownWordsSeen = 0;
|
||||||
|
let totalWordsSeen = 0;
|
||||||
|
try {
|
||||||
|
const summary = summarizeFilteredWordOccurrences(
|
||||||
|
await tracker.getSessionWordsByLine(session.sessionId),
|
||||||
|
knownWordsSet,
|
||||||
|
);
|
||||||
|
knownWordsSeen = summary.knownWordsSeen;
|
||||||
|
totalWordsSeen = summary.totalWordsSeen;
|
||||||
|
} catch {
|
||||||
|
knownWordsSeen = 0;
|
||||||
|
totalWordsSeen = 0;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
knownWordsSeen,
|
||||||
|
knownWordRate: toKnownWordRate(knownWordsSeen, totalWordsSeen),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (raw === undefined) return fallback;
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
if (!normalized) return fallback;
|
||||||
|
return !['0', 'false', 'no', 'off'].includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueNonEmptyStrings(values: readonly string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized || seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
result.push(normalized);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildSentenceSearchOptions(
|
||||||
|
query: string,
|
||||||
|
searchByHeadword: boolean,
|
||||||
|
resolveSentenceSearchHeadwords: ((term: string) => Promise<string[]> | string[]) | undefined,
|
||||||
|
): Promise<{ headwordTerms: Array<{ term: string; headwords: string[] }> } | undefined> {
|
||||||
|
if (!searchByHeadword) return undefined;
|
||||||
|
const headwordTerms: Array<{ term: string; headwords: string[] }> = [];
|
||||||
|
for (const term of splitSentenceSearchTerms(query)) {
|
||||||
|
const resolved = resolveSentenceSearchHeadwords
|
||||||
|
? await resolveSentenceSearchHeadwords(term)
|
||||||
|
: [term];
|
||||||
|
const headwords = uniqueNonEmptyStrings(resolved);
|
||||||
|
if (headwords.length > 0) headwordTerms.push({ term, headwords });
|
||||||
|
}
|
||||||
|
return headwordTerms.length > 0 ? { headwordTerms } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAnkiNotePreview(
|
||||||
|
fields: Record<string, { value: string }>,
|
||||||
|
ankiConfig?: Pick<AnkiConnectConfig, 'fields'>,
|
||||||
|
): { word: string; sentence: string; translation: string } {
|
||||||
|
return {
|
||||||
|
word: getPreferredNoteFieldValue(fields, [getConfiguredWordFieldName(ankiConfig)]),
|
||||||
|
sentence: getPreferredNoteFieldValue(fields, [getConfiguredSentenceFieldName(ankiConfig)]),
|
||||||
|
translation: getPreferredNoteFieldValue(fields, [
|
||||||
|
getConfiguredTranslationFieldName(ankiConfig),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null {
|
||||||
|
const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html';
|
||||||
|
const absoluteStaticDir = resolve(staticDir);
|
||||||
|
let decodedPath: string;
|
||||||
|
try {
|
||||||
|
decodedPath = decodeURIComponent(normalizedPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const absolutePath = resolve(absoluteStaticDir, decodedPath);
|
||||||
|
if (
|
||||||
|
absolutePath !== absoluteStaticDir &&
|
||||||
|
!absolutePath.startsWith(`${absoluteStaticDir}${sep}`)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) return null;
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatsStaticResponse(staticDir: string, requestPath: string): Response | null {
|
||||||
|
const absolutePath = resolveStatsStaticPath(staticDir, requestPath);
|
||||||
|
if (!absolutePath) return null;
|
||||||
|
const contentType =
|
||||||
|
STATS_STATIC_CONTENT_TYPES[extname(absolutePath).toLowerCase()] ?? 'application/octet-stream';
|
||||||
|
return new Response(readFileSync(absolutePath), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': absolutePath.endsWith('index.html')
|
||||||
|
? 'no-cache'
|
||||||
|
: 'public, max-age=31536000, immutable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { registerStatsAnalyticsRoutes } from './analytics-routes.js';
|
||||||
|
export { registerStatsIntegrationRoutes } from './integration-routes.js';
|
||||||
|
export { registerStatsLibraryRoutes } from './library-routes.js';
|
||||||
|
export { registerStatsMiningRoutes } from './mining-routes.js';
|
||||||
|
export { registerStatsStaticRoutes } from './static-routes.js';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { createStatsStaticResponse } from './route-support.js';
|
||||||
|
|
||||||
|
export function registerStatsStaticRoutes(app: Hono, staticDir?: string): void {
|
||||||
|
if (!staticDir) return;
|
||||||
|
|
||||||
|
app.get('/assets/*', (c) => {
|
||||||
|
const response = createStatsStaticResponse(staticDir, c.req.path);
|
||||||
|
if (!response) return c.text('Not found', 404);
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/index.html', (c) => {
|
||||||
|
const response = createStatsStaticResponse(staticDir, '/index.html');
|
||||||
|
if (!response) return c.text('Stats UI not built', 404);
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('*', (c) => {
|
||||||
|
const staticResponse = createStatsStaticResponse(staticDir, c.req.path);
|
||||||
|
if (staticResponse) return staticResponse;
|
||||||
|
const fallback = createStatsStaticResponse(staticDir, '/index.html');
|
||||||
|
if (!fallback) return c.text('Stats UI not built', 404);
|
||||||
|
return fallback;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { DEFAULT_CONFIG } from './config/definitions';
|
|||||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||||
import {
|
import {
|
||||||
configureEarlyAppPaths,
|
configureEarlyAppPaths,
|
||||||
|
exitBackgroundBootstrap,
|
||||||
normalizeLaunchMpvExtraArgs,
|
normalizeLaunchMpvExtraArgs,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
@@ -21,10 +22,17 @@ import {
|
|||||||
shouldHandleStatsDaemonCommandAtEntry,
|
shouldHandleStatsDaemonCommandAtEntry,
|
||||||
hasTransportedStartupArgs,
|
hasTransportedStartupArgs,
|
||||||
shouldForwardStartupArgvViaAppControl,
|
shouldForwardStartupArgvViaAppControl,
|
||||||
|
applyBackgroundBootstrapCommandLineSwitches,
|
||||||
applyEarlyLinuxCommandLineSwitches,
|
applyEarlyLinuxCommandLineSwitches,
|
||||||
resolveLinuxPasswordStoreValue,
|
resolveLinuxPasswordStoreValue,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
|
|
||||||
|
test('background bootstrap exits through Electron so Chromium children shut down', () => {
|
||||||
|
const exitCodes: number[] = [];
|
||||||
|
exitBackgroundBootstrap({ exit: (code) => exitCodes.push(code) });
|
||||||
|
assert.deepEqual(exitCodes, [0]);
|
||||||
|
});
|
||||||
|
|
||||||
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
try {
|
try {
|
||||||
@@ -151,6 +159,56 @@ test('applyEarlyLinuxCommandLineSwitches appends password store before main star
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('background bootstrap keeps the GPU in-process so no child outlives its AppImage mount', () => {
|
||||||
|
const collect = () => {
|
||||||
|
const switches: Array<[string, string | undefined]> = [];
|
||||||
|
return {
|
||||||
|
switches,
|
||||||
|
commandLine: {
|
||||||
|
appendSwitch: (name: string, value?: string) => {
|
||||||
|
switches.push([name, value]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const bootstrap = collect();
|
||||||
|
applyBackgroundBootstrapCommandLineSwitches(
|
||||||
|
bootstrap.commandLine,
|
||||||
|
['SubMiner.AppImage', '--start', '--background'],
|
||||||
|
{},
|
||||||
|
'linux',
|
||||||
|
);
|
||||||
|
assert.deepEqual(bootstrap.switches, [['in-process-gpu', undefined]]);
|
||||||
|
|
||||||
|
const detachedChild = collect();
|
||||||
|
applyBackgroundBootstrapCommandLineSwitches(
|
||||||
|
detachedChild.commandLine,
|
||||||
|
['SubMiner.AppImage', '--start', '--background'],
|
||||||
|
{ SUBMINER_BACKGROUND_CHILD: '1' },
|
||||||
|
'linux',
|
||||||
|
);
|
||||||
|
assert.deepEqual(detachedChild.switches, []);
|
||||||
|
|
||||||
|
const foreground = collect();
|
||||||
|
applyBackgroundBootstrapCommandLineSwitches(
|
||||||
|
foreground.commandLine,
|
||||||
|
['SubMiner.AppImage', '--stop'],
|
||||||
|
{},
|
||||||
|
'linux',
|
||||||
|
);
|
||||||
|
assert.deepEqual(foreground.switches, []);
|
||||||
|
|
||||||
|
const windows = collect();
|
||||||
|
applyBackgroundBootstrapCommandLineSwitches(
|
||||||
|
windows.commandLine,
|
||||||
|
['SubMiner.exe', '--start', '--background'],
|
||||||
|
{},
|
||||||
|
'win32',
|
||||||
|
);
|
||||||
|
assert.deepEqual(windows.switches, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('transported AppImage visibility commands forward through app control', () => {
|
test('transported AppImage visibility commands forward through app control', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
|
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ type EarlyAppLike = {
|
|||||||
setPath: (name: 'userData', value: string) => void;
|
setPath: (name: 'userData', value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BackgroundBootstrapAppLike = {
|
||||||
|
exit: (code: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type CommandLineLike = {
|
type CommandLineLike = {
|
||||||
appendSwitch: (name: string, value?: string) => void;
|
appendSwitch: (name: string, value?: string) => void;
|
||||||
};
|
};
|
||||||
@@ -132,6 +136,22 @@ export function applyEarlyLinuxCommandLineSwitches(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The detach bootstrap exits within milliseconds, but a separate GPU child it
|
||||||
|
// spawned survives app.exit() and keeps executing from the bootstrap's FUSE
|
||||||
|
// mount until session teardown finally unmaps it — SIGBUS, surfaced by DrKonqi
|
||||||
|
// as a "Service Crash" notification on every video close. Keeping the GPU
|
||||||
|
// in-process means the bootstrap leaves no child behind.
|
||||||
|
export function applyBackgroundBootstrapCommandLineSwitches(
|
||||||
|
commandLine: CommandLineLike,
|
||||||
|
argv: string[],
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): void {
|
||||||
|
if (platform !== 'linux') return;
|
||||||
|
if (!shouldDetachBackgroundLaunch(argv, env)) return;
|
||||||
|
commandLine.appendSwitch('in-process-gpu');
|
||||||
|
}
|
||||||
|
|
||||||
function consumesLaunchMpvValue(token: string): boolean {
|
function consumesLaunchMpvValue(token: string): boolean {
|
||||||
return (
|
return (
|
||||||
token.startsWith('--') &&
|
token.startsWith('--') &&
|
||||||
@@ -241,6 +261,10 @@ export function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.Process
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exitBackgroundBootstrap(app: BackgroundBootstrapAppLike): void {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
||||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||||
const args = parseCliArgs(argv);
|
const args = parseCliArgs(argv);
|
||||||
|
|||||||
+5
-1
@@ -3,7 +3,9 @@ import { spawn } from 'node:child_process';
|
|||||||
import { app, dialog, shell } from 'electron';
|
import { app, dialog, shell } from 'electron';
|
||||||
import { printHelp } from './cli/help';
|
import { printHelp } from './cli/help';
|
||||||
import {
|
import {
|
||||||
|
applyBackgroundBootstrapCommandLineSwitches,
|
||||||
configureEarlyAppPaths,
|
configureEarlyAppPaths,
|
||||||
|
exitBackgroundBootstrap,
|
||||||
normalizeLaunchMpvExtraArgs,
|
normalizeLaunchMpvExtraArgs,
|
||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
@@ -174,6 +176,7 @@ function createWindowsRuntimePluginPolicy() {
|
|||||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||||
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
|
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
|
||||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||||
|
applyBackgroundBootstrapCommandLineSwitches(app.commandLine, process.argv, process.env);
|
||||||
const userDataPath = configureEarlyAppPaths(app);
|
const userDataPath = configureEarlyAppPaths(app);
|
||||||
const reportFatalError = createFatalErrorReporter({
|
const reportFatalError = createFatalErrorReporter({
|
||||||
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
|
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
|
||||||
@@ -308,7 +311,8 @@ async function runEntryProcess(): Promise<void> {
|
|||||||
env: sanitizeBackgroundEnv(process.env),
|
env: sanitizeBackgroundEnv(process.env),
|
||||||
});
|
});
|
||||||
child.unref();
|
child.unref();
|
||||||
process.exit(0);
|
// Let Electron stop bootstrap Chromium children before its AppImage mount is released.
|
||||||
|
exitBackgroundBootstrap(app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,17 @@ test('stats data uses the shared HTTP contract while native dialogs retain IPC',
|
|||||||
assert.doesNotMatch(ipcHandlers, /statsGet[A-Z]/);
|
assert.doesNotMatch(ipcHandlers, /statsGet[A-Z]/);
|
||||||
|
|
||||||
const apiClient = read('stats/src/lib/api-client.ts');
|
const apiClient = read('stats/src/lib/api-client.ts');
|
||||||
const statsServer = read('src/core/services/stats-server.ts');
|
const statsServerRoutes = [
|
||||||
|
'analytics-routes.ts',
|
||||||
|
'integration-routes.ts',
|
||||||
|
'library-routes.ts',
|
||||||
|
'mining-routes.ts',
|
||||||
|
].map((filename) => ({
|
||||||
|
filename,
|
||||||
|
source: read(`src/core/services/stats-server/${filename}`),
|
||||||
|
}));
|
||||||
assert.match(apiClient, /StatsHttpClient/);
|
assert.match(apiClient, /StatsHttpClient/);
|
||||||
assert.match(statsServer, /statsJson/);
|
for (const { filename, source } of statsServerRoutes) {
|
||||||
|
assert.match(source, /statsJson/, `${filename} must use the shared HTTP contract`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user