mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
refactor: split startup lifecycle and Anki service architecture
This commit is contained in:
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 { MediaGenerator } from "./media-generator";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
KikuDuplicateCardInfo,
|
||||
@@ -27,14 +28,23 @@ import {
|
||||
KikuMergePreviewResponse,
|
||||
MpvClient,
|
||||
NotificationOptions,
|
||||
NPlusOneMatchMode,
|
||||
} from "./types";
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from "./config";
|
||||
import { createLogger } from "./logger";
|
||||
import {
|
||||
AiTranslateCallbacks,
|
||||
AiTranslateRequest,
|
||||
translateSentenceWithAi,
|
||||
createUiFeedbackState,
|
||||
beginUpdateProgress,
|
||||
endUpdateProgress,
|
||||
showProgressTick,
|
||||
showStatusNotification,
|
||||
withUpdateProgress,
|
||||
UiFeedbackState,
|
||||
} from "./anki-integration-ui-feedback";
|
||||
import {
|
||||
resolveSentenceBackText,
|
||||
} from "./anki-integration/ai";
|
||||
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from "./anki-integration-duplicate";
|
||||
|
||||
const log = createLogger("anki").child("integration");
|
||||
|
||||
@@ -43,6 +53,13 @@ interface NoteInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
interface KnownWordCacheState {
|
||||
readonly version: 1;
|
||||
readonly refreshedAtMs: number;
|
||||
readonly scope: string;
|
||||
readonly words: string[];
|
||||
}
|
||||
|
||||
type CardKind = "sentence" | "audio";
|
||||
|
||||
export class AnkiIntegration {
|
||||
@@ -62,10 +79,7 @@ export class AnkiIntegration {
|
||||
| ((title: string, options: NotificationOptions) => void)
|
||||
| null = null;
|
||||
private updateInProgress = false;
|
||||
private progressDepth = 0;
|
||||
private progressTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private progressMessage = "";
|
||||
private progressFrame = 0;
|
||||
private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
|
||||
private parseWarningKeys = new Set<string>();
|
||||
private readonly strictGroupingFieldDefaults = new Set<string>([
|
||||
"picture",
|
||||
@@ -80,6 +94,12 @@ export class AnkiIntegration {
|
||||
duplicate: KikuDuplicateCardInfo;
|
||||
}) => Promise<KikuFieldGroupingChoice>)
|
||||
| null = null;
|
||||
private readonly knownWordCacheStatePath: string;
|
||||
private knownWordsLastRefreshedAtMs = 0;
|
||||
private knownWordsScope = "";
|
||||
private knownWords: Set<string> = new Set();
|
||||
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private isRefreshingKnownWords = false;
|
||||
|
||||
constructor(
|
||||
config: AnkiConnectConfig,
|
||||
@@ -94,6 +114,7 @@ export class AnkiIntegration {
|
||||
original: KikuDuplicateCardInfo;
|
||||
duplicate: KikuDuplicateCardInfo;
|
||||
}) => Promise<KikuFieldGroupingChoice>,
|
||||
knownWordCacheStatePath?: string,
|
||||
) {
|
||||
this.config = {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG,
|
||||
@@ -136,6 +157,352 @@ export class AnkiIntegration {
|
||||
this.osdCallback = osdCallback || null;
|
||||
this.notificationCallback = notificationCallback || null;
|
||||
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
||||
this.knownWordCacheStatePath = path.normalize(
|
||||
knownWordCacheStatePath ||
|
||||
path.join(process.cwd(), "known-words-cache.json"),
|
||||
);
|
||||
}
|
||||
|
||||
isKnownWord(text: string): boolean {
|
||||
if (!this.isKnownWordCacheEnabled()) {
|
||||
return false;
|
||||
}
|
||||
const normalized = this.normalizeKnownWordForLookup(text);
|
||||
return normalized.length > 0 ? this.knownWords.has(normalized) : false;
|
||||
}
|
||||
|
||||
getKnownWordMatchMode(): NPlusOneMatchMode {
|
||||
return (
|
||||
this.config.nPlusOne?.matchMode ??
|
||||
DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.matchMode
|
||||
);
|
||||
}
|
||||
|
||||
private isKnownWordCacheEnabled(): boolean {
|
||||
return this.config.nPlusOne?.highlightEnabled === true;
|
||||
}
|
||||
|
||||
private getKnownWordRefreshIntervalMs(): number {
|
||||
const minutes = this.config.nPlusOne?.refreshMinutes;
|
||||
const safeMinutes =
|
||||
typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0
|
||||
? minutes
|
||||
: DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne.refreshMinutes;
|
||||
return safeMinutes * 60_000;
|
||||
}
|
||||
|
||||
private startKnownWordCacheLifecycle(): void {
|
||||
this.stopKnownWordCacheLifecycle();
|
||||
if (!this.isKnownWordCacheEnabled()) {
|
||||
log.info("Known-word cache disabled; clearing local cache state");
|
||||
this.clearKnownWordCacheState();
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshMinutes = this.getKnownWordRefreshIntervalMs() / 60_000;
|
||||
const scope = this.getKnownWordCacheScope();
|
||||
log.info(
|
||||
"Known-word cache lifecycle enabled",
|
||||
`scope=${scope}`,
|
||||
`refreshMinutes=${refreshMinutes}`,
|
||||
`cachePath=${this.knownWordCacheStatePath}`,
|
||||
);
|
||||
|
||||
this.loadKnownWordCacheState();
|
||||
void this.refreshKnownWords();
|
||||
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
|
||||
this.knownWordsRefreshTimer = setInterval(() => {
|
||||
void this.refreshKnownWords();
|
||||
}, refreshIntervalMs);
|
||||
}
|
||||
|
||||
private stopKnownWordCacheLifecycle(): void {
|
||||
if (this.knownWordsRefreshTimer) {
|
||||
clearInterval(this.knownWordsRefreshTimer);
|
||||
this.knownWordsRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshKnownWords(): Promise<void> {
|
||||
if (!this.isKnownWordCacheEnabled()) {
|
||||
log.debug("Known-word cache refresh skipped; feature disabled");
|
||||
return;
|
||||
}
|
||||
if (this.isRefreshingKnownWords) {
|
||||
log.debug("Known-word cache refresh skipped; already refreshing");
|
||||
return;
|
||||
}
|
||||
if (!this.isKnownWordCacheStale()) {
|
||||
log.debug("Known-word cache refresh skipped; cache is fresh");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRefreshingKnownWords = true;
|
||||
try {
|
||||
const query = this.buildKnownWordsQuery();
|
||||
log.debug("Refreshing known-word cache", `query=${query}`);
|
||||
const noteIds = (await this.client.findNotes(query, {
|
||||
maxRetries: 0,
|
||||
})) as number[];
|
||||
|
||||
const nextKnownWords = new Set<string>();
|
||||
if (noteIds.length > 0) {
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < noteIds.length; i += chunkSize) {
|
||||
const chunk = noteIds.slice(i, i + chunkSize);
|
||||
const notesInfoResult = (await this.client.notesInfo(chunk)) as unknown[];
|
||||
const notesInfo = notesInfoResult as NoteInfo[];
|
||||
|
||||
for (const noteInfo of notesInfo) {
|
||||
for (const word of this.extractKnownWordsFromNoteInfo(noteInfo)) {
|
||||
const normalized = this.normalizeKnownWordForLookup(word);
|
||||
if (normalized) {
|
||||
nextKnownWords.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.knownWords = nextKnownWords;
|
||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||
this.knownWordsScope = this.getKnownWordCacheScope();
|
||||
this.persistKnownWordCacheState();
|
||||
log.info(
|
||||
"Known-word cache refreshed",
|
||||
`noteCount=${noteIds.length}`,
|
||||
`wordCount=${nextKnownWords.size}`,
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
"Failed to refresh known-word cache:",
|
||||
(error as Error).message,
|
||||
);
|
||||
this.showStatusNotification("AnkiConnect: unable to refresh known words");
|
||||
} finally {
|
||||
this.isRefreshingKnownWords = false;
|
||||
}
|
||||
}
|
||||
|
||||
private getKnownWordDecks(): string[] {
|
||||
const configuredDecks = this.config.nPlusOne?.decks;
|
||||
if (Array.isArray(configuredDecks)) {
|
||||
const decks = configuredDecks
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
return [...new Set(decks)];
|
||||
}
|
||||
|
||||
const deck = this.config.deck?.trim();
|
||||
return deck ? [deck] : [];
|
||||
}
|
||||
|
||||
private buildKnownWordsQuery(): string {
|
||||
const decks = this.getKnownWordDecks();
|
||||
if (decks.length === 0) {
|
||||
return "is:note";
|
||||
}
|
||||
|
||||
if (decks.length === 1) {
|
||||
return `deck:"${escapeAnkiSearchValue(decks[0])}"`;
|
||||
}
|
||||
|
||||
const deckQueries = decks.map(
|
||||
(deck) => `deck:"${escapeAnkiSearchValue(deck)}"`,
|
||||
);
|
||||
return `(${deckQueries.join(" OR ")})`;
|
||||
}
|
||||
|
||||
private getKnownWordCacheScope(): string {
|
||||
const decks = this.getKnownWordDecks();
|
||||
if (decks.length === 0) {
|
||||
return "is:note";
|
||||
}
|
||||
return `decks:${JSON.stringify(decks)}`;
|
||||
}
|
||||
|
||||
private isKnownWordCacheStale(): boolean {
|
||||
if (!this.isKnownWordCacheEnabled()) {
|
||||
return true;
|
||||
}
|
||||
if (this.knownWordsScope !== this.getKnownWordCacheScope()) {
|
||||
return true;
|
||||
}
|
||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
Date.now() - this.knownWordsLastRefreshedAtMs >=
|
||||
this.getKnownWordRefreshIntervalMs()
|
||||
);
|
||||
}
|
||||
|
||||
private loadKnownWordCacheState(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.knownWordCacheStatePath)) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.knownWordsScope = this.getKnownWordCacheScope();
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(this.knownWordCacheStatePath, "utf-8");
|
||||
if (!raw.trim()) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.knownWordsScope = this.getKnownWordCacheScope();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!this.isKnownWordCacheStateValid(parsed)) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.knownWordsScope = this.getKnownWordCacheScope();
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.scope !== this.getKnownWordCacheScope()) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.knownWordsScope = this.getKnownWordCacheScope();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKnownWords = new Set<string>();
|
||||
for (const value of parsed.words) {
|
||||
const normalized = this.normalizeKnownWordForLookup(value);
|
||||
if (normalized) {
|
||||
nextKnownWords.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
this.knownWords = nextKnownWords;
|
||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||
this.knownWordsScope = parsed.scope;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
"Failed to load known-word cache state:",
|
||||
(error as Error).message,
|
||||
);
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.knownWordsScope = this.getKnownWordCacheScope();
|
||||
}
|
||||
}
|
||||
|
||||
private persistKnownWordCacheState(): void {
|
||||
try {
|
||||
const state: KnownWordCacheState = {
|
||||
version: 1,
|
||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||
scope: this.knownWordsScope,
|
||||
words: Array.from(this.knownWords),
|
||||
};
|
||||
fs.writeFileSync(this.knownWordCacheStatePath, JSON.stringify(state), "utf-8");
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
"Failed to persist known-word cache state:",
|
||||
(error as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private clearKnownWordCacheState(): void {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.knownWordsScope = this.getKnownWordCacheScope();
|
||||
try {
|
||||
if (fs.existsSync(this.knownWordCacheStatePath)) {
|
||||
fs.unlinkSync(this.knownWordCacheStatePath);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Failed to clear known-word cache state:", (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private isKnownWordCacheStateValid(
|
||||
value: unknown,
|
||||
): value is KnownWordCacheState {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as Partial<KnownWordCacheState>;
|
||||
if (candidate.version !== 1) return false;
|
||||
if (typeof candidate.refreshedAtMs !== "number") return false;
|
||||
if (typeof candidate.scope !== "string") return false;
|
||||
if (!Array.isArray(candidate.words)) return false;
|
||||
if (!candidate.words.every((entry) => typeof entry === "string")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractKnownWordsFromNoteInfo(noteInfo: NoteInfo): string[] {
|
||||
const words: string[] = [];
|
||||
const preferredFields = ["Expression", "Word"];
|
||||
for (const preferredField of preferredFields) {
|
||||
const fieldName = this.resolveFieldName(
|
||||
Object.keys(noteInfo.fields),
|
||||
preferredField,
|
||||
);
|
||||
if (!fieldName) continue;
|
||||
|
||||
const raw = noteInfo.fields[fieldName]?.value;
|
||||
if (!raw) continue;
|
||||
|
||||
const extracted = this.normalizeRawKnownWordValue(raw);
|
||||
if (extracted) {
|
||||
words.push(extracted);
|
||||
}
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void {
|
||||
if (!this.isKnownWordCacheEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentScope = this.getKnownWordCacheScope();
|
||||
if (this.knownWordsScope && this.knownWordsScope !== currentScope) {
|
||||
this.clearKnownWordCacheState();
|
||||
}
|
||||
if (!this.knownWordsScope) {
|
||||
this.knownWordsScope = currentScope;
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
for (const rawWord of this.extractKnownWordsFromNoteInfo(noteInfo)) {
|
||||
const normalized = this.normalizeKnownWordForLookup(rawWord);
|
||||
if (!normalized || this.knownWords.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
this.knownWords.add(normalized);
|
||||
addedCount += 1;
|
||||
}
|
||||
|
||||
if (addedCount > 0) {
|
||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||
}
|
||||
this.persistKnownWordCacheState();
|
||||
log.info(
|
||||
"Known-word cache updated in-session",
|
||||
`added=${addedCount}`,
|
||||
`scope=${currentScope}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeRawKnownWordValue(value: string): string {
|
||||
return value
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\u3000/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private normalizeKnownWordForLookup(value: string): string {
|
||||
return this.normalizeRawKnownWordValue(value).toLowerCase();
|
||||
}
|
||||
|
||||
private getLapisConfig(): {
|
||||
@@ -201,6 +568,7 @@ export class AnkiIntegration {
|
||||
"Starting AnkiConnect integration with polling rate:",
|
||||
this.config.pollingRate,
|
||||
);
|
||||
this.startKnownWordCacheLifecycle();
|
||||
this.poll();
|
||||
}
|
||||
|
||||
@@ -209,6 +577,7 @@ export class AnkiIntegration {
|
||||
clearInterval(this.pollingInterval);
|
||||
this.pollingInterval = null;
|
||||
}
|
||||
this.stopKnownWordCacheLifecycle();
|
||||
log.info("Stopped AnkiConnect integration");
|
||||
}
|
||||
|
||||
@@ -296,6 +665,7 @@ export class AnkiIntegration {
|
||||
}
|
||||
|
||||
const noteInfo = notesInfo[0];
|
||||
this.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
const fields = this.extractFields(noteInfo.fields);
|
||||
|
||||
const expressionText = fields.expression || fields.word || "";
|
||||
@@ -624,61 +994,60 @@ export class AnkiIntegration {
|
||||
}
|
||||
|
||||
private showStatusNotification(message: string): void {
|
||||
const type = this.config.behavior?.notificationType || "osd";
|
||||
|
||||
if (type === "osd" || type === "both") {
|
||||
this.showOsdNotification(message);
|
||||
}
|
||||
|
||||
if ((type === "system" || type === "both") && this.notificationCallback) {
|
||||
this.notificationCallback("SubMiner", { body: message });
|
||||
}
|
||||
showStatusNotification(message, {
|
||||
getNotificationType: () => this.config.behavior?.notificationType,
|
||||
showOsd: (text: string) => {
|
||||
this.showOsdNotification(text);
|
||||
},
|
||||
showSystemNotification: (
|
||||
title: string,
|
||||
options: NotificationOptions,
|
||||
) => {
|
||||
if (this.notificationCallback) {
|
||||
this.notificationCallback(title, options);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private beginUpdateProgress(initialMessage: string): void {
|
||||
this.progressDepth += 1;
|
||||
if (this.progressDepth > 1) return;
|
||||
|
||||
this.progressMessage = initialMessage;
|
||||
this.progressFrame = 0;
|
||||
this.showProgressTick();
|
||||
this.progressTimer = setInterval(() => {
|
||||
this.showProgressTick();
|
||||
}, 180);
|
||||
beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => {
|
||||
this.showOsdNotification(text);
|
||||
});
|
||||
}
|
||||
|
||||
private endUpdateProgress(): void {
|
||||
this.progressDepth = Math.max(0, this.progressDepth - 1);
|
||||
if (this.progressDepth > 0) return;
|
||||
|
||||
if (this.progressTimer) {
|
||||
clearInterval(this.progressTimer);
|
||||
this.progressTimer = null;
|
||||
}
|
||||
this.progressMessage = "";
|
||||
this.progressFrame = 0;
|
||||
endUpdateProgress(this.uiFeedbackState, (timer) => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
}
|
||||
|
||||
private showProgressTick(): void {
|
||||
if (!this.progressMessage) return;
|
||||
const frames = ["|", "/", "-", "\\"];
|
||||
const frame = frames[this.progressFrame % frames.length];
|
||||
this.progressFrame += 1;
|
||||
this.showOsdNotification(`${this.progressMessage} ${frame}`);
|
||||
showProgressTick(
|
||||
this.uiFeedbackState,
|
||||
(text: string) => {
|
||||
this.showOsdNotification(text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async withUpdateProgress<T>(
|
||||
initialMessage: string,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
this.beginUpdateProgress(initialMessage);
|
||||
this.updateInProgress = true;
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
this.updateInProgress = false;
|
||||
this.endUpdateProgress();
|
||||
}
|
||||
return withUpdateProgress(
|
||||
this.uiFeedbackState,
|
||||
{
|
||||
setUpdateInProgress: (value: boolean) => {
|
||||
this.updateInProgress = value;
|
||||
},
|
||||
showOsdNotification: (text: string) => {
|
||||
this.showOsdNotification(text);
|
||||
},
|
||||
},
|
||||
initialMessage,
|
||||
action,
|
||||
);
|
||||
}
|
||||
|
||||
private showOsdNotification(text: string): void {
|
||||
@@ -1436,33 +1805,16 @@ export class AnkiIntegration {
|
||||
|
||||
fields[sentenceField] = sentence;
|
||||
|
||||
const hasSecondarySub = Boolean(secondarySubText?.trim());
|
||||
let backText = secondarySubText?.trim() || "";
|
||||
const aiConfig = this.config.ai ?? DEFAULT_ANKI_CONNECT_CONFIG.ai;
|
||||
const aiEnabled = aiConfig?.enabled === true;
|
||||
const alwaysUseAiTranslation =
|
||||
aiConfig?.alwaysUseAiTranslation === true;
|
||||
const shouldAttemptAiTranslation =
|
||||
aiEnabled && (alwaysUseAiTranslation || !hasSecondarySub);
|
||||
if (shouldAttemptAiTranslation) {
|
||||
const request: AiTranslateRequest = {
|
||||
const backText = await resolveSentenceBackText(
|
||||
{
|
||||
sentence,
|
||||
apiKey: aiConfig?.apiKey || "",
|
||||
baseUrl: aiConfig?.baseUrl,
|
||||
model: aiConfig?.model,
|
||||
targetLanguage: aiConfig?.targetLanguage,
|
||||
systemPrompt: aiConfig?.systemPrompt,
|
||||
};
|
||||
const callbacks: AiTranslateCallbacks = {
|
||||
secondarySubText,
|
||||
config: this.config.ai || {},
|
||||
},
|
||||
{
|
||||
logWarning: (message: string) => log.warn(message),
|
||||
};
|
||||
const translated = await translateSentenceWithAi(request, callbacks);
|
||||
if (translated) {
|
||||
backText = translated;
|
||||
} else if (!hasSecondarySub) {
|
||||
backText = sentence;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if (backText) {
|
||||
fields[translationField] = backText;
|
||||
}
|
||||
@@ -1498,6 +1850,7 @@ export class AnkiIntegration {
|
||||
const noteInfos = noteInfoResult as unknown as NoteInfo[];
|
||||
if (noteInfos.length > 0) {
|
||||
const createdNoteInfo = noteInfos[0];
|
||||
this.appendKnownWordsFromNoteInfo(createdNoteInfo);
|
||||
resolvedSentenceAudioField =
|
||||
this.resolveNoteFieldName(createdNoteInfo, audioFieldName) ||
|
||||
audioFieldName;
|
||||
@@ -1639,78 +1992,23 @@ export class AnkiIntegration {
|
||||
excludeNoteId: number,
|
||||
noteInfo: NoteInfo,
|
||||
): Promise<number | null> {
|
||||
let fieldName = "";
|
||||
for (const name of Object.keys(noteInfo.fields)) {
|
||||
if (
|
||||
["word", "expression"].includes(name.toLowerCase()) &&
|
||||
noteInfo.fields[name].value
|
||||
) {
|
||||
fieldName = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!fieldName) return null;
|
||||
|
||||
const escapedFieldName = this.escapeAnkiSearchValue(fieldName);
|
||||
const escapedExpression = this.escapeAnkiSearchValue(expression);
|
||||
const deckPrefix = this.config.deck
|
||||
? `"deck:${this.escapeAnkiSearchValue(this.config.deck)}" `
|
||||
: "";
|
||||
const query = `${deckPrefix}"${escapedFieldName}:${escapedExpression}"`;
|
||||
|
||||
try {
|
||||
const noteIds = (await this.client.findNotes(query)) as number[];
|
||||
return await this.findFirstExactDuplicateNoteId(
|
||||
noteIds,
|
||||
excludeNoteId,
|
||||
fieldName,
|
||||
expression,
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn("Duplicate search failed:", (error as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private escapeAnkiSearchValue(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/([:*?()[\]{}])/g, "\\$1");
|
||||
}
|
||||
|
||||
private normalizeDuplicateValue(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
private async findFirstExactDuplicateNoteId(
|
||||
candidateNoteIds: number[],
|
||||
excludeNoteId: number,
|
||||
fieldName: string,
|
||||
expression: string,
|
||||
): Promise<number | null> {
|
||||
const candidates = candidateNoteIds.filter((id) => id !== excludeNoteId);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const normalizedExpression = this.normalizeDuplicateValue(expression);
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < candidates.length; i += chunkSize) {
|
||||
const chunk = candidates.slice(i, i + chunkSize);
|
||||
const notesInfoResult = await this.client.notesInfo(chunk);
|
||||
const notesInfo = notesInfoResult as unknown as NoteInfo[];
|
||||
for (const noteInfo of notesInfo) {
|
||||
const resolvedField = this.resolveNoteFieldName(noteInfo, fieldName);
|
||||
if (!resolvedField) continue;
|
||||
const candidateValue = noteInfo.fields[resolvedField]?.value || "";
|
||||
if (
|
||||
this.normalizeDuplicateValue(candidateValue) === normalizedExpression
|
||||
) {
|
||||
return noteInfo.noteId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return findDuplicateNoteForAnkiIntegration(
|
||||
expression,
|
||||
excludeNoteId,
|
||||
noteInfo,
|
||||
{
|
||||
findNotes: async (query, options) =>
|
||||
(await this.client.findNotes(query, options)) as unknown,
|
||||
notesInfo: async (noteIds) =>
|
||||
(await this.client.notesInfo(noteIds)) as unknown,
|
||||
getDeck: () => this.config.deck,
|
||||
resolveFieldName: (info, preferredName) =>
|
||||
this.resolveNoteFieldName(info, preferredName),
|
||||
logWarn: (message, error) => {
|
||||
log.warn(message, (error as Error).message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private getGroupableFieldNames(): string[] {
|
||||
@@ -2559,10 +2857,18 @@ export class AnkiIntegration {
|
||||
}
|
||||
|
||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
||||
const wasEnabled = this.config.nPlusOne?.highlightEnabled === true;
|
||||
const previousPollingRate = this.config.pollingRate;
|
||||
this.config = {
|
||||
...this.config,
|
||||
...patch,
|
||||
nPlusOne:
|
||||
patch.nPlusOne !== undefined
|
||||
? {
|
||||
...(this.config.nPlusOne ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne),
|
||||
...patch.nPlusOne,
|
||||
}
|
||||
: this.config.nPlusOne,
|
||||
fields:
|
||||
patch.fields !== undefined
|
||||
? { ...this.config.fields, ...patch.fields }
|
||||
@@ -2589,6 +2895,21 @@ export class AnkiIntegration {
|
||||
: this.config.isKiku,
|
||||
};
|
||||
|
||||
if (
|
||||
wasEnabled &&
|
||||
this.config.nPlusOne?.highlightEnabled === false
|
||||
) {
|
||||
this.stopKnownWordCacheLifecycle();
|
||||
this.clearKnownWordCacheState();
|
||||
} else if (
|
||||
!wasEnabled &&
|
||||
this.config.nPlusOne?.highlightEnabled === true
|
||||
) {
|
||||
this.startKnownWordCacheLifecycle();
|
||||
} else {
|
||||
this.startKnownWordCacheLifecycle();
|
||||
}
|
||||
|
||||
if (
|
||||
patch.pollingRate !== undefined &&
|
||||
previousPollingRate !== this.config.pollingRate &&
|
||||
@@ -2605,3 +2926,10 @@ export class AnkiIntegration {
|
||||
this.mediaGenerator.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAnkiSearchValue(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/([:*?()[\]{}])/g, "\\$1");
|
||||
}
|
||||
|
||||
@@ -51,6 +51,24 @@ export interface AiTranslateCallbacks {
|
||||
logWarning: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface AiSentenceTranslationInput {
|
||||
sentence: string;
|
||||
secondarySubText?: string;
|
||||
config: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
targetLanguage?: string;
|
||||
systemPrompt?: string;
|
||||
enabled?: boolean;
|
||||
alwaysUseAiTranslation?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AiSentenceTranslationCallbacks {
|
||||
logWarning: (message: string) => void;
|
||||
}
|
||||
|
||||
export async function translateSentenceWithAi(
|
||||
request: AiTranslateRequest,
|
||||
callbacks: AiTranslateCallbacks,
|
||||
@@ -101,3 +119,40 @@ export async function translateSentenceWithAi(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSentenceBackText(
|
||||
input: AiSentenceTranslationInput,
|
||||
callbacks: AiSentenceTranslationCallbacks,
|
||||
): Promise<string> {
|
||||
const hasSecondarySub = Boolean(input.secondarySubText?.trim());
|
||||
let backText = input.secondarySubText?.trim() || "";
|
||||
|
||||
const aiConfig = {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
|
||||
...input.config,
|
||||
};
|
||||
const shouldAttemptAiTranslation =
|
||||
aiConfig.enabled === true &&
|
||||
(aiConfig.alwaysUseAiTranslation === true || !hasSecondarySub);
|
||||
|
||||
if (!shouldAttemptAiTranslation) return backText;
|
||||
|
||||
const request: AiTranslateRequest = {
|
||||
sentence: input.sentence,
|
||||
apiKey: aiConfig.apiKey ?? "",
|
||||
baseUrl: aiConfig.baseUrl,
|
||||
model: aiConfig.model,
|
||||
targetLanguage: aiConfig.targetLanguage,
|
||||
systemPrompt: aiConfig.systemPrompt,
|
||||
};
|
||||
|
||||
const translated = await translateSentenceWithAi(request, {
|
||||
logWarning: (message) => callbacks.logWarning(message),
|
||||
});
|
||||
|
||||
if (translated) {
|
||||
return translated;
|
||||
}
|
||||
|
||||
return hasSecondarySub ? backText : input.sentence;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,198 @@ test("parses invisible overlay config and new global shortcuts", () => {
|
||||
|
||||
test("runtime options registry is centralized", () => {
|
||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||
assert.deepEqual(ids, ["anki.autoUpdateNewCards", "anki.kikuFieldGrouping"]);
|
||||
assert.deepEqual(ids, [
|
||||
"anki.autoUpdateNewCards",
|
||||
"anki.nPlusOneMatchMode",
|
||||
"anki.kikuFieldGrouping",
|
||||
]);
|
||||
});
|
||||
|
||||
test("validates ankiConnect n+1 behavior values", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "config.jsonc"),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"highlightEnabled": "yes",
|
||||
"refreshMinutes": -5
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(
|
||||
config.ankiConnect.nPlusOne.highlightEnabled,
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
|
||||
);
|
||||
assert.equal(
|
||||
config.ankiConnect.nPlusOne.refreshMinutes,
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) => warning.path === "ankiConnect.nPlusOne.highlightEnabled",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) => warning.path === "ankiConnect.nPlusOne.refreshMinutes",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts valid ankiConnect n+1 behavior values", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "config.jsonc"),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"highlightEnabled": true,
|
||||
"refreshMinutes": 120
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
|
||||
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 120);
|
||||
});
|
||||
|
||||
test("validates ankiConnect n+1 match mode values", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "config.jsonc"),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"matchMode": "bad-mode"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(
|
||||
config.ankiConnect.nPlusOne.matchMode,
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some((warning) =>
|
||||
warning.path === "ankiConnect.nPlusOne.matchMode",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts valid ankiConnect n+1 match mode values", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "config.jsonc"),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"matchMode": "surface"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.matchMode, "surface");
|
||||
});
|
||||
|
||||
test("supports legacy ankiConnect.behavior N+1 settings as fallback", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "config.jsonc"),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"behavior": {
|
||||
"nPlusOneHighlightEnabled": true,
|
||||
"nPlusOneRefreshMinutes": 90,
|
||||
"nPlusOneMatchMode": "surface"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
|
||||
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 90);
|
||||
assert.equal(config.ankiConnect.nPlusOne.matchMode, "surface");
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === "ankiConnect.behavior.nPlusOneHighlightEnabled" ||
|
||||
warning.path === "ankiConnect.behavior.nPlusOneRefreshMinutes" ||
|
||||
warning.path === "ankiConnect.behavior.nPlusOneMatchMode",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts valid ankiConnect n+1 deck list", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "config.jsonc"),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"decks": ["Deck One", "Deck Two"]
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.deepEqual(config.ankiConnect.nPlusOne.decks, ["Deck One", "Deck Two"]);
|
||||
});
|
||||
|
||||
test("falls back to default when ankiConnect n+1 deck list is invalid", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "config.jsonc"),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"decks": "not-an-array"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.deepEqual(config.ankiConnect.nPlusOne.decks, []);
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === "ankiConnect.nPlusOne.decks"),
|
||||
);
|
||||
});
|
||||
|
||||
test("template generator includes known keys", () => {
|
||||
|
||||
@@ -123,6 +123,12 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
notificationType: "osd",
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
nPlusOne: {
|
||||
highlightEnabled: false,
|
||||
refreshMinutes: 1440,
|
||||
matchMode: "headword",
|
||||
decks: [],
|
||||
},
|
||||
metadata: {
|
||||
pattern: "[SubMiner] %f (%t)",
|
||||
},
|
||||
@@ -218,6 +224,23 @@ export const RUNTIME_OPTION_REGISTRY: RuntimeOptionRegistryEntry[] = [
|
||||
behavior: { autoUpdateNewCards: value === true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "anki.nPlusOneMatchMode",
|
||||
path: "ankiConnect.nPlusOne.matchMode",
|
||||
label: "N+1 Match Mode",
|
||||
scope: "ankiConnect",
|
||||
valueType: "enum",
|
||||
allowedValues: ["headword", "surface"],
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => String(value),
|
||||
toAnkiPatch: (value) => ({
|
||||
nPlusOne: {
|
||||
matchMode:
|
||||
value === "headword" || value === "surface" ? value : "headword",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "anki.kikuFieldGrouping",
|
||||
path: "ankiConnect.isKiku.fieldGrouping",
|
||||
@@ -272,6 +295,32 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
||||
description: "Automatically update newly added cards.",
|
||||
runtime: RUNTIME_OPTION_REGISTRY[0],
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.nPlusOne.matchMode",
|
||||
kind: "enum",
|
||||
enumValues: ["headword", "surface"],
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
|
||||
description: "Known-word matching strategy for N+1 highlighting.",
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.nPlusOne.highlightEnabled",
|
||||
kind: "boolean",
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
|
||||
description: "Enable fast local highlighting for words already known in Anki.",
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.nPlusOne.refreshMinutes",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
|
||||
description: "Minutes between known-word cache refreshes.",
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.nPlusOne.decks",
|
||||
kind: "array",
|
||||
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.decks,
|
||||
description:
|
||||
"Decks used for N+1 known-word cache scope. Supports one or more deck names.",
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.isKiku.fieldGrouping",
|
||||
kind: "enum",
|
||||
|
||||
@@ -437,6 +437,9 @@ export class ConfigService {
|
||||
|
||||
if (isObject(src.ankiConnect)) {
|
||||
const ac = src.ankiConnect;
|
||||
const behavior = isObject(ac.behavior)
|
||||
? (ac.behavior as Record<string, unknown>)
|
||||
: {};
|
||||
const aiSource = isObject(ac.ai)
|
||||
? ac.ai
|
||||
: isObject(ac.openRouter)
|
||||
@@ -580,6 +583,159 @@ export class ConfigService {
|
||||
resolved.ankiConnect.behavior.autoUpdateNewCards = value as boolean;
|
||||
});
|
||||
|
||||
const nPlusOneConfig = isObject(ac.nPlusOne)
|
||||
? (ac.nPlusOne as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const nPlusOneHighlightEnabled = asBoolean(
|
||||
nPlusOneConfig.highlightEnabled,
|
||||
);
|
||||
if (nPlusOneHighlightEnabled !== undefined) {
|
||||
resolved.ankiConnect.nPlusOne.highlightEnabled =
|
||||
nPlusOneHighlightEnabled;
|
||||
} else {
|
||||
const legacyNPlusOneHighlightEnabled = asBoolean(
|
||||
behavior.nPlusOneHighlightEnabled,
|
||||
);
|
||||
if (legacyNPlusOneHighlightEnabled !== undefined) {
|
||||
resolved.ankiConnect.nPlusOne.highlightEnabled =
|
||||
legacyNPlusOneHighlightEnabled;
|
||||
warn(
|
||||
"ankiConnect.behavior.nPlusOneHighlightEnabled",
|
||||
behavior.nPlusOneHighlightEnabled,
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
|
||||
"Legacy key is deprecated; use ankiConnect.nPlusOne.highlightEnabled",
|
||||
);
|
||||
} else {
|
||||
resolved.ankiConnect.nPlusOne.highlightEnabled =
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
const nPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
|
||||
const hasValidNPlusOneRefreshMinutes =
|
||||
nPlusOneRefreshMinutes !== undefined &&
|
||||
Number.isInteger(nPlusOneRefreshMinutes) &&
|
||||
nPlusOneRefreshMinutes > 0;
|
||||
if (nPlusOneRefreshMinutes !== undefined) {
|
||||
if (hasValidNPlusOneRefreshMinutes) {
|
||||
resolved.ankiConnect.nPlusOne.refreshMinutes =
|
||||
nPlusOneRefreshMinutes;
|
||||
} else {
|
||||
warn(
|
||||
"ankiConnect.nPlusOne.refreshMinutes",
|
||||
nPlusOneConfig.refreshMinutes,
|
||||
resolved.ankiConnect.nPlusOne.refreshMinutes,
|
||||
"Expected a positive integer.",
|
||||
);
|
||||
resolved.ankiConnect.nPlusOne.refreshMinutes =
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
|
||||
}
|
||||
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
|
||||
const legacyNPlusOneRefreshMinutes = asNumber(
|
||||
behavior.nPlusOneRefreshMinutes,
|
||||
);
|
||||
const hasValidLegacyRefreshMinutes =
|
||||
legacyNPlusOneRefreshMinutes !== undefined &&
|
||||
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
|
||||
legacyNPlusOneRefreshMinutes > 0;
|
||||
if (hasValidLegacyRefreshMinutes) {
|
||||
resolved.ankiConnect.nPlusOne.refreshMinutes =
|
||||
legacyNPlusOneRefreshMinutes;
|
||||
warn(
|
||||
"ankiConnect.behavior.nPlusOneRefreshMinutes",
|
||||
behavior.nPlusOneRefreshMinutes,
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
|
||||
"Legacy key is deprecated; use ankiConnect.nPlusOne.refreshMinutes",
|
||||
);
|
||||
} else {
|
||||
warn(
|
||||
"ankiConnect.behavior.nPlusOneRefreshMinutes",
|
||||
behavior.nPlusOneRefreshMinutes,
|
||||
resolved.ankiConnect.nPlusOne.refreshMinutes,
|
||||
"Expected a positive integer.",
|
||||
);
|
||||
resolved.ankiConnect.nPlusOne.refreshMinutes =
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
|
||||
}
|
||||
} else {
|
||||
resolved.ankiConnect.nPlusOne.refreshMinutes =
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes;
|
||||
}
|
||||
|
||||
const nPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
|
||||
const legacyNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
|
||||
const hasValidNPlusOneMatchMode =
|
||||
nPlusOneMatchMode === "headword" || nPlusOneMatchMode === "surface";
|
||||
const hasValidLegacyMatchMode =
|
||||
legacyNPlusOneMatchMode === "headword" ||
|
||||
legacyNPlusOneMatchMode === "surface";
|
||||
if (hasValidNPlusOneMatchMode) {
|
||||
resolved.ankiConnect.nPlusOne.matchMode = nPlusOneMatchMode;
|
||||
} else if (nPlusOneMatchMode !== undefined) {
|
||||
warn(
|
||||
"ankiConnect.nPlusOne.matchMode",
|
||||
nPlusOneConfig.matchMode,
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
|
||||
"Expected 'headword' or 'surface'.",
|
||||
);
|
||||
resolved.ankiConnect.nPlusOne.matchMode =
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
|
||||
} else if (legacyNPlusOneMatchMode !== undefined) {
|
||||
if (hasValidLegacyMatchMode) {
|
||||
resolved.ankiConnect.nPlusOne.matchMode =
|
||||
legacyNPlusOneMatchMode;
|
||||
warn(
|
||||
"ankiConnect.behavior.nPlusOneMatchMode",
|
||||
behavior.nPlusOneMatchMode,
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
|
||||
"Legacy key is deprecated; use ankiConnect.nPlusOne.matchMode",
|
||||
);
|
||||
} else {
|
||||
warn(
|
||||
"ankiConnect.behavior.nPlusOneMatchMode",
|
||||
behavior.nPlusOneMatchMode,
|
||||
resolved.ankiConnect.nPlusOne.matchMode,
|
||||
"Expected 'headword' or 'surface'.",
|
||||
);
|
||||
resolved.ankiConnect.nPlusOne.matchMode =
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
|
||||
}
|
||||
} else {
|
||||
resolved.ankiConnect.nPlusOne.matchMode =
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
|
||||
}
|
||||
|
||||
const nPlusOneDecks = nPlusOneConfig.decks;
|
||||
if (Array.isArray(nPlusOneDecks)) {
|
||||
const normalizedDecks = nPlusOneDecks
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
if (normalizedDecks.length === nPlusOneDecks.length) {
|
||||
resolved.ankiConnect.nPlusOne.decks = [
|
||||
...new Set(normalizedDecks),
|
||||
];
|
||||
} else if (nPlusOneDecks.length > 0) {
|
||||
warn(
|
||||
"ankiConnect.nPlusOne.decks",
|
||||
nPlusOneDecks,
|
||||
resolved.ankiConnect.nPlusOne.decks,
|
||||
"Expected an array of strings.",
|
||||
);
|
||||
} else {
|
||||
resolved.ankiConnect.nPlusOne.decks = [];
|
||||
}
|
||||
} else if (nPlusOneDecks !== undefined) {
|
||||
warn(
|
||||
"ankiConnect.nPlusOne.decks",
|
||||
nPlusOneDecks,
|
||||
resolved.ankiConnect.nPlusOne.decks,
|
||||
"Expected an array of strings.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
resolved.ankiConnect.isKiku.fieldGrouping !== "auto" &&
|
||||
resolved.ankiConnect.isKiku.fieldGrouping !== "manual" &&
|
||||
|
||||
@@ -45,6 +45,8 @@ function createHarness(): RuntimeHarness {
|
||||
setAnkiIntegration: (integration) => {
|
||||
state.ankiIntegration = integration;
|
||||
},
|
||||
getKnownWordCacheStatePath: () =>
|
||||
"/tmp/subminer-known-words-cache.json",
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
getAnkiIntegration: () => AnkiIntegration | null;
|
||||
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
@@ -87,6 +88,7 @@ export function registerAnkiJimakuIpcRuntimeService(
|
||||
},
|
||||
options.showDesktopNotification,
|
||||
options.createFieldGroupingCallback(),
|
||||
options.getKnownWordCacheStatePath(),
|
||||
);
|
||||
integration.start();
|
||||
options.setAnkiIntegration(integration);
|
||||
|
||||
@@ -35,6 +35,7 @@ export function initializeOverlayRuntimeService(options: {
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
}): {
|
||||
invisibleOverlayVisible: boolean;
|
||||
} {
|
||||
@@ -98,6 +99,7 @@ export function initializeOverlayRuntimeService(options: {
|
||||
},
|
||||
options.showDesktopNotification,
|
||||
options.createFieldGroupingCallback(),
|
||||
options.getKnownWordCacheStatePath(),
|
||||
);
|
||||
integration.start();
|
||||
options.setAnkiIntegration(integration);
|
||||
|
||||
@@ -14,6 +14,8 @@ function makeDeps(
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
getYomitanParserInitPromise: () => null,
|
||||
setYomitanParserInitPromise: () => {},
|
||||
isKnownWord: () => false,
|
||||
getKnownWordMatchMode: () => "headword",
|
||||
tokenizeWithMecab: async () => null,
|
||||
...overrides,
|
||||
};
|
||||
@@ -32,7 +34,7 @@ test("tokenizeSubtitleService normalizes newlines before mecab fallback", async
|
||||
tokenizeWithMecab: async (text) => {
|
||||
tokenizeInput = text;
|
||||
return [
|
||||
{
|
||||
{
|
||||
surface: "猫ですね",
|
||||
reading: "ネコデスネ",
|
||||
headword: "猫ですね",
|
||||
@@ -40,6 +42,7 @@ test("tokenizeSubtitleService normalizes newlines before mecab fallback", async
|
||||
endPos: 4,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: true,
|
||||
isKnown: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
@@ -64,6 +67,7 @@ test("tokenizeSubtitleService falls back to mecab tokens when available", async
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -126,4 +130,78 @@ test("tokenizeSubtitleService uses Yomitan parser result when available", async
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.surface, "猫です");
|
||||
assert.equal(result.tokens?.[0]?.reading, "ねこです");
|
||||
assert.equal(result.tokens?.[0]?.isKnown, false);
|
||||
});
|
||||
|
||||
test("tokenizeSubtitleService marks tokens as known using callback", async () => {
|
||||
const result = await tokenizeSubtitleService(
|
||||
"猫です",
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === "猫",
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
surface: "猫",
|
||||
reading: "ネコ",
|
||||
headword: "猫",
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.text, "猫です");
|
||||
assert.equal(result.tokens?.[0]?.isKnown, true);
|
||||
});
|
||||
|
||||
test("tokenizeSubtitleService checks known words by headword, not surface", async () => {
|
||||
const result = await tokenizeSubtitleService(
|
||||
"猫です",
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === "猫です",
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
surface: "猫",
|
||||
reading: "ネコ",
|
||||
headword: "猫です",
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.text, "猫です");
|
||||
assert.equal(result.tokens?.[0]?.isKnown, true);
|
||||
});
|
||||
|
||||
test("tokenizeSubtitleService checks known words by surface when configured", async () => {
|
||||
const result = await tokenizeSubtitleService(
|
||||
"猫です",
|
||||
makeDeps({
|
||||
getKnownWordMatchMode: () => "surface",
|
||||
isKnownWord: (text) => text === "猫",
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
surface: "猫",
|
||||
reading: "ネコ",
|
||||
headword: "猫です",
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.text, "猫です");
|
||||
assert.equal(result.tokens?.[0]?.isKnown, true);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { BrowserWindow, Extension, session } from "electron";
|
||||
import { mergeTokens } from "../../token-merger";
|
||||
import { MergedToken, PartOfSpeech, SubtitleData, Token } from "../../types";
|
||||
import {
|
||||
MergedToken,
|
||||
NPlusOneMatchMode,
|
||||
PartOfSpeech,
|
||||
SubtitleData,
|
||||
Token,
|
||||
} from "../../types";
|
||||
|
||||
interface YomitanParseHeadword {
|
||||
term?: unknown;
|
||||
@@ -26,6 +32,8 @@ export interface TokenizerServiceDeps {
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: (text: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
|
||||
}
|
||||
|
||||
@@ -41,6 +49,8 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: (text: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getMecabTokenizer: () => MecabTokenizerLike | null;
|
||||
}
|
||||
|
||||
@@ -55,6 +65,8 @@ export function createTokenizerDepsRuntimeService(
|
||||
setYomitanParserReadyPromise: options.setYomitanParserReadyPromise,
|
||||
getYomitanParserInitPromise: options.getYomitanParserInitPromise,
|
||||
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
|
||||
isKnownWord: options.isKnownWord,
|
||||
getKnownWordMatchMode: options.getKnownWordMatchMode,
|
||||
tokenizeWithMecab: async (text) => {
|
||||
const mecabTokenizer = options.getMecabTokenizer();
|
||||
if (!mecabTokenizer) {
|
||||
@@ -64,11 +76,23 @@ export function createTokenizerDepsRuntimeService(
|
||||
if (!rawTokens || rawTokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return mergeTokens(rawTokens);
|
||||
return mergeTokens(
|
||||
rawTokens,
|
||||
options.isKnownWord,
|
||||
options.getKnownWordMatchMode(),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveKnownWordText(
|
||||
surface: string,
|
||||
headword: string,
|
||||
matchMode: NPlusOneMatchMode,
|
||||
): string {
|
||||
return matchMode === "surface" ? surface : headword;
|
||||
}
|
||||
|
||||
function extractYomitanHeadword(segment: YomitanParseSegment): string {
|
||||
const headwords = segment.headwords;
|
||||
if (!Array.isArray(headwords) || headwords.length === 0) {
|
||||
@@ -86,6 +110,8 @@ function extractYomitanHeadword(segment: YomitanParseSegment): string {
|
||||
|
||||
function mapYomitanParseResultsToMergedTokens(
|
||||
parseResults: unknown,
|
||||
isKnownWord: (text: string) => boolean,
|
||||
knownWordMatchMode: NPlusOneMatchMode,
|
||||
): MergedToken[] | null {
|
||||
if (!Array.isArray(parseResults) || parseResults.length === 0) {
|
||||
return null;
|
||||
@@ -161,6 +187,14 @@ function mapYomitanParseResultsToMergedTokens(
|
||||
endPos: end,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: true,
|
||||
isKnown: (() => {
|
||||
const matchText = resolveKnownWordText(
|
||||
surface,
|
||||
headword,
|
||||
knownWordMatchMode,
|
||||
);
|
||||
return matchText ? isKnownWord(matchText) : false;
|
||||
})(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -302,7 +336,11 @@ async function parseWithYomitanInternalParser(
|
||||
script,
|
||||
true,
|
||||
);
|
||||
return mapYomitanParseResultsToMergedTokens(parseResults);
|
||||
return mapYomitanParseResultsToMergedTokens(
|
||||
parseResults,
|
||||
deps.isKnownWord,
|
||||
deps.getKnownWordMatchMode(),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Yomitan parser request failed:", (err as Error).message);
|
||||
return null;
|
||||
|
||||
256
src/main.ts
256
src/main.ts
@@ -89,7 +89,6 @@ import {
|
||||
applyMpvSubtitleRenderMetricsPatchService,
|
||||
broadcastRuntimeOptionsChangedRuntimeService,
|
||||
copyCurrentSubtitleService,
|
||||
createAppLifecycleDepsRuntimeService,
|
||||
createOverlayManagerService,
|
||||
createFieldGroupingOverlayRuntimeService,
|
||||
createNumericShortcutRuntimeService,
|
||||
@@ -128,7 +127,6 @@ import {
|
||||
shouldAutoInitializeOverlayRuntimeFromConfigService,
|
||||
shouldBindVisibleOverlayToMpvSubVisibilityService,
|
||||
showMpvOsdRuntimeService,
|
||||
startAppLifecycleService,
|
||||
syncInvisibleOverlayMousePassthroughService,
|
||||
tokenizeSubtitleService,
|
||||
triggerFieldGroupingService,
|
||||
@@ -137,13 +135,9 @@ import {
|
||||
updateLastCardFromClipboardService,
|
||||
updateVisibleOverlayVisibilityService,
|
||||
} from "./core/services";
|
||||
import {
|
||||
runAppReadyRuntimeService,
|
||||
} from "./core/services/startup-service";
|
||||
import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service";
|
||||
import {
|
||||
createAppLifecycleRuntimeDeps,
|
||||
createAppReadyRuntimeDeps,
|
||||
createAppReadyRuntimeRunner,
|
||||
} from "./main/app-lifecycle";
|
||||
import { handleMpvCommandFromIpcRuntime } from "./main/ipc-mpv-command";
|
||||
import {
|
||||
@@ -158,6 +152,7 @@ import {
|
||||
import {
|
||||
runSubsyncManualFromIpcRuntime,
|
||||
triggerSubsyncFromConfigRuntime,
|
||||
createSubsyncRuntimeServiceInputFromState,
|
||||
} from "./main/subsync-runtime";
|
||||
import {
|
||||
createOverlayModalRuntimeService,
|
||||
@@ -171,6 +166,7 @@ import {
|
||||
createAppState,
|
||||
} from "./main/state";
|
||||
import { createStartupBootstrapRuntimeDeps } from "./main/startup";
|
||||
import { createAppLifecycleRuntimeRunner } from "./main/startup-lifecycle";
|
||||
import {
|
||||
ConfigService,
|
||||
DEFAULT_CONFIG,
|
||||
@@ -562,130 +558,113 @@ const startupState = runStartupBootstrapRuntimeService(
|
||||
process.exitCode = 1;
|
||||
app.quit();
|
||||
},
|
||||
startAppLifecycle: (args: CliArgs) => {
|
||||
startAppLifecycleService(
|
||||
args,
|
||||
createAppLifecycleDepsRuntimeService(
|
||||
createAppLifecycleRuntimeDeps({
|
||||
app,
|
||||
platform: process.platform,
|
||||
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
|
||||
parseArgs: (argv: string[]) => parseArgs(argv),
|
||||
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
|
||||
handleCliCommand(nextArgs, source),
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
|
||||
onReady: async () => {
|
||||
await runAppReadyRuntimeService(
|
||||
createAppReadyRuntimeDeps({
|
||||
loadSubtitlePosition: () => loadSubtitlePosition(),
|
||||
resolveKeybindings: () => {
|
||||
appState.keybindings = resolveKeybindings(
|
||||
getResolvedConfig(),
|
||||
DEFAULT_KEYBINDINGS,
|
||||
);
|
||||
},
|
||||
createMpvClient: () => {
|
||||
appState.mpvClient = createMpvClientRuntimeService();
|
||||
},
|
||||
reloadConfig: () => {
|
||||
configService.reloadConfig();
|
||||
appLogger.logInfo(
|
||||
`Using config file: ${configService.getConfigPath()}`,
|
||||
);
|
||||
},
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
getConfigWarnings: () => configService.getWarnings(),
|
||||
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
|
||||
initRuntimeOptionsManager: () => {
|
||||
appState.runtimeOptionsManager = new RuntimeOptionsManager(
|
||||
() => configService.getConfig().ankiConnect,
|
||||
{
|
||||
applyAnkiPatch: (patch) => {
|
||||
if (appState.ankiIntegration) {
|
||||
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
|
||||
}
|
||||
},
|
||||
onOptionsChanged: () => {
|
||||
broadcastRuntimeOptionsChanged();
|
||||
refreshOverlayShortcuts();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => {
|
||||
appState.secondarySubMode = mode;
|
||||
},
|
||||
defaultSecondarySubMode: "hover",
|
||||
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
|
||||
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
|
||||
startSubtitleWebsocket: (port: number) => {
|
||||
subtitleWsService.start(port, () => appState.currentSubText);
|
||||
},
|
||||
log: (message) => appLogger.logInfo(message),
|
||||
createMecabTokenizerAndCheck: async () => {
|
||||
const tokenizer = new MecabTokenizer();
|
||||
appState.mecabTokenizer = tokenizer;
|
||||
await tokenizer.checkAvailability();
|
||||
},
|
||||
createSubtitleTimingTracker: () => {
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
appState.subtitleTimingTracker = tracker;
|
||||
},
|
||||
loadYomitanExtension: async () => {
|
||||
await loadYomitanExtension();
|
||||
},
|
||||
texthookerOnlyMode: appState.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
handleInitialArgs: () => handleInitialArgs(),
|
||||
}),
|
||||
);
|
||||
startAppLifecycle: createAppLifecycleRuntimeRunner({
|
||||
app,
|
||||
platform: process.platform,
|
||||
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
|
||||
parseArgs: (argv: string[]) => parseArgs(argv),
|
||||
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
|
||||
handleCliCommand(nextArgs, source),
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
|
||||
onReady: createAppReadyRuntimeRunner({
|
||||
loadSubtitlePosition: () => loadSubtitlePosition(),
|
||||
resolveKeybindings: () => {
|
||||
appState.keybindings = resolveKeybindings(
|
||||
getResolvedConfig(),
|
||||
DEFAULT_KEYBINDINGS,
|
||||
);
|
||||
},
|
||||
createMpvClient: () => {
|
||||
appState.mpvClient = createMpvClientRuntimeService();
|
||||
},
|
||||
reloadConfig: () => {
|
||||
configService.reloadConfig();
|
||||
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`);
|
||||
},
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
getConfigWarnings: () => configService.getWarnings(),
|
||||
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
|
||||
initRuntimeOptionsManager: () => {
|
||||
appState.runtimeOptionsManager = new RuntimeOptionsManager(
|
||||
() => configService.getConfig().ankiConnect,
|
||||
{
|
||||
applyAnkiPatch: (patch) => {
|
||||
if (appState.ankiIntegration) {
|
||||
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
|
||||
}
|
||||
},
|
||||
onOptionsChanged: () => {
|
||||
broadcastRuntimeOptionsChanged();
|
||||
refreshOverlayShortcuts();
|
||||
},
|
||||
},
|
||||
onWillQuitCleanup: () => {
|
||||
restorePreviousSecondarySubVisibility();
|
||||
globalShortcut.unregisterAll();
|
||||
subtitleWsService.stop();
|
||||
texthookerService.stop();
|
||||
if (
|
||||
appState.yomitanParserWindow &&
|
||||
!appState.yomitanParserWindow.isDestroyed()
|
||||
) {
|
||||
appState.yomitanParserWindow.destroy();
|
||||
}
|
||||
appState.yomitanParserWindow = null;
|
||||
appState.yomitanParserReadyPromise = null;
|
||||
appState.yomitanParserInitPromise = null;
|
||||
if (appState.windowTracker) {
|
||||
appState.windowTracker.stop();
|
||||
}
|
||||
if (appState.mpvClient && appState.mpvClient.socket) {
|
||||
appState.mpvClient.socket.destroy();
|
||||
}
|
||||
if (appState.reconnectTimer) {
|
||||
clearTimeout(appState.reconnectTimer);
|
||||
}
|
||||
if (appState.subtitleTimingTracker) {
|
||||
appState.subtitleTimingTracker.destroy();
|
||||
}
|
||||
if (appState.ankiIntegration) {
|
||||
appState.ankiIntegration.destroy();
|
||||
}
|
||||
},
|
||||
shouldRestoreWindowsOnActivate: () =>
|
||||
appState.overlayRuntimeInitialized &&
|
||||
BrowserWindow.getAllWindows().length === 0,
|
||||
restoreWindowsOnActivate: () => {
|
||||
createMainWindow();
|
||||
createInvisibleWindow();
|
||||
updateVisibleOverlayVisibility();
|
||||
updateInvisibleOverlayVisibility();
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => {
|
||||
appState.secondarySubMode = mode;
|
||||
},
|
||||
defaultSecondarySubMode: "hover",
|
||||
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
|
||||
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
|
||||
startSubtitleWebsocket: (port: number) => {
|
||||
subtitleWsService.start(port, () => appState.currentSubText);
|
||||
},
|
||||
log: (message) => appLogger.logInfo(message),
|
||||
createMecabTokenizerAndCheck: async () => {
|
||||
const tokenizer = new MecabTokenizer();
|
||||
appState.mecabTokenizer = tokenizer;
|
||||
await tokenizer.checkAvailability();
|
||||
},
|
||||
createSubtitleTimingTracker: () => {
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
appState.subtitleTimingTracker = tracker;
|
||||
},
|
||||
loadYomitanExtension: async () => {
|
||||
await loadYomitanExtension();
|
||||
},
|
||||
texthookerOnlyMode: appState.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
handleInitialArgs: () => handleInitialArgs(),
|
||||
}),
|
||||
onWillQuitCleanup: () => {
|
||||
restorePreviousSecondarySubVisibility();
|
||||
globalShortcut.unregisterAll();
|
||||
subtitleWsService.stop();
|
||||
texthookerService.stop();
|
||||
if (appState.yomitanParserWindow && !appState.yomitanParserWindow.isDestroyed()) {
|
||||
appState.yomitanParserWindow.destroy();
|
||||
}
|
||||
appState.yomitanParserWindow = null;
|
||||
appState.yomitanParserReadyPromise = null;
|
||||
appState.yomitanParserInitPromise = null;
|
||||
if (appState.windowTracker) {
|
||||
appState.windowTracker.stop();
|
||||
}
|
||||
if (appState.mpvClient && appState.mpvClient.socket) {
|
||||
appState.mpvClient.socket.destroy();
|
||||
}
|
||||
if (appState.reconnectTimer) {
|
||||
clearTimeout(appState.reconnectTimer);
|
||||
}
|
||||
if (appState.subtitleTimingTracker) {
|
||||
appState.subtitleTimingTracker.destroy();
|
||||
}
|
||||
if (appState.ankiIntegration) {
|
||||
appState.ankiIntegration.destroy();
|
||||
}
|
||||
},
|
||||
shouldRestoreWindowsOnActivate: () =>
|
||||
appState.overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0,
|
||||
restoreWindowsOnActivate: () => {
|
||||
createMainWindow();
|
||||
createInvisibleWindow();
|
||||
updateVisibleOverlayVisibility();
|
||||
updateInvisibleOverlayVisibility();
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -840,6 +819,11 @@ async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
|
||||
setYomitanParserInitPromise: (promise) => {
|
||||
appState.yomitanParserInitPromise = promise;
|
||||
},
|
||||
isKnownWord: (text) =>
|
||||
Boolean(appState.ankiIntegration?.isKnownWord(text)),
|
||||
getKnownWordMatchMode: () =>
|
||||
appState.ankiIntegration?.getKnownWordMatchMode() ??
|
||||
getResolvedConfig().ankiConnect.nPlusOne.matchMode,
|
||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||
}),
|
||||
);
|
||||
@@ -972,6 +956,8 @@ function initializeOverlayRuntime(): void {
|
||||
},
|
||||
showDesktopNotification,
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () =>
|
||||
path.join(USER_DATA_PATH, "known-words-cache.json"),
|
||||
},
|
||||
);
|
||||
overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
|
||||
@@ -1057,10 +1043,10 @@ const multiCopySession = numericShortcutRuntime.createSession();
|
||||
const mineSentenceSession = numericShortcutRuntime.createSession();
|
||||
|
||||
function getSubsyncRuntimeServiceParams() {
|
||||
return {
|
||||
return createSubsyncRuntimeServiceInputFromState({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getResolvedSubsyncConfig: () => getSubsyncConfig(getResolvedConfig().subsync),
|
||||
isSubsyncInProgress: () => appState.subsyncInProgress,
|
||||
getSubsyncInProgress: () => appState.subsyncInProgress,
|
||||
setSubsyncInProgress: (inProgress: boolean) => {
|
||||
appState.subsyncInProgress = inProgress;
|
||||
},
|
||||
@@ -1070,7 +1056,7 @@ function getSubsyncRuntimeServiceParams() {
|
||||
restoreOnModalClose: "subsync",
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function triggerSubsyncFromConfig(): Promise<void> {
|
||||
@@ -1336,7 +1322,7 @@ registerIpcRuntimeServices({
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
quitApp: () => app.quit(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
tokenizeCurrentSubtitle: () => tokenizeCurrentSubtitle(appState.currentSubText),
|
||||
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
|
||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
|
||||
getSubtitlePosition: () => loadSubtitlePosition(),
|
||||
@@ -1369,6 +1355,8 @@ registerIpcRuntimeServices({
|
||||
setAnkiIntegration: (integration: AnkiIntegration | null) => {
|
||||
appState.ankiIntegration = integration;
|
||||
},
|
||||
getKnownWordCacheStatePath: () =>
|
||||
path.join(USER_DATA_PATH, "known-words-cache.json"),
|
||||
showDesktopNotification,
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
||||
|
||||
@@ -87,3 +87,11 @@ export function createAppReadyRuntimeDeps(
|
||||
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"];
|
||||
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions["getAnkiIntegration"];
|
||||
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions["setAnkiIntegration"];
|
||||
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions["getKnownWordCacheStatePath"];
|
||||
showDesktopNotification: AnkiJimakuIpcRuntimeOptions["showDesktopNotification"];
|
||||
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions["createFieldGroupingCallback"];
|
||||
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions["broadcastRuntimeOptionsChanged"];
|
||||
@@ -224,6 +225,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
|
||||
getMpvClient: params.getMpvClient,
|
||||
getAnkiIntegration: params.getAnkiIntegration,
|
||||
setAnkiIntegration: params.setAnkiIntegration,
|
||||
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
|
||||
showDesktopNotification: params.showDesktopNotification,
|
||||
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
||||
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface SubsyncRuntimeServiceStateInput {
|
||||
getMpvClient: SubsyncRuntimeServiceInput["getMpvClient"];
|
||||
getResolvedSubsyncConfig: SubsyncRuntimeServiceInput["getResolvedSubsyncConfig"];
|
||||
getSubsyncInProgress: () => boolean;
|
||||
setSubsyncInProgress: SubsyncRuntimeServiceInput["setSubsyncInProgress"];
|
||||
showMpvOsd: SubsyncRuntimeServiceInput["showMpvOsd"];
|
||||
openManualPicker: SubsyncRuntimeServiceInput["openManualPicker"];
|
||||
}
|
||||
|
||||
export function createSubsyncRuntimeServiceInputFromState(
|
||||
input: SubsyncRuntimeServiceStateInput,
|
||||
): SubsyncRuntimeServiceInput {
|
||||
return {
|
||||
getMpvClient: input.getMpvClient,
|
||||
getResolvedSubsyncConfig: input.getResolvedSubsyncConfig,
|
||||
isSubsyncInProgress: input.getSubsyncInProgress,
|
||||
setSubsyncInProgress: input.setSubsyncInProgress,
|
||||
showMpvOsd: input.showMpvOsd,
|
||||
openManualPicker: input.openManualPicker,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSubsyncRuntimeServiceDeps(
|
||||
params: SubsyncRuntimeServiceInput,
|
||||
): SubsyncRuntimeDeps {
|
||||
|
||||
@@ -284,6 +284,11 @@ body.settings-modal-open #subtitleContainer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-known {
|
||||
color: #a6da95;
|
||||
text-shadow: 0 0 6px rgba(166, 218, 149, 0.35);
|
||||
}
|
||||
|
||||
#subtitleRoot .word:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -26,7 +26,7 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
if (parts[i]) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "word";
|
||||
span.className = token.isKnown ? "word word-known" : "word";
|
||||
span.textContent = parts[i];
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
@@ -40,7 +40,7 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
}
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.className = "word";
|
||||
span.className = token.isKnown ? "word word-known" : "word";
|
||||
span.textContent = surface;
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
|
||||
@@ -179,7 +179,11 @@ export function shouldMerge(lastStandaloneToken: Token, token: Token): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function mergeTokens(tokens: Token[]): MergedToken[] {
|
||||
export function mergeTokens(
|
||||
tokens: Token[],
|
||||
isKnownWord: (text: string) => boolean = () => false,
|
||||
knownWordMatchMode: "headword" | "surface" = "headword",
|
||||
): MergedToken[] {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -205,6 +209,13 @@ export function mergeTokens(tokens: Token[]): MergedToken[] {
|
||||
|
||||
if (shouldMergeToken && result.length > 0) {
|
||||
const prev = result.pop()!;
|
||||
const mergedHeadword = prev.headword;
|
||||
const headwordForKnownMatch = (() => {
|
||||
if (knownWordMatchMode === "surface") {
|
||||
return prev.surface;
|
||||
}
|
||||
return mergedHeadword;
|
||||
})();
|
||||
result.push({
|
||||
surface: prev.surface + token.word,
|
||||
reading: prev.reading + tokenReading,
|
||||
@@ -213,8 +224,17 @@ export function mergeTokens(tokens: Token[]): MergedToken[] {
|
||||
endPos: end,
|
||||
partOfSpeech: prev.partOfSpeech,
|
||||
isMerged: true,
|
||||
isKnown: headwordForKnownMatch
|
||||
? isKnownWord(headwordForKnownMatch)
|
||||
: false,
|
||||
});
|
||||
} else {
|
||||
const headwordForKnownMatch = (() => {
|
||||
if (knownWordMatchMode === "surface") {
|
||||
return token.word;
|
||||
}
|
||||
return token.headword;
|
||||
})();
|
||||
result.push({
|
||||
surface: token.word,
|
||||
reading: tokenReading,
|
||||
@@ -223,6 +243,9 @@ export function mergeTokens(tokens: Token[]): MergedToken[] {
|
||||
endPos: end,
|
||||
partOfSpeech: token.partOfSpeech,
|
||||
isMerged: false,
|
||||
isKnown: headwordForKnownMatch
|
||||
? isKnownWord(headwordForKnownMatch)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
32
src/types.ts
32
src/types.ts
@@ -49,6 +49,7 @@ export interface MergedToken {
|
||||
endPos: number;
|
||||
partOfSpeech: PartOfSpeech;
|
||||
isMerged: boolean;
|
||||
isKnown: boolean;
|
||||
}
|
||||
|
||||
export interface WindowGeometry {
|
||||
@@ -150,7 +151,8 @@ export interface KikuMergePreviewResponse {
|
||||
|
||||
export type RuntimeOptionId =
|
||||
| "anki.autoUpdateNewCards"
|
||||
| "anki.kikuFieldGrouping";
|
||||
| "anki.kikuFieldGrouping"
|
||||
| "anki.nPlusOneMatchMode";
|
||||
|
||||
export type RuntimeOptionScope = "ankiConnect";
|
||||
|
||||
@@ -158,6 +160,8 @@ export type RuntimeOptionValueType = "boolean" | "enum";
|
||||
|
||||
export type RuntimeOptionValue = boolean | string;
|
||||
|
||||
export type NPlusOneMatchMode = "headword" | "surface";
|
||||
|
||||
export interface RuntimeOptionState {
|
||||
id: RuntimeOptionId;
|
||||
label: string;
|
||||
@@ -221,14 +225,20 @@ export interface AnkiConnectConfig {
|
||||
fallbackDuration?: number;
|
||||
maxMediaDuration?: number;
|
||||
};
|
||||
behavior?: {
|
||||
overwriteAudio?: boolean;
|
||||
overwriteImage?: boolean;
|
||||
mediaInsertMode?: "append" | "prepend";
|
||||
highlightWord?: boolean;
|
||||
notificationType?: "osd" | "system" | "both" | "none";
|
||||
autoUpdateNewCards?: boolean;
|
||||
nPlusOne?: {
|
||||
highlightEnabled?: boolean;
|
||||
refreshMinutes?: number;
|
||||
matchMode?: NPlusOneMatchMode;
|
||||
decks?: string[];
|
||||
};
|
||||
behavior?: {
|
||||
overwriteAudio?: boolean;
|
||||
overwriteImage?: boolean;
|
||||
mediaInsertMode?: "append" | "prepend";
|
||||
highlightWord?: boolean;
|
||||
notificationType?: "osd" | "system" | "both" | "none";
|
||||
autoUpdateNewCards?: boolean;
|
||||
};
|
||||
metadata?: {
|
||||
pattern?: string;
|
||||
};
|
||||
@@ -363,6 +373,12 @@ export interface ResolvedConfig {
|
||||
fallbackDuration: number;
|
||||
maxMediaDuration: number;
|
||||
};
|
||||
nPlusOne: {
|
||||
highlightEnabled: boolean;
|
||||
refreshMinutes: number;
|
||||
matchMode: NPlusOneMatchMode;
|
||||
decks: string[];
|
||||
};
|
||||
behavior: {
|
||||
overwriteAudio: boolean;
|
||||
overwriteImage: boolean;
|
||||
|
||||
Reference in New Issue
Block a user