Merge branch 'V2' into posthog

This commit is contained in:
Connor Yoh 2025-08-28 12:42:00 +01:00
commit 6daae8fcd4
64 changed files with 1114 additions and 1247 deletions

View File

@ -50,6 +50,7 @@
"title": "Files", "title": "Files",
"placeholder": "Select a PDF file in the main view to get started", "placeholder": "Select a PDF file in the main view to get started",
"upload": "Upload", "upload": "Upload",
"uploadFiles": "Upload Files",
"addFiles": "Add files", "addFiles": "Add files",
"selectFromWorkbench": "Select files from the workbench or " "selectFromWorkbench": "Select files from the workbench or "
}, },
@ -2068,7 +2069,7 @@
"loading": "Loading...", "loading": "Loading...",
"or": "or", "or": "or",
"dropFileHere": "Drop file here or click to upload", "dropFileHere": "Drop file here or click to upload",
"dropFilesHere": "Drop files here or click to upload", "dropFilesHere": "Drop files here or click the upload button",
"pdfFilesOnly": "PDF files only", "pdfFilesOnly": "PDF files only",
"supportedFileTypes": "Supported file types", "supportedFileTypes": "Supported file types",
"upload": "Upload", "upload": "Upload",
@ -2381,4 +2382,4 @@
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
} }
} }
} }

View File

