Unzip working

This commit is contained in:
Connor Yoh 2025-10-02 15:38:23 +01:00
parent 247f82b5a7
commit 9a0503b33b
14 changed files with 448 additions and 64 deletions

View File

@ -256,6 +256,13 @@
"name": "Save form inputs",
"help": "Enable to store previously used inputs for future runs"
},
"general": {
"title": "General",
"description": "Configure general application preferences.",
"autoUnzip": "Auto-unzip API responses",
"autoUnzipDescription": "Automatically extract files from ZIP responses",
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows."
},
"hotkeys": {
"title": "Keyboard Shortcuts",
"description": "Hover a tool to see its shortcut or customise it below. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
@ -3184,6 +3191,7 @@
"lastModified": "Last Modified",
"toolChain": "Tools Applied",
"restore": "Restore",
"unzip": "Unzip",
"searchFiles": "Search files...",
"recent": "Recent",
"localFiles": "Local Files",

View File

@ -6,6 +6,7 @@ import { FilesModalProvider } from "./contexts/FilesModalContext";
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { HotkeyProvider } from "./contexts/HotkeyContext";
import { SidebarProvider } from "./contexts/SidebarContext";
import { PreferencesProvider } from "./contexts/PreferencesContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage";
@ -41,25 +42,27 @@ export default function App() {
<Suspense fallback={<LoadingFallback />}>
<RainbowThemeProvider>
<ErrorBoundary>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
<PreferencesProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</PreferencesProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</Suspense>

View File

@ -309,6 +309,47 @@ const FileEditor = ({
}
}, [activeStirlingFileStubs, selectors, _setStatus]);
const handleUnzipFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
// Extract files from the ZIP
const extractionResult = await zipFileService.extractPdfFiles(file);
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
// Add extracted files to FileContext
await addFiles(extractionResult.extractedFiles);
// Remove the original ZIP file
removeFiles([fileId], false);
alert({
alertType: 'success',
title: `Extracted ${extractionResult.extractedFiles.length} file(s) from ${file.name}`,
expandable: false,
durationMs: 3500
});
} else {
alert({
alertType: 'error',
title: `Failed to extract files from ${file.name}`,
expandable: false,
durationMs: 3500
});
}
} catch (error) {
console.error('Failed to unzip file:', error);
alert({
alertType: 'error',
title: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500
});
}
}
}, [activeStirlingFileStubs, selectors, addFiles, removeFiles]);
const handleViewFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
if (record) {
@ -429,6 +470,7 @@ const FileEditor = ({
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>

View File

@ -5,11 +5,13 @@ import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { StirlingFileStub } from '../../types/fileContext';
import { zipFileService } from '../../services/zipFileService';
import styles from './FileEditor.module.css';
import { useFileContext } from '../../contexts/FileContext';
@ -32,6 +34,7 @@ interface FileEditorThumbnailProps {
_onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile: (fileId: FileId) => void;
onUnzipFile?: (fileId: FileId) => void;
toolMode?: boolean;
isSupported?: boolean;
}
@ -45,6 +48,7 @@ const FileEditorThumbnail = ({
_onSetStatus,
onReorderFiles,
onDownloadFile,
onUnzipFile,
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
@ -64,6 +68,9 @@ const FileEditorThumbnail = ({
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
const pageCount = file.processedFile?.totalPages || 0;
const handleRef = useRef<HTMLSpanElement | null>(null);
@ -299,6 +306,16 @@ const FileEditorThumbnail = ({
<span>{t('download', 'Download')}</span>
</button>
{isZipFile && onUnzipFile && (
<button
className={styles.actionRow}
onClick={() => { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
>
<UnarchiveIcon fontSize="small" />
<span>{t('fileManager.unzip', 'Unzip')}</span>
</button>
)}
<div className={styles.actionsDivider} />
<button

View File

@ -5,10 +5,12 @@ import DeleteIcon from '@mui/icons-material/Delete';
import DownloadIcon from '@mui/icons-material/Download';
import HistoryIcon from '@mui/icons-material/History';
import RestoreIcon from '@mui/icons-material/Restore';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileId, StirlingFileStub } from '../../types/fileContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import { zipFileService } from '../../services/zipFileService';
import ToolChain from '../shared/ToolChain';
interface FileListItemProps {
@ -38,7 +40,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
const {expandedFileIds, onToggleExpansion, onAddToRecents, onUnzipFile } = useFileManagerContext();
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
@ -192,6 +197,22 @@ const FileListItem: React.FC<FileListItemProps> = ({
</>
)}
{/* Unzip option for ZIP files */}
{isZipFile && !isHistoryFile && (
<>
<Menu.Item
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onUnzipFile(file);
}}
>
{t('fileManager.unzip', 'Unzip')}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {

View File

@ -1,6 +1,7 @@
import React from 'react';
import { NavKey } from './types';
import HotkeysSection from './configSections/HotkeysSection';
import GeneralSection from './configSections/GeneralSection';
export interface ConfigNavItem {
key: NavKey;
@ -43,6 +44,12 @@ export const createConfigNavSections = (
{
title: 'Preferences',
items: [
{
key: 'general',
label: 'General',
icon: 'settings-rounded',
component: <GeneralSection />
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',

View File

@ -0,0 +1,48 @@
import React from 'react';
import { Paper, Stack, Switch, Text, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '../../../../contexts/PreferencesContext';
const GeneralSection: React.FC = () => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
</Stack>
</Paper>
</Stack>
);
};
export default GeneralSection;

View File

@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { fileStorage } from '../services/fileStorage';
import { zipFileService } from '../services/zipFileService';
import { StirlingFileStub } from '../types/fileContext';
import { downloadFiles } from '../utils/downloadUtils';
import { FileId } from '../types/file';
@ -36,6 +37,7 @@ interface FileManagerContextValue {
onDownloadSingle: (file: StirlingFileStub) => void;
onToggleExpansion: (fileId: FileId) => void;
onAddToRecents: (file: StirlingFileStub) => void;
onUnzipFile: (file: StirlingFileStub) => Promise<void>;
onNewFilesSelect: (files: File[]) => void;
// External props
@ -544,6 +546,32 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}, [refreshRecentFiles]);
const handleUnzipFile = useCallback(async (file: StirlingFileStub) => {
try {
// Load the full file from storage
const stirlingFile = await fileStorage.getStirlingFile(file.id);
if (!stirlingFile) {
return;
}
// Extract files from the ZIP
const extractionResult = await zipFileService.extractPdfFiles(stirlingFile);
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
// Add extracted files to the file manager
onNewFilesSelect(extractionResult.extractedFiles);
// Optionally remove the original ZIP file
const fileIndex = filteredFiles.findIndex(f => f.id === file.id);
if (fileIndex !== -1) {
await handleFileRemove(fileIndex);
}
}
} catch (error) {
console.error('Failed to unzip file:', error);
}
}, [onNewFilesSelect, filteredFiles, handleFileRemove]);
// Cleanup blob URLs when component unmounts
useEffect(() => {
return () => {
@ -595,6 +623,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
onDownloadSingle: handleDownloadSingle,
onToggleExpansion: handleToggleExpansion,
onAddToRecents: handleAddToRecents,
onUnzipFile: handleUnzipFile,
onNewFilesSelect,
// External props
@ -627,6 +656,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
handleDownloadSelected,
handleToggleExpansion,
handleAddToRecents,
handleUnzipFile,
onNewFilesSelect,
recentFiles,
isFileSupported,

View File

@ -0,0 +1,81 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { preferencesService, UserPreferences, DEFAULT_PREFERENCES } from '../services/preferencesService';
interface PreferencesContextValue {
preferences: UserPreferences;
updatePreference: <K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
) => Promise<void>;
resetPreferences: () => Promise<void>;
isLoading: boolean;
}
const PreferencesContext = createContext<PreferencesContextValue | undefined>(undefined);
export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadPreferences = async () => {
try {
await preferencesService.initialize();
const loadedPreferences = await preferencesService.getAllPreferences();
setPreferences(loadedPreferences);
} catch (error) {
console.error('Failed to load preferences:', error);
// Keep default preferences on error
} finally {
setIsLoading(false);
}
};
loadPreferences();
}, []);
const updatePreference = useCallback(
async <K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => {
try {
await preferencesService.setPreference(key, value);
setPreferences((prev) => ({
...prev,
[key]: value,
}));
} catch (error) {
throw error;
}
},
[]
);
const resetPreferences = useCallback(async () => {
try {
await preferencesService.clearAllPreferences();
setPreferences(DEFAULT_PREFERENCES);
} catch (error) {
throw error;
}
}, []);
return (
<PreferencesContext.Provider
value={{
preferences,
updatePreference,
resetPreferences,
isLoading,
}}
>
{children}
</PreferencesContext.Provider>
);
};
export const usePreferences = (): PreferencesContextValue => {
const context = useContext(PreferencesContext);
if (!context) {
throw new Error('usePreferences must be used within a PreferencesProvider');
}
return context;
};

View File

@ -257,6 +257,7 @@ export const useToolOperation = <TParams>(
processedFiles = [singleFile];
} else {
// Default: assume ZIP response for multi-file endpoints
// Note: extractZipFiles will check preferences.autoUnzip setting
processedFiles = await extractZipFiles(response.data);
if (processedFiles.length === 0) {

View File

@ -1,9 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { generateThumbnailForFile, generateThumbnailWithMetadata, ThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
import { zipFileService } from '../../../services/zipFileService';
import { usePreferences } from '../../../contexts/PreferencesContext';
export const useToolResources = () => {
const { preferences } = usePreferences();
const [blobUrls, setBlobUrls] = useState<string[]>([]);
const addBlobUrl = useCallback((url: string) => {
@ -81,8 +83,13 @@ export const useToolResources = () => {
return results;
}, []);
const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise<File[]> => {
try {
// Check if auto-unzip is disabled (unless explicitly skipped like in automation)
if (!skipAutoUnzip && !preferences.autoUnzip) {
return [new File([zipBlob], 'result.zip', { type: 'application/zip' })];
}
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
return extractionResult.success ? extractionResult.extractedFiles : [];
@ -90,7 +97,7 @@ export const useToolResources = () => {
console.error('useToolResources.extractZipFiles - Error:', error);
return [];
}
}, []);
}, [preferences.autoUnzip]);
const extractAllZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
try {

View File

@ -314,6 +314,15 @@ export const DATABASE_CONFIGS = {
}]
} as DatabaseConfig,
PREFERENCES: {
name: 'stirling-pdf-preferences',
version: 1,
stores: [{
name: 'preferences',
keyPath: 'key'
}]
} as DatabaseConfig,
} as const;
export const indexedDBManager = IndexedDBManager.getInstance();

View File

@ -0,0 +1,127 @@
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
export interface UserPreferences {
autoUnzip: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
autoUnzip: true,
};
class PreferencesService {
private db: IDBDatabase | null = null;
async initialize(): Promise<void> {
this.db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.PREFERENCES);
}
private ensureDatabase(): IDBDatabase {
if (!this.db) {
throw new Error('PreferencesService not initialized. Call initialize() first.');
}
return this.db;
}
async getPreference<K extends keyof UserPreferences>(
key: K
): Promise<UserPreferences[K]> {
const db = this.ensureDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['preferences'], 'readonly');
const store = transaction.objectStore('preferences');
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
if (result && result.value !== undefined) {
resolve(result.value);
} else {
// Return default value if preference not found
resolve(DEFAULT_PREFERENCES[key]);
}
};
request.onerror = () => {
console.error('Error reading preference:', key, request.error);
// Return default value on error
resolve(DEFAULT_PREFERENCES[key]);
};
});
}
async setPreference<K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
): Promise<void> {
const db = this.ensureDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['preferences'], 'readwrite');
const store = transaction.objectStore('preferences');
const request = store.put({ key, value });
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
console.error('Error writing preference:', key, request.error);
reject(request.error);
};
});
}
async getAllPreferences(): Promise<UserPreferences> {
const db = this.ensureDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['preferences'], 'readonly');
const store = transaction.objectStore('preferences');
const request = store.getAll();
request.onsuccess = () => {
const storedPrefs: Partial<UserPreferences> = {};
const results = request.result;
for (const item of results) {
if (item.key && item.value !== undefined) {
storedPrefs[item.key as keyof UserPreferences] = item.value;
}
}
// Merge with defaults to ensure all preferences exist
resolve({
...DEFAULT_PREFERENCES,
...storedPrefs,
});
};
request.onerror = () => {
console.error('Error reading all preferences:', request.error);
// Return defaults on error
resolve({ ...DEFAULT_PREFERENCES });
};
});
}
async clearAllPreferences(): Promise<void> {
const db = this.ensureDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['preferences'], 'readwrite');
const store = transaction.objectStore('preferences');
const request = store.clear();
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}
}
export const preferencesService = new PreferencesService();

