From 74a1438c212e92892377cbdb4f8f4570e91227df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:18:15 +0100 Subject: [PATCH] [V2] feat(unzip/front-end): Implement ZIP extraction confirmation for archives over 20 files (#4834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes TLDR: - Introduced a user confirmation dialog for extracting ZIP files with more than **20 files**. - Created `useZipConfirmation` hook to handle confirmation dialog logic and state. - Implemented `ZipWarningModal` component to display the confirmation dialog. - Updated `zipFileService` to count files in ZIP and trigger confirmation callback for large files. - Integrated confirmation flow into `FileContext` and `useToolResources`. - Added translations for new ZIP warning dialog messages. This pull request introduces a user confirmation dialog when attempting to extract large ZIP files (**over 20 files**), improving safety and user experience by preventing accidental extraction of very large archives. The implementation includes a reusable confirmation modal, a custom hook to handle dialog state and resolution, and updates to the ZIP extraction logic to support this workflow. **User Experience Improvements** * Added a new localized warning dialog (`ZipWarningModal`) that prompts users for confirmation when extracting ZIP files containing more than 20 files. This dialog displays the ZIP file name, file count, and offers "Cancel" and "Extract" actions, with responsive layouts for desktop and mobile **ZIP Extraction Workflow Enhancements** * Updated the ZIP extraction logic in `ZipFileService` to count the number of files in a ZIP and invoke a confirmation callback if the file count exceeds the threshold. Extraction proceeds only if the user confirms; otherwise, the ZIP remains unextracted. * Added a new hook (`useZipConfirmation`) to manage the confirmation dialog’s state and provide a promise-based API for requesting user confirmation. **Integration with Application State** * Integrated the confirmation workflow into `FileContext`, passing the confirmation function into ZIP extraction calls and rendering the modal dialog at the appropriate time. * Updated relevant interfaces and method signatures to support the optional confirmation callback for large ZIP extractions throughout the codebase. image image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../public/locales/en-GB/translation.json | 6 ++ .../components/shared/ZipWarningModal.tsx | 96 +++++++++++++++++++ frontend/src/core/contexts/FileContext.tsx | 18 +++- .../src/core/contexts/file/fileActions.ts | 5 +- .../hooks/tools/shared/useToolResources.ts | 9 +- frontend/src/core/hooks/useZipConfirmation.ts | 75 +++++++++++++++ frontend/src/core/services/zipFileService.ts | 44 ++++++--- .../tests/convert/ConvertIntegration.test.tsx | 17 ++-- .../ConvertSmartDetectionIntegration.test.tsx | 17 ++-- 9 files changed, 255 insertions(+), 32 deletions(-) create mode 100644 frontend/src/core/components/shared/ZipWarningModal.tsx create mode 100644 frontend/src/core/hooks/useZipConfirmation.ts 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', () => {