mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-01 06:12:07 -07:00
feat: streamline Kiku duplicate grouping and popup flow
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] }]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
35
src/renderer/kiku-open.test.ts
Normal file
35
src/renderer/kiku-open.test.ts
Normal 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']);
|
||||
});
|
||||
7
src/renderer/kiku-open.ts
Normal file
7
src/renderer/kiku-open.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function prepareForKikuFieldGroupingOpen(options: {
|
||||
closeLookupWindow: () => boolean;
|
||||
pausePlayback: () => void;
|
||||
}): void {
|
||||
options.closeLookupWindow();
|
||||
options.pausePlayback();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user