import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useAppStore } from '../../store'; import { openEntityTab } from '../../navigation/tabPolicy'; import { useI18n } from '../../i18n'; import { getPersistedDuplicatesResult, removeDismissedPair, removeDismissedPairs } from '../../navigation/duplicatesPersistence'; import type { DuplicatePair } from '../../../main/shared/electronApi'; import './DuplicatesView.css'; const PAGE_SIZE = 500; function pairKey(pair: DuplicatePair): string { return `${pair.postA.id}::${pair.postB.id}`; } export const DuplicatesView: React.FC = () => { const { t } = useI18n(); const { openTab, activeProject } = useAppStore(); const [pairs, setPairs] = useState([]); const [isSearching, setIsSearching] = useState(false); const [checkedKeys, setCheckedKeys] = useState>(new Set()); const [isDismissing, setIsDismissing] = useState(false); const [notEnabled, setNotEnabled] = useState(false); const [checked, setChecked] = useState(false); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const projectId = activeProject?.id; // Load persisted results (or check if feature is enabled) useEffect(() => { if (!projectId) return; let cancelled = false; (async () => { const metadata = await window.electronAPI?.meta.getProjectMetadata(); if (cancelled) return; if (!metadata?.semanticSimilarityEnabled) { setNotEnabled(true); setPairs([]); setChecked(true); return; } setNotEnabled(false); const persisted = getPersistedDuplicatesResult(projectId); if (persisted) { setPairs(persisted); } setChecked(true); })(); return () => { cancelled = true; }; }, [projectId]); // Listen for search result updates from the background task useEffect(() => { const handler = (event: Event) => { const detail = (event as CustomEvent<{ projectId?: string }>).detail; if (!projectId || detail?.projectId !== projectId) return; const persisted = getPersistedDuplicatesResult(projectId); setPairs(persisted ?? []); setIsSearching(false); setVisibleCount(PAGE_SIZE); setCheckedKeys(new Set()); }; window.addEventListener('bds:duplicates-updated', handler); return () => window.removeEventListener('bds:duplicates-updated', handler); }, [projectId]); const visiblePairs = useMemo(() => pairs.slice(0, visibleCount), [pairs, visibleCount]); const hasMore = visibleCount < pairs.length; const handleRunSearch = useCallback(() => { setIsSearching(true); window.electronAPI?.embeddings.runDuplicateSearch(0.92); }, []); const handleDismiss = useCallback(async (postIdA: string, postIdB: string) => { try { await window.electronAPI?.embeddings.dismissPair(postIdA, postIdB); setPairs(prev => prev.filter(p => !(p.postA.id === postIdA && p.postB.id === postIdB))); setCheckedKeys(prev => { const next = new Set(prev); next.delete(`${postIdA}::${postIdB}`); return next; }); if (projectId) { removeDismissedPair(projectId, postIdA, postIdB); } } catch (err) { console.error('Failed to dismiss duplicate pair:', err); } }, [projectId]); const handleDismissChecked = useCallback(async () => { if (checkedKeys.size === 0) return; setIsDismissing(true); const pairIds: Array<[string, string]> = []; for (const key of checkedKeys) { const [a, b] = key.split('::') as [string, string]; pairIds.push([a, b]); } try { await window.electronAPI?.embeddings.dismissPairs(pairIds); setPairs(prev => prev.filter(p => !checkedKeys.has(pairKey(p)))); if (projectId) { removeDismissedPairs(projectId, pairIds); } setCheckedKeys(new Set()); } catch (err) { console.error('Failed to dismiss pairs:', err); } finally { setIsDismissing(false); } }, [checkedKeys, projectId]); const handleToggleCheck = useCallback((key: string) => { setCheckedKeys(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const handleCheckAll = useCallback(() => { setCheckedKeys(new Set(visiblePairs.map(pairKey))); }, [visiblePairs]); const handleUncheckAll = useCallback(() => { setCheckedKeys(new Set()); }, []); const handleOpenPost = useCallback((postId: string) => { openEntityTab(openTab, 'post', postId, 'pin'); }, [openTab]); const handleShowMore = useCallback(() => { setVisibleCount(prev => prev + PAGE_SIZE); }, []); const hasCachedResults = pairs.length > 0 || (checked && getPersistedDuplicatesResult(projectId ?? '') !== null); return (

{t('duplicatesView.title')}

{t('duplicatesView.description')}

{notEnabled && (

{t('duplicatesView.notEnabled')}

)} {!notEnabled && checked && (
{pairs.length > 0 && ( <> )}
)} {!notEnabled && isSearching && (

{t('duplicatesView.loading')}

)} {!notEnabled && !isSearching && checked && !hasCachedResults && (

{t('duplicatesView.empty')}

)} {!notEnabled && !isSearching && pairs.length > 0 && (

{t('duplicatesView.count', { count: pairs.length })}

{visiblePairs.map(pair => { const key = pairKey(pair); return (
handleToggleCheck(key)} />
{pair.exactMatch ? t('duplicatesView.exactMatch') : t('duplicatesView.similarity', { value: Math.round(pair.similarity * 100) })}
); })} {hasMore && ( )}
)}
); };