This commit is contained in:
Reece Browne 2025-09-03 14:35:50 +01:00
parent dad9f20879
commit 564b14e3e2
32 changed files with 944 additions and 810 deletions

View File

@ -13,9 +13,11 @@
"Bash(npx tsc:*)", "Bash(npx tsc:*)",
"Bash(node:*)", "Bash(node:*)",
"Bash(npm run dev:*)", "Bash(npm run dev:*)",
"Bash(sed:*)" "Bash(sed:*)",
"Bash(npm run typecheck:*)",
"Bash(git checkout:*)"
], ],
"deny": [], "deny": [],
"defaultMode": "acceptEdits" "defaultMode": "acceptEdits"
} }
} }

View File

@ -41,6 +41,7 @@
"prebuild": "npm run generate-icons", "prebuild": "npm run generate-icons",
"build": "npx tsc --noEmit && vite build", "build": "npx tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit",
"generate-licenses": "node scripts/generate-licenses.js", "generate-licenses": "node scripts/generate-licenses.js",
"generate-icons": "node scripts/generate-icons.js", "generate-icons": "node scripts/generate-icons.js",
"generate-icons:verbose": "node scripts/generate-icons.js --verbose", "generate-icons:verbose": "node scripts/generate-icons.js --verbose",

View File

@ -41,6 +41,9 @@
"save": "Save", "save": "Save",
"saveToBrowser": "Save to Browser", "saveToBrowser": "Save to Browser",
"download": "Download", "download": "Download",
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
"undo": "Undo",
"moreOptions": "More Options",
"editYourNewFiles": "Edit your new file(s)", "editYourNewFiles": "Edit your new file(s)",
"close": "Close", "close": "Close",
"fileSelected": "Selected: {{filename}}", "fileSelected": "Selected: {{filename}}",

View File

