feat: streamline Kiku duplicate grouping and popup flow

This commit is contained in:
2026-03-31 22:23:02 -07:00
parent 3502cdc607
commit a5faef5aee
25 changed files with 864 additions and 25 deletions

View File

@@ -51,6 +51,7 @@ import { KnownWordCacheManager } from './anki-integration/known-word-cache';
import { PollingRunner } from './anki-integration/polling';
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate';
import { findDuplicateNoteIds as findDuplicateNoteIdsForAnkiIntegration } from './anki-integration/duplicate';
import { CardCreationService } from './anki-integration/card-creation';
import { FieldGroupingService } from './anki-integration/field-grouping';
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
@@ -148,6 +149,7 @@ export class AnkiIntegration {
private aiConfig: AiConfig;
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
private noteIdRedirects = new Map<number, number>();
private trackedDuplicateNoteIds = new Map<number, number[]>();
constructor(
config: AnkiConnectConfig,
@@ -264,6 +266,9 @@ export class AnkiIntegration {
recordCardsAdded: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'proxy');
},
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
},
getDeck: () => this.config.deck,
findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[],
@@ -361,6 +366,10 @@ export class AnkiIntegration {
trackLastAddedNoteId: (noteId) => {
this.previousNoteIds.add(noteId);
},
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
},
findDuplicateNoteIds: (expression, noteInfo) => this.findDuplicateNoteIds(expression, noteInfo),
recordCardsMinedCallback: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'card creation');
},
@@ -382,6 +391,10 @@ export class AnkiIntegration {
extractFields: (fields) => this.extractFields(fields),
findDuplicateNote: (expression, noteId, noteInfo) =>
this.findDuplicateNote(expression, noteId, noteInfo),
getTrackedDuplicateNoteIds: (noteId) =>
this.trackedDuplicateNoteIds.has(noteId)
? [...(this.trackedDuplicateNoteIds.get(noteId) ?? [])]
: null,
hasAllConfiguredFields: (noteInfo, configuredFieldNames) =>
this.hasAllConfiguredFields(noteInfo, configuredFieldNames),
processNewCard: (noteId, options) => this.processNewCard(noteId, options),
@@ -1042,6 +1055,10 @@ export class AnkiIntegration {
);
}
trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void {
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
}
private async findDuplicateNote(
expression: string,
excludeNoteId: number,
@@ -1065,6 +1082,28 @@ export class AnkiIntegration {
});
}
private async findDuplicateNoteIds(
expression: string,
noteInfo: NoteInfo,
): Promise<number[]> {
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, 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,
getWordFieldCandidates: () => this.getConfiguredWordFieldCandidates(),
resolveFieldName: (info, preferredName) => this.resolveNoteFieldName(info, preferredName),
logInfo: (message) => {
log.info(message);
},
logDebug: (message) => {
log.debug(message);
},
logWarn: (message, error) => {
log.warn(message, (error as Error).message);
},
});
}
private getPreferredSentenceAudioFieldName(): string {
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
return sentenceCardConfig.audioField || 'SentenceAudio';

View File

@@ -324,6 +324,123 @@ test('proxy fallback-enqueues latest note for addNote responses without note IDs
assert.deepEqual(recordedCards, [1]);
});
test('proxy tracks duplicate note ids from addNote request metadata before enrichment', async () => {
const processed: number[] = [];
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async (noteId) => {
processed.push(noteId);
},
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
tracked.push({ noteId, duplicateNoteIds });
},
logInfo: () => undefined,
logWarn: () => undefined,
logError: () => undefined,
});
(
proxy as unknown as {
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
}
).maybeEnqueueFromRequest(
{
action: 'addNote',
params: {
note: {},
subminerDuplicateNoteIds: [11, -1, 40, 11, 25],
},
},
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
);
await waitForCondition(() => processed.length === 1);
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [11, 25, 40] }]);
assert.deepEqual(processed, [42]);
});
test('proxy strips SubMiner duplicate metadata before forwarding upstream addNote request', async () => {
let upstreamBody = '';
const upstream = http.createServer(async (req, res) => {
upstreamBody = await new Promise<string>((resolve) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
res.statusCode = 200;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ result: 42, error: null }));
});
upstream.listen(0, '127.0.0.1');
await once(upstream, 'listening');
const upstreamAddress = upstream.address();
assert.ok(upstreamAddress && typeof upstreamAddress === 'object');
const upstreamPort = upstreamAddress.port;
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async () => undefined,
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
tracked.push({ noteId, duplicateNoteIds });
},
logInfo: () => undefined,
logWarn: () => undefined,
logError: () => undefined,
});
try {
proxy.start({
host: '127.0.0.1',
port: 0,
upstreamUrl: `http://127.0.0.1:${upstreamPort}`,
});
const proxyServer = (
proxy as unknown as {
server: http.Server | null;
}
).server;
assert.ok(proxyServer);
if (!proxyServer.listening) {
await once(proxyServer, 'listening');
}
const proxyAddress = proxyServer.address();
assert.ok(proxyAddress && typeof proxyAddress === 'object');
const proxyPort = proxyAddress.port;
const response = await fetch(`http://127.0.0.1:${proxyPort}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
action: 'addNote',
version: 6,
params: {
note: {
deckName: 'Mining',
modelName: 'Sentence',
fields: { Expression: '食べる' },
},
subminerDuplicateNoteIds: [18, 7],
},
}),
});
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { result: 42, error: null });
await waitForCondition(() => tracked.length === 1);
assert.equal(upstreamBody.includes('subminerDuplicateNoteIds'), false);
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [7, 18] }]);
} finally {
proxy.stop();
upstream.close();
await once(upstream, 'close');
}
});
test('proxy returns addNote response without waiting for background enrichment', async () => {
const processed: number[] = [];
let releaseProcessing: (() => void) | undefined;

View File

@@ -16,6 +16,7 @@ export interface AnkiConnectProxyServerDeps {
shouldAutoUpdateNewCards: () => boolean;
processNewCard: (noteId: number) => Promise<void>;
recordCardsAdded?: (count: number, noteIds: number[]) => void;
trackAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
getDeck?: () => string | undefined;
findNotes?: (
query: string,
@@ -161,6 +162,7 @@ export class AnkiConnectProxyServer {
}
try {
const forwardedBody = req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
const contentType =
typeof req.headers['content-type'] === 'string'
@@ -169,7 +171,7 @@ export class AnkiConnectProxyServer {
const upstreamResponse = await this.client.request<ArrayBuffer>({
url: targetUrl,
method: req.method,
data: req.method === 'POST' ? rawBody : undefined,
data: req.method === 'POST' ? forwardedBody : undefined,
headers: {
'content-type': contentType,
},
@@ -219,6 +221,8 @@ export class AnkiConnectProxyServer {
return;
}
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
const noteIds =
action === 'multi'
? this.collectMultiResultIds(requestJson, responseResult)
@@ -231,6 +235,77 @@ export class AnkiConnectProxyServer {
this.enqueueNotes(noteIds);
}
private maybeTrackDuplicateNoteIds(
requestJson: Record<string, unknown>,
action: string,
responseResult: unknown,
): void {
if (action !== 'addNote') {
return;
}
const duplicateNoteIds = this.getRequestDuplicateNoteIds(requestJson);
if (duplicateNoteIds.length === 0) {
return;
}
const noteId = this.collectSingleResultId(responseResult)[0];
if (!noteId) {
return;
}
this.deps.trackAddedDuplicateNoteIds?.(noteId, duplicateNoteIds);
}
private getForwardRequestBody(
rawBody: Buffer,
requestJson: Record<string, unknown> | null,
): Buffer {
if (!requestJson) {
return rawBody;
}
const sanitized = this.sanitizeRequestJson(requestJson);
if (sanitized === requestJson) {
return rawBody;
}
return Buffer.from(JSON.stringify(sanitized), 'utf8');
}
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
const action =
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
if (action !== 'addNote') {
return requestJson;
}
const params =
requestJson.params && typeof requestJson.params === 'object'
? (requestJson.params as Record<string, unknown>)
: null;
if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) {
return requestJson;
}
const nextParams = { ...params };
delete nextParams.subminerDuplicateNoteIds;
return {
...requestJson,
params: nextParams,
};
}
private getRequestDuplicateNoteIds(requestJson: Record<string, unknown>): number[] {
const params =
requestJson.params && typeof requestJson.params === 'object'
? (requestJson.params as Record<string, unknown>)
: null;
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
? params.subminerDuplicateNoteIds
: [];
return [...new Set(rawNoteIds.filter((entry): entry is number => {
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
}))].sort((left, right) => left - right);
}
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {
if (action === 'addNote' || action === 'addNotes') {
return true;

View File

@@ -397,3 +397,93 @@ test('CardCreationService uses stream-open-filename for remote media generation'
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
});
test('CardCreationService tracks pre-add duplicate note ids for kiku sentence cards', async () => {
const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
const duplicateLookupExpressions: string[] = [];
const service = new CardCreationService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'SentenceAudio',
},
media: {
generateAudio: false,
generateImage: false,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentSubText: '字幕',
currentSubStart: 1,
currentSubEnd: 2,
currentTimePos: 1.5,
currentAudioStreamIndex: 0,
}) as never,
client: {
addNote: async () => 42,
addTags: async () => undefined,
notesInfo: async () => [],
updateNoteFields: async () => undefined,
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
showOsdNotification: () => undefined,
showUpdateResult: () => undefined,
showStatusNotification: () => undefined,
showNotification: async () => undefined,
beginUpdateProgress: () => undefined,
endUpdateProgress: () => undefined,
withUpdateProgress: async (_message, action) => action(),
resolveConfiguredFieldName: () => null,
resolveNoteFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
extractFields: () => ({}),
processSentence: (sentence) => sentence,
setCardTypeFields: () => undefined,
mergeFieldValue: (_existing, newValue) => newValue,
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
findDuplicateNoteIds: async (expression) => {
duplicateLookupExpressions.push(expression);
return [18, 7, 30];
},
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
trackedDuplicates.push({ noteId, duplicateNoteIds });
},
});
const created = await service.createSentenceCard('重複文', 0, 1);
assert.equal(created, true);
assert.deepEqual(duplicateLookupExpressions, ['重複文']);
assert.deepEqual(trackedDuplicates, [{ noteId: 42, duplicateNoteIds: [7, 18, 30] }]);
});

View File

@@ -112,6 +112,11 @@ interface CardCreationDeps {
isUpdateInProgress: () => boolean;
setUpdateInProgress: (value: boolean) => void;
trackLastAddedNoteId?: (noteId: number) => void;
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
findDuplicateNoteIds?: (
expression: string,
noteInfo: CardCreationNoteInfo,
) => Promise<number[]>;
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
}
@@ -548,6 +553,33 @@ export class CardCreationService {
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
}
const pendingNoteInfo = this.createPendingNoteInfo(fields);
const pendingNoteFields = Object.fromEntries(
Object.entries(fields).map(([name, value]) => [name.toLowerCase(), value]),
);
const pendingExpressionText = getPreferredWordValueFromExtractedFields(
pendingNoteFields,
this.deps.getConfig(),
).trim();
let duplicateNoteIds: number[] = [];
if (
sentenceCardConfig.kikuEnabled &&
sentenceCardConfig.kikuFieldGrouping !== 'disabled' &&
pendingExpressionText &&
this.deps.findDuplicateNoteIds
) {
try {
duplicateNoteIds = sortUniqueNoteIds(
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
);
} catch (error) {
log.warn(
'Failed to capture pre-add duplicate note ids:',
(error as Error).message,
);
}
}
const deck = this.deps.getConfig().deck || 'Default';
let noteId: number;
try {
@@ -570,6 +602,12 @@ export class CardCreationService {
log.warn('Failed to track last added note:', (error as Error).message);
}
try {
this.deps.trackLastAddedDuplicateNoteIds?.(noteId, duplicateNoteIds);
} catch (error) {
log.warn('Failed to track duplicate note ids:', (error as Error).message);
}
try {
this.deps.recordCardsMinedCallback?.(1, [noteId]);
} catch (error) {
@@ -685,6 +723,15 @@ export class CardCreationService {
);
}
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
return {
noteId: -1,
fields: Object.fromEntries(
Object.entries(fields).map(([name, value]) => [name, { value }]),
),
};
}
private async mediaGenerateAudio(
videoPath: string,
startTime: number,
@@ -764,3 +811,7 @@ export class CardCreationService {
return `image_${timestamp}.${ext}`;
}
}
function sortUniqueNoteIds(noteIds: number[]): number[] {
return [...new Set(noteIds)].sort((left, right) => left - right);
}

View File

@@ -24,13 +24,23 @@ export async function findDuplicateNote(
noteInfo: NoteInfo,
deps: DuplicateDetectionDeps,
): Promise<number | null> {
const duplicateNoteIds = await findDuplicateNoteIds(expression, excludeNoteId, noteInfo, deps);
return duplicateNoteIds[0] ?? null;
}
export async function findDuplicateNoteIds(
expression: string,
excludeNoteId: number,
noteInfo: NoteInfo,
deps: DuplicateDetectionDeps,
): Promise<number[]> {
const configuredWordFieldCandidates = deps.getWordFieldCandidates?.() ?? ['Expression', 'Word'];
const sourceCandidates = getDuplicateSourceCandidates(
noteInfo,
expression,
configuredWordFieldCandidates,
);
if (sourceCandidates.length === 0) return null;
if (sourceCandidates.length === 0) return [];
deps.logInfo?.(
`[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates
.map((entry) => `${entry.fieldName}:${entry.value}`)
@@ -83,7 +93,7 @@ export async function findDuplicateNote(
}
}
return await findFirstExactDuplicateNoteId(
return await findExactDuplicateNoteIds(
noteIds,
excludeNoteId,
sourceCandidates.map((candidate) => candidate.value),
@@ -92,33 +102,34 @@ export async function findDuplicateNote(
);
} catch (error) {
deps.logWarn('Duplicate search failed:', error);
return null;
return [];
}
}
function findFirstExactDuplicateNoteId(
function findExactDuplicateNoteIds(
candidateNoteIds: Iterable<number>,
excludeNoteId: number,
sourceValues: string[],
candidateFieldNames: string[],
deps: DuplicateDetectionDeps,
): Promise<number | null> {
): Promise<number[]> {
const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId);
deps.logDebug?.(`[duplicate] candidateIds=${candidates.length} exclude=${excludeNoteId}`);
if (candidates.length === 0) {
deps.logInfo?.('[duplicate] no candidates after query + exclude');
return Promise.resolve(null);
return Promise.resolve([]);
}
const normalizedValues = new Set(
sourceValues.map((value) => normalizeDuplicateValue(value)).filter((value) => value.length > 0),
);
if (normalizedValues.size === 0) {
return Promise.resolve(null);
return Promise.resolve([]);
}
const chunkSize = 50;
return (async () => {
const matches: number[] = [];
for (let i = 0; i < candidates.length; i += chunkSize) {
const chunk = candidates.slice(i, i + chunkSize);
const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[];
@@ -133,13 +144,16 @@ function findFirstExactDuplicateNoteId(
`[duplicate] exact-match noteId=${noteInfo.noteId} field=${resolvedField}`,
);
deps.logInfo?.(`[duplicate] matched noteId=${noteInfo.noteId} field=${resolvedField}`);
return noteInfo.noteId;
matches.push(noteInfo.noteId);
break;
}
}
}
}
deps.logInfo?.('[duplicate] no exact match in candidate notes');
return null;
if (matches.length === 0) {
deps.logInfo?.('[duplicate] no exact match in candidate notes');
}
return matches;
})();
}

