diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 4e9904f19..4b33f6034 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -35,6 +35,12 @@ "discardChanges": "Discard & Leave", "applyAndContinue": "Save & Leave", "exportAndContinue": "Export & Continue", + "zipWarning": { + "title": "Large ZIP File", + "message": "This ZIP contains {{count}} files. Extract anyway?", + "cancel": "Cancel", + "confirm": "Extract" + }, "language": { "direction": "ltr" }, diff --git a/frontend/src/core/components/shared/ZipWarningModal.tsx b/frontend/src/core/components/shared/ZipWarningModal.tsx new file mode 100644 index 000000000..909cf1b31 --- /dev/null +++ b/frontend/src/core/components/shared/ZipWarningModal.tsx @@ -0,0 +1,96 @@ +import { Modal, Text, Button, Group, Stack } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import CancelIcon from "@mui/icons-material/Cancel"; +import { CSSProperties } from "react"; + +interface ZipWarningModalProps { + opened: boolean; + onConfirm: () => void; + onCancel: () => void; + fileCount: number; + zipFileName: string; +} + +const WARNING_ICON_STYLE: CSSProperties = { + fontSize: 36, + display: 'block', + margin: '0 auto 8px', + color: 'var(--mantine-color-blue-6)' +}; + +const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }: ZipWarningModalProps) => { + const { t } = useTranslation(); + + return ( + + + + + {zipFileName} + + + {t("zipWarning.message", { + count: fileCount, + defaultValue: "This ZIP contains {{count}} files. Extract anyway?" + })} + + + + {/* Desktop layout: centered buttons */} + + + + + + {/* Mobile layout: vertical stack */} + + + + + + ); +}; + +export default ZipWarningModal; diff --git a/frontend/src/core/contexts/FileContext.tsx b/frontend/src/core/contexts/FileContext.tsx index 60d95b676..56f1dcabd 100644 --- a/frontend/src/core/contexts/FileContext.tsx +++ b/frontend/src/core/contexts/FileContext.tsx @@ -31,6 +31,8 @@ import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createF import { FileLifecycleManager } from '@app/contexts/file/lifecycle'; import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts'; import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext'; +import { useZipConfirmation } from '@app/hooks/useZipConfirmation'; +import ZipWarningModal from '@app/components/shared/ZipWarningModal'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -52,6 +54,9 @@ function FileContextInner({ const stateRef = useRef(state); stateRef.current = state; + // ZIP confirmation dialog + const { confirmationState, requestConfirmation, handleConfirm, handleCancel } = useZipConfirmation(); + // Create lifecycle manager const lifecycleManagerRef = useRef(null); if (!lifecycleManagerRef.current) { @@ -86,7 +91,9 @@ function FileContextInner({ ...options, // For direct file uploads: ALWAYS unzip (except HTML ZIPs) // skipAutoUnzip bypasses preference checks - HTML detection still applies - skipAutoUnzip: true + skipAutoUnzip: true, + // Provide confirmation callback for large ZIP files + confirmLargeExtraction: requestConfirmation }, stateRef, filesRef, @@ -101,7 +108,7 @@ function FileContextInner({ } return stirlingFiles; - }, [enablePersistence]); + }, [enablePersistence, requestConfirmation]); const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise => { // StirlingFileStubs preserve all metadata - perfect for FileManager use case! @@ -237,6 +244,13 @@ function FileContextInner({ {children} + ); diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index 0a37134f1..d209781c8 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -186,6 +186,7 @@ interface AddFileOptions { autoUnzip?: boolean; autoUnzipFileLimit?: number; skipAutoUnzip?: boolean; // When true: always unzip (except HTML). Used for file uploads. When false: respect autoUnzip/autoUnzipFileLimit preferences. Used for tool outputs. + confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise; // Optional callback to confirm extraction of large ZIP files } /** @@ -219,6 +220,7 @@ export async function addFiles( const autoUnzip = options.autoUnzip ?? true; // Default to true const autoUnzipFileLimit = options.autoUnzipFileLimit ?? 4; // Default limit const skipAutoUnzip = options.skipAutoUnzip ?? false; + const confirmLargeExtraction = options.confirmLargeExtraction; for (const file of files) { // Check if file is a ZIP @@ -238,7 +240,8 @@ export async function addFiles( const extractedFiles = await zipFileService.extractWithPreferences(file, { autoUnzip, autoUnzipFileLimit, - skipAutoUnzip + skipAutoUnzip, + confirmLargeExtraction }); if (extractedFiles.length === 1 && extractedFiles[0] === file) { diff --git a/frontend/src/core/hooks/tools/shared/useToolResources.ts b/frontend/src/core/hooks/tools/shared/useToolResources.ts index 086a00f49..a80c2c298 100644 --- a/frontend/src/core/hooks/tools/shared/useToolResources.ts +++ b/frontend/src/core/hooks/tools/shared/useToolResources.ts @@ -83,12 +83,17 @@ export const useToolResources = () => { return results; }, []); - const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise => { + const extractZipFiles = useCallback(async ( + zipBlob: Blob, + skipAutoUnzip = false, + confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise + ): Promise => { try { return await zipFileService.extractWithPreferences(zipBlob, { autoUnzip: preferences.autoUnzip, autoUnzipFileLimit: preferences.autoUnzipFileLimit, - skipAutoUnzip + skipAutoUnzip, + confirmLargeExtraction }); } catch (error) { console.error('useToolResources.extractZipFiles - Error:', error); diff --git a/frontend/src/core/hooks/useZipConfirmation.ts b/frontend/src/core/hooks/useZipConfirmation.ts new file mode 100644 index 000000000..e23a2ce80 --- /dev/null +++ b/frontend/src/core/hooks/useZipConfirmation.ts @@ -0,0 +1,75 @@ +import { useState, useCallback, useRef } from 'react'; + +interface ZipConfirmationState { + opened: boolean; + fileCount: number; + fileName: string; +} + +/** + * Hook to manage ZIP warning confirmation dialog + * Returns state and handlers for the confirmation dialog + * Uses useRef to avoid recreating callbacks on every state change + */ +export const useZipConfirmation = () => { + const [confirmationState, setConfirmationState] = useState({ + opened: false, + fileCount: 0, + fileName: '', + }); + + // Store resolve function in ref to avoid callback recreation + const resolveRef = useRef<((value: boolean) => void) | null>(null); + + /** + * Request confirmation from user for extracting a large ZIP file + * Returns a Promise that resolves to true if user confirms, false if cancelled + */ + const requestConfirmation = useCallback((fileCount: number, fileName: string): Promise => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setConfirmationState({ + opened: true, + fileCount, + fileName, + }); + }); + }, []); + + /** + * Handle user confirmation - extract the ZIP + */ + const handleConfirm = useCallback(() => { + if (resolveRef.current) { + resolveRef.current(true); + resolveRef.current = null; + } + setConfirmationState({ + opened: false, + fileCount: 0, + fileName: '', + }); + }, []); // No dependencies - uses ref + + /** + * Handle user cancellation - keep ZIP as-is + */ + const handleCancel = useCallback(() => { + if (resolveRef.current) { + resolveRef.current(false); + resolveRef.current = null; + } + setConfirmationState({ + opened: false, + fileCount: 0, + fileName: '', + }); + }, []); // No dependencies - uses ref + + return { + confirmationState, + requestConfirmation, + handleConfirm, + handleCancel, + }; +}; diff --git a/frontend/src/core/services/zipFileService.ts b/frontend/src/core/services/zipFileService.ts index f7f157283..f4ed39865 100644 --- a/frontend/src/core/services/zipFileService.ts +++ b/frontend/src/core/services/zipFileService.ts @@ -44,6 +44,9 @@ export class ZipFileService { private readonly maxFileSize = 100 * 1024 * 1024; // 100MB per file private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit + // Warn user when extracting ZIP with more than this many files + public static readonly ZIP_WARNING_THRESHOLD = 20; + // ZIP file validation constants private static readonly VALID_ZIP_TYPES = [ 'application/zip', @@ -361,31 +364,35 @@ export class ZipFileService { /** * Determine if a ZIP file should be extracted based on user preferences + * Returns both the extraction decision and file count to avoid redundant ZIP parsing * * @param zipBlob - The ZIP file to check * @param autoUnzip - User preference for auto-unzipping * @param autoUnzipFileLimit - Maximum number of files to auto-extract * @param skipAutoUnzip - Bypass preference check (for automation) - * @returns true if the ZIP should be extracted, false otherwise + * @returns Object with shouldExtract flag and fileCount */ async shouldUnzip( zipBlob: Blob | File, autoUnzip: boolean, autoUnzipFileLimit: number, skipAutoUnzip: boolean = false - ): Promise { + ): Promise<{ shouldExtract: boolean; fileCount: number }> { try { - // Automation always extracts + // Automation always extracts - but still need to count files for warning if (skipAutoUnzip) { - return true; + const zip = new JSZip(); + const zipContents = await zip.loadAsync(zipBlob); + const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length; + return { shouldExtract: true, fileCount }; } // Check if auto-unzip is enabled if (!autoUnzip) { - return false; + return { shouldExtract: false, fileCount: 0 }; } - // Load ZIP and count files + // Load ZIP and count files (single parse) const zip = new JSZip(); const zipContents = await zip.loadAsync(zipBlob); @@ -393,20 +400,22 @@ export class ZipFileService { const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length; // Only extract if within limit - return fileCount <= autoUnzipFileLimit; + return { + shouldExtract: fileCount <= autoUnzipFileLimit, + fileCount + }; } catch (error) { console.error('Error checking shouldUnzip:', error); // On error, default to not extracting (safer) - return false; + return { shouldExtract: false, fileCount: 0 }; } } - /** * Extract files from ZIP with HTML detection and preference checking - * This is the unified method that handles the common pattern of: * 1. Check for HTML files → keep zipped if present * 2. Check user preferences → respect autoUnzipFileLimit - * 3. Extract files if appropriate + * 3. Show warning for large ZIPs (>20 files) if callback provided + * 4. Extract files if appropriate * * @param zipBlob - The ZIP blob to process * @param options - Extraction options @@ -418,6 +427,7 @@ export class ZipFileService { autoUnzip: boolean; autoUnzipFileLimit: number; skipAutoUnzip?: boolean; + confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise; } ): Promise { try { @@ -432,8 +442,8 @@ export class ZipFileService { return [zipFile]; } - // Check if we should extract based on preferences - const shouldExtract = await this.shouldUnzip( + // Check if we should extract based on preferences (returns both decision and count) + const { shouldExtract, fileCount } = await this.shouldUnzip( zipBlob, options.autoUnzip, options.autoUnzipFileLimit, @@ -444,6 +454,14 @@ export class ZipFileService { return [zipFile]; } + // Warn user if ZIP is large (fileCount already obtained from shouldUnzip) + if (fileCount > ZipFileService.ZIP_WARNING_THRESHOLD && options.confirmLargeExtraction) { + const userConfirmed = await options.confirmLargeExtraction(fileCount, zipFile.name); + if (!userConfirmed) { + return [zipFile]; // User cancelled, keep ZIP as-is + } + } + // Extract all files const extractionResult = await this.extractAllFiles(zipFile); return extractionResult.success ? extractionResult.extractedFiles : [zipFile]; diff --git a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx index 5d0d899d7..80e8760c7 100644 --- a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx @@ -20,6 +20,7 @@ import { I18nextProvider } from 'react-i18next'; import i18n from '@app/i18n/config'; import { createTestStirlingFile } from '@app/tests/utils/testFileHelpers'; import { StirlingFile } from '@app/types/fileContext'; +import { MantineProvider } from '@mantine/core'; // Mock axios (for static methods like CancelToken, isCancel) vi.mock('axios', () => ({ @@ -88,13 +89,15 @@ const createPDFFile = (): StirlingFile => { // Test wrapper component const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - - - {children} - - - + + + + + {children} + + + + ); describe('Convert Tool Integration Tests', () => { diff --git a/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx index a47bb26fc..9994d36a7 100644 --- a/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -15,6 +15,7 @@ import i18n from '@app/i18n/config'; import { detectFileExtension } from '@app/utils/fileUtils'; import { FIT_OPTIONS } from '@app/constants/convertConstants'; import { createTestStirlingFile, createTestFilesWithId } from '@app/tests/utils/testFileHelpers'; +import { MantineProvider } from '@mantine/core'; // Mock axios (for static methods like CancelToken, isCancel) vi.mock('axios', () => ({ @@ -76,13 +77,15 @@ vi.mock('../../services/thumbnailGenerationService', () => ({ })); const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - - - {children} - - - + + + + + {children} + + + + ); describe('Convert Tool - Smart Detection Integration Tests', () => {