diff --git a/src/client/components/Import/Import.tsx b/src/client/components/Import/Import.tsx index c522bc3..b5c48a7 100644 --- a/src/client/components/Import/Import.tsx +++ b/src/client/components/Import/Import.tsx @@ -1,11 +1,15 @@ import React, { ReactElement, useCallback, useEffect, useState } from "react"; import { format } from "date-fns"; -import Loader from "react-loader-spinner"; import { isEmpty, isNil, isUndefined } from "lodash"; import { useQuery, useMutation, useQueryClient } 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"; interface IProps { matches?: unknown; @@ -27,6 +31,7 @@ interface IProps { export const Import = (props: IProps): ReactElement => { const queryClient = useQueryClient(); const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0); + const [showPreview, setShowPreview] = useState(false); const { importJobQueue, getSocket, disconnectSocket } = useStore( useShallow((state) => ({ importJobQueue: state.importJobQueue, @@ -35,6 +40,29 @@ export const Import = (props: IProps): ReactElement => { })), ); + // Query to get import statistics (preview) + const { + data: importStats, + isLoading: isLoadingStats, + refetch: refetchStats + } = useGetImportStatisticsQuery( + {}, + { + enabled: showPreview, + refetchOnWindowFocus: false, + } + ); + + // Mutation for incremental import (smart import) + const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({ + onSuccess: (data) => { + if (data.startIncrementalImport.success) { + importJobQueue.setStatus("running"); + setShowPreview(false); + } + }, + }); + const { mutate: initiateImport } = useMutation({ mutationFn: async () => { const sessionId = localStorage.getItem("sessionId"); @@ -46,20 +74,7 @@ export const Import = (props: IProps): ReactElement => { }, }); - const { data, isError, isLoading, refetch } = useQuery({ - queryKey: ["allImportJobResults"], - queryFn: async () => { - const response = await axios({ - method: "GET", - url: "http://localhost:3000/api/jobqueue/getJobResultStatistics", - params: { _t: Date.now() }, // Cache busting - }); - return response; - }, - refetchOnWindowFocus: false, - staleTime: 0, // Always consider data stale - gcTime: 0, // Don't cache the data (formerly cacheTime) - }); + const { data, isError, isLoading, refetch } = useGetJobResultStatisticsQuery(); // Ensure socket connection is established and listen for import completion useEffect(() => { @@ -95,6 +110,35 @@ export const Import = (props: IProps): ReactElement => { }, ); }; + + const handleShowPreview = () => { + setShowPreview(true); + refetchStats(); + }; + + const handleStartSmartImport = () => { + // Clear old sessionId when starting a new import after queue is drained + if (importJobQueue.status === "drained") { + localStorage.removeItem("sessionId"); + // Disconnect and reconnect socket to get new sessionId + disconnectSocket("/"); + // Wait for socket to reconnect and get new sessionId before starting import + setTimeout(() => { + getSocket("/"); + // Trigger useEffect to re-attach event listeners + setSocketReconnectTrigger(prev => prev + 1); + // Wait a bit more for sessionInitialized event to fire + setTimeout(() => { + const sessionId = localStorage.getItem("sessionId") || ""; + startIncrementalImport({ sessionId }); + }, 500); + }, 100); + } else { + const sessionId = localStorage.getItem("sessionId") || ""; + startIncrementalImport({ sessionId }); + } + }; + /** * Method to render import job queue pause/resume controls on the UI * @@ -146,6 +190,7 @@ export const Import = (props: IProps): ReactElement => { return null; } }; + return (
@@ -185,41 +230,147 @@ export const Import = (props: IProps): ReactElement => {
-
- {(importJobQueue.status === "drained" || - importJobQueue.status === undefined) && ( + {/* Import Preview Section */} + {!showPreview && (importJobQueue.status === "drained" || importJobQueue.status === undefined) && ( +
- )} -
+
+ )} + + {/* Preview Statistics */} + {showPreview && !isLoadingStats && importStats?.getImportStatistics && ( +
+ + + Import Preview + + + + +
+
+

+ Directory: {importStats.getImportStatistics.directory} +

+
+ +
+
+
+ {importStats.getImportStatistics.stats.totalLocalFiles} +
+
+ Total Files +
+
+ +
+
+ {importStats.getImportStatistics.stats.newFiles} +
+
+ New Comics +
+
+ +
+
+ {importStats.getImportStatistics.stats.alreadyImported} +
+
+ Already Imported +
+
+ +
+
+ {(() => { + const percentage = importStats.getImportStatistics.stats.percentageImported; + const numValue = typeof percentage === 'number' ? percentage : parseFloat(percentage); + return !isNaN(numValue) ? numValue.toFixed(1) : '0.0'; + })()}% +
+
+ Already in Library +
+
+
+ + {importStats.getImportStatistics.stats.newFiles > 0 && ( +
+
+

+ Ready to import {importStats.getImportStatistics.stats.newFiles} new comic{importStats.getImportStatistics.stats.newFiles !== 1 ? 's' : ''}! +

+

+ {importStats.getImportStatistics.stats.alreadyImported} comic{importStats.getImportStatistics.stats.alreadyImported !== 1 ? 's' : ''} will be skipped (already in library). +

+
+
+ )} + + {importStats.getImportStatistics.stats.newFiles === 0 && ( +
+
+

+ No new comics to import! +

+

+ All {importStats.getImportStatistics.stats.totalLocalFiles} comic{importStats.getImportStatistics.stats.totalLocalFiles !== 1 ? 's' : ''} in the directory {importStats.getImportStatistics.stats.totalLocalFiles !== 1 ? 'are' : 'is'} already in your library. +

+
+
+ )} + +
+ {importStats.getImportStatistics.stats.newFiles > 0 && ( + + )} + +
+
+
+ )} + + {/* Loading state for preview */} + {showPreview && isLoadingStats && ( +
+
+ + Analyzing comics folder... + +
+ )} {/* Activity */} {(importJobQueue.status === "running" || @@ -266,7 +417,7 @@ export const Import = (props: IProps): ReactElement => { )} {/* Past imports */} - {!isLoading && !isEmpty(data?.data) && ( + {!isLoading && !isEmpty(data?.getJobResultStatistics) && (
@@ -298,17 +449,19 @@ export const Import = (props: IProps): ReactElement => { - {data?.data.map((jobResult: any, index: number) => { + {data?.getJobResultStatistics.map((jobResult: any, index: number) => { return ( {index + 1} - {format( - new Date(jobResult.earliestTimestamp), - "EEEE, hh:mma, do LLLL y", - )} + {jobResult.earliestTimestamp && !isNaN(new Date(jobResult.earliestTimestamp).getTime()) + ? format( + new Date(jobResult.earliestTimestamp), + "EEEE, hh:mma, do LLLL y", + ) + : "N/A"} diff --git a/src/client/graphql/generated.ts b/src/client/graphql/generated.ts index 6f7cd59..76d49f0 100644 --- a/src/client/graphql/generated.ts +++ b/src/client/graphql/generated.ts @@ -1,4 +1,4 @@ -import { useQuery, useInfiniteQuery, UseQueryOptions, UseInfiniteQueryOptions, InfiniteData } from '@tanstack/react-query'; +import { useQuery, useInfiniteQuery, useMutation, UseQueryOptions, UseInfiniteQueryOptions, InfiniteData, UseMutationOptions } from '@tanstack/react-query'; import { fetcher } from './fetcher'; export type Maybe = T | null; export type InputMaybe = Maybe; @@ -260,6 +260,28 @@ export type ImportComicResult = { success: Scalars['Boolean']['output']; }; +export type ImportJobResult = { + __typename?: 'ImportJobResult'; + jobsQueued: Scalars['Int']['output']; + message: Scalars['String']['output']; + success: Scalars['Boolean']['output']; +}; + +export type ImportStatistics = { + __typename?: 'ImportStatistics'; + directory: Scalars['String']['output']; + stats: ImportStats; + success: Scalars['Boolean']['output']; +}; + +export type ImportStats = { + __typename?: 'ImportStats'; + alreadyImported: Scalars['Int']['output']; + newFiles: Scalars['Int']['output']; + percentageImported: Scalars['String']['output']; + totalLocalFiles: Scalars['Int']['output']; +}; + export type ImportStatus = { __typename?: 'ImportStatus'; isImported?: Maybe; @@ -267,6 +289,21 @@ export type ImportStatus = { tagged?: Maybe; }; +export type IncrementalImportResult = { + __typename?: 'IncrementalImportResult'; + message: Scalars['String']['output']; + stats: IncrementalImportStats; + success: Scalars['Boolean']['output']; +}; + +export type IncrementalImportStats = { + __typename?: 'IncrementalImportStats'; + alreadyImported: Scalars['Int']['output']; + newFiles: Scalars['Int']['output']; + queued: Scalars['Int']['output']; + total: Scalars['Int']['output']; +}; + export type InferredMetadata = { __typename?: 'InferredMetadata'; issue?: Maybe; @@ -278,9 +315,23 @@ export type InferredMetadataInput = { export type Issue = { __typename?: 'Issue'; + api_detail_url?: Maybe; + character_credits?: Maybe>; + cover_date?: Maybe; + description?: Maybe; + id: Scalars['Int']['output']; + image?: Maybe; + issue_number?: Maybe; + location_credits?: Maybe>; name?: Maybe; number?: Maybe; + person_credits?: Maybe>; + site_detail_url?: Maybe; + store_date?: Maybe; + story_arc_credits?: Maybe>; subtitle?: Maybe; + team_credits?: Maybe>; + volume?: Maybe; year?: Maybe; }; @@ -308,6 +359,14 @@ export type IssuesForSeriesResponse = { status_code: Scalars['Int']['output']; }; +export type JobResultStatistics = { + __typename?: 'JobResultStatistics'; + completedJobs: Scalars['Int']['output']; + earliestTimestamp: Scalars['String']['output']; + failedJobs: Scalars['Int']['output']; + sessionId: Scalars['String']['output']; +}; + export type LocgMetadata = { __typename?: 'LOCGMetadata'; cover?: Maybe; @@ -375,6 +434,36 @@ export type MetadataField = { value?: Maybe; }; +export type MetadataPaginationMeta = { + __typename?: 'MetadataPaginationMeta'; + currentPage: Scalars['Int']['output']; + hasNextPage: Scalars['Boolean']['output']; + hasPreviousPage: Scalars['Boolean']['output']; + pageSize: Scalars['Int']['output']; + totalCount: Scalars['Int']['output']; + totalPages: Scalars['Int']['output']; +}; + +export type MetadataPullListItem = { + __typename?: 'MetadataPullListItem'; + cover?: Maybe; + description?: Maybe; + name?: Maybe; + potw?: Maybe; + price?: Maybe; + publicationDate?: Maybe; + publisher?: Maybe; + pulls?: Maybe; + rating?: Maybe; + url?: Maybe; +}; + +export type MetadataPullListResponse = { + __typename?: 'MetadataPullListResponse'; + meta: MetadataPaginationMeta; + result: Array; +}; + export enum MetadataSource { ComicinfoXml = 'COMICINFO_XML', Comicvine = 'COMICVINE', @@ -406,6 +495,8 @@ export type Mutation = { removeMetadataOverride: Comic; resolveMetadata: Comic; setMetadataField: Comic; + startIncrementalImport: IncrementalImportResult; + startNewImport: ImportJobResult; updateSourcedMetadata: Comic; updateUserPreferences: UserPreferences; }; @@ -445,6 +536,17 @@ export type MutationSetMetadataFieldArgs = { }; +export type MutationStartIncrementalImportArgs = { + directoryPath?: InputMaybe; + sessionId: Scalars['String']['input']; +}; + + +export type MutationStartNewImportArgs = { + sessionId: Scalars['String']['input']; +}; + + export type MutationUpdateSourcedMetadataArgs = { comicId: Scalars['ID']['input']; metadata: Scalars['String']['input']; @@ -465,16 +567,6 @@ export type PageInfo = { totalPages: Scalars['Int']['output']; }; -export type PaginationMeta = { - __typename?: 'PaginationMeta'; - currentPage: Scalars['Int']['output']; - hasNextPage: Scalars['Boolean']['output']; - hasPreviousPage: Scalars['Boolean']['output']; - pageSize: Scalars['Int']['output']; - totalCount: Scalars['Int']['output']; - totalPages: Scalars['Int']['output']; -}; - export type PaginationOptionsInput = { lean?: InputMaybe; leanWithId?: InputMaybe; @@ -516,26 +608,6 @@ export type PublisherStats = { id: Scalars['String']['output']; }; -export type PullListItem = { - __typename?: 'PullListItem'; - cover?: Maybe; - description?: Maybe; - name?: Maybe; - potw?: Maybe; - price?: Maybe; - publicationDate?: Maybe; - publisher?: Maybe; - pulls?: Maybe; - rating?: Maybe; - url?: Maybe; -}; - -export type PullListResponse = { - __typename?: 'PullListResponse'; - meta: PaginationMeta; - result: Array; -}; - export type Query = { __typename?: 'Query'; analyzeMetadataConflicts: Array; @@ -547,15 +619,17 @@ export type Query = { getComicBooks: ComicBooksResult; /** Get generic ComicVine resource (issues, volumes, etc.) */ getComicVineResource: ComicVineResourceResponse; + getImportStatistics: ImportStatistics; /** Get all issues for a series by comic object ID */ getIssuesForSeries: IssuesForSeriesResponse; + getJobResultStatistics: Array; getLibraryStatistics: LibraryStatistics; /** Get story arcs for a volume */ getStoryArcs: Array; /** Get volume details by URI */ getVolume: VolumeDetailResponse; /** Get weekly pull list from League of Comic Geeks */ - getWeeklyPullList: PullListResponse; + getWeeklyPullList: MetadataPullListResponse; previewCanonicalMetadata?: Maybe; /** Search ComicVine for volumes, issues, characters, etc. */ searchComicVine: ComicVineSearchResult; @@ -601,6 +675,11 @@ export type QueryGetComicVineResourceArgs = { }; +export type QueryGetImportStatisticsArgs = { + directoryPath?: InputMaybe; +}; + + export type QueryGetIssuesForSeriesArgs = { comicObjectId: Scalars['ID']['input']; }; @@ -996,7 +1075,34 @@ export type GetWeeklyPullListQueryVariables = Exact<{ }>; -export type GetWeeklyPullListQuery = { __typename?: 'Query', getWeeklyPullList: { __typename?: 'PullListResponse', result: Array<{ __typename?: 'PullListItem', name?: string | null, publisher?: string | null, cover?: string | null }> } }; +export type GetWeeklyPullListQuery = { __typename?: 'Query', getWeeklyPullList: { __typename?: 'MetadataPullListResponse', result: Array<{ __typename?: 'MetadataPullListItem', name?: string | null, publisher?: string | null, cover?: string | null }> } }; + +export type GetImportStatisticsQueryVariables = Exact<{ + directoryPath?: InputMaybe; +}>; + + +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 StartNewImportMutationVariables = Exact<{ + sessionId: Scalars['String']['input']; +}>; + + +export type StartNewImportMutation = { __typename?: 'Mutation', startNewImport: { __typename?: 'ImportJobResult', success: boolean, message: string, jobsQueued: number } }; + +export type StartIncrementalImportMutationVariables = Exact<{ + sessionId: Scalars['String']['input']; + directoryPath?: InputMaybe; +}>; + + +export type StartIncrementalImportMutation = { __typename?: 'Mutation', startIncrementalImport: { __typename?: 'IncrementalImportResult', success: boolean, message: string, stats: { __typename?: 'IncrementalImportStats', total: number, alreadyImported: number, newFiles: number, queued: number } } }; + +export type GetJobResultStatisticsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetJobResultStatisticsQuery = { __typename?: 'Query', getJobResultStatistics: Array<{ __typename?: 'JobResultStatistics', sessionId: string, earliestTimestamp: string, completedJobs: number, failedJobs: number }> }; export type GetLibraryComicsQueryVariables = Exact<{ page?: InputMaybe; @@ -1721,6 +1827,173 @@ useInfiniteGetWeeklyPullListQuery.getKey = (variables: GetWeeklyPullListQueryVar useGetWeeklyPullListQuery.fetcher = (variables: GetWeeklyPullListQueryVariables, options?: RequestInit['headers']) => fetcher(GetWeeklyPullListDocument, variables, options); +export const GetImportStatisticsDocument = ` + query GetImportStatistics($directoryPath: String) { + getImportStatistics(directoryPath: $directoryPath) { + success + directory + stats { + totalLocalFiles + alreadyImported + newFiles + percentageImported + } + } +} + `; + +export const useGetImportStatisticsQuery = < + TData = GetImportStatisticsQuery, + TError = unknown + >( + variables?: GetImportStatisticsQueryVariables, + options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } + ) => { + + return useQuery( + { + queryKey: variables === undefined ? ['GetImportStatistics'] : ['GetImportStatistics', variables], + queryFn: fetcher(GetImportStatisticsDocument, variables), + ...options + } + )}; + +useGetImportStatisticsQuery.getKey = (variables?: GetImportStatisticsQueryVariables) => variables === undefined ? ['GetImportStatistics'] : ['GetImportStatistics', variables]; + +export const useInfiniteGetImportStatisticsQuery = < + TData = InfiniteData, + TError = unknown + >( + variables: GetImportStatisticsQueryVariables, + options: Omit, 'queryKey'> & { queryKey?: UseInfiniteQueryOptions['queryKey'] } + ) => { + + return useInfiniteQuery( + (() => { + const { queryKey: optionsQueryKey, ...restOptions } = options; + return { + queryKey: optionsQueryKey ?? variables === undefined ? ['GetImportStatistics.infinite'] : ['GetImportStatistics.infinite', variables], + queryFn: (metaData) => fetcher(GetImportStatisticsDocument, {...variables, ...(metaData.pageParam ?? {})})(), + ...restOptions + } + })() + )}; + +useInfiniteGetImportStatisticsQuery.getKey = (variables?: GetImportStatisticsQueryVariables) => variables === undefined ? ['GetImportStatistics.infinite'] : ['GetImportStatistics.infinite', variables]; + + +useGetImportStatisticsQuery.fetcher = (variables?: GetImportStatisticsQueryVariables, options?: RequestInit['headers']) => fetcher(GetImportStatisticsDocument, variables, options); + +export const StartNewImportDocument = ` + mutation StartNewImport($sessionId: String!) { + startNewImport(sessionId: $sessionId) { + success + message + jobsQueued + } +} + `; + +export const useStartNewImportMutation = < + TError = unknown, + TContext = unknown + >(options?: UseMutationOptions) => { + + return useMutation( + { + mutationKey: ['StartNewImport'], + mutationFn: (variables?: StartNewImportMutationVariables) => fetcher(StartNewImportDocument, variables)(), + ...options + } + )}; + + +useStartNewImportMutation.fetcher = (variables: StartNewImportMutationVariables, options?: RequestInit['headers']) => fetcher(StartNewImportDocument, variables, options); + +export const StartIncrementalImportDocument = ` + mutation StartIncrementalImport($sessionId: String!, $directoryPath: String) { + startIncrementalImport(sessionId: $sessionId, directoryPath: $directoryPath) { + success + message + stats { + total + alreadyImported + newFiles + queued + } + } +} + `; + +export const useStartIncrementalImportMutation = < + TError = unknown, + TContext = unknown + >(options?: UseMutationOptions) => { + + return useMutation( + { + mutationKey: ['StartIncrementalImport'], + mutationFn: (variables?: StartIncrementalImportMutationVariables) => fetcher(StartIncrementalImportDocument, variables)(), + ...options + } + )}; + + +useStartIncrementalImportMutation.fetcher = (variables: StartIncrementalImportMutationVariables, options?: RequestInit['headers']) => fetcher(StartIncrementalImportDocument, variables, options); + +export const GetJobResultStatisticsDocument = ` + query GetJobResultStatistics { + getJobResultStatistics { + sessionId + earliestTimestamp + completedJobs + failedJobs + } +} + `; + +export const useGetJobResultStatisticsQuery = < + TData = GetJobResultStatisticsQuery, + TError = unknown + >( + variables?: GetJobResultStatisticsQueryVariables, + options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } + ) => { + + return useQuery( + { + queryKey: variables === undefined ? ['GetJobResultStatistics'] : ['GetJobResultStatistics', variables], + queryFn: fetcher(GetJobResultStatisticsDocument, variables), + ...options + } + )}; + +useGetJobResultStatisticsQuery.getKey = (variables?: GetJobResultStatisticsQueryVariables) => variables === undefined ? ['GetJobResultStatistics'] : ['GetJobResultStatistics', variables]; + +export const useInfiniteGetJobResultStatisticsQuery = < + TData = InfiniteData, + TError = unknown + >( + variables: GetJobResultStatisticsQueryVariables, + options: Omit, 'queryKey'> & { queryKey?: UseInfiniteQueryOptions['queryKey'] } + ) => { + + return useInfiniteQuery( + (() => { + const { queryKey: optionsQueryKey, ...restOptions } = options; + return { + queryKey: optionsQueryKey ?? variables === undefined ? ['GetJobResultStatistics.infinite'] : ['GetJobResultStatistics.infinite', variables], + queryFn: (metaData) => fetcher(GetJobResultStatisticsDocument, {...variables, ...(metaData.pageParam ?? {})})(), + ...restOptions + } + })() + )}; + +useInfiniteGetJobResultStatisticsQuery.getKey = (variables?: GetJobResultStatisticsQueryVariables) => variables === undefined ? ['GetJobResultStatistics.infinite'] : ['GetJobResultStatistics.infinite', variables]; + + +useGetJobResultStatisticsQuery.fetcher = (variables?: GetJobResultStatisticsQueryVariables, options?: RequestInit['headers']) => fetcher(GetJobResultStatisticsDocument, variables, options); + export const GetLibraryComicsDocument = ` query GetLibraryComics($page: Int, $limit: Int, $search: String, $series: String) { comics(page: $page, limit: $limit, search: $search, series: $series) { diff --git a/src/client/graphql/queries/dashboard.graphql b/src/client/graphql/queries/dashboard.graphql index 66abead..b52de40 100644 --- a/src/client/graphql/queries/dashboard.graphql +++ b/src/client/graphql/queries/dashboard.graphql @@ -214,3 +214,4 @@ query GetWeeklyPullList($input: WeeklyPullListInput!) { } } } + diff --git a/src/client/graphql/queries/import.graphql b/src/client/graphql/queries/import.graphql new file mode 100644 index 0000000..c896f96 --- /dev/null +++ b/src/client/graphql/queries/import.graphql @@ -0,0 +1,42 @@ +query GetImportStatistics($directoryPath: String) { + getImportStatistics(directoryPath: $directoryPath) { + success + directory + stats { + totalLocalFiles + alreadyImported + newFiles + percentageImported + } + } +} + +mutation StartNewImport($sessionId: String!) { + startNewImport(sessionId: $sessionId) { + success + message + jobsQueued + } +} + +mutation StartIncrementalImport($sessionId: String!, $directoryPath: String) { + startIncrementalImport(sessionId: $sessionId, directoryPath: $directoryPath) { + success + message + stats { + total + alreadyImported + newFiles + queued + } + } +} + +query GetJobResultStatistics { + getJobResultStatistics { + sessionId + earliestTimestamp + completedJobs + failedJobs + } +}