feat: streamline Kiku duplicate grouping and popup flow (#38)

This commit is contained in:
2026-04-01 00:04:03 -07:00
committed by GitHub
parent 3502cdc607
commit d6c72806bb
31 changed files with 1227 additions and 36 deletions

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,178 @@ 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, 7];
},
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] }]);
});
test('CardCreationService does not track duplicate ids when pre-add lookup returns none', async () => {
const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
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 () => [],
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
trackedDuplicates.push({ noteId, duplicateNoteIds });
},
});
const created = await service.createSentenceCard('重複なし', 0, 1);
assert.equal(created, true);
assert.deepEqual(trackedDuplicates, []);
});

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,14 @@ export class CardCreationService {
log.warn('Failed to track last added note:', (error as Error).message);
}
if (duplicateNoteIds.length > 0) {
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 +725,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 +813,7 @@ export class CardCreationService {
return `image_${timestamp}.${ext}`;
}
}
function sortUniqueNoteIds(noteIds: number[]): number[] {
return [...new Set(noteIds)].sort((left, right) => left - right);
}

View File

@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { findDuplicateNote, type NoteInfo } from './duplicate';
import { findDuplicateNote, findDuplicateNoteIds, type NoteInfo } from './duplicate';
function createFieldResolver(noteInfo: NoteInfo, preferredName: string): string | null {
const names = Object.keys(noteInfo.fields);
@@ -267,3 +267,62 @@ test('findDuplicateNote does not disable retries on findNotes calls', async () =
assert.ok(seenOptions.length > 0);
assert.ok(seenOptions.every((options) => options?.maxRetries !== 0));
});
test('findDuplicateNote stops after the first exact-match chunk', async () => {
const currentNote: NoteInfo = {
noteId: 100,
fields: {
Expression: { value: '貴様' },
},
};
let notesInfoCalls = 0;
const candidateIds = Array.from({ length: 51 }, (_, index) => 200 + index);
const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
findNotes: async () => candidateIds,
notesInfo: async (noteIds) => {
notesInfoCalls += 1;
return noteIds.map((noteId) => ({
noteId,
fields: {
Expression: { value: noteId === 200 ? '貴様' : `別単語-${noteId}` },
},
}));
},
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
});
assert.equal(duplicateId, 200);
assert.equal(notesInfoCalls, 1);
});
test('findDuplicateNoteIds returns no matches when maxMatches is zero', async () => {
const currentNote: NoteInfo = {
noteId: 100,
fields: {
Expression: { value: '貴様' },
},
};
let notesInfoCalls = 0;
const duplicateIds = await findDuplicateNoteIds('貴様', 100, currentNote, {
findNotes: async () => [200],
notesInfo: async (noteIds) => {
notesInfoCalls += 1;
return noteIds.map((noteId) => ({
noteId,
fields: {
Expression: { value: '貴様' },
},
}));
},
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
}, 0);
assert.deepEqual(duplicateIds, []);
assert.equal(notesInfoCalls, 0);
});

View File

@@ -24,13 +24,30 @@ export async function findDuplicateNote(
noteInfo: NoteInfo,
deps: DuplicateDetectionDeps,
): Promise<number | null> {
const duplicateNoteIds = await findDuplicateNoteIds(
expression,
excludeNoteId,
noteInfo,
deps,
1,
);
return duplicateNoteIds[0] ?? null;
}
export async function findDuplicateNoteIds(
expression: string,
excludeNoteId: number,
noteInfo: NoteInfo,
deps: DuplicateDetectionDeps,
maxMatches?: number,
): 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,42 +100,49 @@ export async function findDuplicateNote(
}
}
return await findFirstExactDuplicateNoteId(
return await findExactDuplicateNoteIds(
noteIds,
excludeNoteId,
sourceCandidates.map((candidate) => candidate.value),
configuredWordFieldCandidates,
deps,
maxMatches,
);
} 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> {
maxMatches?: number,
): Promise<number[]> {
if (maxMatches !== undefined && maxMatches <= 0) {
return Promise.resolve([]);
}
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 +157,19 @@ 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);
if (maxMatches !== undefined && matches.length >= maxMatches) {
return matches;
}
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;
}