View File

@@ -16,6 +16,7 @@ function createHarness(
noteIds?: number[];
notesInfo?: NoteInfo[][];
duplicateNoteId?: number | null;
trackedDuplicateNoteIds?: number[] | null;
hasAllConfiguredFields?: boolean;
manualHandled?: boolean;
expression?: string | null;
@@ -74,6 +75,7 @@ function createHarness(
duplicateRequests.push({ expression, excludeNoteId });
return options.duplicateNoteId ?? 99;
},
getTrackedDuplicateNoteIds: () => options.trackedDuplicateNoteIds ?? null,
hasAllConfiguredFields: () => options.hasAllConfiguredFields ?? true,
processNewCard: async (noteId, processOptions) => {
processCalls.push({ noteId, options: processOptions });
@@ -223,6 +225,46 @@ test('triggerFieldGroupingForLastAddedCard finds the newest note and hands off t
]);
});
test('triggerFieldGroupingForLastAddedCard prefers tracked duplicate note ids before duplicate lookup', async () => {
const harness = createHarness({
noteIds: [7],
notesInfo: [
[
{
noteId: 7,
fields: {
Expression: { value: 'word-7' },
Sentence: { value: 'line-7' },
},
},
],
[
{
noteId: 7,
fields: {
Expression: { value: 'word-7' },
Sentence: { value: 'line-7' },
},
},
],
],
trackedDuplicateNoteIds: [12, 40, 25],
duplicateNoteId: 99,
hasAllConfiguredFields: true,
});
await harness.service.triggerFieldGroupingForLastAddedCard();
assert.deepEqual(harness.duplicateRequests, []);
assert.deepEqual(harness.autoCalls, [
{
originalNoteId: 40,
newNoteId: 7,
expression: 'word-7',
},
]);
});
test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fields are missing', async () => {
const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = [];
const harness = createHarness({

View File

@@ -41,6 +41,7 @@ interface FieldGroupingDeps {
excludeNoteId: number,
noteInfo: FieldGroupingNoteInfo,
) => Promise<number | null>;
getTrackedDuplicateNoteIds?: (noteId: number) => number[] | null;
hasAllConfiguredFields: (
noteInfo: FieldGroupingNoteInfo,
configuredFieldNames: (string | undefined)[],
@@ -117,11 +118,11 @@ export class FieldGroupingService {
return;
}
const duplicateNoteId = await this.deps.findDuplicateNote(
expressionText,
noteId,
noteInfoBeforeUpdate,
);
const trackedDuplicateNoteIds = this.deps.getTrackedDuplicateNoteIds?.(noteId) ?? null;
const duplicateNoteId =
trackedDuplicateNoteIds !== null
? pickMostRecentDuplicateNoteId(trackedDuplicateNoteIds, noteId)
: await this.deps.findDuplicateNote(expressionText, noteId, noteInfoBeforeUpdate);
if (duplicateNoteId === null) {
this.deps.showOsdNotification('No duplicate card found');
return;
@@ -243,3 +244,17 @@ export class FieldGroupingService {
}
}
}
function pickMostRecentDuplicateNoteId(
duplicateNoteIds: number[],
excludeNoteId: number,
): number | null {
let bestNoteId: number | null = null;
for (const noteId of duplicateNoteIds) {
if (noteId === excludeNoteId) continue;
if (bestNoteId === null || noteId > bestNoteId) {
bestNoteId = noteId;
}
}
return bestNoteId;
}

View File

@@ -5,6 +5,7 @@ import * as path from 'path';
import test from 'node:test';
import * as vm from 'node:vm';
import {
addYomitanNoteViaSearch,
getYomitanDictionaryInfo,
importYomitanDictionaryFromZip,
deleteYomitanDictionaryByTitle,
@@ -1373,3 +1374,19 @@ test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of
false,
);
});
test('addYomitanNoteViaSearch returns note and duplicate ids from the bridge payload', async () => {
const deps = createDeps(async (_script) => ({
noteId: 42,
duplicateNoteIds: [18, 7, 18],
}));
const result = await addYomitanNoteViaSearch('食べる', deps, {
error: () => undefined,
});
assert.deepEqual(result, {
noteId: 42,
duplicateNoteIds: [18, 7, 18],
});
});

