mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 17:39:24 +00:00
Merge branch 'V2' into posthog
This commit is contained in:
commit
6daae8fcd4
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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}
|
||||||
|
@ -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);
|
||||||
|
@ -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 || [];
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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 (
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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);
|
||||||
|
@ -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';
|
||||||
|
@ -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)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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]);
|
||||||
|
@ -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]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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' }
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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.'))
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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' }
|
||||||
|
@ -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.')
|
||||||
)
|
),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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.");
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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.'))
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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' }
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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.'))
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
118
frontend/src/hooks/tools/shared/useBaseTool.ts
Normal file
118
frontend/src/hooks/tools/shared/useBaseTool.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
@ -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);
|
||||||
|
@ -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.'))
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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.'))
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
@ -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();
|
||||||
|
@ -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) => {
|
||||||
|
@ -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();
|
||||||
|
50
frontend/src/tests/translation.test.ts
Normal file
50
frontend/src/tests/translation.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user