@ -16,6 +16,7 @@ import styles from './FileEditor.module.css';
import FileEditorThumbnail from './FileEditorThumbnail'; import FileEditorThumbnail from './FileEditorThumbnail';
import FilePickerModal from '../shared/FilePickerModal'; import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader'; import SkeletonLoader from '../shared/SkeletonLoader';
import { FileId } from '../../types/file';
interface FileEditorProps { interface FileEditorProps {
@ -46,17 +47,17 @@ const FileEditor = ({
// Use optimized FileContext hooks // Use optimized FileContext hooks
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement(); const { addFiles, removeFiles, reorderFiles } = useFileManagement();
// Extract needed values from state (memoized to prevent infinite loops) // Extract needed values from state (memoized to prevent infinite loops)
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]); const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]); const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
const selectedFileIds = state.ui.selectedFileIds; const selectedFileIds = state.ui.selectedFileIds;
const isProcessing = state.ui.isProcessing; const isProcessing = state.ui.isProcessing;
// Get the real context actions // Get the real context actions
const { actions } = useFileActions(); const { actions } = useFileActions();
const { actions: navActions } = useNavigationActions(); const { actions: navActions } = useNavigationActions();
// Get file selection context // Get file selection context
const { setSelectedFiles } = useFileSelection(); const { setSelectedFiles } = useFileSelection();
@ -86,9 +87,9 @@ const FileEditor = ({
}); });
// Get selected file IDs from context (defensive programming) // Get selected file IDs from context (defensive programming)
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
// Create refs for frequently changing values to stabilize callbacks // Create refs for frequently changing values to stabilize callbacks
const contextSelectedIdsRef = useRef<string[]>([]); const contextSelectedIdsRef = useRef<FileId[]>([]);
contextSelectedIdsRef.current = contextSelectedIds; contextSelectedIdsRef.current = contextSelectedIds;
// Use activeFileRecords directly - no conversion needed // Use activeFileRecords directly - no conversion needed
@ -98,7 +99,7 @@ const FileEditor = ({
const recordToFileItem = useCallback((record: any) => { const recordToFileItem = useCallback((record: any) => {
const file = selectors.getFile(record.id); const file = selectors.getFile(record.id);
if (!file) return null; if (!file) return null;
return { return {
id: record.id, id: record.id,
name: file.name, name: file.name,
@ -166,7 +167,7 @@ const FileEditor = ({
id: operationId, id: operationId,
type: 'convert', type: 'convert',
timestamp: Date.now(), timestamp: Date.now(),
fileIds: extractionResult.extractedFiles.map(f => f.name), fileIds: extractionResult.extractedFiles.map(f => f.name) as FileId[] /* FIX ME: This doesn't seem right */,
status: 'pending', status: 'pending',
metadata: { metadata: {
originalFileName: file.name, originalFileName: file.name,
@ -179,7 +180,7 @@ const FileEditor = ({
} }
} }
}; };
if (extractionResult.errors.length > 0) { if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors); errors.push(...extractionResult.errors);
} }
@ -219,7 +220,7 @@ const FileEditor = ({
id: operationId, id: operationId,
type: 'upload', type: 'upload',
timestamp: Date.now(), timestamp: Date.now(),
fileIds: [file.name], fileIds: [file.name as FileId /* This doesn't seem right */],
status: 'pending', status: 'pending',
metadata: { metadata: {
originalFileName: file.name, originalFileName: file.name,
@ -239,7 +240,7 @@ const FileEditor = ({
const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
setError(errorMessage); setError(errorMessage);
console.error('File processing error:', err); console.error('File processing error:', err);
// Reset extraction progress on error // Reset extraction progress on error
setZipExtractionProgress({ setZipExtractionProgress({
isExtracting: false, isExtracting: false,
@ -263,21 +264,21 @@ const FileEditor = ({
// Remove all files from context but keep in storage // Remove all files from context but keep in storage
const allFileIds = activeFileRecords.map(record => record.id); const allFileIds = activeFileRecords.map(record => record.id);
removeFiles(allFileIds, false); // false = keep in storage removeFiles(allFileIds, false); // false = keep in storage
// Clear selections // Clear selections
setSelectedFiles([]); setSelectedFiles([]);
}, [activeFileRecords, removeFiles, setSelectedFiles]); }, [activeFileRecords, removeFiles, setSelectedFiles]);
const toggleFile = useCallback((fileId: string) => { const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current; const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeFileRecords.find(r => r.id === fileId); const targetRecord = activeFileRecords.find(r => r.id === fileId);
if (!targetRecord) return; if (!targetRecord) return;
const contextFileId = fileId; // No need to create a new ID const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId); const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: string[]; let newSelection: FileId[];
if (isSelected) { if (isSelected) {
// Remove file from selection // Remove file from selection
@ -286,7 +287,7 @@ const FileEditor = ({
// Add file to selection // Add file to selection
// In tool mode, typically allow multiple files unless specified otherwise // In tool mode, typically allow multiple files unless specified otherwise
const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools
if (maxAllowed === 1) { if (maxAllowed === 1) {
newSelection = [contextFileId]; newSelection = [contextFileId];
} else { } else {
@ -314,30 +315,30 @@ const FileEditor = ({
}, [setSelectedFiles]); }, [setSelectedFiles]);
// File reordering handler for drag and drop // File reordering handler for drag and drop
const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => { const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeFileRecords.map(r => r.id); const currentIds = activeFileRecords.map(r => r.id);
// Find indices // Find indices
const sourceIndex = currentIds.findIndex(id => id === sourceFileId); const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
const targetIndex = currentIds.findIndex(id => id === targetFileId); const targetIndex = currentIds.findIndex(id => id === targetFileId);
if (sourceIndex === -1 || targetIndex === -1) { if (sourceIndex === -1 || targetIndex === -1) {
console.warn('Could not find source or target file for reordering'); console.warn('Could not find source or target file for reordering');
return; return;
} }
// Handle multi-file selection reordering // Handle multi-file selection reordering
const filesToMove = selectedFileIds.length > 1 const filesToMove = selectedFileIds.length > 1
? selectedFileIds.filter(id => currentIds.includes(id)) ? selectedFileIds.filter(id => currentIds.includes(id))
: [sourceFileId]; : [sourceFileId];
// Create new order // Create new order
const newOrder = [...currentIds]; const newOrder = [...currentIds];
// Remove files to move from their current positions (in reverse order to maintain indices) // Remove files to move from their current positions (in reverse order to maintain indices)
const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id)) const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id))
.sort((a, b) => b - a); // Sort descending .sort((a, b) => b - a); // Sort descending
sourceIndices.forEach(index => { sourceIndices.forEach(index => {
newOrder.splice(index, 1); newOrder.splice(index, 1);
}); });
@ -372,7 +373,7 @@ const FileEditor = ({
// File operations using context // File operations using context
const handleDeleteFile = useCallback((fileId: string) => { const handleDeleteFile = useCallback((fileId: FileId) => {
const record = activeFileRecords.find(r => r.id === fileId); const record = activeFileRecords.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null; const file = record ? selectors.getFile(record.id) : null;
@ -385,7 +386,7 @@ const FileEditor = ({
id: operationId, id: operationId,
type: 'remove', type: 'remove',
timestamp: Date.now(), timestamp: Date.now(),
fileIds: [fileName], fileIds: [fileName as FileId /* FIX ME: This doesn't seem right */],
status: 'pending', status: 'pending',
metadata: { metadata: {
originalFileName: fileName, originalFileName: fileName,
@ -396,7 +397,7 @@ const FileEditor = ({
} }
} }
}; };
// Remove file from context but keep in storage (close, don't delete) // Remove file from context but keep in storage (close, don't delete)
removeFiles([contextFileId], false); removeFiles([contextFileId], false);
@ -406,7 +407,7 @@ const FileEditor = ({
} }
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]); }, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleViewFile = useCallback((fileId: string) => { const handleViewFile = useCallback((fileId: FileId) => {
const record = activeFileRecords.find(r => r.id === fileId); const record = activeFileRecords.find(r => r.id === fileId);
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
@ -415,7 +416,7 @@ const FileEditor = ({
} }
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]); }, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
const handleMergeFromHere = useCallback((fileId: string) => { const handleMergeFromHere = useCallback((fileId: FileId) => {
const startIndex = activeFileRecords.findIndex(r => r.id === fileId); const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
if (startIndex === -1) return; if (startIndex === -1) return;
@ -426,14 +427,14 @@ const FileEditor = ({
} }
}, [activeFileRecords, selectors, onMergeFiles]); }, [activeFileRecords, selectors, onMergeFiles]);
const handleSplitFile = useCallback((fileId: string) => { const handleSplitFile = useCallback((fileId: FileId) => {
const file = selectors.getFile(fileId); const file = selectors.getFile(fileId);
if (file && onOpenPageEditor) { if (file && onOpenPageEditor) {
onOpenPageEditor(file); onOpenPageEditor(file);
} }
}, [selectors, onOpenPageEditor]); }, [selectors, onOpenPageEditor]);
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => { const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
if (selectedFiles.length === 0) return; if (selectedFiles.length === 0) return;
try { try {
@ -513,11 +514,11 @@ const FileEditor = ({
<SkeletonLoader type="fileGrid" count={6} /> <SkeletonLoader type="fileGrid" count={6} />
</Box> </Box>
) : ( ) : (
<div <div
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1.5rem', gap: '1.5rem',
padding: '1rem', padding: '1rem',
pointerEvents: 'auto' pointerEvents: 'auto'
}} }}
@ -525,7 +526,7 @@ const FileEditor = ({
{activeFileRecords.map((record, index) => { {activeFileRecords.map((record, index) => {
const fileItem = recordToFileItem(record); const fileItem = recordToFileItem(record);
if (!fileItem) return null; if (!fileItem) return null;
return ( return (
<FileEditorThumbnail <FileEditorThumbnail
key={record.id} key={record.id}

View File

@ -11,9 +11,10 @@ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-d
import styles from './FileEditor.module.css'; import styles from './FileEditor.module.css';
import { useFileContext } from '../../contexts/FileContext'; import { useFileContext } from '../../contexts/FileContext';
import { FileId } from '../../types/file';
interface FileItem { interface FileItem {
id: string; id: FileId;
name: string; name: string;
pageCount: number; pageCount: number;
thumbnail: string | null; thumbnail: string | null;
@ -25,14 +26,14 @@ interface FileEditorThumbnailProps {
file: FileItem; file: FileItem;
index: number; index: number;
totalFiles: number; totalFiles: number;
selectedFiles: string[]; selectedFiles: FileId[];
selectionMode: boolean; selectionMode: boolean;
onToggleFile: (fileId: string) => void; onToggleFile: (fileId: FileId) => void;
onDeleteFile: (fileId: string) => void; onDeleteFile: (fileId: FileId) => void;
onViewFile: (fileId: string) => void; onViewFile: (fileId: FileId) => void;
onSetStatus: (status: string) => void; onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void; onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile?: (fileId: string) => void; onDownloadFile?: (fileId: FileId) => void;
toolMode?: boolean; toolMode?: boolean;
isSupported?: boolean; isSupported?: boolean;
} }
@ -161,8 +162,8 @@ const FileEditorThumbnail = ({
onDrop: ({ source }) => { onDrop: ({ source }) => {
const sourceData = source.data; const sourceData = source.data;
if (sourceData.type === 'file' && onReorderFiles) { if (sourceData.type === 'file' && onReorderFiles) {
const sourceFileId = sourceData.fileId as string; const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as string[]; const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds); onReorderFiles(sourceFileId, file.id, selectedFileIds);
} }
} }
@ -332,7 +333,7 @@ const FileEditorThumbnail = ({
)} )}
{/* Title + meta line */} {/* Title + meta line */}
<div <div
style={{ style={{
padding: '0.5rem', padding: '0.5rem',
textAlign: 'center', textAlign: 'center',
@ -404,4 +405,4 @@ const FileEditorThumbnail = ({
); );
}; };
export default React.memo(FileEditorThumbnail); export default React.memo(FileEditorThumbnail);

View File

@ -12,11 +12,12 @@ import {
Divider Divider
} from '@mantine/core'; } from '@mantine/core';
// FileContext no longer needed - these were stub functions anyway // FileContext no longer needed - these were stub functions anyway
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext'; import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
import { PageOperation } from '../../types/pageEditor'; import { PageOperation } from '../../types/pageEditor';
import { FileId } from '../../types/file';
interface FileOperationHistoryProps { interface FileOperationHistoryProps {
fileId: string; fileId: FileId;
showOnlyApplied?: boolean; showOnlyApplied?: boolean;
maxHeight?: number; maxHeight?: number;
} }
@ -27,8 +28,8 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
maxHeight = 400 maxHeight = 400
}) => { }) => {
// These were stub functions in the old context - replace with empty stubs // These were stub functions in the old context - replace with empty stubs
const getFileHistory = (fileId: string) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() }); const getFileHistory = (fileId: FileId) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() });
const getAppliedOperations = (fileId: string) => []; const getAppliedOperations = (fileId: FileId) => [];
const history = getFileHistory(fileId); const history = getFileHistory(fileId);
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || []; const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];

View File

@ -11,9 +11,10 @@ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-d
import styles from './PageEditor.module.css'; import styles from './PageEditor.module.css';
import { useFileContext } from '../../contexts/FileContext'; import { useFileContext } from '../../contexts/FileContext';
import { FileId } from '../../types/file';
interface FileItem { interface FileItem {
id: string; id: FileId;
name: string; name: string;
pageCount: number; pageCount: number;
thumbnail: string | null; thumbnail: string | null;
@ -27,12 +28,12 @@ interface FileThumbnailProps {
totalFiles: number; totalFiles: number;
selectedFiles: string[]; selectedFiles: string[];
selectionMode: boolean; selectionMode: boolean;
onToggleFile: (fileId: string) => void; onToggleFile: (fileId: FileId) => void;
onDeleteFile: (fileId: string) => void; onDeleteFile: (fileId: FileId) => void;
onViewFile: (fileId: string) => void; onViewFile: (fileId: FileId) => void;
onSetStatus: (status: string) => void; onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void; onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile?: (fileId: string) => void; onDownloadFile?: (fileId: FileId) => void;
toolMode?: boolean; toolMode?: boolean;
isSupported?: boolean; isSupported?: boolean;
} }
@ -161,8 +162,8 @@ const FileThumbnail = ({
onDrop: ({ source }) => { onDrop: ({ source }) => {
const sourceData = source.data; const sourceData = source.data;
if (sourceData.type === 'file' && onReorderFiles) { if (sourceData.type === 'file' && onReorderFiles) {
const sourceFileId = sourceData.fileId as string; const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as string[]; const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds); onReorderFiles(sourceFileId, file.id, selectedFileIds);
} }
} }

View File

@ -16,6 +16,7 @@ import PageThumbnail from './PageThumbnail';
import DragDropGrid from './DragDropGrid'; import DragDropGrid from './DragDropGrid';
import SkeletonLoader from '../shared/SkeletonLoader'; import SkeletonLoader from '../shared/SkeletonLoader';
import NavigationWarningModal from '../shared/NavigationWarningModal'; import NavigationWarningModal from '../shared/NavigationWarningModal';
import { FileId } from "../../types/file";
import { import {
DOMCommand, DOMCommand,
@ -172,7 +173,8 @@ const PageEditor = ({
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({ const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
execute: () => { execute: () => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
undoManagerRef.current.executeCommand(bulkRotateCommand);
undoManagerRef.current.executeCommand(bulkRotateCommand);
} }
}), []); }), []);
@ -181,7 +183,8 @@ const PageEditor = ({
if (!displayDocument) return; if (!displayDocument) return;
const pagesToDelete = pageIds.map(pageId => { const pagesToDelete = pageIds.map(pageId => {
const page = displayDocument.pages.find(p => p.id === pageId);
const page = displayDocument.pages.find(p => p.id === pageId);
return page?.pageNumber || 0; return page?.pageNumber || 0;
}).filter(num => num > 0); }).filter(num => num > 0);
@ -212,7 +215,7 @@ const PageEditor = ({
); );
undoManagerRef.current.executeCommand(splitCommand); undoManagerRef.current.executeCommand(splitCommand);
} }
}), [splitPositions]); }), [splitPositions]);
// Command executor for PageThumbnail // Command executor for PageThumbnail
const executeCommand = useCallback((command: any) => { const executeCommand = useCallback((command: any) => {
@ -442,8 +445,8 @@ const PageEditor = ({
}, [displayDocument, getPageNumbersFromIds]); }, [displayDocument, getPageNumbersFromIds]);
// Helper function to collect source files for multi-file export // Helper function to collect source files for multi-file export
const getSourceFiles = useCallback((): Map<string, File> | null => { const getSourceFiles = useCallback((): Map<FileId, File> | null => {
const sourceFiles = new Map<string, File>(); const sourceFiles = new Map<FileId, File>();
// Always include original files // Always include original files
activeFileIds.forEach(fileId => { activeFileIds.forEach(fileId => {
@ -621,6 +624,7 @@ const PageEditor = ({
const closePdf = useCallback(() => { const closePdf = useCallback(() => {
actions.clearAllFiles(); actions.clearAllFiles();
undoManagerRef.current.clear(); undoManagerRef.current.clear();
setSelectedPageIds([]); setSelectedPageIds([]);
setSelectionMode(false); setSelectionMode(false);
@ -631,7 +635,7 @@ const PageEditor = ({
if (!displayDocument) return; if (!displayDocument) return;
// For now, trigger the actual export directly // For now, trigger the actual export directly
// In the original, this would show a preview modal first // In the original, this would show a preview modal first
if (selectedOnly) { if (selectedOnly) {
onExportSelected(); onExportSelected();
} else { } else {

View File

@ -1,3 +1,4 @@
import { FileId } from '../../../types/file';
import { PDFDocument, PDFPage } from '../../../types/pageEditor'; import { PDFDocument, PDFPage } from '../../../types/pageEditor';
// V1-style DOM-first command system (replaces the old React state commands) // V1-style DOM-first command system (replaces the old React state commands)
@ -83,18 +84,18 @@ export class DeletePagesCommand extends DOMCommand {
}; };
this.originalSplitPositions = new Set(this.getSplitPositions()); this.originalSplitPositions = new Set(this.getSplitPositions());
this.originalSelectedPages = [...this.getSelectedPages()]; this.originalSelectedPages = [...this.getSelectedPages()];
// Convert page numbers to page IDs for stable identification // Convert page numbers to page IDs for stable identification
this.pageIdsToDelete = this.pagesToDelete.map(pageNum => { this.pageIdsToDelete = this.pagesToDelete.map(pageNum => {
const page = currentDoc.pages.find(p => p.pageNumber === pageNum); const page = currentDoc.pages.find(p => p.pageNumber === pageNum);
return page?.id || ''; return page?.id || '';
}).filter(id => id); }).filter(id => id);
this.hasExecuted = true; this.hasExecuted = true;
} }
// Filter out deleted pages by ID (stable across undo/redo) // Filter out deleted pages by ID (stable across undo/redo)
const remainingPages = currentDoc.pages.filter(page => const remainingPages = currentDoc.pages.filter(page =>
!this.pageIdsToDelete.includes(page.id) !this.pageIdsToDelete.includes(page.id)
); );
@ -172,20 +173,20 @@ export class ReorderPagesCommand extends DOMCommand {
const selectedPageObjects = this.selectedPages const selectedPageObjects = this.selectedPages
.map(pageNum => currentDoc.pages.find(p => p.pageNumber === pageNum)) .map(pageNum => currentDoc.pages.find(p => p.pageNumber === pageNum))
.filter(page => page !== undefined) as PDFPage[]; .filter(page => page !== undefined) as PDFPage[];
const remainingPages = newPages.filter(page => !this.selectedPages!.includes(page.pageNumber)); const remainingPages = newPages.filter(page => !this.selectedPages!.includes(page.pageNumber));
remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects); remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects);
remainingPages.forEach((page, index) => { remainingPages.forEach((page, index) => {
page.pageNumber = index + 1; page.pageNumber = index + 1;
}); });
newPages.splice(0, newPages.length, ...remainingPages); newPages.splice(0, newPages.length, ...remainingPages);
} else { } else {
// Single page reorder // Single page reorder
const [movedPage] = newPages.splice(sourceIndex, 1); const [movedPage] = newPages.splice(sourceIndex, 1);
newPages.splice(this.targetIndex, 0, movedPage); newPages.splice(this.targetIndex, 0, movedPage);
newPages.forEach((page, index) => { newPages.forEach((page, index) => {
page.pageNumber = index + 1; page.pageNumber = index + 1;
}); });
@ -237,13 +238,13 @@ export class SplitCommand extends DOMCommand {
// Toggle the split position // Toggle the split position
const currentPositions = this.getSplitPositions(); const currentPositions = this.getSplitPositions();
const newPositions = new Set(currentPositions); const newPositions = new Set(currentPositions);
if (newPositions.has(this.position)) { if (newPositions.has(this.position)) {
newPositions.delete(this.position); newPositions.delete(this.position);
} else { } else {
newPositions.add(this.position); newPositions.add(this.position);
} }
this.setSplitPositions(newPositions); this.setSplitPositions(newPositions);
} }
@ -282,7 +283,7 @@ export class BulkRotateCommand extends DOMCommand {
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
this.originalRotations.set(pageId, currentRotation); this.originalRotations.set(pageId, currentRotation);
} }
// Apply rotation using transform to trigger CSS animation // Apply rotation using transform to trigger CSS animation
const currentTransform = img.style.transform || ''; const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
@ -354,7 +355,7 @@ export class BulkSplitCommand extends DOMCommand {
export class SplitAllCommand extends DOMCommand { export class SplitAllCommand extends DOMCommand {
private originalSplitPositions: Set<number> = new Set(); private originalSplitPositions: Set<number> = new Set();
private allPossibleSplits: Set<number> = new Set(); private allPossibleSplits: Set<number> = new Set();
constructor( constructor(
private totalPages: number, private totalPages: number,
private getSplitPositions: () => Set<number>, private getSplitPositions: () => Set<number>,
@ -366,15 +367,15 @@ export class SplitAllCommand extends DOMCommand {
this.allPossibleSplits.add(i); this.allPossibleSplits.add(i);
} }
} }
execute(): void { execute(): void {
// Store original state for undo // Store original state for undo
this.originalSplitPositions = new Set(this.getSplitPositions()); this.originalSplitPositions = new Set(this.getSplitPositions());
// Check if all splits are already active // Check if all splits are already active
const currentSplits = this.getSplitPositions(); const currentSplits = this.getSplitPositions();
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos)); const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
if (hasAllSplits) { if (hasAllSplits) {
// Remove all splits // Remove all splits
this.setSplitPositions(new Set()); this.setSplitPositions(new Set());
@ -383,12 +384,12 @@ export class SplitAllCommand extends DOMCommand {
this.setSplitPositions(this.allPossibleSplits); this.setSplitPositions(this.allPossibleSplits);
} }
} }
undo(): void { undo(): void {
// Restore original split positions // Restore original split positions
this.setSplitPositions(this.originalSplitPositions); this.setSplitPositions(this.originalSplitPositions);
} }
get description(): string { get description(): string {
const currentSplits = this.getSplitPositions(); const currentSplits = this.getSplitPositions();
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos)); const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
@ -453,7 +454,7 @@ export class PageBreakCommand extends DOMCommand {
}; };
this.setDocument(updatedDocument); this.setDocument(updatedDocument);
// No need to maintain selection - page IDs remain stable, so selection persists automatically // No need to maintain selection - page IDs remain stable, so selection persists automatically
} }
@ -529,7 +530,7 @@ export class BulkPageBreakCommand extends DOMCommand {
}; };
this.setDocument(updatedDocument); this.setDocument(updatedDocument);
// Maintain existing selection by mapping original selected pages to their new positions // Maintain existing selection by mapping original selected pages to their new positions
const updatedSelection: number[] = []; const updatedSelection: number[] = [];
this.originalSelectedPages.forEach(originalPageNum => { this.originalSelectedPages.forEach(originalPageNum => {
@ -558,9 +559,9 @@ export class BulkPageBreakCommand extends DOMCommand {
export class InsertFilesCommand extends DOMCommand { export class InsertFilesCommand extends DOMCommand {
private insertedPages: PDFPage[] = []; private insertedPages: PDFPage[] = [];
private originalDocument: PDFDocument | null = null; private originalDocument: PDFDocument | null = null;
private fileDataMap = new Map<string, ArrayBuffer>(); // Store file data for thumbnail generation private fileDataMap = new Map<FileId, ArrayBuffer>(); // Store file data for thumbnail generation
private originalProcessedFile: any = null; // Store original ProcessedFile for undo private originalProcessedFile: any = null; // Store original ProcessedFile for undo
private insertedFileMap = new Map<string, File>(); // Store inserted files for export private insertedFileMap = new Map<FileId, File>(); // Store inserted files for export
constructor( constructor(
private files: File[], private files: File[],
@ -569,7 +570,7 @@ export class InsertFilesCommand extends DOMCommand {
private setDocument: (doc: PDFDocument) => void, private setDocument: (doc: PDFDocument) => void,
private setSelectedPages: (pages: number[]) => void, private setSelectedPages: (pages: number[]) => void,
private getSelectedPages: () => number[], private getSelectedPages: () => number[],
private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<string, File>) => void private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<FileId, File>) => void
) { ) {
super(); super();
} }
@ -587,19 +588,19 @@ export class InsertFilesCommand extends DOMCommand {
try { try {
// Process each file to extract pages and wait for all to complete // Process each file to extract pages and wait for all to complete
const allNewPages: PDFPage[] = []; const allNewPages: PDFPage[] = [];
// Process all files and wait for their completion // Process all files and wait for their completion
const baseTimestamp = Date.now(); const baseTimestamp = Date.now();
const extractionPromises = this.files.map(async (file, index) => { const extractionPromises = this.files.map(async (file, index) => {
const fileId = `inserted-${file.name}-${baseTimestamp + index}`; const fileId = `inserted-${file.name}-${baseTimestamp + index}` as FileId;
// Store inserted file for export // Store inserted file for export
this.insertedFileMap.set(fileId, file); this.insertedFileMap.set(fileId, file);
// Use base timestamp + index to ensure unique but predictable file IDs // Use base timestamp + index to ensure unique but predictable file IDs
return await this.extractPagesFromFile(file, baseTimestamp + index); return await this.extractPagesFromFile(file, baseTimestamp + index);
}); });
const extractedPageArrays = await Promise.all(extractionPromises); const extractedPageArrays = await Promise.all(extractionPromises);
// Flatten all extracted pages // Flatten all extracted pages
for (const pages of extractedPageArrays) { for (const pages of extractedPageArrays) {
allNewPages.push(...pages); allNewPages.push(...pages);
@ -658,7 +659,7 @@ export class InsertFilesCommand extends DOMCommand {
// Maintain existing selection by mapping original selected pages to their new positions // Maintain existing selection by mapping original selected pages to their new positions
const originalSelection = this.getSelectedPages(); const originalSelection = this.getSelectedPages();
const updatedSelection: number[] = []; const updatedSelection: number[] = [];
originalSelection.forEach(originalPageNum => { originalSelection.forEach(originalPageNum => {
if (originalPageNum <= this.insertAfterPageNumber) { if (originalPageNum <= this.insertAfterPageNumber) {
// Pages before insertion point keep same number // Pages before insertion point keep same number
@ -668,7 +669,7 @@ export class InsertFilesCommand extends DOMCommand {
updatedSelection.push(originalPageNum + allNewPages.length); updatedSelection.push(originalPageNum + allNewPages.length);
} }
}); });
this.setSelectedPages(updatedSelection); this.setSelectedPages(updatedSelection);
} catch (error) { } catch (error) {
@ -683,35 +684,35 @@ export class InsertFilesCommand extends DOMCommand {
private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise<void> { private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise<void> {
try { try {
const { thumbnailGenerationService } = await import('../../../services/thumbnailGenerationService'); const { thumbnailGenerationService } = await import('../../../services/thumbnailGenerationService');
// Group pages by file ID to generate thumbnails efficiently // Group pages by file ID to generate thumbnails efficiently
const pagesByFileId = new Map<string, PDFPage[]>(); const pagesByFileId = new Map<FileId, PDFPage[]>();
for (const page of this.insertedPages) { for (const page of this.insertedPages) {
const fileId = page.id.substring(0, page.id.lastIndexOf('-page-')); const fileId = page.id.substring(0, page.id.lastIndexOf('-page-')) as FileId /* FIX ME: This looks wrong - like we've thrown away info too early and need to recreate it */;
if (!pagesByFileId.has(fileId)) { if (!pagesByFileId.has(fileId)) {
pagesByFileId.set(fileId, []); pagesByFileId.set(fileId, []);
} }
pagesByFileId.get(fileId)!.push(page); pagesByFileId.get(fileId)!.push(page);
} }
// Generate thumbnails for each file // Generate thumbnails for each file
for (const [fileId, pages] of pagesByFileId) { for (const [fileId, pages] of pagesByFileId) {
const arrayBuffer = this.fileDataMap.get(fileId); const arrayBuffer = this.fileDataMap.get(fileId);
console.log('Generating thumbnails for file:', fileId); console.log('Generating thumbnails for file:', fileId);
console.log('Pages:', pages.length); console.log('Pages:', pages.length);
console.log('ArrayBuffer size:', arrayBuffer?.byteLength || 'undefined'); console.log('ArrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
if (arrayBuffer && arrayBuffer.byteLength > 0) { if (arrayBuffer && arrayBuffer.byteLength > 0) {
// Extract page numbers for all pages from this file // Extract page numbers for all pages from this file
const pageNumbers = pages.map(page => { const pageNumbers = pages.map(page => {
const pageNumMatch = page.id.match(/-page-(\d+)$/); const pageNumMatch = page.id.match(/-page-(\d+)$/);
return pageNumMatch ? parseInt(pageNumMatch[1]) : 1; return pageNumMatch ? parseInt(pageNumMatch[1]) : 1;
}); });
console.log('Generating thumbnails for page numbers:', pageNumbers); console.log('Generating thumbnails for page numbers:', pageNumbers);
// Generate thumbnails for all pages from this file at once // Generate thumbnails for all pages from this file at once
const results = await thumbnailGenerationService.generateThumbnails( const results = await thumbnailGenerationService.generateThumbnails(
fileId, fileId,
@ -719,14 +720,14 @@ export class InsertFilesCommand extends DOMCommand {
pageNumbers, pageNumbers,
{ scale: 0.2, quality: 0.8 } { scale: 0.2, quality: 0.8 }
); );
console.log('Thumbnail generation results:', results.length, 'thumbnails generated'); console.log('Thumbnail generation results:', results.length, 'thumbnails generated');
// Update pages with generated thumbnails // Update pages with generated thumbnails
for (let i = 0; i < results.length && i < pages.length; i++) { for (let i = 0; i < results.length && i < pages.length; i++) {
const result = results[i]; const result = results[i];
const page = pages[i]; const page = pages[i];
if (result.success) { if (result.success) {
const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id); const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id);
if (pageIndex >= 0) { if (pageIndex >= 0) {
@ -735,7 +736,7 @@ export class InsertFilesCommand extends DOMCommand {
} }
} }
} }
// Trigger re-render by updating the document // Trigger re-render by updating the document
this.setDocument({ ...updatedDocument }); this.setDocument({ ...updatedDocument });
} else { } else {
@ -754,7 +755,7 @@ export class InsertFilesCommand extends DOMCommand {
try { try {
const arrayBuffer = event.target?.result as ArrayBuffer; const arrayBuffer = event.target?.result as ArrayBuffer;
console.log('File reader onload - arrayBuffer size:', arrayBuffer?.byteLength || 'undefined'); console.log('File reader onload - arrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
if (!arrayBuffer) { if (!arrayBuffer) {
reject(new Error('Failed to read file')); reject(new Error('Failed to read file'));
return; return;
@ -762,24 +763,24 @@ export class InsertFilesCommand extends DOMCommand {
// Clone the ArrayBuffer before passing to PDF.js (it might consume it) // Clone the ArrayBuffer before passing to PDF.js (it might consume it)
const clonedArrayBuffer = arrayBuffer.slice(0); const clonedArrayBuffer = arrayBuffer.slice(0);
// Use PDF.js via the worker manager to extract pages // Use PDF.js via the worker manager to extract pages
const { pdfWorkerManager } = await import('../../../services/pdfWorkerManager'); const { pdfWorkerManager } = await import('../../../services/pdfWorkerManager');
const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer); const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer);
const pageCount = pdf.numPages; const pageCount = pdf.numPages;
const pages: PDFPage[] = []; const pages: PDFPage[] = [];
const fileId = `inserted-${file.name}-${baseTimestamp}`; const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId;
console.log('Original ArrayBuffer size:', arrayBuffer.byteLength); console.log('Original ArrayBuffer size:', arrayBuffer.byteLength);
console.log('Storing ArrayBuffer for fileId:', fileId, 'size:', arrayBuffer.byteLength); console.log('Storing ArrayBuffer for fileId:', fileId, 'size:', arrayBuffer.byteLength);
// Store the original ArrayBuffer for thumbnail generation // Store the original ArrayBuffer for thumbnail generation
this.fileDataMap.set(fileId, arrayBuffer); this.fileDataMap.set(fileId, arrayBuffer);
console.log('After storing - fileDataMap size:', this.fileDataMap.size); console.log('After storing - fileDataMap size:', this.fileDataMap.size);
console.log('Stored value size:', this.fileDataMap.get(fileId)?.byteLength || 'undefined'); console.log('Stored value size:', this.fileDataMap.get(fileId)?.byteLength || 'undefined');
for (let i = 1; i <= pageCount; i++) { for (let i = 1; i <= pageCount; i++) {
const pageId = `${fileId}-page-${i}`; const pageId = `${fileId}-page-${i}`;
pages.push({ pages.push({
@ -793,10 +794,10 @@ export class InsertFilesCommand extends DOMCommand {
isBlankPage: false isBlankPage: false
}); });
} }
// Clean up PDF document // Clean up PDF document
pdfWorkerManager.destroyDocument(pdf); pdfWorkerManager.destroyDocument(pdf);
resolve(pages); resolve(pages);
} catch (error) { } catch (error) {
reject(error); reject(error);
@ -876,4 +877,4 @@ export class UndoManager {
this.redoStack = []; this.redoStack = [];
this.onStateChange?.(); this.onStateChange?.();
} }
} }

View File

@ -1,6 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useFileState } from '../../../contexts/FileContext'; import { useFileState } from '../../../contexts/FileContext';
import { PDFDocument, PDFPage } from '../../../types/pageEditor'; import { PDFDocument, PDFPage } from '../../../types/pageEditor';
import { FileId } from '../../../types/file';
export interface PageDocumentHook { export interface PageDocumentHook {
document: PDFDocument | null; document: PDFDocument | null;
@ -14,17 +15,17 @@ export interface PageDocumentHook {
*/ */
export function usePageDocument(): PageDocumentHook { export function usePageDocument(): PageDocumentHook {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
// Prefer IDs + selectors to avoid array identity churn // Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids; const activeFileIds = state.files.ids;
const primaryFileId = activeFileIds[0] ?? null; const primaryFileId = activeFileIds[0] ?? null;
// Stable signature for effects (prevents loops) // Stable signature for effects (prevents loops)
const filesSignature = selectors.getFilesSignature(); const filesSignature = selectors.getFilesSignature();
// UI state // UI state
const globalProcessing = state.ui.isProcessing; const globalProcessing = state.ui.isProcessing;
// Get primary file record outside useMemo to track processedFile changes // Get primary file record outside useMemo to track processedFile changes
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null;
const processedFilePages = primaryFileRecord?.processedFile?.pages; const processedFilePages = primaryFileRecord?.processedFile?.pages;
@ -35,7 +36,7 @@ export function usePageDocument(): PageDocumentHook {
if (activeFileIds.length === 0) return null; if (activeFileIds.length === 0) return null;
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
// If we have file IDs but no file record, something is wrong - return null to show loading // If we have file IDs but no file record, something is wrong - return null to show loading
if (!primaryFileRecord) { if (!primaryFileRecord) {
console.log('🎬 PageEditor: No primary file record found, showing loading'); console.log('🎬 PageEditor: No primary file record found, showing loading');
@ -50,9 +51,9 @@ export function usePageDocument(): PageDocumentHook {
.join(' + '); .join(' + ');
// Build page insertion map from files with insertion positions // Build page insertion map from files with insertion positions
const insertionMap = new Map<string, string[]>(); // insertAfterPageId -> fileIds const insertionMap = new Map<string, FileId[]>(); // insertAfterPageId -> fileIds
const originalFileIds: string[] = []; const originalFileIds: FileId[] = [];
activeFileIds.forEach(fileId => { activeFileIds.forEach(fileId => {
const record = selectors.getFileRecord(fileId); const record = selectors.getFileRecord(fileId);
if (record?.insertAfterPageId !== undefined) { if (record?.insertAfterPageId !== undefined) {
@ -64,21 +65,21 @@ export function usePageDocument(): PageDocumentHook {
originalFileIds.push(fileId); originalFileIds.push(fileId);
} }
}); });
// Build pages by interleaving original pages with insertions // Build pages by interleaving original pages with insertions
let pages: PDFPage[] = []; let pages: PDFPage[] = [];
let totalPageCount = 0; let totalPageCount = 0;
// Helper function to create pages from a file // Helper function to create pages from a file
const createPagesFromFile = (fileId: string, startPageNumber: number): PDFPage[] => { const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
const fileRecord = selectors.getFileRecord(fileId); const fileRecord = selectors.getFileRecord(fileId);
if (!fileRecord) { if (!fileRecord) {
return []; return [];
} }
const processedFile = fileRecord.processedFile; const processedFile = fileRecord.processedFile;
let filePages: PDFPage[] = []; let filePages: PDFPage[] = [];
if (processedFile?.pages && processedFile.pages.length > 0) { if (processedFile?.pages && processedFile.pages.length > 0) {
// Use fully processed pages with thumbnails // Use fully processed pages with thumbnails
filePages = processedFile.pages.map((page, pageIndex) => ({ filePages = processedFile.pages.map((page, pageIndex) => ({
@ -104,7 +105,7 @@ export function usePageDocument(): PageDocumentHook {
splitAfter: false, splitAfter: false,
})); }));
} }
return filePages; return filePages;
}; };
@ -114,35 +115,35 @@ export function usePageDocument(): PageDocumentHook {
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
originalFilePages.push(...filePages); originalFilePages.push(...filePages);
}); });
// Start with all original pages numbered sequentially // Start with all original pages numbered sequentially
pages = originalFilePages.map((page, index) => ({ pages = originalFilePages.map((page, index) => ({
...page, ...page,
pageNumber: index + 1 pageNumber: index + 1
})); }));
// Process each insertion by finding the page ID and inserting after it // Process each insertion by finding the page ID and inserting after it
for (const [insertAfterPageId, fileIds] of insertionMap.entries()) { for (const [insertAfterPageId, fileIds] of insertionMap.entries()) {
const targetPageIndex = pages.findIndex(p => p.id === insertAfterPageId); const targetPageIndex = pages.findIndex(p => p.id === insertAfterPageId);
if (targetPageIndex === -1) continue; if (targetPageIndex === -1) continue;
// Collect all pages to insert // Collect all pages to insert
const allNewPages: PDFPage[] = []; const allNewPages: PDFPage[] = [];
fileIds.forEach(fileId => { fileIds.forEach(fileId => {
const insertedPages = createPagesFromFile(fileId, 1); const insertedPages = createPagesFromFile(fileId, 1);
allNewPages.push(...insertedPages); allNewPages.push(...insertedPages);
}); });
// Insert all new pages after the target page // Insert all new pages after the target page
pages.splice(targetPageIndex + 1, 0, ...allNewPages); pages.splice(targetPageIndex + 1, 0, ...allNewPages);
// Renumber all pages after insertion // Renumber all pages after insertion
pages.forEach((page, index) => { pages.forEach((page, index) => {
page.pageNumber = index + 1; page.pageNumber = index + 1;
}); });
} }
totalPageCount = pages.length; totalPageCount = pages.length;
if (pages.length === 0) { if (pages.length === 0) {
@ -173,4 +174,4 @@ export function usePageDocument(): PageDocumentHook {
isVeryLargeDocument, isVeryLargeDocument,
isLoading isLoading
}; };
} }

View File

@ -5,6 +5,7 @@ import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort"; import SortIcon from "@mui/icons-material/Sort";
import FileCard from "./FileCard"; import FileCard from "./FileCard";
import { FileRecord } from "../../types/fileContext"; import { FileRecord } from "../../types/fileContext";
import { FileId } from "../../types/file";
interface FileGridProps { interface FileGridProps {
files: Array<{ file: File; record?: FileRecord }>; files: Array<{ file: File; record?: FileRecord }>;
@ -12,8 +13,8 @@ interface FileGridProps {
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void; onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
onView?: (item: { file: File; record?: FileRecord }) => void; onView?: (item: { file: File; record?: FileRecord }) => void;
onEdit?: (item: { file: File; record?: FileRecord }) => void; onEdit?: (item: { file: File; record?: FileRecord }) => void;
onSelect?: (fileId: string) => void; onSelect?: (fileId: FileId) => void;
selectedFiles?: string[]; selectedFiles?: FileId[];
showSearch?: boolean; showSearch?: boolean;
showSort?: boolean; showSort?: boolean;
maxDisplay?: number; // If set, shows only this many files with "Show All" option maxDisplay?: number; // If set, shows only this many files with "Show All" option
@ -119,11 +120,11 @@ const FileGrid = ({
direction="row" direction="row"
wrap="wrap" wrap="wrap"
gap="md" gap="md"
h="30rem" h="30rem"
style={{ overflowY: "auto", width: "100%" }} style={{ overflowY: "auto", width: "100%" }}
> >
{displayFiles.map((item, idx) => { {displayFiles.map((item, idx) => {
const fileId = item.record?.id || item.file.name; const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */;
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId); const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
const supported = isFileSupported ? isFileSupported(item.file.name) : true; const supported = isFileSupported ? isFileSupported(item.file.name) : true;
return ( return (

View File

@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Modal, Modal,
Text, Text,
Button, Button,
Group, Group,
Stack, Stack,
Checkbox, Checkbox,
ScrollArea, ScrollArea,
Box, Box,
Image, Image,
Badge, Badge,
@ -15,6 +15,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FileId } from '../../types/file';
interface FilePickerModalProps { interface FilePickerModalProps {
opened: boolean; opened: boolean;
@ -30,7 +31,7 @@ const FilePickerModal = ({
onSelectFiles, onSelectFiles,
}: FilePickerModalProps) => { }: FilePickerModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]); const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
// Reset selection when modal opens // Reset selection when modal opens
useEffect(() => { useEffect(() => {
@ -39,9 +40,9 @@ const FilePickerModal = ({
} }
}, [opened]); }, [opened]);
const toggleFileSelection = (fileId: string) => { const toggleFileSelection = (fileId: FileId) => {
setSelectedFileIds(prev => { setSelectedFileIds(prev => {
return prev.includes(fileId) return prev.includes(fileId)
? prev.filter(id => id !== fileId) ? prev.filter(id => id !== fileId)
: [...prev, fileId]; : [...prev, fileId];
}); });
@ -56,10 +57,10 @@ const FilePickerModal = ({
}; };
const handleConfirm = async () => { const handleConfirm = async () => {
const selectedFiles = storedFiles.filter(f => const selectedFiles = storedFiles.filter(f =>
selectedFileIds.includes(f.id) selectedFileIds.includes(f.id)
); );
// Convert stored files to File objects // Convert stored files to File objects
const convertedFiles = await Promise.all( const convertedFiles = await Promise.all(
selectedFiles.map(async (fileItem) => { selectedFiles.map(async (fileItem) => {
@ -68,12 +69,12 @@ const FilePickerModal = ({
if (fileItem instanceof File) { if (fileItem instanceof File) {
return fileItem; return fileItem;
} }
// If it has a file property, use that // If it has a file property, use that
if (fileItem.file && fileItem.file instanceof File) { if (fileItem.file && fileItem.file instanceof File) {
return fileItem.file; return fileItem.file;
} }
// If it's from IndexedDB storage, reconstruct the File // If it's from IndexedDB storage, reconstruct the File
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') { if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') {
const arrayBuffer = await fileItem.arrayBuffer(); const arrayBuffer = await fileItem.arrayBuffer();
@ -83,8 +84,8 @@ const FilePickerModal = ({
lastModified: fileItem.lastModified || Date.now() lastModified: fileItem.lastModified || Date.now()
}); });
} }
// If it has data property, reconstruct the File // If it has data property, reconstruct the File
if (fileItem.data) { if (fileItem.data) {
const blob = new Blob([fileItem.data], { type: fileItem.type || 'application/pdf' }); const blob = new Blob([fileItem.data], { type: fileItem.type || 'application/pdf' });
return new File([blob], fileItem.name, { return new File([blob], fileItem.name, {
@ -92,7 +93,7 @@ const FilePickerModal = ({
lastModified: fileItem.lastModified || Date.now() lastModified: fileItem.lastModified || Date.now()
}); });
} }
console.warn('Could not convert file item:', fileItem); console.warn('Could not convert file item:', fileItem);
return null; return null;
} catch (error) { } catch (error) {
@ -101,10 +102,10 @@ const FilePickerModal = ({
} }
}) })
); );
// Filter out any null values and return valid Files // Filter out any null values and return valid Files
const validFiles = convertedFiles.filter((f): f is File => f !== null); const validFiles = convertedFiles.filter((f): f is File => f !== null);
onSelectFiles(validFiles); onSelectFiles(validFiles);
onClose(); onClose();
}; };
@ -156,18 +157,18 @@ const FilePickerModal = ({
{storedFiles.map((file) => { {storedFiles.map((file) => {
const fileId = file.id; const fileId = file.id;
const isSelected = selectedFileIds.includes(fileId); const isSelected = selectedFileIds.includes(fileId);
return ( return (
<Box <Box
key={fileId} key={fileId}
p="sm" p="sm"
style={{ style={{
border: isSelected border: isSelected
? '2px solid var(--mantine-color-blue-6)' ? '2px solid var(--mantine-color-blue-6)'
: '1px solid var(--mantine-color-gray-3)', : '1px solid var(--mantine-color-gray-3)',
borderRadius: 8, borderRadius: 8,
backgroundColor: isSelected backgroundColor: isSelected
? 'var(--mantine-color-blue-0)' ? 'var(--mantine-color-blue-0)'
: 'transparent', : 'transparent',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease' transition: 'all 0.2s ease'
@ -180,7 +181,7 @@ const FilePickerModal = ({
onChange={() => toggleFileSelection(fileId)} onChange={() => toggleFileSelection(fileId)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
{/* Thumbnail */} {/* Thumbnail */}
<Box <Box
style={{ style={{
@ -246,11 +247,11 @@ const FilePickerModal = ({
<Button variant="light" onClick={onClose}> <Button variant="light" onClick={onClose}>
{t("close", "Cancel")} {t("close", "Cancel")}
</Button> </Button>
<Button <Button
onClick={handleConfirm} onClick={handleConfirm}
disabled={selectedFileIds.length === 0} disabled={selectedFileIds.length === 0}
> >
{selectedFileIds.length > 0 {selectedFileIds.length > 0
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}` ? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}`
: t("fileUpload.loadFromStorage", "Load Files") : t("fileUpload.loadFromStorage", "Load Files")
} }
@ -261,4 +262,4 @@ const FilePickerModal = ({
); );
}; };
export default FilePickerModal; export default FilePickerModal;

View File

@ -189,7 +189,7 @@ const LandingPage = () => {
className="text-[var(--accent-interactive)]" className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem' }} style={{ fontSize: '.8rem' }}
> >
{t('fileUpload.dropFilesHere', 'Drop files here or click to upload')} {t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
</span> </span>
</div> </div>
</Dropzone> </Dropzone>

View File

@ -15,13 +15,14 @@ import ConvertFromWebSettings from "./ConvertFromWebSettings";
import ConvertFromEmailSettings from "./ConvertFromEmailSettings"; import ConvertFromEmailSettings from "./ConvertFromEmailSettings";
import ConvertToPdfaSettings from "./ConvertToPdfaSettings"; import ConvertToPdfaSettings from "./ConvertToPdfaSettings";
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters"; import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
import { import {
FROM_FORMAT_OPTIONS, FROM_FORMAT_OPTIONS,
EXTENSION_TO_ENDPOINT, EXTENSION_TO_ENDPOINT,
COLOR_TYPES, COLOR_TYPES,
OUTPUT_OPTIONS, OUTPUT_OPTIONS,
FIT_OPTIONS FIT_OPTIONS
} from "../../../constants/convertConstants"; } from "../../../constants/convertConstants";
import { FileId } from "../../../types/file";
interface ConvertSettingsProps { interface ConvertSettingsProps {
parameters: ConvertParameters; parameters: ConvertParameters;
@ -31,8 +32,8 @@ interface ConvertSettingsProps {
disabled?: boolean; disabled?: boolean;
} }
const ConvertSettings = ({ const ConvertSettings = ({
parameters, parameters,
onParameterChange, onParameterChange,
getAvailableToExtensions, getAvailableToExtensions,
selectedFiles, selectedFiles,
@ -52,7 +53,7 @@ const ConvertSettings = ({
const isConversionAvailable = (fromExt: string, toExt: string): boolean => { const isConversionAvailable = (fromExt: string, toExt: string): boolean => {
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt]; const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
if (!endpointKey) return false; if (!endpointKey) return false;
return endpointStatus[endpointKey] === true; return endpointStatus[endpointKey] === true;
}; };
@ -61,10 +62,10 @@ const ConvertSettings = ({
const baseOptions = FROM_FORMAT_OPTIONS.map(option => { const baseOptions = FROM_FORMAT_OPTIONS.map(option => {
// Check if this source format has any available conversions // Check if this source format has any available conversions
const availableConversions = getAvailableToExtensions(option.value) || []; const availableConversions = getAvailableToExtensions(option.value) || [];
const hasAvailableConversions = availableConversions.some(targetOption => const hasAvailableConversions = availableConversions.some(targetOption =>
isConversionAvailable(option.value, targetOption.value) isConversionAvailable(option.value, targetOption.value)
); );
return { return {
...option, ...option,
enabled: hasAvailableConversions enabled: hasAvailableConversions
@ -80,18 +81,18 @@ const ConvertSettings = ({
group: 'File', group: 'File',
enabled: true enabled: true
}; };
// Add the dynamic option at the beginning // Add the dynamic option at the beginning
return [dynamicOption, ...baseOptions]; return [dynamicOption, ...baseOptions];
} }
return baseOptions; return baseOptions;
}, [parameters.fromExtension, endpointStatus]); }, [parameters.fromExtension, endpointStatus]);
// Enhanced TO options with endpoint availability // Enhanced TO options with endpoint availability
const enhancedToOptions = useMemo(() => { const enhancedToOptions = useMemo(() => {
if (!parameters.fromExtension) return []; if (!parameters.fromExtension) return [];
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || []; const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
return availableOptions.map(option => ({ return availableOptions.map(option => ({
...option, ...option,
@ -131,7 +132,7 @@ const ConvertSettings = ({
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[]; const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
return files.filter(file => { return files.filter(file => {
const fileExtension = detectFileExtension(file.name); const fileExtension = detectFileExtension(file.name);
if (extension === 'any') { if (extension === 'any') {
return true; return true;
} else if (extension === 'image') { } else if (extension === 'image') {
@ -148,15 +149,15 @@ const ConvertSettings = ({
// Find the file ID by matching file properties // Find the file ID by matching file properties
const fileRecord = state.files.ids const fileRecord = state.files.ids
.map(id => selectors.getFileRecord(id)) .map(id => selectors.getFileRecord(id))
.find(record => .find(record =>
record && record &&
record.name === file.name && record.name === file.name &&
record.size === file.size && record.size === file.size &&
record.lastModified === file.lastModified record.lastModified === file.lastModified
); );
return fileRecord?.id; return fileRecord?.id;
}).filter((id): id is string => id !== undefined); // Type guard to ensure only strings }).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
setSelectedFiles(fileIds); setSelectedFiles(fileIds);
}; };
@ -164,7 +165,7 @@ const ConvertSettings = ({
onParameterChange('fromExtension', value); onParameterChange('fromExtension', value);
setAutoTargetExtension(value); setAutoTargetExtension(value);
resetParametersToDefaults(); resetParametersToDefaults();
if (activeFiles.length > 0) { if (activeFiles.length > 0) {
const matchingFiles = filterFilesByExtension(value); const matchingFiles = filterFilesByExtension(value);
updateFileSelection(matchingFiles); updateFileSelection(matchingFiles);
@ -232,11 +233,11 @@ const ConvertSettings = ({
> >
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm">{t("convert.selectSourceFormatFirst", "Select a source format first")}</Text> <Text size="sm">{t("convert.selectSourceFormatFirst", "Select a source format first")}</Text>
<KeyboardArrowDownIcon <KeyboardArrowDownIcon
style={{ style={{
fontSize: '1rem', fontSize: '1rem',
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6] color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6]
}} }}
/> />
</Group> </Group>
</UnstyledButton> </UnstyledButton>
@ -266,9 +267,9 @@ const ConvertSettings = ({
</> </>
)} )}
{/* Color options for image to PDF conversion */} {/* Color options for image to PDF conversion */}
{(isImageFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') || {(isImageFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
(parameters.isSmartDetection && parameters.smartDetectionType === 'images') ? ( (parameters.isSmartDetection && parameters.smartDetectionType === 'images') ? (
<> <>
<Divider /> <Divider />
@ -281,7 +282,7 @@ const ConvertSettings = ({
) : null} ) : null}
{/* Web to PDF options */} {/* Web to PDF options */}
{((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') || {((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
(parameters.isSmartDetection && parameters.smartDetectionType === 'web')) ? ( (parameters.isSmartDetection && parameters.smartDetectionType === 'web')) ? (
<> <>
<Divider /> <Divider />
@ -322,4 +323,4 @@ const ConvertSettings = ({
); );
}; };
export default ConvertSettings; export default ConvertSettings;

View File

@ -100,7 +100,7 @@ const FileStatusIndicator = ({
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }} style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
> >
<UploadIcon style={{ fontSize: '0.875rem' }} /> <UploadIcon style={{ fontSize: '0.875rem' }} />
{t("files.upload", "Upload")} {t("files.uploadFiles", "Upload Files")}
</Anchor> </Anchor>
</Text> </Text>
); );

View File

@ -15,6 +15,7 @@ import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader'; import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext"; import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl"; import { useFileWithUrl } from "../../hooks/useFileWithUrl";
import { FileId } from "../../types/file";
// Lazy loading page image component // Lazy loading page image component
@ -152,7 +153,7 @@ const Viewer = ({
const { selectors } = useFileState(); const { selectors } = useFileState();
const { actions } = useFileActions(); const { actions } = useFileActions();
const currentFile = useCurrentFile(); const currentFile = useCurrentFile();
const getCurrentFile = () => currentFile.file; const getCurrentFile = () => currentFile.file;
const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined; const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined;
const clearAllFiles = actions.clearAllFiles; const clearAllFiles = actions.clearAllFiles;
@ -378,7 +379,7 @@ const Viewer = ({
} }
// Handle special IndexedDB URLs for large files // Handle special IndexedDB URLs for large files
else if (effectiveFile.url?.startsWith('indexeddb:')) { else if (effectiveFile.url?.startsWith('indexeddb:')) {
const fileId = effectiveFile.url.replace('indexeddb:', ''); const fileId = effectiveFile.url.replace('indexeddb:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */;
// Get data directly from IndexedDB // Get data directly from IndexedDB
const arrayBuffer = await fileStorage.getFileData(fileId); const arrayBuffer = await fileStorage.getFileData(fileId);

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).
*/ */
@ -19,7 +19,6 @@ import {
FileContextStateValue, FileContextStateValue,
FileContextActionsValue, FileContextActionsValue,
FileContextActions, FileContextActions,
FileId,
FileRecord FileRecord
} from '../types/fileContext'; } from '../types/fileContext';
@ -30,6 +29,7 @@ import { addFiles, consumeFiles, 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';
import { FileId } from '../types/file';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
@ -38,16 +38,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;
@ -60,8 +60,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
); );
@ -75,7 +75,6 @@ function FileContextInner({
// 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<File[]> => { const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string }): Promise<File[]> => {
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager); const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
// 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 }) => {
@ -86,7 +85,7 @@ function FileContextInner({
} }
})); }));
} }
return addedFilesWithIds.map(({ file }) => file); return addedFilesWithIds.map(({ file }) => file);
}, [indexedDB, enablePersistence]); }, [indexedDB, enablePersistence]);
@ -110,11 +109,11 @@ function FileContextInner({
// Helper to find FileId from File object // Helper to find FileId from File object
const findFileId = useCallback((file: File): FileId | undefined => { const findFileId = useCallback((file: File): FileId | undefined => {
return Object.keys(stateRef.current.files.byId).find(id => { return (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
const storedFile = filesRef.current.get(id); const storedFile = filesRef.current.get(id);
return storedFile && return storedFile &&
storedFile.name === file.name && storedFile.name === file.name &&
storedFile.size === file.size && storedFile.size === file.size &&
storedFile.lastModified === file.lastModified; storedFile.lastModified === file.lastModified;
}); });
}, []); }, []);
@ -143,11 +142,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 {
@ -157,7 +156,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 } });
@ -166,7 +165,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
}, },
@ -175,7 +174,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 {
@ -191,14 +190,14 @@ function FileContextInner({
consumeFiles: consumeFilesWrapper, consumeFiles: consumeFilesWrapper,
setHasUnsavedChanges, setHasUnsavedChanges,
trackBlobUrl: lifecycleManager.trackBlobUrl, trackBlobUrl: lifecycleManager.trackBlobUrl,
cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef), cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef),
scheduleCleanup: (fileId: string, delay?: number) => scheduleCleanup: (fileId: FileId, 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,
@ -247,12 +246,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}
> >
@ -262,7 +261,7 @@ export function FileContextProvider({
); );
} else { } else {
return ( return (
<FileContextInner <FileContextInner
enableUrlSync={enableUrlSync} enableUrlSync={enableUrlSync}
enablePersistence={enablePersistence} enablePersistence={enablePersistence}
> >
@ -285,4 +284,4 @@ export {
useSelectedFiles, useSelectedFiles,
// Primary API hooks for tools // Primary API hooks for tools
useFileContext useFileContext
} from './file/fileHooks'; } from './file/fileHooks';

View File

@ -2,12 +2,13 @@ import React, { createContext, useContext, useState, useRef, useCallback, useEff
import { FileMetadata } from '../types/file'; import { FileMetadata } from '../types/file';
import { StoredFile, fileStorage } from '../services/fileStorage'; import { StoredFile, fileStorage } from '../services/fileStorage';
import { downloadFiles } from '../utils/downloadUtils'; import { downloadFiles } from '../utils/downloadUtils';
import { FileId } from '../types/file';
// Type for the context value - now contains everything directly // Type for the context value - now contains everything directly
interface FileManagerContextValue { interface FileManagerContextValue {
// State // State
activeSource: 'recent' | 'local' | 'drive'; activeSource: 'recent' | 'local' | 'drive';
selectedFileIds: string[]; selectedFileIds: FileId[];
searchTerm: string; searchTerm: string;
selectedFiles: FileMetadata[]; selectedFiles: FileMetadata[];
filteredFiles: FileMetadata[]; filteredFiles: FileMetadata[];
@ -64,7 +65,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
refreshRecentFiles, refreshRecentFiles,
}) => { }) => {
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent'); const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]); const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null); const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -74,10 +75,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Computed values (with null safety) // Computed values (with null safety)
const selectedFilesSet = new Set(selectedFileIds); const selectedFilesSet = new Set(selectedFileIds);
const selectedFiles = selectedFileIds.length === 0 ? [] : const selectedFiles = selectedFileIds.length === 0 ? [] :
(recentFiles || []).filter(file => selectedFilesSet.has(file.id)); (recentFiles || []).filter(file => selectedFilesSet.has(file.id));
const filteredFiles = !searchTerm ? recentFiles || [] : const filteredFiles = !searchTerm ? recentFiles || [] :
(recentFiles || []).filter(file => (recentFiles || []).filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase()) file.name.toLowerCase().includes(searchTerm.toLowerCase())
@ -99,15 +100,15 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => { const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => {
const fileId = file.id; const fileId = file.id;
if (!fileId) return; if (!fileId) return;
if (shiftKey && lastClickedIndex !== null) { if (shiftKey && lastClickedIndex !== null) {
// Range selection with shift-click // Range selection with shift-click
const startIndex = Math.min(lastClickedIndex, currentIndex); const startIndex = Math.min(lastClickedIndex, currentIndex);
const endIndex = Math.max(lastClickedIndex, currentIndex); const endIndex = Math.max(lastClickedIndex, currentIndex);
setSelectedFileIds(prev => { setSelectedFileIds(prev => {
const selectedSet = new Set(prev); const selectedSet = new Set(prev);
// Add all files in the range to selection // Add all files in the range to selection
for (let i = startIndex; i <= endIndex; i++) { for (let i = startIndex; i <= endIndex; i++) {
const rangeFileId = filteredFiles[i]?.id; const rangeFileId = filteredFiles[i]?.id;
@ -115,23 +116,23 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
selectedSet.add(rangeFileId); selectedSet.add(rangeFileId);
} }
} }
return Array.from(selectedSet); return Array.from(selectedSet);
}); });
} else { } else {
// Normal click behavior - optimized with Set for O(1) lookup // Normal click behavior - optimized with Set for O(1) lookup
setSelectedFileIds(prev => { setSelectedFileIds(prev => {
const selectedSet = new Set(prev); const selectedSet = new Set(prev);
if (selectedSet.has(fileId)) { if (selectedSet.has(fileId)) {
selectedSet.delete(fileId); selectedSet.delete(fileId);
} else { } else {
selectedSet.add(fileId); selectedSet.add(fileId);
} }
return Array.from(selectedSet); return Array.from(selectedSet);
}); });
// Update last clicked index for future range selections // Update last clicked index for future range selections
setLastClickedIndex(currentIndex); setLastClickedIndex(currentIndex);
} }
@ -196,7 +197,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
try { try {
// Get files to delete based on current filtered view // Get files to delete based on current filtered view
const filesToDelete = filteredFiles.filter(file => const filesToDelete = filteredFiles.filter(file =>
selectedFileIds.includes(file.id) selectedFileIds.includes(file.id)
); );
@ -221,7 +222,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
try { try {
// Get selected files // Get selected files
const selectedFilesToDownload = filteredFiles.filter(file => const selectedFilesToDownload = filteredFiles.filter(file =>
selectedFileIds.includes(file.id) selectedFileIds.includes(file.id)
); );

View File

@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react'; import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { useFileHandler } from '../hooks/useFileHandler'; import { useFileHandler } from '../hooks/useFileHandler';
import { FileMetadata } from '../types/file'; import { FileMetadata } from '../types/file';
import { FileId } from '../types/file';
interface FilesModalContextType { interface FilesModalContextType {
isFilesModalOpen: boolean; isFilesModalOpen: boolean;
@ -8,7 +9,7 @@ interface FilesModalContextType {
closeFilesModal: () => void; closeFilesModal: () => void;
onFileSelect: (file: File) => void; onFileSelect: (file: File) => void;
onFilesSelect: (files: File[]) => void; onFilesSelect: (files: File[]) => void;
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void; onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void;
onModalClose?: () => void; onModalClose?: () => void;
setOnModalClose: (callback: () => void) => void; setOnModalClose: (callback: () => void) => void;
} }
@ -57,7 +58,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
closeFilesModal(); closeFilesModal();
}, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]); }, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]);
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => { const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => {
if (customHandler) { if (customHandler) {
// Use custom handler for special cases (like page insertion) // Use custom handler for special cases (like page insertion)
const files = filesWithMetadata.map(item => item.file); const files = filesWithMetadata.map(item => item.file);

View File

@ -7,7 +7,7 @@ import React, { createContext, useContext, useCallback, useRef } from 'react';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
import { fileStorage, StoredFile } from '../services/fileStorage'; import { fileStorage, StoredFile } from '../services/fileStorage';
import { FileId } from '../types/fileContext'; import { FileId } from '../types/file';
import { FileMetadata } from '../types/file'; import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../utils/thumbnailUtils';
@ -17,12 +17,12 @@ interface IndexedDBContextValue {
loadFile: (fileId: FileId) => Promise<File | null>; loadFile: (fileId: FileId) => Promise<File | null>;
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>; loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>;
deleteFile: (fileId: FileId) => Promise<void>; deleteFile: (fileId: FileId) => Promise<void>;
// Batch operations // Batch operations
loadAllMetadata: () => Promise<FileMetadata[]>; loadAllMetadata: () => Promise<FileMetadata[]>;
deleteMultiple: (fileIds: FileId[]) => Promise<void>; deleteMultiple: (fileIds: FileId[]) => Promise<void>;
clearAll: () => Promise<void>; clearAll: () => Promise<void>;
// Utilities // Utilities
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>; getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>; updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
@ -59,14 +59,14 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => { const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
// Use existing thumbnail or generate new one if none provided // Use existing thumbnail or generate new one if none provided
const thumbnail = existingThumbnail || await generateThumbnailForFile(file); const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
// Store in IndexedDB // Store in IndexedDB
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail); const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
// Cache the file object for immediate reuse // Cache the file object for immediate reuse
fileCache.current.set(fileId, { file, lastAccessed: Date.now() }); fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
evictLRUEntries(); evictLRUEntries();
// Return metadata // Return metadata
return { return {
id: fileId, id: fileId,
@ -121,7 +121,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
// Load metadata from IndexedDB (efficient - no data field) // Load metadata from IndexedDB (efficient - no data field)
const metadata = await fileStorage.getAllFileMetadata(); const metadata = await fileStorage.getAllFileMetadata();
const fileMetadata = metadata.find(m => m.id === fileId); const fileMetadata = metadata.find(m => m.id === fileId);
if (!fileMetadata) return null; if (!fileMetadata) return null;
return { return {
@ -137,14 +137,14 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => { const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
// Remove from cache // Remove from cache
fileCache.current.delete(fileId); fileCache.current.delete(fileId);
// Remove from IndexedDB // Remove from IndexedDB
await fileStorage.deleteFile(fileId); await fileStorage.deleteFile(fileId);
}, []); }, []);
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => { const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
const metadata = await fileStorage.getAllFileMetadata(); const metadata = await fileStorage.getAllFileMetadata();
return metadata.map(m => ({ return metadata.map(m => ({
id: m.id, id: m.id,
name: m.name, name: m.name,
@ -158,7 +158,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => { const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
// Remove from cache // Remove from cache
fileIds.forEach(id => fileCache.current.delete(id)); fileIds.forEach(id => fileCache.current.delete(id));
// Remove from IndexedDB in parallel // Remove from IndexedDB in parallel
await Promise.all(fileIds.map(id => fileStorage.deleteFile(id))); await Promise.all(fileIds.map(id => fileStorage.deleteFile(id)));
}, []); }, []);
@ -166,7 +166,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const clearAll = useCallback(async (): Promise<void> => { const clearAll = useCallback(async (): Promise<void> => {
// Clear cache // Clear cache
fileCache.current.clear(); fileCache.current.clear();
// Clear IndexedDB // Clear IndexedDB
await fileStorage.clearAll(); await fileStorage.clearAll();
}, []); }, []);
@ -204,4 +204,4 @@ export function useIndexedDB() {
throw new Error('useIndexedDB must be used within an IndexedDBProvider'); throw new Error('useIndexedDB must be used within an IndexedDBProvider');
} }
return context; return context;
} }

View File

@ -2,10 +2,10 @@
* FileContext reducer - Pure state management for file operations * FileContext reducer - Pure state management for file operations
*/ */
import { import { FileId } from '../../types/file';
FileContextState, import {
FileContextAction, FileContextState,
FileId, FileContextAction,
FileRecord FileRecord
} from '../../types/fileContext'; } from '../../types/fileContext';
@ -32,7 +32,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 +40,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
newById[record.id] = record; newById[record.id] = record;
} }
}); });
return { return {
...state, ...state,
files: { files: {
@ -49,20 +49,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 +75,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 +98,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 +113,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 +124,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 +135,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'CLEAR_SELECTIONS': { case 'CLEAR_SELECTIONS': {
return { return {
...state, ...state,
@ -146,7 +146,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 +158,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'SET_UNSAVED_CHANGES': { case 'SET_UNSAVED_CHANGES': {
return { return {
...state, ...state,
@ -168,42 +168,42 @@ 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;
// Only remove unpinned input files // Only remove unpinned input files
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id)); const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id)); const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
// Remove unpinned files from state // Remove unpinned files from state
const newById = { ...state.files.byId }; const newById = { ...state.files.byId };
unpinnedInputIds.forEach(id => { unpinnedInputIds.forEach(id => {
delete newById[id]; delete newById[id];
}); });
// Add output files // Add output files
const outputIds: FileId[] = []; const outputIds: FileId[] = [];
outputFileRecords.forEach(record => { outputFileRecords.forEach(record => {
@ -212,10 +212,10 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
newById[record.id] = record; newById[record.id] = record;
} }
}); });
// Clear selections that reference removed files // Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id)); const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
return { return {
...state, ...state,
files: { files: {
@ -228,13 +228,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
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,16 +2,15 @@
* File actions - Unified file operations with single addFiles helper * File actions - Unified file operations with single addFiles helper
*/ */
import { import {
FileId, FileRecord,
FileRecord,
FileContextAction, FileContextAction,
FileContextState, FileContextState,
toFileRecord, toFileRecord,
createFileId, createFileId,
createQuickKey createQuickKey
} from '../../types/fileContext'; } from '../../types/fileContext';
import { FileMetadata } from '../../types/file'; import { FileId, FileMetadata } from '../../types/file';
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils'; import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
import { FileLifecycleManager } from './lifecycle'; import { FileLifecycleManager } from './lifecycle';
import { fileProcessingService } from '../../services/fileProcessingService'; import { fileProcessingService } from '../../services/fileProcessingService';
@ -78,13 +77,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 +101,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 +155,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 +165,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 +207,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 +273,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 +282,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
@ -328,17 +327,17 @@ export async function consumeFiles(
dispatch: React.Dispatch<FileContextAction> dispatch: React.Dispatch<FileContextAction>
): Promise<void> { ): Promise<void> {
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 through the 'processed' path to generate thumbnails
const outputFileRecords = await Promise.all( const outputFileRecords = await Promise.all(
outputFiles.map(async (file) => { outputFiles.map(async (file) => {
const fileId = createFileId(); const fileId = createFileId();
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
// Generate thumbnail and page count for output file // Generate thumbnail and page count for output file
let thumbnail: string | undefined; let thumbnail: string | undefined;
let pageCount: number = 1; let pageCount: number = 1;
try { try {
if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`); if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`);
const result = await generateThumbnailWithMetadata(file); const result = await generateThumbnailWithMetadata(file);
@ -347,29 +346,29 @@ export async function consumeFiles(
} catch (error) { } catch (error) {
if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error); if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error);
} }
const record = toFileRecord(file, fileId); const record = toFileRecord(file, fileId);
if (thumbnail) { if (thumbnail) {
record.thumbnailUrl = thumbnail; record.thumbnailUrl = thumbnail;
} }
if (pageCount > 0) { if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail); record.processedFile = createProcessedFile(pageCount, thumbnail);
} }
return record; return record;
}) })
); );
// Dispatch the consume action // Dispatch the consume action
dispatch({ dispatch({
type: 'CONSUME_FILES', type: 'CONSUME_FILES',
payload: { payload: {
inputFileIds, inputFileIds,
outputFileRecords outputFileRecords
} }
}); });
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`);
} }

View File

@ -3,13 +3,14 @@
*/ */
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 } from '../../types/fileContext'; import { FileRecord } from '../../types/fileContext';
import { FileId } from '../../types/file';
/** /**
* Hook for accessing file state (will re-render on any state change) * Hook for accessing file state (will re-render on any state change)
@ -39,7 +40,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 +82,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 +113,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 +125,7 @@ export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecor
*/ */
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } { export function useAllFiles(): { files: File[]; 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 +138,7 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds:
*/ */
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } { export function useSelectedFiles(): { files: File[]; 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,31 +161,31 @@ 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,
recordOperation: (fileId: string, operation: any) => {}, // Operation tracking not implemented recordOperation: (fileId: FileId, operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented markOperationApplied: (fileId: FileId, operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // Operation tracking not implemented markOperationFailed: (fileId: FileId, operationId: string, error: string) => {}, // Operation tracking not implemented
// File ID lookup // File ID lookup
findFileId: (file: File) => { findFileId: (file: File) => {
return state.files.ids.find(id => { return state.files.ids.find(id => {
const record = state.files.byId[id]; const record = state.files.byId[id];
return record && return record &&
record.name === file.name && record.name === file.name &&
record.size === file.size && record.size === file.size &&
record.lastModified === file.lastModified; 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()
}), [state, selectors, actions]); }), [state, selectors, actions]);

View File

@ -2,11 +2,11 @@
* File selectors - Pure functions for accessing file state * File selectors - Pure functions for accessing file state
*/ */
import { import { FileId } from '../../types/file';
FileId, import {
FileRecord, FileRecord,
FileContextState, FileContextState,
FileContextSelectors FileContextSelectors
} from '../../types/fileContext'; } from '../../types/fileContext';
/** /**
@ -18,62 +18,62 @@ export function createFileSelectors(
): FileContextSelectors { ): FileContextSelectors {
return { return {
getFile: (id: FileId) => filesRef.current.get(id), getFile: (id: FileId) => filesRef.current.get(id),
getFiles: (ids?: FileId[]) => { getFiles: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids; const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[]; return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
}, },
getFileRecord: (id: FileId) => stateRef.current.files.byId[id], getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
getFileRecords: (ids?: FileId[]) => { getFileRecords: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids; const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean); return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
}, },
getAllFileIds: () => stateRef.current.files.ids, getAllFileIds: () => stateRef.current.files.ids,
getSelectedFiles: () => { getSelectedFiles: () => {
return stateRef.current.ui.selectedFileIds return stateRef.current.ui.selectedFileIds
.map(id => filesRef.current.get(id)) .map(id => filesRef.current.get(id))
.filter(Boolean) as File[]; .filter(Boolean) as File[];
}, },
getSelectedFileRecords: () => { getSelectedFileRecords: () => {
return stateRef.current.ui.selectedFileIds return stateRef.current.ui.selectedFileIds
.map(id => stateRef.current.files.byId[id]) .map(id => stateRef.current.files.byId[id])
.filter(Boolean); .filter(Boolean);
}, },
// Pinned files selectors // Pinned files selectors
getPinnedFileIds: () => { getPinnedFileIds: () => {
return Array.from(stateRef.current.pinnedFiles); return Array.from(stateRef.current.pinnedFiles);
}, },
getPinnedFiles: () => { getPinnedFiles: () => {
return Array.from(stateRef.current.pinnedFiles) return Array.from(stateRef.current.pinnedFiles)
.map(id => filesRef.current.get(id)) .map(id => filesRef.current.get(id))
.filter(Boolean) as File[]; .filter(Boolean) as File[];
}, },
getPinnedFileRecords: () => { getPinnedFileRecords: () => {
return Array.from(stateRef.current.pinnedFiles) return Array.from(stateRef.current.pinnedFiles)
.map(id => stateRef.current.files.byId[id]) .map(id => stateRef.current.files.byId[id])
.filter(Boolean); .filter(Boolean);
}, },
isFilePinned: (file: File) => { isFilePinned: (file: File) => {
// Find FileId by matching File object properties // Find FileId by matching File object properties
const fileId = Object.keys(stateRef.current.files.byId).find(id => { const fileId = (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
const storedFile = filesRef.current.get(id); const storedFile = filesRef.current.get(id);
return storedFile && return storedFile &&
storedFile.name === file.name && storedFile.name === file.name &&
storedFile.size === file.size && storedFile.size === file.size &&
storedFile.lastModified === file.lastModified; storedFile.lastModified === file.lastModified;
}); });
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false; return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
}, },
// Stable signature for effects - prevents unnecessary re-renders // Stable signature for effects - prevents unnecessary re-renders
getFilesSignature: () => { getFilesSignature: () => {
return stateRef.current.files.ids return stateRef.current.files.ids
@ -122,9 +122,9 @@ export function getPrimaryFile(
): { file?: File; record?: FileRecord } { ): { file?: File; record?: FileRecord } {
const primaryFileId = stateRef.current.files.ids[0]; const primaryFileId = stateRef.current.files.ids[0];
if (!primaryFileId) return {}; if (!primaryFileId) return {};
return { return {
file: filesRef.current.get(primaryFileId), file: filesRef.current.get(primaryFileId),
record: stateRef.current.files.byId[primaryFileId] record: stateRef.current.files.byId[primaryFileId]
}; };
} }

View File

@ -2,7 +2,8 @@
* File lifecycle management - Resource cleanup and memory management * File lifecycle management - Resource cleanup and memory management
*/ */
import { FileId, FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext'; import { FileId } from '../../types/file';
import { FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
@ -33,10 +34,10 @@ export class FileLifecycleManager {
/** /**
* Clean up resources for a specific file (with stateRef access for complete cleanup) * Clean up resources for a specific file (with stateRef access for complete cleanup)
*/ */
cleanupFile = (fileId: string, stateRef?: React.MutableRefObject<any>): void => { cleanupFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
// Use comprehensive cleanup (same as removeFiles) // Use comprehensive cleanup (same as removeFiles)
this.cleanupAllResourcesForFile(fileId, stateRef); this.cleanupAllResourcesForFile(fileId, stateRef);
// Remove file from state // Remove file from state
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId] } }); this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId] } });
}; };
@ -54,12 +55,12 @@ export class FileLifecycleManager {
} }
}); });
this.blobUrls.clear(); this.blobUrls.clear();
// Clear all cleanup timers and generations // Clear all cleanup timers and generations
this.cleanupTimers.forEach(timer => clearTimeout(timer)); this.cleanupTimers.forEach(timer => clearTimeout(timer));
this.cleanupTimers.clear(); this.cleanupTimers.clear();
this.fileGenerations.clear(); this.fileGenerations.clear();
// Clear files ref // Clear files ref
this.filesRef.current.clear(); this.filesRef.current.clear();
}; };
@ -67,7 +68,7 @@ export class FileLifecycleManager {
/** /**
* Schedule delayed cleanup for a file with generation token to prevent stale cleanup * Schedule delayed cleanup for a file with generation token to prevent stale cleanup
*/ */
scheduleCleanup = (fileId: string, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => { scheduleCleanup = (fileId: FileId, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
// Cancel existing timer // Cancel existing timer
const existingTimer = this.cleanupTimers.get(fileId); const existingTimer = this.cleanupTimers.get(fileId);
if (existingTimer) { if (existingTimer) {
@ -105,7 +106,7 @@ export class FileLifecycleManager {
// Clean up all resources for this file // Clean up all resources for this file
this.cleanupAllResourcesForFile(fileId, stateRef); this.cleanupAllResourcesForFile(fileId, stateRef);
}); });
// Dispatch removal action once for all files (reducer only updates state) // Dispatch removal action once for all files (reducer only updates state)
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds } }); this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
}; };
@ -116,7 +117,7 @@ export class FileLifecycleManager {
private cleanupAllResourcesForFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => { private cleanupAllResourcesForFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
// Remove from files ref // Remove from files ref
this.filesRef.current.delete(fileId); this.filesRef.current.delete(fileId);
// Cancel cleanup timer and generation // Cancel cleanup timer and generation
const timer = this.cleanupTimers.get(fileId); const timer = this.cleanupTimers.get(fileId);
if (timer) { if (timer) {
@ -124,7 +125,7 @@ export class FileLifecycleManager {
this.cleanupTimers.delete(fileId); this.cleanupTimers.delete(fileId);
} }
this.fileGenerations.delete(fileId); this.fileGenerations.delete(fileId);
// Clean up blob URLs from file record if we have access to state // Clean up blob URLs from file record if we have access to state
if (stateRef) { if (stateRef) {
const record = stateRef.current.files.byId[fileId]; const record = stateRef.current.files.byId[fileId];
@ -137,7 +138,7 @@ export class FileLifecycleManager {
// Ignore revocation errors // Ignore revocation errors
} }
} }
if (record.blobUrl && record.blobUrl.startsWith('blob:')) { if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
try { try {
URL.revokeObjectURL(record.blobUrl); URL.revokeObjectURL(record.blobUrl);
@ -145,7 +146,7 @@ export class FileLifecycleManager {
// Ignore revocation errors // Ignore revocation errors
} }
} }
// Clean up processed file thumbnails // Clean up processed file thumbnails
if (record.processedFile?.pages) { if (record.processedFile?.pages) {
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => { record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
@ -171,13 +172,13 @@ export class FileLifecycleManager {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`); if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
return; return;
} }
// Additional state guard for rare race conditions // Additional state guard for rare race conditions
if (stateRef && !stateRef.current.files.byId[fileId]) { if (stateRef && !stateRef.current.files.byId[fileId]) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`); if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`);
return; return;
} }
this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } }); this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } });
}; };
@ -187,4 +188,4 @@ export class FileLifecycleManager {
destroy = (): void => { destroy = (): void => {
this.cleanupAllFiles(); this.cleanupAllFiles();
}; };
} }

View File

@ -4,9 +4,13 @@ import { useAddPasswordOperation } from './useAddPasswordOperation';
import type { AddPasswordFullParameters, AddPasswordParameters } from './useAddPasswordParameters'; import type { AddPasswordFullParameters, AddPasswordParameters } from './useAddPasswordParameters';
// Mock the useToolOperation hook // Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', () => ({ vi.mock('../shared/useToolOperation', async () => {
useToolOperation: vi.fn() const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
})); return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook // Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`); const mockT = vi.fn((key: string) => `translated-${key}`);
@ -20,13 +24,13 @@ vi.mock('../../../utils/toolErrorHandler', () => ({
})); }));
// Import the mocked function // Import the mocked function
import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation'; import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation';
describe('useAddPasswordOperation', () => { describe('useAddPasswordOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation); const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = (): ToolOperationConfig<AddPasswordFullParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<AddPasswordFullParameters>; const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<AddPasswordFullParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = { const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [], files: [],
@ -91,7 +95,7 @@ describe('useAddPasswordOperation', () => {
}; };
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' }); const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const formData = buildFormData(testParameters, testFile as any /* FIX ME */); const formData = buildFormData(testParameters, testFile);
// Verify the form data contains the file // Verify the form data contains the file
expect(formData.get('fileInput')).toBe(testFile); expect(formData.get('fileInput')).toBe(testFile);
@ -112,7 +116,7 @@ describe('useAddPasswordOperation', () => {
}); });
test.each([ test.each([
{ property: 'multiFileEndpoint' as const, expectedValue: false }, { property: 'toolType' as const, expectedValue: ToolType.singleFile },
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' }, { property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
{ property: 'filePrefix' as const, expectedValue: 'translated-addPassword.filenamePrefix_' }, { property: 'filePrefix' as const, expectedValue: 'translated-addPassword.filenamePrefix_' },
{ property: 'operationType' as const, expectedValue: 'addPassword' } { property: 'operationType' as const, expectedValue: 'addPassword' }

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation'; import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddPasswordFullParameters, defaultParameters } from './useAddPasswordParameters'; import { AddPasswordFullParameters, defaultParameters } from './useAddPasswordParameters';
import { defaultParameters as permissionsDefaults } from '../changePermissions/useChangePermissionsParameters'; import { defaultParameters as permissionsDefaults } from '../changePermissions/useChangePermissionsParameters';
@ -26,11 +26,11 @@ const fullDefaultParameters: AddPasswordFullParameters = {
// Static configuration object // Static configuration object
export const addPasswordOperationConfig = { export const addPasswordOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddPasswordFormData,
operationType: 'addPassword', operationType: 'addPassword',
endpoint: '/api/v1/security/add-password', endpoint: '/api/v1/security/add-password',
buildFormData: buildAddPasswordFormData,
filePrefix: 'encrypted_', // Will be overridden in hook with translation filePrefix: 'encrypted_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters: fullDefaultParameters, defaultParameters: fullDefaultParameters,
} as const; } as const;

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation'; import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddWatermarkParameters, defaultParameters } from './useAddWatermarkParameters'; import { AddWatermarkParameters, defaultParameters } from './useAddWatermarkParameters';
@ -35,11 +35,11 @@ export const buildAddWatermarkFormData = (parameters: AddWatermarkParameters, fi
// Static configuration object // Static configuration object
export const addWatermarkOperationConfig = { export const addWatermarkOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddWatermarkFormData,
operationType: 'watermark', operationType: 'watermark',
endpoint: '/api/v1/security/add-watermark', endpoint: '/api/v1/security/add-watermark',
buildFormData: buildAddWatermarkFormData,
filePrefix: 'watermarked_', // Will be overridden in hook with translation filePrefix: 'watermarked_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters, defaultParameters,
} as const; } as const;
@ -51,4 +51,4 @@ export const useAddWatermarkOperation = () => {
filePrefix: t('watermark.filenamePrefix', 'watermarked') + '_', filePrefix: t('watermark.filenamePrefix', 'watermarked') + '_',
getErrorMessage: createStandardErrorHandler(t('watermark.error.failed', 'An error occurred while adding watermark to the PDF.')) getErrorMessage: createStandardErrorHandler(t('watermark.error.failed', 'An error occurred while adding watermark to the PDF.'))
}); });
}; };

View File

@ -1,4 +1,4 @@
import { useToolOperation } from '../shared/useToolOperation'; import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { executeAutomationSequence } from '../../../utils/automationExecutor'; import { executeAutomationSequence } from '../../../utils/automationExecutor';
import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry'; import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry';
@ -10,7 +10,7 @@ export function useAutomateOperation() {
const customProcessor = useCallback(async (params: AutomateParameters, files: File[]) => { const customProcessor = useCallback(async (params: AutomateParameters, files: File[]) => {
console.log('🚀 Starting automation execution via customProcessor', { params, files }); console.log('🚀 Starting automation execution via customProcessor', { params, files });
if (!params.automationConfig) { if (!params.automationConfig) {
throw new Error('No automation configuration provided'); throw new Error('No automation configuration provided');
} }
@ -40,10 +40,9 @@ export function useAutomateOperation() {
}, [toolRegistry]); }, [toolRegistry]);
return useToolOperation<AutomateParameters>({ return useToolOperation<AutomateParameters>({
toolType: ToolType.custom,
operationType: 'automate', operationType: 'automate',
endpoint: '/api/v1/pipeline/handleData', // Not used with customProcessor
buildFormData: () => new FormData(), // Not used with customProcessor
customProcessor, customProcessor,
filePrefix: '' // No prefix needed since automation handles naming internally filePrefix: '' // No prefix needed since automation handles naming internally
}); });
} }

View File

@ -4,9 +4,13 @@ import { useChangePermissionsOperation } from './useChangePermissionsOperation';
import type { ChangePermissionsParameters } from './useChangePermissionsParameters'; import type { ChangePermissionsParameters } from './useChangePermissionsParameters';
// Mock the useToolOperation hook // Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', () => ({ vi.mock('../shared/useToolOperation', async () => {
useToolOperation: vi.fn() const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
})); return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook // Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`); const mockT = vi.fn((key: string) => `translated-${key}`);
@ -20,12 +24,12 @@ vi.mock('../../../utils/toolErrorHandler', () => ({
})); }));
// Import the mocked function // Import the mocked function
import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation'; import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation';
describe('useChangePermissionsOperation', () => { describe('useChangePermissionsOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation); const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = (): ToolOperationConfig<ChangePermissionsParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<ChangePermissionsParameters>; const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<ChangePermissionsParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = { const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [], files: [],
@ -86,7 +90,7 @@ describe('useChangePermissionsOperation', () => {
const buildFormData = callArgs.buildFormData; const buildFormData = callArgs.buildFormData;
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' }); const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const formData = buildFormData(testParameters, testFile as any /* FIX ME */); const formData = buildFormData(testParameters, testFile);
// Verify the form data contains the file // Verify the form data contains the file
expect(formData.get('fileInput')).toBe(testFile); expect(formData.get('fileInput')).toBe(testFile);
@ -106,7 +110,7 @@ describe('useChangePermissionsOperation', () => {
}); });
test.each([ test.each([
{ property: 'multiFileEndpoint' as const, expectedValue: false }, { property: 'toolType' as const, expectedValue: ToolType.singleFile },
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' }, { property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
{ property: 'filePrefix' as const, expectedValue: 'permissions_' }, { property: 'filePrefix' as const, expectedValue: 'permissions_' },
{ property: 'operationType' as const, expectedValue: 'change-permissions' } { property: 'operationType' as const, expectedValue: 'change-permissions' }

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation'; import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { ChangePermissionsParameters, defaultParameters } from './useChangePermissionsParameters'; import { ChangePermissionsParameters, defaultParameters } from './useChangePermissionsParameters';
@ -24,11 +24,11 @@ export const buildChangePermissionsFormData = (parameters: ChangePermissionsPara
// Static configuration object // Static configuration object
export const changePermissionsOperationConfig = { export const changePermissionsOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildChangePermissionsFormData,
operationType: 'change-permissions', operationType: 'change-permissions',
endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool
buildFormData: buildChangePermissionsFormData,
filePrefix: 'permissions_', filePrefix: 'permissions_',
multiFileEndpoint: false,
defaultParameters, defaultParameters,
} as const; } as const;
@ -39,6 +39,6 @@ export const useChangePermissionsOperation = () => {
...changePermissionsOperationConfig, ...changePermissionsOperationConfig,
getErrorMessage: createStandardErrorHandler( getErrorMessage: createStandardErrorHandler(
t('changePermissions.error.failed', 'An error occurred while changing PDF permissions.') t('changePermissions.error.failed', 'An error occurred while changing PDF permissions.')
) ),
}); });
}; };

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { CompressParameters, defaultParameters } from './useCompressParameters'; import { CompressParameters, defaultParameters } from './useCompressParameters';
@ -24,11 +24,11 @@ export const buildCompressFormData = (parameters: CompressParameters, file: File
// Static configuration object // Static configuration object
export const compressOperationConfig = { export const compressOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCompressFormData,
operationType: 'compress', operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf', endpoint: '/api/v1/misc/compress-pdf',
buildFormData: buildCompressFormData,
filePrefix: 'compressed_', filePrefix: 'compressed_',
multiFileEndpoint: false, // Individual API calls per file
defaultParameters, defaultParameters,
} as const; } as const;

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { ConvertParameters, defaultParameters } from './useConvertParameters'; import { ConvertParameters, defaultParameters } from './useConvertParameters';
import { detectFileExtension } from '../../../utils/fileUtils'; import { detectFileExtension } from '../../../utils/fileUtils';
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils'; import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils'; import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
// Static function that can be used by both the hook and automation executor // Static function that can be used by both the hook and automation executor
@ -129,11 +129,10 @@ export const convertProcessor = async (
// Static configuration object // Static configuration object
export const convertOperationConfig = { export const convertOperationConfig = {
toolType: ToolType.custom,
customProcessor: convertProcessor, // Can't use callback version here
operationType: 'convert', operationType: 'convert',
endpoint: '', // Not used with customProcessor but required
buildFormData: buildConvertFormData, // Not used with customProcessor but required
filePrefix: 'converted_', filePrefix: 'converted_',
customProcessor: convertProcessor,
defaultParameters, defaultParameters,
} as const; } as const;
@ -158,6 +157,6 @@ export const useConvertOperation = () => {
return error.message; return error.message;
} }
return t("convert.errorConversion", "An error occurred while converting the file."); return t("convert.errorConversion", "An error occurred while converting the file.");
} },
}); });
}; };

View File

@ -1,7 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { OCRParameters, defaultParameters } from './useOCRParameters'; import { OCRParameters, defaultParameters } from './useOCRParameters';
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { useToolResources } from '../shared/useToolResources'; import { useToolResources } from '../shared/useToolResources';
@ -52,7 +52,7 @@ export const buildOCRFormData = (parameters: OCRParameters, file: File): FormDat
return formData; return formData;
}; };
// Static response handler for OCR - can be used by automation executor // Static response handler for OCR - can be used by automation executor
export const ocrResponseHandler = async (blob: Blob, originalFiles: File[], extractZipFiles: (blob: Blob) => Promise<File[]>): Promise<File[]> => { export const ocrResponseHandler = async (blob: Blob, originalFiles: File[], extractZipFiles: (blob: Blob) => Promise<File[]>): Promise<File[]> => {
const headBuf = await blob.slice(0, 8).arrayBuffer(); const headBuf = await blob.slice(0, 8).arrayBuffer();
const head = new TextDecoder().decode(new Uint8Array(headBuf)); const head = new TextDecoder().decode(new Uint8Array(headBuf));
@ -94,11 +94,11 @@ export const ocrResponseHandler = async (blob: Blob, originalFiles: File[], extr
// Static configuration object (without t function dependencies) // Static configuration object (without t function dependencies)
export const ocrOperationConfig = { export const ocrOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildOCRFormData,
operationType: 'ocr', operationType: 'ocr',
endpoint: '/api/v1/misc/ocr-pdf', endpoint: '/api/v1/misc/ocr-pdf',
buildFormData: buildOCRFormData,
filePrefix: 'ocr_', filePrefix: 'ocr_',
multiFileEndpoint: false,
defaultParameters, defaultParameters,
} as const; } as const;

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation'; import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RemoveCertificateSignParameters, defaultParameters } from './useRemoveCertificateSignParameters'; import { RemoveCertificateSignParameters, defaultParameters } from './useRemoveCertificateSignParameters';
@ -12,11 +12,11 @@ export const buildRemoveCertificateSignFormData = (parameters: RemoveCertificate
// Static configuration object // Static configuration object
export const removeCertificateSignOperationConfig = { export const removeCertificateSignOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveCertificateSignFormData,
operationType: 'remove-certificate-sign', operationType: 'remove-certificate-sign',
endpoint: '/api/v1/security/remove-cert-sign', endpoint: '/api/v1/security/remove-cert-sign',
buildFormData: buildRemoveCertificateSignFormData,
filePrefix: 'unsigned_', // Will be overridden in hook with translation filePrefix: 'unsigned_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters, defaultParameters,
} as const; } as const;
@ -28,4 +28,4 @@ export const useRemoveCertificateSignOperation = () => {
filePrefix: t('removeCertSign.filenamePrefix', 'unsigned') + '_', filePrefix: t('removeCertSign.filenamePrefix', 'unsigned') + '_',
getErrorMessage: createStandardErrorHandler(t('removeCertSign.error.failed', 'An error occurred while removing certificate signatures.')) getErrorMessage: createStandardErrorHandler(t('removeCertSign.error.failed', 'An error occurred while removing certificate signatures.'))
}); });
}; };

View File

@ -3,10 +3,13 @@ import { renderHook } from '@testing-library/react';
import { useRemovePasswordOperation } from './useRemovePasswordOperation'; import { useRemovePasswordOperation } from './useRemovePasswordOperation';
import type { RemovePasswordParameters } from './useRemovePasswordParameters'; import type { RemovePasswordParameters } from './useRemovePasswordParameters';
// Mock the useToolOperation hook vi.mock('../shared/useToolOperation', async () => {
vi.mock('../shared/useToolOperation', () => ({ const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
useToolOperation: vi.fn() return {
})); ...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook // Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`); const mockT = vi.fn((key: string) => `translated-${key}`);
@ -20,12 +23,12 @@ vi.mock('../../../utils/toolErrorHandler', () => ({
})); }));
// Import the mocked function // Import the mocked function
import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation'; import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation';
describe('useRemovePasswordOperation', () => { describe('useRemovePasswordOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation); const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = (): ToolOperationConfig<RemovePasswordParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<RemovePasswordParameters>; const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<RemovePasswordParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = { const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [], files: [],
@ -91,7 +94,7 @@ describe('useRemovePasswordOperation', () => {
}); });
test.each([ test.each([
{ property: 'multiFileEndpoint' as const, expectedValue: false }, { property: 'toolType' as const, expectedValue: ToolType.singleFile },
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/remove-password' }, { property: 'endpoint' as const, expectedValue: '/api/v1/security/remove-password' },
{ property: 'filePrefix' as const, expectedValue: 'translated-removePassword.filenamePrefix_' }, { property: 'filePrefix' as const, expectedValue: 'translated-removePassword.filenamePrefix_' },
{ property: 'operationType' as const, expectedValue: 'removePassword' } { property: 'operationType' as const, expectedValue: 'removePassword' }

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation'; import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RemovePasswordParameters, defaultParameters } from './useRemovePasswordParameters'; import { RemovePasswordParameters, defaultParameters } from './useRemovePasswordParameters';
@ -13,11 +13,11 @@ export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters
// Static configuration object // Static configuration object
export const removePasswordOperationConfig = { export const removePasswordOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemovePasswordFormData,
operationType: 'removePassword', operationType: 'removePassword',
endpoint: '/api/v1/security/remove-password', endpoint: '/api/v1/security/remove-password',
buildFormData: buildRemovePasswordFormData,
filePrefix: 'decrypted_', // Will be overridden in hook with translation filePrefix: 'decrypted_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters, defaultParameters,
} as const; } as const;

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation'; import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RepairParameters, defaultParameters } from './useRepairParameters'; import { RepairParameters, defaultParameters } from './useRepairParameters';
@ -12,11 +12,11 @@ export const buildRepairFormData = (parameters: RepairParameters, file: File): F
// Static configuration object // Static configuration object
export const repairOperationConfig = { export const repairOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRepairFormData,
operationType: 'repair', operationType: 'repair',
endpoint: '/api/v1/misc/repair', endpoint: '/api/v1/misc/repair',
buildFormData: buildRepairFormData,
filePrefix: 'repaired_', // Will be overridden in hook with translation filePrefix: 'repaired_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters, defaultParameters,
} as const; } as const;
@ -28,4 +28,4 @@ export const useRepairOperation = () => {
filePrefix: t('repair.filenamePrefix', 'repaired') + '_', filePrefix: t('repair.filenamePrefix', 'repaired') + '_',
getErrorMessage: createStandardErrorHandler(t('repair.error.failed', 'An error occurred while repairing the PDF.')) getErrorMessage: createStandardErrorHandler(t('repair.error.failed', 'An error occurred while repairing the PDF.'))
}); });
}; };

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation'; import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { SanitizeParameters, defaultParameters } from './useSanitizeParameters'; import { SanitizeParameters, defaultParameters } from './useSanitizeParameters';
@ -21,9 +21,10 @@ export const buildSanitizeFormData = (parameters: SanitizeParameters, file: File
// Static configuration object // Static configuration object
export const sanitizeOperationConfig = { export const sanitizeOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSanitizeFormData,
operationType: 'sanitize', operationType: 'sanitize',
endpoint: '/api/v1/security/sanitize-pdf', endpoint: '/api/v1/security/sanitize-pdf',
buildFormData: buildSanitizeFormData,
filePrefix: 'sanitized_', // Will be overridden in hook with translation filePrefix: 'sanitized_', // Will be overridden in hook with translation
multiFileEndpoint: false, multiFileEndpoint: false,
defaultParameters, defaultParameters,

View File

@ -0,0 +1,118 @@
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';
interface BaseToolReturn<TParams> {
// File management
selectedFiles: File[];
// 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;
// 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]);
// 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,
// State
hasFiles,
hasResults,
settingsCollapsed
};
}

View File

@ -8,10 +8,17 @@ import { useToolResources } from './useToolResources';
import { extractErrorMessage } from '../../../utils/toolErrorHandler'; import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { createOperation } from '../../../utils/toolOperationTracker'; import { createOperation } from '../../../utils/toolOperationTracker';
import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { ResponseHandler } from '../../../utils/toolResponseProcessor';
import { FileId } from '../../../types/file';
// Re-export for backwards compatibility // Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler }; export type { ProcessingProgress, ResponseHandler };
export enum ToolType {
singleFile,
multiFile,
custom,
}
/** /**
* Configuration for tool operations defining processing behavior and API integration. * Configuration for tool operations defining processing behavior and API integration.
* *
@ -20,45 +27,16 @@ export type { ProcessingProgress, ResponseHandler };
* 2. Multi-file tools: multiFileEndpoint: true, single API call with all files * 2. Multi-file tools: multiFileEndpoint: true, single API call with all files
* 3. Complex tools: customProcessor handles all processing logic * 3. Complex tools: customProcessor handles all processing logic
*/ */
export interface ToolOperationConfig<TParams = void> { interface BaseToolOperationConfig<TParams> {
/** Operation identifier for tracking and logging */ /** Operation identifier for tracking and logging */
operationType: string; operationType: string;
/**
* API endpoint for the operation. Can be static string or function for dynamic routing.
* Not used when customProcessor is provided.
*/
endpoint: string | ((params: TParams) => string);
/**
* Builds FormData for API request. Signature determines processing approach:
* - (params, file: File) => FormData: Single-file processing
* - (params, files: File[]) => FormData: Multi-file processing
* Not used when customProcessor is provided.
*/
buildFormData: ((params: TParams, file: File) => FormData) | ((params: TParams, files: File[]) => FormData); /* FIX ME */
/** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */ /** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */
filePrefix: string; filePrefix: string;
/**
* Whether this tool uses backends that accept MultipartFile[] arrays.
* - true: Single API call with all files (backend uses MultipartFile[])
* - false/undefined: Individual API calls per file (backend uses single MultipartFile)
* Ignored when customProcessor is provided.
*/
multiFileEndpoint?: boolean;
/** How to handle API responses (e.g., ZIP extraction, single file response) */ /** How to handle API responses (e.g., ZIP extraction, single file response) */
responseHandler?: ResponseHandler; responseHandler?: ResponseHandler;
/**
* Custom processing logic that completely bypasses standard file processing.
* When provided, tool handles all API calls, response processing, and file creation.
* Use for tools with complex routing logic or non-standard processing requirements.
*/
customProcessor?: (params: TParams, files: File[]) => Promise<File[]>;
/** Extract user-friendly error messages from API errors */ /** Extract user-friendly error messages from API errors */
getErrorMessage?: (error: any) => string; getErrorMessage?: (error: any) => string;
@ -66,6 +44,49 @@ export interface ToolOperationConfig<TParams = void> {
defaultParameters?: TParams; defaultParameters?: TParams;
} }
export interface SingleFileToolOperationConfig<TParams> extends BaseToolOperationConfig<TParams> {
/** This tool processes one file at a time. */
toolType: ToolType.singleFile;
/** Builds FormData for API request. */
buildFormData: ((params: TParams, file: File) => FormData);
/** API endpoint for the operation. Can be static string or function for dynamic routing. */
endpoint: string | ((params: TParams) => string);
customProcessor?: undefined;
}
export interface MultiFileToolOperationConfig<TParams> extends BaseToolOperationConfig<TParams> {
/** This tool processes multiple files at once. */
toolType: ToolType.multiFile;
/** Builds FormData for API request. */
buildFormData: ((params: TParams, files: File[]) => FormData);
/** API endpoint for the operation. Can be static string or function for dynamic routing. */
endpoint: string | ((params: TParams) => string);
customProcessor?: undefined;
}
export interface CustomToolOperationConfig<TParams> extends BaseToolOperationConfig<TParams> {
/** This tool has custom behaviour. */
toolType: ToolType.custom;
buildFormData?: undefined;
endpoint?: undefined;
/**
* Custom processing logic that completely bypasses standard file processing.
* This tool handles all API calls, response processing, and file creation.
* Use for tools with complex routing logic or non-standard processing requirements.
*/
customProcessor: (params: TParams, files: File[]) => Promise<File[]>;
}
export type ToolOperationConfig<TParams = void> = SingleFileToolOperationConfig<TParams> | MultiFileToolOperationConfig<TParams> | CustomToolOperationConfig<TParams>;
/** /**
* Complete tool operation interface with execution capability * Complete tool operation interface with execution capability
*/ */
@ -103,7 +124,7 @@ export { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
* @param config - Tool operation configuration * @param config - Tool operation configuration
* @returns Hook interface with state and execution methods * @returns Hook interface with state and execution methods
*/ */
export const useToolOperation = <TParams = void>( export const useToolOperation = <TParams>(
config: ToolOperationConfig<TParams> config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => { ): ToolOperationHook<TParams> => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -143,15 +164,28 @@ export const useToolOperation = <TParams = void>(
try { try {
let processedFiles: File[]; let processedFiles: File[];
if (config.customProcessor) { switch (config.toolType) {
actions.setStatus('Processing files...'); case ToolType.singleFile:
processedFiles = await config.customProcessor(params, validFiles); // Individual file processing - separate API call per file
} else { const apiCallsConfig: ApiCallsConfig<TParams> = {
// Use explicit multiFileEndpoint flag to determine processing approach endpoint: config.endpoint,
if (config.multiFileEndpoint) { buildFormData: config.buildFormData,
filePrefix: config.filePrefix,
responseHandler: config.responseHandler
};
processedFiles = await processFiles(
params,
validFiles,
apiCallsConfig,
actions.setProgress,
actions.setStatus
);
break;
case ToolType.multiFile:
// Multi-file processing - single API call with all files // Multi-file processing - single API call with all files
actions.setStatus('Processing files...'); actions.setStatus('Processing files...');
const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validFiles); const formData = config.buildFormData(params, validFiles);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
const response = await axios.post(endpoint, formData, { responseType: 'blob' }); const response = await axios.post(endpoint, formData, { responseType: 'blob' });
@ -160,7 +194,7 @@ export const useToolOperation = <TParams = void>(
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, validFiles); 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 = validFiles[0]?.name || 'document.pdf'; const originalFileName = validFiles[0]?.name || 'document.pdf';
@ -175,22 +209,12 @@ export const useToolOperation = <TParams = void>(
processedFiles = await extractAllZipFiles(response.data); processedFiles = await extractAllZipFiles(response.data);
} }
} }
} else { break;
// Individual file processing - separate API call per file
const apiCallsConfig: ApiCallsConfig<TParams> = { case ToolType.custom:
endpoint: config.endpoint, actions.setStatus('Processing files...');
buildFormData: config.buildFormData as (params: TParams, file: File) => FormData, processedFiles = await config.customProcessor(params, validFiles);
filePrefix: config.filePrefix, break;
responseHandler: config.responseHandler
};
processedFiles = await processFiles(
params,
validFiles,
apiCallsConfig,
actions.setProgress,
actions.setStatus
);
}
} }
if (processedFiles.length > 0) { if (processedFiles.length > 0) {
@ -208,7 +232,7 @@ 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 => findFileId(file)).filter(Boolean) as string[]; const inputFileIds = validFiles.map(file => findFileId(file)).filter(Boolean) as FileId[];
await consumeFiles(inputFileIds, processedFiles); await consumeFiles(inputFileIds, processedFiles);
markOperationApplied(fileId, operationId); markOperationApplied(fileId, operationId);

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation'; import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters'; import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters';
@ -12,11 +12,11 @@ export const buildSingleLargePageFormData = (parameters: SingleLargePageParamete
// Static configuration object // Static configuration object
export const singleLargePageOperationConfig = { export const singleLargePageOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSingleLargePageFormData,
operationType: 'single-large-page', operationType: 'single-large-page',
endpoint: '/api/v1/general/pdf-to-single-page', endpoint: '/api/v1/general/pdf-to-single-page',
buildFormData: buildSingleLargePageFormData,
filePrefix: 'single_page_', // Will be overridden in hook with translation filePrefix: 'single_page_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters, defaultParameters,
} as const; } as const;
@ -28,4 +28,4 @@ export const useSingleLargePageOperation = () => {
filePrefix: t('pdfToSinglePage.filenamePrefix', 'single_page') + '_', filePrefix: t('pdfToSinglePage.filenamePrefix', 'single_page') + '_',
getErrorMessage: createStandardErrorHandler(t('pdfToSinglePage.error.failed', 'An error occurred while converting to single page.')) getErrorMessage: createStandardErrorHandler(t('pdfToSinglePage.error.failed', 'An error occurred while converting to single page.'))
}); });
}; };

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation'; import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { SplitParameters, defaultParameters } from './useSplitParameters'; import { SplitParameters, defaultParameters } from './useSplitParameters';
import { SPLIT_MODES } from '../../../constants/splitConstants'; import { SPLIT_MODES } from '../../../constants/splitConstants';
@ -57,11 +57,11 @@ export const getSplitEndpoint = (parameters: SplitParameters): string => {
// Static configuration object // Static configuration object
export const splitOperationConfig = { export const splitOperationConfig = {
toolType: ToolType.multiFile,
buildFormData: buildSplitFormData,
operationType: 'splitPdf', operationType: 'splitPdf',
endpoint: getSplitEndpoint, endpoint: getSplitEndpoint,
buildFormData: buildSplitFormData,
filePrefix: 'split_', filePrefix: 'split_',
multiFileEndpoint: true, // Single API call with all files
defaultParameters, defaultParameters,
} as const; } as const;

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation'; import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters'; import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters';
@ -12,11 +12,11 @@ export const buildUnlockPdfFormsFormData = (parameters: UnlockPdfFormsParameters
// Static configuration object // Static configuration object
export const unlockPdfFormsOperationConfig = { export const unlockPdfFormsOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildUnlockPdfFormsFormData,
operationType: 'unlock-pdf-forms', operationType: 'unlock-pdf-forms',
endpoint: '/api/v1/misc/unlock-pdf-forms', endpoint: '/api/v1/misc/unlock-pdf-forms',
buildFormData: buildUnlockPdfFormsFormData,
filePrefix: 'unlocked_forms_', // Will be overridden in hook with translation filePrefix: 'unlocked_forms_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters, defaultParameters,
} as const; } as const;
@ -28,4 +28,4 @@ export const useUnlockPdfFormsOperation = () => {
filePrefix: t('unlockPDFForms.filenamePrefix', 'unlocked_forms') + '_', filePrefix: t('unlockPDFForms.filenamePrefix', 'unlocked_forms') + '_',
getErrorMessage: createStandardErrorHandler(t('unlockPDFForms.error.failed', 'An error occurred while unlocking PDF forms.')) getErrorMessage: createStandardErrorHandler(t('unlockPDFForms.error.failed', 'An error occurred while unlocking PDF forms.'))
}); });
}; };

View File

@ -1,6 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useFileState, useFileActions } from '../contexts/FileContext'; import { useFileState, useFileActions } from '../contexts/FileContext';
import { FileMetadata } from '../types/file'; import { FileMetadata } from '../types/file';
import { FileId } from '../types/file';
export const useFileHandler = () => { export const useFileHandler = () => {
const { state } = useFileState(); // Still needed for addStoredFiles const { state } = useFileState(); // Still needed for addStoredFiles
@ -17,16 +18,16 @@ export const useFileHandler = () => {
}, [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
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => { const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => {
// Filter out files that already exist with the same ID (exact match) // Filter out files that already exist with the same ID (exact match)
const newFiles = filesWithMetadata.filter(({ originalId }) => { const newFiles = filesWithMetadata.filter(({ originalId }) => {
return state.files.byId[originalId] === undefined; return state.files.byId[originalId] === undefined;
}); });
if (newFiles.length > 0) { if (newFiles.length > 0) {
await actions.addStoredFiles(newFiles); await actions.addStoredFiles(newFiles);
} }
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)`);
}, [state.files.byId, actions.addStoredFiles]); }, [state.files.byId, actions.addStoredFiles]);
@ -35,4 +36,4 @@ export const useFileHandler = () => {
addMultipleFiles, addMultipleFiles,
addStoredFiles, addStoredFiles,
}; };
}; };

View File

@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
import { useIndexedDB } from '../contexts/IndexedDBContext'; import { useIndexedDB } from '../contexts/IndexedDBContext';
import { FileMetadata } from '../types/file'; import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../utils/thumbnailUtils';
import { FileId } from '../types/file';
export const useFileManager = () => { export const useFileManager = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -11,19 +12,19 @@ export const useFileManager = () => {
if (!indexedDB) { if (!indexedDB) {
throw new Error('IndexedDB context not available'); throw new Error('IndexedDB context not available');
} }
// Handle drafts differently from regular files // Handle drafts differently from regular files
if (fileMetadata.isDraft) { if (fileMetadata.isDraft) {
// Load draft from the drafts database // Load draft from the drafts database
try { try {
const { indexedDBManager, DATABASE_CONFIGS } = await import('../services/indexedDBManager'); const { indexedDBManager, DATABASE_CONFIGS } = await import('../services/indexedDBManager');
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction(['drafts'], 'readonly'); const transaction = db.transaction(['drafts'], 'readonly');
const store = transaction.objectStore('drafts'); const store = transaction.objectStore('drafts');
const request = store.get(fileMetadata.id); const request = store.get(fileMetadata.id);
request.onsuccess = () => { request.onsuccess = () => {
const draft = request.result; const draft = request.result;
if (draft && draft.pdfData) { if (draft && draft.pdfData) {
@ -36,14 +37,14 @@ export const useFileManager = () => {
reject(new Error('Draft data not found')); reject(new Error('Draft data not found'));
} }
}; };
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
} catch (error) { } catch (error) {
throw new Error(`Failed to load draft: ${fileMetadata.name} (${error})`); throw new Error(`Failed to load draft: ${fileMetadata.name} (${error})`);
} }
} }
// Regular file loading // Regular file loading
if (fileMetadata.id) { if (fileMetadata.id) {
const file = await indexedDB.loadFile(fileMetadata.id); const file = await indexedDB.loadFile(fileMetadata.id);
@ -60,14 +61,14 @@ export const useFileManager = () => {
if (!indexedDB) { if (!indexedDB) {
return []; return [];
} }
// Load regular files metadata only // Load regular files metadata only
const storedFileMetadata = await indexedDB.loadAllMetadata(); const storedFileMetadata = await indexedDB.loadAllMetadata();
// For now, only regular files - drafts will be handled separately in the future // For now, only regular files - drafts will be handled separately in the future
const allFiles = storedFileMetadata; const allFiles = storedFileMetadata;
const sortedFiles = allFiles.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0)); const sortedFiles = allFiles.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
return sortedFiles; return sortedFiles;
} catch (error) { } catch (error) {
console.error('Failed to load recent files:', error); console.error('Failed to load recent files:', error);
@ -94,7 +95,7 @@ export const useFileManager = () => {
} }
}, [indexedDB]); }, [indexedDB]);
const storeFile = useCallback(async (file: File, fileId: string) => { const storeFile = useCallback(async (file: File, fileId: FileId) => {
if (!indexedDB) { if (!indexedDB) {
throw new Error('IndexedDB context not available'); throw new Error('IndexedDB context not available');
} }
@ -104,7 +105,7 @@ export const useFileManager = () => {
// Convert file to ArrayBuffer for StoredFile interface compatibility // Convert file to ArrayBuffer for StoredFile interface compatibility
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
// Return StoredFile format for compatibility with old API // Return StoredFile format for compatibility with old API
return { return {
id: fileId, id: fileId,
@ -122,10 +123,10 @@ export const useFileManager = () => {
}, [indexedDB]); }, [indexedDB]);
const createFileSelectionHandlers = useCallback(( const createFileSelectionHandlers = useCallback((
selectedFiles: string[], selectedFiles: FileId[],
setSelectedFiles: (files: string[]) => void setSelectedFiles: (files: FileId[]) => void
) => { ) => {
const toggleSelection = (fileId: string) => { const toggleSelection = (fileId: FileId) => {
setSelectedFiles( setSelectedFiles(
selectedFiles.includes(fileId) selectedFiles.includes(fileId)
? selectedFiles.filter(id => id !== fileId) ? selectedFiles.filter(id => id !== fileId)
@ -137,13 +138,13 @@ export const useFileManager = () => {
setSelectedFiles([]); setSelectedFiles([]);
}; };
const selectMultipleFiles = async (files: FileMetadata[], onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void) => { const selectMultipleFiles = async (files: FileMetadata[], onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void) => {
if (selectedFiles.length === 0) return; if (selectedFiles.length === 0) return;
try { try {
// Filter by UUID and convert to File objects // Filter by UUID and convert to File objects
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id)); const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id));
// Use stored files flow that preserves IDs // Use stored files flow that preserves IDs
const filesWithMetadata = await Promise.all( const filesWithMetadata = await Promise.all(
selectedFileObjects.map(async (metadata) => ({ selectedFileObjects.map(async (metadata) => ({
@ -153,7 +154,7 @@ export const useFileManager = () => {
})) }))
); );
onStoredFilesSelect(filesWithMetadata); onStoredFilesSelect(filesWithMetadata);
clearSelection(); clearSelection();
} catch (error) { } catch (error) {
console.error('Failed to load selected files:', error); console.error('Failed to load selected files:', error);
@ -168,7 +169,7 @@ export const useFileManager = () => {
}; };
}, [convertToFile]); }, [convertToFile]);
const touchFile = useCallback(async (id: string) => { const touchFile = useCallback(async (id: FileId) => {
if (!indexedDB) { if (!indexedDB) {
console.warn('IndexedDB context not available for touch operation'); console.warn('IndexedDB context not available for touch operation');
return; return;

View File

@ -1,5 +1,6 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
import { FileId } from '../types/file';
// Request queue to handle concurrent thumbnail requests // Request queue to handle concurrent thumbnail requests
interface ThumbnailRequest { interface ThumbnailRequest {
@ -35,44 +36,44 @@ async function processRequestQueue() {
while (requestQueue.length > 0) { while (requestQueue.length > 0) {
// Sort queue by page number to prioritize visible pages first // Sort queue by page number to prioritize visible pages first
requestQueue.sort((a, b) => a.pageNumber - b.pageNumber); requestQueue.sort((a, b) => a.pageNumber - b.pageNumber);
// Take a batch of requests (same file only for efficiency) // Take a batch of requests (same file only for efficiency)
const batchSize = Math.min(BATCH_SIZE, requestQueue.length); const batchSize = Math.min(BATCH_SIZE, requestQueue.length);
const batch = requestQueue.splice(0, batchSize); const batch = requestQueue.splice(0, batchSize);
// Group by file to process efficiently // Group by file to process efficiently
const fileGroups = new Map<File, ThumbnailRequest[]>(); const fileGroups = new Map<File, ThumbnailRequest[]>();
// First, resolve any cached thumbnails immediately // First, resolve any cached thumbnails immediately
const uncachedRequests: ThumbnailRequest[] = []; const uncachedRequests: ThumbnailRequest[] = [];
for (const request of batch) { for (const request of batch) {
const cached = thumbnailGenerationService.getThumbnailFromCache(request.pageId); const cached = thumbnailGenerationService.getThumbnailFromCache(request.pageId);
if (cached) { if (cached) {
request.resolve(cached); request.resolve(cached);
} else { } else {
uncachedRequests.push(request); uncachedRequests.push(request);
if (!fileGroups.has(request.file)) { if (!fileGroups.has(request.file)) {
fileGroups.set(request.file, []); fileGroups.set(request.file, []);
} }
fileGroups.get(request.file)!.push(request); fileGroups.get(request.file)!.push(request);
} }
} }
// Process each file group with batch thumbnail generation // Process each file group with batch thumbnail generation
for (const [file, requests] of fileGroups) { for (const [file, requests] of fileGroups) {
if (requests.length === 0) continue; if (requests.length === 0) continue;
try { try {
const pageNumbers = requests.map(req => req.pageNumber); const pageNumbers = requests.map(req => req.pageNumber);
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`); console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
// Use file name as fileId for PDF document caching // Use file name as fileId for PDF document caching
const fileId = file.name + '_' + file.size + '_' + file.lastModified; const fileId = file.name + '_' + file.size + '_' + file.lastModified as FileId;
const results = await thumbnailGenerationService.generateThumbnails( const results = await thumbnailGenerationService.generateThumbnails(
fileId, fileId,
arrayBuffer, arrayBuffer,
@ -83,11 +84,11 @@ async function processRequestQueue() {
console.log(`📸 Batch progress: ${progress.completed}/${progress.total} thumbnails generated`); console.log(`📸 Batch progress: ${progress.completed}/${progress.total} thumbnails generated`);
} }
); );
// Match results back to requests and resolve // Match results back to requests and resolve
for (const request of requests) { for (const request of requests) {
const result = results.find(r => r.pageNumber === request.pageNumber); const result = results.find(r => r.pageNumber === request.pageNumber);
if (result && result.success && result.thumbnail) { if (result && result.success && result.thumbnail) {
thumbnailGenerationService.addThumbnailToCache(request.pageId, result.thumbnail); thumbnailGenerationService.addThumbnailToCache(request.pageId, result.thumbnail);
request.resolve(result.thumbnail); request.resolve(result.thumbnail);
@ -96,7 +97,7 @@ async function processRequestQueue() {
request.resolve(null); request.resolve(null);
} }
} }
} catch (error) { } catch (error) {
console.warn(`Batch thumbnail generation failed for ${requests.length} pages:`, error); console.warn(`Batch thumbnail generation failed for ${requests.length} pages:`, error);
// Reject all requests in this batch // Reject all requests in this batch
@ -115,7 +116,7 @@ async function processRequestQueue() {
*/ */
export function useThumbnailGeneration() { export function useThumbnailGeneration() {
const generateThumbnails = useCallback(async ( const generateThumbnails = useCallback(async (
fileId: string, fileId: FileId,
pdfArrayBuffer: ArrayBuffer, pdfArrayBuffer: ArrayBuffer,
pageNumbers: number[], pageNumbers: number[],
options: { options: {
@ -157,22 +158,22 @@ export function useThumbnailGeneration() {
clearTimeout(batchTimer); clearTimeout(batchTimer);
batchTimer = null; batchTimer = null;
} }
// Clear the queue and active requests // Clear the queue and active requests
requestQueue.length = 0; requestQueue.length = 0;
activeRequests.clear(); activeRequests.clear();
isProcessingQueue = false; isProcessingQueue = false;
thumbnailGenerationService.destroy(); thumbnailGenerationService.destroy();
}, []); }, []);
const clearPDFCacheForFile = useCallback((fileId: string) => { const clearPDFCacheForFile = useCallback((fileId: FileId) => {
thumbnailGenerationService.clearPDFCacheForFile(fileId); thumbnailGenerationService.clearPDFCacheForFile(fileId);
}, []); }, []);
const requestThumbnail = useCallback(async ( const requestThumbnail = useCallback(async (
pageId: string, pageId: string,
file: File, file: File,
pageNumber: number pageNumber: number
): Promise<string | null> => { ): Promise<string | null> => {
// Check cache first for immediate return // Check cache first for immediate return
@ -202,16 +203,16 @@ export function useThumbnailGeneration() {
reject(error); reject(error);
} }
}); });
// Schedule batch processing with a small delay to collect more requests // Schedule batch processing with a small delay to collect more requests
if (batchTimer) { if (batchTimer) {
clearTimeout(batchTimer); clearTimeout(batchTimer);
} }
// Use shorter delay for the first batch (pages 1-50) to show visible content faster // Use shorter delay for the first batch (pages 1-50) to show visible content faster
const isFirstBatch = requestQueue.length <= BATCH_SIZE && requestQueue.every(req => req.pageNumber <= BATCH_SIZE); const isFirstBatch = requestQueue.length <= BATCH_SIZE && requestQueue.every(req => req.pageNumber <= BATCH_SIZE);
const delay = isFirstBatch ? PRIORITY_BATCH_DELAY : BATCH_DELAY; const delay = isFirstBatch ? PRIORITY_BATCH_DELAY : BATCH_DELAY;
batchTimer = window.setTimeout(() => { batchTimer = window.setTimeout(() => {
processRequestQueue().catch(error => { processRequestQueue().catch(error => {
console.error('Error processing thumbnail request queue:', error); console.error('Error processing thumbnail request queue:', error);
@ -222,7 +223,7 @@ export function useThumbnailGeneration() {
// Track this request to prevent duplicates // Track this request to prevent duplicates
activeRequests.set(pageId, requestPromise); activeRequests.set(pageId, requestPromise);
return requestPromise; return requestPromise;
}, []); }, []);
@ -236,4 +237,4 @@ export function useThumbnailGeneration() {
clearPDFCacheForFile, clearPDFCacheForFile,
requestThumbnail requestThumbnail
}; };
} }

View File

@ -3,19 +3,20 @@ import { useTranslation } from 'react-i18next';
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry"; import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy"; import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { FileId } from '../types/file';
interface ToolManagementResult { interface ToolManagementResult {
selectedTool: ToolRegistryEntry | null; selectedTool: ToolRegistryEntry | null;
toolSelectedFileIds: string[]; toolSelectedFileIds: FileId[];
toolRegistry: Record<string, ToolRegistryEntry>; toolRegistry: Record<string, ToolRegistryEntry>;
setToolSelectedFileIds: (fileIds: string[]) => void; setToolSelectedFileIds: (fileIds: FileId[]) => void;
getSelectedTool: (toolKey: string | null) => ToolRegistryEntry | null; getSelectedTool: (toolKey: string | null) => ToolRegistryEntry | null;
} }
export const useToolManagement = (): ToolManagementResult => { export const useToolManagement = (): ToolManagementResult => {
const { t } = useTranslation(); const { t } = useTranslation();
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]); const [toolSelectedFileIds, setToolSelectedFileIds] = useState<FileId[]>([]);
// Build endpoints list from registry entries with fallback to legacy mapping // Build endpoints list from registry entries with fallback to legacy mapping
const baseRegistry = useFlatToolRegistry(); const baseRegistry = useFlatToolRegistry();

View File

@ -7,6 +7,7 @@
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import { generateThumbnailForFile } from '../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../utils/thumbnailUtils';
import { pdfWorkerManager } from './pdfWorkerManager'; import { pdfWorkerManager } from './pdfWorkerManager';
import { FileId } from '../types/file';
export interface ProcessedFileMetadata { export interface ProcessedFileMetadata {
totalPages: number; totalPages: number;
@ -38,7 +39,7 @@ class FileProcessingService {
* Process a file to extract metadata, page count, and generate thumbnails * Process a file to extract metadata, page count, and generate thumbnails
* This is the single source of truth for file processing * This is the single source of truth for file processing
*/ */
async processFile(file: File, fileId: string): Promise<FileProcessingResult> { async processFile(file: File, fileId: FileId): Promise<FileProcessingResult> {
// Check if we're already processing this file // Check if we're already processing this file
const existingOperation = this.processingCache.get(fileId); const existingOperation = this.processingCache.get(fileId);
if (existingOperation) { if (existingOperation) {
@ -48,10 +49,10 @@ class FileProcessingService {
// Create abort controller for this operation // Create abort controller for this operation
const abortController = new AbortController(); const abortController = new AbortController();
// Create processing promise // Create processing promise
const processingPromise = this.performProcessing(file, fileId, abortController); const processingPromise = this.performProcessing(file, fileId, abortController);
// Store operation with abort controller // Store operation with abort controller
const operation: ProcessingOperation = { const operation: ProcessingOperation = {
promise: processingPromise, promise: processingPromise,
@ -67,7 +68,7 @@ class FileProcessingService {
return processingPromise; return processingPromise;
} }
private async performProcessing(file: File, fileId: string, abortController: AbortController): Promise<FileProcessingResult> { private async performProcessing(file: File, fileId: FileId, abortController: AbortController): Promise<FileProcessingResult> {
console.log(`📁 FileProcessingService: Starting processing for ${file.name} (${fileId})`); console.log(`📁 FileProcessingService: Starting processing for ${file.name} (${fileId})`);
try { try {
@ -83,12 +84,12 @@ class FileProcessingService {
if (file.type === 'application/pdf') { if (file.type === 'application/pdf') {
// Read arrayBuffer once and reuse for both PDF.js and fallback // Read arrayBuffer once and reuse for both PDF.js and fallback
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
// Check for cancellation after async operation // Check for cancellation after async operation
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
throw new Error('Processing cancelled'); throw new Error('Processing cancelled');
} }
// Discover page count using PDF.js (most accurate) // Discover page count using PDF.js (most accurate)
try { try {
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, { const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
@ -101,7 +102,7 @@ class FileProcessingService {
// Clean up immediately // Clean up immediately
pdfWorkerManager.destroyDocument(pdfDoc); pdfWorkerManager.destroyDocument(pdfDoc);
// Check for cancellation after PDF.js processing // Check for cancellation after PDF.js processing
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
throw new Error('Processing cancelled'); throw new Error('Processing cancelled');
@ -116,7 +117,7 @@ class FileProcessingService {
try { try {
thumbnailUrl = await generateThumbnailForFile(file); thumbnailUrl = await generateThumbnailForFile(file);
console.log(`📁 FileProcessingService: Generated thumbnail for ${file.name}`); console.log(`📁 FileProcessingService: Generated thumbnail for ${file.name}`);
// Check for cancellation after thumbnail generation // Check for cancellation after thumbnail generation
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
throw new Error('Processing cancelled'); throw new Error('Processing cancelled');
@ -141,7 +142,7 @@ class FileProcessingService {
}; };
console.log(`📁 FileProcessingService: Processing complete for ${file.name} - ${totalPages} pages`); console.log(`📁 FileProcessingService: Processing complete for ${file.name} - ${totalPages} pages`);
return { return {
success: true, success: true,
metadata metadata
@ -149,7 +150,7 @@ class FileProcessingService {
} catch (error) { } catch (error) {
console.error(`📁 FileProcessingService: Processing failed for ${file.name}:`, error); console.error(`📁 FileProcessingService: Processing failed for ${file.name}:`, error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown processing error' error: error instanceof Error ? error.message : 'Unknown processing error'
@ -167,14 +168,14 @@ class FileProcessingService {
/** /**
* Check if a file is currently being processed * Check if a file is currently being processed
*/ */
isProcessing(fileId: string): boolean { isProcessing(fileId: FileId): boolean {
return this.processingCache.has(fileId); return this.processingCache.has(fileId);
} }
/** /**
* Cancel processing for a specific file * Cancel processing for a specific file
*/ */
cancelProcessing(fileId: string): boolean { cancelProcessing(fileId: FileId): boolean {
const operation = this.processingCache.get(fileId); const operation = this.processingCache.get(fileId);
if (operation) { if (operation) {
operation.abortController.abort(); operation.abortController.abort();
@ -206,4 +207,4 @@ class FileProcessingService {
} }
// Export singleton instance // Export singleton instance
export const fileProcessingService = new FileProcessingService(); export const fileProcessingService = new FileProcessingService();

View File

@ -4,10 +4,11 @@
* Now uses centralized IndexedDB manager * Now uses centralized IndexedDB manager
*/ */
import { FileId } from '../types/file';
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager'; import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
export interface StoredFile { export interface StoredFile {
id: string; id: FileId;
name: string; name: string;
type: string; type: string;
size: number; size: number;
@ -38,7 +39,7 @@ class FileStorageService {
/** /**
* Store a file in IndexedDB with external UUID * Store a file in IndexedDB with external UUID
*/ */
async storeFile(file: File, fileId: string, thumbnail?: string): Promise<StoredFile> { async storeFile(file: File, fileId: FileId, thumbnail?: string): Promise<StoredFile> {
const db = await this.getDatabase(); const db = await this.getDatabase();
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
@ -88,7 +89,7 @@ class FileStorageService {
/** /**
* Retrieve a file from IndexedDB * Retrieve a file from IndexedDB
*/ */
async getFile(id: string): Promise<StoredFile | null> { async getFile(id: FileId): Promise<StoredFile | null> {
const db = await this.getDatabase(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -166,7 +167,7 @@ class FileStorageService {
/** /**
* Delete a file from IndexedDB * Delete a file from IndexedDB
*/ */
async deleteFile(id: string): Promise<void> { async deleteFile(id: FileId): Promise<void> {
const db = await this.getDatabase(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -182,12 +183,12 @@ class FileStorageService {
/** /**
* Update the lastModified timestamp of a file (for most recently used sorting) * Update the lastModified timestamp of a file (for most recently used sorting)
*/ */
async touchFile(id: string): Promise<boolean> { async touchFile(id: FileId): Promise<boolean> {
const db = await this.getDatabase(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite'); const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const getRequest = store.get(id); const getRequest = store.get(id);
getRequest.onsuccess = () => { getRequest.onsuccess = () => {
const file = getRequest.result; const file = getRequest.result;
@ -438,9 +439,9 @@ class FileStorageService {
* Convert StoredFile to the format expected by FileContext.addStoredFiles() * Convert StoredFile to the format expected by FileContext.addStoredFiles()
* This is the recommended way to load stored files into FileContext * This is the recommended way to load stored files into FileContext
*/ */
createFileWithMetadata(storedFile: StoredFile): { file: File; originalId: string; metadata: { thumbnail?: string } } { createFileWithMetadata(storedFile: StoredFile): { file: File; originalId: FileId; metadata: { thumbnail?: string } } {
const file = this.createFileFromStored(storedFile); const file = this.createFileFromStored(storedFile);
return { return {
file, file,
originalId: storedFile.id, originalId: storedFile.id,
@ -461,7 +462,7 @@ class FileStorageService {
/** /**
* Get file data as ArrayBuffer for streaming/chunked processing * Get file data as ArrayBuffer for streaming/chunked processing
*/ */
async getFileData(id: string): Promise<ArrayBuffer | null> { async getFileData(id: FileId): Promise<ArrayBuffer | null> {
try { try {
const storedFile = await this.getFile(id); const storedFile = await this.getFile(id);
return storedFile ? storedFile.data : null; return storedFile ? storedFile.data : null;
@ -474,7 +475,7 @@ class FileStorageService {
/** /**
* Create a temporary blob URL that gets revoked automatically * Create a temporary blob URL that gets revoked automatically
*/ */
async createTemporaryBlobUrl(id: string): Promise<string | null> { async createTemporaryBlobUrl(id: FileId): Promise<string | null> {
const data = await this.getFileData(id); const data = await this.getFileData(id);
if (!data) return null; if (!data) return null;
@ -492,7 +493,7 @@ class FileStorageService {
/** /**
* Update thumbnail for an existing file * Update thumbnail for an existing file
*/ */
async updateThumbnail(id: string, thumbnail: string): Promise<boolean> { async updateThumbnail(id: FileId, thumbnail: string): Promise<boolean> {
const db = await this.getDatabase(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@ -2,6 +2,7 @@
* High-performance thumbnail generation service using main thread processing * High-performance thumbnail generation service using main thread processing
*/ */
import { FileId } from '../types/file';
import { pdfWorkerManager } from './pdfWorkerManager'; import { pdfWorkerManager } from './pdfWorkerManager';
interface ThumbnailResult { interface ThumbnailResult {
@ -32,12 +33,12 @@ interface CachedPDFDocument {
export class ThumbnailGenerationService { export class ThumbnailGenerationService {
// Session-based thumbnail cache // Session-based thumbnail cache
private thumbnailCache = new Map<string, CachedThumbnail>(); private thumbnailCache = new Map<FileId | string /* FIX ME: Page ID */, CachedThumbnail>();
private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit
private currentCacheSize = 0; private currentCacheSize = 0;
// PDF document cache to reuse PDF instances and avoid creating multiple workers // PDF document cache to reuse PDF instances and avoid creating multiple workers
private pdfDocumentCache = new Map<string, CachedPDFDocument>(); private pdfDocumentCache = new Map<FileId, CachedPDFDocument>();
private maxPdfCacheSize = 10; // Keep up to 10 PDF documents cached private maxPdfCacheSize = 10; // Keep up to 10 PDF documents cached
constructor(private maxWorkers: number = 10) { constructor(private maxWorkers: number = 10) {
@ -47,7 +48,7 @@ export class ThumbnailGenerationService {
/** /**
* Get or create a cached PDF document * Get or create a cached PDF document
*/ */
private async getCachedPDFDocument(fileId: string, pdfArrayBuffer: ArrayBuffer): Promise<any> { private async getCachedPDFDocument(fileId: FileId, pdfArrayBuffer: ArrayBuffer): Promise<any> {
const cached = this.pdfDocumentCache.get(fileId); const cached = this.pdfDocumentCache.get(fileId);
if (cached) { if (cached) {
cached.lastUsed = Date.now(); cached.lastUsed = Date.now();
@ -79,7 +80,7 @@ export class ThumbnailGenerationService {
/** /**
* Release a reference to a cached PDF document * Release a reference to a cached PDF document
*/ */
private releasePDFDocument(fileId: string): void { private releasePDFDocument(fileId: FileId): void {
const cached = this.pdfDocumentCache.get(fileId); const cached = this.pdfDocumentCache.get(fileId);
if (cached) { if (cached) {
cached.refCount--; cached.refCount--;
@ -91,7 +92,7 @@ export class ThumbnailGenerationService {
* Evict the least recently used PDF document * Evict the least recently used PDF document
*/ */
private evictLeastRecentlyUsedPDF(): void { private evictLeastRecentlyUsedPDF(): void {
let oldestEntry: [string, CachedPDFDocument] | null = null; let oldestEntry: [FileId, CachedPDFDocument] | null = null;
let oldestTime = Date.now(); let oldestTime = Date.now();
for (const [key, value] of this.pdfDocumentCache.entries()) { for (const [key, value] of this.pdfDocumentCache.entries()) {
@ -111,7 +112,7 @@ export class ThumbnailGenerationService {
* Generate thumbnails for multiple pages using main thread processing * Generate thumbnails for multiple pages using main thread processing
*/ */
async generateThumbnails( async generateThumbnails(
fileId: string, fileId: FileId,
pdfArrayBuffer: ArrayBuffer, pdfArrayBuffer: ArrayBuffer,
pageNumbers: number[], pageNumbers: number[],
options: ThumbnailGenerationOptions = {}, options: ThumbnailGenerationOptions = {},
@ -121,11 +122,11 @@ export class ThumbnailGenerationService {
if (!fileId || typeof fileId !== 'string' || fileId.trim() === '') { if (!fileId || typeof fileId !== 'string' || fileId.trim() === '') {
throw new Error('generateThumbnails: fileId must be a non-empty string'); throw new Error('generateThumbnails: fileId must be a non-empty string');
} }
if (!pdfArrayBuffer || pdfArrayBuffer.byteLength === 0) { if (!pdfArrayBuffer || pdfArrayBuffer.byteLength === 0) {
throw new Error('generateThumbnails: pdfArrayBuffer must not be empty'); throw new Error('generateThumbnails: pdfArrayBuffer must not be empty');
} }
if (!pageNumbers || pageNumbers.length === 0) { if (!pageNumbers || pageNumbers.length === 0) {
throw new Error('generateThumbnails: pageNumbers must not be empty'); throw new Error('generateThumbnails: pageNumbers must not be empty');
} }
@ -142,7 +143,7 @@ export class ThumbnailGenerationService {
* Main thread thumbnail generation with batching for UI responsiveness * Main thread thumbnail generation with batching for UI responsiveness
*/ */
private async generateThumbnailsMainThread( private async generateThumbnailsMainThread(
fileId: string, fileId: FileId,
pdfArrayBuffer: ArrayBuffer, pdfArrayBuffer: ArrayBuffer,
pageNumbers: number[], pageNumbers: number[],
scale: number, scale: number,
@ -150,48 +151,48 @@ export class ThumbnailGenerationService {
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
): Promise<ThumbnailResult[]> { ): Promise<ThumbnailResult[]> {
const pdf = await this.getCachedPDFDocument(fileId, pdfArrayBuffer); const pdf = await this.getCachedPDFDocument(fileId, pdfArrayBuffer);
const allResults: ThumbnailResult[] = []; const allResults: ThumbnailResult[] = [];
let completed = 0; let completed = 0;
const batchSize = 3; // Smaller batches for better UI responsiveness const batchSize = 3; // Smaller batches for better UI responsiveness
// Process pages in small batches // Process pages in small batches
for (let i = 0; i < pageNumbers.length; i += batchSize) { for (let i = 0; i < pageNumbers.length; i += batchSize) {
const batch = pageNumbers.slice(i, i + batchSize); const batch = pageNumbers.slice(i, i + batchSize);
// Process batch sequentially (to avoid canvas conflicts) // Process batch sequentially (to avoid canvas conflicts)
for (const pageNumber of batch) { for (const pageNumber of batch) {
try { try {
const page = await pdf.getPage(pageNumber); const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale }); const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = viewport.width; canvas.width = viewport.width;
canvas.height = viewport.height; canvas.height = viewport.height;
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
if (!context) { if (!context) {
throw new Error('Could not get canvas context'); throw new Error('Could not get canvas context');
} }
await page.render({ canvasContext: context, viewport }).promise; await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL('image/jpeg', quality); const thumbnail = canvas.toDataURL('image/jpeg', quality);
allResults.push({ pageNumber, thumbnail, success: true }); allResults.push({ pageNumber, thumbnail, success: true });
} catch (error) { } catch (error) {
console.error(`Failed to generate thumbnail for page ${pageNumber}:`, error); console.error(`Failed to generate thumbnail for page ${pageNumber}:`, error);
allResults.push({ allResults.push({
pageNumber, pageNumber,
thumbnail: '', thumbnail: '',
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : 'Unknown error'
}); });
} }
} }
completed += batch.length; completed += batch.length;
// Report progress // Report progress
if (onProgress) { if (onProgress) {
onProgress({ onProgress({
@ -200,16 +201,16 @@ export class ThumbnailGenerationService {
thumbnails: allResults.slice(-batch.length).filter(r => r.success) thumbnails: allResults.slice(-batch.length).filter(r => r.success)
}); });
} }
// Yield control to prevent UI blocking // Yield control to prevent UI blocking
await new Promise(resolve => setTimeout(resolve, 1)); await new Promise(resolve => setTimeout(resolve, 1));
} }
// Release reference to PDF document (don't destroy - keep in cache) // Release reference to PDF document (don't destroy - keep in cache)
this.releasePDFDocument(fileId); this.releasePDFDocument(fileId);
this.cleanupCompletedDocument(fileId); this.cleanupCompletedDocument(fileId);
return allResults; return allResults;
} }
@ -227,7 +228,7 @@ export class ThumbnailGenerationService {
addThumbnailToCache(pageId: string, thumbnail: string): void { addThumbnailToCache(pageId: string, thumbnail: string): void {
const sizeBytes = thumbnail.length * 2; // Rough estimate for base64 string const sizeBytes = thumbnail.length * 2; // Rough estimate for base64 string
// Enforce cache size limits // Enforce cache size limits
while (this.currentCacheSize + sizeBytes > this.maxCacheSizeBytes && this.thumbnailCache.size > 0) { while (this.currentCacheSize + sizeBytes > this.maxCacheSizeBytes && this.thumbnailCache.size > 0) {
this.evictLeastRecentlyUsed(); this.evictLeastRecentlyUsed();
@ -238,7 +239,7 @@ export class ThumbnailGenerationService {
lastUsed: Date.now(), lastUsed: Date.now(),
sizeBytes sizeBytes
}); });
this.currentCacheSize += sizeBytes; this.currentCacheSize += sizeBytes;
} }
@ -284,7 +285,7 @@ export class ThumbnailGenerationService {
this.pdfDocumentCache.clear(); this.pdfDocumentCache.clear();
} }
clearPDFCacheForFile(fileId: string): void { clearPDFCacheForFile(fileId: FileId): void {
const cached = this.pdfDocumentCache.get(fileId); const cached = this.pdfDocumentCache.get(fileId);
if (cached) { if (cached) {
pdfWorkerManager.destroyDocument(cached.pdf); pdfWorkerManager.destroyDocument(cached.pdf);
@ -296,7 +297,7 @@ export class ThumbnailGenerationService {
* Clean up a PDF document from cache when thumbnail generation is complete * Clean up a PDF document from cache when thumbnail generation is complete
* This frees up workers faster for better performance * This frees up workers faster for better performance
*/ */
cleanupCompletedDocument(fileId: string): void { cleanupCompletedDocument(fileId: FileId): void {
const cached = this.pdfDocumentCache.get(fileId); const cached = this.pdfDocumentCache.get(fileId);
if (cached && cached.refCount <= 0) { if (cached && cached.refCount <= 0) {
pdfWorkerManager.destroyDocument(cached.pdf); pdfWorkerManager.destroyDocument(cached.pdf);
@ -311,4 +312,4 @@ export class ThumbnailGenerationService {
} }
// Global singleton instance // Global singleton instance
export const thumbnailGenerationService = new ThumbnailGenerationService(); export const thumbnailGenerationService = new ThumbnailGenerationService();

View File

@ -0,0 +1,50 @@
import { describe, test, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
const LOCALES_DIR = path.join(__dirname, '../../public/locales');
// Get all locale directories for parameterized tests
const getLocaleDirectories = () => {
if (!fs.existsSync(LOCALES_DIR)) {
return [];
}
return fs.readdirSync(LOCALES_DIR, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
};
const localeDirectories = getLocaleDirectories();
describe('Translation JSON Validation', () => {
test('should find the locales directory', () => {
expect(fs.existsSync(LOCALES_DIR)).toBe(true);
});
test('should have at least one locale directory', () => {
expect(localeDirectories.length).toBeGreaterThan(0);
});
test.each(localeDirectories)('should have valid JSON in %s/translation.json', (localeDir) => {
const translationFile = path.join(LOCALES_DIR, localeDir, 'translation.json');
// Check if file exists
expect(fs.existsSync(translationFile)).toBe(true);
// Read file content
const content = fs.readFileSync(translationFile, 'utf8');
expect(content.trim()).not.toBe('');
// Parse JSON - this will throw if invalid JSON
let jsonData;
expect(() => {
jsonData = JSON.parse(content);
}).not.toThrow();
// Ensure it's an object at root level
expect(typeof jsonData).toBe('object');
expect(jsonData).not.toBeNull();
expect(Array.isArray(jsonData)).toBe(false);
});
});

View File

@ -1,96 +1,55 @@
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,
}, },
}); });
}; };

View File

@ -1,95 +1,55 @@
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,
}, },
}); });
}; };

View File

@ -1,78 +1,39 @@
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.setWorkbench("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,
}, },
}); });
}; };
@ -80,4 +41,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,55 @@
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,
}, },
}); });
}; };

View File

@ -1,78 +1,39 @@
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.setWorkbench("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,
}, },
}); });
}; };

View File

@ -1,90 +1,53 @@
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,
}, },
}); });
}; };

View File

@ -1,78 +1,39 @@
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.setWorkbench("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,
}, },
}); });
}; };
@ -80,4 +41,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,15 @@ 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,
}, },
}); });
}; };

View File

@ -1,78 +1,39 @@
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.setWorkbench("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,
}, },
}); });
}; };
@ -80,4 +41,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

@ -3,13 +3,15 @@
* FileContext uses pure File objects with separate ID tracking * FileContext uses pure File objects with separate ID tracking
*/ */
declare const tag: unique symbol;
export type FileId = string & { readonly [tag]: 'FileId' };
/** /**
* File metadata for efficient operations without loading full file data * File metadata for efficient operations without loading full file data
* Used by IndexedDBContext and FileContext for lazy file loading * Used by IndexedDBContext and FileContext for lazy file loading
*/ */
export interface FileMetadata { export interface FileMetadata {
id: string; id: FileId;
name: string; name: string;
type: string; type: string;
size: number; size: number;
@ -36,9 +38,9 @@ export const defaultStorageConfig: StorageConfig = {
export const initializeStorageConfig = async (): Promise<StorageConfig> => { export const initializeStorageConfig = async (): Promise<StorageConfig> => {
const tenGB = 10 * 1024 * 1024 * 1024; // 10GB in bytes const tenGB = 10 * 1024 * 1024 * 1024; // 10GB in bytes
const oneGB = 1024 * 1024 * 1024; // 1GB fallback const oneGB = 1024 * 1024 * 1024; // 1GB fallback
let maxTotalStorage = oneGB; // Default fallback let maxTotalStorage = oneGB; // Default fallback
// Try to estimate available storage // Try to estimate available storage
if ('storage' in navigator && 'estimate' in navigator.storage) { if ('storage' in navigator && 'estimate' in navigator.storage) {
try { try {
@ -51,9 +53,9 @@ export const initializeStorageConfig = async (): Promise<StorageConfig> => {
console.warn('Could not estimate storage quota, using 1GB default:', error); console.warn('Could not estimate storage quota, using 1GB default:', error);
} }
} }
return { return {
...defaultStorageConfig, ...defaultStorageConfig,
maxTotalStorage maxTotalStorage
}; };
}; };

View File

@ -2,9 +2,8 @@
* Types for global file context management across views and tools * Types for global file context management across views and tools
*/ */
import { ProcessedFile } from './processing'; import { PageOperation } from './pageEditor';
import { PDFDocument, PDFPage, PageOperation } from './pageEditor'; import { FileId, FileMetadata } from './file';
import { FileMetadata } from './file';
export type ModeType = export type ModeType =
| 'viewer' | 'viewer'
@ -26,8 +25,6 @@ export type ModeType =
| 'removeCertificateSign'; | 'removeCertificateSign';
// Normalized state types // Normalized state types
export type FileId = string;
export interface ProcessedFilePage { export interface ProcessedFilePage {
thumbnail?: string; thumbnail?: string;
pageNumber?: number; pageNumber?: number;
@ -69,14 +66,14 @@ export interface FileContextNormalizedFiles {
export function createFileId(): FileId { export function createFileId(): FileId {
// Use crypto.randomUUID for authoritative primary key // Use crypto.randomUUID for authoritative primary key
if (typeof window !== 'undefined' && window.crypto?.randomUUID) { if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID(); return window.crypto.randomUUID() as FileId;
} }
// Fallback for environments without randomUUID // Fallback for environments without randomUUID
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0; const r = Math.random() * 16 | 0;
const v = c == 'x' ? r : (r & 0x3 | 0x8); const v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16); return v.toString(16);
}); }) as FileId;
} }
// Generate quick deduplication key from file metadata // Generate quick deduplication key from file metadata
@ -136,7 +133,7 @@ export interface FileOperation {
id: string; id: string;
type: OperationType; type: OperationType;
timestamp: number; timestamp: number;
fileIds: string[]; fileIds: FileId[];
status: 'pending' | 'applied' | 'failed'; status: 'pending' | 'applied' | 'failed';
data?: any; data?: any;
metadata?: { metadata?: {
@ -150,7 +147,7 @@ export interface FileOperation {
} }
export interface FileOperationHistory { export interface FileOperationHistory {
fileId: string; fileId: FileId;
fileName: string; fileName: string;
operations: (FileOperation | PageOperation)[]; operations: (FileOperation | PageOperation)[];
createdAt: number; createdAt: number;
@ -165,7 +162,7 @@ export interface ViewerConfig {
} }
export interface FileEditHistory { export interface FileEditHistory {
fileId: string; fileId: FileId;
pageOperations: PageOperation[]; pageOperations: PageOperation[];
lastModified: number; lastModified: number;
} }
@ -176,10 +173,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[];
@ -191,27 +188,27 @@ 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[] } }
// 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' };
@ -236,20 +233,20 @@ export interface FileContextActions {
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: string, delay?: number) => void; scheduleCleanup: (fileId: FileId, delay?: number) => void;
cleanupFile: (fileId: string) => void; cleanupFile: (fileId: FileId) => void;
} }
// File selectors (separate from actions to avoid re-renders) // File selectors (separate from actions to avoid re-renders)
@ -257,22 +254,22 @@ export interface FileContextSelectors {
// File access - no state dependency, uses ref // File access - no state dependency, uses ref
getFile: (id: FileId) => File | undefined; getFile: (id: FileId) => File | undefined;
getFiles: (ids?: FileId[]) => File[]; getFiles: (ids?: FileId[]) => File[];
// Record access - uses normalized state // Record access - uses normalized state
getFileRecord: (id: FileId) => FileRecord | undefined; getFileRecord: (id: FileId) => FileRecord | undefined;
getFileRecords: (ids?: FileId[]) => FileRecord[]; getFileRecords: (ids?: FileId[]) => FileRecord[];
// Derived selectors // Derived selectors
getAllFileIds: () => FileId[]; getAllFileIds: () => FileId[];
getSelectedFiles: () => File[]; getSelectedFiles: () => File[];
getSelectedFileRecords: () => FileRecord[]; getSelectedFileRecords: () => FileRecord[];
// Pinned files selectors // Pinned files selectors
getPinnedFileIds: () => FileId[]; getPinnedFileIds: () => FileId[];
getPinnedFiles: () => File[]; getPinnedFiles: () => File[];
getPinnedFileRecords: () => FileRecord[]; getPinnedFileRecords: () => FileRecord[];
isFilePinned: (file: File) => boolean; isFilePinned: (file: File) => boolean;
// Stable signature for effect dependencies // Stable signature for effect dependencies
getFilesSignature: () => string; getFilesSignature: () => string;
} }

View File

@ -1,3 +1,5 @@
import { FileId } from './file';
export interface PDFPage { export interface PDFPage {
id: string; id: string;
pageNumber: number; pageNumber: number;
@ -7,7 +9,7 @@ export interface PDFPage {
selected: boolean; selected: boolean;
splitAfter?: boolean; splitAfter?: boolean;
isBlankPage?: boolean; isBlankPage?: boolean;
originalFileId?: string; originalFileId?: FileId;
} }
export interface PDFDocument { export interface PDFDocument {

View File

@ -4,14 +4,15 @@ import { AutomationConfig, AutomationExecutionCallbacks } from '../types/automat
import { AUTOMATION_CONSTANTS } from '../constants/automation'; import { AUTOMATION_CONSTANTS } from '../constants/automation';
import { AutomationFileProcessor } from './automationFileProcessor'; import { AutomationFileProcessor } from './automationFileProcessor';
import { ResourceManager } from './resourceManager'; import { ResourceManager } from './resourceManager';
import { ToolType } from '../hooks/tools/shared/useToolOperation';
/** /**
* Execute a tool operation directly without using React hooks * Execute a tool operation directly without using React hooks
*/ */
export const executeToolOperation = async ( export const executeToolOperation = async (
operationName: string, operationName: string,
parameters: any, parameters: any,
files: File[], files: File[],
toolRegistry: ToolRegistry toolRegistry: ToolRegistry
): Promise<File[]> => { ): Promise<File[]> => {
@ -22,14 +23,14 @@ export const executeToolOperation = async (
* Execute a tool operation with custom prefix * Execute a tool operation with custom prefix
*/ */
export const executeToolOperationWithPrefix = async ( export const executeToolOperationWithPrefix = async (
operationName: string, operationName: string,
parameters: any, parameters: any,
files: File[], files: File[],
toolRegistry: ToolRegistry, toolRegistry: ToolRegistry,
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX
): Promise<File[]> => { ): Promise<File[]> => {
console.log(`🔧 Executing tool: ${operationName}`, { parameters, fileCount: files.length }); console.log(`🔧 Executing tool: ${operationName}`, { parameters, fileCount: files.length });
const config = toolRegistry[operationName]?.operationConfig; const config = toolRegistry[operationName]?.operationConfig;
if (!config) { if (!config) {
console.error(`❌ Tool operation not supported: ${operationName}`); console.error(`❌ Tool operation not supported: ${operationName}`);
@ -47,17 +48,17 @@ export const executeToolOperationWithPrefix = async (
return resultFiles; return resultFiles;
} }
if (config.multiFileEndpoint) { if (config.toolType === ToolType.multiFile) {
// Multi-file processing - single API call with all files // Multi-file processing - single API call with all files
const endpoint = typeof config.endpoint === 'function' const endpoint = typeof config.endpoint === 'function'
? config.endpoint(parameters) ? config.endpoint(parameters)
: config.endpoint; : config.endpoint;
console.log(`🌐 Making multi-file request to: ${endpoint}`); console.log(`🌐 Making multi-file request to: ${endpoint}`);
const formData = (config.buildFormData as (params: any, files: File[]) => FormData)(parameters, files); const formData = (config.buildFormData as (params: any, files: File[]) => FormData)(parameters, files);
console.log(`📤 FormData entries:`, Array.from(formData.entries())); console.log(`📤 FormData entries:`, Array.from(formData.entries()));
const response = await axios.post(endpoint, formData, { const response = await axios.post(endpoint, formData, {
responseType: 'blob', responseType: 'blob',
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
}); });
@ -66,7 +67,7 @@ export const executeToolOperationWithPrefix = async (
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true) // Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
let result; let result;
if (response.data.type === 'application/pdf' || 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 = files[0]?.name || 'document.pdf'; const originalFileName = files[0]?.name || 'document.pdf';
@ -80,19 +81,18 @@ export const executeToolOperationWithPrefix = async (
// ZIP response // ZIP response
result = await AutomationFileProcessor.extractAutomationZipFiles(response.data); result = await AutomationFileProcessor.extractAutomationZipFiles(response.data);
} }
if (result.errors.length > 0) { if (result.errors.length > 0) {
console.warn(`⚠️ File processing warnings:`, result.errors); console.warn(`⚠️ File processing warnings:`, result.errors);
} }
// Apply prefix to files, replacing any existing prefix // Apply prefix to files, replacing any existing prefix
const processedFiles = filePrefix const processedFiles = filePrefix
? result.files.map(file => { ? result.files.map(file => {
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, ''); const nameWithoutPrefix = file.name.replace(/^[^_]*_/, '');
return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type }); return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type });
}) })
: result.files; : result.files;
console.log(`📁 Processed ${processedFiles.length} files from response`); console.log(`📁 Processed ${processedFiles.length} files from response`);
return processedFiles; return processedFiles;
@ -100,18 +100,18 @@ export const executeToolOperationWithPrefix = async (
// Single-file processing - separate API call per file // Single-file processing - separate API call per file
console.log(`🔄 Processing ${files.length} files individually`); console.log(`🔄 Processing ${files.length} files individually`);
const resultFiles: File[] = []; const resultFiles: File[] = [];
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
const endpoint = typeof config.endpoint === 'function' const endpoint = typeof config.endpoint === 'function'
? config.endpoint(parameters) ? config.endpoint(parameters)
: config.endpoint; : config.endpoint;
console.log(`🌐 Making single-file request ${i+1}/${files.length} to: ${endpoint} for file: ${file.name}`); console.log(`🌐 Making single-file request ${i+1}/${files.length} to: ${endpoint} for file: ${file.name}`);
const formData = (config.buildFormData as (params: any, file: File) => FormData)(parameters, file); const formData = (config.buildFormData as (params: any, file: File) => FormData)(parameters, file);
console.log(`📤 FormData entries:`, Array.from(formData.entries())); console.log(`📤 FormData entries:`, Array.from(formData.entries()));
const response = await axios.post(endpoint, formData, { const response = await axios.post(endpoint, formData, {
responseType: 'blob', responseType: 'blob',
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
}); });
@ -119,9 +119,9 @@ export const executeToolOperationWithPrefix = async (
console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`); console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`);
// Create result file with automation prefix // Create result file with automation prefix
const resultFile = ResourceManager.createResultFile( const resultFile = ResourceManager.createResultFile(
response.data, response.data,
file.name, file.name,
filePrefix filePrefix
); );
@ -143,7 +143,7 @@ export const executeToolOperationWithPrefix = async (
* Execute an entire automation sequence * Execute an entire automation sequence
*/ */
export const executeAutomationSequence = async ( export const executeAutomationSequence = async (
automation: any, automation: any,
initialFiles: File[], initialFiles: File[],
toolRegistry: ToolRegistry, toolRegistry: ToolRegistry,
onStepStart?: (stepIndex: number, operationName: string) => void, onStepStart?: (stepIndex: number, operationName: string) => void,
@ -153,7 +153,7 @@ export const executeAutomationSequence = async (
console.log(`🚀 Starting automation sequence: ${automation.name || 'Unnamed'}`); console.log(`🚀 Starting automation sequence: ${automation.name || 'Unnamed'}`);
console.log(`📁 Initial files: ${initialFiles.length}`); console.log(`📁 Initial files: ${initialFiles.length}`);
console.log(`🔧 Operations: ${automation.operations?.length || 0}`); console.log(`🔧 Operations: ${automation.operations?.length || 0}`);
if (!automation?.operations || automation.operations.length === 0) { if (!automation?.operations || automation.operations.length === 0) {
throw new Error('No operations in automation'); throw new Error('No operations in automation');
} }
@ -163,26 +163,26 @@ export const executeAutomationSequence = async (
for (let i = 0; i < automation.operations.length; i++) { for (let i = 0; i < automation.operations.length; i++) {
const operation = automation.operations[i]; const operation = automation.operations[i];
console.log(`📋 Step ${i + 1}/${automation.operations.length}: ${operation.operation}`); console.log(`📋 Step ${i + 1}/${automation.operations.length}: ${operation.operation}`);
console.log(`📄 Input files: ${currentFiles.length}`); console.log(`📄 Input files: ${currentFiles.length}`);
console.log(`⚙️ Parameters:`, operation.parameters || {}); console.log(`⚙️ Parameters:`, operation.parameters || {});
try { try {
onStepStart?.(i, operation.operation); onStepStart?.(i, operation.operation);
const resultFiles = await executeToolOperationWithPrefix( const resultFiles = await executeToolOperationWithPrefix(
operation.operation, operation.operation,
operation.parameters || {}, operation.parameters || {},
currentFiles, currentFiles,
toolRegistry, toolRegistry,
i === automation.operations.length - 1 ? automationPrefix : '' // Only add prefix to final step i === automation.operations.length - 1 ? automationPrefix : '' // Only add prefix to final step
); );
console.log(`✅ Step ${i + 1} completed: ${resultFiles.length} result files`); console.log(`✅ Step ${i + 1} completed: ${resultFiles.length} result files`);
currentFiles = resultFiles; currentFiles = resultFiles;
onStepComplete?.(i, resultFiles); onStepComplete?.(i, resultFiles);
} catch (error: any) { } catch (error: any) {
console.error(`❌ Step ${i + 1} failed:`, error); console.error(`❌ Step ${i + 1} failed:`, error);
onStepError?.(i, error.message); onStepError?.(i, error.message);
@ -192,4 +192,4 @@ export const executeAutomationSequence = async (
console.log(`🎉 Automation sequence completed: ${currentFiles.length} final files`); console.log(`🎉 Automation sequence completed: ${currentFiles.length} final files`);
return currentFiles; return currentFiles;
}; };

View File

@ -1,3 +1,4 @@
import { FileId } from '../types/file';
import { FileOperation } from '../types/fileContext'; import { FileOperation } from '../types/fileContext';
/** /**
@ -7,9 +8,9 @@ export const createOperation = <TParams = void>(
operationType: string, operationType: string,
params: TParams, params: TParams,
selectedFiles: File[] selectedFiles: File[]
): { operation: FileOperation; operationId: string; fileId: string } => { ): { operation: FileOperation; operationId: string; fileId: FileId } => {
const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const fileId = selectedFiles.map(f => f.name).join(','); const fileId = selectedFiles.map(f => f.name).join(',') as FileId;
const operation: FileOperation = { const operation: FileOperation = {
id: operationId, id: operationId,