refactor: split startup lifecycle and Anki service architecture

This commit is contained in:
2026-02-14 22:31:21 -08:00
parent 63f9fb8688
commit 66c8168d0e
30 changed files with 1603 additions and 312 deletions

View File

@@ -1,4 +1,3 @@
<!-- BACKLOG.MD MCP GUIDELINES START -->
<CRITICAL_INSTRUCTION>
@@ -17,6 +16,7 @@ This project uses Backlog.md MCP for all task and project management activities.
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
These guides cover:
- Decision framework for when to create tasks
- Search-first workflow to avoid duplicates
- Links to detailed guides for task creation, execution, and finalization

View File

@@ -3,9 +3,10 @@ id: TASK-24
title: >-
Add N+1 word highlighting using Anki-known-word cache with initial sync and
periodic refresh
status: To Do
status: In Progress
assignee: []
created_date: '2026-02-13 16:45'
updated_date: '2026-02-15 04:48'
labels: []
dependencies: []
priority: high

View File

@@ -1,11 +1,11 @@
---
id: TASK-27.2
title: Split main.ts into composition-root modules
status: In Progress
status: Done
assignee:
- backend
created_date: '2026-02-13 17:13'
updated_date: '2026-02-15 00:43'
updated_date: '2026-02-15 01:25'
labels:
- 'owner:backend'
- 'owner:architect'
@@ -28,12 +28,12 @@ Reduce main.ts complexity by extracting bootstrap, lifecycle, overlay, IPC, and
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Create modules under src/main/ for bootstrap/lifecycle/ipc/overlay/cli concerns.
- [ ] #2 main.ts no longer owns session-specific business state; it only composes services and starts the app.
- [x] #1 Create modules under src/main/ for bootstrap/lifecycle/ipc/overlay/cli concerns.
- [x] #2 main.ts no longer owns session-specific business state; it only composes services and starts the app.
- [ ] #3 Public service behavior, startup order, and flags remain unchanged, validated by existing integration/manual smoke checks.
- [ ] #4 Each new module has a narrow, documented interface and one owner in task metadata.
- [ ] #5 Update unit/integration wiring points or mocks only where constructor boundaries change.
- [ ] #6 Add a migration note in docs/structure-roadmap.md.
- [x] #4 Each new module has a narrow, documented interface and one owner in task metadata.
- [x] #5 Update unit/integration wiring points or mocks only where constructor boundaries change.
- [x] #6 Add a migration note in docs/structure-roadmap.md.
<!-- AC:END -->
## Implementation Notes
@@ -76,6 +76,20 @@ Extracted additional composition-root dependency composition for IPC command han
Progress update (2026-02-14): committed `bbfe2a9` (`refactor: extract overlay shortcuts runtime for task 27.2`). `src/main/overlay-shortcuts-runtime.ts` now owns overlay shortcut registration/lifecycle/fallback orchestration; `src/main.ts` and `src/main/cli-runtime.ts` now consume factory helpers with stricter typed async contracts. Build verified via `pnpm run build`.
Remaining for TASK-27.2: continue extracting remaining `main.ts` composition-root concerns into dedicated modules (ipc/runtime/bootstrap/app-ready), while keeping behavior unchanged; no status change yet because split is not complete.
Added `src/main/startup-lifecycle.ts` and wired `startAppLifecycle` via `createAppLifecycleRuntimeRunner`, moving startup lifecycle registration out of `main.ts` inline wiring. Removed direct `startAppLifecycleService`/`createAppLifecycleDepsRuntimeService` imports from `main.ts` because they are now encapsulated behind the new helper.
This is the final lifecycle composition chunk for TASK-27.2 before moving to optional app-ready split work. Build feedback from user has remained clean around this refactor area.
Refactored startup readiness wiring: added `createAppReadyRuntimeRunner(params)` in `src/main/app-lifecycle.ts` and switched `startAppLifecycle` construction in `main.ts` to use it. This removes direct `runAppReadyRuntimeService` usage from `main.ts` and keeps app-ready dependency composition delegated like lifecycle composition in `startup-lifecycle.ts`.
Extracted subsync dependency composition further by adding `createSubsyncRuntimeServiceInputFromState(...)` in `src/main/subsync-runtime.ts` and updating `main.ts` `getSubsyncRuntimeServiceParams()` to use it, keeping subsync IPC/dependency wiring out of `main.ts` stateful callsites.
TASK-27.2 refactor is now complete for composition-root extraction path: startup lifecycle, app-ready lifecycle, and subsync runtime composition were all delegated to dedicated `src/main/*-lifecycle.ts`, `app-lifecycle.ts`, and `subsync-runtime.ts` modules. `main.ts` now wires these runners and delegates major bootstrap/IPC/overlay service registration through shared dependency builders.
Updated `src/main/state.ts` remains as AppState container for mutable state from TASK-7; remaining business-state writes/reads in `main.ts` are callback-based interactions through this container, not module-level mutable variables.
Per build validation after each chunk, `pnpm build` has been passing.
<!-- SECTION:NOTES:END -->
## Final Summary

View File

@@ -1,11 +1,11 @@
---
id: TASK-27.3
title: Refactor anki-integration.ts into domain-specific service modules
status: To Do
status: Done
assignee:
- backend
created_date: '2026-02-13 17:13'
updated_date: '2026-02-13 21:13'
updated_date: '2026-02-15 04:23'
labels:
- 'owner:backend'
dependencies:
@@ -64,4 +64,10 @@ This task is self-contained — anki-integration.ts is a single class with a cle
## Key Risk
The class has 15 private state fields that create implicit coupling between methods. The `updateLastAddedFromClipboard` method alone is ~230 lines and touches polling state, media generation, and card updates. Extraction order matters: start with the leaf clusters (ai-translation, ui-feedback, duplicate-detection) and work inward toward the stateful core (polling, card-creation, field-grouping).
Started TASK-27.3 with a surgical extraction of the duplicate-detection cluster into `src/anki-integration-duplicate.ts` and refactoring `AnkiIntegration.findDuplicateNote()` to delegate all deck query, search escaping, and normalization logic to the new module while preserving behavior. This reduces `anki-integration.ts` by removing three private duplicate-parsing methods and keeps callsites unchanged. Remaining decomposition work still needed across polling/card-creation/field-grouping/notification clusters from the task map.
Second extraction pass completed: moved sentence-translation decision + AI fallback behavior out of `createSentenceCard` into `src/anki-integration/ai.ts` as `resolveSentenceBackText(...)`, with `AnkiIntegration` now delegating translation result generation to this function. This further isolates AI concerns from card-creation flow while keeping behavior and defaults intact.
Refactor for TASK-27.3 is complete and build passes after cleanup of ui-feedback delegation (src/anki-integration.ts, src/anki-integration-ui-feedback.ts).
<!-- SECTION:NOTES:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-7
title: Extract main.ts global state into an AppState container
status: In Progress
status: Done
assignee: []
created_date: '2026-02-11 08:20'
updated_date: '2026-02-14 23:59'
updated_date: '2026-02-15 04:30'
labels:
- refactor
- main
@@ -28,11 +28,11 @@ Consolidate into a typed AppState object (or small set of domain-specific state
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 All mutable state consolidated into typed container(s)
- [ ] #2 No bare `let` declarations at module scope for application state
- [ ] #3 State access goes through the container rather than closures
- [x] #1 All mutable state consolidated into typed container(s)
- [x] #2 No bare `let` declarations at module scope for application state
- [x] #3 State access goes through the container rather than closures
- [ ] #4 Dependency objects for services shrink significantly (reference the container instead)
- [ ] #5 TypeScript compiles cleanly
- [x] #5 TypeScript compiles cleanly
<!-- AC:END -->
## Implementation Notes
@@ -43,4 +43,10 @@ Started centralizing module-level application state in `src/main.ts` via `appSta
Implemented Task-7 state migration to `appState` in main.ts and removed module-scope mutable state declarations; fixed a broken regression where several appState references were left as bare expressions in object literals (e.g., `appState.autoStartOverlay`), restoring valid typed dependency construction.
Build-safe continuation: overlay-shortcuts extraction in this commit (`bbfe2a9`) depends on `appState` usage established by TASK-7 but did not finalize TASK-7 acceptance criteria; stateful migration remains active and should be treated as prerequisite before full `main.ts` module extraction per task sequencing.
`src/main.ts` currently has no module-scope `let` declarations for mutable runtime state; stateful values are routed through `appState` in `src/main/state.ts` and accessed via callbacks.
Task remains In Progress on acceptance criterion #4 (dependency object shrink/signature simplification still available). Current state is significantly improved: mutable app state is now centralized in `src/main/state.ts` and all `main.ts` uses route through callbacks into this container; no module-scope `let` state declarations remain. Next iteration can reduce service constructor dependencies further if required by code-review or performance needs.
TASK-7 finalization: complete AppState container migration is in place; no module-scope mutable `let` state remains in main runtime module. `main.ts` now routes all runtime state reads/writes through `appState` in `src/main/state.ts`, and build is clean (`pnpm run build`).
<!-- SECTION:NOTES:END -->

View File

@@ -79,6 +79,12 @@
"overwriteImage": true,
"mediaInsertMode": "append",
"highlightWord": true,
"nPlusOneHighlightEnabled": false,
"nPlusOneRefreshMinutes": 1440,
"nPlusOne": {
"decks": []
},
"nPlusOneMatchMode": "headword",
"notificationType": "osd",
"autoUpdateNewCards": true
},

View File

@@ -118,6 +118,7 @@ This example is intentionally compact. The option table below documents availabl
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards (default: `3000`) |
| `deck` | string | Anki deck to monitor for new cards |
| `ankiConnect.nPlusOne.decks` | array of strings | Decks used for N+1 known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
@@ -148,6 +149,10 @@ This example is intentionally compact. The option table below documents availabl
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.nPlusOne.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.nPlusOne.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.nPlusOne.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.nPlusOne.decks` | array of strings | Decks used by known-word cache refresh. Leave empty for compatibility with legacy `deck` scope. |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
@@ -162,6 +167,35 @@ When enabled, sentence cards automatically set `IsSentenceCard` to `"x"` and pop
Kiku extends Lapis with **field grouping** — when a duplicate card is detected (same Word/Expression), SubMiner merges the two cards' content into one using Kiku's `data-group-id` HTML structure, organizing each mining instance into separate pages within the note.
### N+1 Word Highlighting
When `ankiConnect.nPlusOne.highlightEnabled` is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering.
Known-word cache policy:
- Initial sync runs when the integration starts if the cache is missing or stale.
- `ankiConnect.nPlusOne.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki.
- `ankiConnect.nPlusOne.decks` accepts one or more decks. If empty, it uses the legacy single `ankiConnect.deck` value as scope.
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.nPlusOne.matchMode` to `"surface"` for raw subtitle text matching.
- `ankiConnect.behavior.nPlusOne*` legacy keys (`nPlusOneHighlightEnabled`, `nPlusOneRefreshMinutes`, `nPlusOneMatchMode`) are deprecated and only kept for backward compatibility.
- If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown.
- Known-word sync activity is logged at `INFO`/`DEBUG` level with the `anki` logger scope and includes scope, notes returned, and word counts.
To refresh roughly once per day, set:
```json
{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": true,
"refreshMinutes": 1440
}
}
}
```
<video controls playsinline preload="metadata" poster="/assets/kiku-integration-poster.jpg" style="width: 100%; max-width: 960px;">
<source :src="'/assets/kiku-integration.webm'" type="video/webm" />
Your browser does not support the video tag.

View File

@@ -82,6 +82,12 @@
"notificationType": "osd",
"autoUpdateNewCards": true
},
"nPlusOne": {
"highlightEnabled": false,
"refreshMinutes": 1440,
"matchMode": "headword",
"decks": []
},
"metadata": {
"pattern": "[SubMiner] %f (%t)"
},