View File

@@ -63,6 +63,11 @@ interface YomitanProfileMetadata {
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>;
}
export interface YomitanAddNoteResult {
noteId: number | null;
duplicateNoteIds: number[];
}
const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
const yomitanFrequencyCacheByWindow = new WeakMap<
@@ -1984,11 +1989,11 @@ export async function addYomitanNoteViaSearch(
word: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<number | null> {
): Promise<YomitanAddNoteResult> {
const isReady = await ensureYomitanParserWindow(deps, logger);
const parserWindow = deps.getYomitanParserWindow();
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
return null;
return { noteId: null, duplicateNoteIds: [] };
}
const escapedWord = JSON.stringify(word);
@@ -2003,10 +2008,27 @@ export async function addYomitanNoteViaSearch(
`;
try {
const noteId = await parserWindow.webContents.executeJavaScript(script, true);
return typeof noteId === 'number' ? noteId : null;
const result = await parserWindow.webContents.executeJavaScript(script, true);
if (typeof result === 'number') {
return { noteId: result, duplicateNoteIds: [] };
}
if (result && typeof result === 'object' && !Array.isArray(result)) {
const envelope = result as {
noteId?: unknown;
duplicateNoteIds?: unknown;
};
return {
noteId: typeof envelope.noteId === 'number' ? envelope.noteId : null,
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
? envelope.duplicateNoteIds.filter(
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
)
: [],
};
}
return { noteId: null, duplicateNoteIds: [] };
} catch (err) {
logger.error('Yomitan addNoteFromWord failed:', (err as Error).message);
return null;
return { noteId: null, duplicateNoteIds: [] };
}
}

View File

@@ -2809,7 +2809,14 @@ const ensureStatsServerStarted = (): string => {
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: true,
});
return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
if (result.noteId && result.duplicateNoteIds.length > 0) {
appState.ankiIntegration?.trackDuplicateNoteIdsForNote(
result.noteId,
result.duplicateNoteIds,
);
}
return result.noteId;
},
});
appState.statsServer = statsServer;

View File

@@ -524,6 +524,31 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
}
});
test('paused configured subtitle-jump keybinding re-applies pause after backward seek', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateKeybindings([
{
key: 'Shift+KeyH',
command: ['sub-seek', -1],
},
] as never);
testGlobals.setPlaybackPausedResponse(true);
testGlobals.dispatchKeydown({ key: 'H', code: 'KeyH', shiftKey: true });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
['sub-seek', -1],
['set_property', 'pause', 'yes'],
]);
} finally {
testGlobals.restore();
}
});
test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -1159,6 +1184,56 @@ test('keyboard mode: edge jump while paused re-applies paused state after subtit
}
});
test('keyboard mode: left edge jump while paused re-applies paused state after subtitle seek', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(2);
ctx.state.keyboardSelectedWordIndex = 0;
handlers.syncKeyboardTokenSelection();
testGlobals.setPlaybackPausedResponse(true);
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
['sub-seek', -1],
['set_property', 'pause', 'yes'],
]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: h edge jump while paused re-applies paused state after subtitle seek', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(2);
ctx.state.keyboardSelectedWordIndex = 0;
handlers.syncKeyboardTokenSelection();
testGlobals.setPlaybackPausedResponse(true);
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
['sub-seek', -1],
['set_property', 'pause', 'yes'],
]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: edge jump with unknown pause state re-applies pause conservatively', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();

View File

@@ -358,6 +358,33 @@ export function createKeyboardHandlers(
});
}
function isSubtitleSeekCommand(command: (string | number)[] | undefined): command is [string, number] {
return (
Array.isArray(command) &&
command[0] === 'sub-seek' &&
typeof command[1] === 'number'
);
}
function dispatchConfiguredMpvCommand(command: (string | number)[]): void {
if (!isSubtitleSeekCommand(command)) {
window.electronAPI.sendMpvCommand(command);
return;
}
void options
.getPlaybackPaused()
.then((paused) => {
window.electronAPI.sendMpvCommand(command);
if (paused === true) {
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
}
})
.catch(() => {
window.electronAPI.sendMpvCommand(command);
});
}
type ScanModifierState = {
shiftKey?: boolean;
ctrlKey?: boolean;
@@ -954,7 +981,7 @@ export function createKeyboardHandlers(
if (command) {
e.preventDefault();
window.electronAPI.sendMpvCommand(command);
dispatchConfiguredMpvCommand(command);
}
});

View File

@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { prepareForKikuFieldGroupingOpen } from './kiku-open';
test('prepareForKikuFieldGroupingOpen closes lookup popup before pausing playback', () => {
const calls: string[] = [];
prepareForKikuFieldGroupingOpen({
closeLookupWindow: () => {
calls.push('close');
return true;
},
pausePlayback: () => {
calls.push('pause');
},
});
assert.deepEqual(calls, ['close', 'pause']);
});
test('prepareForKikuFieldGroupingOpen still pauses playback when no popup is open', () => {
const calls: string[] = [];
prepareForKikuFieldGroupingOpen({
closeLookupWindow: () => {
calls.push('close');
return false;
},
pausePlayback: () => {
calls.push('pause');
},
});
assert.deepEqual(calls, ['close', 'pause']);
});

View File

@@ -0,0 +1,7 @@
export function prepareForKikuFieldGroupingOpen(options: {
closeLookupWindow: () => boolean;
pausePlayback: () => void;
}): void {
options.closeLookupWindow();
options.pausePlayback();
}

View File

@@ -33,6 +33,7 @@ import { createControllerDebugModal } from './modals/controller-debug.js';
import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js';
import { createKikuModal } from './modals/kiku.js';
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
import { createSessionHelpModal } from './modals/session-help.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
@@ -470,6 +471,12 @@ function registerModalOpenHandlers(): void {
window.electronAPI.onKikuFieldGroupingRequest(
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
runGuarded('kiku:field-grouping-open', () => {
prepareForKikuFieldGroupingOpen({
closeLookupWindow: () => keyboardHandlers.closeLookupWindow(),
pausePlayback: () => {
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
},
});
kikuModal.openKikuFieldGroupingModal(data);
window.electronAPI.notifyOverlayModalOpened('kiku');
});

View File

@@ -147,7 +147,7 @@ async function main(): Promise<void> {
{ forceOverride: true },
);
const noteId = await addYomitanNoteViaSearch(
const addResult = await addYomitanNoteViaSearch(
word!,
{
getYomitanExt: () => yomitanExt,
@@ -168,6 +168,7 @@ async function main(): Promise<void> {
logger,
);
const noteId = addResult.noteId;
if (typeof noteId !== 'number') {
throw new Error('Yomitan failed to create note.');
}