[V2] feat(unzip/front-end): Implement ZIP extraction confirmation for archives over 20 files (#4834)

# 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.


<img width="515" height="321" alt="image"
src="https://github.com/user-attachments/assets/f35a7588-4635-4ccd-9ee6-95edb17fee99"
/>
<img width="515" height="321" alt="image"
src="https://github.com/user-attachments/assets/0525acf3-4174-42cd-8912-910e754c467c"
/>

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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 <bszucs1209@gmail.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
Balázs Szücs 2025-11-12 16:18:15 +01:00 committed by GitHub
parent c8615518a6
commit 74a1438c21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 255 additions and 32 deletions

View File

@ -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"
},

View File

@ -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 (
<Modal
opened={opened}
onClose={onCancel}
title={t("zipWarning.title", "Large ZIP File")}
centered
size="auto"
closeOnClickOutside={true}
closeOnEscape={true}
>
<Stack ta="center" p="md" gap="sm">
<WarningAmberIcon style={WARNING_ICON_STYLE} />
<Text size="md" fw={300}>
{zipFileName}
</Text>
<Text size="lg" fw={500}>
{t("zipWarning.message", {
count: fileCount,
defaultValue: "This ZIP contains {{count}} files. Extract anyway?"
})}
</Text>
</Stack>
{/* Desktop layout: centered buttons */}
<Group justify="center" gap="sm" visibleFrom="md">
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={onCancel}
leftSection={<CancelIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.cancel", "Cancel")}
</Button>
<Button
variant="filled"
color="var(--mantine-color-blue-9)"
onClick={onConfirm}
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.confirm", "Extract")}
</Button>
</Group>
{/* Mobile layout: vertical stack */}
<Stack align="center" gap="sm" hiddenFrom="md">
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={onCancel}
leftSection={<CancelIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.cancel", "Cancel")}
</Button>
<Button
variant="filled"
color="var(--mantine-color-blue-9)"
onClick={onConfirm}
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.confirm", "Extract")}
</Button>
</Stack>
</Modal>
);
};
export default ZipWarningModal;

View File

@ -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<FileLifecycleManager | null>(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<StirlingFile[]> => {
// StirlingFileStubs preserve all metadata - perfect for FileManager use case!
@ -237,6 +244,13 @@ function FileContextInner({
<FileStateContext.Provider value={stateValue}>
<FileActionsContext.Provider value={actionsValue}>
{children}
<ZipWarningModal
opened={confirmationState.opened}
onConfirm={handleConfirm}
onCancel={handleCancel}
fileCount={confirmationState.fileCount}
zipFileName={confirmationState.fileName}
/>
</FileActionsContext.Provider>
</FileStateContext.Provider>
);

View File

@ -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<boolean>; // 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) {

View File

@ -83,12 +83,17 @@ export const useToolResources = () => {
return results;
}, []);
const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise<File[]> => {
const extractZipFiles = useCallback(async (
zipBlob: Blob,
skipAutoUnzip = false,
confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise<boolean>
): Promise<File[]> => {
try {
return await zipFileService.extractWithPreferences(zipBlob, {
autoUnzip: preferences.autoUnzip,
autoUnzipFileLimit: preferences.autoUnzipFileLimit,
skipAutoUnzip
skipAutoUnzip,
confirmLargeExtraction
});
} catch (error) {
console.error('useToolResources.extractZipFiles - Error:', error);

View File

@ -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<ZipConfirmationState>({
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<boolean> => {
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,
};
};

View File

@ -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<boolean> {
): 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<boolean>;
}
): Promise<File[]> {
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];

View File

@ -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,6 +89,7 @@ const createPDFFile = (): StirlingFile => {
// Test wrapper component
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<MantineProvider>
<I18nextProvider i18n={i18n}>
<PreferencesProvider>
<FileContextProvider>
@ -95,6 +97,7 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
</FileContextProvider>
</PreferencesProvider>
</I18nextProvider>
</MantineProvider>
);
describe('Convert Tool Integration Tests', () => {

View File

@ -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,6 +77,7 @@ vi.mock('../../services/thumbnailGenerationService', () => ({
}));
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<MantineProvider>
<I18nextProvider i18n={i18n}>
<PreferencesProvider>
<FileContextProvider>
@ -83,6 +85,7 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
</FileContextProvider>
</PreferencesProvider>
</I18nextProvider>
</MantineProvider>
);
describe('Convert Tool - Smart Detection Integration Tests', () => {