Unified upload file unzippage

This commit is contained in:
Connor Yoh 2025-10-14 10:26:11 +01:00
parent 851e8f9394
commit 3db8c016a8
3 changed files with 84 additions and 154 deletions

View File

@ -1,6 +1,6 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import {
Text, Center, Box, LoadingOverlay, Stack, Group
Text, Center, Box, LoadingOverlay, Stack
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
@ -10,7 +10,6 @@ import { detectFileExtension } from '../../utils/fileUtils';
import FileEditorThumbnail from './FileEditorThumbnail';
import AddFileCard from './AddFileCard';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
import { FileId, StirlingFile } from '../../types/fileContext';
import { alert } from '../toast';
import { downloadBlob } from '../../utils/downloadUtils';
@ -68,19 +67,6 @@ const FileEditor = ({
}
}, [toolMode]);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
isExtracting: boolean;
currentFile: string;
progress: number;
extractedCount: number;
totalFiles: number;
}>({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
// Get selected file IDs from context (defensive programming)
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
@ -92,115 +78,26 @@ const FileEditor = ({
const localSelectedIds = contextSelectedIds;
// Process uploaded files using context
// ZIP extraction is now handled automatically in FileContext based on user preferences
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
_setError(null);
try {
const allExtractedFiles: File[] = [];
const errors: string[] = [];
for (const file of uploadedFiles) {
if (file.type === 'application/pdf') {
// Handle PDF files normally
allExtractedFiles.push(file);
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
// Handle ZIP files - extract all files except HTML
try {
// Check if ZIP contains HTML files - if so, don't extract
const containsHtml = await zipFileService.containsHtmlFiles(file);
if (containsHtml) {
// HTML files should stay zipped
allExtractedFiles.push(file);
continue;
}
// Validate ZIP file first
const validation = await zipFileService.validateZipFile(file);
if (validation.isValid && validation.containsFiles) {
// ZIP contains files - extract them
setZipExtractionProgress({
isExtracting: true,
currentFile: file.name,
progress: 0,
extractedCount: 0,
totalFiles: validation.fileCount
});
const extractionResult = await zipFileService.extractAllFiles(file, (progress) => {
setZipExtractionProgress({
isExtracting: true,
currentFile: progress.currentFile,
progress: progress.progress,
extractedCount: progress.extractedCount,
totalFiles: progress.totalFiles
});
});
// Reset extraction progress
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
if (extractionResult.success) {
allExtractedFiles.push(...extractionResult.extractedFiles);
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
}
} else {
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
}
} else {
// ZIP is empty or invalid - treat as regular file
allExtractedFiles.push(file);
}
} catch (zipError) {
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
}
} else {
allExtractedFiles.push(file);
}
}
// Show any errors
if (errors.length > 0) {
showError(errors.join('\n'));
}
// Process all extracted files
if (allExtractedFiles.length > 0) {
// Add files to context and select them automatically
await addFiles(allExtractedFiles, { selectFiles: true });
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
if (uploadedFiles.length > 0) {
// FileContext will automatically handle ZIP extraction based on user preferences
// - Respects autoUnzip setting
// - Respects autoUnzipFileLimit
// - HTML ZIPs stay intact
// - Non-ZIP files pass through unchanged
await addFiles(uploadedFiles, { selectFiles: true });
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
showError(errorMessage);
console.error('File processing error:', err);
// Reset extraction progress on error
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
}
}, [addFiles]);
}, [addFiles, showStatus, showError]);
const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
@ -403,7 +300,7 @@ const FileEditor = ({
<Box p="md">
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
{activeStirlingFileStubs.length === 0 ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
@ -411,43 +308,6 @@ const FileEditor = ({
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack>
</Center>
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
<Box>
<SkeletonLoader type="controls" />
{/* ZIP Extraction Progress */}
{zipExtractionProgress.isExtracting && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Extracting ZIP archive...</Text>
<Text size="sm" c="dimmed">{Math.round(zipExtractionProgress.progress)}%</Text>
</Group>
<Text size="xs" c="dimmed" mb="xs">
{zipExtractionProgress.currentFile || 'Processing files...'}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
</Text>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.round(zipExtractionProgress.progress)}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-orange-6)',
transition: 'width 0.3s ease'
}} />
</div>
</Box>
)}
<SkeletonLoader type="fileGrid" count={6} />
</Box>
) : (
<div
style={{

View File

@ -79,8 +79,21 @@ function FileContextInner({
};
// File operations using unified addFiles helper with persistence
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
const stirlingFiles = await addFiles({ files, ...options }, stateRef, filesRef, dispatch, lifecycleManager, enablePersistence);
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean; skipAutoUnzip?: boolean }): Promise<StirlingFile[]> => {
const stirlingFiles = await addFiles(
{
files,
...options,
// For direct file uploads: ALWAYS unzip (except HTML ZIPs)
// skipAutoUnzip bypasses preference checks - HTML detection still applies
skipAutoUnzip: true
},
stateRef,
filesRef,
dispatch,
lifecycleManager,
enablePersistence
);
// Auto-select the newly added files if requested
if (options?.selectFiles && stirlingFiles.length > 0) {

View File

@ -18,6 +18,7 @@ import { FileLifecycleManager } from './lifecycle';
import { buildQuickKeySet } from './fileSelectors';
import { StirlingFile } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
import { zipFileService } from '../../services/zipFileService';
const DEBUG = process.env.NODE_ENV === 'development';
/**
@ -172,6 +173,11 @@ interface AddFileOptions {
// Auto-selection after adding
selectFiles?: boolean;
// Auto-unzip control
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.
}
/**
@ -198,7 +204,58 @@ export async function addFiles(
const { files = [] } = options;
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
// ZIP pre-processing: Extract ZIP files with configurable behavior
// - File uploads: skipAutoUnzip=true → always extract (except HTML)
// - Tool outputs: skipAutoUnzip=false → respect user preferences
const filesToProcess: File[] = [];
const autoUnzip = options.autoUnzip ?? true; // Default to true
const autoUnzipFileLimit = options.autoUnzipFileLimit ?? 4; // Default limit
const skipAutoUnzip = options.skipAutoUnzip ?? false;
for (const file of files) {
// Check if file is a ZIP
if (zipFileService.isZipFile(file)) {
try {
if (DEBUG) console.log(`📄 addFiles: Detected ZIP file: ${file.name}`);
// Check if ZIP contains HTML files - if so, keep as ZIP
const containsHtml = await zipFileService.containsHtmlFiles(file);
if (containsHtml) {
if (DEBUG) console.log(`📄 addFiles: ZIP contains HTML, keeping as ZIP: ${file.name}`);
filesToProcess.push(file);
continue;
}
// Apply extraction with preferences
const extractedFiles = await zipFileService.extractWithPreferences(file, {
autoUnzip,
autoUnzipFileLimit,
skipAutoUnzip
});
if (extractedFiles.length === 1 && extractedFiles[0] === file) {
// ZIP was not extracted (over limit or autoUnzip disabled)
if (DEBUG) console.log(`📄 addFiles: ZIP not extracted (preferences): ${file.name}`);
} else {
// ZIP was extracted
if (DEBUG) console.log(`📄 addFiles: Extracted ${extractedFiles.length} files from ZIP: ${file.name}`);
}
filesToProcess.push(...extractedFiles);
} catch (error) {
console.error(`📄 addFiles: Failed to process ZIP file ${file.name}:`, error);
// On error, keep the ZIP file as-is
filesToProcess.push(file);
}
} else {
// Not a ZIP file, add as-is
filesToProcess.push(file);
}
}
if (DEBUG) console.log(`📄 addFiles: After ZIP processing, ${filesToProcess.length} files to add`);
for (const file of filesToProcess) {
const quickKey = createQuickKey(file);
// Soft deduplication: Check if file already exists by metadata