@ -350,9 +350,9 @@ const FileEditor = ({
if (record) { if (record) {
// Set the file as selected in context and switch to viewer for preview // Set the file as selected in context and switch to viewer for preview
setSelectedFiles([fileId as FileId]); setSelectedFiles([fileId as FileId]);
navActions.setMode('viewer'); navActions.setWorkbench('viewer');
} }
}, [activeFileRecords, setSelectedFiles, navActions.setMode]); }, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
const handleMergeFromHere = useCallback((fileId: string) => { const handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = activeFileRecords.findIndex(r => r.id === fileId); const startIndex = activeFileRecords.findIndex(r => r.id === fileId);

View File

@ -1,27 +1,48 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef, useState } from "react";
import { Button, Stack, Text } from '@mantine/core'; import { Button, Group, Stack } from "@mantine/core";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
import DownloadIcon from '@mui/icons-material/Download'; import DownloadIcon from "@mui/icons-material/Download";
import ErrorNotification from './ErrorNotification'; import UndoIcon from "@mui/icons-material/Undo";
import ResultsPreview from './ResultsPreview'; import ErrorNotification from "./ErrorNotification";
import { SuggestedToolsSection } from './SuggestedToolsSection'; import ResultsPreview from "./ResultsPreview";
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation'; import { SuggestedToolsSection } from "./SuggestedToolsSection";
import { ToolOperationHook } from "../../../hooks/tools/shared/useToolOperation";
import { Tooltip } from "../../shared/Tooltip";
export interface ReviewToolStepProps<TParams = unknown> { export interface ReviewToolStepProps<TParams = unknown> {
isVisible: boolean; isVisible: boolean;
operation: ToolOperationHook<TParams>; operation: ToolOperationHook<TParams>;
title?: string; title?: string;
onFileClick?: (file: File) => void; onFileClick?: (file: File) => void;
onUndo: () => void;
} }
function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { operation: ToolOperationHook<TParams>; onFileClick?: (file: File) => void }) { function ReviewStepContent<TParams = unknown>({
operation,
onFileClick,
onUndo,
}: {
operation: ToolOperationHook<TParams>;
onFileClick?: (file: File) => void;
onUndo: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const stepRef = useRef<HTMLDivElement>(null); const stepRef = useRef<HTMLDivElement>(null);
const previewFiles = operation.files?.map((file, index) => ({ const handleUndo = async () => {
file, try {
thumbnail: operation.thumbnails[index] onUndo();
})) || []; } catch (error) {
// Error is already handled by useToolOperation, just reset loading state
console.error("Undo operation failed:", error);
}
};
const previewFiles =
operation.files?.map((file, index) => ({
file,
thumbnail: operation.thumbnails[index],
})) || [];
// Auto-scroll to bottom when content appears // Auto-scroll to bottom when content appears
useEffect(() => { useEffect(() => {
@ -31,7 +52,7 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
setTimeout(() => { setTimeout(() => {
scrollableContainer.scrollTo({ scrollableContainer.scrollTo({
top: scrollableContainer.scrollHeight, top: scrollableContainer.scrollHeight,
behavior: 'smooth' behavior: "smooth",
}); });
}, 100); // Small delay to ensure content is rendered }, 100); // Small delay to ensure content is rendered
} }
@ -40,10 +61,7 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
return ( return (
<Stack gap="sm" ref={stepRef}> <Stack gap="sm" ref={stepRef}>
<ErrorNotification <ErrorNotification error={operation.errorMessage} onClose={operation.clearError} />
error={operation.errorMessage}
onClose={operation.clearError}
/>
{previewFiles.length > 0 && ( {previewFiles.length > 0 && (
<ResultsPreview <ResultsPreview
@ -53,7 +71,18 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
/> />
)} )}
{operation.downloadUrl && ( <Tooltip content={t("undoOperationTooltip", "Click to undo the last operation and restore the original files")}>
<Button
leftSection={<UndoIcon />}
variant="outline"
color="var(--mantine-color-gray-6)"
onClick={handleUndo}
fullWidth
>
{t("undo", "Undo")}
</Button>
</Tooltip>
{operation.downloadUrl && (
<Button <Button
component="a" component="a"
href={operation.downloadUrl} href={operation.downloadUrl}
@ -78,14 +107,13 @@ export function createReviewToolStep<TParams = unknown>(
): React.ReactElement { ): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
return createStep(t("review", "Review"), { return createStep(
isVisible: props.isVisible, t("review", "Review"),
_excludeFromCount: true, {
_noPadding: true isVisible: props.isVisible,
}, ( _excludeFromCount: true,
<ReviewStepContent _noPadding: true,
operation={props.operation} },
onFileClick={props.onFileClick} <ReviewStepContent operation={props.operation} onFileClick={props.onFileClick} onUndo={props.onUndo} />
/> );
));
} }

View File

@ -44,6 +44,7 @@ export interface ReviewStepConfig {
operation: ToolOperationHook<any>; operation: ToolOperationHook<any>;
title: string; title: string;
onFileClick?: (file: File) => void; onFileClick?: (file: File) => void;
onUndo: () => void;
testId?: string; testId?: string;
} }
@ -106,7 +107,8 @@ export function createToolFlow(config: ToolFlowConfig) {
isVisible: config.review.isVisible, isVisible: config.review.isVisible,
operation: config.review.operation, operation: config.review.operation,
title: config.review.title, title: config.review.title,
onFileClick: config.review.onFileClick onFileClick: config.review.onFileClick,
onUndo: config.review.onUndo
})} })}
</ToolStepProvider> </ToolStepProvider>
</Stack> </Stack>

View File

@ -1,14 +1,14 @@
/** /**
* FileContext - Manages PDF files for Stirling PDF multi-tool workflow * FileContext - Manages PDF files for Stirling PDF multi-tool workflow
* *
* Handles file state, memory management, and resource cleanup for large PDFs (up to 100GB+). * 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. * Users upload PDFs once and chain tools (split merge compress view) without reloading.
* *
* Key hooks: * Key hooks:
* - useFileState() - access file state and UI state * - useFileState() - access file state and UI state
* - useFileActions() - file operations (add/remove/update) * - useFileActions() - file operations (add/remove/update)
* - useFileSelection() - for file selection state and actions * - useFileSelection() - for file selection state and actions
* *
* Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation). * Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
*/ */
@ -28,7 +28,7 @@ import {
// Import modular components // Import modular components
import { fileContextReducer, initialFileContextState } from './file/FileReducer'; import { fileContextReducer, initialFileContextState } from './file/FileReducer';
import { createFileSelectors } from './file/fileSelectors'; import { createFileSelectors } from './file/fileSelectors';
import { addFiles, consumeFiles, createFileActions } from './file/fileActions'; import { addFiles, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
import { FileLifecycleManager } from './file/lifecycle'; import { FileLifecycleManager } from './file/lifecycle';
import { FileStateContext, FileActionsContext } from './file/contexts'; import { FileStateContext, FileActionsContext } from './file/contexts';
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext'; import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
@ -40,16 +40,16 @@ const DEBUG = process.env.NODE_ENV === 'development';
function FileContextInner({ function FileContextInner({
children, children,
enableUrlSync = true, enableUrlSync = true,
enablePersistence = true enablePersistence = true
}: FileContextProviderProps) { }: FileContextProviderProps) {
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState); const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
// IndexedDB context for persistence // IndexedDB context for persistence
const indexedDB = enablePersistence ? useIndexedDB() : null; const indexedDB = enablePersistence ? useIndexedDB() : null;
// File ref map - stores File objects outside React state // File ref map - stores File objects outside React state
const filesRef = useRef<Map<FileId, File>>(new Map()); const filesRef = useRef<Map<FileId, File>>(new Map());
// Stable state reference for selectors // Stable state reference for selectors
const stateRef = useRef(state); const stateRef = useRef(state);
stateRef.current = state; stateRef.current = state;
@ -62,8 +62,8 @@ function FileContextInner({
const lifecycleManager = lifecycleManagerRef.current; const lifecycleManager = lifecycleManagerRef.current;
// Create stable selectors (memoized once to avoid re-renders) // Create stable selectors (memoized once to avoid re-renders)
const selectors = useMemo<FileContextSelectors>(() => const selectors = useMemo<FileContextSelectors>(() =>
createFileSelectors(stateRef, filesRef), createFileSelectors(stateRef, filesRef),
[] // Empty deps - selectors are stable [] // Empty deps - selectors are stable
); );
@ -74,10 +74,21 @@ function FileContextInner({
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
}, []); }, []);
const selectFiles = useCallback((addedFilesWithIds: Array<{ file: File; id: FileId; thumbnail?: string }>) => {
const currentSelection = stateRef.current.ui.selectedFileIds;
const newFileIds = addedFilesWithIds.map(({ id }) => id);
dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds: [...currentSelection, ...newFileIds] } });
}, []);
// File operations using unified addFiles helper with persistence // File operations using unified addFiles helper with persistence
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string }): Promise<FileWithId[]> => { const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<FileWithId[]> => {
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager); const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested
if (options?.selectFiles && addedFilesWithIds.length > 0) {
selectFiles(addedFilesWithIds);
}
// Persist to IndexedDB if enabled // Persist to IndexedDB if enabled
if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) { if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) {
await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => { await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => {
@ -88,7 +99,7 @@ function FileContextInner({
} }
})); }));
} }
return addedFilesWithIds.map(({ file, id }) => createFileWithId(file, id)); return addedFilesWithIds.map(({ file, id }) => createFileWithId(file, id));
}, [indexedDB, enablePersistence]); }, [indexedDB, enablePersistence]);
@ -97,8 +108,14 @@ function FileContextInner({
return result.map(({ file, id }) => createFileWithId(file, id)); return result.map(({ file, id }) => createFileWithId(file, id));
}, []); }, []);
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise<FileWithId[]> => { const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<FileWithId[]> => {
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager); const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested
if (options?.selectFiles && result.length > 0) {
selectFiles(result);
}
return result.map(({ file, id }) => createFileWithId(file, id)); return result.map(({ file, id }) => createFileWithId(file, id));
}, []); }, []);
@ -106,10 +123,13 @@ function FileContextInner({
const baseActions = useMemo(() => createFileActions(dispatch), []); const baseActions = useMemo(() => createFileActions(dispatch), []);
// Helper functions for pinned files // Helper functions for pinned files
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileWithId[]> => { const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
const result = await consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch); return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch, indexedDB);
return result.map(({ file, id }) => createFileWithId(file, id)); }, [indexedDB]);
}, []);
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => {
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
}, [indexedDB]);
const pinFileWrapper = useCallback((file: FileWithId) => { const pinFileWrapper = useCallback((file: FileWithId) => {
baseActions.pinFile(file.fileId); baseActions.pinFile(file.fileId);
@ -124,11 +144,11 @@ function FileContextInner({
...baseActions, ...baseActions,
addFiles: addRawFiles, addFiles: addRawFiles,
addProcessedFiles, addProcessedFiles,
addStoredFiles, addStoredFiles,
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => { removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
// Remove from memory and cleanup resources // Remove from memory and cleanup resources
lifecycleManager.removeFiles(fileIds, stateRef); lifecycleManager.removeFiles(fileIds, stateRef);
// Remove from IndexedDB if enabled // Remove from IndexedDB if enabled
if (indexedDB && enablePersistence && deleteFromStorage !== false) { if (indexedDB && enablePersistence && deleteFromStorage !== false) {
try { try {
@ -138,7 +158,7 @@ function FileContextInner({
} }
} }
}, },
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) => updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
lifecycleManager.updateFileRecord(fileId, updates, stateRef), lifecycleManager.updateFileRecord(fileId, updates, stateRef),
reorderFiles: (orderedFileIds: FileId[]) => { reorderFiles: (orderedFileIds: FileId[]) => {
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } }); dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
@ -147,7 +167,7 @@ function FileContextInner({
lifecycleManager.cleanupAllFiles(); lifecycleManager.cleanupAllFiles();
filesRef.current.clear(); filesRef.current.clear();
dispatch({ type: 'RESET_CONTEXT' }); dispatch({ type: 'RESET_CONTEXT' });
// Don't clear IndexedDB automatically - only clear in-memory state // Don't clear IndexedDB automatically - only clear in-memory state
// IndexedDB should only be cleared when explicitly requested by user // IndexedDB should only be cleared when explicitly requested by user
}, },
@ -156,7 +176,7 @@ function FileContextInner({
lifecycleManager.cleanupAllFiles(); lifecycleManager.cleanupAllFiles();
filesRef.current.clear(); filesRef.current.clear();
dispatch({ type: 'RESET_CONTEXT' }); dispatch({ type: 'RESET_CONTEXT' });
// Then clear IndexedDB storage // Then clear IndexedDB storage
if (indexedDB && enablePersistence) { if (indexedDB && enablePersistence) {
try { try {
@ -170,19 +190,21 @@ function FileContextInner({
pinFile: pinFileWrapper, pinFile: pinFileWrapper,
unpinFile: unpinFileWrapper, unpinFile: unpinFileWrapper,
consumeFiles: consumeFilesWrapper, consumeFiles: consumeFilesWrapper,
undoConsumeFiles: undoConsumeFilesWrapper,
setHasUnsavedChanges, setHasUnsavedChanges,
trackBlobUrl: lifecycleManager.trackBlobUrl, trackBlobUrl: lifecycleManager.trackBlobUrl,
cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef), cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef),
scheduleCleanup: (fileId: string, delay?: number) => scheduleCleanup: (fileId: string, delay?: number) =>
lifecycleManager.scheduleCleanup(fileId, delay, stateRef) lifecycleManager.scheduleCleanup(fileId, delay, stateRef)
}), [ }), [
baseActions, baseActions,
addRawFiles, addRawFiles,
addProcessedFiles, addProcessedFiles,
addStoredFiles, addStoredFiles,
lifecycleManager, lifecycleManager,
setHasUnsavedChanges, setHasUnsavedChanges,
consumeFilesWrapper, consumeFilesWrapper,
undoConsumeFilesWrapper,
pinFileWrapper, pinFileWrapper,
unpinFileWrapper, unpinFileWrapper,
indexedDB, indexedDB,
@ -228,12 +250,12 @@ function FileContextInner({
export function FileContextProvider({ export function FileContextProvider({
children, children,
enableUrlSync = true, enableUrlSync = true,
enablePersistence = true enablePersistence = true
}: FileContextProviderProps) { }: FileContextProviderProps) {
if (enablePersistence) { if (enablePersistence) {
return ( return (
<IndexedDBProvider> <IndexedDBProvider>
<FileContextInner <FileContextInner
enableUrlSync={enableUrlSync} enableUrlSync={enableUrlSync}
enablePersistence={enablePersistence} enablePersistence={enablePersistence}
> >
@ -243,7 +265,7 @@ export function FileContextProvider({
); );
} else { } else {
return ( return (
<FileContextInner <FileContextInner
enableUrlSync={enableUrlSync} enableUrlSync={enableUrlSync}
enablePersistence={enablePersistence} enablePersistence={enablePersistence}
> >
@ -266,4 +288,4 @@ export {
useSelectedFiles, useSelectedFiles,
// Primary API hooks for tools // Primary API hooks for tools
useFileContext useFileContext
} from './file/fileHooks'; } from './file/fileHooks';

View File

@ -62,6 +62,7 @@ const initialState: NavigationState = {
// Navigation context actions interface // Navigation context actions interface
export interface NavigationContextActions { export interface NavigationContextActions {
setMode: (mode: ModeType) => void; setMode: (mode: ModeType) => void;
setWorkbench: (mode: ModeType) => void; // Alias for V2 compatibility
setHasUnsavedChanges: (hasChanges: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void;
showNavigationWarning: (show: boolean) => void; showNavigationWarning: (show: boolean) => void;
requestNavigation: (navigationFn: () => void) => void; requestNavigation: (navigationFn: () => void) => void;
@ -101,6 +102,10 @@ export const NavigationProvider: React.FC<{
dispatch({ type: 'SET_MODE', payload: { mode } }); dispatch({ type: 'SET_MODE', payload: { mode } });
}, []), }, []),
setWorkbench: useCallback((mode: ModeType) => {
dispatch({ type: 'SET_MODE', payload: { mode } });
}, []),
setHasUnsavedChanges: useCallback((hasChanges: boolean) => { setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
}, []), }, []),

View File

@ -2,10 +2,10 @@
* FileContext reducer - Pure state management for file operations * FileContext reducer - Pure state management for file operations
*/ */
import { import {
FileContextState, FileContextState,
FileContextAction, FileContextAction,
FileId, FileId,
FileRecord FileRecord
} from '../../types/fileContext'; } from '../../types/fileContext';
@ -25,6 +25,47 @@ export const initialFileContextState: FileContextState = {
} }
}; };
// Helper function for consume/undo operations
function processFileSwap(
state: FileContextState,
filesToRemove: FileId[],
filesToAdd: FileRecord[]
): FileContextState {
// Only remove unpinned files
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
const remainingIds = state.files.ids.filter(id => !unpinnedRemoveIds.includes(id));
// Remove unpinned files from state
const newById = { ...state.files.byId };
unpinnedRemoveIds.forEach(id => {
delete newById[id];
});
// Add new files
const addedIds: FileId[] = [];
filesToAdd.forEach(record => {
if (!newById[record.id]) {
addedIds.push(record.id);
newById[record.id] = record;
}
});
// Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedRemoveIds.includes(id));
return {
...state,
files: {
ids: [...addedIds, ...remainingIds],
byId: newById
},
ui: {
...state.ui,
selectedFileIds: validSelectedFileIds
}
};
}
// Pure reducer function // Pure reducer function
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
switch (action.type) { switch (action.type) {
@ -32,7 +73,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
const { fileRecords } = action.payload; const { fileRecords } = action.payload;
const newIds: FileId[] = []; const newIds: FileId[] = [];
const newById: Record<FileId, FileRecord> = { ...state.files.byId }; const newById: Record<FileId, FileRecord> = { ...state.files.byId };
fileRecords.forEach(record => { fileRecords.forEach(record => {
// Only add if not already present (dedupe by stable ID) // Only add if not already present (dedupe by stable ID)
if (!newById[record.id]) { if (!newById[record.id]) {
@ -40,7 +81,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
newById[record.id] = record; newById[record.id] = record;
} }
}); });
return { return {
...state, ...state,
files: { files: {
@ -49,20 +90,20 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'REMOVE_FILES': { case 'REMOVE_FILES': {
const { fileIds } = action.payload; const { fileIds } = action.payload;
const remainingIds = state.files.ids.filter(id => !fileIds.includes(id)); const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
const newById = { ...state.files.byId }; const newById = { ...state.files.byId };
// Remove files from state (resource cleanup handled by lifecycle manager) // Remove files from state (resource cleanup handled by lifecycle manager)
fileIds.forEach(id => { fileIds.forEach(id => {
delete newById[id]; delete newById[id];
}); });
// Clear selections that reference removed files // Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id)); const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id));
return { return {
...state, ...state,
files: { files: {
@ -75,15 +116,15 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'UPDATE_FILE_RECORD': { case 'UPDATE_FILE_RECORD': {
const { id, updates } = action.payload; const { id, updates } = action.payload;
const existingRecord = state.files.byId[id]; const existingRecord = state.files.byId[id];
if (!existingRecord) { if (!existingRecord) {
return state; // File doesn't exist, no-op return state; // File doesn't exist, no-op
} }
return { return {
...state, ...state,
files: { files: {
@ -98,13 +139,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'REORDER_FILES': { case 'REORDER_FILES': {
const { orderedFileIds } = action.payload; const { orderedFileIds } = action.payload;
// Validate that all IDs exist in current state // Validate that all IDs exist in current state
const validIds = orderedFileIds.filter(id => state.files.byId[id]); const validIds = orderedFileIds.filter(id => state.files.byId[id]);
return { return {
...state, ...state,
files: { files: {
@ -113,7 +154,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'SET_SELECTED_FILES': { case 'SET_SELECTED_FILES': {
const { fileIds } = action.payload; const { fileIds } = action.payload;
return { return {
@ -124,7 +165,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'SET_SELECTED_PAGES': { case 'SET_SELECTED_PAGES': {
const { pageNumbers } = action.payload; const { pageNumbers } = action.payload;
return { return {
@ -135,7 +176,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'CLEAR_SELECTIONS': { case 'CLEAR_SELECTIONS': {
return { return {
...state, ...state,
@ -146,7 +187,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'SET_PROCESSING': { case 'SET_PROCESSING': {
const { isProcessing, progress } = action.payload; const { isProcessing, progress } = action.payload;
return { return {
@ -158,7 +199,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'SET_UNSAVED_CHANGES': { case 'SET_UNSAVED_CHANGES': {
return { return {
...state, ...state,
@ -168,73 +209,45 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'PIN_FILE': { case 'PIN_FILE': {
const { fileId } = action.payload; const { fileId } = action.payload;
const newPinnedFiles = new Set(state.pinnedFiles); const newPinnedFiles = new Set(state.pinnedFiles);
newPinnedFiles.add(fileId); newPinnedFiles.add(fileId);
return { return {
...state, ...state,
pinnedFiles: newPinnedFiles pinnedFiles: newPinnedFiles
}; };
} }
case 'UNPIN_FILE': { case 'UNPIN_FILE': {
const { fileId } = action.payload; const { fileId } = action.payload;
const newPinnedFiles = new Set(state.pinnedFiles); const newPinnedFiles = new Set(state.pinnedFiles);
newPinnedFiles.delete(fileId); newPinnedFiles.delete(fileId);
return { return {
...state, ...state,
pinnedFiles: newPinnedFiles pinnedFiles: newPinnedFiles
}; };
} }
case 'CONSUME_FILES': { case 'CONSUME_FILES': {
const { inputFileIds, outputFileRecords } = action.payload; const { inputFileIds, outputFileRecords } = action.payload;
return processFileSwap(state, inputFileIds, outputFileRecords);
// Only remove unpinned input files
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
// Remove unpinned files from state
const newById = { ...state.files.byId };
unpinnedInputIds.forEach(id => {
delete newById[id];
});
// Add output files
const outputIds: FileId[] = [];
outputFileRecords.forEach(record => {
if (!newById[record.id]) {
outputIds.push(record.id);
newById[record.id] = record;
}
});
// Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
return {
...state,
files: {
ids: [...remainingIds, ...outputIds],
byId: newById
},
ui: {
...state.ui,
selectedFileIds: validSelectedFileIds
}
};
} }
case 'UNDO_CONSUME_FILES': {
const { inputFileRecords, outputFileIds } = action.payload;
return processFileSwap(state, outputFileIds, inputFileRecords);
}
case 'RESET_CONTEXT': { case 'RESET_CONTEXT': {
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager) // Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
return { ...initialFileContextState }; return { ...initialFileContextState };
} }
default: default:
return state; return state;
} }
} }

View File

@ -2,9 +2,9 @@
* File actions - Unified file operations with single addFiles helper * File actions - Unified file operations with single addFiles helper
*/ */
import { import {
FileId, FileId,
FileRecord, FileRecord,
FileContextAction, FileContextAction,
FileContextState, FileContextState,
toFileRecord, toFileRecord,
@ -78,13 +78,13 @@ type AddFileKind = 'raw' | 'processed' | 'stored';
interface AddFileOptions { interface AddFileOptions {
// For 'raw' files // For 'raw' files
files?: File[]; files?: File[];
// For 'processed' files // For 'processed' files
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>; filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
// For 'stored' files // For 'stored' files
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>; filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
// Insertion position // Insertion position
insertAfterPageId?: string; insertAfterPageId?: string;
} }
@ -102,37 +102,37 @@ export async function addFiles(
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> { ): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
// Acquire mutex to prevent race conditions // Acquire mutex to prevent race conditions
await addFilesMutex.lock(); await addFilesMutex.lock();
try { try {
const fileRecords: FileRecord[] = []; const fileRecords: FileRecord[] = [];
const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = []; const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = [];
// Build quickKey lookup from existing files for deduplication // Build quickKey lookup from existing files for deduplication
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId); const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys)); if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
switch (kind) { switch (kind) {
case 'raw': { case 'raw': {
const { files = [] } = options; const { files = [] } = options;
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`); if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
for (const file of files) { for (const file of files) {
const quickKey = createQuickKey(file); const quickKey = createQuickKey(file);
// Soft deduplication: Check if file already exists by metadata // Soft deduplication: Check if file already exists by metadata
if (existingQuickKeys.has(quickKey)) { if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`); if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
continue; continue;
} }
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`); if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
const fileId = createFileId(); const fileId = createFileId();
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
// Generate thumbnail and page count immediately // Generate thumbnail and page count immediately
let thumbnail: string | undefined; let thumbnail: string | undefined;
let pageCount: number = 1; let pageCount: number = 1;
// Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path // Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path
if (file.type.startsWith('application/pdf')) { if (file.type.startsWith('application/pdf')) {
try { try {
@ -156,7 +156,7 @@ export async function addFiles(
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error); if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
} }
} }
// Create record with immediate thumbnail and page metadata // Create record with immediate thumbnail and page metadata
const record = toFileRecord(file, fileId); const record = toFileRecord(file, fileId);
if (thumbnail) { if (thumbnail) {
@ -166,40 +166,40 @@ export async function addFiles(
lifecycleManager.trackBlobUrl(thumbnail); lifecycleManager.trackBlobUrl(thumbnail);
} }
} }
// Store insertion position if provided // Store insertion position if provided
if (options.insertAfterPageId !== undefined) { if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId; record.insertAfterPageId = options.insertAfterPageId;
} }
// Create initial processedFile metadata with page count // Create initial processedFile metadata with page count
if (pageCount > 0) { if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail); record.processedFile = createProcessedFile(pageCount, thumbnail);
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`); if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
} }
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail }); addedFiles.push({ file, id: fileId, thumbnail });
} }
break; break;
} }
case 'processed': { case 'processed': {
const { filesWithThumbnails = [] } = options; const { filesWithThumbnails = [] } = options;
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`); if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) { for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
const quickKey = createQuickKey(file); const quickKey = createQuickKey(file);
if (existingQuickKeys.has(quickKey)) { if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`); if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
continue; continue;
} }
const fileId = createFileId(); const fileId = createFileId();
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId); const record = toFileRecord(file, fileId);
if (thumbnail) { if (thumbnail) {
record.thumbnailUrl = thumbnail; record.thumbnailUrl = thumbnail;
@ -208,64 +208,64 @@ export async function addFiles(
lifecycleManager.trackBlobUrl(thumbnail); lifecycleManager.trackBlobUrl(thumbnail);
} }
} }
// Store insertion position if provided // Store insertion position if provided
if (options.insertAfterPageId !== undefined) { if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId; record.insertAfterPageId = options.insertAfterPageId;
} }
// Create processedFile with provided metadata // Create processedFile with provided metadata
if (pageCount > 0) { if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail); record.processedFile = createProcessedFile(pageCount, thumbnail);
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`); if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
} }
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail }); addedFiles.push({ file, id: fileId, thumbnail });
} }
break; break;
} }
case 'stored': { case 'stored': {
const { filesWithMetadata = [] } = options; const { filesWithMetadata = [] } = options;
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`); if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
for (const { file, originalId, metadata } of filesWithMetadata) { for (const { file, originalId, metadata } of filesWithMetadata) {
const quickKey = createQuickKey(file); const quickKey = createQuickKey(file);
if (existingQuickKeys.has(quickKey)) { if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`); if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
continue; continue;
} }
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`); if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
// Try to preserve original ID, but generate new if it conflicts // Try to preserve original ID, but generate new if it conflicts
let fileId = originalId; let fileId = originalId;
if (filesRef.current.has(originalId)) { if (filesRef.current.has(originalId)) {
fileId = createFileId(); fileId = createFileId();
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`); if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
} }
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId); const record = toFileRecord(file, fileId);
// Generate processedFile metadata for stored files // Generate processedFile metadata for stored files
let pageCount: number = 1; let pageCount: number = 1;
// Only process PDFs through PDF worker manager, non-PDFs have no page count // Only process PDFs through PDF worker manager, non-PDFs have no page count
if (file.type.startsWith('application/pdf')) { if (file.type.startsWith('application/pdf')) {
try { try {
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`); if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
// Get page count from PDF // Get page count from PDF
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager'); const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
const pdf = await pdfWorkerManager.createDocument(arrayBuffer); const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
pageCount = pdf.numPages; pageCount = pdf.numPages;
pdfWorkerManager.destroyDocument(pdf); pdfWorkerManager.destroyDocument(pdf);
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`); if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
} catch (error) { } catch (error) {
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error); if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
@ -274,7 +274,7 @@ export async function addFiles(
pageCount = 0; // Non-PDFs have no page count pageCount = 0; // Non-PDFs have no page count
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`); if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
} }
// Restore metadata from storage // Restore metadata from storage
if (metadata.thumbnail) { if (metadata.thumbnail) {
record.thumbnailUrl = metadata.thumbnail; record.thumbnailUrl = metadata.thumbnail;
@ -283,33 +283,33 @@ export async function addFiles(
lifecycleManager.trackBlobUrl(metadata.thumbnail); lifecycleManager.trackBlobUrl(metadata.thumbnail);
} }
} }
// Store insertion position if provided // Store insertion position if provided
if (options.insertAfterPageId !== undefined) { if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId; record.insertAfterPageId = options.insertAfterPageId;
} }
// Create processedFile metadata with correct page count // Create processedFile metadata with correct page count
if (pageCount > 0) { if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail); record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`); if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
} }
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail }); addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
} }
break; break;
} }
} }
// Dispatch ADD_FILES action if we have new files // Dispatch ADD_FILES action if we have new files
if (fileRecords.length > 0) { if (fileRecords.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { fileRecords } }); dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`); if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
} }
return addedFiles; return addedFiles;
} finally { } finally {
// Always release mutex even if error occurs // Always release mutex even if error occurs
@ -317,6 +317,61 @@ export async function addFiles(
} }
} }
/**
* Helper function to process files into records with thumbnails and metadata
*/
async function processFilesIntoRecords(
files: File[],
filesRef: React.MutableRefObject<Map<FileId, File>>
): Promise<Array<{ record: FileRecord; file: File; fileId: FileId; thumbnail?: string }>> {
return Promise.all(
files.map(async (file) => {
const fileId = createFileId();
filesRef.current.set(fileId, file);
// Generate thumbnail and page count
let thumbnail: string | undefined;
let pageCount: number = 1;
try {
if (DEBUG) console.log(`📄 Generating thumbnail for file ${file.name}`);
const result = await generateThumbnailWithMetadata(file);
thumbnail = result.thumbnail;
pageCount = result.pageCount;
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
}
const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
}
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
}
return { record, file, fileId, thumbnail };
})
);
}
/**
* Helper function to persist files to IndexedDB
*/
async function persistFilesToIndexedDB(
fileRecords: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
): Promise<void> {
await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => {
try {
await indexedDB.saveFile(file, fileId, thumbnail);
} catch (error) {
console.error('Failed to persist file to IndexedDB:', file.name, error);
}
}));
}
/** /**
* Consume files helper - replace unpinned input files with output files * Consume files helper - replace unpinned input files with output files
*/ */
@ -325,56 +380,139 @@ export async function consumeFiles(
outputFiles: File[], outputFiles: File[],
stateRef: React.MutableRefObject<FileContextState>, stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>, filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction> dispatch: React.Dispatch<FileContextAction>,
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> { indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
): Promise<FileId[]> {
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`); if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
// Process output files through the 'processed' path to generate thumbnails // Process output files with thumbnails and metadata
const processedOutputs: Array<{ file: File; id: FileId; thumbnail?: string; record: FileRecord }> = await Promise.all( const outputFileRecords = await processFilesIntoRecords(outputFiles, filesRef);
outputFiles.map(async (file) => {
const fileId = createFileId(); // Persist output files to IndexedDB if available
filesRef.current.set(fileId, file); if (indexedDB) {
await persistFilesToIndexedDB(outputFileRecords, indexedDB);
// Generate thumbnail and page count for output file }
let thumbnail: string | undefined;
let pageCount: number = 1;
try {
if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`);
const result = await generateThumbnailWithMetadata(file);
thumbnail = result.thumbnail;
pageCount = result.pageCount;
} catch (error) {
if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error);
}
const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
}
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
}
return { file, id: fileId, thumbnail, record };
})
);
const outputFileRecords = processedOutputs.map(({ record }) => record);
// Dispatch the consume action // Dispatch the consume action
dispatch({ dispatch({
type: 'CONSUME_FILES', type: 'CONSUME_FILES',
payload: { payload: {
inputFileIds, inputFileIds,
outputFileRecords outputFileRecords: outputFileRecords.map(({ record }) => record)
} }
}); });
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`); if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
return processedOutputs.map(({ file, id, thumbnail }) => ({ file, id, thumbnail })); // Return the output file IDs for undo tracking
return outputFileRecords.map(({ fileId }) => fileId);
}
/**
* Helper function to restore files to filesRef and manage IndexedDB cleanup
*/
async function restoreFilesAndCleanup(
filesToRestore: Array<{ file: File; record: FileRecord }>,
fileIdsToRemove: FileId[],
filesRef: React.MutableRefObject<Map<FileId, File>>,
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
): Promise<void> {
// Remove files from filesRef
fileIdsToRemove.forEach(id => {
if (filesRef.current.has(id)) {
if (DEBUG) console.log(`📄 Removing file ${id} from filesRef`);
filesRef.current.delete(id);
} else {
if (DEBUG) console.warn(`📄 File ${id} not found in filesRef`);
}
});
// Restore files to filesRef
filesToRestore.forEach(({ file, record }) => {
if (file && record) {
// Validate the file before restoring
if (file.size === 0) {
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
return;
}
// Restore the file to filesRef
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
filesRef.current.set(record.id, file);
}
});
// Clean up IndexedDB
if (indexedDB) {
const indexedDBPromises = fileIdsToRemove.map(fileId =>
indexedDB.deleteFile(fileId).catch(error => {
console.error('Failed to delete file from IndexedDB:', fileId, error);
throw error; // Re-throw to trigger rollback
})
);
// Execute all IndexedDB operations
await Promise.all(indexedDBPromises);
}
}
/**
* Undoes a previous consumeFiles operation by restoring input files and removing output files (unless pinned)
*/
export async function undoConsumeFiles(
inputFiles: File[],
inputFileRecords: FileRecord[],
outputFileIds: FileId[],
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>,
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
): Promise<void> {
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputFileRecords.length} input files, removing ${outputFileIds.length} output files`);
// Validate inputs
if (inputFiles.length !== inputFileRecords.length) {
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`);
}
// Create a backup of current filesRef state for rollback
const backupFilesRef = new Map(filesRef.current);
try {
// Prepare files to restore
const filesToRestore = inputFiles.map((file, index) => ({
file,
record: inputFileRecords[index]
}));
// Restore input files and clean up output files
await restoreFilesAndCleanup(
filesToRestore,
outputFileIds,
filesRef,
indexedDB
);
// Dispatch the undo action (only if everything else succeeded)
dispatch({
type: 'UNDO_CONSUME_FILES',
payload: {
inputFileRecords,
outputFileIds
}
});
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputFileRecords.length} inputs, removed ${outputFileIds.length} outputs`);
} catch (error) {
// Rollback filesRef to previous state
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
filesRef.current.clear();
backupFilesRef.forEach((file, id) => {
filesRef.current.set(id, file);
});
throw error; // Re-throw to let caller handle
}
} }
/** /**

View File

@ -3,11 +3,11 @@
*/ */
import { useContext, useMemo } from 'react'; import { useContext, useMemo } from 'react';
import { import {
FileStateContext, FileStateContext,
FileActionsContext, FileActionsContext,
FileContextStateValue, FileContextStateValue,
FileContextActionsValue FileContextActionsValue
} from './contexts'; } from './contexts';
import { FileId, FileRecord, FileWithId } from '../../types/fileContext'; import { FileId, FileRecord, FileWithId } from '../../types/fileContext';
@ -39,7 +39,7 @@ export function useFileActions(): FileContextActionsValue {
*/ */
export function useCurrentFile(): { file?: File; record?: FileRecord } { export function useCurrentFile(): { file?: File; record?: FileRecord } {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
const primaryFileId = state.files.ids[0]; const primaryFileId = state.files.ids[0];
return useMemo(() => ({ return useMemo(() => ({
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined, file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
@ -81,7 +81,7 @@ export function useFileSelection() {
*/ */
export function useFileManagement() { export function useFileManagement() {
const { actions } = useFileActions(); const { actions } = useFileActions();
return useMemo(() => ({ return useMemo(() => ({
addFiles: actions.addFiles, addFiles: actions.addFiles,
removeFiles: actions.removeFiles, removeFiles: actions.removeFiles,
@ -112,7 +112,7 @@ export function useFileUI() {
*/ */
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } { export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
const { selectors } = useFileState(); const { selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
file: selectors.getFile(fileId), file: selectors.getFile(fileId),
record: selectors.getFileRecord(fileId) record: selectors.getFileRecord(fileId)
@ -124,7 +124,7 @@ export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecor
*/ */
export function useAllFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } { export function useAllFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
files: selectors.getFiles(), files: selectors.getFiles(),
records: selectors.getFileRecords(), records: selectors.getFileRecords(),
@ -137,7 +137,7 @@ export function useAllFiles(): { files: FileWithId[]; records: FileRecord[]; fil
*/ */
export function useSelectedFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } { export function useSelectedFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
files: selectors.getSelectedFiles(), files: selectors.getSelectedFiles(),
records: selectors.getSelectedFileRecords(), records: selectors.getSelectedFileRecords(),
@ -160,19 +160,38 @@ export function useFileContext() {
trackBlobUrl: actions.trackBlobUrl, trackBlobUrl: actions.trackBlobUrl,
scheduleCleanup: actions.scheduleCleanup, scheduleCleanup: actions.scheduleCleanup,
setUnsavedChanges: actions.setHasUnsavedChanges, setUnsavedChanges: actions.setHasUnsavedChanges,
// File management // File management
addFiles: actions.addFiles, addFiles: actions.addFiles,
consumeFiles: actions.consumeFiles, consumeFiles: actions.consumeFiles,
undoConsumeFiles: actions.undoConsumeFiles,
recordOperation: (fileId: FileId, operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (fileId: FileId, operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (fileId: FileId, operationId: string, error: string) => {}, // Operation tracking not implemented
// File ID lookup
findFileId: (file: File) => {
return state.files.ids.find(id => {
const record = state.files.byId[id];
return record &&
record.name === file.name &&
record.size === file.size &&
record.lastModified === file.lastModified;
});
},
// Pinned files // Pinned files
pinnedFiles: state.pinnedFiles, pinnedFiles: state.pinnedFiles,
pinFile: actions.pinFile, pinFile: actions.pinFile,
unpinFile: actions.unpinFile, unpinFile: actions.unpinFile,
isFilePinned: selectors.isFilePinned, isFilePinned: selectors.isFilePinned,
// Active files // Active files
activeFiles: selectors.getFiles() activeFiles: selectors.getFiles(),
// Direct access to actions and selectors (for advanced use cases)
actions,
selectors
}), [state, selectors, actions]); }), [state, selectors, actions]);
} }

View File

@ -42,6 +42,7 @@ describe('useAddPasswordOperation', () => {
resetResults: vi.fn(), resetResults: vi.fn(),
clearError: vi.fn(), clearError: vi.fn(),
cancelOperation: vi.fn(), cancelOperation: vi.fn(),
undoOperation: vi.fn(),
}; };
beforeEach(() => { beforeEach(() => {

View File

@ -41,6 +41,7 @@ describe('useChangePermissionsOperation', () => {
resetResults: vi.fn(), resetResults: vi.fn(),
clearError: vi.fn(), clearError: vi.fn(),
cancelOperation: vi.fn(), cancelOperation: vi.fn(),
undoOperation: vi.fn(),
}; };
beforeEach(() => { beforeEach(() => {

View File

@ -41,6 +41,7 @@ describe('useRemovePasswordOperation', () => {
resetResults: vi.fn(), resetResults: vi.fn(),
clearError: vi.fn(), clearError: vi.fn(),
cancelOperation: vi.fn(), cancelOperation: vi.fn(),
undoOperation: vi.fn(),
}; };
beforeEach(() => { beforeEach(() => {

View File

@ -0,0 +1,126 @@
import { useEffect, useCallback } from 'react';
import { useFileSelection } from '../../../contexts/FileContext';
import { useEndpointEnabled } from '../../useEndpointConfig';
import { BaseToolProps } from '../../../types/tool';
import { ToolOperationHook } from './useToolOperation';
import { BaseParametersHook } from './useBaseParameters';
import { FileWithId } from '../../../types/fileContext';
interface BaseToolReturn<TParams> {
// File management
selectedFiles: FileWithId[];
// Tool-specific hooks
params: BaseParametersHook<TParams>;
operation: ToolOperationHook<TParams>;
// Endpoint validation
endpointEnabled: boolean | null;
endpointLoading: boolean;
// Standard handlers
handleExecute: () => Promise<void>;
handleThumbnailClick: (file: File) => void;
handleSettingsReset: () => void;
handleUndo: () => Promise<void>;
// Standard computed state
hasFiles: boolean;
hasResults: boolean;
settingsCollapsed: boolean;
}
/**
* Base tool hook for tool components. Manages standard behaviour for tools.
*/
export function useBaseTool<TParams>(
toolName: string,
useParams: () => BaseParametersHook<TParams>,
useOperation: () => ToolOperationHook<TParams>,
props: BaseToolProps,
): BaseToolReturn<TParams> {
const { onPreviewFile, onComplete, onError } = props;
// File selection
const { selectedFiles } = useFileSelection();
// Tool-specific hooks
const params = useParams();
const operation = useOperation();
// Endpoint validation using parameters hook
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(params.getEndpointName());
// Reset results when parameters change
useEffect(() => {
operation.resetResults();
onPreviewFile?.(null);
}, [params.parameters]);
// Reset results when selected files change
useEffect(() => {
if (selectedFiles.length > 0) {
operation.resetResults();
onPreviewFile?.(null);
}
}, [selectedFiles.length]);
// Standard handlers
const handleExecute = useCallback(async () => {
try {
await operation.executeOperation(params.parameters, selectedFiles);
if (operation.files && onComplete) {
onComplete(operation.files);
}
} catch (error) {
if (onError) {
const message = error instanceof Error ? error.message : `${toolName} operation failed`;
onError(message);
}
}
}, [operation, params.parameters, selectedFiles, onComplete, onError, toolName]);
const handleThumbnailClick = useCallback((file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', toolName);
}, [onPreviewFile, toolName]);
const handleSettingsReset = useCallback(() => {
operation.resetResults();
onPreviewFile?.(null);
}, [operation, onPreviewFile]);
const handleUndo = useCallback(async () => {
await operation.undoOperation();
onPreviewFile?.(null);
}, [operation, onPreviewFile]);
// Standard computed state
const hasFiles = selectedFiles.length > 0;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;
return {
// File management
selectedFiles,
// Tool-specific hooks
params,
operation,
// Endpoint validation
endpointEnabled,
endpointLoading,
// Handlers
handleExecute,
handleThumbnailClick,
handleSettingsReset,
handleUndo,
// State
hasFiles,
hasResults,
settingsCollapsed
};
}

View File

@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, useRef, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFileContext } from '../../../contexts/FileContext'; import { useFileContext } from '../../../contexts/FileContext';
@ -6,7 +6,7 @@ import { useToolState, type ProcessingProgress } from './useToolState';
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls'; import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
import { useToolResources } from './useToolResources'; import { useToolResources } from './useToolResources';
import { extractErrorMessage } from '../../../utils/toolErrorHandler'; import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { FileWithId, extractFiles } from '../../../types/fileContext'; import { FileWithId, extractFiles, FileId, FileRecord } from '../../../types/fileContext';
import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { ResponseHandler } from '../../../utils/toolResponseProcessor';
// Re-export for backwards compatibility // Re-export for backwards compatibility
@ -86,6 +86,7 @@ export interface ToolOperationHook<TParams = void> {
resetResults: () => void; resetResults: () => void;
clearError: () => void; clearError: () => void;
cancelOperation: () => void; cancelOperation: () => void;
undoOperation: () => Promise<void>;
} }
// Re-export for backwards compatibility // Re-export for backwards compatibility
@ -107,13 +108,20 @@ export const useToolOperation = <TParams = void>(
config: ToolOperationConfig<TParams> config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => { ): ToolOperationHook<TParams> => {
const { t } = useTranslation(); const { t } = useTranslation();
const { addFiles, consumeFiles } = useFileContext(); const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext();
// Composed hooks // Composed hooks
const { state, actions } = useToolState(); const { state, actions } = useToolState();
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>(); const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources(); const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
// Track last operation for undo functionality
const lastOperationRef = useRef<{
inputFiles: File[];
inputFileRecords: FileRecord[];
outputFileIds: FileId[];
} | null>(null);
const executeOperation = useCallback(async ( const executeOperation = useCallback(async (
params: TParams, params: TParams,
selectedFiles: FileWithId[] selectedFiles: FileWithId[]
@ -158,8 +166,8 @@ export const useToolOperation = <TParams = void>(
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs // Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
if (config.responseHandler) { if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction) // Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, validRegularFiles); processedFiles = await config.responseHandler(response.data, validFiles);
} else if (response.data.type === 'application/pdf' || } else if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) { (response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - use original filename // Single PDF response (e.g. split with merge option) - use original filename
const originalFileName = validRegularFiles[0]?.name || 'document.pdf'; const originalFileName = validRegularFiles[0]?.name || 'document.pdf';
@ -207,8 +215,34 @@ export const useToolOperation = <TParams = void>(
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename); actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
// Replace input files with processed files (consumeFiles handles pinning) // Replace input files with processed files (consumeFiles handles pinning)
const inputFileIds = validFiles.map(file => file.fileId); const inputFileIds: FileId[] = [];
await consumeFiles(inputFileIds, processedFiles); const inputFileRecords: FileRecord[] = [];
// Build parallel arrays of IDs and records for undo tracking
for (const file of validFiles) {
const fileId = findFileId(file);
if (fileId) {
const record = selectors.getFileRecord(fileId);
if (record) {
inputFileIds.push(fileId);
inputFileRecords.push(record);
} else {
console.warn(`No file record found for file: ${file.name}`);
}
} else {
console.warn(`No file ID found for file: ${file.name}`);
}
}
const outputFileIds = await consumeFiles(inputFileIds, processedFiles);
// Store operation data for undo (only store what we need to avoid memory bloat)
lastOperationRef.current = {
inputFiles: validFiles, // Keep original File objects for undo
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues
outputFileIds
};
} }
} catch (error: any) { } catch (error: any) {
@ -231,8 +265,65 @@ export const useToolOperation = <TParams = void>(
const resetResults = useCallback(() => { const resetResults = useCallback(() => {
cleanupBlobUrls(); cleanupBlobUrls();
actions.resetResults(); actions.resetResults();
// Clear undo data when results are reset to prevent memory leaks
lastOperationRef.current = null;
}, [cleanupBlobUrls, actions]); }, [cleanupBlobUrls, actions]);
// Cleanup on unmount to prevent memory leaks
useEffect(() => {
return () => {
lastOperationRef.current = null;
};
}, []);
const undoOperation = useCallback(async () => {
if (!lastOperationRef.current) {
actions.setError(t('noOperationToUndo', 'No operation to undo'));
return;
}
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current;
// Validate that we have data to undo
if (inputFiles.length === 0 || inputFileRecords.length === 0) {
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
return;
}
if (outputFileIds.length === 0) {
actions.setError(t('noFilesToUndo', 'Cannot undo: no files were processed in the last operation'));
return;
}
try {
// Undo the consume operation
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds);
// Clear results and operation tracking
resetResults();
lastOperationRef.current = null;
// Show success message
actions.setStatus(t('undoSuccess', 'Operation undone successfully'));
} catch (error: any) {
let errorMessage = extractErrorMessage(error);
// Provide more specific error messages based on error type
if (error.message?.includes('Mismatch between input files')) {
errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted');
} else if (error.message?.includes('IndexedDB')) {
errorMessage = t('undoStorageError', 'Undo completed but some files could not be saved to storage');
} else if (error.name === 'QuotaExceededError') {
errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space');
}
actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`);
// Don't clear the operation data if undo failed - user might want to try again
}
}, [undoConsumeFiles, resetResults, actions, t]);
return { return {
// State // State
files: state.files, files: state.files,
@ -249,6 +340,7 @@ export const useToolOperation = <TParams = void>(
executeOperation, executeOperation,
resetResults, resetResults,
clearError: actions.clearError, clearError: actions.clearError,
cancelOperation cancelOperation,
undoOperation
}; };
}; };

View File

@ -9,12 +9,12 @@ export const useFileHandler = () => {
const addToActiveFiles = useCallback(async (file: File) => { const addToActiveFiles = useCallback(async (file: File) => {
// Let FileContext handle deduplication with quickKey logic // Let FileContext handle deduplication with quickKey logic
await actions.addFiles([file]); await actions.addFiles([file], { selectFiles: true });
}, [actions.addFiles]); }, [actions.addFiles]);
const addMultipleFiles = useCallback(async (files: File[]) => { const addMultipleFiles = useCallback(async (files: File[]) => {
// Let FileContext handle deduplication with quickKey logic // Let FileContext handle deduplication with quickKey logic
await actions.addFiles(files); await actions.addFiles(files, { selectFiles: true });
}, [actions.addFiles]); }, [actions.addFiles]);
// Add stored files preserving their original IDs to prevent session duplicates // Add stored files preserving their original IDs to prevent session duplicates
@ -29,7 +29,7 @@ export const useFileHandler = () => {
file, file,
originalId: originalId as FileId, originalId: originalId as FileId,
metadata metadata
}))); })), { selectFiles: true });
} }
console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`); console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`);

View File

@ -60,6 +60,11 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
onPreviewFile?.(null); onPreviewFile?.(null);
}; };
const handleUndo = async () => {
await addPasswordOperation.undoOperation();
onPreviewFile?.(null);
};
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;
const hasResults = addPasswordOperation.files.length > 0 || addPasswordOperation.downloadUrl !== null; const hasResults = addPasswordOperation.files.length > 0 || addPasswordOperation.downloadUrl !== null;
const passwordsCollapsed = !hasFiles || hasResults; const passwordsCollapsed = !hasFiles || hasResults;
@ -110,6 +115,7 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
operation: addPasswordOperation, operation: addPasswordOperation,
title: t("addPassword.results.title", "Encrypted PDFs"), title: t("addPassword.results.title", "Encrypted PDFs"),
onFileClick: handleThumbnailClick, onFileClick: handleThumbnailClick,
onUndo: handleUndo,
}, },
}); });
}; };

View File

@ -79,6 +79,11 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
onPreviewFile?.(null); onPreviewFile?.(null);
}; };
const handleUndo = async () => {
await watermarkOperation.undoOperation();
onPreviewFile?.(null);
};
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;
const hasResults = watermarkOperation.files.length > 0 || watermarkOperation.downloadUrl !== null; const hasResults = watermarkOperation.files.length > 0 || watermarkOperation.downloadUrl !== null;
@ -203,6 +208,7 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
operation: watermarkOperation, operation: watermarkOperation,
title: t("watermark.results.title", "Watermark Results"), title: t("watermark.results.title", "Watermark Results"),
onFileClick: handleThumbnailClick, onFileClick: handleThumbnailClick,
onUndo: handleUndo,
}, },
forceStepNumbers: true, forceStepNumbers: true,
}); });

View File

@ -2,7 +2,7 @@ import React, { useState, useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useFileSelection } from "../contexts/FileContext"; import { useFileSelection } from "../contexts/FileContext";
import { useNavigation } from "../contexts/NavigationContext"; import { useNavigationActions } from "../contexts/NavigationContext";
import { useToolWorkflow } from "../contexts/ToolWorkflowContext"; import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
@ -21,7 +21,7 @@ import { AUTOMATION_STEPS } from "../constants/automation";
const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { selectedFiles } = useFileSelection(); const { selectedFiles } = useFileSelection();
const { setMode } = useNavigation(); const { actions } = useNavigationActions();
const { registerToolReset } = useToolWorkflow(); const { registerToolReset } = useToolWorkflow();
const [currentStep, setCurrentStep] = useState<AutomationStep>(AUTOMATION_STEPS.SELECTION); const [currentStep, setCurrentStep] = useState<AutomationStep>(AUTOMATION_STEPS.SELECTION);
@ -43,6 +43,11 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
setStepData({ step: AUTOMATION_STEPS.SELECTION }); setStepData({ step: AUTOMATION_STEPS.SELECTION });
}; };
const handleUndo = async () => {
await automateOperation.undoOperation();
onPreviewFile?.(null);
};
// Register reset function with the tool workflow context - only once on mount // Register reset function with the tool workflow context - only once on mount
React.useEffect(() => { React.useEffect(() => {
const stableResetFunction = () => { const stableResetFunction = () => {
@ -223,8 +228,9 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
title: t('automate.reviewTitle', 'Automation Results'), title: t('automate.reviewTitle', 'Automation Results'),
onFileClick: (file: File) => { onFileClick: (file: File) => {
onPreviewFile?.(file); onPreviewFile?.(file);
setMode('viewer'); actions.setWorkbench('viewer');
} },
onUndo: handleUndo
} }
}); });
}; };

View File

@ -1,96 +1,56 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings"; import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import { useChangePermissionsParameters } from "../hooks/tools/changePermissions/useChangePermissionsParameters"; import { useChangePermissionsParameters } from "../hooks/tools/changePermissions/useChangePermissionsParameters";
import { useChangePermissionsOperation } from "../hooks/tools/changePermissions/useChangePermissionsOperation"; import { useChangePermissionsOperation } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
import { useChangePermissionsTips } from "../components/tooltips/useChangePermissionsTips"; import { useChangePermissionsTips } from "../components/tooltips/useChangePermissionsTips";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool"; import { BaseToolProps, ToolComponent } from "../types/tool";
const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const ChangePermissions = (props: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useNavigationActions();
const { selectedFiles } = useFileSelection();
const changePermissionsParams = useChangePermissionsParameters();
const changePermissionsOperation = useChangePermissionsOperation();
const changePermissionsTips = useChangePermissionsTips(); const changePermissionsTips = useChangePermissionsTips();
// Endpoint validation const base = useBaseTool(
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(changePermissionsParams.getEndpointName()); 'changePermissions',
useChangePermissionsParameters,
useEffect(() => { useChangePermissionsOperation,
changePermissionsOperation.resetResults(); props
onPreviewFile?.(null); );
}, [changePermissionsParams.parameters]);
const handleChangePermissions = async () => {
try {
await changePermissionsOperation.executeOperation(changePermissionsParams.parameters, selectedFiles);
if (changePermissionsOperation.files && onComplete) {
onComplete(changePermissionsOperation.files);
}
} catch (error) {
if (onError) {
onError(
error instanceof Error ? error.message : t("changePermissions.error.failed", "Change permissions operation failed")
);
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "changePermissions");
};
const handleSettingsReset = () => {
changePermissionsOperation.resetResults();
onPreviewFile?.(null);
};
const hasFiles = selectedFiles.length > 0;
const hasResults = changePermissionsOperation.files.length > 0 || changePermissionsOperation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;
return createToolFlow({ return createToolFlow({
files: { files: {
selectedFiles, selectedFiles: base.selectedFiles,
isCollapsed: hasResults, isCollapsed: base.hasResults,
}, },
steps: [ steps: [
{ {
title: t("changePermissions.title", "Document Permissions"), title: t("changePermissions.title", "Document Permissions"),
isCollapsed: settingsCollapsed, isCollapsed: base.settingsCollapsed,
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined, onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: changePermissionsTips, tooltip: changePermissionsTips,
content: ( content: (
<ChangePermissionsSettings <ChangePermissionsSettings
parameters={changePermissionsParams.parameters} parameters={base.params.parameters}
onParameterChange={changePermissionsParams.updateParameter} onParameterChange={base.params.updateParameter}
disabled={endpointLoading} disabled={base.endpointLoading}
/> />
), ),
}, },
], ],
executeButton: { executeButton: {
text: t("changePermissions.submit", "Change Permissions"), text: t("changePermissions.submit", "Change Permissions"),
isVisible: !hasResults, isVisible: !base.hasResults,
loadingText: t("loading"), loadingText: t("loading"),
onClick: handleChangePermissions, onClick: base.handleExecute,
disabled: !changePermissionsParams.validateParameters() || !hasFiles || !endpointEnabled, disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
}, },
review: { review: {
isVisible: hasResults, isVisible: base.hasResults,
operation: changePermissionsOperation, operation: base.operation,
title: t("changePermissions.results.title", "Modified PDFs"), title: t("changePermissions.results.title", "Modified PDFs"),
onFileClick: handleThumbnailClick, onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
}, },
}); });
}; };

View File

@ -1,95 +1,56 @@
import React, { use, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import CompressSettings from "../components/tools/compress/CompressSettings"; import CompressSettings from "../components/tools/compress/CompressSettings";
import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters"; import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters";
import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation"; import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool"; import { BaseToolProps, ToolComponent } from "../types/tool";
import { useCompressTips } from "../components/tooltips/useCompressTips"; import { useCompressTips } from "../components/tooltips/useCompressTips";
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Compress = (props: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useNavigationActions();
const { selectedFiles } = useFileSelection();
const compressParams = useCompressParameters();
const compressOperation = useCompressOperation();
const compressTips = useCompressTips(); const compressTips = useCompressTips();
// Endpoint validation const base = useBaseTool(
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf"); 'compress',
useCompressParameters,
useEffect(() => { useCompressOperation,
compressOperation.resetResults(); props
onPreviewFile?.(null); );
}, [compressParams.parameters]);
const handleCompress = async () => {
try {
await compressOperation.executeOperation(compressParams.parameters, selectedFiles);
if (compressOperation.files && onComplete) {
onComplete(compressOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : "Compress operation failed");
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "compress");
};
const handleSettingsReset = () => {
compressOperation.resetResults();
onPreviewFile?.(null); };
const hasFiles = selectedFiles.length > 0;
const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;
return createToolFlow({ return createToolFlow({
files: { files: {
selectedFiles, selectedFiles: base.selectedFiles,
isCollapsed: hasResults, isCollapsed: base.hasResults,
}, },
steps: [ steps: [
{ {
title: "Settings", title: "Settings",
isCollapsed: settingsCollapsed, isCollapsed: base.settingsCollapsed,
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined, onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: compressTips, tooltip: compressTips,
content: ( content: (
<CompressSettings <CompressSettings
parameters={compressParams.parameters} parameters={base.params.parameters}
onParameterChange={compressParams.updateParameter} onParameterChange={base.params.updateParameter}
disabled={endpointLoading} disabled={base.endpointLoading}
/> />
), ),
}, },
], ],
executeButton: { executeButton: {
text: t("compress.submit", "Compress"), text: t("compress.submit", "Compress"),
isVisible: !hasResults, isVisible: !base.hasResults,
loadingText: t("loading"), loadingText: t("loading"),
onClick: handleCompress, onClick: base.handleExecute,
disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled, disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
}, },
review: { review: {
isVisible: hasResults, isVisible: base.hasResults,
operation: compressOperation, operation: base.operation,
title: t("compress.title", "Compression Results"), title: t("compress.title", "Compression Results"),
onFileClick: handleThumbnailClick, onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
}, },
}); });
}; };

View File

@ -93,6 +93,11 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
onPreviewFile?.(null); onPreviewFile?.(null);
}; };
const handleUndo = async () => {
await convertOperation.undoOperation();
onPreviewFile?.(null);
};
return createToolFlow({ return createToolFlow({
files: { files: {
selectedFiles, selectedFiles,
@ -128,6 +133,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
operation: convertOperation, operation: convertOperation,
title: t("convert.conversionResults", "Conversion Results"), title: t("convert.conversionResults", "Conversion Results"),
onFileClick: handleThumbnailClick, onFileClick: handleThumbnailClick,
onUndo: handleUndo,
testId: "conversion-results", testId: "conversion-results",
}, },
}); });

View File

@ -74,6 +74,11 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
onPreviewFile?.(null); onPreviewFile?.(null);
}; };
const handleUndo = async () => {
await ocrOperation.undoOperation();
onPreviewFile?.(null);
};
const settingsCollapsed = expandedStep !== "settings"; const settingsCollapsed = expandedStep !== "settings";
return createToolFlow({ return createToolFlow({
@ -132,6 +137,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
operation: ocrOperation, operation: ocrOperation,
title: t("ocr.results.title", "OCR Results"), title: t("ocr.results.title", "OCR Results"),
onFileClick: handleThumbnailClick, onFileClick: handleThumbnailClick,
onUndo: handleUndo,
}, },
}); });
}; };

View File

@ -1,78 +1,40 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import { useFileSelection } from "../contexts/file/fileHooks";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { useRemoveCertificateSignParameters } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters"; import { useRemoveCertificateSignParameters } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters";
import { useRemoveCertificateSignOperation } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation"; import { useRemoveCertificateSignOperation } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool"; import { BaseToolProps, ToolComponent } from "../types/tool";
const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const RemoveCertificateSign = (props: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useNavigationActions();
const { selectedFiles } = useFileSelection();
const removeCertificateSignParams = useRemoveCertificateSignParameters(); const base = useBaseTool(
const removeCertificateSignOperation = useRemoveCertificateSignOperation(); 'removeCertificateSign',
useRemoveCertificateSignParameters,
// Endpoint validation useRemoveCertificateSignOperation,
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(removeCertificateSignParams.getEndpointName()); props
);
useEffect(() => {
removeCertificateSignOperation.resetResults();
onPreviewFile?.(null);
}, [removeCertificateSignParams.parameters]);
const handleRemoveSignature = async () => {
try {
await removeCertificateSignOperation.executeOperation(removeCertificateSignParams.parameters, selectedFiles);
if (removeCertificateSignOperation.files && onComplete) {
onComplete(removeCertificateSignOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : t("removeCertSign.error.failed", "Remove certificate signature operation failed"));
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "removeCertificateSign");
actions.setMode("viewer");
};
const handleSettingsReset = () => {
removeCertificateSignOperation.resetResults();
onPreviewFile?.(null);
};
const hasFiles = selectedFiles.length > 0;
const hasResults = removeCertificateSignOperation.files.length > 0 || removeCertificateSignOperation.downloadUrl !== null;
return createToolFlow({ return createToolFlow({
files: { files: {
selectedFiles, selectedFiles: base.selectedFiles,
isCollapsed: hasFiles || hasResults, isCollapsed: base.hasResults,
placeholder: t("removeCertSign.files.placeholder", "Select a PDF file in the main view to get started"), placeholder: t("removeCertSign.files.placeholder", "Select a PDF file in the main view to get started"),
}, },
steps: [], steps: [],
executeButton: { executeButton: {
text: t("removeCertSign.submit", "Remove Signature"), text: t("removeCertSign.submit", "Remove Signature"),
isVisible: !hasResults, isVisible: !base.hasResults,
loadingText: t("loading"), loadingText: t("loading"),
onClick: handleRemoveSignature, onClick: base.handleExecute,
disabled: !removeCertificateSignParams.validateParameters() || !hasFiles || !endpointEnabled, disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
}, },
review: { review: {
isVisible: hasResults, isVisible: base.hasResults,
operation: removeCertificateSignOperation, operation: base.operation,
title: t("removeCertSign.results.title", "Certificate Removal Results"), title: t("removeCertSign.results.title", "Certificate Removal Results"),
onFileClick: handleThumbnailClick, onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
}, },
}); });
}; };
@ -80,4 +42,4 @@ const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolP
// Static method to get the operation hook for automation // Static method to get the operation hook for automation
RemoveCertificateSign.tool = () => useRemoveCertificateSignOperation; RemoveCertificateSign.tool = () => useRemoveCertificateSignOperation;
export default RemoveCertificateSign as ToolComponent; export default RemoveCertificateSign as ToolComponent;

View File

@ -1,95 +1,56 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import RemovePasswordSettings from "../components/tools/removePassword/RemovePasswordSettings"; import RemovePasswordSettings from "../components/tools/removePassword/RemovePasswordSettings";
import { useRemovePasswordParameters } from "../hooks/tools/removePassword/useRemovePasswordParameters"; import { useRemovePasswordParameters } from "../hooks/tools/removePassword/useRemovePasswordParameters";
import { useRemovePasswordOperation } from "../hooks/tools/removePassword/useRemovePasswordOperation"; import { useRemovePasswordOperation } from "../hooks/tools/removePassword/useRemovePasswordOperation";
import { useRemovePasswordTips } from "../components/tooltips/useRemovePasswordTips"; import { useRemovePasswordTips } from "../components/tooltips/useRemovePasswordTips";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool"; import { BaseToolProps, ToolComponent } from "../types/tool";
const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const RemovePassword = (props: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useNavigationActions();
const { selectedFiles } = useFileSelection();
const removePasswordParams = useRemovePasswordParameters();
const removePasswordOperation = useRemovePasswordOperation();
const removePasswordTips = useRemovePasswordTips(); const removePasswordTips = useRemovePasswordTips();
// Endpoint validation const base = useBaseTool(
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(removePasswordParams.getEndpointName()); 'removePassword',
useRemovePasswordParameters,
useRemovePasswordOperation,
useEffect(() => { props
removePasswordOperation.resetResults(); );
onPreviewFile?.(null);
}, [removePasswordParams.parameters]);
const handleRemovePassword = async () => {
try {
await removePasswordOperation.executeOperation(removePasswordParams.parameters, selectedFiles);
if (removePasswordOperation.files && onComplete) {
onComplete(removePasswordOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : t("removePassword.error.failed", "Remove password operation failed"));
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "removePassword");
};
const handleSettingsReset = () => {
removePasswordOperation.resetResults();
onPreviewFile?.(null);
};
const hasFiles = selectedFiles.length > 0;
const hasResults = removePasswordOperation.files.length > 0 || removePasswordOperation.downloadUrl !== null;
const passwordCollapsed = !hasFiles || hasResults;
return createToolFlow({ return createToolFlow({
files: { files: {
selectedFiles, selectedFiles: base.selectedFiles,
isCollapsed: hasResults, isCollapsed: base.hasResults,
}, },
steps: [ steps: [
{ {
title: t("removePassword.password.stepTitle", "Remove Password"), title: t("removePassword.password.stepTitle", "Remove Password"),
isCollapsed: passwordCollapsed, isCollapsed: base.settingsCollapsed,
onCollapsedClick: hasResults ? handleSettingsReset : undefined, onCollapsedClick: base.hasResults ? base.handleSettingsReset : undefined,
tooltip: removePasswordTips, tooltip: removePasswordTips,
content: ( content: (
<RemovePasswordSettings <RemovePasswordSettings
parameters={removePasswordParams.parameters} parameters={base.params.parameters}
onParameterChange={removePasswordParams.updateParameter} onParameterChange={base.params.updateParameter}
disabled={endpointLoading} disabled={base.endpointLoading}
/> />
), ),
}, },
], ],
executeButton: { executeButton: {
text: t("removePassword.submit", "Remove Password"), text: t("removePassword.submit", "Remove Password"),
isVisible: !hasResults, isVisible: !base.hasResults,
loadingText: t("loading"), loadingText: t("loading"),
onClick: handleRemovePassword, onClick: base.handleExecute,
disabled: !removePasswordParams.validateParameters() || !hasFiles || !endpointEnabled, disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
}, },
review: { review: {
isVisible: hasResults, isVisible: base.hasResults,
operation: removePasswordOperation, operation: base.operation,
title: t("removePassword.results.title", "Decrypted PDFs"), title: t("removePassword.results.title", "Decrypted PDFs"),
onFileClick: handleThumbnailClick, onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
}, },
}); });
}; };

View File

@ -1,78 +1,40 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import { useFileSelection } from "../contexts/file/fileHooks";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { useRepairParameters } from "../hooks/tools/repair/useRepairParameters"; import { useRepairParameters } from "../hooks/tools/repair/useRepairParameters";
import { useRepairOperation } from "../hooks/tools/repair/useRepairOperation"; import { useRepairOperation } from "../hooks/tools/repair/useRepairOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool"; import { BaseToolProps, ToolComponent } from "../types/tool";
const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Repair = (props: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useNavigationActions();
const { selectedFiles } = useFileSelection();
const repairParams = useRepairParameters(); const base = useBaseTool(
const repairOperation = useRepairOperation(); 'repair',
useRepairParameters,
// Endpoint validation useRepairOperation,
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(repairParams.getEndpointName()); props
);
useEffect(() => {
repairOperation.resetResults();
onPreviewFile?.(null);
}, [repairParams.parameters]);
const handleRepair = async () => {
try {
await repairOperation.executeOperation(repairParams.parameters, selectedFiles);
if (repairOperation.files && onComplete) {
onComplete(repairOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : t("repair.error.failed", "Repair operation failed"));
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "repair");
actions.setMode("viewer");
};
const handleSettingsReset = () => {
repairOperation.resetResults();
onPreviewFile?.(null);
};
const hasFiles = selectedFiles.length > 0;
const hasResults = repairOperation.files.length > 0 || repairOperation.downloadUrl !== null;
return createToolFlow({ return createToolFlow({
files: { files: {
selectedFiles, selectedFiles: base.selectedFiles,
isCollapsed: hasResults, isCollapsed: base.hasResults,
placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"), placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"),
}, },
steps: [], steps: [],
executeButton: { executeButton: {
text: t("repair.submit", "Repair PDF"), text: t("repair.submit", "Repair PDF"),
isVisible: !hasResults, isVisible: !base.hasResults,
loadingText: t("loading"), loadingText: t("loading"),
onClick: handleRepair, onClick: base.handleExecute,
disabled: !repairParams.validateParameters() || !hasFiles || !endpointEnabled, disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
}, },
review: { review: {
isVisible: hasResults, isVisible: base.hasResults,
operation: repairOperation, operation: base.operation,
title: t("repair.results.title", "Repair Results"), title: t("repair.results.title", "Repair Results"),
onFileClick: handleThumbnailClick, onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
}, },
}); });
}; };

View File

@ -1,90 +1,54 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileSelection } from "../contexts/FileContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings"; import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters"; import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation"; import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool"; import { BaseToolProps, ToolComponent } from "../types/tool";
const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Sanitize = (props: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { selectedFiles } = useFileSelection(); const base = useBaseTool(
'sanitize',
const sanitizeParams = useSanitizeParameters(); useSanitizeParameters,
const sanitizeOperation = useSanitizeOperation(); useSanitizeOperation,
props
// Endpoint validation );
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(sanitizeParams.getEndpointName());
useEffect(() => {
sanitizeOperation.resetResults();
onPreviewFile?.(null);
}, [sanitizeParams.parameters]);
const handleSanitize = async () => {
try {
await sanitizeOperation.executeOperation(sanitizeParams.parameters, selectedFiles);
if (sanitizeOperation.files && onComplete) {
onComplete(sanitizeOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : t("sanitize.error.generic", "Sanitization failed"));
}
}
};
const handleSettingsReset = () => {
sanitizeOperation.resetResults();
onPreviewFile?.(null);
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "sanitize");
};
const hasFiles = selectedFiles.length > 0;
const hasResults = sanitizeOperation.files.length > 0;
const settingsCollapsed = !hasFiles || hasResults;
return createToolFlow({ return createToolFlow({
files: { files: {
selectedFiles, selectedFiles: base.selectedFiles,
isCollapsed: hasResults, isCollapsed: base.hasResults,
placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"), placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"),
}, },
steps: [ steps: [
{ {
title: t("sanitize.steps.settings", "Settings"), title: t("sanitize.steps.settings", "Settings"),
isCollapsed: settingsCollapsed, isCollapsed: base.settingsCollapsed,
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined, onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
content: ( content: (
<SanitizeSettings <SanitizeSettings
parameters={sanitizeParams.parameters} parameters={base.params.parameters}
onParameterChange={sanitizeParams.updateParameter} onParameterChange={base.params.updateParameter}
disabled={endpointLoading} disabled={base.endpointLoading}
/> />
), ),
}, },
], ],
executeButton: { executeButton: {
text: t("sanitize.submit", "Sanitize PDF"), text: t("sanitize.submit", "Sanitize PDF"),
isVisible: !hasResults, isVisible: !base.hasResults,
loadingText: t("loading"), loadingText: t("loading"),
onClick: handleSanitize, onClick: base.handleExecute,
disabled: !sanitizeParams.validateParameters() || !hasFiles || !endpointEnabled, disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
}, },
review: { review: {
isVisible: hasResults, isVisible: base.hasResults,
operation: sanitizeOperation, operation: base.operation,
title: t("sanitize.sanitizationResults", "Sanitization Results"), title: t("sanitize.sanitizationResults", "Sanitization Results"),
onFileClick: handleThumbnailClick, onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
}, },
}); });
}; };

View File

@ -1,78 +1,40 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import { useFileSelection } from "../contexts/file/fileHooks";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { useSingleLargePageParameters } from "../hooks/tools/singleLargePage/useSingleLargePageParameters"; import { useSingleLargePageParameters } from "../hooks/tools/singleLargePage/useSingleLargePageParameters";
import { useSingleLargePageOperation } from "../hooks/tools/singleLargePage/useSingleLargePageOperation"; import { useSingleLargePageOperation } from "../hooks/tools/singleLargePage/useSingleLargePageOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool"; import { BaseToolProps, ToolComponent } from "../types/tool";
const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const SingleLargePage = (props: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useNavigationActions();
const { selectedFiles } = useFileSelection();
const singleLargePageParams = useSingleLargePageParameters(); const base = useBaseTool(
const singleLargePageOperation = useSingleLargePageOperation(); 'singleLargePage',
useSingleLargePageParameters,
// Endpoint validation useSingleLargePageOperation,
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(singleLargePageParams.getEndpointName()); props
);
useEffect(() => {
singleLargePageOperation.resetResults();
onPreviewFile?.(null);
}, [singleLargePageParams.parameters]);
const handleConvert = async () => {
try {
await singleLargePageOperation.executeOperation(singleLargePageParams.parameters, selectedFiles);
if (singleLargePageOperation.files && onComplete) {
onComplete(singleLargePageOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : t("pdfToSinglePage.error.failed", "Single large page operation failed"));
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "single-large-page");
actions.setMode("viewer");
};
const handleSettingsReset = () => {
singleLargePageOperation.resetResults();
onPreviewFile?.(null);
};
const hasFiles = selectedFiles.length > 0;
const hasResults = singleLargePageOperation.files.length > 0 || singleLargePageOperation.downloadUrl !== null;
return createToolFlow({ return createToolFlow({
files: { files: {
selectedFiles, selectedFiles: base.selectedFiles,
isCollapsed: hasFiles || hasResults, isCollapsed: base.hasResults,
placeholder: t("pdfToSinglePage.files.placeholder", "Select a PDF file in the main view to get started"), placeholder: t("pdfToSinglePage.files.placeholder", "Select a PDF file in the main view to get started"),
}, },
steps: [], steps: [],
executeButton: { executeButton: {
text: t("pdfToSinglePage.submit", "Convert To Single Page"), text: t("pdfToSinglePage.submit", "Convert To Single Page"),
isVisible: !hasResults, isVisible: !base.hasResults,
loadingText: t("loading"), loadingText: t("loading"),
onClick: handleConvert, onClick: base.handleExecute,
disabled: !singleLargePageParams.validateParameters() || !hasFiles || !endpointEnabled, disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
}, },
review: { review: {
isVisible: hasResults, isVisible: base.hasResults,
operation: singleLargePageOperation, operation: base.operation,
title: t("pdfToSinglePage.results.title", "Single Page Results"), title: t("pdfToSinglePage.results.title", "Single Page Results"),
onFileClick: handleThumbnailClick, onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
}, },
}); });
}; };
@ -80,4 +42,4 @@ const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps)
// Static method to get the operation hook for automation // Static method to get the operation hook for automation
SingleLargePage.tool = () => useSingleLargePageOperation; SingleLargePage.tool = () => useSingleLargePageOperation;
export default SingleLargePage as ToolComponent; export default SingleLargePage as ToolComponent;

View File

@ -1,84 +1,37 @@
import React, { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import SplitSettings from "../components/tools/split/SplitSettings"; import SplitSettings from "../components/tools/split/SplitSettings";
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters"; import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
import { useSplitOperation } from "../hooks/tools/split/useSplitOperation"; import { useSplitOperation } from "../hooks/tools/split/useSplitOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool"; import { BaseToolProps, ToolComponent } from "../types/tool";
const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Split = (props: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useNavigationActions();
const { selectedFiles } = useFileSelection();
const splitParams = useSplitParameters(); const base = useBaseTool(
const splitOperation = useSplitOperation(); 'split',
useSplitParameters,
// Endpoint validation useSplitOperation,
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(splitParams.getEndpointName()); props
);
useEffect(() => {
// Only reset results when parameters change, not when files change
splitOperation.resetResults();
onPreviewFile?.(null);
}, [splitParams.parameters]);
useEffect(() => {
// Reset results when selected files change (user selected different files)
if (selectedFiles.length > 0) {
splitOperation.resetResults();
onPreviewFile?.(null);
}
}, [selectedFiles]);
const handleSplit = async () => {
try {
await splitOperation.executeOperation(splitParams.parameters, selectedFiles);
if (splitOperation.files && onComplete) {
onComplete(splitOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : "Split operation failed");
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "split");
};
const handleSettingsReset = () => {
splitOperation.resetResults();
onPreviewFile?.(null);
};
const hasFiles = selectedFiles.length > 0;
const hasResults = splitOperation.files.length > 0 || splitOperation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;
return createToolFlow({ return createToolFlow({
files: { files: {
selectedFiles, selectedFiles: base.selectedFiles,
isCollapsed: hasResults, isCollapsed: base.hasResults,
placeholder: "Select a PDF file in the main view to get started",
}, },
steps: [ steps: [
{ {
title: "Settings", title: "Settings",
isCollapsed: settingsCollapsed, isCollapsed: base.settingsCollapsed,
onCollapsedClick: hasResults ? handleSettingsReset : undefined, onCollapsedClick: base.hasResults ? base.handleSettingsReset : undefined,
content: ( content: (
<SplitSettings <SplitSettings
parameters={splitParams.parameters} parameters={base.params.parameters}
onParameterChange={splitParams.updateParameter} onParameterChange={base.params.updateParameter}
disabled={endpointLoading} disabled={base.endpointLoading}
/> />
), ),
}, },
@ -86,15 +39,16 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
executeButton: { executeButton: {
text: t("split.submit", "Split PDF"), text: t("split.submit", "Split PDF"),
loadingText: t("loading"), loadingText: t("loading"),
onClick: handleSplit, onClick: base.handleExecute,
isVisible: !hasResults, isVisible: !base.hasResults,
disabled: !splitParams.validateParameters() || !hasFiles || !endpointEnabled, disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
}, },
review: { review: {
isVisible: hasResults, isVisible: base.hasResults,
operation: splitOperation, operation: base.operation,
title: "Split Results", title: "Split Results",
onFileClick: handleThumbnailClick, onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
}, },
}); });
}; };

View File

@ -1,78 +1,40 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import { useFileSelection } from "../contexts/file/fileHooks";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { useUnlockPdfFormsParameters } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters"; import { useUnlockPdfFormsParameters } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters";
import { useUnlockPdfFormsOperation } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation"; import { useUnlockPdfFormsOperation } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool"; import { BaseToolProps, ToolComponent } from "../types/tool";
const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const UnlockPdfForms = (props: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { actions } = useNavigationActions();
const { selectedFiles } = useFileSelection();
const unlockPdfFormsParams = useUnlockPdfFormsParameters(); const base = useBaseTool(
const unlockPdfFormsOperation = useUnlockPdfFormsOperation(); 'unlockPdfForms',
useUnlockPdfFormsParameters,
// Endpoint validation useUnlockPdfFormsOperation,
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(unlockPdfFormsParams.getEndpointName()); props
);
useEffect(() => {
unlockPdfFormsOperation.resetResults();
onPreviewFile?.(null);
}, [unlockPdfFormsParams.parameters]);
const handleUnlock = async () => {
try {
await unlockPdfFormsOperation.executeOperation(unlockPdfFormsParams.parameters, selectedFiles);
if (unlockPdfFormsOperation.files && onComplete) {
onComplete(unlockPdfFormsOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : t("unlockPDFForms.error.failed", "Unlock PDF forms operation failed"));
}
}
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "unlockPdfForms");
actions.setMode("viewer");
};
const handleSettingsReset = () => {
unlockPdfFormsOperation.resetResults();
onPreviewFile?.(null);
};
const hasFiles = selectedFiles.length > 0;
const hasResults = unlockPdfFormsOperation.files.length > 0 || unlockPdfFormsOperation.downloadUrl !== null;
return createToolFlow({ return createToolFlow({
files: { files: {
selectedFiles, selectedFiles: base.selectedFiles,
isCollapsed: hasFiles || hasResults, isCollapsed: base.hasFiles || base.hasResults,
placeholder: t("unlockPDFForms.files.placeholder", "Select a PDF file in the main view to get started"), placeholder: t("unlockPDFForms.files.placeholder", "Select a PDF file in the main view to get started"),
}, },
steps: [], steps: [],
executeButton: { executeButton: {
text: t("unlockPDFForms.submit", "Unlock Forms"), text: t("unlockPDFForms.submit", "Unlock Forms"),
isVisible: !hasResults, isVisible: !base.hasResults,
loadingText: t("loading"), loadingText: t("loading"),
onClick: handleUnlock, onClick: base.handleExecute,
disabled: !unlockPdfFormsParams.validateParameters() || !hasFiles || !endpointEnabled, disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
}, },
review: { review: {
isVisible: hasResults, isVisible: base.hasResults,
operation: unlockPdfFormsOperation, operation: base.operation,
title: t("unlockPDFForms.results.title", "Unlocked Forms Results"), title: t("unlockPDFForms.results.title", "Unlocked Forms Results"),
onFileClick: handleThumbnailClick, onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
}, },
}); });
}; };
@ -80,4 +42,4 @@ const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
// Static method to get the operation hook for automation // Static method to get the operation hook for automation
UnlockPdfForms.tool = () => useUnlockPdfFormsOperation; UnlockPdfForms.tool = () => useUnlockPdfFormsOperation;
export default UnlockPdfForms as ToolComponent; export default UnlockPdfForms as ToolComponent;

View File

@ -101,32 +101,32 @@ export function isFileWithId(file: File): file is FileWithId {
export function createFileWithId(file: File, id?: FileId): FileWithId { export function createFileWithId(file: File, id?: FileId): FileWithId {
const fileId = id || createFileId(); const fileId = id || createFileId();
const quickKey = createQuickKey(file); const quickKey = createQuickKey(file);
const newFile = new File([file], file.name, { const newFile = new File([file], file.name, {
type: file.type, type: file.type,
lastModified: file.lastModified lastModified: file.lastModified
}); });
Object.defineProperty(newFile, 'fileId', { Object.defineProperty(newFile, 'fileId', {
value: fileId, value: fileId,
writable: false, writable: false,
enumerable: true, enumerable: true,
configurable: false configurable: false
}); });
Object.defineProperty(newFile, 'quickKey', { Object.defineProperty(newFile, 'quickKey', {
value: quickKey, value: quickKey,
writable: false, writable: false,
enumerable: true, enumerable: true,
configurable: false configurable: false
}); });
return newFile as FileWithId; return newFile as FileWithId;
} }
// Wrap array of Files with FileIds // Wrap array of Files with FileIds
export function wrapFilesWithIds(files: File[], ids?: FileId[]): FileWithId[] { export function wrapFilesWithIds(files: File[], ids?: FileId[]): FileWithId[] {
return files.map((file, index) => return files.map((file, index) =>
createFileWithId(file, ids?.[index]) createFileWithId(file, ids?.[index])
); );
} }
@ -144,7 +144,7 @@ export function extractFiles(files: FileWithId[]): File[] {
// Check if an object is a File or FileWithId (replaces instanceof File checks) // Check if an object is a File or FileWithId (replaces instanceof File checks)
export function isFileObject(obj: any): obj is File | FileWithId { export function isFileObject(obj: any): obj is File | FileWithId {
return obj && return obj &&
typeof obj.name === 'string' && typeof obj.name === 'string' &&
typeof obj.size === 'number' && typeof obj.size === 'number' &&
typeof obj.type === 'string' && typeof obj.type === 'string' &&
@ -172,12 +172,12 @@ export function isDangerousFileNameAsId(fileName: string, context: string = ''):
if (isValidFileId(fileName)) { if (isValidFileId(fileName)) {
return false; return false;
} }
// Check if it's a quickKey (safe) - format: name|size|lastModified // Check if it's a quickKey (safe) - format: name|size|lastModified
if (/^.+\|\d+\|\d+$/.test(fileName)) { if (/^.+\|\d+\|\d+$/.test(fileName)) {
return false; // quickKeys are legitimate, not dangerous return false; // quickKeys are legitimate, not dangerous
} }
// Common patterns that suggest file.name is being used as ID // Common patterns that suggest file.name is being used as ID
const dangerousPatterns = [ const dangerousPatterns = [
/^[^-]+-page-\d+$/, // pattern: filename-page-123 /^[^-]+-page-\d+$/, // pattern: filename-page-123
@ -187,14 +187,14 @@ export function isDangerousFileNameAsId(fileName: string, context: string = ''):
/['"]/, // contains quotes /['"]/, // contains quotes
/[^a-zA-Z0-9\-._]/ // contains special characters not in UUIDs /[^a-zA-Z0-9\-._]/ // contains special characters not in UUIDs
]; ];
// Check dangerous patterns // Check dangerous patterns
const isDangerous = dangerousPatterns.some(pattern => pattern.test(fileName)); const isDangerous = dangerousPatterns.some(pattern => pattern.test(fileName));
if (isDangerous && context) { if (isDangerous && context) {
console.warn(`⚠️ Potentially dangerous file.name usage detected in ${context}: "${fileName}"`); console.warn(`⚠️ Potentially dangerous file.name usage detected in ${context}: "${fileName}"`);
} }
return isDangerous; return isDangerous;
} }
@ -203,7 +203,7 @@ export function safeGetFileId(file: File, context: string = ''): FileId {
if (isFileWithId(file)) { if (isFileWithId(file)) {
return file.fileId; return file.fileId;
} }
// If we reach here, someone is trying to use a regular File without embedded ID // If we reach here, someone is trying to use a regular File without embedded ID
throw new Error(`Attempted to get FileId from regular File object in ${context}. Use FileWithId instead.`); throw new Error(`Attempted to get FileId from regular File object in ${context}. Use FileWithId instead.`);
} }
@ -304,10 +304,10 @@ export interface FileContextState {
ids: FileId[]; ids: FileId[];
byId: Record<FileId, FileRecord>; byId: Record<FileId, FileRecord>;
}; };
// Pinned files - files that won't be consumed by tools // Pinned files - files that won't be consumed by tools
pinnedFiles: Set<FileId>; pinnedFiles: Set<FileId>;
// UI state - file-related UI state only // UI state - file-related UI state only
ui: { ui: {
selectedFileIds: FileId[]; selectedFileIds: FileId[];
@ -319,35 +319,36 @@ export interface FileContextState {
} }
// Action types for reducer pattern // Action types for reducer pattern
export type FileContextAction = export type FileContextAction =
// File management actions // File management actions
| { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } } | { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } }
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } } | { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } } | { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
| { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } } | { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } }
// Pinned files actions // Pinned files actions
| { type: 'PIN_FILE'; payload: { fileId: FileId } } | { type: 'PIN_FILE'; payload: { fileId: FileId } }
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } } | { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } } | { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } }
| { type: 'UNDO_CONSUME_FILES'; payload: { inputFileRecords: FileRecord[]; outputFileIds: FileId[] } }
// UI actions
// UI actions
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } } | { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
| { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } } | { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } }
| { type: 'CLEAR_SELECTIONS' } | { type: 'CLEAR_SELECTIONS' }
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } } | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
// Navigation guard actions (minimal for file-related unsaved changes only) // Navigation guard actions (minimal for file-related unsaved changes only)
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } } | { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
// Context management // Context management
| { type: 'RESET_CONTEXT' }; | { type: 'RESET_CONTEXT' };
export interface FileContextActions { export interface FileContextActions {
// File management - lightweight actions only // File management - lightweight actions only
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<FileWithId[]>; addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<FileWithId[]>;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<FileWithId[]>; addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<FileWithId[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<FileWithId[]>; addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<FileWithId[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void; updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
reorderFiles: (orderedFileIds: FileId[]) => void; reorderFiles: (orderedFileIds: FileId[]) => void;
@ -358,22 +359,23 @@ export interface FileContextActions {
pinFile: (file: FileWithId) => void; pinFile: (file: FileWithId) => void;
unpinFile: (file: FileWithId) => void; unpinFile: (file: FileWithId) => void;
// File consumption (replace unpinned files with outputs) - now returns FileWithId // File consumption (replace unpinned files with outputs)
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileWithId[]>; consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileId[]>;
undoConsumeFiles: (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]) => Promise<void>;
// Selection management // Selection management
setSelectedFiles: (fileIds: FileId[]) => void; setSelectedFiles: (fileIds: FileId[]) => void;
setSelectedPages: (pageNumbers: number[]) => void; setSelectedPages: (pageNumbers: number[]) => void;
clearSelections: () => void; clearSelections: () => void;
// Processing state - simple flags only // Processing state - simple flags only
setProcessing: (isProcessing: boolean, progress?: number) => void; setProcessing: (isProcessing: boolean, progress?: number) => void;
// File-related unsaved changes (minimal navigation guard support) // File-related unsaved changes (minimal navigation guard support)
setHasUnsavedChanges: (hasChanges: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void;
// Context management // Context management
resetContext: () => void; resetContext: () => void;
// Resource management // Resource management
trackBlobUrl: (url: string) => void; trackBlobUrl: (url: string) => void;
scheduleCleanup: (fileId: FileId, delay?: number) => void; scheduleCleanup: (fileId: FileId, delay?: number) => void;
@ -393,7 +395,7 @@ export interface FileContextSelectors {
getPinnedFiles: () => FileWithId[]; getPinnedFiles: () => FileWithId[];
getPinnedFileRecords: () => FileRecord[]; getPinnedFileRecords: () => FileRecord[];
isFilePinned: (file: FileWithId) => boolean; isFilePinned: (file: FileWithId) => boolean;
// Stable signature for effect dependencies // Stable signature for effect dependencies
getFilesSignature: () => string; getFilesSignature: () => string;
} }