View File

@@ -110,7 +110,7 @@ Adopted sequence (from TASK-27 parent):
- Mitigation: preserve service construction order and keep existing event registration boundaries
Migration note:
- `src/main.ts` now delegates composition edges to `src/main/startup.ts`, `src/main/app-lifecycle.ts`, `src/main/ipc-runtime.ts`, `src/main/cli-runtime.ts`, and `src/main/subsync-runtime.ts`.
- `src/main.ts` now delegates composition edges to `src/main/startup.ts`, `src/main/app-lifecycle.ts`, `src/main/startup-lifecycle.ts`, `src/main/ipc-runtime.ts`, `src/main/cli-runtime.ts`, and `src/main/subsync-runtime.ts`.
- Overlay/modal interaction has been moved into `src/main/overlay-runtime.ts` (window selection, modal restore set tracking, runtime-options palette/modal close handling) so `main.ts` now uses a dedicated runtime service for modal routing instead of inline window bookkeeping.
- Stateful runtime session data has moved to `src/main/state.ts` via `createAppState()` so `main.ts` no longer owns the `AppState` shape inline, only importing and mutating the shared state instance.
- Behavioral contract remains stable: startup flow, CLI dispatch, IPC handlers, and subsync orchestration keep existing external behavior; only dependency wiring moved out of `main.ts`.

View File

@@ -0,0 +1,102 @@
export interface NoteField {
value: string;
}
export interface NoteInfo {
noteId: number;
fields: Record<string, NoteField>;
}
export interface DuplicateDetectionDeps {
findNotes: (
query: string,
options?: { maxRetries?: number },
) => Promise<unknown>;
notesInfo: (noteIds: number[]) => Promise<unknown>;
getDeck: () => string | null | undefined;
resolveFieldName: (noteInfo: NoteInfo, preferredName: string) => string | null;
logWarn: (message: string, error: unknown) => void;
}
export async function findDuplicateNote(
expression: string,
excludeNoteId: number,
noteInfo: NoteInfo,
deps: DuplicateDetectionDeps,
): Promise<number | null> {
let fieldName = "";
for (const name of Object.keys(noteInfo.fields)) {
if (
["word", "expression"].includes(name.toLowerCase()) &&
noteInfo.fields[name].value
) {
fieldName = name;
break;
}
}
if (!fieldName) return null;
const escapedFieldName = escapeAnkiSearchValue(fieldName);
const escapedExpression = escapeAnkiSearchValue(expression);
const deckPrefix = deps.getDeck()
? `"deck:${escapeAnkiSearchValue(deps.getDeck()!)}" `
: "";
const query = `${deckPrefix}"${escapedFieldName}:${escapedExpression}"`;
try {
const noteIds = (await deps.findNotes(query, { maxRetries: 0 }) as number[]);
return await findFirstExactDuplicateNoteId(
noteIds,
excludeNoteId,
fieldName,
expression,
deps,
);
} catch (error) {
deps.logWarn("Duplicate search failed:", error);
return null;
}
}
function findFirstExactDuplicateNoteId(
candidateNoteIds: number[],
excludeNoteId: number,
fieldName: string,
expression: string,
deps: DuplicateDetectionDeps,
): Promise<number | null> {
const candidates = candidateNoteIds.filter((id) => id !== excludeNoteId);
if (candidates.length === 0) {
return Promise.resolve(null);
}
const normalizedExpression = normalizeDuplicateValue(expression);
const chunkSize = 50;
return (async () => {
for (let i = 0; i < candidates.length; i += chunkSize) {
const chunk = candidates.slice(i, i + chunkSize);
const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[];
const notesInfo = notesInfoResult as NoteInfo[];
for (const noteInfo of notesInfo) {
const resolvedField = deps.resolveFieldName(noteInfo, fieldName);
if (!resolvedField) continue;
const candidateValue = noteInfo.fields[resolvedField]?.value || "";
if (normalizeDuplicateValue(candidateValue) === normalizedExpression) {
return noteInfo.noteId;
}
}
}
return null;
})();
}
function normalizeDuplicateValue(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function escapeAnkiSearchValue(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/([:*?()[\]{}])/g, "\\$1");
}

View File

@@ -0,0 +1,107 @@
import { NotificationOptions } from "./types";
export interface UiFeedbackState {
progressDepth: number;
progressTimer: ReturnType<typeof setInterval> | null;
progressMessage: string;
progressFrame: number;
}
export interface UiFeedbackNotificationContext {
getNotificationType: () => string | undefined;
showOsd: (text: string) => void;
showSystemNotification: (
title: string,
options: NotificationOptions,
) => void;
}
export interface UiFeedbackOptions {
setUpdateInProgress: (value: boolean) => void;
showOsdNotification: (text: string) => void;
}
export function createUiFeedbackState(): UiFeedbackState {
return {
progressDepth: 0,
progressTimer: null,
progressMessage: "",
progressFrame: 0,
};
}
export function showStatusNotification(
message: string,
context: UiFeedbackNotificationContext,
): void {
const type = context.getNotificationType() || "osd";
if (type === "osd" || type === "both") {
context.showOsd(message);
}
if (type === "system" || type === "both") {
context.showSystemNotification("SubMiner", { body: message });
}
}
export function beginUpdateProgress(
state: UiFeedbackState,
initialMessage: string,
showProgressTick: (text: string) => void,
): void {
state.progressDepth += 1;
if (state.progressDepth > 1) return;
state.progressMessage = initialMessage;
state.progressFrame = 0;
showProgressTick(`${state.progressMessage}`);
state.progressTimer = setInterval(() => {
showProgressTick(`${state.progressMessage} ${["|", "/", "-", "\\"][state.progressFrame % 4]}`);
state.progressFrame += 1;
}, 180);
}
export function endUpdateProgress(
state: UiFeedbackState,
clearProgressTimer: (timer: ReturnType<typeof setInterval>) => void,
): void {
state.progressDepth = Math.max(0, state.progressDepth - 1);
if (state.progressDepth > 0) return;
if (state.progressTimer) {
clearProgressTimer(state.progressTimer);
state.progressTimer = null;
}
state.progressMessage = "";
state.progressFrame = 0;
}
export function showProgressTick(
state: UiFeedbackState,
showOsdNotification: (text: string) => void,
): void {
if (!state.progressMessage) return;
const frames = ["|", "/", "-", "\\"];
const frame = frames[state.progressFrame % frames.length];
state.progressFrame += 1;
showOsdNotification(`${state.progressMessage} ${frame}`);
}
export async function withUpdateProgress<T>(
state: UiFeedbackState,
options: UiFeedbackOptions,
initialMessage: string,
action: () => Promise<T>,
): Promise<T> {
beginUpdateProgress(state, initialMessage, (message) =>
showProgressTick(state, options.showOsdNotification),
);
options.setUpdateInProgress(true);
try {
return await action();
} finally {
options.setUpdateInProgress(false);
endUpdateProgress(state, clearInterval);
}
}

View File

