Files
Stirling-PDF/frontend/src/core/contexts/FileContext.tsx
T
2026-04-21 18:47:23 +01:00

757 lines
22 KiB
TypeScript

/**
* 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<Map<FileId, File>>(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<FileLifecycleManager | null>(null);
if (!lifecycleManagerRef.current) {
lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch);
}
const lifecycleManager = lifecycleManagerRef.current;
const { t } = useTranslation();
const [encryptedQueue, setEncryptedQueue] = useState<FileId[]>([]);
const [activeEncryptedFileId, setActiveEncryptedFileId] =
useState<FileId | null>(null);
const [unlockPassword, setUnlockPassword] = useState("");
const [unlockError, setUnlockError] = useState<string | null>(null);
const [isUnlocking, setIsUnlocking] = useState(false);
const dismissedEncryptedFilesRef = useRef<Set<FileId>>(new Set());
const observedFileIdsRef = useRef<Set<FileId>>(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<FileId>(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<FileContextSelectors>(
() => 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<StirlingFile[]> => {
const stirlingFiles = await addFiles(
{
files,
...options,
// For direct file uploads: ALWAYS unzip (except HTML ZIPs)
// skipAutoUnzip bypasses preference checks - HTML detection still applies
skipAutoUnzip: true,
// 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<boolean>;
allowDuplicates?: boolean;
},
): Promise<StirlingFile[]> => {
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<StirlingFile[]> => {
// 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<FileId[]> => {
return consumeFiles(
inputFileIds,
outputStirlingFiles,
outputStirlingFileStubs,
filesRef,
dispatch,
);
},
[],
);
const runAutomaticPasswordRemoval = useCallback(
async (fileId: FileId, password: string): Promise<void> => {
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 handleUnlockAll = useCallback(async () => {
if (!activeEncryptedFileId) return;
const pw = unlockPassword.trim();
if (!pw) {
setUnlockError(
t("encryptedPdfUnlock.required", "Enter the password to continue."),
);
return;
}
setIsUnlocking(true);
setUnlockError(null);
const allIds = [activeEncryptedFileId, ...encryptedQueue];
let successCount = 0;
const failedIds: FileId[] = [];
for (const fileId of allIds) {
try {
await runAutomaticPasswordRemoval(fileId, pw);
dismissedEncryptedFilesRef.current.delete(fileId);
successCount++;
} catch {
failedIds.push(fileId);
}
}
if (successCount > 0) {
alert({
alertType: "success",
title: t("encryptedPdfUnlock.successTitle", "Password removed"),
body: t("encryptedPdfUnlock.unlockAllSuccess", {
defaultValue: "Unlocked {{count}} file(s).",
count: successCount,
}),
expandable: false,
isPersistentPopup: false,
});
}
if (failedIds.length > 0) {
const failedNames = failedIds.map(
(id) => stateRef.current.files.byId[id]?.name ?? id,
);
setUnlockError(
t("encryptedPdfUnlock.unlockAllPartialFail", {
defaultValue: "Wrong password for: {{names}}",
names: failedNames.join(", "),
}),
);
setEncryptedQueue(failedIds.slice(1));
setActiveEncryptedFileId(failedIds[0]);
} else {
setEncryptedQueue([]);
setActiveEncryptedFileId(null);
}
setIsUnlocking(false);
}, [
activeEncryptedFileId,
encryptedQueue,
unlockPassword,
runAutomaticPasswordRemoval,
t,
]);
const undoConsumeFilesWrapper = useCallback(
async (
inputFiles: File[],
inputStirlingFileStubs: StirlingFileStub[],
outputFileIds: FileId[],
): Promise<void> => {
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<FileContextActions>(
() => ({
...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<StirlingFileStub>,
) => 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<FileContextStateValue>(
() => ({
state,
selectors,
}),
[state, selectors],
);
const actionsValue = useMemo<FileContextActionsValue>(
() => ({
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 (
<FileStateContext.Provider value={stateValue}>
<FileActionsContext.Provider value={actionsValue}>
{children}
<ZipWarningModal
opened={confirmationState.opened}
onConfirm={handleConfirm}
onCancel={handleCancel}
fileCount={confirmationState.fileCount}
zipFileName={confirmationState.fileName}
/>
<EncryptedPdfUnlockModal
opened={isUnlockModalOpen}
fileName={activeEncryptedStub?.name}
password={unlockPassword}
errorMessage={unlockError}
isProcessing={isUnlocking}
remainingCount={encryptedQueue.length}
onPasswordChange={setUnlockPassword}
onUnlock={handleUnlockSubmit}
onUnlockAll={handleUnlockAll}
onSkip={handleUnlockSkip}
/>
</FileActionsContext.Provider>
</FileStateContext.Provider>
);
}
// Outer provider component that wraps with IndexedDBProvider
export function FileContextProvider({
children,
enableUrlSync = true,
enablePersistence = true,
}: FileContextProviderProps) {
if (enablePersistence) {
return (
<IndexedDBProvider>
<FileContextInner
enableUrlSync={enableUrlSync}
enablePersistence={enablePersistence}
>
{children}
</FileContextInner>
</IndexedDBProvider>
);
} else {
return (
<FileContextInner
enableUrlSync={enableUrlSync}
enablePersistence={enablePersistence}
>
{children}
</FileContextInner>
);
}
}
// 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";