diff --git a/README.md b/README.md index 804346223..a75ae1046 100644 --- a/README.md +++ b/README.md @@ -122,11 +122,11 @@ Stirling-PDF currently supports 40 languages! | Catalan (Català) (ca_CA) | ![38%](https://geps.dev/progress/38) | | Croatian (Hrvatski) (hr_HR) | ![35%](https://geps.dev/progress/35) | | Czech (Česky) (cs_CZ) | ![39%](https://geps.dev/progress/39) | -| Danish (Dansk) (da_DK) | ![35%](https://geps.dev/progress/35) | +| Danish (Dansk) (da_DK) | ![34%](https://geps.dev/progress/34) | | Dutch (Nederlands) (nl_NL) | ![34%](https://geps.dev/progress/34) | | English (English) (en_GB) | ![100%](https://geps.dev/progress/100) | | English (US) (en_US) | ![100%](https://geps.dev/progress/100) | -| French (Français) (fr_FR) | ![96%](https://geps.dev/progress/96) | +| French (Français) (fr_FR) | ![95%](https://geps.dev/progress/95) | | German (Deutsch) (de_DE) | ![97%](https://geps.dev/progress/97) | | Greek (Ελληνικά) (el_GR) | ![39%](https://geps.dev/progress/39) | | Hindi (हिंदी) (hi_IN) | ![39%](https://geps.dev/progress/39) | @@ -136,14 +136,14 @@ Stirling-PDF currently supports 40 languages! | Italian (Italiano) (it_IT) | ![97%](https://geps.dev/progress/97) | | Japanese (日本語) (ja_JP) | ![72%](https://geps.dev/progress/72) | | Korean (한국어) (ko_KR) | ![39%](https://geps.dev/progress/39) | -| Norwegian (Norsk) (no_NB) | ![37%](https://geps.dev/progress/37) | +| Norwegian (Norsk) (no_NB) | ![36%](https://geps.dev/progress/36) | | Persian (فارسی) (fa_IR) | ![38%](https://geps.dev/progress/38) | | Polish (Polski) (pl_PL) | ![41%](https://geps.dev/progress/41) | | Portuguese (Português) (pt_PT) | ![39%](https://geps.dev/progress/39) | | Portuguese Brazilian (Português) (pt_BR) | ![97%](https://geps.dev/progress/97) | | Romanian (Română) (ro_RO) | ![33%](https://geps.dev/progress/33) | | Russian (Русский) (ru_RU) | ![96%](https://geps.dev/progress/96) | -| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![43%](https://geps.dev/progress/43) | +| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![42%](https://geps.dev/progress/42) | | Simplified Chinese (简体中文) (zh_CN) | ![98%](https://geps.dev/progress/98) | | Slovakian (Slovensky) (sk_SK) | ![29%](https://geps.dev/progress/29) | | Slovenian (Slovenščina) (sl_SI) | ![40%](https://geps.dev/progress/40) | diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 831344e19..2ae2338ed 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -50,6 +50,14 @@ const nodeGlobs = [ const __dirname = fileURLToPath(new URL('./', import.meta.url)); +const srcGlobs = [ + 'src/**/*.{js,mjs,jsx,ts,tsx}', +]; +const nodeGlobs = [ + 'scripts/**/*.{js,ts,mjs}', + '*.config.{js,ts,mjs}', +]; + export default defineConfig( { ignores: ignorePatterns }, @@ -224,5 +232,5 @@ export default defineConfig( 'react/jsx-uses-react': 'error', 'react/jsx-uses-vars': 'error', } - } + }, ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c9fb07988..0146da753 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,7 @@ "@tanstack/react-virtual": "^3.13.12", "autoprefixer": "^10.4.21", "axios": "^1.12.2", + "globals": "^16.4.0", "i18next": "^25.5.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", diff --git a/frontend/package.json b/frontend/package.json index d353341c7..5c88b5de8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,6 +37,7 @@ "@tanstack/react-virtual": "^3.13.12", "autoprefixer": "^10.4.21", "axios": "^1.12.2", + "globals": "^16.4.0", "i18next": "^25.5.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 71f0bb90b..76a498d2d 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1,9 +1,10 @@ { - "unsavedChanges": "You have unsaved changes to your PDF. What would you like to do?", + "unsavedChanges": "You have unsaved changes to your PDF.", + "areYouSure": "Are you sure you want to leave?", "unsavedChangesTitle": "Unsaved Changes", "keepWorking": "Keep Working", - "discardChanges": "Discard Changes", - "applyAndContinue": "Apply & Continue", + "discardChanges": "Discard & Leave", + "applyAndContinue": "Save & Leave", "exportAndContinue": "Export & Continue", "language": { "direction": "ltr" @@ -256,6 +257,16 @@ "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.", + "autoUnzipFileLimit": "Auto-unzip file limit", + "autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP", + "autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs." + }, "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.", @@ -3186,6 +3197,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..626eaab4f 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -3,7 +3,7 @@ import { Text, Center, Box, LoadingOverlay, Stack, Group } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext'; +import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext'; import { useNavigationActions } from '../../contexts/NavigationContext'; import { zipFileService } from '../../services/zipFileService'; import { detectFileExtension } from '../../utils/fileUtils'; @@ -37,6 +37,7 @@ const FileEditor = ({ // Use optimized FileContext hooks const { state, selectors } = useFileState(); const { addFiles, removeFiles, reorderFiles } = useFileManagement(); + const { actions } = useFileActions(); // Extract needed values from state (memoized to prevent infinite loops) const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]); @@ -309,6 +310,48 @@ 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 and store files using shared service method + const result = await zipFileService.extractAndStoreFilesWithHistory(file, record); + + if (result.success && result.extractedStubs.length > 0) { + // Add extracted file stubs to FileContext + await actions.addStirlingFileStubs(result.extractedStubs); + + // Remove the original ZIP file + removeFiles([fileId], false); + + alert({ + alertType: 'success', + title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`, + expandable: false, + durationMs: 3500 + }); + } else { + alert({ + alertType: 'error', + title: `Failed to extract files from ${file.name}`, + body: result.errors.join('\n'), + expandable: true, + 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, actions, removeFiles]); + const handleViewFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); if (record) { @@ -348,7 +391,7 @@ const FileEditor = ({ - + {activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? ( @@ -429,6 +472,7 @@ const FileEditor = ({ _onSetStatus={showStatus} onReorderFiles={handleReorderFiles} onDownloadFile={handleDownloadFile} + onUnzipFile={handleUnzipFile} toolMode={toolMode} isSupported={isFileSupported(record.name)} /> @@ -446,7 +490,7 @@ const FileEditor = ({ onSelectFiles={handleLoadFromStorage} /> - + ); 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 && ( + + )} +
- - - - - {onExportAndContinue && ( - - )} - + + {onApplyAndContinue && ( - )} + + {/* Mobile layout: centered stack of 4 buttons */} + + + + {onApplyAndContinue && ( + + )} + ); 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..07f71c484 --- /dev/null +++ b/frontend/src/components/shared/config/configSections/GeneralSection.tsx @@ -0,0 +1,89 @@ +import React, { useState, useEffect } from 'react'; +import { Paper, Stack, Switch, Text, Tooltip, NumberInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { usePreferences } from '../../../../contexts/PreferencesContext'; + +const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4; + +const GeneralSection: React.FC = () => { + const { t } = useTranslation(); + const { preferences, updatePreference } = usePreferences(); + const [fileLimitInput, setFileLimitInput] = useState(preferences.autoUnzipFileLimit); + + // Sync local state with preference changes + useEffect(() => { + setFileLimitInput(preferences.autoUnzipFileLimit); + }, [preferences.autoUnzipFileLimit]); + + 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)} + /> +
+
+ + +
+
+ + {t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')} + + + {t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')} + +
+ { + const numValue = Number(fileLimitInput); + const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue; + setFileLimitInput(finalValue); + updatePreference('autoUnzipFileLimit', finalValue); + }} + min={1} + max={100} + step={1} + disabled={!preferences.autoUnzip} + style={{ width: 90 }} + /> +
+
+
+
+
+ ); +}; + +export default GeneralSection; diff --git a/frontend/src/components/viewer/LocalEmbedPDF.tsx b/frontend/src/components/viewer/LocalEmbedPDF.tsx index c0ef68990..bc65c5557 100644 --- a/frontend/src/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/components/viewer/LocalEmbedPDF.tsx @@ -274,7 +274,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur e.preventDefault()} diff --git a/frontend/src/components/viewer/ZoomAPIBridge.tsx b/frontend/src/components/viewer/ZoomAPIBridge.tsx index fa47b1c8a..129cf00bd 100644 --- a/frontend/src/components/viewer/ZoomAPIBridge.tsx +++ b/frontend/src/components/viewer/ZoomAPIBridge.tsx @@ -12,30 +12,42 @@ export function ZoomAPIBridge() { // Set initial zoom once when plugin is ready useEffect(() => { - if (zoom && !hasSetInitialZoom.current) { - hasSetInitialZoom.current = true; - setTimeout(() => { - try { - zoom.requestZoom(1.4); - } catch (error) { - console.log('Zoom initialization delayed, viewport not ready:', error); - // Retry after a longer delay - setTimeout(() => { - try { - zoom.requestZoom(1.4); - } catch (retryError) { - console.log('Zoom initialization failed:', retryError); - } - }, 200); - } - }, 50); + if (!zoom || hasSetInitialZoom.current) { + return; } - }, [zoom]); + + let retryTimer: ReturnType | undefined; + const attemptInitialZoom = () => { + try { + zoom.requestZoom(1.4); + hasSetInitialZoom.current = true; + } catch (error) { + console.log('Zoom initialization delayed, viewport not ready:', error); + retryTimer = setTimeout(() => { + try { + zoom.requestZoom(1.4); + hasSetInitialZoom.current = true; + } catch (retryError) { + console.log('Zoom initialization failed:', retryError); + } + }, 200); + } + }; + + const timer = setTimeout(attemptInitialZoom, 50); + + return () => { + clearTimeout(timer); + if (retryTimer) { + clearTimeout(retryTimer); + } + }; + }, [zoom, zoomState]); useEffect(() => { if (zoom && zoomState) { // Update local state - const currentZoomLevel = zoomState.currentZoomLevel || 1.4; + const currentZoomLevel = zoomState.currentZoomLevel ?? 1.4; const newState = { currentZoom: currentZoomLevel, zoomPercent: Math.round(currentZoomLevel * 100), diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index fb82e9071..28a30fc20 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,30 @@ 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 and store files using shared service method + const result = await zipFileService.extractAndStoreFilesWithHistory(stirlingFile, file); + + if (result.success) { + // Refresh file manager to show new files + await refreshRecentFiles(); + } + + if (result.errors.length > 0) { + console.error('Errors during unzip:', result.errors); + } + } catch (error) { + console.error('Failed to unzip file:', error); + } + }, [refreshRecentFiles]); + // Cleanup blob URLs when component unmounts useEffect(() => { return () => { @@ -595,6 +621,7 @@ export const FileManagerProvider: React.FC = ({ onDownloadSingle: handleDownloadSingle, onToggleExpansion: handleToggleExpansion, onAddToRecents: handleAddToRecents, + onUnzipFile: handleUnzipFile, onNewFilesSelect, // External props @@ -627,6 +654,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..b5e8068f4 --- /dev/null +++ b/frontend/src/contexts/PreferencesContext.tsx @@ -0,0 +1,73 @@ +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]) => { + await preferencesService.setPreference(key, value); + setPreferences((prev) => ({ + ...prev, + [key]: value, + })); + }, + [] + ); + + const resetPreferences = useCallback(async () => { + await preferencesService.clearAllPreferences(); + setPreferences(DEFAULT_PREFERENCES); + }, []); + + 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/scannerImageSplit/useScannerImageSplitOperation.ts b/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts index 4ef0f99f6..65b4ba1c0 100644 --- a/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts +++ b/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts @@ -1,8 +1,9 @@ +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { ScannerImageSplitParameters, defaultParameters } from './useScannerImageSplitParameters'; -import { zipFileService } from '../../../services/zipFileService'; +import { useToolResources } from '../shared/useToolResources'; export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitParameters, file: File): FormData => { const formData = new FormData(); @@ -15,40 +16,46 @@ export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitPara return formData; }; -// Custom response handler to handle ZIP files that might be misidentified -const scannerImageSplitResponseHandler = async (responseData: Blob, inputFiles: File[]): Promise => { - try { - // Always try to extract as ZIP first, regardless of content-type - const extractionResult = await zipFileService.extractAllFiles(responseData); - if (extractionResult.success && extractionResult.extractedFiles.length > 0) { - return extractionResult.extractedFiles; - } - } catch (error) { - console.warn('Failed to extract as ZIP, treating as single file:', error); - } - - // Fallback: treat as single file (PNG image) - const inputFileName = inputFiles[0]?.name || 'document'; - const baseFileName = inputFileName.replace(/\.[^.]+$/, ''); - const singleFile = new File([responseData], `${baseFileName}.png`, { type: 'image/png' }); - return [singleFile]; -}; - +// Static configuration object export const scannerImageSplitOperationConfig = { toolType: ToolType.singleFile, buildFormData: buildScannerImageSplitFormData, operationType: 'scannerImageSplit', endpoint: '/api/v1/misc/extract-image-scans', - multiFileEndpoint: false, - responseHandler: scannerImageSplitResponseHandler, defaultParameters, } as const; export const useScannerImageSplitOperation = () => { const { t } = useTranslation(); + const { extractAllZipFiles } = useToolResources(); - return useToolOperation({ + // Custom response handler that extracts ZIP files containing images + // Can't add to exported config because it requires access to the hook so must be part of the hook + const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise => { + try { + // Scanner image split returns ZIP files with multiple images + const extractedFiles = await extractAllZipFiles(blob); + + // If extraction succeeded and returned files, use them + if (extractedFiles.length > 0) { + return extractedFiles; + } + } catch (error) { + console.warn('Failed to extract as ZIP, treating as single file:', error); + } + + // Fallback: treat as single file (PNG image) + const inputFileName = originalFiles[0]?.name || 'document'; + const baseFileName = inputFileName.replace(/\.[^.]+$/, ''); + const singleFile = new File([blob], `${baseFileName}.png`, { type: 'image/png' }); + return [singleFile]; + }, [extractAllZipFiles]); + + const config: ToolOperationConfig = { ...scannerImageSplitOperationConfig, + responseHandler, getErrorMessage: createStandardErrorHandler(t('scannerImageSplit.error.failed', 'An error occurred while extracting image scans.')) - }); + }; + + return useToolOperation(config); }; \ No newline at end of file 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..366730885 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,20 @@ export const useToolResources = () => { return results; }, []); - const extractZipFiles = useCallback(async (zipBlob: Blob): Promise => { + const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise => { try { + // Check if we should extract based on preferences + const shouldExtract = await zipFileService.shouldUnzip( + zipBlob, + preferences.autoUnzip, + preferences.autoUnzipFileLimit, + skipAutoUnzip + ); + + if (!shouldExtract) { + 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,32 +104,30 @@ export const useToolResources = () => { console.error('useToolResources.extractZipFiles - Error:', error); return []; } - }, []); + }, [preferences.autoUnzip, preferences.autoUnzipFileLimit]); - const extractAllZipFiles = useCallback(async (zipBlob: Blob): Promise => { + const extractAllZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise => { try { - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); + // Check if we should extract based on preferences + const shouldExtract = await zipFileService.shouldUnzip( + zipBlob, + preferences.autoUnzip, + preferences.autoUnzipFileLimit, + skipAutoUnzip + ); - const arrayBuffer = await zipBlob.arrayBuffer(); - const zipContent = await zip.loadAsync(arrayBuffer); - - const extractedFiles: File[] = []; - - for (const [filename, file] of Object.entries(zipContent.files)) { - if (!file.dir) { - const content = await file.async('blob'); - const extractedFile = new File([content], filename, { type: 'application/pdf' }); - extractedFiles.push(extractedFile); - } + if (!shouldExtract) { + return [new File([zipBlob], 'result.zip', { type: 'application/zip' })]; } - return extractedFiles; + const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' }); + const extractionResult = await zipFileService.extractAllFiles(zipFile); + return extractionResult.success ? extractionResult.extractedFiles : []; } catch (error) { - console.error('Error in extractAllZipFiles:', error); + console.error('useToolResources.extractAllZipFiles - Error:', error); return []; } - }, []); + }, [preferences.autoUnzip, preferences.autoUnzipFileLimit]); const createDownloadInfo = useCallback(async ( files: File[], 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..b7c22094e --- /dev/null +++ b/frontend/src/services/preferencesService.ts @@ -0,0 +1,129 @@ +import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager'; + +export interface UserPreferences { + autoUnzip: boolean; + autoUnzipFileLimit: number; +} + +export const DEFAULT_PREFERENCES: UserPreferences = { + autoUnzip: true, + autoUnzipFileLimit: 4, +}; + +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) => { + 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) => { + 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..45ec39219 100644 --- a/frontend/src/services/zipFileService.ts +++ b/frontend/src/services/zipFileService.ts @@ -1,4 +1,7 @@ import JSZip, { JSZipObject } from 'jszip'; +import { StirlingFileStub, createStirlingFile } from '../types/fileContext'; +import { generateThumbnailForFile } from '../utils/thumbnailUtils'; +import { fileStorage } from './fileStorage'; // Undocumented interface in JSZip for JSZipObject._data interface CompressedObject { @@ -41,6 +44,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 +250,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 */ @@ -309,33 +325,44 @@ export class ZipFileService { } /** - * Get file extension from filename + * Determine if a ZIP file should be extracted based on user preferences + * + * @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 */ - private getFileExtension(filename: string): string { - return filename.substring(filename.lastIndexOf('.')).toLowerCase(); - } - - /** - * Check if ZIP file contains password protection - */ - private async isPasswordProtected(file: File): Promise { + async shouldUnzip( + zipBlob: Blob | File, + autoUnzip: boolean, + autoUnzipFileLimit: number, + skipAutoUnzip: boolean = false + ): 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 - } + // Automation always extracts + if (skipAutoUnzip) { + return true; } - return false; // JSZip will throw an error if password is required + // Check if auto-unzip is enabled + if (!autoUnzip) { + return false; + } + + // Load ZIP and count files + const zip = new JSZip(); + const zipContents = await zip.loadAsync(zipBlob); + + // Count non-directory entries + const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length; + + // Only extract if within limit + return fileCount <= autoUnzipFileLimit; } 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'); + console.error('Error checking shouldUnzip:', error); + // On error, default to not extracting (safer) + return false; } } @@ -457,6 +484,79 @@ export class ZipFileService { return mimeTypes[ext || ''] || 'application/octet-stream'; } + + /** + * Extract PDF files from ZIP and store them in IndexedDB with preserved history metadata + * Used by both FileManager and FileEditor to avoid code duplication + * + * @param zipFile - The ZIP file to extract from + * @param zipStub - The StirlingFileStub for the ZIP (contains metadata to preserve) + * @returns Object with success status, extracted stubs, and any errors + */ + async extractAndStoreFilesWithHistory( + zipFile: File, + zipStub: StirlingFileStub + ): Promise<{ success: boolean; extractedStubs: StirlingFileStub[]; errors: string[] }> { + const result = { + success: false, + extractedStubs: [] as StirlingFileStub[], + errors: [] as string[] + }; + + try { + // Extract PDF files from ZIP + const extractionResult = await this.extractPdfFiles(zipFile); + + if (!extractionResult.success || extractionResult.extractedFiles.length === 0) { + result.errors = extractionResult.errors; + return result; + } + + // Process each extracted file + for (const extractedFile of extractionResult.extractedFiles) { + try { + // Generate thumbnail + const thumbnail = await generateThumbnailForFile(extractedFile); + + // Create StirlingFile + const newStirlingFile = createStirlingFile(extractedFile); + + // Create StirlingFileStub with ZIP's history metadata + const stub: StirlingFileStub = { + id: newStirlingFile.fileId, + name: extractedFile.name, + size: extractedFile.size, + type: extractedFile.type, + lastModified: extractedFile.lastModified, + quickKey: newStirlingFile.quickKey, + createdAt: Date.now(), + isLeaf: true, + // Preserve ZIP's history - unzipping is NOT a tool operation + originalFileId: zipStub.originalFileId, + parentFileId: zipStub.parentFileId, + versionNumber: zipStub.versionNumber, + toolHistory: zipStub.toolHistory || [], + thumbnailUrl: thumbnail + }; + + // Store in IndexedDB + await fileStorage.storeStirlingFile(newStirlingFile, stub); + + result.extractedStubs.push(stub); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`Failed to process "${extractedFile.name}": ${errorMessage}`); + } + } + + result.success = result.extractedStubs.length > 0; + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`Failed to extract ZIP file: ${errorMessage}`); + return result; + } + } } // Export singleton instance diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx index a7a6cda06..1b8c437f1 100644 --- a/frontend/src/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx @@ -15,6 +15,7 @@ import { renderHook, act } from '@testing-library/react'; import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation'; import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameters'; import { FileContextProvider } from '../../contexts/FileContext'; +import { PreferencesProvider } from '../../contexts/PreferencesContext'; import { I18nextProvider } from 'react-i18next'; import i18n from '../../i18n/config'; import { createTestStirlingFile } from '../utils/testFileHelpers'; @@ -88,9 +89,11 @@ const createPDFFile = (): StirlingFile => { // Test wrapper component const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - {children} - + + + {children} + + ); diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx index cf28da955..45cd461e8 100644 --- a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -9,6 +9,7 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation'; import { useConvertParameters } from '../../hooks/tools/convert/useConvertParameters'; import { FileContextProvider } from '../../contexts/FileContext'; +import { PreferencesProvider } from '../../contexts/PreferencesContext'; import { I18nextProvider } from 'react-i18next'; import i18n from '../../i18n/config'; import { detectFileExtension } from '../../utils/fileUtils'; @@ -76,9 +77,11 @@ vi.mock('../../services/thumbnailGenerationService', () => ({ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - {children} - + + + {children} + + );