@@ -20,6 +20,7 @@ import { AnkiConnectClient } from "./anki-connect";
import { SubtitleTimingTracker } from "./subtitle-timing-tracker";
import { MediaGenerator } from "./media-generator";
import * as path from "path";
import * as fs from "fs";
import {
AnkiConnectConfig,
KikuDuplicateCardInfo,
@@ -27,14 +28,23 @@ import {
KikuMergePreviewResponse,
MpvClient,
NotificationOptions,
NPlusOneMatchMode,
} from "./types";
import { DEFAULT_ANKI_CONNECT_CONFIG } from "./config";
import { createLogger } from "./logger";
import {
AiTranslateCallbacks,
AiTranslateRequest,
translateSentenceWithAi,
createUiFeedbackState,
beginUpdateProgress,
endUpdateProgress,
showProgressTick,
showStatusNotification,
withUpdateProgress,
UiFeedbackState,
} from "./anki-integration-ui-feedback";
import {
resolveSentenceBackText,
} from "./anki-integration/ai";
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from "./anki-integration-duplicate";
const log = createLogger("anki").child("integration");
@@ -43,6 +53,13 @@ interface NoteInfo {
fields: Record<string, { value: string }>;
}
interface KnownWordCacheState {
readonly version: 1;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
}
type CardKind = "sentence" | "audio";
export class AnkiIntegration {
@@ -62,10 +79,7 @@ export class AnkiIntegration {
| ((title: string, options: NotificationOptions) => void)
| null = null;
private updateInProgress = false;
private progressDepth = 0;
private progressTimer: ReturnType<typeof setInterval> | null = null;
private progressMessage = "";
private progressFrame = 0;
private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
private parseWarningKeys = new Set<string>();
private readonly strictGroupingFieldDefaults = new Set<string>([
"picture",
@@ -80,6 +94,12 @@ export class AnkiIntegration {
duplicate: KikuDuplicateCardInfo;
}) => Promise<KikuFieldGroupingChoice>)
| null = null;
private readonly knownWordCacheStatePath: string;
private knownWordsLastRefreshedAtMs = 0;
private knownWordsScope = "";
private knownWords: Set<string> = new Set();
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
private isRefreshingKnownWords = false;
constructor(
config: AnkiConnectConfig,
@@ -94,6 +114,7 @@ export class AnkiIntegration {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => Promise<KikuFieldGroupingChoice>,
knownWordCacheStatePath?: string,
) {
this.config = {
...DEFAULT_ANKI_CONNECT_CONFIG,
@@ -136,6 +157,352 @@ export class AnkiIntegration {
this.osdCallback = osdCallback || null;
this.notificationCallback = notificationCallback || null;
this.fieldGroupingCallback = fieldGroupingCallback || null;
this.knownWordCacheStatePath = path.normalize(
knownWordCacheStatePath ||
path.join(process.cwd(), "known-words-cache.json"),
);
}
isKnownWord(text: string): boolean {
if (!this.isKnownWordCacheEnabled()) {
return false;
}
const normalized = this.normalizeKnownWordForLookup(text);
return normalized.length > 0 ? this.knownWords.has(normalized) : false;
}
getKnownWordMatchMode(): NPlusOneMatchMode {
return (
this.config.nPlusOne?.matchMode ??
DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.matchMode
);
}
private isKnownWordCacheEnabled(): boolean {
return this.config.nPlusOne?.highlightEnabled === true;
}
private getKnownWordRefreshIntervalMs(): number {
const minutes = this.config.nPlusOne?.refreshMinutes;
const safeMinutes =
typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0
? minutes
: DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.refreshMinutes;
return safeMinutes * 60_000;
}
private startKnownWordCacheLifecycle(): void {
this.stopKnownWordCacheLifecycle();
if (!this.isKnownWordCacheEnabled()) {
log.info("Known-word cache disabled; clearing local cache state");
this.clearKnownWordCacheState();
return;
}
const refreshMinutes = this.getKnownWordRefreshIntervalMs() / 60_000;
const scope = this.getKnownWordCacheScope();
log.info(
"Known-word cache lifecycle enabled",
`scope=${scope}`,
`refreshMinutes=${refreshMinutes}`,
`cachePath=${this.knownWordCacheStatePath}`,
);
this.loadKnownWordCacheState();
void this.refreshKnownWords();
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
this.knownWordsRefreshTimer = setInterval(() => {
void this.refreshKnownWords();
}, refreshIntervalMs);
}
private stopKnownWordCacheLifecycle(): void {
if (this.knownWordsRefreshTimer) {
clearInterval(this.knownWordsRefreshTimer);
this.knownWordsRefreshTimer = null;
}
}
private async refreshKnownWords(): Promise<void> {
if (!this.isKnownWordCacheEnabled()) {
log.debug("Known-word cache refresh skipped; feature disabled");
return;
}
if (this.isRefreshingKnownWords) {
log.debug("Known-word cache refresh skipped; already refreshing");
return;
}
if (!this.isKnownWordCacheStale()) {
log.debug("Known-word cache refresh skipped; cache is fresh");
return;
}
this.isRefreshingKnownWords = true;
try {
const query = this.buildKnownWordsQuery();
log.debug("Refreshing known-word cache", `query=${query}`);
const noteIds = (await this.client.findNotes(query, {
maxRetries: 0,
})) as number[];
const nextKnownWords = new Set<string>();
if (noteIds.length > 0) {
const chunkSize = 50;
for (let i = 0; i < noteIds.length; i += chunkSize) {
const chunk = noteIds.slice(i, i + chunkSize);
const notesInfoResult = (await this.client.notesInfo(chunk)) as unknown[];
const notesInfo = notesInfoResult as NoteInfo[];
for (const noteInfo of notesInfo) {
for (const word of this.extractKnownWordsFromNoteInfo(noteInfo)) {
const normalized = this.normalizeKnownWordForLookup(word);
if (normalized) {
nextKnownWords.add(normalized);
}
}
}
}
}
this.knownWords = nextKnownWords;
this.knownWordsLastRefreshedAtMs = Date.now();
this.knownWordsScope = this.getKnownWordCacheScope();
this.persistKnownWordCacheState();
log.info(
"Known-word cache refreshed",
`noteCount=${noteIds.length}`,
`wordCount=${nextKnownWords.size}`,
);
} catch (error) {
log.warn(
"Failed to refresh known-word cache:",
(error as Error).message,
);
this.showStatusNotification("AnkiConnect: unable to refresh known words");
} finally {
this.isRefreshingKnownWords = false;
}
}
private getKnownWordDecks(): string[] {
const configuredDecks = this.config.nPlusOne?.decks;
if (Array.isArray(configuredDecks)) {
const decks = configuredDecks
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return [...new Set(decks)];
}
const deck = this.config.deck?.trim();
return deck ? [deck] : [];
}
private buildKnownWordsQuery(): string {
const decks = this.getKnownWordDecks();
if (decks.length === 0) {
return "is:note";
}
if (decks.length === 1) {
return `deck:"${escapeAnkiSearchValue(decks[0])}"`;
}
const deckQueries = decks.map(
(deck) => `deck:"${escapeAnkiSearchValue(deck)}"`,
);
return `(${deckQueries.join(" OR ")})`;
}
private getKnownWordCacheScope(): string {
const decks = this.getKnownWordDecks();
if (decks.length === 0) {
return "is:note";
}
return `decks:${JSON.stringify(decks)}`;
}
private isKnownWordCacheStale(): boolean {
if (!this.isKnownWordCacheEnabled()) {
return true;
}
if (this.knownWordsScope !== this.getKnownWordCacheScope()) {
return true;
}
if (this.knownWordsLastRefreshedAtMs <= 0) {
return true;
}
return (
Date.now() - this.knownWordsLastRefreshedAtMs >=
this.getKnownWordRefreshIntervalMs()
);
}
private loadKnownWordCacheState(): void {
try {
if (!fs.existsSync(this.knownWordCacheStatePath)) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
return;
}
const raw = fs.readFileSync(this.knownWordCacheStatePath, "utf-8");
if (!raw.trim()) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
return;
}
const parsed = JSON.parse(raw) as unknown;
if (!this.isKnownWordCacheStateValid(parsed)) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
return;
}
if (parsed.scope !== this.getKnownWordCacheScope()) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
return;
}
const nextKnownWords = new Set<string>();
for (const value of parsed.words) {
const normalized = this.normalizeKnownWordForLookup(value);
if (normalized) {
nextKnownWords.add(normalized);
}
}
this.knownWords = nextKnownWords;
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsScope = parsed.scope;
} catch (error) {
log.warn(
"Failed to load known-word cache state:",
(error as Error).message,
);
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
}
}
private persistKnownWordCacheState(): void {
try {
const state: KnownWordCacheState = {
version: 1,
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
scope: this.knownWordsScope,
words: Array.from(this.knownWords),
};
fs.writeFileSync(this.knownWordCacheStatePath, JSON.stringify(state), "utf-8");
} catch (error) {
log.warn(
"Failed to persist known-word cache state:",
(error as Error).message,
);
}
}
private clearKnownWordCacheState(): void {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
try {
if (fs.existsSync(this.knownWordCacheStatePath)) {
fs.unlinkSync(this.knownWordCacheStatePath);
}
} catch (error) {
log.warn("Failed to clear known-word cache state:", (error as Error).message);
}
}
private isKnownWordCacheStateValid(
value: unknown,
): value is KnownWordCacheState {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<KnownWordCacheState>;
if (candidate.version !== 1) return false;
if (typeof candidate.refreshedAtMs !== "number") return false;
if (typeof candidate.scope !== "string") return false;
if (!Array.isArray(candidate.words)) return false;
if (!candidate.words.every((entry) => typeof entry === "string")) {
return false;
}
return true;
}
private extractKnownWordsFromNoteInfo(noteInfo: NoteInfo): string[] {
const words: string[] = [];
const preferredFields = ["Expression", "Word"];
for (const preferredField of preferredFields) {
const fieldName = this.resolveFieldName(
Object.keys(noteInfo.fields),
preferredField,
);
if (!fieldName) continue;
const raw = noteInfo.fields[fieldName]?.value;
if (!raw) continue;
const extracted = this.normalizeRawKnownWordValue(raw);
if (extracted) {
words.push(extracted);
}
}
return words;
}
private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void {
if (!this.isKnownWordCacheEnabled()) {
return;
}
const currentScope = this.getKnownWordCacheScope();
if (this.knownWordsScope && this.knownWordsScope !== currentScope) {
this.clearKnownWordCacheState();
}
if (!this.knownWordsScope) {
this.knownWordsScope = currentScope;
}
let addedCount = 0;
for (const rawWord of this.extractKnownWordsFromNoteInfo(noteInfo)) {
const normalized = this.normalizeKnownWordForLookup(rawWord);
if (!normalized || this.knownWords.has(normalized)) {
continue;
}
this.knownWords.add(normalized);
addedCount += 1;
}
if (addedCount > 0) {
if (this.knownWordsLastRefreshedAtMs <= 0) {
this.knownWordsLastRefreshedAtMs = Date.now();
}
this.persistKnownWordCacheState();
log.info(
"Known-word cache updated in-session",
`added=${addedCount}`,
`scope=${currentScope}`,
);
}
}
private normalizeRawKnownWordValue(value: string): string {
return value
.replace(/<[^>]*>/g, "")
.replace(/\u3000/g, " ")
.trim();
}
private normalizeKnownWordForLookup(value: string): string {
return this.normalizeRawKnownWordValue(value).toLowerCase();
}
private getLapisConfig(): {
@@ -201,6 +568,7 @@ export class AnkiIntegration {
"Starting AnkiConnect integration with polling rate:",
this.config.pollingRate,
);
this.startKnownWordCacheLifecycle();
this.poll();
}
@@ -209,6 +577,7 @@ export class AnkiIntegration {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
this.stopKnownWordCacheLifecycle();
log.info("Stopped AnkiConnect integration");
}
@@ -296,6 +665,7 @@ export class AnkiIntegration {
}
const noteInfo = notesInfo[0];
this.appendKnownWordsFromNoteInfo(noteInfo);
const fields = this.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || "";
@@ -624,61 +994,60 @@ export class AnkiIntegration {
}
private showStatusNotification(message: string): void {
const type = this.config.behavior?.notificationType || "osd";
if (type === "osd" || type === "both") {
this.showOsdNotification(message);
}
if ((type === "system" || type === "both") && this.notificationCallback) {
this.notificationCallback("SubMiner", { body: message });
}
showStatusNotification(message, {
getNotificationType: () => this.config.behavior?.notificationType,
showOsd: (text: string) => {
this.showOsdNotification(text);
},
showSystemNotification: (
title: string,
options: NotificationOptions,
) => {
if (this.notificationCallback) {
this.notificationCallback(title, options);
}
},
});
}
private beginUpdateProgress(initialMessage: string): void {
this.progressDepth += 1;
if (this.progressDepth > 1) return;
this.progressMessage = initialMessage;
this.progressFrame = 0;
this.showProgressTick();
this.progressTimer = setInterval(() => {
this.showProgressTick();
}, 180);
beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => {
this.showOsdNotification(text);
});
}
private endUpdateProgress(): void {
this.progressDepth = Math.max(0, this.progressDepth - 1);
if (this.progressDepth > 0) return;
if (this.progressTimer) {
clearInterval(this.progressTimer);
this.progressTimer = null;
}
this.progressMessage = "";
this.progressFrame = 0;
endUpdateProgress(this.uiFeedbackState, (timer) => {
clearInterval(timer);
});
}
private showProgressTick(): void {
if (!this.progressMessage) return;
const frames = ["|", "/", "-", "\\"];
const frame = frames[this.progressFrame % frames.length];
this.progressFrame += 1;
this.showOsdNotification(`${this.progressMessage} ${frame}`);
showProgressTick(
this.uiFeedbackState,
(text: string) => {
this.showOsdNotification(text);
},
);
}
private async withUpdateProgress<T>(
initialMessage: string,
action: () => Promise<T>,
): Promise<T> {
this.beginUpdateProgress(initialMessage);
this.updateInProgress = true;
try {
return await action();
} finally {
this.updateInProgress = false;
this.endUpdateProgress();
}
return withUpdateProgress(
this.uiFeedbackState,
{
setUpdateInProgress: (value: boolean) => {
this.updateInProgress = value;
},
showOsdNotification: (text: string) => {
this.showOsdNotification(text);
},
},
initialMessage,
action,
);
}
private showOsdNotification(text: string): void {
@@ -1436,33 +1805,16 @@ export class AnkiIntegration {
fields[sentenceField] = sentence;
const hasSecondarySub = Boolean(secondarySubText?.trim());
let backText = secondarySubText?.trim() || "";
const aiConfig = this.config.ai ?? DEFAULT_ANKI_CONNECT_CONFIG.ai;
const aiEnabled = aiConfig?.enabled === true;
const alwaysUseAiTranslation =
aiConfig?.alwaysUseAiTranslation === true;
const shouldAttemptAiTranslation =
aiEnabled && (alwaysUseAiTranslation || !hasSecondarySub);
if (shouldAttemptAiTranslation) {
const request: AiTranslateRequest = {
const backText = await resolveSentenceBackText(
{
sentence,
apiKey: aiConfig?.apiKey || "",
baseUrl: aiConfig?.baseUrl,
model: aiConfig?.model,
targetLanguage: aiConfig?.targetLanguage,
systemPrompt: aiConfig?.systemPrompt,
};
const callbacks: AiTranslateCallbacks = {
secondarySubText,
config: this.config.ai || {},
},
{
logWarning: (message: string) => log.warn(message),
};
const translated = await translateSentenceWithAi(request, callbacks);
if (translated) {
backText = translated;
} else if (!hasSecondarySub) {
backText = sentence;
}
}
},
);
if (backText) {
fields[translationField] = backText;
}
@@ -1498,6 +1850,7 @@ export class AnkiIntegration {
const noteInfos = noteInfoResult as unknown as NoteInfo[];
if (noteInfos.length > 0) {
const createdNoteInfo = noteInfos[0];
this.appendKnownWordsFromNoteInfo(createdNoteInfo);
resolvedSentenceAudioField =
this.resolveNoteFieldName(createdNoteInfo, audioFieldName) ||
audioFieldName;
@@ -1639,78 +1992,23 @@ export class AnkiIntegration {
excludeNoteId: number,
noteInfo: NoteInfo,
): Promise<number | null> {
let fieldName = "";
for (const name of Object.keys(noteInfo.fields)) {
if (
["word", "expression"].includes(name.toLowerCase()) &&
noteInfo.fields[name].value
) {
fieldName = name;
break;
}
}
if (!fieldName) return null;
const escapedFieldName = this.escapeAnkiSearchValue(fieldName);
const escapedExpression = this.escapeAnkiSearchValue(expression);
const deckPrefix = this.config.deck
? `"deck:${this.escapeAnkiSearchValue(this.config.deck)}" `
: "";
const query = `${deckPrefix}"${escapedFieldName}:${escapedExpression}"`;
try {
const noteIds = (await this.client.findNotes(query)) as number[];
return await this.findFirstExactDuplicateNoteId(
noteIds,
excludeNoteId,
fieldName,
expression,
);
} catch (error) {
log.warn("Duplicate search failed:", (error as Error).message);
return null;
}
}
private escapeAnkiSearchValue(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/([:*?()[\]{}])/g, "\\$1");
}
private normalizeDuplicateValue(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
private async findFirstExactDuplicateNoteId(
candidateNoteIds: number[],
excludeNoteId: number,
fieldName: string,
expression: string,
): Promise<number | null> {
const candidates = candidateNoteIds.filter((id) => id !== excludeNoteId);
if (candidates.length === 0) return null;
const normalizedExpression = this.normalizeDuplicateValue(expression);
const chunkSize = 50;
for (let i = 0; i < candidates.length; i += chunkSize) {
const chunk = candidates.slice(i, i + chunkSize);
const notesInfoResult = await this.client.notesInfo(chunk);
const notesInfo = notesInfoResult as unknown as NoteInfo[];
for (const noteInfo of notesInfo) {
const resolvedField = this.resolveNoteFieldName(noteInfo, fieldName);
if (!resolvedField) continue;
const candidateValue = noteInfo.fields[resolvedField]?.value || "";
if (
this.normalizeDuplicateValue(candidateValue) === normalizedExpression
) {
return noteInfo.noteId;
}
}
}
return null;
return findDuplicateNoteForAnkiIntegration(
expression,
excludeNoteId,
noteInfo,
{
findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as unknown,
notesInfo: async (noteIds) =>
(await this.client.notesInfo(noteIds)) as unknown,
getDeck: () => this.config.deck,
resolveFieldName: (info, preferredName) =>
this.resolveNoteFieldName(info, preferredName),
logWarn: (message, error) => {
log.warn(message, (error as Error).message);
},
},
);
}
private getGroupableFieldNames(): string[] {
@@ -2559,10 +2857,18 @@ export class AnkiIntegration {
}
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
const wasEnabled = this.config.nPlusOne?.highlightEnabled === true;
const previousPollingRate = this.config.pollingRate;
this.config = {
...this.config,
...patch,
nPlusOne:
patch.nPlusOne !== undefined
? {
...(this.config.nPlusOne ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne),
...patch.nPlusOne,
}
: this.config.nPlusOne,
fields:
patch.fields !== undefined
? { ...this.config.fields, ...patch.fields }
@@ -2589,6 +2895,21 @@ export class AnkiIntegration {
: this.config.isKiku,
};
if (
wasEnabled &&
this.config.nPlusOne?.highlightEnabled === false
) {
this.stopKnownWordCacheLifecycle();
this.clearKnownWordCacheState();
} else if (
!wasEnabled &&
this.config.nPlusOne?.highlightEnabled === true
) {
this.startKnownWordCacheLifecycle();
} else {
this.startKnownWordCacheLifecycle();
}
if (
patch.pollingRate !== undefined &&
previousPollingRate !== this.config.pollingRate &&
@@ -2605,3 +2926,10 @@ export class AnkiIntegration {
this.mediaGenerator.cleanup();
}
}
function escapeAnkiSearchValue(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/([:*?()[\]{}])/g, "\\$1");
}

View File

@@ -51,6 +51,24 @@ export interface AiTranslateCallbacks {
logWarning: (message: string) => void;
}
export interface AiSentenceTranslationInput {
sentence: string;
secondarySubText?: string;
config: {
apiKey?: string;
baseUrl?: string;
model?: string;
targetLanguage?: string;
systemPrompt?: string;
enabled?: boolean;
alwaysUseAiTranslation?: boolean;
};
}
export interface AiSentenceTranslationCallbacks {
logWarning: (message: string) => void;
}
export async function translateSentenceWithAi(
request: AiTranslateRequest,
callbacks: AiTranslateCallbacks,
@@ -101,3 +119,40 @@ export async function translateSentenceWithAi(
return null;
}
}
export async function resolveSentenceBackText(
input: AiSentenceTranslationInput,
callbacks: AiSentenceTranslationCallbacks,
): Promise<string> {
const hasSecondarySub = Boolean(input.secondarySubText?.trim());
let backText = input.secondarySubText?.trim() || "";
const aiConfig = {
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
...input.config,
};
const shouldAttemptAiTranslation =
aiConfig.enabled === true &&
(aiConfig.alwaysUseAiTranslation === true || !hasSecondarySub);
if (!shouldAttemptAiTranslation) return backText;
const request: AiTranslateRequest = {
sentence: input.sentence,
apiKey: aiConfig.apiKey ?? "",
baseUrl: aiConfig.baseUrl,
model: aiConfig.model,
targetLanguage: aiConfig.targetLanguage,
systemPrompt: aiConfig.systemPrompt,
};
const translated = await translateSentenceWithAi(request, {
logWarning: (message) => callbacks.logWarning(message),
});
if (translated) {
return translated;
}
return hasSecondarySub ? backText : input.sentence;
}

View File

@@ -69,7 +69,198 @@ test("parses invisible overlay config and new global shortcuts", () => {
test("runtime options registry is centralized", () => {
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
assert.deepEqual(ids, ["anki.autoUpdateNewCards", "anki.kikuFieldGrouping"]);
assert.deepEqual(ids, [
"anki.autoUpdateNewCards",
"anki.nPlusOneMatchMode",
"anki.kikuFieldGrouping",
]);
});
test("validates ankiConnect n+1 behavior values", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": "yes",
"refreshMinutes": -5
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(
config.ankiConnect.nPlusOne.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
);
assert.equal(
config.ankiConnect.nPlusOne.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
);
assert.ok(
warnings.some(
(warning) => warning.path === "ankiConnect.nPlusOne.highlightEnabled",
),
);
assert.ok(
warnings.some(
(warning) => warning.path === "ankiConnect.nPlusOne.refreshMinutes",
),
);
});
test("accepts valid ankiConnect n+1 behavior values", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": true,
"refreshMinutes": 120
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 120);
});
test("validates ankiConnect n+1 match mode values", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"matchMode": "bad-mode"
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(
config.ankiConnect.nPlusOne.matchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
);
assert.ok(
warnings.some((warning) =>
warning.path === "ankiConnect.nPlusOne.matchMode",
),
);
});
test("accepts valid ankiConnect n+1 match mode values", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"matchMode": "surface"
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.matchMode, "surface");
});
test("supports legacy ankiConnect.behavior N+1 settings as fallback", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"behavior": {
"nPlusOneHighlightEnabled": true,
"nPlusOneRefreshMinutes": 90,
"nPlusOneMatchMode": "surface"
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 90);
assert.equal(config.ankiConnect.nPlusOne.matchMode, "surface");
assert.ok(
warnings.some(
(warning) =>
warning.path === "ankiConnect.behavior.nPlusOneHighlightEnabled" ||
warning.path === "ankiConnect.behavior.nPlusOneRefreshMinutes" ||
warning.path === "ankiConnect.behavior.nPlusOneMatchMode",
),
);
});
test("accepts valid ankiConnect n+1 deck list", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"decks": ["Deck One", "Deck Two"]
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.ankiConnect.nPlusOne.decks, ["Deck One", "Deck Two"]);
});
test("falls back to default when ankiConnect n+1 deck list is invalid", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"ankiConnect": {
"nPlusOne": {
"decks": "not-an-array"
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(config.ankiConnect.nPlusOne.decks, []);
assert.ok(
warnings.some((warning) => warning.path === "ankiConnect.nPlusOne.decks"),
);
});
test("template generator includes known keys", () => {

View File

@@ -123,6 +123,12 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
notificationType: "osd",
autoUpdateNewCards: true,
},
nPlusOne: {
highlightEnabled: false,
refreshMinutes: 1440,
matchMode: "headword",
decks: [],
},
metadata: {
pattern: "[SubMiner] %f (%t)",
},
@@ -218,6 +224,23 @@ export const RUNTIME_OPTION_REGISTRY: RuntimeOptionRegistryEntry[] = [
behavior: { autoUpdateNewCards: value === true },
}),
},
{
id: "anki.nPlusOneMatchMode",
path: "ankiConnect.nPlusOne.matchMode",
label: "N+1 Match Mode",
scope: "ankiConnect",
valueType: "enum",
allowedValues: ["headword", "surface"],
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
requiresRestart: false,
formatValueForOsd: (value) => String(value),
toAnkiPatch: (value) => ({
nPlusOne: {
matchMode:
value === "headword" || value === "surface" ? value : "headword",
},
}),
},
{
id: "anki.kikuFieldGrouping",
path: "ankiConnect.isKiku.fieldGrouping",
@@ -272,6 +295,32 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
description: "Automatically update newly added cards.",
runtime: RUNTIME_OPTION_REGISTRY[0],
},
{
path: "ankiConnect.nPlusOne.matchMode",
kind: "enum",
enumValues: ["headword", "surface"],
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
description: "Known-word matching strategy for N+1 highlighting.",
},
{
path: "ankiConnect.nPlusOne.highlightEnabled",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
description: "Enable fast local highlighting for words already known in Anki.",
},
{
path: "ankiConnect.nPlusOne.refreshMinutes",
kind: "number",
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
description: "Minutes between known-word cache refreshes.",
},
{
path: "ankiConnect.nPlusOne.decks",
kind: "array",
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.decks,
description:
"Decks used for N+1 known-word cache scope. Supports one or more deck names.",
},
{
path: "ankiConnect.isKiku.fieldGrouping",
kind: "enum",

View File

@@ -437,6 +437,9 @@ export class ConfigService {
if (isObject(src.ankiConnect)) {
const ac = src.ankiConnect;
const behavior = isObject(ac.behavior)
? (ac.behavior as Record<string, unknown>)
: {};
const aiSource = isObject(ac.ai)
? ac.ai
: isObject(ac.openRouter)
@@ -580,6 +583,159 @@ export class ConfigService {
resolved.ankiConnect.behavior.autoUpdateNewCards = value as boolean;
});
const nPlusOneConfig = isObject(ac.nPlusOne)
? (ac.nPlusOne as Record<string, unknown>)
: {};
const nPlusOneHighlightEnabled = asBoolean(
nPlusOneConfig.highlightEnabled,
);
if (nPlusOneHighlightEnabled !== undefined) {
resolved.ankiConnect.nPlusOne.highlightEnabled =
nPlusOneHighlightEnabled;
} else {
const legacyNPlusOneHighlightEnabled = asBoolean(
behavior.nPlusOneHighlightEnabled,
);
if (legacyNPlusOneHighlightEnabled !== undefined) {
resolved.ankiConnect.nPlusOne.highlightEnabled =
legacyNPlusOneHighlightEnabled;
warn(
"ankiConnect.behavior.nPlusOneHighlightEnabled",
behavior.nPlusOneHighlightEnabled,
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
"Legacy key is deprecated; use ankiConnect.nPlusOne.highlightEnabled",
);
} else {
resolved.ankiConnect.nPlusOne.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled;
}
}
const nPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
const hasValidNPlusOneRefreshMinutes =
nPlusOneRefreshMinutes !== undefined &&
Number.isInteger(nPlusOneRefreshMinutes) &&
nPlusOneRefreshMinutes > 0;
if (nPlusOneRefreshMinutes !== undefined) {
if (hasValidNPlusOneRefreshMinutes) {
resolved.ankiConnect.nPlusOne.refreshMinutes =
nPlusOneRefreshMinutes;
} else {
warn(
"ankiConnect.nPlusOne.refreshMinutes",
nPlusOneConfig.refreshMinutes,
resolved.ankiConnect.nPlusOne.refreshMinutes,
"Expected a positive integer.",
);
resolved.ankiConnect.nPlusOne.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
}
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
const legacyNPlusOneRefreshMinutes = asNumber(
behavior.nPlusOneRefreshMinutes,
);
const hasValidLegacyRefreshMinutes =
legacyNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
legacyNPlusOneRefreshMinutes > 0;
if (hasValidLegacyRefreshMinutes) {
resolved.ankiConnect.nPlusOne.refreshMinutes =
legacyNPlusOneRefreshMinutes;
warn(
"ankiConnect.behavior.nPlusOneRefreshMinutes",
behavior.nPlusOneRefreshMinutes,
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
"Legacy key is deprecated; use ankiConnect.nPlusOne.refreshMinutes",
);
} else {
warn(
"ankiConnect.behavior.nPlusOneRefreshMinutes",
behavior.nPlusOneRefreshMinutes,
resolved.ankiConnect.nPlusOne.refreshMinutes,
"Expected a positive integer.",
);
resolved.ankiConnect.nPlusOne.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
}
} else {
resolved.ankiConnect.nPlusOne.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
}
const nPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
const legacyNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
const hasValidNPlusOneMatchMode =
nPlusOneMatchMode === "headword" || nPlusOneMatchMode === "surface";
const hasValidLegacyMatchMode =
legacyNPlusOneMatchMode === "headword" ||
legacyNPlusOneMatchMode === "surface";
if (hasValidNPlusOneMatchMode) {
resolved.ankiConnect.nPlusOne.matchMode = nPlusOneMatchMode;
} else if (nPlusOneMatchMode !== undefined) {
warn(
"ankiConnect.nPlusOne.matchMode",
nPlusOneConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
"Expected 'headword' or 'surface'.",
);
resolved.ankiConnect.nPlusOne.matchMode =
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
} else if (legacyNPlusOneMatchMode !== undefined) {
if (hasValidLegacyMatchMode) {
resolved.ankiConnect.nPlusOne.matchMode =
legacyNPlusOneMatchMode;
warn(
"ankiConnect.behavior.nPlusOneMatchMode",
behavior.nPlusOneMatchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
"Legacy key is deprecated; use ankiConnect.nPlusOne.matchMode",
);
} else {
warn(
"ankiConnect.behavior.nPlusOneMatchMode",
behavior.nPlusOneMatchMode,
resolved.ankiConnect.nPlusOne.matchMode,
"Expected 'headword' or 'surface'.",
);
resolved.ankiConnect.nPlusOne.matchMode =
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
}
} else {
resolved.ankiConnect.nPlusOne.matchMode =
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
}
const nPlusOneDecks = nPlusOneConfig.decks;
if (Array.isArray(nPlusOneDecks)) {
const normalizedDecks = nPlusOneDecks
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (normalizedDecks.length === nPlusOneDecks.length) {
resolved.ankiConnect.nPlusOne.decks = [
...new Set(normalizedDecks),
];
} else if (nPlusOneDecks.length > 0) {
warn(
"ankiConnect.nPlusOne.decks",
nPlusOneDecks,
resolved.ankiConnect.nPlusOne.decks,
"Expected an array of strings.",
);
} else {
resolved.ankiConnect.nPlusOne.decks = [];
}
} else if (nPlusOneDecks !== undefined) {
warn(
"ankiConnect.nPlusOne.decks",
nPlusOneDecks,
resolved.ankiConnect.nPlusOne.decks,
"Expected an array of strings.",
);
}
if (
resolved.ankiConnect.isKiku.fieldGrouping !== "auto" &&
resolved.ankiConnect.isKiku.fieldGrouping !== "manual" &&

View File

@@ -45,6 +45,8 @@ function createHarness(): RuntimeHarness {
setAnkiIntegration: (integration) => {
state.ankiIntegration = integration;
},
getKnownWordCacheStatePath: () =>
"/tmp/subminer-known-words-cache.json",
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,

View File

@@ -33,6 +33,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
getMpvClient: () => MpvClientLike | null;
getAnkiIntegration: () => AnkiIntegration | null;
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
getKnownWordCacheStatePath: () => string;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
@@ -87,6 +88,7 @@ export function registerAnkiJimakuIpcRuntimeService(
},
options.showDesktopNotification,
options.createFieldGroupingCallback(),
options.getKnownWordCacheStatePath(),
);
integration.start();
options.setAnkiIntegration(integration);

View File

@@ -35,6 +35,7 @@ export function initializeOverlayRuntimeService(options: {
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
}): {
invisibleOverlayVisible: boolean;
} {
@@ -98,6 +99,7 @@ export function initializeOverlayRuntimeService(options: {
},
options.showDesktopNotification,
options.createFieldGroupingCallback(),
options.getKnownWordCacheStatePath(),
);
integration.start();
options.setAnkiIntegration(integration);

View File

@@ -14,6 +14,8 @@ function makeDeps(
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
getKnownWordMatchMode: () => "headword",
tokenizeWithMecab: async () => null,
...overrides,
};
@@ -32,7 +34,7 @@ test("tokenizeSubtitleService normalizes newlines before mecab fallback", async
tokenizeWithMecab: async (text) => {
tokenizeInput = text;
return [
{
{
surface: "猫ですね",
reading: "ネコデスネ",
headword: "猫ですね",
@@ -40,6 +42,7 @@ test("tokenizeSubtitleService normalizes newlines before mecab fallback", async
endPos: 4,
partOfSpeech: PartOfSpeech.other,
isMerged: true,
isKnown: false,
},
];
},
@@ -64,6 +67,7 @@ test("tokenizeSubtitleService falls back to mecab tokens when available", async
endPos: 1,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
},
],
}),
@@ -126,4 +130,78 @@ test("tokenizeSubtitleService uses Yomitan parser result when available", async
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.surface, "猫です");
assert.equal(result.tokens?.[0]?.reading, "ねこです");
assert.equal(result.tokens?.[0]?.isKnown, false);
});
test("tokenizeSubtitleService marks tokens as known using callback", async () => {
const result = await tokenizeSubtitleService(
"猫です",
makeDeps({
isKnownWord: (text) => text === "猫",
tokenizeWithMecab: async () => [
{
surface: "猫",
reading: "ネコ",
headword: "猫",
startPos: 0,
endPos: 1,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
},
],
}),
);
assert.equal(result.text, "猫です");
assert.equal(result.tokens?.[0]?.isKnown, true);
});
test("tokenizeSubtitleService checks known words by headword, not surface", async () => {
const result = await tokenizeSubtitleService(
"猫です",
makeDeps({
isKnownWord: (text) => text === "猫です",
tokenizeWithMecab: async () => [
{
surface: "猫",
reading: "ネコ",
headword: "猫です",
startPos: 0,
endPos: 1,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
},
],
}),
);
assert.equal(result.text, "猫です");
assert.equal(result.tokens?.[0]?.isKnown, true);
});
test("tokenizeSubtitleService checks known words by surface when configured", async () => {
const result = await tokenizeSubtitleService(
"猫です",
makeDeps({
getKnownWordMatchMode: () => "surface",
isKnownWord: (text) => text === "猫",
tokenizeWithMecab: async () => [
{
surface: "猫",
reading: "ネコ",
headword: "猫です",
startPos: 0,
endPos: 1,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
},
],
}),
);
assert.equal(result.text, "猫です");
assert.equal(result.tokens?.[0]?.isKnown, true);
});

View File

@@ -1,6 +1,12 @@
import { BrowserWindow, Extension, session } from "electron";
import { mergeTokens } from "../../token-merger";
import { MergedToken, PartOfSpeech, SubtitleData, Token } from "../../types";
import {
MergedToken,
NPlusOneMatchMode,
PartOfSpeech,
SubtitleData,
Token,
} from "../../types";
interface YomitanParseHeadword {
term?: unknown;
@@ -26,6 +32,8 @@ export interface TokenizerServiceDeps {
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
}
@@ -41,6 +49,8 @@ export interface TokenizerDepsRuntimeOptions {
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getMecabTokenizer: () => MecabTokenizerLike | null;
}
@@ -55,6 +65,8 @@ export function createTokenizerDepsRuntimeService(
setYomitanParserReadyPromise: options.setYomitanParserReadyPromise,
getYomitanParserInitPromise: options.getYomitanParserInitPromise,
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
isKnownWord: options.isKnownWord,
getKnownWordMatchMode: options.getKnownWordMatchMode,
tokenizeWithMecab: async (text) => {
const mecabTokenizer = options.getMecabTokenizer();
if (!mecabTokenizer) {
@@ -64,11 +76,23 @@ export function createTokenizerDepsRuntimeService(
if (!rawTokens || rawTokens.length === 0) {
return null;
}
return mergeTokens(rawTokens);
return mergeTokens(
rawTokens,
options.isKnownWord,
options.getKnownWordMatchMode(),
);
},
};
}
function resolveKnownWordText(
surface: string,
headword: string,
matchMode: NPlusOneMatchMode,
): string {
return matchMode === "surface" ? surface : headword;
}
function extractYomitanHeadword(segment: YomitanParseSegment): string {
const headwords = segment.headwords;
if (!Array.isArray(headwords) || headwords.length === 0) {
@@ -86,6 +110,8 @@ function extractYomitanHeadword(segment: YomitanParseSegment): string {
function mapYomitanParseResultsToMergedTokens(
parseResults: unknown,
isKnownWord: (text: string) => boolean,
knownWordMatchMode: NPlusOneMatchMode,
): MergedToken[] | null {
if (!Array.isArray(parseResults) || parseResults.length === 0) {
return null;
@@ -161,6 +187,14 @@ function mapYomitanParseResultsToMergedTokens(
endPos: end,
partOfSpeech: PartOfSpeech.other,
isMerged: true,
isKnown: (() => {
const matchText = resolveKnownWordText(
surface,
headword,
knownWordMatchMode,
);
return matchText ? isKnownWord(matchText) : false;
})(),
});
}
@@ -302,7 +336,11 @@ async function parseWithYomitanInternalParser(
script,
true,
);
return mapYomitanParseResultsToMergedTokens(parseResults);
return mapYomitanParseResultsToMergedTokens(
parseResults,
deps.isKnownWord,
deps.getKnownWordMatchMode(),
);
} catch (err) {
console.error("Yomitan parser request failed:", (err as Error).message);
return null;

View File

@@ -89,7 +89,6 @@ import {
applyMpvSubtitleRenderMetricsPatchService,
broadcastRuntimeOptionsChangedRuntimeService,
copyCurrentSubtitleService,
createAppLifecycleDepsRuntimeService,
createOverlayManagerService,
createFieldGroupingOverlayRuntimeService,
createNumericShortcutRuntimeService,
@@ -128,7 +127,6 @@ import {
shouldAutoInitializeOverlayRuntimeFromConfigService,
shouldBindVisibleOverlayToMpvSubVisibilityService,
showMpvOsdRuntimeService,
startAppLifecycleService,
syncInvisibleOverlayMousePassthroughService,
tokenizeSubtitleService,
triggerFieldGroupingService,
@@ -137,13 +135,9 @@ import {
updateLastCardFromClipboardService,
updateVisibleOverlayVisibilityService,
} from "./core/services";
import {
runAppReadyRuntimeService,
} from "./core/services/startup-service";
import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service";
import {
createAppLifecycleRuntimeDeps,
createAppReadyRuntimeDeps,
createAppReadyRuntimeRunner,
} from "./main/app-lifecycle";
import { handleMpvCommandFromIpcRuntime } from "./main/ipc-mpv-command";
import {
@@ -158,6 +152,7 @@ import {
import {
runSubsyncManualFromIpcRuntime,
triggerSubsyncFromConfigRuntime,
createSubsyncRuntimeServiceInputFromState,
} from "./main/subsync-runtime";
import {
createOverlayModalRuntimeService,
@@ -171,6 +166,7 @@ import {
createAppState,
} from "./main/state";
import { createStartupBootstrapRuntimeDeps } from "./main/startup";
import { createAppLifecycleRuntimeRunner } from "./main/startup-lifecycle";
import {
ConfigService,
DEFAULT_CONFIG,
@@ -562,130 +558,113 @@ const startupState = runStartupBootstrapRuntimeService(
process.exitCode = 1;
app.quit();
},
startAppLifecycle: (args: CliArgs) => {
startAppLifecycleService(
args,
createAppLifecycleDepsRuntimeService(
createAppLifecycleRuntimeDeps({
app,
platform: process.platform,
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv),
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: async () => {
await runAppReadyRuntimeService(
createAppReadyRuntimeDeps({
loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(
getResolvedConfig(),
DEFAULT_KEYBINDINGS,
);
},
createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService();
},
reloadConfig: () => {
configService.reloadConfig();
appLogger.logInfo(
`Using config file: ${configService.getConfigPath()}`,
);
},
getResolvedConfig: () => getResolvedConfig(),
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
initRuntimeOptionsManager: () => {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
onOptionsChanged: () => {
broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts();
},
},
);
},
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
},
defaultSecondarySubMode: "hover",
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port: number) => {
subtitleWsService.start(port, () => appState.currentSubText);
},
log: (message) => appLogger.logInfo(message),
createMecabTokenizerAndCheck: async () => {
const tokenizer = new MecabTokenizer();
appState.mecabTokenizer = tokenizer;
await tokenizer.checkAvailability();
},
createSubtitleTimingTracker: () => {
const tracker = new SubtitleTimingTracker();
appState.subtitleTimingTracker = tracker;
},
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
texthookerOnlyMode: appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
}),
);
startAppLifecycle: createAppLifecycleRuntimeRunner({
app,
platform: process.platform,
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv),
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: createAppReadyRuntimeRunner({
loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(
getResolvedConfig(),
DEFAULT_KEYBINDINGS,
);
},
createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService();
},
reloadConfig: () => {
configService.reloadConfig();
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`);
},
getResolvedConfig: () => getResolvedConfig(),
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
initRuntimeOptionsManager: () => {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
onOptionsChanged: () => {
broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts();
},
},
onWillQuitCleanup: () => {
restorePreviousSecondarySubVisibility();
globalShortcut.unregisterAll();
subtitleWsService.stop();
texthookerService.stop();
if (
appState.yomitanParserWindow &&
!appState.yomitanParserWindow.isDestroyed()
) {
appState.yomitanParserWindow.destroy();
}
appState.yomitanParserWindow = null;
appState.yomitanParserReadyPromise = null;
appState.yomitanParserInitPromise = null;
if (appState.windowTracker) {
appState.windowTracker.stop();
}
if (appState.mpvClient && appState.mpvClient.socket) {
appState.mpvClient.socket.destroy();
}
if (appState.reconnectTimer) {
clearTimeout(appState.reconnectTimer);
}
if (appState.subtitleTimingTracker) {
appState.subtitleTimingTracker.destroy();
}
if (appState.ankiIntegration) {
appState.ankiIntegration.destroy();
}
},
shouldRestoreWindowsOnActivate: () =>
appState.overlayRuntimeInitialized &&
BrowserWindow.getAllWindows().length === 0,
restoreWindowsOnActivate: () => {
createMainWindow();
createInvisibleWindow();
updateVisibleOverlayVisibility();
updateInvisibleOverlayVisibility();
},
}),
),
);
},
);
},
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
},
defaultSecondarySubMode: "hover",
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port: number) => {
subtitleWsService.start(port, () => appState.currentSubText);
},
log: (message) => appLogger.logInfo(message),
createMecabTokenizerAndCheck: async () => {
const tokenizer = new MecabTokenizer();
appState.mecabTokenizer = tokenizer;
await tokenizer.checkAvailability();
},
createSubtitleTimingTracker: () => {
const tracker = new SubtitleTimingTracker();
appState.subtitleTimingTracker = tracker;
},
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
texthookerOnlyMode: appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
}),
onWillQuitCleanup: () => {
restorePreviousSecondarySubVisibility();
globalShortcut.unregisterAll();
subtitleWsService.stop();
texthookerService.stop();
if (appState.yomitanParserWindow && !appState.yomitanParserWindow.isDestroyed()) {
appState.yomitanParserWindow.destroy();
}
appState.yomitanParserWindow = null;
appState.yomitanParserReadyPromise = null;
appState.yomitanParserInitPromise = null;
if (appState.windowTracker) {
appState.windowTracker.stop();
}
if (appState.mpvClient && appState.mpvClient.socket) {
appState.mpvClient.socket.destroy();
}
if (appState.reconnectTimer) {
clearTimeout(appState.reconnectTimer);
}
if (appState.subtitleTimingTracker) {
appState.subtitleTimingTracker.destroy();
}
if (appState.ankiIntegration) {
appState.ankiIntegration.destroy();
}
},
shouldRestoreWindowsOnActivate: () =>
appState.overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0,
restoreWindowsOnActivate: () => {
createMainWindow();
createInvisibleWindow();
updateVisibleOverlayVisibility();
updateInvisibleOverlayVisibility();
},
}),
}),
);
@@ -840,6 +819,11 @@ async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
setYomitanParserInitPromise: (promise) => {
appState.yomitanParserInitPromise = promise;
},
isKnownWord: (text) =>
Boolean(appState.ankiIntegration?.isKnownWord(text)),
getKnownWordMatchMode: () =>
appState.ankiIntegration?.getKnownWordMatchMode() ??
getResolvedConfig().ankiConnect.nPlusOne.matchMode,
getMecabTokenizer: () => appState.mecabTokenizer,
}),
);
@@ -972,6 +956,8 @@ function initializeOverlayRuntime(): void {
},
showDesktopNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () =>
path.join(USER_DATA_PATH, "known-words-cache.json"),
},
);
overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
@@ -1057,10 +1043,10 @@ const multiCopySession = numericShortcutRuntime.createSession();
const mineSentenceSession = numericShortcutRuntime.createSession();
function getSubsyncRuntimeServiceParams() {
return {
return createSubsyncRuntimeServiceInputFromState({
getMpvClient: () => appState.mpvClient,
getResolvedSubsyncConfig: () => getSubsyncConfig(getResolvedConfig().subsync),
isSubsyncInProgress: () => appState.subsyncInProgress,
getSubsyncInProgress: () => appState.subsyncInProgress,
setSubsyncInProgress: (inProgress: boolean) => {
appState.subsyncInProgress = inProgress;
},
@@ -1070,7 +1056,7 @@ function getSubsyncRuntimeServiceParams() {
restoreOnModalClose: "subsync",
});
},
};
});
}
async function triggerSubsyncFromConfig(): Promise<void> {
@@ -1336,7 +1322,7 @@ registerIpcRuntimeServices({
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => app.quit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: () => tokenizeCurrentSubtitle(appState.currentSubText),
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
getCurrentSubtitleAss: () => appState.currentSubAssText,
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
getSubtitlePosition: () => loadSubtitlePosition(),
@@ -1369,6 +1355,8 @@ registerIpcRuntimeServices({
setAnkiIntegration: (integration: AnkiIntegration | null) => {
appState.ankiIntegration = integration;
},
getKnownWordCacheStatePath: () =>
path.join(USER_DATA_PATH, "known-words-cache.json"),
showDesktopNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(),
broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),

View File

@@ -87,3 +87,11 @@ export function createAppReadyRuntimeDeps(
handleInitialArgs: params.handleInitialArgs,
};
}
export function createAppReadyRuntimeRunner(
params: AppReadyRuntimeDepsFactoryInput,
): () => Promise<void> {
return async () => {
await runAppReadyRuntimeService(createAppReadyRuntimeDeps(params));
};
}

View File

@@ -98,6 +98,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
getMpvClient: AnkiJimakuIpcRuntimeOptions["getMpvClient"];
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions["getAnkiIntegration"];
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions["setAnkiIntegration"];
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions["getKnownWordCacheStatePath"];
showDesktopNotification: AnkiJimakuIpcRuntimeOptions["showDesktopNotification"];
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions["createFieldGroupingCallback"];
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions["broadcastRuntimeOptionsChanged"];
@@ -224,6 +225,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
getMpvClient: params.getMpvClient,
getAnkiIntegration: params.getAnkiIntegration,
setAnkiIntegration: params.setAnkiIntegration,
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
showDesktopNotification: params.showDesktopNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback,
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,

View File

@@ -0,0 +1,44 @@
import { CliArgs, CliCommandSource } from "../cli/args";
import { createAppLifecycleDepsRuntimeService } from "../core/services";
import { startAppLifecycleService } from "../core/services/app-lifecycle-service";
import type { AppLifecycleDepsRuntimeOptions } from "../core/services/app-lifecycle-service";
import { createAppLifecycleRuntimeDeps } from "./app-lifecycle";
export interface AppLifecycleRuntimeRunnerParams {
app: AppLifecycleDepsRuntimeOptions["app"];
platform: NodeJS.Platform;
shouldStartApp: (args: CliArgs) => boolean;
parseArgs: (argv: string[]) => CliArgs;
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
}
export function createAppLifecycleRuntimeRunner(
params: AppLifecycleRuntimeRunnerParams,
): (args: CliArgs) => void {
return (args: CliArgs): void => {
startAppLifecycleService(
args,
createAppLifecycleDepsRuntimeService(
createAppLifecycleRuntimeDeps({
app: params.app,
platform: params.platform,
shouldStartApp: params.shouldStartApp,
parseArgs: params.parseArgs,
handleCliCommand: params.handleCliCommand,
printHelp: params.printHelp,
logNoRunningInstance: params.logNoRunningInstance,
onReady: params.onReady,
onWillQuitCleanup: params.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
restoreWindowsOnActivate: params.restoreWindowsOnActivate,
}),
),
);
};
}

View File

@@ -13,6 +13,28 @@ export interface SubsyncRuntimeServiceInput {
openManualPicker: (payload: SubsyncManualPayload) => void;
}
export interface SubsyncRuntimeServiceStateInput {
getMpvClient: SubsyncRuntimeServiceInput["getMpvClient"];
getResolvedSubsyncConfig: SubsyncRuntimeServiceInput["getResolvedSubsyncConfig"];
getSubsyncInProgress: () => boolean;
setSubsyncInProgress: SubsyncRuntimeServiceInput["setSubsyncInProgress"];
showMpvOsd: SubsyncRuntimeServiceInput["showMpvOsd"];
openManualPicker: SubsyncRuntimeServiceInput["openManualPicker"];
}
export function createSubsyncRuntimeServiceInputFromState(
input: SubsyncRuntimeServiceStateInput,
): SubsyncRuntimeServiceInput {
return {
getMpvClient: input.getMpvClient,
getResolvedSubsyncConfig: input.getResolvedSubsyncConfig,
isSubsyncInProgress: input.getSubsyncInProgress,
setSubsyncInProgress: input.setSubsyncInProgress,
showMpvOsd: input.showMpvOsd,
openManualPicker: input.openManualPicker,
};
}
export function createSubsyncRuntimeServiceDeps(
params: SubsyncRuntimeServiceInput,
): SubsyncRuntimeDeps {

View File

@@ -284,6 +284,11 @@ body.settings-modal-open #subtitleContainer {
position: relative;
}
#subtitleRoot .word.word-known {
color: #a6da95;
text-shadow: 0 0 6px rgba(166, 218, 149, 0.35);
}
#subtitleRoot .word:hover {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;

View File

@@ -26,7 +26,7 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
for (let i = 0; i < parts.length; i += 1) {
if (parts[i]) {
const span = document.createElement("span");
span.className = "word";
span.className = token.isKnown ? "word word-known" : "word";
span.textContent = parts[i];
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
@@ -40,7 +40,7 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
}
const span = document.createElement("span");
span.className = "word";
span.className = token.isKnown ? "word word-known" : "word";
span.textContent = surface;
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;

View File

@@ -179,7 +179,11 @@ export function shouldMerge(lastStandaloneToken: Token, token: Token): boolean {
return false;
}
export function mergeTokens(tokens: Token[]): MergedToken[] {
export function mergeTokens(
tokens: Token[],
isKnownWord: (text: string) => boolean = () => false,
knownWordMatchMode: "headword" | "surface" = "headword",
): MergedToken[] {
if (!tokens || tokens.length === 0) {
return [];
}
@@ -205,6 +209,13 @@ export function mergeTokens(tokens: Token[]): MergedToken[] {
if (shouldMergeToken && result.length > 0) {
const prev = result.pop()!;
const mergedHeadword = prev.headword;
const headwordForKnownMatch = (() => {
if (knownWordMatchMode === "surface") {
return prev.surface;
}
return mergedHeadword;
})();
result.push({
surface: prev.surface + token.word,
reading: prev.reading + tokenReading,
@@ -213,8 +224,17 @@ export function mergeTokens(tokens: Token[]): MergedToken[] {
endPos: end,
partOfSpeech: prev.partOfSpeech,
isMerged: true,
isKnown: headwordForKnownMatch
? isKnownWord(headwordForKnownMatch)
: false,
});
} else {
const headwordForKnownMatch = (() => {
if (knownWordMatchMode === "surface") {
return token.word;
}
return token.headword;
})();
result.push({
surface: token.word,
reading: tokenReading,
@@ -223,6 +243,9 @@ export function mergeTokens(tokens: Token[]): MergedToken[] {
endPos: end,
partOfSpeech: token.partOfSpeech,
isMerged: false,
isKnown: headwordForKnownMatch
? isKnownWord(headwordForKnownMatch)
: false,
});
}

View File

@@ -49,6 +49,7 @@ export interface MergedToken {
endPos: number;
partOfSpeech: PartOfSpeech;
isMerged: boolean;
isKnown: boolean;
}
export interface WindowGeometry {
@@ -150,7 +151,8 @@ export interface KikuMergePreviewResponse {
export type RuntimeOptionId =
| "anki.autoUpdateNewCards"
| "anki.kikuFieldGrouping";
| "anki.kikuFieldGrouping"
| "anki.nPlusOneMatchMode";
export type RuntimeOptionScope = "ankiConnect";
@@ -158,6 +160,8 @@ export type RuntimeOptionValueType = "boolean" | "enum";
export type RuntimeOptionValue = boolean | string;
export type NPlusOneMatchMode = "headword" | "surface";
export interface RuntimeOptionState {
id: RuntimeOptionId;
label: string;
@@ -221,14 +225,20 @@ export interface AnkiConnectConfig {
fallbackDuration?: number;
maxMediaDuration?: number;
};
behavior?: {
overwriteAudio?: boolean;
overwriteImage?: boolean;
mediaInsertMode?: "append" | "prepend";
highlightWord?: boolean;
notificationType?: "osd" | "system" | "both" | "none";
autoUpdateNewCards?: boolean;
nPlusOne?: {
highlightEnabled?: boolean;
refreshMinutes?: number;
matchMode?: NPlusOneMatchMode;
decks?: string[];
};
behavior?: {
overwriteAudio?: boolean;
overwriteImage?: boolean;
mediaInsertMode?: "append" | "prepend";
highlightWord?: boolean;
notificationType?: "osd" | "system" | "both" | "none";
autoUpdateNewCards?: boolean;
};
metadata?: {
pattern?: string;
};
@@ -363,6 +373,12 @@ export interface ResolvedConfig {
fallbackDuration: number;
maxMediaDuration: number;
};
nPlusOne: {
highlightEnabled: boolean;
refreshMinutes: number;
matchMode: NPlusOneMatchMode;
decks: string[];
};
behavior: {
overwriteAudio: boolean;
overwriteImage: boolean;