mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor: split startup lifecycle and Anki service architecture
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
<!-- BACKLOG.MD MCP GUIDELINES START -->
|
<!-- BACKLOG.MD MCP GUIDELINES START -->
|
||||||
|
|
||||||
<CRITICAL_INSTRUCTION>
|
<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
|
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
|
||||||
|
|
||||||
These guides cover:
|
These guides cover:
|
||||||
|
|
||||||
- Decision framework for when to create tasks
|
- Decision framework for when to create tasks
|
||||||
- Search-first workflow to avoid duplicates
|
- Search-first workflow to avoid duplicates
|
||||||
- Links to detailed guides for task creation, execution, and finalization
|
- Links to detailed guides for task creation, execution, and finalization
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ id: TASK-24
|
|||||||
title: >-
|
title: >-
|
||||||
Add N+1 word highlighting using Anki-known-word cache with initial sync and
|
Add N+1 word highlighting using Anki-known-word cache with initial sync and
|
||||||
periodic refresh
|
periodic refresh
|
||||||
status: To Do
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-13 16:45'
|
created_date: '2026-02-13 16:45'
|
||||||
|
updated_date: '2026-02-15 04:48'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
id: TASK-27.2
|
id: TASK-27.2
|
||||||
title: Split main.ts into composition-root modules
|
title: Split main.ts into composition-root modules
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee:
|
assignee:
|
||||||
- backend
|
- backend
|
||||||
created_date: '2026-02-13 17:13'
|
created_date: '2026-02-13 17:13'
|
||||||
updated_date: '2026-02-15 00:43'
|
updated_date: '2026-02-15 01:25'
|
||||||
labels:
|
labels:
|
||||||
- 'owner:backend'
|
- 'owner:backend'
|
||||||
- 'owner:architect'
|
- 'owner:architect'
|
||||||
@@ -28,12 +28,12 @@ Reduce main.ts complexity by extracting bootstrap, lifecycle, overlay, IPC, and
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Create modules under src/main/ for bootstrap/lifecycle/ipc/overlay/cli concerns.
|
- [x] #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] #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.
|
- [ ] #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.
|
- [x] #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.
|
- [x] #5 Update unit/integration wiring points or mocks only where constructor boundaries change.
|
||||||
- [ ] #6 Add a migration note in docs/structure-roadmap.md.
|
- [x] #6 Add a migration note in docs/structure-roadmap.md.
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
## 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`.
|
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.
|
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 -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
## Final Summary
|
## Final Summary
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
id: TASK-27.3
|
id: TASK-27.3
|
||||||
title: Refactor anki-integration.ts into domain-specific service modules
|
title: Refactor anki-integration.ts into domain-specific service modules
|
||||||
status: To Do
|
status: Done
|
||||||
assignee:
|
assignee:
|
||||||
- backend
|
- backend
|
||||||
created_date: '2026-02-13 17:13'
|
created_date: '2026-02-13 17:13'
|
||||||
updated_date: '2026-02-13 21:13'
|
updated_date: '2026-02-15 04:23'
|
||||||
labels:
|
labels:
|
||||||
- 'owner:backend'
|
- 'owner:backend'
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -64,4 +64,10 @@ This task is self-contained — anki-integration.ts is a single class with a cle
|
|||||||
|
|
||||||
## Key Risk
|
## 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).
|
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 -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-7
|
id: TASK-7
|
||||||
title: Extract main.ts global state into an AppState container
|
title: Extract main.ts global state into an AppState container
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-11 08:20'
|
created_date: '2026-02-11 08:20'
|
||||||
updated_date: '2026-02-14 23:59'
|
updated_date: '2026-02-15 04:30'
|
||||||
labels:
|
labels:
|
||||||
- refactor
|
- refactor
|
||||||
- main
|
- main
|
||||||
@@ -28,11 +28,11 @@ Consolidate into a typed AppState object (or small set of domain-specific state
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 All mutable state consolidated into typed container(s)
|
- [x] #1 All mutable state consolidated into typed container(s)
|
||||||
- [ ] #2 No bare `let` declarations at module scope for application state
|
- [x] #2 No bare `let` declarations at module scope for application state
|
||||||
- [ ] #3 State access goes through the container rather than closures
|
- [x] #3 State access goes through the container rather than closures
|
||||||
- [ ] #4 Dependency objects for services shrink significantly (reference the container instead)
|
- [ ] #4 Dependency objects for services shrink significantly (reference the container instead)
|
||||||
- [ ] #5 TypeScript compiles cleanly
|
- [x] #5 TypeScript compiles cleanly
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
## 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.
|
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.
|
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 -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -79,6 +79,12 @@
|
|||||||
"overwriteImage": true,
|
"overwriteImage": true,
|
||||||
"mediaInsertMode": "append",
|
"mediaInsertMode": "append",
|
||||||
"highlightWord": true,
|
"highlightWord": true,
|
||||||
|
"nPlusOneHighlightEnabled": false,
|
||||||
|
"nPlusOneRefreshMinutes": 1440,
|
||||||
|
"nPlusOne": {
|
||||||
|
"decks": []
|
||||||
|
},
|
||||||
|
"nPlusOneMatchMode": "headword",
|
||||||
"notificationType": "osd",
|
"notificationType": "osd",
|
||||||
"autoUpdateNewCards": true
|
"autoUpdateNewCards": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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`) |
|
| `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`) |
|
| `pollingRate` | number (ms) | How often to check for new cards (default: `3000`) |
|
||||||
| `deck` | string | Anki deck to monitor for new cards |
|
| `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.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
| `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.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.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`) |
|
| `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.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
| `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 |
|
| `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.
|
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;">
|
<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" />
|
<source :src="'/assets/kiku-integration.webm'" type="video/webm" />
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
|
|||||||
@@ -82,6 +82,12 @@
|
|||||||
"notificationType": "osd",
|
"notificationType": "osd",
|
||||||
"autoUpdateNewCards": true
|
"autoUpdateNewCards": true
|
||||||
},
|
},
|
||||||
|
"nPlusOne": {
|
||||||
|
"highlightEnabled": false,
|
||||||
|
"refreshMinutes": 1440,
|
||||||
|
"matchMode": "headword",
|
||||||
|
"decks": []
|
||||||
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)"
|
"pattern": "[SubMiner] %f (%t)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ Adopted sequence (from TASK-27 parent):
|
|||||||
- Mitigation: preserve service construction order and keep existing event registration boundaries
|
- Mitigation: preserve service construction order and keep existing event registration boundaries
|
||||||
|
|
||||||
Migration note:
|
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.
|
- 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.
|
- 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`.
|
- 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`.
|
||||||
|
|||||||
102
src/anki-integration-duplicate.ts
Normal file
102
src/anki-integration-duplicate.ts
Normal 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");
|
||||||
|
}
|
||||||
107
src/anki-integration-ui-feedback.ts
Normal file
107
src/anki-integration-ui-feedback.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { AnkiConnectClient } from "./anki-connect";
|
|||||||
import { SubtitleTimingTracker } from "./subtitle-timing-tracker";
|
import { SubtitleTimingTracker } from "./subtitle-timing-tracker";
|
||||||
import { MediaGenerator } from "./media-generator";
|
import { MediaGenerator } from "./media-generator";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
import * as fs from "fs";
|
||||||
import {
|
import {
|
||||||
AnkiConnectConfig,
|
AnkiConnectConfig,
|
||||||
KikuDuplicateCardInfo,
|
KikuDuplicateCardInfo,
|
||||||
@@ -27,14 +28,23 @@ import {
|
|||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
MpvClient,
|
MpvClient,
|
||||||
NotificationOptions,
|
NotificationOptions,
|
||||||
|
NPlusOneMatchMode,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from "./config";
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from "./config";
|
||||||
import { createLogger } from "./logger";
|
import { createLogger } from "./logger";
|
||||||
import {
|
import {
|
||||||
AiTranslateCallbacks,
|
createUiFeedbackState,
|
||||||
AiTranslateRequest,
|
beginUpdateProgress,
|
||||||
translateSentenceWithAi,
|
endUpdateProgress,
|
||||||
|
showProgressTick,
|
||||||
|
showStatusNotification,
|
||||||
|
withUpdateProgress,
|
||||||
|
UiFeedbackState,
|
||||||
|
} from "./anki-integration-ui-feedback";
|
||||||
|
import {
|
||||||
|
resolveSentenceBackText,
|
||||||
} from "./anki-integration/ai";
|
} from "./anki-integration/ai";
|
||||||
|
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from "./anki-integration-duplicate";
|
||||||
|
|
||||||
const log = createLogger("anki").child("integration");
|
const log = createLogger("anki").child("integration");
|
||||||
|
|
||||||
@@ -43,6 +53,13 @@ interface NoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface KnownWordCacheState {
|
||||||
|
readonly version: 1;
|
||||||
|
readonly refreshedAtMs: number;
|
||||||
|
readonly scope: string;
|
||||||
|
readonly words: string[];
|
||||||
|
}
|
||||||
|
|
||||||
type CardKind = "sentence" | "audio";
|
type CardKind = "sentence" | "audio";
|
||||||
|
|
||||||
export class AnkiIntegration {
|
export class AnkiIntegration {
|
||||||
@@ -62,10 +79,7 @@ export class AnkiIntegration {
|
|||||||
| ((title: string, options: NotificationOptions) => void)
|
| ((title: string, options: NotificationOptions) => void)
|
||||||
| null = null;
|
| null = null;
|
||||||
private updateInProgress = false;
|
private updateInProgress = false;
|
||||||
private progressDepth = 0;
|
private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
|
||||||
private progressTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private progressMessage = "";
|
|
||||||
private progressFrame = 0;
|
|
||||||
private parseWarningKeys = new Set<string>();
|
private parseWarningKeys = new Set<string>();
|
||||||
private readonly strictGroupingFieldDefaults = new Set<string>([
|
private readonly strictGroupingFieldDefaults = new Set<string>([
|
||||||
"picture",
|
"picture",
|
||||||
@@ -80,6 +94,12 @@ export class AnkiIntegration {
|
|||||||
duplicate: KikuDuplicateCardInfo;
|
duplicate: KikuDuplicateCardInfo;
|
||||||
}) => Promise<KikuFieldGroupingChoice>)
|
}) => Promise<KikuFieldGroupingChoice>)
|
||||||
| null = null;
|
| 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(
|
constructor(
|
||||||
config: AnkiConnectConfig,
|
config: AnkiConnectConfig,
|
||||||
@@ -94,6 +114,7 @@ export class AnkiIntegration {
|
|||||||
original: KikuDuplicateCardInfo;
|
original: KikuDuplicateCardInfo;
|
||||||
duplicate: KikuDuplicateCardInfo;
|
duplicate: KikuDuplicateCardInfo;
|
||||||
}) => Promise<KikuFieldGroupingChoice>,
|
}) => Promise<KikuFieldGroupingChoice>,
|
||||||
|
knownWordCacheStatePath?: string,
|
||||||
) {
|
) {
|
||||||
this.config = {
|
this.config = {
|
||||||
...DEFAULT_ANKI_CONNECT_CONFIG,
|
...DEFAULT_ANKI_CONNECT_CONFIG,
|
||||||
@@ -136,6 +157,352 @@ export class AnkiIntegration {
|
|||||||
this.osdCallback = osdCallback || null;
|
this.osdCallback = osdCallback || null;
|
||||||
this.notificationCallback = notificationCallback || null;
|
this.notificationCallback = notificationCallback || null;
|
||||||
this.fieldGroupingCallback = fieldGroupingCallback || 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(): {
|
private getLapisConfig(): {
|
||||||
@@ -201,6 +568,7 @@ export class AnkiIntegration {
|
|||||||
"Starting AnkiConnect integration with polling rate:",
|
"Starting AnkiConnect integration with polling rate:",
|
||||||
this.config.pollingRate,
|
this.config.pollingRate,
|
||||||
);
|
);
|
||||||
|
this.startKnownWordCacheLifecycle();
|
||||||
this.poll();
|
this.poll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +577,7 @@ export class AnkiIntegration {
|
|||||||
clearInterval(this.pollingInterval);
|
clearInterval(this.pollingInterval);
|
||||||
this.pollingInterval = null;
|
this.pollingInterval = null;
|
||||||
}
|
}
|
||||||
|
this.stopKnownWordCacheLifecycle();
|
||||||
log.info("Stopped AnkiConnect integration");
|
log.info("Stopped AnkiConnect integration");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +665,7 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noteInfo = notesInfo[0];
|
const noteInfo = notesInfo[0];
|
||||||
|
this.appendKnownWordsFromNoteInfo(noteInfo);
|
||||||
const fields = this.extractFields(noteInfo.fields);
|
const fields = this.extractFields(noteInfo.fields);
|
||||||
|
|
||||||
const expressionText = fields.expression || fields.word || "";
|
const expressionText = fields.expression || fields.word || "";
|
||||||
@@ -624,61 +994,60 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private showStatusNotification(message: string): void {
|
private showStatusNotification(message: string): void {
|
||||||
const type = this.config.behavior?.notificationType || "osd";
|
showStatusNotification(message, {
|
||||||
|
getNotificationType: () => this.config.behavior?.notificationType,
|
||||||
if (type === "osd" || type === "both") {
|
showOsd: (text: string) => {
|
||||||
this.showOsdNotification(message);
|
this.showOsdNotification(text);
|
||||||
}
|
},
|
||||||
|
showSystemNotification: (
|
||||||
if ((type === "system" || type === "both") && this.notificationCallback) {
|
title: string,
|
||||||
this.notificationCallback("SubMiner", { body: message });
|
options: NotificationOptions,
|
||||||
}
|
) => {
|
||||||
|
if (this.notificationCallback) {
|
||||||
|
this.notificationCallback(title, options);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private beginUpdateProgress(initialMessage: string): void {
|
private beginUpdateProgress(initialMessage: string): void {
|
||||||
this.progressDepth += 1;
|
beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => {
|
||||||
if (this.progressDepth > 1) return;
|
this.showOsdNotification(text);
|
||||||
|
});
|
||||||
this.progressMessage = initialMessage;
|
|
||||||
this.progressFrame = 0;
|
|
||||||
this.showProgressTick();
|
|
||||||
this.progressTimer = setInterval(() => {
|
|
||||||
this.showProgressTick();
|
|
||||||
}, 180);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private endUpdateProgress(): void {
|
private endUpdateProgress(): void {
|
||||||
this.progressDepth = Math.max(0, this.progressDepth - 1);
|
endUpdateProgress(this.uiFeedbackState, (timer) => {
|
||||||
if (this.progressDepth > 0) return;
|
clearInterval(timer);
|
||||||
|
});
|
||||||
if (this.progressTimer) {
|
|
||||||
clearInterval(this.progressTimer);
|
|
||||||
this.progressTimer = null;
|
|
||||||
}
|
|
||||||
this.progressMessage = "";
|
|
||||||
this.progressFrame = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private showProgressTick(): void {
|
private showProgressTick(): void {
|
||||||
if (!this.progressMessage) return;
|
showProgressTick(
|
||||||
const frames = ["|", "/", "-", "\\"];
|
this.uiFeedbackState,
|
||||||
const frame = frames[this.progressFrame % frames.length];
|
(text: string) => {
|
||||||
this.progressFrame += 1;
|
this.showOsdNotification(text);
|
||||||
this.showOsdNotification(`${this.progressMessage} ${frame}`);
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async withUpdateProgress<T>(
|
private async withUpdateProgress<T>(
|
||||||
initialMessage: string,
|
initialMessage: string,
|
||||||
action: () => Promise<T>,
|
action: () => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
this.beginUpdateProgress(initialMessage);
|
return withUpdateProgress(
|
||||||
this.updateInProgress = true;
|
this.uiFeedbackState,
|
||||||
try {
|
{
|
||||||
return await action();
|
setUpdateInProgress: (value: boolean) => {
|
||||||
} finally {
|
this.updateInProgress = value;
|
||||||
this.updateInProgress = false;
|
},
|
||||||
this.endUpdateProgress();
|
showOsdNotification: (text: string) => {
|
||||||
}
|
this.showOsdNotification(text);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
initialMessage,
|
||||||
|
action,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private showOsdNotification(text: string): void {
|
private showOsdNotification(text: string): void {
|
||||||
@@ -1436,33 +1805,16 @@ export class AnkiIntegration {
|
|||||||
|
|
||||||
fields[sentenceField] = sentence;
|
fields[sentenceField] = sentence;
|
||||||
|
|
||||||
const hasSecondarySub = Boolean(secondarySubText?.trim());
|
const backText = await resolveSentenceBackText(
|
||||||
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 = {
|
|
||||||
sentence,
|
sentence,
|
||||||
apiKey: aiConfig?.apiKey || "",
|
secondarySubText,
|
||||||
baseUrl: aiConfig?.baseUrl,
|
config: this.config.ai || {},
|
||||||
model: aiConfig?.model,
|
},
|
||||||
targetLanguage: aiConfig?.targetLanguage,
|
{
|
||||||
systemPrompt: aiConfig?.systemPrompt,
|
|
||||||
};
|
|
||||||
const callbacks: AiTranslateCallbacks = {
|
|
||||||
logWarning: (message: string) => log.warn(message),
|
logWarning: (message: string) => log.warn(message),
|
||||||
};
|
},
|
||||||
const translated = await translateSentenceWithAi(request, callbacks);
|
);
|
||||||
if (translated) {
|
|
||||||
backText = translated;
|
|
||||||
} else if (!hasSecondarySub) {
|
|
||||||
backText = sentence;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (backText) {
|
if (backText) {
|
||||||
fields[translationField] = backText;
|
fields[translationField] = backText;
|
||||||
}
|
}
|
||||||
@@ -1498,6 +1850,7 @@ export class AnkiIntegration {
|
|||||||
const noteInfos = noteInfoResult as unknown as NoteInfo[];
|
const noteInfos = noteInfoResult as unknown as NoteInfo[];
|
||||||
if (noteInfos.length > 0) {
|
if (noteInfos.length > 0) {
|
||||||
const createdNoteInfo = noteInfos[0];
|
const createdNoteInfo = noteInfos[0];
|
||||||
|
this.appendKnownWordsFromNoteInfo(createdNoteInfo);
|
||||||
resolvedSentenceAudioField =
|
resolvedSentenceAudioField =
|
||||||
this.resolveNoteFieldName(createdNoteInfo, audioFieldName) ||
|
this.resolveNoteFieldName(createdNoteInfo, audioFieldName) ||
|
||||||
audioFieldName;
|
audioFieldName;
|
||||||
@@ -1639,78 +1992,23 @@ export class AnkiIntegration {
|
|||||||
excludeNoteId: number,
|
excludeNoteId: number,
|
||||||
noteInfo: NoteInfo,
|
noteInfo: NoteInfo,
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
let fieldName = "";
|
return findDuplicateNoteForAnkiIntegration(
|
||||||
for (const name of Object.keys(noteInfo.fields)) {
|
expression,
|
||||||
if (
|
excludeNoteId,
|
||||||
["word", "expression"].includes(name.toLowerCase()) &&
|
noteInfo,
|
||||||
noteInfo.fields[name].value
|
{
|
||||||
) {
|
findNotes: async (query, options) =>
|
||||||
fieldName = name;
|
(await this.client.findNotes(query, options)) as unknown,
|
||||||
break;
|
notesInfo: async (noteIds) =>
|
||||||
}
|
(await this.client.notesInfo(noteIds)) as unknown,
|
||||||
}
|
getDeck: () => this.config.deck,
|
||||||
if (!fieldName) return null;
|
resolveFieldName: (info, preferredName) =>
|
||||||
|
this.resolveNoteFieldName(info, preferredName),
|
||||||
const escapedFieldName = this.escapeAnkiSearchValue(fieldName);
|
logWarn: (message, error) => {
|
||||||
const escapedExpression = this.escapeAnkiSearchValue(expression);
|
log.warn(message, (error as Error).message);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getGroupableFieldNames(): string[] {
|
private getGroupableFieldNames(): string[] {
|
||||||
@@ -2559,10 +2857,18 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
||||||
|
const wasEnabled = this.config.nPlusOne?.highlightEnabled === true;
|
||||||
const previousPollingRate = this.config.pollingRate;
|
const previousPollingRate = this.config.pollingRate;
|
||||||
this.config = {
|
this.config = {
|
||||||
...this.config,
|
...this.config,
|
||||||
...patch,
|
...patch,
|
||||||
|
nPlusOne:
|
||||||
|
patch.nPlusOne !== undefined
|
||||||
|
? {
|
||||||
|
...(this.config.nPlusOne ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne),
|
||||||
|
...patch.nPlusOne,
|
||||||
|
}
|
||||||
|
: this.config.nPlusOne,
|
||||||
fields:
|
fields:
|
||||||
patch.fields !== undefined
|
patch.fields !== undefined
|
||||||
? { ...this.config.fields, ...patch.fields }
|
? { ...this.config.fields, ...patch.fields }
|
||||||
@@ -2589,6 +2895,21 @@ export class AnkiIntegration {
|
|||||||
: this.config.isKiku,
|
: 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 (
|
if (
|
||||||
patch.pollingRate !== undefined &&
|
patch.pollingRate !== undefined &&
|
||||||
previousPollingRate !== this.config.pollingRate &&
|
previousPollingRate !== this.config.pollingRate &&
|
||||||
@@ -2605,3 +2926,10 @@ export class AnkiIntegration {
|
|||||||
this.mediaGenerator.cleanup();
|
this.mediaGenerator.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAnkiSearchValue(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/([:*?()[\]{}])/g, "\\$1");
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,24 @@ export interface AiTranslateCallbacks {
|
|||||||
logWarning: (message: string) => void;
|
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(
|
export async function translateSentenceWithAi(
|
||||||
request: AiTranslateRequest,
|
request: AiTranslateRequest,
|
||||||
callbacks: AiTranslateCallbacks,
|
callbacks: AiTranslateCallbacks,
|
||||||
@@ -101,3 +119,40 @@ export async function translateSentenceWithAi(
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,7 +69,198 @@ test("parses invisible overlay config and new global shortcuts", () => {
|
|||||||
|
|
||||||
test("runtime options registry is centralized", () => {
|
test("runtime options registry is centralized", () => {
|
||||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
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", () => {
|
test("template generator includes known keys", () => {
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
notificationType: "osd",
|
notificationType: "osd",
|
||||||
autoUpdateNewCards: true,
|
autoUpdateNewCards: true,
|
||||||
},
|
},
|
||||||
|
nPlusOne: {
|
||||||
|
highlightEnabled: false,
|
||||||
|
refreshMinutes: 1440,
|
||||||
|
matchMode: "headword",
|
||||||
|
decks: [],
|
||||||
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
pattern: "[SubMiner] %f (%t)",
|
pattern: "[SubMiner] %f (%t)",
|
||||||
},
|
},
|
||||||
@@ -218,6 +224,23 @@ export const RUNTIME_OPTION_REGISTRY: RuntimeOptionRegistryEntry[] = [
|
|||||||
behavior: { autoUpdateNewCards: value === true },
|
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",
|
id: "anki.kikuFieldGrouping",
|
||||||
path: "ankiConnect.isKiku.fieldGrouping",
|
path: "ankiConnect.isKiku.fieldGrouping",
|
||||||
@@ -272,6 +295,32 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
|||||||
description: "Automatically update newly added cards.",
|
description: "Automatically update newly added cards.",
|
||||||
runtime: RUNTIME_OPTION_REGISTRY[0],
|
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",
|
path: "ankiConnect.isKiku.fieldGrouping",
|
||||||
kind: "enum",
|
kind: "enum",
|
||||||
|
|||||||
@@ -437,6 +437,9 @@ export class ConfigService {
|
|||||||
|
|
||||||
if (isObject(src.ankiConnect)) {
|
if (isObject(src.ankiConnect)) {
|
||||||
const ac = src.ankiConnect;
|
const ac = src.ankiConnect;
|
||||||
|
const behavior = isObject(ac.behavior)
|
||||||
|
? (ac.behavior as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
const aiSource = isObject(ac.ai)
|
const aiSource = isObject(ac.ai)
|
||||||
? ac.ai
|
? ac.ai
|
||||||
: isObject(ac.openRouter)
|
: isObject(ac.openRouter)
|
||||||
@@ -580,6 +583,159 @@ export class ConfigService {
|
|||||||
resolved.ankiConnect.behavior.autoUpdateNewCards = value as boolean;
|
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 (
|
if (
|
||||||
resolved.ankiConnect.isKiku.fieldGrouping !== "auto" &&
|
resolved.ankiConnect.isKiku.fieldGrouping !== "auto" &&
|
||||||
resolved.ankiConnect.isKiku.fieldGrouping !== "manual" &&
|
resolved.ankiConnect.isKiku.fieldGrouping !== "manual" &&
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ function createHarness(): RuntimeHarness {
|
|||||||
setAnkiIntegration: (integration) => {
|
setAnkiIntegration: (integration) => {
|
||||||
state.ankiIntegration = integration;
|
state.ankiIntegration = integration;
|
||||||
},
|
},
|
||||||
|
getKnownWordCacheStatePath: () =>
|
||||||
|
"/tmp/subminer-known-words-cache.json",
|
||||||
showDesktopNotification: () => {},
|
showDesktopNotification: () => {},
|
||||||
createFieldGroupingCallback: () => async () => ({
|
createFieldGroupingCallback: () => async () => ({
|
||||||
keepNoteId: 1,
|
keepNoteId: 1,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
|||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
getAnkiIntegration: () => AnkiIntegration | null;
|
getAnkiIntegration: () => AnkiIntegration | null;
|
||||||
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
||||||
|
getKnownWordCacheStatePath: () => string;
|
||||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||||
createFieldGroupingCallback: () => (
|
createFieldGroupingCallback: () => (
|
||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
@@ -87,6 +88,7 @@ export function registerAnkiJimakuIpcRuntimeService(
|
|||||||
},
|
},
|
||||||
options.showDesktopNotification,
|
options.showDesktopNotification,
|
||||||
options.createFieldGroupingCallback(),
|
options.createFieldGroupingCallback(),
|
||||||
|
options.getKnownWordCacheStatePath(),
|
||||||
);
|
);
|
||||||
integration.start();
|
integration.start();
|
||||||
options.setAnkiIntegration(integration);
|
options.setAnkiIntegration(integration);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function initializeOverlayRuntimeService(options: {
|
|||||||
createFieldGroupingCallback: () => (
|
createFieldGroupingCallback: () => (
|
||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
|
getKnownWordCacheStatePath: () => string;
|
||||||
}): {
|
}): {
|
||||||
invisibleOverlayVisible: boolean;
|
invisibleOverlayVisible: boolean;
|
||||||
} {
|
} {
|
||||||
@@ -98,6 +99,7 @@ export function initializeOverlayRuntimeService(options: {
|
|||||||
},
|
},
|
||||||
options.showDesktopNotification,
|
options.showDesktopNotification,
|
||||||
options.createFieldGroupingCallback(),
|
options.createFieldGroupingCallback(),
|
||||||
|
options.getKnownWordCacheStatePath(),
|
||||||
);
|
);
|
||||||
integration.start();
|
integration.start();
|
||||||
options.setAnkiIntegration(integration);
|
options.setAnkiIntegration(integration);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ function makeDeps(
|
|||||||
setYomitanParserReadyPromise: () => {},
|
setYomitanParserReadyPromise: () => {},
|
||||||
getYomitanParserInitPromise: () => null,
|
getYomitanParserInitPromise: () => null,
|
||||||
setYomitanParserInitPromise: () => {},
|
setYomitanParserInitPromise: () => {},
|
||||||
|
isKnownWord: () => false,
|
||||||
|
getKnownWordMatchMode: () => "headword",
|
||||||
tokenizeWithMecab: async () => null,
|
tokenizeWithMecab: async () => null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -32,7 +34,7 @@ test("tokenizeSubtitleService normalizes newlines before mecab fallback", async
|
|||||||
tokenizeWithMecab: async (text) => {
|
tokenizeWithMecab: async (text) => {
|
||||||
tokenizeInput = text;
|
tokenizeInput = text;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
surface: "猫ですね",
|
surface: "猫ですね",
|
||||||
reading: "ネコデスネ",
|
reading: "ネコデスネ",
|
||||||
headword: "猫ですね",
|
headword: "猫ですね",
|
||||||
@@ -40,6 +42,7 @@ test("tokenizeSubtitleService normalizes newlines before mecab fallback", async
|
|||||||
endPos: 4,
|
endPos: 4,
|
||||||
partOfSpeech: PartOfSpeech.other,
|
partOfSpeech: PartOfSpeech.other,
|
||||||
isMerged: true,
|
isMerged: true,
|
||||||
|
isKnown: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
@@ -64,6 +67,7 @@ test("tokenizeSubtitleService falls back to mecab tokens when available", async
|
|||||||
endPos: 1,
|
endPos: 1,
|
||||||
partOfSpeech: PartOfSpeech.noun,
|
partOfSpeech: PartOfSpeech.noun,
|
||||||
isMerged: false,
|
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?.length, 1);
|
||||||
assert.equal(result.tokens?.[0]?.surface, "猫です");
|
assert.equal(result.tokens?.[0]?.surface, "猫です");
|
||||||
assert.equal(result.tokens?.[0]?.reading, "ねこです");
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { BrowserWindow, Extension, session } from "electron";
|
import { BrowserWindow, Extension, session } from "electron";
|
||||||
import { mergeTokens } from "../../token-merger";
|
import { mergeTokens } from "../../token-merger";
|
||||||
import { MergedToken, PartOfSpeech, SubtitleData, Token } from "../../types";
|
import {
|
||||||
|
MergedToken,
|
||||||
|
NPlusOneMatchMode,
|
||||||
|
PartOfSpeech,
|
||||||
|
SubtitleData,
|
||||||
|
Token,
|
||||||
|
} from "../../types";
|
||||||
|
|
||||||
interface YomitanParseHeadword {
|
interface YomitanParseHeadword {
|
||||||
term?: unknown;
|
term?: unknown;
|
||||||
@@ -26,6 +32,8 @@ export interface TokenizerServiceDeps {
|
|||||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
|
isKnownWord: (text: string) => boolean;
|
||||||
|
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||||
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
|
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +49,8 @@ export interface TokenizerDepsRuntimeOptions {
|
|||||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
|
isKnownWord: (text: string) => boolean;
|
||||||
|
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||||
getMecabTokenizer: () => MecabTokenizerLike | null;
|
getMecabTokenizer: () => MecabTokenizerLike | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +65,8 @@ export function createTokenizerDepsRuntimeService(
|
|||||||
setYomitanParserReadyPromise: options.setYomitanParserReadyPromise,
|
setYomitanParserReadyPromise: options.setYomitanParserReadyPromise,
|
||||||
getYomitanParserInitPromise: options.getYomitanParserInitPromise,
|
getYomitanParserInitPromise: options.getYomitanParserInitPromise,
|
||||||
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
|
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
|
||||||
|
isKnownWord: options.isKnownWord,
|
||||||
|
getKnownWordMatchMode: options.getKnownWordMatchMode,
|
||||||
tokenizeWithMecab: async (text) => {
|
tokenizeWithMecab: async (text) => {
|
||||||
const mecabTokenizer = options.getMecabTokenizer();
|
const mecabTokenizer = options.getMecabTokenizer();
|
||||||
if (!mecabTokenizer) {
|
if (!mecabTokenizer) {
|
||||||
@@ -64,11 +76,23 @@ export function createTokenizerDepsRuntimeService(
|
|||||||
if (!rawTokens || rawTokens.length === 0) {
|
if (!rawTokens || rawTokens.length === 0) {
|
||||||
return null;
|
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 {
|
function extractYomitanHeadword(segment: YomitanParseSegment): string {
|
||||||
const headwords = segment.headwords;
|
const headwords = segment.headwords;
|
||||||
if (!Array.isArray(headwords) || headwords.length === 0) {
|
if (!Array.isArray(headwords) || headwords.length === 0) {
|
||||||
@@ -86,6 +110,8 @@ function extractYomitanHeadword(segment: YomitanParseSegment): string {
|
|||||||
|
|
||||||
function mapYomitanParseResultsToMergedTokens(
|
function mapYomitanParseResultsToMergedTokens(
|
||||||
parseResults: unknown,
|
parseResults: unknown,
|
||||||
|
isKnownWord: (text: string) => boolean,
|
||||||
|
knownWordMatchMode: NPlusOneMatchMode,
|
||||||
): MergedToken[] | null {
|
): MergedToken[] | null {
|
||||||
if (!Array.isArray(parseResults) || parseResults.length === 0) {
|
if (!Array.isArray(parseResults) || parseResults.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -161,6 +187,14 @@ function mapYomitanParseResultsToMergedTokens(
|
|||||||
endPos: end,
|
endPos: end,
|
||||||
partOfSpeech: PartOfSpeech.other,
|
partOfSpeech: PartOfSpeech.other,
|
||||||
isMerged: true,
|
isMerged: true,
|
||||||
|
isKnown: (() => {
|
||||||
|
const matchText = resolveKnownWordText(
|
||||||
|
surface,
|
||||||
|
headword,
|
||||||
|
knownWordMatchMode,
|
||||||
|
);
|
||||||
|
return matchText ? isKnownWord(matchText) : false;
|
||||||
|
})(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +336,11 @@ async function parseWithYomitanInternalParser(
|
|||||||
script,
|
script,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
return mapYomitanParseResultsToMergedTokens(parseResults);
|
return mapYomitanParseResultsToMergedTokens(
|
||||||
|
parseResults,
|
||||||
|
deps.isKnownWord,
|
||||||
|
deps.getKnownWordMatchMode(),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Yomitan parser request failed:", (err as Error).message);
|
console.error("Yomitan parser request failed:", (err as Error).message);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
256
src/main.ts
256
src/main.ts
@@ -89,7 +89,6 @@ import {
|
|||||||
applyMpvSubtitleRenderMetricsPatchService,
|
applyMpvSubtitleRenderMetricsPatchService,
|
||||||
broadcastRuntimeOptionsChangedRuntimeService,
|
broadcastRuntimeOptionsChangedRuntimeService,
|
||||||
copyCurrentSubtitleService,
|
copyCurrentSubtitleService,
|
||||||
createAppLifecycleDepsRuntimeService,
|
|
||||||
createOverlayManagerService,
|
createOverlayManagerService,
|
||||||
createFieldGroupingOverlayRuntimeService,
|
createFieldGroupingOverlayRuntimeService,
|
||||||
createNumericShortcutRuntimeService,
|
createNumericShortcutRuntimeService,
|
||||||
@@ -128,7 +127,6 @@ import {
|
|||||||
shouldAutoInitializeOverlayRuntimeFromConfigService,
|
shouldAutoInitializeOverlayRuntimeFromConfigService,
|
||||||
shouldBindVisibleOverlayToMpvSubVisibilityService,
|
shouldBindVisibleOverlayToMpvSubVisibilityService,
|
||||||
showMpvOsdRuntimeService,
|
showMpvOsdRuntimeService,
|
||||||
startAppLifecycleService,
|
|
||||||
syncInvisibleOverlayMousePassthroughService,
|
syncInvisibleOverlayMousePassthroughService,
|
||||||
tokenizeSubtitleService,
|
tokenizeSubtitleService,
|
||||||
triggerFieldGroupingService,
|
triggerFieldGroupingService,
|
||||||
@@ -137,13 +135,9 @@ import {
|
|||||||
updateLastCardFromClipboardService,
|
updateLastCardFromClipboardService,
|
||||||
updateVisibleOverlayVisibilityService,
|
updateVisibleOverlayVisibilityService,
|
||||||
} from "./core/services";
|
} from "./core/services";
|
||||||
import {
|
|
||||||
runAppReadyRuntimeService,
|
|
||||||
} from "./core/services/startup-service";
|
|
||||||
import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service";
|
import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service";
|
||||||
import {
|
import {
|
||||||
createAppLifecycleRuntimeDeps,
|
createAppReadyRuntimeRunner,
|
||||||
createAppReadyRuntimeDeps,
|
|
||||||
} from "./main/app-lifecycle";
|
} from "./main/app-lifecycle";
|
||||||
import { handleMpvCommandFromIpcRuntime } from "./main/ipc-mpv-command";
|
import { handleMpvCommandFromIpcRuntime } from "./main/ipc-mpv-command";
|
||||||
import {
|
import {
|
||||||
@@ -158,6 +152,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
runSubsyncManualFromIpcRuntime,
|
runSubsyncManualFromIpcRuntime,
|
||||||
triggerSubsyncFromConfigRuntime,
|
triggerSubsyncFromConfigRuntime,
|
||||||
|
createSubsyncRuntimeServiceInputFromState,
|
||||||
} from "./main/subsync-runtime";
|
} from "./main/subsync-runtime";
|
||||||
import {
|
import {
|
||||||
createOverlayModalRuntimeService,
|
createOverlayModalRuntimeService,
|
||||||
@@ -171,6 +166,7 @@ import {
|
|||||||
createAppState,
|
createAppState,
|
||||||
} from "./main/state";
|
} from "./main/state";
|
||||||
import { createStartupBootstrapRuntimeDeps } from "./main/startup";
|
import { createStartupBootstrapRuntimeDeps } from "./main/startup";
|
||||||
|
import { createAppLifecycleRuntimeRunner } from "./main/startup-lifecycle";
|
||||||
import {
|
import {
|
||||||
ConfigService,
|
ConfigService,
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
@@ -562,130 +558,113 @@ const startupState = runStartupBootstrapRuntimeService(
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
app.quit();
|
app.quit();
|
||||||
},
|
},
|
||||||
startAppLifecycle: (args: CliArgs) => {
|
startAppLifecycle: createAppLifecycleRuntimeRunner({
|
||||||
startAppLifecycleService(
|
app,
|
||||||
args,
|
platform: process.platform,
|
||||||
createAppLifecycleDepsRuntimeService(
|
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
|
||||||
createAppLifecycleRuntimeDeps({
|
parseArgs: (argv: string[]) => parseArgs(argv),
|
||||||
app,
|
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
|
||||||
platform: process.platform,
|
handleCliCommand(nextArgs, source),
|
||||||
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
|
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||||
parseArgs: (argv: string[]) => parseArgs(argv),
|
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
|
||||||
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
|
onReady: createAppReadyRuntimeRunner({
|
||||||
handleCliCommand(nextArgs, source),
|
loadSubtitlePosition: () => loadSubtitlePosition(),
|
||||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
resolveKeybindings: () => {
|
||||||
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
|
appState.keybindings = resolveKeybindings(
|
||||||
onReady: async () => {
|
getResolvedConfig(),
|
||||||
await runAppReadyRuntimeService(
|
DEFAULT_KEYBINDINGS,
|
||||||
createAppReadyRuntimeDeps({
|
);
|
||||||
loadSubtitlePosition: () => loadSubtitlePosition(),
|
},
|
||||||
resolveKeybindings: () => {
|
createMpvClient: () => {
|
||||||
appState.keybindings = resolveKeybindings(
|
appState.mpvClient = createMpvClientRuntimeService();
|
||||||
getResolvedConfig(),
|
},
|
||||||
DEFAULT_KEYBINDINGS,
|
reloadConfig: () => {
|
||||||
);
|
configService.reloadConfig();
|
||||||
},
|
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`);
|
||||||
createMpvClient: () => {
|
},
|
||||||
appState.mpvClient = createMpvClientRuntimeService();
|
getResolvedConfig: () => getResolvedConfig(),
|
||||||
},
|
getConfigWarnings: () => configService.getWarnings(),
|
||||||
reloadConfig: () => {
|
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
|
||||||
configService.reloadConfig();
|
initRuntimeOptionsManager: () => {
|
||||||
appLogger.logInfo(
|
appState.runtimeOptionsManager = new RuntimeOptionsManager(
|
||||||
`Using config file: ${configService.getConfigPath()}`,
|
() => configService.getConfig().ankiConnect,
|
||||||
);
|
{
|
||||||
},
|
applyAnkiPatch: (patch) => {
|
||||||
getResolvedConfig: () => getResolvedConfig(),
|
if (appState.ankiIntegration) {
|
||||||
getConfigWarnings: () => configService.getWarnings(),
|
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
|
||||||
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
|
}
|
||||||
initRuntimeOptionsManager: () => {
|
},
|
||||||
appState.runtimeOptionsManager = new RuntimeOptionsManager(
|
onOptionsChanged: () => {
|
||||||
() => configService.getConfig().ankiConnect,
|
broadcastRuntimeOptionsChanged();
|
||||||
{
|
refreshOverlayShortcuts();
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onWillQuitCleanup: () => {
|
);
|
||||||
restorePreviousSecondarySubVisibility();
|
},
|
||||||
globalShortcut.unregisterAll();
|
setSecondarySubMode: (mode: SecondarySubMode) => {
|
||||||
subtitleWsService.stop();
|
appState.secondarySubMode = mode;
|
||||||
texthookerService.stop();
|
},
|
||||||
if (
|
defaultSecondarySubMode: "hover",
|
||||||
appState.yomitanParserWindow &&
|
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
|
||||||
!appState.yomitanParserWindow.isDestroyed()
|
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
|
||||||
) {
|
startSubtitleWebsocket: (port: number) => {
|
||||||
appState.yomitanParserWindow.destroy();
|
subtitleWsService.start(port, () => appState.currentSubText);
|
||||||
}
|
},
|
||||||
appState.yomitanParserWindow = null;
|
log: (message) => appLogger.logInfo(message),
|
||||||
appState.yomitanParserReadyPromise = null;
|
createMecabTokenizerAndCheck: async () => {
|
||||||
appState.yomitanParserInitPromise = null;
|
const tokenizer = new MecabTokenizer();
|
||||||
if (appState.windowTracker) {
|
appState.mecabTokenizer = tokenizer;
|
||||||
appState.windowTracker.stop();
|
await tokenizer.checkAvailability();
|
||||||
}
|
},
|
||||||
if (appState.mpvClient && appState.mpvClient.socket) {
|
createSubtitleTimingTracker: () => {
|
||||||
appState.mpvClient.socket.destroy();
|
const tracker = new SubtitleTimingTracker();
|
||||||
}
|
appState.subtitleTimingTracker = tracker;
|
||||||
if (appState.reconnectTimer) {
|
},
|
||||||
clearTimeout(appState.reconnectTimer);
|
loadYomitanExtension: async () => {
|
||||||
}
|
await loadYomitanExtension();
|
||||||
if (appState.subtitleTimingTracker) {
|
},
|
||||||
appState.subtitleTimingTracker.destroy();
|
texthookerOnlyMode: appState.texthookerOnlyMode,
|
||||||
}
|
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||||
if (appState.ankiIntegration) {
|
shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||||
appState.ankiIntegration.destroy();
|
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||||
}
|
handleInitialArgs: () => handleInitialArgs(),
|
||||||
},
|
}),
|
||||||
shouldRestoreWindowsOnActivate: () =>
|
onWillQuitCleanup: () => {
|
||||||
appState.overlayRuntimeInitialized &&
|
restorePreviousSecondarySubVisibility();
|
||||||
BrowserWindow.getAllWindows().length === 0,
|
globalShortcut.unregisterAll();
|
||||||
restoreWindowsOnActivate: () => {
|
subtitleWsService.stop();
|
||||||
createMainWindow();
|
texthookerService.stop();
|
||||||
createInvisibleWindow();
|
if (appState.yomitanParserWindow && !appState.yomitanParserWindow.isDestroyed()) {
|
||||||
updateVisibleOverlayVisibility();
|
appState.yomitanParserWindow.destroy();
|
||||||
updateInvisibleOverlayVisibility();
|
}
|
||||||
},
|
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) => {
|
setYomitanParserInitPromise: (promise) => {
|
||||||
appState.yomitanParserInitPromise = promise;
|
appState.yomitanParserInitPromise = promise;
|
||||||
},
|
},
|
||||||
|
isKnownWord: (text) =>
|
||||||
|
Boolean(appState.ankiIntegration?.isKnownWord(text)),
|
||||||
|
getKnownWordMatchMode: () =>
|
||||||
|
appState.ankiIntegration?.getKnownWordMatchMode() ??
|
||||||
|
getResolvedConfig().ankiConnect.nPlusOne.matchMode,
|
||||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -972,6 +956,8 @@ function initializeOverlayRuntime(): void {
|
|||||||
},
|
},
|
||||||
showDesktopNotification,
|
showDesktopNotification,
|
||||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||||
|
getKnownWordCacheStatePath: () =>
|
||||||
|
path.join(USER_DATA_PATH, "known-words-cache.json"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
|
overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
|
||||||
@@ -1057,10 +1043,10 @@ const multiCopySession = numericShortcutRuntime.createSession();
|
|||||||
const mineSentenceSession = numericShortcutRuntime.createSession();
|
const mineSentenceSession = numericShortcutRuntime.createSession();
|
||||||
|
|
||||||
function getSubsyncRuntimeServiceParams() {
|
function getSubsyncRuntimeServiceParams() {
|
||||||
return {
|
return createSubsyncRuntimeServiceInputFromState({
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
getResolvedSubsyncConfig: () => getSubsyncConfig(getResolvedConfig().subsync),
|
getResolvedSubsyncConfig: () => getSubsyncConfig(getResolvedConfig().subsync),
|
||||||
isSubsyncInProgress: () => appState.subsyncInProgress,
|
getSubsyncInProgress: () => appState.subsyncInProgress,
|
||||||
setSubsyncInProgress: (inProgress: boolean) => {
|
setSubsyncInProgress: (inProgress: boolean) => {
|
||||||
appState.subsyncInProgress = inProgress;
|
appState.subsyncInProgress = inProgress;
|
||||||
},
|
},
|
||||||
@@ -1070,7 +1056,7 @@ function getSubsyncRuntimeServiceParams() {
|
|||||||
restoreOnModalClose: "subsync",
|
restoreOnModalClose: "subsync",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerSubsyncFromConfig(): Promise<void> {
|
async function triggerSubsyncFromConfig(): Promise<void> {
|
||||||
@@ -1336,7 +1322,7 @@ registerIpcRuntimeServices({
|
|||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
quitApp: () => app.quit(),
|
quitApp: () => app.quit(),
|
||||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
tokenizeCurrentSubtitle: () => tokenizeCurrentSubtitle(appState.currentSubText),
|
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
|
||||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||||
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
|
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
|
||||||
getSubtitlePosition: () => loadSubtitlePosition(),
|
getSubtitlePosition: () => loadSubtitlePosition(),
|
||||||
@@ -1369,6 +1355,8 @@ registerIpcRuntimeServices({
|
|||||||
setAnkiIntegration: (integration: AnkiIntegration | null) => {
|
setAnkiIntegration: (integration: AnkiIntegration | null) => {
|
||||||
appState.ankiIntegration = integration;
|
appState.ankiIntegration = integration;
|
||||||
},
|
},
|
||||||
|
getKnownWordCacheStatePath: () =>
|
||||||
|
path.join(USER_DATA_PATH, "known-words-cache.json"),
|
||||||
showDesktopNotification,
|
showDesktopNotification,
|
||||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||||
broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
||||||
|
|||||||
@@ -87,3 +87,11 @@ export function createAppReadyRuntimeDeps(
|
|||||||
handleInitialArgs: params.handleInitialArgs,
|
handleInitialArgs: params.handleInitialArgs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createAppReadyRuntimeRunner(
|
||||||
|
params: AppReadyRuntimeDepsFactoryInput,
|
||||||
|
): () => Promise<void> {
|
||||||
|
return async () => {
|
||||||
|
await runAppReadyRuntimeService(createAppReadyRuntimeDeps(params));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
|||||||
getMpvClient: AnkiJimakuIpcRuntimeOptions["getMpvClient"];
|
getMpvClient: AnkiJimakuIpcRuntimeOptions["getMpvClient"];
|
||||||
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions["getAnkiIntegration"];
|
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions["getAnkiIntegration"];
|
||||||
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions["setAnkiIntegration"];
|
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions["setAnkiIntegration"];
|
||||||
|
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions["getKnownWordCacheStatePath"];
|
||||||
showDesktopNotification: AnkiJimakuIpcRuntimeOptions["showDesktopNotification"];
|
showDesktopNotification: AnkiJimakuIpcRuntimeOptions["showDesktopNotification"];
|
||||||
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions["createFieldGroupingCallback"];
|
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions["createFieldGroupingCallback"];
|
||||||
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions["broadcastRuntimeOptionsChanged"];
|
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions["broadcastRuntimeOptionsChanged"];
|
||||||
@@ -224,6 +225,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
|
|||||||
getMpvClient: params.getMpvClient,
|
getMpvClient: params.getMpvClient,
|
||||||
getAnkiIntegration: params.getAnkiIntegration,
|
getAnkiIntegration: params.getAnkiIntegration,
|
||||||
setAnkiIntegration: params.setAnkiIntegration,
|
setAnkiIntegration: params.setAnkiIntegration,
|
||||||
|
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
|
||||||
showDesktopNotification: params.showDesktopNotification,
|
showDesktopNotification: params.showDesktopNotification,
|
||||||
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
||||||
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
|
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
|
||||||
|
|||||||
44
src/main/startup-lifecycle.ts
Normal file
44
src/main/startup-lifecycle.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,28 @@ export interface SubsyncRuntimeServiceInput {
|
|||||||
openManualPicker: (payload: SubsyncManualPayload) => void;
|
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(
|
export function createSubsyncRuntimeServiceDeps(
|
||||||
params: SubsyncRuntimeServiceInput,
|
params: SubsyncRuntimeServiceInput,
|
||||||
): SubsyncRuntimeDeps {
|
): SubsyncRuntimeDeps {
|
||||||
|
|||||||
@@ -284,6 +284,11 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#subtitleRoot .word.word-known {
|
||||||
|
color: #a6da95;
|
||||||
|
text-shadow: 0 0 6px rgba(166, 218, 149, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
#subtitleRoot .word:hover {
|
#subtitleRoot .word:hover {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
|||||||
for (let i = 0; i < parts.length; i += 1) {
|
for (let i = 0; i < parts.length; i += 1) {
|
||||||
if (parts[i]) {
|
if (parts[i]) {
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "word";
|
span.className = token.isKnown ? "word word-known" : "word";
|
||||||
span.textContent = parts[i];
|
span.textContent = parts[i];
|
||||||
if (token.reading) span.dataset.reading = token.reading;
|
if (token.reading) span.dataset.reading = token.reading;
|
||||||
if (token.headword) span.dataset.headword = token.headword;
|
if (token.headword) span.dataset.headword = token.headword;
|
||||||
@@ -40,7 +40,7 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "word";
|
span.className = token.isKnown ? "word word-known" : "word";
|
||||||
span.textContent = surface;
|
span.textContent = surface;
|
||||||
if (token.reading) span.dataset.reading = token.reading;
|
if (token.reading) span.dataset.reading = token.reading;
|
||||||
if (token.headword) span.dataset.headword = token.headword;
|
if (token.headword) span.dataset.headword = token.headword;
|
||||||
|
|||||||
@@ -179,7 +179,11 @@ export function shouldMerge(lastStandaloneToken: Token, token: Token): boolean {
|
|||||||
return false;
|
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) {
|
if (!tokens || tokens.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -205,6 +209,13 @@ export function mergeTokens(tokens: Token[]): MergedToken[] {
|
|||||||
|
|
||||||
if (shouldMergeToken && result.length > 0) {
|
if (shouldMergeToken && result.length > 0) {
|
||||||
const prev = result.pop()!;
|
const prev = result.pop()!;
|
||||||
|
const mergedHeadword = prev.headword;
|
||||||
|
const headwordForKnownMatch = (() => {
|
||||||
|
if (knownWordMatchMode === "surface") {
|
||||||
|
return prev.surface;
|
||||||
|
}
|
||||||
|
return mergedHeadword;
|
||||||
|
})();
|
||||||
result.push({
|
result.push({
|
||||||
surface: prev.surface + token.word,
|
surface: prev.surface + token.word,
|
||||||
reading: prev.reading + tokenReading,
|
reading: prev.reading + tokenReading,
|
||||||
@@ -213,8 +224,17 @@ export function mergeTokens(tokens: Token[]): MergedToken[] {
|
|||||||
endPos: end,
|
endPos: end,
|
||||||
partOfSpeech: prev.partOfSpeech,
|
partOfSpeech: prev.partOfSpeech,
|
||||||
isMerged: true,
|
isMerged: true,
|
||||||
|
isKnown: headwordForKnownMatch
|
||||||
|
? isKnownWord(headwordForKnownMatch)
|
||||||
|
: false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const headwordForKnownMatch = (() => {
|
||||||
|
if (knownWordMatchMode === "surface") {
|
||||||
|
return token.word;
|
||||||
|
}
|
||||||
|
return token.headword;
|
||||||
|
})();
|
||||||
result.push({
|
result.push({
|
||||||
surface: token.word,
|
surface: token.word,
|
||||||
reading: tokenReading,
|
reading: tokenReading,
|
||||||
@@ -223,6 +243,9 @@ export function mergeTokens(tokens: Token[]): MergedToken[] {
|
|||||||
endPos: end,
|
endPos: end,
|
||||||
partOfSpeech: token.partOfSpeech,
|
partOfSpeech: token.partOfSpeech,
|
||||||
isMerged: false,
|
isMerged: false,
|
||||||
|
isKnown: headwordForKnownMatch
|
||||||
|
? isKnownWord(headwordForKnownMatch)
|
||||||
|
: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
src/types.ts
32
src/types.ts
@@ -49,6 +49,7 @@ export interface MergedToken {
|
|||||||
endPos: number;
|
endPos: number;
|
||||||
partOfSpeech: PartOfSpeech;
|
partOfSpeech: PartOfSpeech;
|
||||||
isMerged: boolean;
|
isMerged: boolean;
|
||||||
|
isKnown: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WindowGeometry {
|
export interface WindowGeometry {
|
||||||
@@ -150,7 +151,8 @@ export interface KikuMergePreviewResponse {
|
|||||||
|
|
||||||
export type RuntimeOptionId =
|
export type RuntimeOptionId =
|
||||||
| "anki.autoUpdateNewCards"
|
| "anki.autoUpdateNewCards"
|
||||||
| "anki.kikuFieldGrouping";
|
| "anki.kikuFieldGrouping"
|
||||||
|
| "anki.nPlusOneMatchMode";
|
||||||
|
|
||||||
export type RuntimeOptionScope = "ankiConnect";
|
export type RuntimeOptionScope = "ankiConnect";
|
||||||
|
|
||||||
@@ -158,6 +160,8 @@ export type RuntimeOptionValueType = "boolean" | "enum";
|
|||||||
|
|
||||||
export type RuntimeOptionValue = boolean | string;
|
export type RuntimeOptionValue = boolean | string;
|
||||||
|
|
||||||
|
export type NPlusOneMatchMode = "headword" | "surface";
|
||||||
|
|
||||||
export interface RuntimeOptionState {
|
export interface RuntimeOptionState {
|
||||||
id: RuntimeOptionId;
|
id: RuntimeOptionId;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -221,14 +225,20 @@ export interface AnkiConnectConfig {
|
|||||||
fallbackDuration?: number;
|
fallbackDuration?: number;
|
||||||
maxMediaDuration?: number;
|
maxMediaDuration?: number;
|
||||||
};
|
};
|
||||||
behavior?: {
|
nPlusOne?: {
|
||||||
overwriteAudio?: boolean;
|
highlightEnabled?: boolean;
|
||||||
overwriteImage?: boolean;
|
refreshMinutes?: number;
|
||||||
mediaInsertMode?: "append" | "prepend";
|
matchMode?: NPlusOneMatchMode;
|
||||||
highlightWord?: boolean;
|
decks?: string[];
|
||||||
notificationType?: "osd" | "system" | "both" | "none";
|
|
||||||
autoUpdateNewCards?: boolean;
|
|
||||||
};
|
};
|
||||||
|
behavior?: {
|
||||||
|
overwriteAudio?: boolean;
|
||||||
|
overwriteImage?: boolean;
|
||||||
|
mediaInsertMode?: "append" | "prepend";
|
||||||
|
highlightWord?: boolean;
|
||||||
|
notificationType?: "osd" | "system" | "both" | "none";
|
||||||
|
autoUpdateNewCards?: boolean;
|
||||||
|
};
|
||||||
metadata?: {
|
metadata?: {
|
||||||
pattern?: string;
|
pattern?: string;
|
||||||
};
|
};
|
||||||
@@ -363,6 +373,12 @@ export interface ResolvedConfig {
|
|||||||
fallbackDuration: number;
|
fallbackDuration: number;
|
||||||
maxMediaDuration: number;
|
maxMediaDuration: number;
|
||||||
};
|
};
|
||||||
|
nPlusOne: {
|
||||||
|
highlightEnabled: boolean;
|
||||||
|
refreshMinutes: number;
|
||||||
|
matchMode: NPlusOneMatchMode;
|
||||||
|
decks: string[];
|
||||||
|
};
|
||||||
behavior: {
|
behavior: {
|
||||||
overwriteAudio: boolean;
|
overwriteAudio: boolean;
|
||||||
overwriteImage: boolean;
|
overwriteImage: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user