From c392333170fc1d3e32db1cfe9f344455c5d7571b Mon Sep 17 00:00:00 2001 From: Rishi Ghan Date: Sat, 7 Mar 2026 21:52:26 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A0=20Missing=20files=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/components/Import/Import.tsx | 223 +---------------- .../components/Import/RealTimeImportStats.tsx | 227 +++++++++++------- src/client/graphql/generated.ts | 4 +- src/client/graphql/queries/import.graphql | 1 + 4 files changed, 159 insertions(+), 296 deletions(-) diff --git a/src/client/components/Import/Import.tsx b/src/client/components/Import/Import.tsx index be9954e..a68095f 100644 --- a/src/client/components/Import/Import.tsx +++ b/src/client/components/Import/Import.tsx @@ -1,28 +1,15 @@ -import React, { ReactElement, useCallback, useEffect, useState } from "react"; +import { ReactElement, useEffect, useState } from "react"; import { format } from "date-fns"; -import { isEmpty, isNil, isUndefined } from "lodash"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { isEmpty } from "lodash"; +import { useMutation } from "@tanstack/react-query"; import { useStore } from "../../store"; import { useShallow } from "zustand/react/shallow"; import axios from "axios"; -import { - useGetJobResultStatisticsQuery, - useGetImportStatisticsQuery, - useStartIncrementalImportMutation -} from "../../graphql/generated"; +import { useGetJobResultStatisticsQuery } from "../../graphql/generated"; import { RealTimeImportStats } from "./RealTimeImportStats"; import { useImportSessionStatus } from "../../hooks/useImportSessionStatus"; -interface ImportProps { - path: string; -} - -/** - * Import component for adding comics to the ThreeTwo library. - * Provides preview statistics, smart import, and queue management. - */ -export const Import = (props: ImportProps): ReactElement => { - const queryClient = useQueryClient(); +export const Import = (): ReactElement => { const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0); const [importError, setImportError] = useState(null); const { importJobQueue, getSocket, disconnectSocket } = useStore( @@ -33,30 +20,6 @@ export const Import = (props: ImportProps): ReactElement => { })), ); - const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({ - onSuccess: (data) => { - if (data.startIncrementalImport.success) { - importJobQueue.setStatus("running"); - setImportError(null); - } - }, - onError: (error: any) => { - console.error("Failed to start import:", error); - setImportError(error?.message || "Failed to start import. Please try again."); - }, - }); - - const { mutate: initiateImport } = useMutation({ - mutationFn: async () => { - const sessionId = localStorage.getItem("sessionId"); - return await axios.request({ - url: `http://localhost:3000/api/library/newImport`, - method: "POST", - data: { sessionId }, - }); - }, - }); - // Force re-import mutation - re-imports all files regardless of import status const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({ mutationFn: async () => { @@ -78,37 +41,15 @@ export const Import = (props: ImportProps): ReactElement => { }, }); - const { data, isError, isLoading, refetch } = useGetJobResultStatisticsQuery(); - - // Get import statistics to determine if Start Import button should be shown - const { data: importStats } = useGetImportStatisticsQuery( - {}, - { - refetchOnWindowFocus: false, - refetchInterval: false, - } - ); + const { data, isLoading, refetch } = useGetJobResultStatisticsQuery(); - // Use custom hook for definitive import session status tracking - // NO POLLING - relies on Socket.IO events only const importSession = useImportSessionStatus(); - const hasActiveSession = importSession.isActive; - // Determine if we should show the Start Import button - const hasNewFiles = importStats?.getImportStatistics?.success && - importStats.getImportStatistics.stats && - importStats.getImportStatistics.stats.newFiles > 0; - useEffect(() => { const socket = getSocket("/"); const handleQueueDrained = () => refetch(); const handleCoverExtracted = () => refetch(); - - const handleSessionStarted = () => { - importJobQueue.setStatus("running"); - }; - const handleSessionCompleted = () => { refetch(); importJobQueue.setStatus("drained"); @@ -116,64 +57,15 @@ export const Import = (props: ImportProps): ReactElement => { socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained); socket.on("LS_COVER_EXTRACTED", handleCoverExtracted); - socket.on("IMPORT_SESSION_STARTED", handleSessionStarted); socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted); return () => { socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained); socket.off("LS_COVER_EXTRACTED", handleCoverExtracted); - socket.off("IMPORT_SESSION_STARTED", handleSessionStarted); socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted); }; }, [getSocket, refetch, importJobQueue, socketReconnectTrigger]); - /** - * Toggles import queue pause/resume state - */ - const toggleQueue = (queueAction: string, queueStatus: string) => { - const socket = getSocket("/"); - socket.emit( - "call", - "socket.setQueueStatus", - { - queueAction, - queueStatus, - }, - ); - }; - - /** - * Starts smart import with race condition prevention - */ - const handleStartSmartImport = async () => { - // Clear any previous errors - setImportError(null); - - // Check for active session before starting using definitive status - if (hasActiveSession) { - setImportError( - `Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.` - ); - return; - } - - if (importJobQueue.status === "drained") { - localStorage.removeItem("sessionId"); - disconnectSocket("/"); - setTimeout(() => { - getSocket("/"); - setSocketReconnectTrigger(prev => prev + 1); - setTimeout(() => { - const sessionId = localStorage.getItem("sessionId") || ""; - startIncrementalImport({ sessionId }); - }, 500); - }, 100); - } else { - const sessionId = localStorage.getItem("sessionId") || ""; - startIncrementalImport({ sessionId }); - } - }; - /** * Handles force re-import - re-imports all files to fix indexing issues */ @@ -208,54 +100,6 @@ export const Import = (props: ImportProps): ReactElement => { } }; - /** - * Renders pause/resume controls based on queue status - */ - const renderQueueControls = (status: string): ReactElement | null => { - switch (status) { - case "running": - return ( -
- -
- ); - case "paused": - return ( -
- -
- ); - - case "drained": - return null; - - default: - return null; - } - }; - return (
@@ -327,53 +171,10 @@ export const Import = (props: ImportProps): ReactElement => {
)} - {/* Active Session Warning */} - {hasActiveSession && !hasNewFiles && ( -
-
- - - -
-

- Import In Progress -

-

- An import session is currently active. New imports cannot be started until it completes. -

-
-
-
- )} - - {/* Import Action Buttons */} -
- {/* Start Smart Import Button - shown when there are new files, no active session, and no import is running */} - {hasNewFiles && - !hasActiveSession && - (importJobQueue.status === "drained" || importJobQueue.status === undefined) && ( - - )} - - {/* Force Re-Import Button - always shown when no import is running */} - {!hasActiveSession && - (importJobQueue.status === "drained" || importJobQueue.status === undefined) && ( + {/* Force Re-Import Button - always shown when no import is running */} + {!hasActiveSession && + (importJobQueue.status === "drained" || importJobQueue.status === undefined) && ( +
- )} -
+
+ )} {/* Import activity is now shown in the RealTimeImportStats component above */} diff --git a/src/client/components/Import/RealTimeImportStats.tsx b/src/client/components/Import/RealTimeImportStats.tsx index 2310220..be2be24 100644 --- a/src/client/components/Import/RealTimeImportStats.tsx +++ b/src/client/components/Import/RealTimeImportStats.tsx @@ -1,6 +1,8 @@ -import React, { ReactElement, useEffect, useState } from "react"; +import { ReactElement, useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useGetImportStatisticsQuery, + useGetWantedComicsQuery, useStartIncrementalImportMutation } from "../../graphql/generated"; import { useStore } from "../../store"; @@ -8,11 +10,15 @@ import { useShallow } from "zustand/react/shallow"; import { useImportSessionStatus } from "../../hooks/useImportSessionStatus"; /** - * Import statistics with card-based layout and progress bar - * Updates in real-time via the useImportSessionStatus hook + * Import statistics with card-based layout and progress bar. + * Three states: pre-import (idle), importing (active), and post-import (complete). + * Also surfaces missing files detected by the file watcher. */ export const RealTimeImportStats = (): ReactElement => { const [importError, setImportError] = useState(null); + const [detectedFile, setDetectedFile] = useState(null); + const queryClient = useQueryClient(); + const { getSocket, disconnectSocket, importJobQueue } = useStore( useShallow((state) => ({ getSocket: state.getSocket, @@ -21,13 +27,28 @@ export const RealTimeImportStats = (): ReactElement => { })) ); - // Get filesystem statistics (new files vs already imported) - const { data: importStats, isLoading, refetch: refetchStats } = useGetImportStatisticsQuery( + const { data: importStats, isLoading } = useGetImportStatisticsQuery( {}, { refetchOnWindowFocus: false, refetchInterval: false } ); - // Get definitive import session status (handles Socket.IO events internally) + const stats = importStats?.getImportStatistics?.stats; + + // File list for the detail panel — only fetched when there are missing files + const { data: missingComicsData } = useGetWantedComicsQuery( + { + paginationOptions: { limit: 5, page: 1 }, + predicate: { "importStatus.isRawFileMissing": true }, + }, + { + refetchOnWindowFocus: false, + refetchInterval: false, + enabled: (stats?.missingFiles ?? 0) > 0, + } + ); + + const missingDocs = missingComicsData?.getComicBooks?.docs ?? []; + const importSession = useImportSessionStatus(); const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({ @@ -38,53 +59,52 @@ export const RealTimeImportStats = (): ReactElement => { } }, onError: (error: any) => { - console.error("Failed to start import:", error); setImportError(error?.message || "Failed to start import. Please try again."); }, }); - const stats = importStats?.getImportStatistics?.stats; const hasNewFiles = stats && stats.newFiles > 0; + const missingCount = stats?.missingFiles ?? 0; - // Refetch filesystem stats when import completes + // Mark queue drained when session completes — LS_LIBRARY_STATISTICS handles the refetch useEffect(() => { if (importSession.isComplete && importSession.status === "completed") { - console.log("[RealTimeImportStats] Import completed, refetching filesystem stats"); - refetchStats(); importJobQueue.setStatus("drained"); } - }, [importSession.isComplete, importSession.status, refetchStats, importJobQueue]); + }, [importSession.isComplete, importSession.status, importJobQueue]); - // Listen to filesystem change events to refetch stats + // LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion. + // Invalidating GetImportStatistics covers: total files, imported, new files, and missing count. + // Invalidating GetWantedComics refreshes the missing file name list in the detail panel. useEffect(() => { const socket = getSocket("/"); - const handleFilesystemChange = () => { - refetchStats(); + const handleStatsChange = () => { + queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] }); + queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] }); }; - // File system changes that affect import statistics - socket.on("LS_FILE_ADDED", handleFilesystemChange); - socket.on("LS_FILE_REMOVED", handleFilesystemChange); - socket.on("LS_FILE_CHANGED", handleFilesystemChange); - socket.on("LS_DIRECTORY_ADDED", handleFilesystemChange); - socket.on("LS_DIRECTORY_REMOVED", handleFilesystemChange); - socket.on("LS_LIBRARY_STATISTICS", handleFilesystemChange); + const handleFileDetected = (payload: { filePath: string }) => { + handleStatsChange(); + const name = payload.filePath.split("/").pop() ?? payload.filePath; + setDetectedFile(name); + setTimeout(() => setDetectedFile(null), 5000); + }; + + socket.on("LS_LIBRARY_STATS", handleStatsChange); + socket.on("LS_FILES_MISSING", handleStatsChange); + socket.on("LS_FILE_DETECTED", handleFileDetected); return () => { - socket.off("LS_FILE_ADDED", handleFilesystemChange); - socket.off("LS_FILE_REMOVED", handleFilesystemChange); - socket.off("LS_FILE_CHANGED", handleFilesystemChange); - socket.off("LS_DIRECTORY_ADDED", handleFilesystemChange); - socket.off("LS_DIRECTORY_REMOVED", handleFilesystemChange); - socket.off("LS_LIBRARY_STATISTICS", handleFilesystemChange); + socket.off("LS_LIBRARY_STATS", handleStatsChange); + socket.off("LS_FILES_MISSING", handleStatsChange); + socket.off("LS_FILE_DETECTED", handleFileDetected); }; - }, [getSocket, refetchStats]); + }, [getSocket, queryClient]); const handleStartImport = async () => { setImportError(null); - // Check if import is already active using definitive status if (importSession.isActive) { setImportError( `Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.` @@ -112,24 +132,22 @@ export const RealTimeImportStats = (): ReactElement => { return
Loading...
; } - // Determine button text based on whether there are already imported files const isFirstImport = stats.alreadyImported === 0; const buttonText = isFirstImport ? `Start Import (${stats.newFiles} files)` : `Start Incremental Import (${stats.newFiles} new files)`; - // Calculate display statistics - const displayStats = importSession.isActive && importSession.stats - ? { - totalFiles: importSession.stats.filesQueued + stats.alreadyImported, - filesQueued: importSession.stats.filesQueued, - filesSucceeded: importSession.stats.filesSucceeded, - } - : { - totalFiles: stats.totalLocalFiles, - filesQueued: stats.newFiles, - filesSucceeded: stats.alreadyImported, - }; + // Determine what to show in each card based on current phase + const sessionStats = importSession.stats; + const hasSessionStats = (importSession.isActive || importSession.isComplete) && sessionStats !== null; + + const totalFiles = stats.totalLocalFiles; + const importedCount = hasSessionStats ? sessionStats!.filesSucceeded : stats.alreadyImported; + const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0; + + const showProgressBar = importSession.isActive || importSession.isComplete; + const showFailedCard = hasSessionStats && failedCount > 0; + const showMissingCard = missingCount > 0; return (
@@ -148,37 +166,45 @@ export const RealTimeImportStats = (): ReactElement => { onClick={() => setImportError(null)} className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200" > - - - +
)} - {/* Import Button - only show when there are new files and no active import */} + {/* File detected toast */} + {detectedFile && ( +
+ +

+ New file detected: {detectedFile} +

+
+ )} + + {/* Start Import button — only when idle with new files */} {hasNewFiles && !importSession.isActive && ( )} - {/* Active Import Progress Bar */} - {importSession.isActive && ( -
-
- - Importing {importSession.stats?.filesSucceeded || 0} / {importSession.stats?.filesQueued || 0}... + {/* Progress bar — shown while importing and once complete */} + {showProgressBar && ( +
+
+ + {importSession.isActive + ? `Importing ${sessionStats?.filesSucceeded ?? 0} / ${sessionStats?.filesQueued ?? 0}` + : `${sessionStats?.filesSucceeded ?? 0} / ${sessionStats?.filesQueued ?? 0} imported`} - - {Math.round(importSession.progress)}% + + {Math.round(importSession.progress)}% complete
@@ -186,44 +212,77 @@ export const RealTimeImportStats = (): ReactElement => { className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative" style={{ width: `${importSession.progress}%` }} > -
+ {importSession.isActive && ( +
+ )}
)} - {/* Stats Cards */} -
- {/* Files Detected Card */} + {/* Stats cards */} +
+ {/* Total files */}
-
- {displayStats.totalFiles} -
-
- files detected -
+
{totalFiles}
+
total files
- {/* To Import Card */} -
-
- {displayStats.filesQueued} -
-
- to import -
-
- - {/* Already Imported Card */} + {/* Imported */}
-
- {displayStats.filesSucceeded} -
+
{importedCount}
- already imported + {importSession.isActive ? "imported so far" : "imported"}
+ + {/* Failed — only shown after a session with failures */} + {showFailedCard && ( +
+
{failedCount}
+
failed
+
+ )} + + {/* Missing files — shown when watcher detects moved/deleted files */} + {showMissingCard && ( +
+
{missingCount}
+
missing
+
+ )}
+ + {/* Missing files detail panel */} + {showMissingCard && ( +
+
+ +
+

+ {missingCount} {missingCount === 1 ? "file" : "files"} missing +

+

+ These files were previously imported but can no longer be found on disk. Move them back to restore access. +

+ {missingDocs.length > 0 && ( +
    + {missingDocs.slice(0, 5).map((comic, i) => ( +
  • + {comic.rawFileDetails?.name ?? comic.id} +
  • + ))} + {missingDocs.length > 5 && ( +
  • + +{missingDocs.length - 5} more +
  • + )} +
+ )} +
+
+
+ )}
); }; diff --git a/src/client/graphql/generated.ts b/src/client/graphql/generated.ts index 07953f0..8d9bc13 100644 --- a/src/client/graphql/generated.ts +++ b/src/client/graphql/generated.ts @@ -303,6 +303,7 @@ export type ImportStatistics = { export type ImportStats = { __typename?: 'ImportStats'; alreadyImported: Scalars['Int']['output']; + missingFiles: Scalars['Int']['output']; newFiles: Scalars['Int']['output']; percentageImported: Scalars['String']['output']; totalLocalFiles: Scalars['Int']['output']; @@ -1115,7 +1116,7 @@ export type GetImportStatisticsQueryVariables = Exact<{ }>; -export type GetImportStatisticsQuery = { __typename?: 'Query', getImportStatistics: { __typename?: 'ImportStatistics', success: boolean, directory: string, stats: { __typename?: 'ImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, percentageImported: string } } }; +export type GetImportStatisticsQuery = { __typename?: 'Query', getImportStatistics: { __typename?: 'ImportStatistics', success: boolean, directory: string, stats: { __typename?: 'ImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, missingFiles: number, percentageImported: string } } }; export type StartNewImportMutationVariables = Exact<{ sessionId: Scalars['String']['input']; @@ -1874,6 +1875,7 @@ export const GetImportStatisticsDocument = ` totalLocalFiles alreadyImported newFiles + missingFiles percentageImported } } diff --git a/src/client/graphql/queries/import.graphql b/src/client/graphql/queries/import.graphql index ed82dd9..1823e80 100644 --- a/src/client/graphql/queries/import.graphql +++ b/src/client/graphql/queries/import.graphql @@ -6,6 +6,7 @@ query GetImportStatistics($directoryPath: String) { totalLocalFiles alreadyImported newFiles + missingFiles percentageImported } }