export function MatchingPage() {
const navigate = useNavigate();
const { authState } = useAuthState();
const { rateLimitState } = useRateLimit();
const { recordEvent } = useDebugActions();
const { completeStep } = useOnboarding();
// State for batch selection
const [selectedMatchIds, setSelectedMatchIds] = useState<Set<number>>(
new Set(),
);
// State for batch operation confirmations and progress
const [showBatchRejectDialog, setShowBatchRejectDialog] = useState(false);
const [showBatchResetDialog, setShowBatchResetDialog] = useState(false);
const [batchOperationProgress, setBatchOperationProgress] = useState<{
current: number;
total: number;
} | null>(null);
const [batchAbortController, setBatchAbortController] =
useState<AbortController | null>(null);
// State for manga data
const [manga, setManga] = useState<KenmeiManga[]>([]);
const [matchResults, setMatchResults] = useState<MangaMatchResult[]>([]);
// Undo/Redo state
const [undoRedoManager] = useState(() => new UndoRedoManager(50));
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
// State for manual search
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchTarget, setSearchTarget] = useState<KenmeiManga | undefined>(
undefined,
);
// Ref to track search target for React Compiler compatibility in useMatchHandlers
const searchTargetRef = useRef<KenmeiManga | undefined>(undefined);
useEffect(() => {
searchTargetRef.current = searchTarget;
}, [searchTarget]);
// Add state for status filtering and rematching
const [selectedStatuses, setSelectedStatuses] = useState<StatusFilterOptions>(
{
pending: true,
skipped: true,
matched: false,
manual: false,
unmatched: true,
},
);
const [isRematchOptionsVisible, setIsRematchOptionsVisible] = useState(false);
const [rematchWarning, setRematchWarning] = useState<string | null>(null);
// State for duplicate detection
const [duplicateEntries, setDuplicateEntries] = useState<DuplicateEntry[]>(
[],
);
const [showDuplicateWarning, setShowDuplicateWarning] = useState(false);
// Confidence recalculation state
const [isConfidenceDialogOpen, setIsConfidenceDialogOpen] = useState(false);
const [confidenceStatus, setConfidenceStatus] =
useState<ConfidenceRunStatus>("idle");
const [confidenceProgress, setConfidenceProgress] =
useState<ConfidenceProgressState | null>(null);
const [confidenceError, setConfidenceError] = useState<string | null>(null);
const [confidenceMetadata, setConfidenceMetadata] =
useState<ConfidenceMetadataSnapshot | null>(null);
const persistConfidenceMetadataSnapshot = useCallback(
(metadata: ConfidenceMetadataSnapshot) => {
try {
storage.setItem(
STORAGE_KEYS.CONFIDENCE_RECALC_METADATA,
JSON.stringify(metadata),
);
} catch (error) {
captureError(
ErrorType.STORAGE,
"Failed to persist confidence metadata",
error instanceof Error ? error : new Error(String(error)),
{},
);
}
},
[],
);
const clearPersistedConfidenceMetadata = useCallback(() => {
storage.removeItem(STORAGE_KEYS.CONFIDENCE_RECALC_METADATA);
}, []);
useEffect(() => {
const storedMetadata = storage.getItem(
STORAGE_KEYS.CONFIDENCE_RECALC_METADATA,
);
if (!storedMetadata) {
return;
}
try {
const parsed = JSON.parse(storedMetadata);
if (isConfidenceMetadataSnapshot(parsed)) {
setConfidenceMetadata(parsed);
} else {
clearPersistedConfidenceMetadata();
}
} catch (error) {
captureError(
ErrorType.STORAGE,
"Failed to read stored confidence metadata",
error instanceof Error ? error : new Error(String(error)),
{},
);
clearPersistedConfidenceMetadata();
}
}, [clearPersistedConfidenceMetadata]);
const confidenceTaskRef = useRef<ConfidenceRecalculationExecution | null>(
null,
);
const confidenceTaskIdRef = useRef<string | null>(null);
// State for search query (to be passed to MangaMatchingPanel)
const [searchQuery, setSearchQuery] = useState<string>("");
// Track initial page load to show skeletons
const [isInitialLoad, setIsInitialLoad] = useState(true);
const matchStatusSummary = useMemo(() => {
const total = matchResults.length;
const matched = matchResults.filter((m) => m.status === "matched").length;
const manual = matchResults.filter((m) => m.status === "manual").length;
const pending = matchResults.filter((m) => m.status === "pending").length;
const skipped = matchResults.filter((m) => m.status === "skipped").length;
const reviewed = matched + manual + skipped;
const completionPercent =
total === 0 ? 0 : Math.round((reviewed / total) * 100);
return {
total,
matched,
manual,
pending,
skipped,
reviewed,
completionPercent,
};
}, [matchResults]);
/**
* Resets all matched and manually matched entries back to pending status for re-review.
* @source
*/
const handleSetAllMatchedToPending = () => {
if (!matchResults.length) return;
const updated = matchResults.map((m) =>
m.status === "matched" || m.status === "manual"
? {
...m,
status: "pending" as const,
selectedMatch: undefined,
matchDate: new Date().toISOString(),
}
: m,
);
setMatchResults(updated);
// Persist to storage
try {
storage.setItem(STORAGE_KEYS.MATCH_RESULTS, JSON.stringify(updated));
} catch (storageError) {
console.error(
"[MatchingPage] Failed to persist match results to storage:",
storageError,
);
}
// Clear undo history for this bulk reset operation
undoRedoManager.clear();
};
/**
* Undo the last action in match operations.
* @source
*/
const handleUndo = () => {
const metadata = undoRedoManager.undo();
if (metadata) {
toast(`Undone: ${metadata.description}`, {
description: truncateToastMessage(
metadata.affectedTitles.join(", "),
200,
).component,
});
recordEvent({
type: "match.undo",
message: `Undone: ${metadata.description}`,
level: "info",
metadata: { description: metadata.description, count: 1 },
});
// Update button states
setCanUndo(undoRedoManager.canUndo());
setCanRedo(undoRedoManager.canRedo());
}
};
/**
* Redo the last undone action in match operations.
* @source
*/
const handleRedo = () => {
const metadata = undoRedoManager.redo();
if (metadata) {
toast(`Redone: ${metadata.description}`, {
description: truncateToastMessage(
metadata.affectedTitles.join(", "),
200,
).component,
});
recordEvent({
type: "match.redo",
message: `Redone: ${metadata.description}`,
level: "info",
metadata: { description: metadata.description, count: 1 },
});
// Update button states
setCanUndo(undoRedoManager.canUndo());
setCanRedo(undoRedoManager.canRedo());
}
};
const matchingProcess = useMatchingProcess({
accessToken: authState.accessToken || null,
rateLimitState,
});
const pendingMangaState = usePendingManga();
// Create debounced progress updater for batch operations
const debouncedProgressUpdate = useMemo(
() =>
debounce(
((current: number, total: number) => {
setBatchOperationProgress({ current, total });
}) as (...args: unknown[]) => unknown,
100,
),
[],
);
// Clean up debounce on unmount
useEffect(() => {
return () => {
debouncedProgressUpdate.cancel();
};
}, [debouncedProgressUpdate]);
// Progress callback for batch operations
const handleBatchProgress = useCallback(
(current: number, total: number) => {
debouncedProgressUpdate(current, total);
},
[debouncedProgressUpdate],
);
// Use match handlers
const matchHandlers = useMatchHandlers(
matchResults,
setMatchResults,
searchTargetRef,
setSearchTarget,
setIsSearchOpen,
matchingProcess.setBypassCache,
undoRedoManager,
);
const persistMatchResults = useCallback((updated: MangaMatchResult[]) => {
queueMicrotask(() => {
try {
const serialized = JSON.stringify(updated);
if (typeof storage.setItemAsync === "function") {
storage
.setItemAsync(STORAGE_KEYS.MATCH_RESULTS, serialized)
.catch((error) => {
captureError(
ErrorType.STORAGE,
"Failed to persist match results asynchronously",
error instanceof Error ? error : new Error(String(error)),
{ size: serialized.length },
);
});
} else {
storage.setItem(STORAGE_KEYS.MATCH_RESULTS, serialized);
}
} catch (error) {
captureError(
ErrorType.STORAGE,
"Failed to persist match results",
error instanceof Error ? error : new Error(String(error)),
{},
);
}
});
}, []);
// Error boundary handlers
const handleMatchingReset = useCallback(() => {
setMatchResults([]);
setSelectedMatchIds(new Set());
setBatchOperationProgress(null);
undoRedoManager.clear();
setCanUndo(false);
setCanRedo(false);
matchingProcess.setError?.(null);
setConfidenceMetadata(null);
clearPersistedConfidenceMetadata();
// Reload from storage
try {
const savedResults = getSavedMatchResults();
if (savedResults) {
setMatchResults(savedResults as MangaMatchResult[]);
}
toast.success("Matching state reset successfully");
} catch (error) {
captureError(
ErrorType.STORAGE,
"Failed to reload match results after reset",
error instanceof Error ? error : new Error(String(error)),
{},
);
}
}, [matchingProcess, undoRedoManager]);
const handleClearMatchingCache = useCallback(() => {
try {
const titles = matchResults.map((m) => m.kenmeiManga.title);
clearCacheForTitles(titles);
toast.success("Matching cache cleared successfully");
} catch (error) {
captureError(
ErrorType.STORAGE,
"Failed to clear matching cache",
error instanceof Error ? error : new Error(String(error)),
{ titleCount: matchResults.length },
);
}
}, [matchResults]);
// Batch selection handlers
const handleToggleSelection = useCallback((matchId: number) => {
setSelectedMatchIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(matchId)) {
newSet.delete(matchId);
} else {
newSet.add(matchId);
}
return newSet;
});
}, []);
const handleSelectAll = useCallback((ids: number[]) => {
setSelectedMatchIds(new Set(ids));
}, []);
const handleClearSelection = useCallback(() => {
setSelectedMatchIds(new Set());
}, []);
const handleConfidenceDialogChange = useCallback((nextOpen: boolean) => {
setIsConfidenceDialogOpen(nextOpen);
}, []);
const cancelConfidenceRecalculation = useCallback(() => {
const activeTask = confidenceTaskRef.current;
if (!activeTask) {
return;
}
activeTask.cancel();
confidenceTaskRef.current = null;
confidenceTaskIdRef.current = null;
setConfidenceStatus("cancelled");
toast.info("Confidence recalculation cancelled");
recordEvent({
type: "confidence.recalc.cancelled",
message: "Confidence recalculation cancelled by user",
level: "info",
});
}, [recordEvent]);
const startConfidenceRecalculation = useCallback(
(autoOpenDialog = false) => {
if (autoOpenDialog) {
setIsConfidenceDialogOpen(true);
}
if (confidenceTaskIdRef.current || confidenceStatus === "running") {
return;
}
if (matchResults.length === 0) {
toast.info("No matches available to update yet.");
return;
}
if (matchingProcess.isLoading) {
toast.info("Please wait for the current matching run to finish.");
return;
}
if (rateLimitState.isRateLimited) {
toast.info("Cannot recalculate while AniList rate limit is active.");
return;
}
setConfidenceStatus("running");
setConfidenceError(null);
setConfidenceProgress({ current: 0, total: matchResults.length });
setConfidenceMetadata(null);
clearPersistedConfidenceMetadata();
const execution = recalculateConfidenceScores(
matchResults,
DEFAULT_MATCH_CONFIG,
{
onProgress: (current, total, currentTitle) => {
setConfidenceProgress({ current, total, currentTitle });
},
},
);
confidenceTaskRef.current = execution;
confidenceTaskIdRef.current = execution.taskId;
recordEvent({
type: "confidence.recalc.start",
message: "Confidence recalculation started",
level: "info",
metadata: { total: matchResults.length },
});
execution.promise
.then(({ results, metadata }) => {
const previousTotal = matchResults.length;
const finalProcessed = metadata?.processed ?? results.length;
const finalTotal =
metadata?.totalItems ?? previousTotal ?? results.length;
const normalizedMetadata: ConfidenceMetadataSnapshot = {
...metadata,
processed: finalProcessed,
totalItems: finalTotal,
cancelled: Boolean(metadata?.cancelled),
completedAt: new Date().toISOString(),
};
setConfidenceMetadata(normalizedMetadata);
persistConfidenceMetadataSnapshot(normalizedMetadata);
setConfidenceProgress({ current: finalProcessed, total: finalTotal });
if (confidenceTaskIdRef.current !== execution.taskId) {
return;
}
if (!normalizedMetadata.cancelled) {
setMatchResults(results);
persistMatchResults(results);
}
const finalStatus: ConfidenceRunStatus = normalizedMetadata.cancelled
? "cancelled"
: "completed";
setConfidenceStatus(finalStatus);
if (!normalizedMetadata.cancelled) {
toast.success("Confidence scores refreshed for all entries.");
recordEvent({
type: "confidence.recalc.complete",
message: "Confidence recalculation completed",
level: "info",
metadata: {
durationMs: normalizedMetadata.durationMs,
processed: normalizedMetadata.processed,
},
});
}
})
.catch((error) => {
if (confidenceTaskIdRef.current !== execution.taskId) {
return;
}
const message =
error instanceof Error ? error.message : String(error);
setConfidenceStatus("error");
setConfidenceError(message);
toast.error("Confidence recalculation failed", {
description: message,
});
recordEvent({
type: "confidence.recalc.error",
message,
level: "error",
});
})
.finally(() => {
if (confidenceTaskIdRef.current === execution.taskId) {
confidenceTaskRef.current = null;
confidenceTaskIdRef.current = null;
}
});
},
[
confidenceStatus,
matchResults,
matchingProcess,
persistMatchResults,
rateLimitState.isRateLimited,
recordEvent,
toast,
],
);
const handleRecalculateConfidenceClick = useCallback(() => {
startConfidenceRecalculation(true);
}, [startConfidenceRecalculation]);
const handleBatchAccept = useCallback(async () => {
const selectedMatches = matchResults.filter((match) =>
selectedMatchIds.has(match.kenmeiManga.id),
);
if (selectedMatches.length > 0) {
// Create abort controller and store in state
const abortController = new AbortController();
setBatchAbortController(abortController);
// Set initial progress
setBatchOperationProgress({ current: 0, total: selectedMatches.length });
try {
// Execute the operation with progress tracking
await matchHandlers.handleAcceptMatch(
{
isBatchOperation: true,
matches: selectedMatches,
},
handleBatchProgress,
abortController.signal,
);
// Show success toast
toast.success(`Accepted ${selectedMatches.length} matches`, {
description: "You can undo this action with Ctrl+Z",
});
} catch (error) {
if (error instanceof AbortError) {
toast.info("Batch operation cancelled");
} else {
console.error("Batch accept error:", error);
toast.error("Failed to accept matches");
}
} finally {
// Clear progress and selection after a short delay
setTimeout(() => {
setBatchOperationProgress(null);
setBatchAbortController(null);
handleClearSelection();
}, 500);
}
}
}, [
matchResults,
selectedMatchIds,
matchHandlers,
handleClearSelection,
handleBatchProgress,
]);
const handleBatchReject = useCallback(() => {
setShowBatchRejectDialog(true);
}, []);
const confirmBatchReject = useCallback(async () => {
const selectedMatches = matchResults.filter((match) =>
selectedMatchIds.has(match.kenmeiManga.id),
);
if (selectedMatches.length > 0) {
// Create abort controller and store in state
const abortController = new AbortController();
setBatchAbortController(abortController);
// Set initial progress
setBatchOperationProgress({ current: 0, total: selectedMatches.length });
try {
// Execute the operation with progress tracking
await matchHandlers.handleRejectMatch(
{
isBatchOperation: true,
matches: selectedMatches,
},
handleBatchProgress,
abortController.signal,
);
// Show success toast
toast.success(`Rejected ${selectedMatches.length} matches`, {
description: "You can undo this action with Ctrl+Z",
});
} catch (error) {
if (error instanceof AbortError) {
toast.info("Batch operation cancelled");
} else {
console.error("Batch reject error:", error);
toast.error("Failed to reject matches");
}
} finally {
// Clear progress and selection after a short delay
setTimeout(() => {
setBatchOperationProgress(null);
setBatchAbortController(null);
handleClearSelection();
}, 500);
}
}
setShowBatchRejectDialog(false);
}, [
matchResults,
selectedMatchIds,
matchHandlers,
handleClearSelection,
handleBatchProgress,
]);
const handleBatchReset = useCallback(() => {
setShowBatchResetDialog(true);
}, []);
const handleCancelBatchOperation = useCallback(() => {
batchAbortController?.abort();
setBatchAbortController(null);
setBatchOperationProgress(null);
handleClearSelection();
toast.info("Batch operation cancelled");
}, [batchAbortController, handleClearSelection]);
const confirmBatchReset = useCallback(async () => {
const selectedMatches = matchResults.filter((match) =>
selectedMatchIds.has(match.kenmeiManga.id),
);
if (selectedMatches.length > 0) {
// Create abort controller and store in state
const abortController = new AbortController();
setBatchAbortController(abortController);
// Set initial progress
setBatchOperationProgress({ current: 0, total: selectedMatches.length });
try {
// Execute the operation with progress tracking
await matchHandlers.handleResetToPending(
{
isBatchOperation: true,
matches: selectedMatches,
},
handleBatchProgress,
abortController.signal,
);
// Show success toast
toast.success(`Reset ${selectedMatches.length} matches to pending`, {
description: "You can undo this action with Ctrl+Z",
});
} catch (error) {
if (error instanceof AbortError) {
toast.info("Batch operation cancelled");
} else {
console.error("Batch reset error:", error);
toast.error("Failed to reset matches");
}
} finally {
// Clear progress and selection after a short delay
setTimeout(() => {
setBatchOperationProgress(null);
setBatchAbortController(null);
handleClearSelection();
}, 500);
}
}
setShowBatchResetDialog(false);
}, [
matchResults,
selectedMatchIds,
matchHandlers,
handleClearSelection,
handleBatchProgress,
]);
const handleImportComplete = useCallback(
(result: { imported: number; merged: number; skipped: number }) => {
// Reload match results from storage
const savedResults = storage.getItem(STORAGE_KEYS.MATCH_RESULTS);
if (savedResults) {
try {
const updatedMatches: MangaMatchResult[] = JSON.parse(savedResults);
setMatchResults(updatedMatches);
// Show success toast with statistics
toast.success(
`Successfully imported ${result.imported} matches (${result.merged} merged, ${result.skipped} skipped)`,
{
description: "Your match results have been updated",
},
);
// Clear any active filters or search to show all results
handleClearSelection();
setSearchQuery("");
} catch (error) {
console.error(
"[MatchingPage] Failed to reload match results:",
error,
);
toast.error("Import completed but failed to reload results");
}
}
},
[handleClearSelection],
);
// Add a ref to track if we've already done initialization
const hasInitialized = useRef(false);
const lastGlobalSyncSnapshot = useRef<{
current: number;
total: number;
currentTitle: string | null;
statusMessage: string | null;
detailMessage: string | null;
} | null>(null);
const matchingProcessRef = useRef(matchingProcess);
useEffect(() => {
matchingProcessRef.current = matchingProcess;
}, [matchingProcess]);
// Calculate whether resume is needed and the count of unprocessed manga
const resumeState = useMemo(() => {
// Create sets for efficient lookup
const processedIds = new Set(
matchResults.map((r) => r.kenmeiManga.id).filter(Boolean),
);
const processedTitles = new Set(
matchResults.map((r) => r.kenmeiManga.title.toLowerCase()),
);
// Check pending manga from storage
const unprocessedFromPending = pendingMangaState.pendingManga.filter(
(manga) => {
const idMatch = manga.id && processedIds.has(manga.id);
const titleMatch = processedTitles.has(manga.title.toLowerCase());
return !idMatch && !titleMatch;
},
);
// Check count difference
const unprocessedFromAll = manga.filter((m) => {
const idMatch = m.id && processedIds.has(m.id);
const titleMatch = processedTitles.has(m.title.toLowerCase());
return !idMatch && !titleMatch;
});
const unprocessedCount = Math.max(
unprocessedFromPending.length,
unprocessedFromAll.length,
);
const needsProcessing = unprocessedCount > 0;
return {
needsProcessing,
unprocessedCount,
unprocessedManga: unprocessedFromAll,
};
}, [matchResults, pendingMangaState.pendingManga, manga]);
// Update undo/redo button states
useEffect(() => {
setCanUndo(undoRedoManager.canUndo());
setCanRedo(undoRedoManager.canRedo());
}, [matchResults, undoRedoManager]);
// Warmup title normalization caches when unprocessed manga is loaded
useEffect(() => {
if (
resumeState.unprocessedManga &&
resumeState.unprocessedManga.length > 0
) {
console.info(
`[MatchingPage] 🔥 Starting cache warmup for ${resumeState.unprocessedManga.length} unprocessed titles`,
);
const cacheWarmer = getCacheWarmer();
const titles = resumeState.unprocessedManga.map((m) => m.title);
cacheWarmer
.warmupCachesInBackground(
titles,
["normalizeForMatching", "processTitle"],
(algorithm, current, total) => {
console.debug(
`[MatchingPage] 📊 Cache warmup progress - ${algorithm}: ${current}/${total}`,
);
},
)
.catch((error) => {
console.warn(
"[MatchingPage] ⚠️ Cache warmup failed, but continuing:",
error,
);
});
}
}, [resumeState.unprocessedManga]);
// Add keyboard shortcuts for undo/redo
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip if target is input or textarea
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return;
}
// Skip if matching is loading
if (matchingProcess.isLoading) {
return;
}
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
const modifier = isMac ? e.metaKey : e.ctrlKey;
// Ctrl/Cmd+Z for undo
if (modifier && e.key === "z" && !e.shiftKey) {
e.preventDefault();
if (undoRedoManager.canUndo()) {
handleUndo();
}
}
// Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y for redo
if (
modifier &&
((e.key === "z" && e.shiftKey) || (!isMac && e.key === "y"))
) {
e.preventDefault();
if (undoRedoManager.canRedo()) {
handleRedo();
}
}
};
globalThis.addEventListener("keydown", handleKeyDown);
return () => globalThis.removeEventListener("keydown", handleKeyDown);
}, [undoRedoManager, matchingProcess.isLoading, handleUndo, handleRedo]);
// Keyboard shortcuts for batch selection
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in input fields
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
// Escape to clear selection
if (e.key === "Escape" && selectedMatchIds.size > 0) {
e.preventDefault();
e.stopPropagation();
handleClearSelection();
}
};
globalThis.addEventListener("keydown", handleKeyDown);
return () => globalThis.removeEventListener("keydown", handleKeyDown);
}, [selectedMatchIds, handleClearSelection]);
// Clear pending manga if all are processed (prevents infinite loops)
useEffect(() => {
if (
pendingMangaState.pendingManga.length > 0 &&
!resumeState.needsProcessing &&
!matchingProcess.isLoading
) {
console.info(
"[MatchingPage] All pending manga are processed - clearing pending manga storage",
);
pendingMangaState.savePendingManga([]);
}
}, [
resumeState.needsProcessing,
pendingMangaState,
matchingProcess.isLoading,
]);
// Add debug effect for matching results
useEffect(() => {
if (matchResults.length > 0) {
const statusCounts = {
matched: matchResults.filter((m) => m.status === "matched").length,
pending: matchResults.filter((m) => m.status === "pending").length,
manual: matchResults.filter((m) => m.status === "manual").length,
skipped: matchResults.filter((m) => m.status === "skipped").length,
};
console.debug("[MatchingPage] Status counts:", statusCounts);
}
}, [matchResults]);
// Effect to detect duplicate AniList IDs
useEffect(() => {
if (matchResults.length === 0) {
setDuplicateEntries([]);
setShowDuplicateWarning(false);
return;
}
let isMounted = true;
const detectDuplicates = async () => {
try {
const pool = getDuplicateDetectionWorkerPool();
await pool.initialize();
const result = await pool.detectDuplicates(matchResults);
if (!isMounted) return;
// Convert worker result format to DuplicateEntry format
const duplicates: DuplicateEntry[] = result.duplicates.map((dup) => ({
anilistId: dup.anilistId,
anilistTitle: dup.anilistTitle,
kenmeiTitles: dup.kenmeiTitles,
}));
setDuplicateEntries(duplicates);
// Show warning if duplicates are found and warning wasn't already dismissed
if (duplicates.length > 0) {
setShowDuplicateWarning(true);
} else {
setShowDuplicateWarning(false);
}
// Log worker execution info for debugging
console.debug(
`[MatchingPage] Duplicate detection completed: ${duplicates.length} groups found (${result.executedOnWorker ? "worker" : "main thread"}, ${result.timing.processingTimeMs.toFixed(2)}ms)`,
);
} catch (error) {
console.error(
"[MatchingPage] Error during duplicate detection:",
error,
);
// Fall back to main thread implementation
const duplicates = detectDuplicateAniListIds(matchResults);
if (isMounted) {
setDuplicateEntries(duplicates);
setShowDuplicateWarning(duplicates.length > 0);
}
}
};
detectDuplicates();
return () => {
isMounted = false;
};
}, [matchResults]);
/**
* Checks user authentication status with AniList.
* Sets appropriate error messages if not authenticated.
* @returns Boolean indicating whether authentication is valid.
* @source
*/
const checkAuthenticationStatus = (): boolean => {
if (!authState.isAuthenticated || !authState.accessToken) {
console.info("[MatchingPage] User not authenticated, showing auth error");
matchingProcess.setError(
"Authentication Required. You need to connect your AniList account to match manga.",
);
matchingProcess.setDetailMessage(
"Please go to Settings to authenticate with AniList.",
);
return false;
}
return true;
};
/**
* Handles ignoring a duplicate AniList entry and refreshes the duplicate list
*/
const handleIgnoreDuplicate = async (
anilistId: number,
anilistTitle: string,
): Promise<void> => {
try {
addIgnoredDuplicate(anilistId, anilistTitle);
// Refresh duplicates using worker
const pool = getDuplicateDetectionWorkerPool();
// Ensure the wrapper pool is initialized using its public API
await pool.initialize();
const result = await pool.detectDuplicates(matchResults);
// Convert worker result format to DuplicateEntry format
const updatedDuplicates: DuplicateEntry[] = result.duplicates.map(
(dup) => ({
anilistId: dup.anilistId,
anilistTitle: dup.anilistTitle,
kenmeiTitles: dup.kenmeiTitles,
}),
);
setDuplicateEntries(updatedDuplicates);
// Hide warning if no duplicates remain
if (updatedDuplicates.length === 0) {
setShowDuplicateWarning(false);
}
console.info(
`[MatchingPage] Ignored duplicate ${anilistId} (${anilistTitle}), ${updatedDuplicates.length} duplicates remaining`,
);
} catch (error) {
console.error("[MatchingPage] Error ignoring duplicate:", error);
// Fall back to main thread implementation
addIgnoredDuplicate(anilistId, anilistTitle);
const updatedDuplicates = detectDuplicateAniListIds(matchResults);
setDuplicateEntries(updatedDuplicates);
if (updatedDuplicates.length === 0) {
setShowDuplicateWarning(false);
}
}
};
/**
* Restores the matching process state from global scope if it was interrupted.
* Recovers progress, status messages, and pause state from previous session.
* @returns Boolean indicating whether process state was successfully restored.
* @source
*/
const restoreRunningProcessState = (): boolean => {
if (globalThis.matchingProcessState?.isRunning) {
console.info(
"[MatchingPage] Detected running matching process, restoring state",
);
// Restore the matching process state
matchingProcess.setIsLoading(true);
matchingProcess.setProgress({
current: globalThis.matchingProcessState.progress.current,
total: globalThis.matchingProcessState.progress.total,
currentTitle: globalThis.matchingProcessState.progress.currentTitle,
});
matchingProcess.setStatusMessage(
globalThis.matchingProcessState.statusMessage,
);
matchingProcess.setDetailMessage(
globalThis.matchingProcessState.detailMessage,
);
if (globalThis.matchingProcessState.timeEstimate) {
matchingProcess.setTimeEstimate(
globalThis.matchingProcessState.timeEstimate,
);
}
const persistedPauseTransitioning = Boolean(
globalThis.matchingProcessState.isPauseTransitioning,
);
const persistedManualPause = Boolean(
globalThis.matchingProcessState.isManuallyPaused,
);
if (
matchingProcess.isPauseTransitioning !== persistedPauseTransitioning
) {
matchingProcess.setIsPauseTransitioning(persistedPauseTransitioning);
}
if (matchingProcess.isManuallyPaused !== persistedManualPause) {
matchingProcess.setIsManuallyPaused(persistedManualPause);
}
if (persistedManualPause) {
matchingProcess.setManualMatchingPause(true);
matchingProcess.pauseTimeTracking();
} else if (!matchingProcess.isRateLimitPaused) {
matchingProcess.setManualMatchingPause(false);
matchingProcess.resumeTimeTracking();
}
// Mark as initialized to prevent auto-starting
matchingProcess.matchingInitialized.current = true;
matchingProcess.setIsInitializing(false);
return true;
}
return false;
};
/**
* Loads and processes previously saved match results from storage.
* Updates component state with saved results and calculates review completion status.
* @returns Object indicating whether results were found and the results themselves if available.
* @source
*/
const processSavedMatchResults = (): {
hasResults: boolean;
savedResults?: MangaMatchResult[];
} => {
console.info("[MatchingPage] Loading saved match results immediately...");
const savedResults = getSavedMatchResults();
if (
savedResults &&
Array.isArray(savedResults) &&
savedResults.length > 0
) {
console.info(
`[MatchingPage] Found ${savedResults.length} existing match results - loading immediately`,
);
setMatchResults(savedResults as MangaMatchResult[]);
// Check how many matches have already been reviewed
const reviewedCount = savedResults.filter(
(m) =>
m.status === "matched" ||
m.status === "manual" ||
m.status === "skipped",
).length;
console.info(
`[MatchingPage] ${reviewedCount} manga have already been reviewed (${Math.round((reviewedCount / savedResults.length) * 100)}% complete)`,
);
return {
hasResults: true,
savedResults: savedResults as MangaMatchResult[],
};
}
console.info("[MatchingPage] No saved match results found");
return { hasResults: false };
};
/**
* Calculates missing manga between saved match results and imported manga data.
* Saves unprocessed manga to storage or handles discrepancies.
* @param savedResults - Previously saved match results.
* @param importedManga - Currently imported manga entries.
* @source
*/
const calculateMissingManga = (
savedResults: MangaMatchResult[],
importedManga: KenmeiManga[],
): void => {
if (importedManga.length === 0) return;
console.info(
"[MatchingPage] Have both saved results and imported manga - calculating unmatched manga",
);
const calculatedPendingManga = pendingMangaState.calculatePendingManga(
savedResults,
importedManga,
);
if (calculatedPendingManga.length > 0) {
console.info(
`[MatchingPage] Calculated ${calculatedPendingManga.length} manga that still need to be processed`,
);
pendingMangaState.savePendingManga(calculatedPendingManga);
console.debug(
`[MatchingPage] Saved ${calculatedPendingManga.length} pending manga to storage for resume`,
);
} else {
handleDiscrepancyDetection(savedResults, importedManga);
}
};
/**
* Detects and handles discrepancies between total imported manga and processed match results.
* Attempts to find and save actual missing manga for processing.
* @param savedResults - Previously saved match results.
* @param importedManga - Currently imported manga entries.
* @source
*/
const handleDiscrepancyDetection = (
savedResults: MangaMatchResult[],
importedManga: KenmeiManga[],
): void => {
console.debug("[MatchingPage] No pending manga found in calculation");
// If there's a clear discrepancy between total manga and processed manga,
// force a calculation of pending manga by finding the actual missing manga
if (importedManga.length > savedResults.length) {
console.warn(
`[MatchingPage] ⚠️ Discrepancy detected! Total manga: ${importedManga.length}, Processed: ${savedResults.length}`,
);
console.info(
"[MatchingPage] Finding actual missing manga using comprehensive title and ID matching",
);
const actualMissingManga = findActualMissingManga(
savedResults,
importedManga,
);
if (actualMissingManga.length > 0) {
console.info(
`[MatchingPage] Found ${actualMissingManga.length} actual missing manga that need processing`,
);
console.debug(
"[MatchingPage] Sample missing manga:",
actualMissingManga
.slice(0, 5)
.map((m) => ({ id: m.id, title: m.title })),
);
pendingMangaState.savePendingManga(actualMissingManga);
} else {
console.info(
"[MatchingPage] No actual missing manga found despite count discrepancy - all manga may already be processed",
);
}
}
};
/**
* Finds manga entries that have not been processed using comprehensive title and ID matching.
* @param savedResults - Previously saved match results for comparison.
* @param importedManga - All imported manga entries to search through.
* @returns Array of unprocessed manga entries that still need matching.
* @source
*/
const findActualMissingManga = (
savedResults: MangaMatchResult[],
importedManga: KenmeiManga[],
): KenmeiManga[] => {
// Create sets of processed manga for quick lookup - convert IDs to strings for consistent comparison
const processedIds = new Set(
savedResults.map((r) => r.kenmeiManga.id?.toString()).filter(Boolean),
);
const processedTitles = new Set(
savedResults.map((r) => r.kenmeiManga.title.toLowerCase()),
);
console.debug(
`[MatchingPage] Processed IDs (first 10):`,
Array.from(processedIds).slice(0, 10),
);
console.debug(
`[MatchingPage] Processed titles (first 5):`,
Array.from(processedTitles).slice(0, 5),
);
// Find manga that aren't in savedResults using proper matching
const actualMissingManga: KenmeiManga[] = [];
for (const manga of importedManga) {
const idMatch = manga.id != null && processedIds.has(manga.id.toString());
const titleMatch = processedTitles.has(manga.title.toLowerCase());
// Debug log for first few manga being checked
if (actualMissingManga.length < 5) {
console.debug(
`[MatchingPage] Checking manga "${manga.title}" (ID: ${manga.id}): idMatch=${idMatch}, titleMatch=${titleMatch}, shouldInclude=${!idMatch && !titleMatch}`,
);
}
if (!idMatch && !titleMatch) {
actualMissingManga.push(manga);
}
}
return actualMissingManga;
};
/**
* Handles module preloading and final initialization steps for matching process.
* Preloads search service, syncs cache, and starts matching if appropriate.
* @param importedManga - The imported manga entries to process.
* @source
*/
const handleModulePreloadingAndInitialization = (
importedManga: KenmeiManga[],
): void => {
// Check for pending manga from a previously interrupted operation (only if no saved results)
const pendingMangaData = pendingMangaState.loadPendingManga();
if (pendingMangaData && pendingMangaData.length > 0) {
// Clear any error message since we're showing the resume notification instead
matchingProcess.setError(null);
// End initialization when we've found pending manga
matchingProcess.setIsInitializing(false);
}
// Preload the cache service to ensure it's initialized
import("../api/matching/search-service").then((module) => {
console.debug("[MatchingPage] Preloaded manga search service");
// Force cache sync
if (module.cacheDebugger) {
module.cacheDebugger.forceSyncCaches();
}
// If we haven't already loaded saved results and have imported manga, start matching
if (
importedManga.length &&
!matchingProcess.matchingInitialized.current
) {
console.info(
"[MatchingPage] Starting initial matching process with imported manga",
);
matchingProcess.matchingInitialized.current = true;
// Start matching process automatically
matchingProcess.startMatching(importedManga, false, setMatchResults);
} else if (!importedManga.length) {
console.info(
"[MatchingPage] No imported manga found, redirecting to import page",
);
matchingProcess.setError(
"No manga data found. Please import your data first.",
);
}
// Make sure we mark initialization as complete
matchingProcess.setIsInitializing(false);
console.info("[MatchingPage] *** INITIALIZATION COMPLETE ***");
});
};
// Initial data loading
useEffect(() => {
// Strong initialization guard to prevent multiple runs
if (hasInitialized.current) {
return;
}
// Mark as initialized immediately to prevent any possibility of re-runs
hasInitialized.current = true;
// Check authentication status
if (!checkAuthenticationStatus()) {
return;
}
console.info("[MatchingPage] *** INITIALIZATION START ***");
console.debug("[MatchingPage] Initial states:", {
isLoading: matchingProcess.isLoading,
hasError: !!matchingProcess.error,
matchResultsLength: matchResults.length,
pendingMangaLength: pendingMangaState.pendingManga.length,
isMatchingInitialized: matchingProcess.matchingInitialized.current,
});
// Check if there's an ongoing matching process and restore state if needed
if (restoreRunningProcessState()) {
return;
}
// Skip if this effect has already been run
if (matchingProcess.matchingInitialized.current) {
console.info(
"[MatchingPage] Matching already initialized, skipping duplicate initialization",
);
matchingProcess.setIsInitializing(false);
return;
}
console.info("[MatchingPage] Initializing MatchingPage component...");
// Get imported data from storage to have it available for calculations
const importedData = getKenmeiData();
const importedManga = importedData?.manga || [];
if (importedManga.length > 0) {
console.info(
`[MatchingPage] Found ${importedManga.length} imported manga from storage`,
);
// Store the imported manga data for later use
setManga(importedManga as KenmeiManga[]);
} else {
console.info("[MatchingPage] No imported manga found in storage");
}
// Load saved match results and process them
const { hasResults, savedResults } = processSavedMatchResults();
if (hasResults && savedResults) {
// Calculate what might still need processing if we have imported manga
calculateMissingManga(savedResults, importedManga as KenmeiManga[]);
// Mark as initialized since we have results
matchingProcess.matchingInitialized.current = true;
matchingProcess.setIsInitializing(false);
setIsInitialLoad(false);
console.info(
"[MatchingPage] *** INITIALIZATION COMPLETE - Using saved results ***",
);
return; // Skip further initialization
}
// Handle module preloading and final initialization steps
handleModulePreloadingAndInitialization(importedManga as KenmeiManga[]);
setIsInitialLoad(false);
// Cleanup function to ensure initialization state is reset
return () => {
matchingProcess.setIsInitializing(false);
};
}, [navigate, matchingProcess, pendingMangaState]);
const renderPageShell = (
content: React.ReactNode,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
headerPropsOverride: Record<string, any> = {},
) => {
const defaultHeaderProps = {
headerVariants,
matchResultsLength: 0,
isRematchOptionsVisible: false,
setIsRematchOptionsVisible: () => {},
isMatchingProcessLoading: false,
isRateLimited: false,
statusSummary: matchStatusSummary,
pendingBacklog: 0,
handleUndo: () => {},
handleRedo: () => {},
canUndo: false,
canRedo: false,
matchResults: [],
onRecalculateConfidence: () => {},
isConfidenceRecalcRunning: false,
};
const headerProps = { ...defaultHeaderProps, ...headerPropsOverride };
return (
<div className="relative flex h-full w-full flex-1">
<motion.div
className="container mx-auto flex h-full max-w-full flex-col px-4 py-6 md:px-6"
variants={pageVariants}
initial="hidden"
animate="visible"
>
<MatchingPageHeader {...headerProps} />
{content}
</motion.div>
</div>
);
};
// Add an effect to sync with the global process state while the page is mounted
useEffect(() => {
// Skip if we're not in the middle of a process
if (!globalThis.matchingProcessState?.isRunning) return;
let hasSyncedCompletion = false;
let syncInterval: ReturnType<typeof setInterval> | null = null;
const applyPauseState = (
state: NonNullable<typeof globalThis.matchingProcessState>,
controls: typeof matchingProcessRef.current,
) => {
const persistedManualPause = Boolean(state.isManuallyPaused);
if (controls.isManuallyPaused !== persistedManualPause) {
controls.setIsManuallyPaused(persistedManualPause);
}
if (persistedManualPause) {
controls.setManualMatchingPause(true);
controls.pauseTimeTracking();
} else if (!controls.isRateLimitPaused) {
controls.setManualMatchingPause(false);
controls.resumeTimeTracking();
}
const persistedPauseTransitioning = Boolean(state.isPauseTransitioning);
if (controls.isPauseTransitioning !== persistedPauseTransitioning) {
controls.setIsPauseTransitioning(persistedPauseTransitioning);
}
};
const syncStateUpdates = (
snapshot: {
current: number;
total: number;
currentTitle: string | null;
statusMessage: string | null;
detailMessage: string | null;
},
state: NonNullable<typeof globalThis.matchingProcessState>,
controls: typeof matchingProcessRef.current,
) => {
controls.setProgress((prev) => {
if (
prev.current === snapshot.current &&
prev.total === snapshot.total &&
prev.currentTitle === (snapshot.currentTitle || "")
) {
return prev;
}
return {
current: snapshot.current,
total: snapshot.total,
currentTitle: snapshot.currentTitle || "",
};
});
controls.setStatusMessage((prev) => {
const next = snapshot.statusMessage || "";
return prev === next ? prev : next;
});
controls.setDetailMessage((prev) => {
const next = snapshot.detailMessage ?? null;
return prev === next ? prev : next;
});
const timeEstimate = state.timeEstimate;
if (
timeEstimate &&
Number.isFinite(timeEstimate.startTime) &&
timeEstimate.startTime > 0
) {
controls.setTimeEstimate((prev) => {
if (
prev.startTime === timeEstimate.startTime &&
prev.averageTimePerManga === timeEstimate.averageTimePerManga &&
prev.estimatedRemainingSeconds ===
timeEstimate.estimatedRemainingSeconds
) {
return prev;
}
return {
startTime: timeEstimate.startTime,
averageTimePerManga: timeEstimate.averageTimePerManga,
estimatedRemainingSeconds: timeEstimate.estimatedRemainingSeconds,
};
});
}
applyPauseState(state, controls);
};
const syncRunningState = (
state: NonNullable<typeof globalThis.matchingProcessState>,
controls: typeof matchingProcessRef.current,
) => {
const snapshot = {
current: state.progress.current,
total: state.progress.total,
currentTitle: state.progress.currentTitle ?? null,
statusMessage: state.statusMessage ?? null,
detailMessage: state.detailMessage ?? null,
};
const hasChanged =
!lastGlobalSyncSnapshot.current ||
lastGlobalSyncSnapshot.current.current !== snapshot.current ||
lastGlobalSyncSnapshot.current.total !== snapshot.total ||
lastGlobalSyncSnapshot.current.currentTitle !== snapshot.currentTitle ||
lastGlobalSyncSnapshot.current.statusMessage !==
snapshot.statusMessage ||
lastGlobalSyncSnapshot.current.detailMessage !== snapshot.detailMessage;
if (hasChanged) {
console.debug("[MatchingPage] Syncing UI with global process state:", {
current: snapshot.current,
total: snapshot.total,
statusMessage: snapshot.statusMessage,
});
lastGlobalSyncSnapshot.current = snapshot;
}
controls.setIsLoading((prev) => prev || true);
syncStateUpdates(snapshot, state, controls);
};
const syncCompletedState = (
state: NonNullable<typeof globalThis.matchingProcessState>,
controls: typeof matchingProcessRef.current,
): boolean => {
if (!state.progress) {
return false;
}
const finalSnapshot = {
current: state.progress.current,
total: state.progress.total,
currentTitle: state.progress.currentTitle ?? null,
statusMessage: state.statusMessage ?? null,
detailMessage: state.detailMessage ?? null,
};
lastGlobalSyncSnapshot.current = finalSnapshot;
syncStateUpdates(finalSnapshot, state, controls);
console.info(
"[MatchingPage] Global process complete, syncing final state",
);
controls.setIsLoading((prev) => (prev ? false : prev));
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
}
return true;
};
// Create a function to sync the UI with the global state
const syncUIWithGlobalState = () => {
const processState = globalThis.matchingProcessState;
if (!processState) {
return;
}
const controls = matchingProcessRef.current;
if (processState.isRunning) {
syncRunningState(processState, controls);
} else if (!hasSyncedCompletion) {
hasSyncedCompletion = syncCompletedState(processState, controls);
}
};
// Create a visibility change listener to ensure UI updates when page becomes visible
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
console.debug(
"[MatchingPage] Page became visible, syncing state immediately",
);
syncUIWithGlobalState();
if (
globalThis.matchingProcessState?.isRunning &&
!matchingProcessRef.current.isManuallyPaused &&
!matchingProcessRef.current.isRateLimitPaused
) {
matchingProcessRef.current.resumeTimeTracking();
}
}
};
// Add visibility change listener
document.addEventListener("visibilitychange", handleVisibilityChange);
// Create an interval to check for updates to the global state (less frequently since we also have visibility events)
syncInterval = setInterval(() => {
if (document.visibilityState === "visible") {
syncUIWithGlobalState();
}
}, 2000); // Check every 2 seconds when visible
// Clean up the interval and event listener when the component unmounts
return () => {
if (syncInterval) {
clearInterval(syncInterval);
}
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []); // Only run once when component mounts
useEffect(() => {
// Only reload when the matching process transitions from loading to not loading
if (!matchingProcess.isLoading && !matchingProcess.isInitializing) {
// Use a small delay to ensure all state updates have been flushed
const timeoutId = setTimeout(() => {
const savedResults = getSavedMatchResults();
if (savedResults && savedResults.length > 0) {
// Only update if we have saved results and current results might be stale
if (
matchResults.length === 0 ||
matchResults.length !== savedResults.length
) {
console.info(
`[MatchingPage] Reloading match results from storage: ${savedResults.length} results found (current: ${matchResults.length})`,
);
setMatchResults(savedResults as MangaMatchResult[]);
}
}
}, 100);
return () => clearTimeout(timeoutId);
}
}, [matchingProcess.isLoading, matchingProcess.isInitializing]);
// Add an effect to listen for re-search empty matches events
useEffect(() => {
async function handleReSearchEmptyMatchesAsync(
mangaToResearch: KenmeiManga[],
) {
try {
// Small delay to ensure UI updates
await new Promise((resolve) => setTimeout(resolve, 100));
// Get the cache service to clear specific entries
await import("../api/matching/search-service");
// Clear cache entries for each manga being re-searched
const mangaTitles = mangaToResearch.map((manga) => manga.title);
console.info(
`[MatchingPage] 🔄 Clearing cache for ${mangaTitles.length} manga titles`,
);
matchingProcess.setStatusMessage(
`Clearing cache for ${mangaTitles.length} manga titles...`,
);
// Use the clearCacheForTitles function to clear entries efficiently
const clearResult = clearCacheForTitles(mangaTitles);
// Log results
console.info(
`[MatchingPage] 🧹 Cleared ${clearResult.clearedCount} cache entries for re-search`,
);
// Before starting fresh search, preserve existing results but reset re-searched manga to pending
if (matchResults.length > 0) {
// Create a set of titles being re-searched (for quick lookup)
const reSearchTitles = new Set(
mangaTitles.map((title) => title.toLowerCase()),
);
// Update the match results to set re-searched items back to pending
const updatedResults = matchResults.map((match) => {
// If this manga is being re-searched, reset its status to pending
if (reSearchTitles.has(match.kenmeiManga.title.toLowerCase())) {
return {
...match,
status: "pending" as const,
selectedMatch: undefined, // Clear any previously selected match
matchDate: new Date().toISOString(),
};
}
// Otherwise, keep it as is
return match;
});
// Update the results state
setMatchResults(updatedResults);
console.info(
`[MatchingPage] Reset status to pending for ${reSearchTitles.size} manga before re-searching`,
);
}
// Hide cache clearing notification
matchingProcess.setIsCacheClearing(false);
matchingProcess.setStatusMessage(
`Cleared cache entries - starting fresh searches...`,
);
// Start fresh search for the manga
matchingProcess.startMatching(mangaToResearch, true, setMatchResults);
} catch (error) {
console.error(
"[MatchingPage] Failed to clear manga cache entries:",
error,
);
matchingProcess.setIsCacheClearing(false);
// Continue with re-search even if cache clearing fails
matchingProcess.startMatching(mangaToResearch, true, setMatchResults);
}
}
// Handler for the reSearchEmptyMatches custom event
const handleReSearchEmptyMatches = (
event: CustomEvent<{ mangaToResearch: KenmeiManga[] }>,
) => {
const { mangaToResearch } = event.detail;
console.info(
`[MatchingPage] Received request to re-search ${mangaToResearch.length} manga without matches`,
);
// Reset any previous warnings or cancel state
setRematchWarning(null);
matchingProcess.cancelMatchingRef.current = false;
matchingProcess.setDetailMessage(null);
if (mangaToResearch.length === 0) {
console.info("[MatchingPage] No manga to re-search, ignoring request");
return;
}
// Show cache clearing notification with count
matchingProcess.setIsCacheClearing(true);
matchingProcess.setCacheClearingCount(mangaToResearch.length);
matchingProcess.setStatusMessage(
"Preparing to clear cache for manga without matches...",
);
handleReSearchEmptyMatchesAsync(mangaToResearch);
};
// Add event listener for the custom event
globalThis.addEventListener(
"reSearchEmptyMatches",
handleReSearchEmptyMatches as EventListener,
);
// Clean up the event listener when the component unmounts
return () => {
globalThis.removeEventListener(
"reSearchEmptyMatches",
handleReSearchEmptyMatches as EventListener,
);
};
}, [matchingProcess, setMatchResults]);
/**
* Retries the matching process for all unmatched manga.
* Clears pending manga and starts a fresh matching attempt.
* @source
*/
const handleRetry = () => {
// Clear selection state on rematch
handleClearSelection();
// Clear any pending manga data
pendingMangaState.savePendingManga([]);
if (manga.length > 0) {
matchingProcess.startMatching(manga, false, setMatchResults);
}
};
/**
* Returns the path to navigate to for synchronization.
* @returns The sync page route path.
* @source
*/
const getSyncPath = () => {
// When we have a sync route, return that instead
return "/sync";
};
/**
* Validates and proceeds to the synchronization page.
* Ensures at least one match has been approved before allowing sync.
* @source
*/
const handleProceedToSync = () => {
// Count how many matches we have
const matchedCount = matchResults.filter(
(m) => m.status === "matched" || m.status === "manual",
).length;
if (matchedCount === 0) {
matchingProcess.setError(
"No matches have been approved. Please review and accept matches before proceeding.",
);
return;
}
completeStep("matching");
navigate({ to: getSyncPath() });
};
/**
* Handles re-matching of manga selected by status filter.
* Filters results by selected status and initiates new matching process.
* @source
*/
const handleRematchByStatus = async () => {
// Clear selection state on rematch
handleClearSelection();
// Reset any previous warnings
setRematchWarning(null);
// Reset any previous cancel state
matchingProcess.cancelMatchingRef.current = false;
matchingProcess.setDetailMessage(null);
console.debug("[MatchingPage] === REMATCH DEBUG INFO ===");
console.debug(`[MatchingPage] Total manga in state: ${manga.length}`);
console.debug(`[MatchingPage] Total match results: ${matchResults.length}`);
console.debug(
`[MatchingPage] Displayed unmatched count: ${manga.length - matchResults.length}`,
);
// Get manga that have been processed but match selected statuses
const filteredManga = matchResults.filter(
(manga) =>
selectedStatuses[manga.status as keyof typeof selectedStatuses] ===
true,
);
console.debug(
`[MatchingPage] Filtered manga from results: ${filteredManga.length}`,
);
// Find unmatched manga that aren't in matchResults yet
let unmatchedManga: KenmeiManga[] = [];
if (selectedStatuses.unmatched) {
console.debug(
"[MatchingPage] Finding unmatched manga from pending manga list using title-based matching",
);
// Use pendingManga instead of the entire manga collection for fresh search
unmatchedManga = pendingMangaState.pendingManga;
console.debug(
`[MatchingPage] Using pending manga list: ${unmatchedManga.length} manga to process`,
);
}
// Combine the filtered manga with unmatched manga
const pendingMangaToProcess = [
...filteredManga.map((item) => item.kenmeiManga),
...unmatchedManga,
];
console.info(
`[MatchingPage] Total manga to process: ${pendingMangaToProcess.length}`,
);
console.info(
"[MatchingPage] IMPORTANT: Will clear cache entries for selected manga to ensure fresh searches",
);
console.debug("[MatchingPage] === END DEBUG INFO ===");
// Show more specific error message depending on what's selected
if (pendingMangaToProcess.length === 0) {
if (selectedStatuses.unmatched && unmatchedManga.length === 0) {
// If unmatched is selected but there are no unmatched manga
setRematchWarning(
"There are no unmatched manga to process. All manga have been processed previously.",
);
} else {
// Generic message for other cases
setRematchWarning(
"No manga found with the selected statuses. Please select different statuses.",
);
}
return;
}
console.info(
`[MatchingPage] Rematching ${filteredManga.length} status-filtered manga and ${unmatchedManga.length} unmatched manga`,
);
try {
// Show cache clearing notification with count
matchingProcess.setIsCacheClearing(true);
matchingProcess.setCacheClearingCount(pendingMangaToProcess.length);
matchingProcess.setStatusMessage(
"Preparing to clear cache for selected manga...",
);
// Small delay to ensure UI updates before potentially intensive operation
await new Promise((resolve) => setTimeout(resolve, 100));
// Get the cache service to clear specific entries
const { cacheDebugger } = await import("../api/matching/search-service");
// Get initial cache status for comparison
const initialCacheStatus = cacheDebugger.getCacheStatus();
console.debug(
`[MatchingPage] 📊 Initial cache status: ${initialCacheStatus.inMemoryCache} entries in memory, ${initialCacheStatus.localStorage.mangaCache} in localStorage`,
);
// Clear cache entries for each manga being rematched - use our new dedicated function
const mangaTitles = pendingMangaToProcess.map((manga) => manga.title);
console.info(
`[MatchingPage] 🔄 Clearing cache for ${mangaTitles.length} manga titles at once`,
);
matchingProcess.setStatusMessage(
`Clearing cache for ${mangaTitles.length} manga titles...`,
);
// Use our new function to clear cache entries efficiently
const clearResult = clearCacheForTitles(mangaTitles);
// Log the results
console.info(
`[MatchingPage] 🧹 Cleared ${clearResult.clearedCount} cache entries for selected manga`,
);
if (clearResult.clearedCount > 0 && mangaTitles.length > 0) {
console.debug(
"[MatchingPage] Cleared titles:",
mangaTitles.slice(0, 5).join(", ") +
(mangaTitles.length > 5
? ` and ${mangaTitles.length - 5} more...`
: ""),
);
}
// Log final cache status
console.debug(
`[MatchingPage] 📊 Final cache status: ${clearResult.remainingCacheSize} entries in memory (removed ${clearResult.clearedCount})`,
);
// Hide cache clearing notification
matchingProcess.setIsCacheClearing(false);
matchingProcess.setStatusMessage(
`Cleared ${clearResult.clearedCount} cache entries - preparing fresh searches from AniList...`,
);
// Reset the options panel and start matching
setIsRematchOptionsVisible(false);
// Before starting the matching process, save the existing matchResults
// Filter out the ones we're about to rematch to avoid duplicates
const mangaIdsToRematch = new Set(pendingMangaToProcess.map((m) => m.id));
const existingResults = matchResults.filter(
(m) => !mangaIdsToRematch.has(m.kenmeiManga.id),
);
console.info(
`[MatchingPage] Preserved ${existingResults.length} existing match results that aren't being rematched`,
);
// Clear undo/redo history before rematch
undoRedoManager.clear();
// Start fresh search
matchingProcess.startMatching(
pendingMangaToProcess,
true,
setMatchResults,
);
} catch (error) {
console.error(
"[MatchingPage] Failed to clear manga cache entries:",
error,
);
matchingProcess.setIsCacheClearing(false);
// Continue with rematch even if cache clearing fails
matchingProcess.startMatching(
pendingMangaToProcess,
true,
setMatchResults,
);
}
};
// Check for authentication error first - show before skeleton loading
if (matchingProcess.error?.includes("Authentication Required")) {
return renderPageShell(
<motion.div
className="relative flex flex-1 items-center justify-center"
variants={contentVariants}
>
<AuthRequiredCard
onGoToSettings={() => navigate({ to: "/settings" })}
/>
</motion.div>,
);
}
// Loading state
if (isInitialLoad && matchResults.length === 0) {
return renderPageShell(
<motion.div
className="relative grid flex-1 grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
variants={contentVariants}
>
{Array.from({ length: 6 }).map((_, index) => (
<motion.div
key={`skeleton-card-${index + 1}`}
variants={itemVariants}
>
<SkeletonCard />
</motion.div>
))}
</motion.div>,
);
}
if (matchingProcess.isLoading) {
return (
<LoadingView
pageVariants={pageVariants}
contentVariants={contentVariants}
matchingProcess={matchingProcess}
rateLimitState={rateLimitState}
navigate={navigate}
matchResultsLength={matchResults.length}
onRetry={handleRetry}
onDismissError={() => matchingProcess.setError?.(null)}
/>
);
}
return (
<div className="relative flex h-full w-full flex-1">
<MatchingErrorBoundary
onReset={handleMatchingReset}
onClearCache={handleClearMatchingCache}
>
<motion.div
className="container mx-auto flex h-full max-w-full flex-col px-4 py-6 md:px-6"
variants={pageVariants}
initial="hidden"
animate="visible"
>
<MatchingPageHeader
headerVariants={headerVariants}
matchResultsLength={matchResults.length}
isRematchOptionsVisible={isRematchOptionsVisible}
setIsRematchOptionsVisible={setIsRematchOptionsVisible}
isMatchingProcessLoading={matchingProcess.isLoading}
isRateLimited={rateLimitState.isRateLimited}
statusSummary={matchStatusSummary}
pendingBacklog={pendingMangaState.pendingManga.length}
handleUndo={handleUndo}
handleRedo={handleRedo}
canUndo={canUndo}
canRedo={canRedo}
matchResults={matchResults}
onImportComplete={handleImportComplete}
onRecalculateConfidence={handleRecalculateConfidenceClick}
isConfidenceRecalcRunning={confidenceStatus === "running"}
/>
{/* Rematch by status options */}
<AnimatePresence>
{isRematchOptionsVisible &&
!matchingProcess.isLoading &&
matchResults.length > 0 && (
<RematchOptions
selectedStatuses={selectedStatuses}
onChangeSelectedStatuses={setSelectedStatuses}
matchResults={matchResults}
rematchWarning={rematchWarning}
onRematchByStatus={handleRematchByStatus}
onCloseOptions={() => setIsRematchOptionsVisible(false)}
/>
)}
</AnimatePresence>
{/* Duplicate AniList ID Warning */}
<AnimatePresence>
{showDuplicateWarning && duplicateEntries.length > 0 && (
<DuplicateWarning
duplicates={duplicateEntries}
onDismiss={() => setShowDuplicateWarning(false)}
onSearchAnilist={(title) => setSearchQuery(title)}
onIgnoreDuplicate={handleIgnoreDuplicate}
/>
)}
</AnimatePresence>
{/* Initialization state - only show if not already loading and we have pending manga */}
{matchingProcess.isInitializing &&
!matchingProcess.isLoading &&
pendingMangaState.pendingManga.length > 0 && <InitializationCard />}
{/* Resume message when we have pending manga but aren't already in the loading state */}
{resumeState.needsProcessing &&
!matchingProcess.isLoading &&
!matchingProcess.isInitializing && (
<MatchingResume
pendingMangaCount={resumeState.unprocessedCount}
onResumeMatching={() => {
console.info(
"[MatchingPage] Resume matching clicked - ensuring unprocessed manga are processed",
);
if (resumeState.unprocessedManga.length > 0) {
pendingMangaState.savePendingManga(
resumeState.unprocessedManga,
);
setTimeout(() => {
matchingProcess.handleResumeMatching(
matchResults,
setMatchResults,
);
}, 100);
} else {
pendingMangaState.savePendingManga([]);
matchingProcess.setError(
"All manga have already been processed. No additional matching is needed.",
);
}
}}
onCancelResume={matchingProcess.handleCancelResume}
/>
)}
{/* Main content */}
<motion.div className="relative flex-1" variants={contentVariants}>
{matchResults.length > 0 ? (
<>
{/* Batch Selection Toolbar */}
{selectedMatchIds.size > 0 && (
<BatchSelectionToolbar
selectedCount={selectedMatchIds.size}
onAccept={handleBatchAccept}
onReject={handleBatchReject}
onReset={handleBatchReset}
onClearSelection={handleClearSelection}
progress={batchOperationProgress}
onCancel={handleCancelBatchOperation}
isBatchBusy={batchAbortController !== null}
/>
)}
<MatchingPanel
matches={matchResults}
onManualSearch={matchHandlers.handleManualSearch}
onAcceptMatch={matchHandlers.handleAcceptMatch}
onRejectMatch={matchHandlers.handleRejectMatch}
onSelectAlternative={matchHandlers.handleSelectAlternative}
onResetToPending={matchHandlers.handleResetToPending}
searchQuery={searchQuery}
onSetMatchedToPending={handleSetAllMatchedToPending}
isSetMatchedToPendingDisabled={
matchingProcess.isLoading || rateLimitState.isRateLimited
}
onProceedToSync={handleProceedToSync}
onBackToImport={() => {
pendingMangaState.savePendingManga([]);
navigate({ to: "/import" });
}}
selectedMatchIds={selectedMatchIds}
onToggleSelection={handleToggleSelection}
onSelectAll={handleSelectAll}
onClearSelection={handleClearSelection}
/>
</>
) : (
<AnimatePresence>
<EmptyState
icon={<FileSearch className="h-10 w-10" />}
title="No manga to match"
description="No manga data available. Import your Kenmei library to get started."
actionLabel="Go to Import"
onAction={() => {
pendingMangaState.savePendingManga([]);
navigate({ to: "/import" });
}}
variant="info"
/>
</AnimatePresence>
)}
</motion.div>
<ConfidenceRecalculationDialog
open={isConfidenceDialogOpen}
onOpenChange={handleConfidenceDialogChange}
progress={confidenceProgress}
status={confidenceStatus}
matchCount={matchResults.length}
metadata={confidenceMetadata}
lastDurationMs={confidenceMetadata?.durationMs}
lastCompletedAt={confidenceMetadata?.completedAt}
errorMessage={confidenceError}
canStart={
matchResults.length > 0 &&
!matchingProcess.isLoading &&
!rateLimitState.isRateLimited &&
confidenceStatus !== "running"
}
onStart={() => startConfidenceRecalculation()}
onCancel={cancelConfidenceRecalculation}
/>
{/* Search Modal */}
<SearchModal
isOpen={isSearchOpen}
searchTarget={searchTarget}
accessToken={authState.accessToken || ""}
bypassCache={true}
onClose={() => {
setIsSearchOpen(false);
setSearchTarget(undefined);
matchingProcess.setBypassCache(false);
}}
onSelectMatch={matchHandlers.handleSelectSearchMatch}
/>
{/* Batch Reject Confirmation Dialog */}
<AlertDialog
open={showBatchRejectDialog}
onOpenChange={setShowBatchRejectDialog}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reject Selected Matches?</AlertDialogTitle>
<AlertDialogDescription>
This will reject {selectedMatchIds.size} selected matches. You
can undo this action with Ctrl+Z.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmBatchReject}
className="bg-rose-600 hover:bg-rose-700 focus:ring-rose-600"
>
Confirm Reject
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Batch Reset Confirmation Dialog */}
<AlertDialog
open={showBatchResetDialog}
onOpenChange={setShowBatchResetDialog}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Reset Selected Matches to Pending?
</AlertDialogTitle>
<AlertDialogDescription>
This will reset {selectedMatchIds.size} matches to pending
status. You can undo this action with Ctrl+Z.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmBatchReset}
className="bg-orange-600 hover:bg-orange-700 focus:ring-orange-600"
>
Confirm Reset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Cache Clearing Notification */}
{matchingProcess.isCacheClearing && (
<CacheClearingNotification
cacheClearingCount={matchingProcess.cacheClearingCount}
/>
)}
{/* Error display when we have an error but also have results */}
<AnimatePresence>
{matchingProcess.error &&
!matchingProcess.error.includes("Authentication Required") &&
matchResults.length > 0 &&
!rateLimitState.isRateLimited && (
<MatchingErrorToast
error={matchingProcess.error}
onDismiss={() => matchingProcess.setError(null)}
/>
)}
</AnimatePresence>
</motion.div>
</MatchingErrorBoundary>
</div>
);
}
Matching page component for the Kenmei to AniList sync tool.
Handles manga matching, review, rematch, and sync preparation for the user.