View File

@ -1,4 +1,5 @@
import JSZip, { JSZipObject } from 'jszip';
import { StirlingFileStub } from '../types/fileContext';
// Undocumented interface in JSZip for JSZipObject._data
interface CompressedObject {
@ -41,6 +42,15 @@ export class ZipFileService {
private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit
private readonly supportedExtensions = ['.pdf'];
// ZIP file validation constants
private static readonly VALID_ZIP_TYPES = [
'application/zip',
'application/x-zip-compressed',
'application/x-zip',
'application/octet-stream' // Some browsers use this for ZIP files
];
private static readonly VALID_ZIP_EXTENSIONS = ['.zip'];
/**
* Validate a ZIP file without extracting it
*/
@ -238,23 +248,27 @@ export class ZipFileService {
/**
* Check if a file is a ZIP file based on type and extension
*/
private isZipFile(file: File): boolean {
const validTypes = [
'application/zip',
'application/x-zip-compressed',
'application/x-zip',
'application/octet-stream' // Some browsers use this for ZIP files
];
const validExtensions = ['.zip'];
const hasValidType = validTypes.includes(file.type);
const hasValidExtension = validExtensions.some(ext =>
public isZipFile(file: File): boolean {
const hasValidType = ZipFileService.VALID_ZIP_TYPES.includes(file.type);
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some(ext =>
file.name.toLowerCase().endsWith(ext)
);
return hasValidType || hasValidExtension;
}
/**
* Check if a StirlingFileStub represents a ZIP file (for UI checks without loading full file)
*/
public isZipFileStub(stub: StirlingFileStub): boolean {
const hasValidType = stub.type && ZipFileService.VALID_ZIP_TYPES.includes(stub.type);
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some(ext =>
stub.name.toLowerCase().endsWith(ext)
);
return hasValidType || hasValidExtension;
}
/**
* Check if a filename indicates a PDF file
*/
@ -308,37 +322,6 @@ export class ZipFileService {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Get file extension from filename
*/
private getFileExtension(filename: string): string {
return filename.substring(filename.lastIndexOf('.')).toLowerCase();
}
/**
* Check if ZIP file contains password protection
*/
private async isPasswordProtected(file: File): Promise<boolean> {
try {
const zip = new JSZip();
await zip.loadAsync(file);
// Check if any files are encrypted
for (const [_filename, zipEntry] of Object.entries(zip.files)) {
if (zipEntry.options?.compression === 'STORE' && getData(zipEntry)?.compressedSize === 0) {
// This might indicate encryption, but JSZip doesn't provide direct encryption detection
// We'll handle this in the extraction phase
}
}
return false; // JSZip will throw an error if password is required
} catch (error) {
// If we can't load the ZIP, it might be password protected
const errorMessage = error instanceof Error ? error.message : '';
return errorMessage.includes('password') || errorMessage.includes('encrypted');
}
}
/**
* Extract all files from a ZIP archive (not limited to PDFs)
*/