export interface NoteField { value: string; } export interface NoteInfo { noteId: number; fields: Record; } export interface DuplicateDetectionDeps { findNotes: (query: string, options?: { maxRetries?: number }) => Promise; notesInfo: (noteIds: number[]) => Promise; getDeck: () => string | null | undefined; getWordFieldCandidates?: () => string[]; resolveFieldName: (noteInfo: NoteInfo, preferredName: string) => string | null; logInfo?: (message: string) => void; logDebug?: (message: string) => void; logWarn: (message: string, error: unknown) => void; } export async function findDuplicateNote( expression: string, excludeNoteId: number, noteInfo: NoteInfo, deps: DuplicateDetectionDeps, ): Promise { 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 { const configuredWordFieldCandidates = deps.getWordFieldCandidates?.() ?? ['Expression', 'Word']; const sourceCandidates = getDuplicateSourceCandidates( noteInfo, expression, configuredWordFieldCandidates, ); if (sourceCandidates.length === 0) return []; deps.logInfo?.( `[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates .map((entry) => `${entry.fieldName}:${entry.value}`) .join('|')}`, ); const deckValue = deps.getDeck(); const queryPrefixes = deckValue ? [`"deck:${escapeAnkiSearchValue(deckValue)}" `, ''] : ['']; try { const noteIds = new Set(); const executedQueries = new Set(); for (const queryPrefix of queryPrefixes) { for (const sourceCandidate of sourceCandidates) { const escapedExpression = escapeAnkiSearchValue(sourceCandidate.value); const queryFieldNames = getDuplicateCandidateFieldNames(sourceCandidate.fieldName); for (const queryFieldName of queryFieldNames) { const escapedFieldName = escapeAnkiSearchValue(queryFieldName); const query = `${queryPrefix}"${escapedFieldName}:${escapedExpression}"`; if (executedQueries.has(query)) continue; executedQueries.add(query); const results = (await deps.findNotes(query)) as number[]; deps.logDebug?.( `[duplicate] query(field)="${query}" hits=${Array.isArray(results) ? results.length : 0}`, ); for (const noteId of results) { noteIds.add(noteId); } } } if (noteIds.size > 0) break; } if (noteIds.size === 0) { for (const queryPrefix of queryPrefixes) { for (const sourceCandidate of sourceCandidates) { const escapedExpression = escapeAnkiSearchValue(sourceCandidate.value); const query = `${queryPrefix}"${escapedExpression}"`; if (executedQueries.has(query)) continue; executedQueries.add(query); const results = (await deps.findNotes(query)) as number[]; deps.logDebug?.( `[duplicate] query(text)="${query}" hits=${Array.isArray(results) ? results.length : 0}`, ); for (const noteId of results) { noteIds.add(noteId); } } if (noteIds.size > 0) break; } } return await findExactDuplicateNoteIds( noteIds, excludeNoteId, sourceCandidates.map((candidate) => candidate.value), configuredWordFieldCandidates, deps, maxMatches, ); } catch (error) { deps.logWarn('Duplicate search failed:', error); return []; } } function findExactDuplicateNoteIds( candidateNoteIds: Iterable, excludeNoteId: number, sourceValues: string[], candidateFieldNames: string[], deps: DuplicateDetectionDeps, maxMatches?: number, ): Promise { 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([]); } const normalizedValues = new Set( sourceValues.map((value) => normalizeDuplicateValue(value)).filter((value) => value.length > 0), ); if (normalizedValues.size === 0) { 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[]; const notesInfo = notesInfoResult as NoteInfo[]; for (const noteInfo of notesInfo) { for (const candidateFieldName of candidateFieldNames) { const resolvedField = deps.resolveFieldName(noteInfo, candidateFieldName); if (!resolvedField) continue; const candidateValue = noteInfo.fields[resolvedField]?.value || ''; if (normalizedValues.has(normalizeDuplicateValue(candidateValue))) { deps.logDebug?.( `[duplicate] exact-match noteId=${noteInfo.noteId} field=${resolvedField}`, ); deps.logInfo?.(`[duplicate] matched noteId=${noteInfo.noteId} field=${resolvedField}`); matches.push(noteInfo.noteId); if (maxMatches !== undefined && matches.length >= maxMatches) { return matches; } break; } } } } if (matches.length === 0) { deps.logInfo?.('[duplicate] no exact match in candidate notes'); } return matches; })(); } function getDuplicateCandidateFieldNames(fieldName: string): string[] { const candidates = [fieldName]; const lower = fieldName.toLowerCase(); if (lower === 'word') { candidates.push('expression'); } else if (lower === 'expression') { candidates.push('word'); } return candidates; } function getDuplicateSourceCandidates( noteInfo: NoteInfo, fallbackExpression: string, configuredFieldNames: string[], ): Array<{ fieldName: string; value: string }> { const candidates: Array<{ fieldName: string; value: string }> = []; const dedupeKey = new Set(); const configuredFieldNameSet = new Set(configuredFieldNames.map((name) => name.toLowerCase())); for (const fieldName of Object.keys(noteInfo.fields)) { const lower = fieldName.toLowerCase(); if (!configuredFieldNameSet.has(lower)) continue; const value = noteInfo.fields[fieldName]?.value?.trim() ?? ''; if (!value) continue; const key = `${lower}:${normalizeDuplicateValue(value)}`; if (dedupeKey.has(key)) continue; dedupeKey.add(key); candidates.push({ fieldName, value }); } const trimmedFallback = fallbackExpression.trim(); if (trimmedFallback.length > 0) { const fallbackFieldName = configuredFieldNames[0]?.toLowerCase() || 'expression'; const fallbackKey = `${fallbackFieldName}:${normalizeDuplicateValue(trimmedFallback)}`; if (!dedupeKey.has(fallbackKey)) { candidates.push({ fieldName: configuredFieldNames[0] || 'Expression', value: trimmedFallback, }); } } return candidates; } function normalizeDuplicateValue(value: string): string { return value .replace(/<[^>]*>/g, '') .replace(/([^\s\[\]]+)\[[^\]]*\]/g, '$1') .replace(/\s+/g, ' ') .trim(); } function escapeAnkiSearchValue(value: string): string { return value .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(/([:*?()[\]{}])/g, '\\$1'); }