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 */}
+
+ }
+ w="10rem"
+ >
+ {t("zipWarning.cancel", "Cancel")}
+
+ }
+ w="10rem"
+ >
+ {t("zipWarning.confirm", "Extract")}
+
+
+
+ {/* Mobile layout: vertical stack */}
+
+ }
+ w="10rem"
+ >
+ {t("zipWarning.cancel", "Cancel")}
+
+ }
+ w="10rem"
+ >
+ {t("zipWarning.confirm", "Extract")}
+
+
+
+ );
+};
+
+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', () => {