757 lines
22 KiB
TypeScript
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";
|