/** * FileContext - Manages PDF files for Stirling PDF multi-tool workflow * * Handles file state, memory management, and resource cleanup for large PDFs (up to 100GB+). * Users upload PDFs once and chain tools (split → merge → compress → view) without reloading. * * Key hooks: * - useFileState() - access file state and UI state * - useFileActions() - file operations (add/remove/update) * - useFileSelection() - for file selection state and actions * * Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation). */ import { useReducer, useCallback, useEffect, useRef, useMemo, useState } from 'react'; import { FileContextProviderProps, FileContextSelectors, FileContextStateValue, FileContextActionsValue, FileContextActions, FileId, StirlingFileStub, StirlingFile, createStirlingFile, } from '@app/types/fileContext'; // Import modular components import { fileContextReducer, initialFileContextState } from '@app/contexts/file/FileReducer'; import { createFileSelectors } from '@app/contexts/file/fileSelectors'; import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions, createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions'; import { FileLifecycleManager } from '@app/contexts/file/lifecycle'; import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts'; import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext'; import { useZipConfirmation } from '@app/hooks/useZipConfirmation'; import ZipWarningModal from '@app/components/shared/ZipWarningModal'; import EncryptedPdfUnlockModal from '@app/components/shared/EncryptedPdfUnlockModal'; import { useTranslation } from 'react-i18next'; import { alert } from '@app/components/toast'; import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData'; import type { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters'; import apiClient from '@app/services/apiClient'; import { processResponse } from '@app/utils/toolResponseProcessor'; import { ToolOperation } from '@app/types/file'; import { handlePasswordError } from '@app/utils/toolErrorHandler'; const DEBUG = process.env.NODE_ENV === 'development'; // Inner provider component that has access to IndexedDB function FileContextInner({ children, enablePersistence = true }: FileContextProviderProps) { const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState); // Always call the hook unconditionally to satisfy React's rules of hooks. // IndexedDB context is only used when enablePersistence is true. const indexedDBValue = useIndexedDB(); const indexedDB = enablePersistence ? indexedDBValue : null; // File ref map - stores File objects outside React state const filesRef = useRef>(new Map()); // Stable state reference for selectors const stateRef = useRef(state); stateRef.current = state; // ZIP confirmation dialog const { confirmationState, requestConfirmation, handleConfirm, handleCancel } = useZipConfirmation(); // Create lifecycle manager const lifecycleManagerRef = useRef(null); if (!lifecycleManagerRef.current) { lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch); } const lifecycleManager = lifecycleManagerRef.current; const { t } = useTranslation(); const [encryptedQueue, setEncryptedQueue] = useState([]); const [activeEncryptedFileId, setActiveEncryptedFileId] = useState(null); const [unlockPassword, setUnlockPassword] = useState(''); const [unlockError, setUnlockError] = useState(null); const [isUnlocking, setIsUnlocking] = useState(false); const dismissedEncryptedFilesRef = useRef>(new Set()); const observedFileIdsRef = useRef>(new Set()); const enqueueEncryptedFiles = useCallback((fileIds: FileId[]) => { if (fileIds.length === 0) return; setEncryptedQueue(prevQueue => { const existing = new Set(prevQueue); const next = [...prevQueue]; for (const id of fileIds) { if (dismissedEncryptedFilesRef.current.has(id)) continue; if (id === activeEncryptedFileId) continue; if (existing.has(id)) continue; existing.add(id); next.push(id); } return next; }); }, [activeEncryptedFileId]); useEffect(() => { const previousIds = observedFileIdsRef.current; const nextIds = new Set(state.files.ids); const newEncryptedIds: FileId[] = []; for (const id of state.files.ids) { if (!previousIds.has(id)) { const stub = state.files.byId[id]; if ((stub?.versionNumber ?? 1) <= 1 && stub?.processedFile?.isEncrypted) { newEncryptedIds.push(id); } } } if (newEncryptedIds.length > 0) { enqueueEncryptedFiles(newEncryptedIds); } observedFileIdsRef.current = nextIds; }, [state.files.ids, state.files.byId, enqueueEncryptedFiles]); useEffect(() => { if (!activeEncryptedFileId && encryptedQueue.length > 0) { setActiveEncryptedFileId(encryptedQueue[0]); setEncryptedQueue(prev => prev.slice(1)); } }, [activeEncryptedFileId, encryptedQueue]); useEffect(() => { if (activeEncryptedFileId && !state.files.ids.includes(activeEncryptedFileId)) { setActiveEncryptedFileId(null); } }, [activeEncryptedFileId, state.files.ids]); useEffect(() => { setUnlockPassword(''); setUnlockError(null); }, [activeEncryptedFileId]); const handleUnlockSkip = useCallback(() => { if (activeEncryptedFileId) { dismissedEncryptedFilesRef.current.add(activeEncryptedFileId); } setActiveEncryptedFileId(null); }, [activeEncryptedFileId]); const promptEncryptedUnlock = useCallback((fileId: FileId) => { const stub = stateRef.current.files.byId[fileId]; if (!stub?.processedFile?.isEncrypted) { return; } dismissedEncryptedFilesRef.current.delete(fileId); setEncryptedQueue(prevQueue => prevQueue.filter(id => id !== fileId)); setActiveEncryptedFileId(currentActiveId => { if (currentActiveId && currentActiveId !== fileId) { setEncryptedQueue(prevQueue => { const withoutDuplicates = prevQueue.filter(id => id !== currentActiveId && id !== fileId); return [currentActiveId, ...withoutDuplicates]; }); } return fileId; }); }, []); // Create stable selectors (memoized once to avoid re-renders) const selectors = useMemo(() => createFileSelectors(stateRef, filesRef), [] // Empty deps - selectors are stable ); // Navigation management removed - moved to NavigationContext // Navigation guard system functions const setHasUnsavedChanges = useCallback((hasChanges: boolean) => { dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); }, []); const selectFiles = (stirlingFiles: StirlingFile[]) => { const currentSelection = stateRef.current.ui.selectedFileIds; const newFileIds = stirlingFiles.map(stirlingFile => stirlingFile.fileId); dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds: [...currentSelection, ...newFileIds] } }); }; // File operations using unified addFiles helper with persistence const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean; skipAutoUnzip?: boolean }): Promise => { 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, // Provide confirmation callback for large ZIP files confirmLargeExtraction: requestConfirmation }, stateRef, filesRef, dispatch, lifecycleManager, enablePersistence ); // Auto-select the newly added files if requested if (options?.selectFiles && stirlingFiles.length > 0) { selectFiles(stirlingFiles); } return stirlingFiles; }, [enablePersistence, requestConfirmation]); const addFilesWithOptions = useCallback( async ( files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean; autoUnzip?: boolean; autoUnzipFileLimit?: number; skipAutoUnzip?: boolean; confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise; allowDuplicates?: boolean; } ): Promise => { const stirlingFiles = await addFiles( { files, ...options, }, stateRef, filesRef, dispatch, lifecycleManager, enablePersistence ); if (options?.selectFiles && stirlingFiles.length > 0) { selectFiles(stirlingFiles); } return stirlingFiles; }, [enablePersistence] ); const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise => { // StirlingFileStubs preserve all metadata - perfect for FileManager use case! const result = await addStirlingFileStubs(stirlingFileStubs, options, stateRef, filesRef, dispatch, lifecycleManager); // Auto-select the newly added files if requested if (options?.selectFiles && result.length > 0) { selectFiles(result); } return result; }, []); // Action creators const baseActions = useMemo(() => createFileActions(dispatch), []); // Helper functions for pinned files const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]): Promise => { return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch); }, []); const runAutomaticPasswordRemoval = useCallback(async (fileId: FileId, password: string): Promise => { const file = filesRef.current.get(fileId); const parentStub = stateRef.current.files.byId[fileId]; if (!file || !parentStub) { throw new Error(t('encryptedPdfUnlock.missingFile', 'The selected file is no longer available.')); } const params: RemovePasswordParameters = { password }; const formData = buildRemovePasswordFormData(params, file); const response = await apiClient.post('/api/v1/security/remove-password', formData, { responseType: 'blob', suppressErrorToast: true // Handle errors in modal UI instead of toast }); const responseFiles = await processResponse(response.data, [file]); const unlockedFile = responseFiles[0]; if (!unlockedFile) { throw new Error(t('encryptedPdfUnlock.emptyResponse', 'Password removal did not produce a file.')); } const processedMetadata = await generateProcessedFileMetadata(unlockedFile); const thumbnail = processedMetadata?.thumbnailUrl; const operation: ToolOperation = { toolId: 'removePassword', timestamp: Date.now() }; const childStub = createChildStub(parentStub, operation, unlockedFile, thumbnail, processedMetadata); const stirlingUnlockedFile = createStirlingFile(unlockedFile, childStub.id); await consumeFilesWrapper([fileId], [stirlingUnlockedFile], [childStub]); }, [consumeFilesWrapper, t]); const handleUnlockSubmit = useCallback(async () => { if (!activeEncryptedFileId) return; if (!unlockPassword.trim()) { setUnlockError(t('encryptedPdfUnlock.required', 'Enter the password to continue.')); return; } setIsUnlocking(true); setUnlockError(null); try { await runAutomaticPasswordRemoval(activeEncryptedFileId, unlockPassword.trim()); const fileName = stateRef.current.files.byId[activeEncryptedFileId]?.name; alert({ alertType: 'success', title: t('encryptedPdfUnlock.successTitle', 'Password removed'), body: fileName ? t('encryptedPdfUnlock.successBodyWithName', { defaultValue: 'Removed password from {{fileName}}', fileName, }) : t('encryptedPdfUnlock.successBody', 'Password removed successfully.'), expandable: false, isPersistentPopup: false, }); dismissedEncryptedFilesRef.current.delete(activeEncryptedFileId); setActiveEncryptedFileId(null); } catch (error) { const errorMessage = await handlePasswordError( error, t('encryptedPdfUnlock.incorrectPassword', 'Incorrect password'), t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.') ); setUnlockError(errorMessage); } finally { setIsUnlocking(false); } }, [activeEncryptedFileId, unlockPassword, runAutomaticPasswordRemoval, t]); const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise => { return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB); }, [indexedDB]); // File pinning functions - use StirlingFile directly const pinFileWrapper = useCallback((file: StirlingFile) => { baseActions.pinFile(file.fileId); }, [baseActions]); const unpinFileWrapper = useCallback((file: StirlingFile) => { baseActions.unpinFile(file.fileId); }, [baseActions]); // Complete actions object const actions = useMemo(() => ({ ...baseActions, addFiles: addRawFiles, addFilesWithOptions, addStirlingFileStubs: addStirlingFileStubsAction, removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => { // Remove from memory and cleanup resources lifecycleManager.removeFiles(fileIds, stateRef); // Remove from IndexedDB if enabled if (indexedDB && enablePersistence && deleteFromStorage !== false) { try { await indexedDB.deleteMultiple(fileIds); } catch (error) { console.error('Failed to delete files from IndexedDB:', error); } } }, updateStirlingFileStub: (fileId: FileId, updates: Partial) => lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef), reorderFiles: (orderedFileIds: FileId[]) => { dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } }); }, clearAllFiles: async () => { lifecycleManager.cleanupAllFiles(); filesRef.current.clear(); dispatch({ type: 'RESET_CONTEXT' }); // Don't clear IndexedDB automatically - only clear in-memory state // IndexedDB should only be cleared when explicitly requested by user }, clearAllData: async () => { // First clear all files from memory lifecycleManager.cleanupAllFiles(); filesRef.current.clear(); dispatch({ type: 'RESET_CONTEXT' }); // Then clear IndexedDB storage if (indexedDB && enablePersistence) { try { await indexedDB.clearAll(); } catch (error) { console.error('Failed to clear IndexedDB:', error); } } }, // Pinned files functionality with File object wrappers pinFile: pinFileWrapper, unpinFile: unpinFileWrapper, consumeFiles: consumeFilesWrapper, undoConsumeFiles: undoConsumeFilesWrapper, setHasUnsavedChanges, trackBlobUrl: lifecycleManager.trackBlobUrl, cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef), scheduleCleanup: (fileId: FileId, delay?: number) => lifecycleManager.scheduleCleanup(fileId, delay, stateRef), openEncryptedUnlockPrompt: promptEncryptedUnlock }), [ baseActions, addRawFiles, addStirlingFileStubsAction, lifecycleManager, setHasUnsavedChanges, consumeFilesWrapper, undoConsumeFilesWrapper, pinFileWrapper, unpinFileWrapper, indexedDB, enablePersistence, promptEncryptedUnlock ]); // Split context values to minimize re-renders const stateValue = useMemo(() => ({ state, selectors }), [state, selectors]); const actionsValue = useMemo(() => ({ actions, dispatch }), [actions]); const activeEncryptedStub = activeEncryptedFileId ? state.files.byId[activeEncryptedFileId] : undefined; const isUnlockModalOpen = Boolean(activeEncryptedFileId && activeEncryptedStub); // Persistence loading disabled - files only loaded on explicit user action // useEffect(() => { // if (!enablePersistence || !indexedDB) return; // const loadFromPersistence = async () => { /* loading logic removed */ }; // loadFromPersistence(); // }, [enablePersistence, indexedDB]); // Cleanup on unmount useEffect(() => { return () => { if (DEBUG) console.log('FileContext unmounting - cleaning up all resources'); lifecycleManager.destroy(); }; }, [lifecycleManager]); return ( {children} ); } // Outer provider component that wraps with IndexedDBProvider export function FileContextProvider({ children, enableUrlSync = true, enablePersistence = true }: FileContextProviderProps) { if (enablePersistence) { return ( {children} ); } else { return ( {children} ); } } // Export all hooks from the fileHooks module export { useFileState, useFileActions, useCurrentFile, useFileSelection, useFileManagement, useFileUI, useStirlingFileStub, useAllFiles, useSelectedFiles, // Primary API hooks for tools useFileContext } from '@app/contexts/file/fileHooks';