diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index b1646e76f..0f7dcfb6e 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d926b9f0d..88c19649f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { }> - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 00bf22480..f7a1aa9f7 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -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)} /> diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index e8f102101..f09bfeeb1 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -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(null); @@ -299,6 +306,16 @@ const FileEditorThumbnail = ({ {t('download', 'Download')} + {isZipFile && onUnzipFile && ( + { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }} + > + + {t('fileManager.unzip', 'Unzip')} + + )} + = ({ 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 = ({ > )} + {/* Unzip option for ZIP files */} + {isZipFile && !isHistoryFile && ( + <> + } + onClick={(e) => { + e.stopPropagation(); + onUnzipFile(file); + }} + > + {t('fileManager.unzip', 'Unzip')} + + + > + )} + } onClick={(e) => { diff --git a/frontend/src/components/shared/config/configNavSections.tsx b/frontend/src/components/shared/config/configNavSections.tsx index 912ad8647..ac5b670ee 100644 --- a/frontend/src/components/shared/config/configNavSections.tsx +++ b/frontend/src/components/shared/config/configNavSections.tsx @@ -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: + }, { key: 'hotkeys', label: 'Keyboard Shortcuts', diff --git a/frontend/src/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/components/shared/config/configSections/GeneralSection.tsx new file mode 100644 index 000000000..059e7674f --- /dev/null +++ b/frontend/src/components/shared/config/configSections/GeneralSection.tsx @@ -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 ( + + + {t('settings.general.title', 'General')} + + {t('settings.general.description', 'Configure general application preferences.')} + + + + + + + + + + {t('settings.general.autoUnzip', 'Auto-unzip API responses')} + + + {t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')} + + + updatePreference('autoUnzip', event.currentTarget.checked)} + /> + + + + + + ); +}; + +export default GeneralSection; diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index fb82e9071..6eabb4144 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -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; onNewFilesSelect: (files: File[]) => void; // External props @@ -544,6 +546,32 @@ export const FileManagerProvider: React.FC = ({ } }, [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 = ({ onDownloadSingle: handleDownloadSingle, onToggleExpansion: handleToggleExpansion, onAddToRecents: handleAddToRecents, + onUnzipFile: handleUnzipFile, onNewFilesSelect, // External props @@ -627,6 +656,7 @@ export const FileManagerProvider: React.FC = ({ handleDownloadSelected, handleToggleExpansion, handleAddToRecents, + handleUnzipFile, onNewFilesSelect, recentFiles, isFileSupported, diff --git a/frontend/src/contexts/PreferencesContext.tsx b/frontend/src/contexts/PreferencesContext.tsx new file mode 100644 index 000000000..4ebfe0aab --- /dev/null +++ b/frontend/src/contexts/PreferencesContext.tsx @@ -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: ( + key: K, + value: UserPreferences[K] + ) => Promise; + resetPreferences: () => Promise; + isLoading: boolean; +} + +const PreferencesContext = createContext(undefined); + +export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [preferences, setPreferences] = useState(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 (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 ( + + {children} + + ); +}; + +export const usePreferences = (): PreferencesContextValue => { + const context = useContext(PreferencesContext); + if (!context) { + throw new Error('usePreferences must be used within a PreferencesProvider'); + } + return context; +}; diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 91b3c8d0c..d28e5ce77 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -257,6 +257,7 @@ export const useToolOperation = ( 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) { diff --git a/frontend/src/hooks/tools/shared/useToolResources.ts b/frontend/src/hooks/tools/shared/useToolResources.ts index 68e142128..852582e54 100644 --- a/frontend/src/hooks/tools/shared/useToolResources.ts +++ b/frontend/src/hooks/tools/shared/useToolResources.ts @@ -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([]); const addBlobUrl = useCallback((url: string) => { @@ -81,8 +83,13 @@ export const useToolResources = () => { return results; }, []); - const extractZipFiles = useCallback(async (zipBlob: Blob): Promise => { + const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise => { 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 => { try { diff --git a/frontend/src/services/indexedDBManager.ts b/frontend/src/services/indexedDBManager.ts index 0c63aec19..d88924518 100644 --- a/frontend/src/services/indexedDBManager.ts +++ b/frontend/src/services/indexedDBManager.ts @@ -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(); diff --git a/frontend/src/services/preferencesService.ts b/frontend/src/services/preferencesService.ts new file mode 100644 index 000000000..ae75e890a --- /dev/null +++ b/frontend/src/services/preferencesService.ts @@ -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 { + 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( + key: K + ): Promise { + 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( + key: K, + value: UserPreferences[K] + ): Promise { + 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 { + 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 = {}; + 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 { + 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(); diff --git a/frontend/src/services/zipFileService.ts b/frontend/src/services/zipFileService.ts index 36321ffa0..6ce56e729 100644 --- a/frontend/src/services/zipFileService.ts +++ b/frontend/src/services/zipFileService.ts @@ -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 { - 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) */