mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-08 17:51:20 +02:00
Initial file id changes
This commit is contained in:
parent
fe466b3ebf
commit
21483c4ec3
55
frontend/.eslintrc.js
Normal file
55
frontend/.eslintrc.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: [
|
||||||
|
'react-app',
|
||||||
|
'react-app/jest'
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
// Custom rules to prevent dangerous file.name as ID patterns
|
||||||
|
'no-file-name-as-id': 'error',
|
||||||
|
'prefer-file-with-id': 'warn'
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['**/*.ts', '**/*.tsx'],
|
||||||
|
rules: {
|
||||||
|
// Prevent file.name being used where FileId is expected
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: 'MemberExpression[object.name="file"][property.name="name"]',
|
||||||
|
message: 'Avoid using file.name directly. Use FileWithId.fileId or safeGetFileId() instead to prevent ID collisions.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'CallExpression[callee.name="createOperation"] > ArrayExpression > CallExpression[callee.property.name="map"] > ArrowFunctionExpression > MemberExpression[object.name="f"][property.name="name"]',
|
||||||
|
message: 'Dangerous pattern: Using file.name as ID in createOperation. Use FileWithId.fileId instead.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'ArrayExpression[elements.length>0] CallExpression[callee.property.name="map"] > ArrowFunctionExpression > MemberExpression[property.name="name"]',
|
||||||
|
message: 'Potential file.name as ID usage detected. Ensure proper FileId usage instead of file.name.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
settings: {
|
||||||
|
// Custom settings for our file ID validation
|
||||||
|
'file-id-validation': {
|
||||||
|
// Functions that should only accept FileId, not strings
|
||||||
|
'file-id-only-functions': [
|
||||||
|
'recordOperation',
|
||||||
|
'markOperationApplied',
|
||||||
|
'markOperationFailed',
|
||||||
|
'removeFiles',
|
||||||
|
'updateFileRecord',
|
||||||
|
'pinFile',
|
||||||
|
'unpinFile'
|
||||||
|
],
|
||||||
|
// Functions that should accept FileWithId instead of File
|
||||||
|
'file-with-id-functions': [
|
||||||
|
'createOperation',
|
||||||
|
'executeOperation',
|
||||||
|
'isFilePinned'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -13,6 +13,9 @@ import "./styles/tailwind.css";
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||||
|
|
||||||
|
// Import file ID safety validators (development only)
|
||||||
|
import "./utils/fileIdSafety";
|
||||||
|
|
||||||
// Loading component for i18next suspense
|
// Loading component for i18next suspense
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
<div
|
<div
|
||||||
|
@ -5,9 +5,8 @@ import {
|
|||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
|
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||||
import { useNavigationActions } from '../../contexts/NavigationContext';
|
import { useNavigationActions } from '../../contexts/NavigationContext';
|
||||||
import { FileOperation } from '../../types/fileContext';
|
|
||||||
import { fileStorage } from '../../services/fileStorage';
|
import { fileStorage } from '../../services/fileStorage';
|
||||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||||
import { zipFileService } from '../../services/zipFileService';
|
import { zipFileService } from '../../services/zipFileService';
|
||||||
@ -53,8 +52,6 @@ const FileEditor = ({
|
|||||||
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
|
|
||||||
const { actions } = useFileActions();
|
|
||||||
const { actions: navActions } = useNavigationActions();
|
const { actions: navActions } = useNavigationActions();
|
||||||
|
|
||||||
// Get file selection context
|
// Get file selection context
|
||||||
@ -212,25 +209,6 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// Process all extracted files
|
// Process all extracted files
|
||||||
if (allExtractedFiles.length > 0) {
|
if (allExtractedFiles.length > 0) {
|
||||||
// Record upload operations for PDF files
|
|
||||||
for (const file of allExtractedFiles) {
|
|
||||||
const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const operation: FileOperation = {
|
|
||||||
id: operationId,
|
|
||||||
type: 'upload',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
fileIds: [file.name],
|
|
||||||
status: 'pending',
|
|
||||||
metadata: {
|
|
||||||
originalFileName: file.name,
|
|
||||||
fileSize: file.size,
|
|
||||||
parameters: {
|
|
||||||
uploadMethod: 'drag-drop'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add files to context (they will be processed automatically)
|
// Add files to context (they will be processed automatically)
|
||||||
await addFiles(allExtractedFiles);
|
await addFiles(allExtractedFiles);
|
||||||
setStatus(`Added ${allExtractedFiles.length} files`);
|
setStatus(`Added ${allExtractedFiles.length} files`);
|
||||||
|
@ -123,8 +123,13 @@ const FileGrid = ({
|
|||||||
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;
|
// Use record ID if available, otherwise throw error for missing FileRecord
|
||||||
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
|
if (!item.record?.id) {
|
||||||
|
console.error('FileGrid: File missing FileRecord with proper ID:', item.file.name);
|
||||||
|
return null; // Skip rendering files without proper IDs
|
||||||
|
}
|
||||||
|
const fileId = item.record.id;
|
||||||
|
const originalIdx = files.findIndex(f => f.record?.id === fileId);
|
||||||
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
||||||
return (
|
return (
|
||||||
<FileCard
|
<FileCard
|
||||||
|
@ -20,7 +20,9 @@ import {
|
|||||||
FileContextActionsValue,
|
FileContextActionsValue,
|
||||||
FileContextActions,
|
FileContextActions,
|
||||||
FileId,
|
FileId,
|
||||||
FileRecord
|
FileRecord,
|
||||||
|
FileWithId,
|
||||||
|
createFileWithId
|
||||||
} from '../types/fileContext';
|
} from '../types/fileContext';
|
||||||
|
|
||||||
// Import modular components
|
// Import modular components
|
||||||
@ -73,7 +75,7 @@ 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<FileWithId[]> => {
|
||||||
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
|
||||||
@ -87,56 +89,40 @@ function FileContextInner({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return addedFilesWithIds.map(({ file }) => file);
|
// Convert to FileWithId objects
|
||||||
|
return addedFilesWithIds.map(({ file, id }) => createFileWithId(file, id));
|
||||||
}, [indexedDB, enablePersistence]);
|
}, [indexedDB, enablePersistence]);
|
||||||
|
|
||||||
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => {
|
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<FileWithId[]> => {
|
||||||
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
|
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||||
return result.map(({ file }) => file);
|
// Convert to FileWithId objects
|
||||||
|
return result.map(({ file, id }) => createFileWithId(file, id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise<File[]> => {
|
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise<FileWithId[]> => {
|
||||||
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||||
return result.map(({ file }) => file);
|
// Convert to FileWithId objects
|
||||||
|
return result.map(({ file, id }) => createFileWithId(file, id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Action creators
|
// Action creators
|
||||||
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
||||||
|
|
||||||
// Helper functions for pinned files
|
// Helper functions for pinned files
|
||||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<void> => {
|
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileWithId[]> => {
|
||||||
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch);
|
const result = await consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch);
|
||||||
|
// Convert results to FileWithId objects
|
||||||
|
return result.map(({ file, id }) => createFileWithId(file, id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Helper to find FileId from File object
|
// File pinning functions - now use FileWithId directly
|
||||||
const findFileId = useCallback((file: File): FileId | undefined => {
|
const pinFileWrapper = useCallback((file: FileWithId) => {
|
||||||
return Object.keys(stateRef.current.files.byId).find(id => {
|
baseActions.pinFile(file.fileId);
|
||||||
const storedFile = filesRef.current.get(id);
|
}, [baseActions]);
|
||||||
return storedFile &&
|
|
||||||
storedFile.name === file.name &&
|
|
||||||
storedFile.size === file.size &&
|
|
||||||
storedFile.lastModified === file.lastModified;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// File-to-ID wrapper functions for pinning
|
const unpinFileWrapper = useCallback((file: FileWithId) => {
|
||||||
const pinFileWrapper = useCallback((file: File) => {
|
baseActions.unpinFile(file.fileId);
|
||||||
const fileId = findFileId(file);
|
}, [baseActions]);
|
||||||
if (fileId) {
|
|
||||||
baseActions.pinFile(fileId);
|
|
||||||
} else {
|
|
||||||
console.warn('File not found for pinning:', file.name);
|
|
||||||
}
|
|
||||||
}, [baseActions, findFileId]);
|
|
||||||
|
|
||||||
const unpinFileWrapper = useCallback((file: File) => {
|
|
||||||
const fileId = findFileId(file);
|
|
||||||
if (fileId) {
|
|
||||||
baseActions.unpinFile(fileId);
|
|
||||||
} else {
|
|
||||||
console.warn('File not found for unpinning:', file.name);
|
|
||||||
}
|
|
||||||
}, [baseActions, findFileId]);
|
|
||||||
|
|
||||||
// Complete actions object
|
// Complete actions object
|
||||||
const actions = useMemo<FileContextActions>(() => ({
|
const actions = useMemo<FileContextActions>(() => ({
|
||||||
|
@ -326,11 +326,11 @@ export async function consumeFiles(
|
|||||||
stateRef: React.MutableRefObject<FileContextState>,
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
dispatch: React.Dispatch<FileContextAction>
|
dispatch: React.Dispatch<FileContextAction>
|
||||||
): Promise<void> {
|
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
|
||||||
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 processedOutputs: Array<{ file: File; id: FileId; thumbnail?: string; record: FileRecord }> = 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);
|
||||||
@ -357,10 +357,13 @@ export async function consumeFiles(
|
|||||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||||
}
|
}
|
||||||
|
|
||||||
return record;
|
return { file, id: fileId, thumbnail, record };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Extract records for dispatch
|
||||||
|
const outputFileRecords = processedOutputs.map(({ record }) => record);
|
||||||
|
|
||||||
// Dispatch the consume action
|
// Dispatch the consume action
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'CONSUME_FILES',
|
type: 'CONSUME_FILES',
|
||||||
@ -371,6 +374,9 @@ export async function consumeFiles(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
||||||
|
|
||||||
|
// Return file data for FileWithId conversion
|
||||||
|
return processedOutputs.map(({ file, id, thumbnail }) => ({ file, id, thumbnail }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -168,16 +168,7 @@ export function useFileContext() {
|
|||||||
markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented
|
markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented
|
||||||
markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // Operation tracking not implemented
|
markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // Operation tracking not implemented
|
||||||
|
|
||||||
// File ID lookup
|
// File ID lookup removed - use FileWithId.fileId directly for better performance and type safety
|
||||||
findFileId: (file: File) => {
|
|
||||||
return state.files.ids.find(id => {
|
|
||||||
const record = state.files.byId[id];
|
|
||||||
return record &&
|
|
||||||
record.name === file.name &&
|
|
||||||
record.size === file.size &&
|
|
||||||
record.lastModified === file.lastModified;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Pinned files
|
// Pinned files
|
||||||
pinnedFiles: state.pinnedFiles,
|
pinnedFiles: state.pinnedFiles,
|
||||||
|
@ -6,7 +6,9 @@ import {
|
|||||||
FileId,
|
FileId,
|
||||||
FileRecord,
|
FileRecord,
|
||||||
FileContextState,
|
FileContextState,
|
||||||
FileContextSelectors
|
FileContextSelectors,
|
||||||
|
FileWithId,
|
||||||
|
createFileWithId
|
||||||
} from '../../types/fileContext';
|
} from '../../types/fileContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,11 +19,19 @@ export function createFileSelectors(
|
|||||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||||
): FileContextSelectors {
|
): FileContextSelectors {
|
||||||
return {
|
return {
|
||||||
getFile: (id: FileId) => filesRef.current.get(id),
|
getFile: (id: FileId) => {
|
||||||
|
const file = filesRef.current.get(id);
|
||||||
|
return file ? createFileWithId(file, id) : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
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 => {
|
||||||
|
const file = filesRef.current.get(id);
|
||||||
|
return file ? createFileWithId(file, id) : undefined;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as FileWithId[];
|
||||||
},
|
},
|
||||||
|
|
||||||
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
|
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
|
||||||
@ -35,8 +45,11 @@ export function createFileSelectors(
|
|||||||
|
|
||||||
getSelectedFiles: () => {
|
getSelectedFiles: () => {
|
||||||
return stateRef.current.ui.selectedFileIds
|
return stateRef.current.ui.selectedFileIds
|
||||||
.map(id => filesRef.current.get(id))
|
.map(id => {
|
||||||
.filter(Boolean) as File[];
|
const file = filesRef.current.get(id);
|
||||||
|
return file ? createFileWithId(file, id) : undefined;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as FileWithId[];
|
||||||
},
|
},
|
||||||
|
|
||||||
getSelectedFileRecords: () => {
|
getSelectedFileRecords: () => {
|
||||||
@ -52,8 +65,11 @@ export function createFileSelectors(
|
|||||||
|
|
||||||
getPinnedFiles: () => {
|
getPinnedFiles: () => {
|
||||||
return Array.from(stateRef.current.pinnedFiles)
|
return Array.from(stateRef.current.pinnedFiles)
|
||||||
.map(id => filesRef.current.get(id))
|
.map(id => {
|
||||||
.filter(Boolean) as File[];
|
const file = filesRef.current.get(id);
|
||||||
|
return file ? createFileWithId(file, id) : undefined;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as FileWithId[];
|
||||||
},
|
},
|
||||||
|
|
||||||
getPinnedFileRecords: () => {
|
getPinnedFileRecords: () => {
|
||||||
@ -62,16 +78,8 @@ export function createFileSelectors(
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
},
|
},
|
||||||
|
|
||||||
isFilePinned: (file: File) => {
|
isFilePinned: (file: FileWithId) => {
|
||||||
// Find FileId by matching File object properties
|
return stateRef.current.pinnedFiles.has(file.fileId);
|
||||||
const fileId = Object.keys(stateRef.current.files.byId).find(id => {
|
|
||||||
const storedFile = filesRef.current.get(id);
|
|
||||||
return storedFile &&
|
|
||||||
storedFile.name === file.name &&
|
|
||||||
storedFile.size === file.size &&
|
|
||||||
storedFile.lastModified === file.lastModified;
|
|
||||||
});
|
|
||||||
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Stable signature for effects - prevents unnecessary re-renders
|
// Stable signature for effects - prevents unnecessary re-renders
|
||||||
@ -119,12 +127,15 @@ export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; siz
|
|||||||
export function getPrimaryFile(
|
export function getPrimaryFile(
|
||||||
stateRef: React.MutableRefObject<FileContextState>,
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||||
): { file?: File; record?: FileRecord } {
|
): { file?: FileWithId; record?: FileRecord } {
|
||||||
const primaryFileId = stateRef.current.files.ids[0];
|
const primaryFileId = stateRef.current.files.ids[0];
|
||||||
if (!primaryFileId) return {};
|
if (!primaryFileId) return {};
|
||||||
|
|
||||||
|
const file = filesRef.current.get(primaryFileId);
|
||||||
|
const record = stateRef.current.files.byId[primaryFileId];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
file: filesRef.current.get(primaryFileId),
|
file: file ? createFileWithId(file, primaryFileId) : undefined,
|
||||||
record: stateRef.current.files.byId[primaryFileId]
|
record
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -7,6 +7,7 @@ import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
|||||||
import { useToolResources } from './useToolResources';
|
import { useToolResources } from './useToolResources';
|
||||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||||
import { createOperation } from '../../../utils/toolOperationTracker';
|
import { createOperation } from '../../../utils/toolOperationTracker';
|
||||||
|
import { FileWithId, extractFiles } from '../../../types/fileContext';
|
||||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
@ -82,7 +83,7 @@ export interface ToolOperationHook<TParams = void> {
|
|||||||
progress: ProcessingProgress | null;
|
progress: ProcessingProgress | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
executeOperation: (params: TParams, selectedFiles: File[]) => Promise<void>;
|
executeOperation: (params: TParams, selectedFiles: FileWithId[]) => Promise<void>;
|
||||||
resetResults: () => void;
|
resetResults: () => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
cancelOperation: () => void;
|
cancelOperation: () => void;
|
||||||
@ -107,7 +108,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
config: ToolOperationConfig<TParams>
|
config: ToolOperationConfig<TParams>
|
||||||
): ToolOperationHook<TParams> => {
|
): ToolOperationHook<TParams> => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId } = useFileContext();
|
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = useFileContext();
|
||||||
|
|
||||||
// Composed hooks
|
// Composed hooks
|
||||||
const { state, actions } = useToolState();
|
const { state, actions } = useToolState();
|
||||||
@ -116,7 +117,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
|
|
||||||
const executeOperation = useCallback(async (
|
const executeOperation = useCallback(async (
|
||||||
params: TParams,
|
params: TParams,
|
||||||
selectedFiles: File[]
|
selectedFiles: FileWithId[]
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// Validation
|
// Validation
|
||||||
if (selectedFiles.length === 0) {
|
if (selectedFiles.length === 0) {
|
||||||
@ -130,7 +131,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup operation tracking
|
// Setup operation tracking with proper FileWithId
|
||||||
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
|
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
|
||||||
recordOperation(fileId, operation);
|
recordOperation(fileId, operation);
|
||||||
|
|
||||||
@ -143,15 +144,18 @@ export const useToolOperation = <TParams = void>(
|
|||||||
try {
|
try {
|
||||||
let processedFiles: File[];
|
let processedFiles: File[];
|
||||||
|
|
||||||
|
// Convert FileWithId to regular File objects for API processing
|
||||||
|
const validRegularFiles = extractFiles(validFiles);
|
||||||
|
|
||||||
if (config.customProcessor) {
|
if (config.customProcessor) {
|
||||||
actions.setStatus('Processing files...');
|
actions.setStatus('Processing files...');
|
||||||
processedFiles = await config.customProcessor(params, validFiles);
|
processedFiles = await config.customProcessor(params, validRegularFiles);
|
||||||
} else {
|
} else {
|
||||||
// Use explicit multiFileEndpoint flag to determine processing approach
|
// Use explicit multiFileEndpoint flag to determine processing approach
|
||||||
if (config.multiFileEndpoint) {
|
if (config.multiFileEndpoint) {
|
||||||
// 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 as (params: TParams, files: File[]) => FormData)(params, validRegularFiles);
|
||||||
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' });
|
||||||
@ -159,11 +163,11 @@ export const useToolOperation = <TParams = void>(
|
|||||||
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
||||||
if (config.responseHandler) {
|
if (config.responseHandler) {
|
||||||
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
||||||
processedFiles = await config.responseHandler(response.data, validFiles);
|
processedFiles = await config.responseHandler(response.data, validRegularFiles);
|
||||||
} 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 = validRegularFiles[0]?.name || 'document.pdf';
|
||||||
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
||||||
processedFiles = [singleFile];
|
processedFiles = [singleFile];
|
||||||
} else {
|
} else {
|
||||||
@ -185,7 +189,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
};
|
};
|
||||||
processedFiles = await processFiles(
|
processedFiles = await processFiles(
|
||||||
params,
|
params,
|
||||||
validFiles,
|
validRegularFiles,
|
||||||
apiCallsConfig,
|
apiCallsConfig,
|
||||||
actions.setProgress,
|
actions.setProgress,
|
||||||
actions.setStatus
|
actions.setStatus
|
||||||
@ -208,7 +212,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 => file.fileId);
|
||||||
await consumeFiles(inputFileIds, processedFiles);
|
await consumeFiles(inputFileIds, processedFiles);
|
||||||
|
|
||||||
markOperationApplied(fileId, operationId);
|
markOperationApplied(fileId, operationId);
|
||||||
@ -223,7 +227,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
actions.setLoading(false);
|
actions.setLoading(false);
|
||||||
actions.setProgress(null);
|
actions.setProgress(null);
|
||||||
}
|
}
|
||||||
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||||
|
|
||||||
const cancelOperation = useCallback(() => {
|
const cancelOperation = useCallback(() => {
|
||||||
cancelApiCalls();
|
cancelApiCalls();
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||||
|
import { createQuickKey } from '../types/fileContext';
|
||||||
|
|
||||||
export function usePDFProcessor() {
|
export function usePDFProcessor() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -75,7 +76,7 @@ export function usePDFProcessor() {
|
|||||||
// Create pages without thumbnails initially - load them lazily
|
// Create pages without thumbnails initially - load them lazily
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
originalPageNumber: i,
|
originalPageNumber: i,
|
||||||
thumbnail: null, // Will be loaded lazily
|
thumbnail: null, // Will be loaded lazily
|
||||||
|
@ -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 { createQuickKey } from '../types/fileContext';
|
||||||
|
|
||||||
// Request queue to handle concurrent thumbnail requests
|
// Request queue to handle concurrent thumbnail requests
|
||||||
interface ThumbnailRequest {
|
interface ThumbnailRequest {
|
||||||
@ -70,8 +71,8 @@ async function processRequestQueue() {
|
|||||||
|
|
||||||
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 quickKey for PDF document caching (same metadata, consistent format)
|
||||||
const fileId = file.name + '_' + file.size + '_' + file.lastModified;
|
const fileId = createQuickKey(file);
|
||||||
|
|
||||||
const results = await thumbnailGenerationService.generateThumbnails(
|
const results = await thumbnailGenerationService.generateThumbnails(
|
||||||
fileId,
|
fileId,
|
||||||
|
@ -5,6 +5,7 @@ import { FileHasher } from '../utils/fileHash';
|
|||||||
import { FileAnalyzer } from './fileAnalyzer';
|
import { FileAnalyzer } from './fileAnalyzer';
|
||||||
import { ProcessingErrorHandler } from './processingErrorHandler';
|
import { ProcessingErrorHandler } from './processingErrorHandler';
|
||||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||||
|
import { createQuickKey } from '../types/fileContext';
|
||||||
|
|
||||||
export class EnhancedPDFProcessingService {
|
export class EnhancedPDFProcessingService {
|
||||||
private static instance: EnhancedPDFProcessingService;
|
private static instance: EnhancedPDFProcessingService;
|
||||||
@ -201,7 +202,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||||
|
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
@ -251,7 +252,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||||
|
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
@ -266,7 +267,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
// Create placeholder pages for remaining pages
|
// Create placeholder pages for remaining pages
|
||||||
for (let i = priorityCount + 1; i <= totalPages; i++) {
|
for (let i = priorityCount + 1; i <= totalPages; i++) {
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail: null, // Will be loaded lazily
|
thumbnail: null, // Will be loaded lazily
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
@ -313,7 +314,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||||
|
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
@ -334,7 +335,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
// Create placeholders for remaining pages
|
// Create placeholders for remaining pages
|
||||||
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
|
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
@ -368,7 +369,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
const pages: PDFPage[] = [];
|
const pages: PDFPage[] = [];
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
|
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
|
||||||
import { ProcessingCache } from './processingCache';
|
import { ProcessingCache } from './processingCache';
|
||||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||||
|
import { createQuickKey } from '../types/fileContext';
|
||||||
|
|
||||||
export class PDFProcessingService {
|
export class PDFProcessingService {
|
||||||
private static instance: PDFProcessingService;
|
private static instance: PDFProcessingService;
|
||||||
@ -113,7 +114,7 @@ export class PDFProcessingService {
|
|||||||
const thumbnail = canvas.toDataURL();
|
const thumbnail = canvas.toDataURL();
|
||||||
|
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
|
@ -29,7 +29,7 @@ vi.mock('../../services/fileStorage', () => ({
|
|||||||
init: vi.fn().mockResolvedValue(undefined),
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
id: `mock-id-${file.name}`,
|
id: `mock-uuid-${Math.random().toString(36).substring(2)}`,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
|
@ -25,7 +25,7 @@ vi.mock('../../services/fileStorage', () => ({
|
|||||||
init: vi.fn().mockResolvedValue(undefined),
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
id: `mock-id-${file.name}`,
|
id: `mock-uuid-${Math.random().toString(36).substring(2)}`,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
|
@ -25,8 +25,8 @@ export type ModeType =
|
|||||||
| 'unlockPdfForms'
|
| 'unlockPdfForms'
|
||||||
| 'removeCertificateSign';
|
| 'removeCertificateSign';
|
||||||
|
|
||||||
// Normalized state types
|
// Normalized state types - Branded type to prevent string/FileId confusion
|
||||||
export type FileId = string;
|
export type FileId = string & { readonly __brand: 'FileId' };
|
||||||
|
|
||||||
export interface ProcessedFilePage {
|
export interface ProcessedFilePage {
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
@ -85,6 +85,127 @@ export function createQuickKey(file: File): string {
|
|||||||
return `${file.name}|${file.size}|${file.lastModified}`;
|
return `${file.name}|${file.size}|${file.lastModified}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File with embedded UUID - replaces loose File + FileId parameter passing
|
||||||
|
export interface FileWithId extends File {
|
||||||
|
readonly fileId: FileId;
|
||||||
|
readonly quickKey: string; // Fast deduplication key: name|size|lastModified
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type guard to check if a File object has an embedded fileId
|
||||||
|
export function isFileWithId(file: File): file is FileWithId {
|
||||||
|
return 'fileId' in file && typeof (file as any).fileId === 'string' &&
|
||||||
|
'quickKey' in file && typeof (file as any).quickKey === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a FileWithId from a regular File object
|
||||||
|
export function createFileWithId(file: File, id?: FileId): FileWithId {
|
||||||
|
const fileId = id || createFileId();
|
||||||
|
const quickKey = createQuickKey(file);
|
||||||
|
|
||||||
|
// Create new File-like object with embedded fileId and quickKey
|
||||||
|
const fileWithId = Object.create(file);
|
||||||
|
Object.defineProperty(fileWithId, 'fileId', {
|
||||||
|
value: fileId,
|
||||||
|
writable: false,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false
|
||||||
|
});
|
||||||
|
Object.defineProperty(fileWithId, 'quickKey', {
|
||||||
|
value: quickKey,
|
||||||
|
writable: false,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return fileWithId as FileWithId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap array of Files with FileIds
|
||||||
|
export function wrapFilesWithIds(files: File[], ids?: FileId[]): FileWithId[] {
|
||||||
|
return files.map((file, index) =>
|
||||||
|
createFileWithId(file, ids?.[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract FileIds from FileWithId array
|
||||||
|
export function extractFileIds(files: FileWithId[]): FileId[] {
|
||||||
|
return files.map(file => file.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract regular File objects from FileWithId array
|
||||||
|
export function extractFiles(files: FileWithId[]): File[] {
|
||||||
|
return files.map(file => {
|
||||||
|
// Create clean File object without the fileId property
|
||||||
|
return new File([file], file.name, {
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type guards and validation functions
|
||||||
|
|
||||||
|
// Validate that a string is a proper FileId (has UUID format)
|
||||||
|
export function isValidFileId(id: string): id is FileId {
|
||||||
|
// Check UUID v4 format: 8-4-4-4-12 hex digits
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
return uuidRegex.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime assertion for FileId validation
|
||||||
|
export function assertValidFileId(id: string): asserts id is FileId {
|
||||||
|
if (!isValidFileId(id)) {
|
||||||
|
throw new Error(`Invalid FileId format: "${id}". Expected UUID format.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect potentially dangerous file.name usage as ID
|
||||||
|
export function isDangerousFileNameAsId(fileName: string, context: string = ''): boolean {
|
||||||
|
// Check if it's definitely a UUID (safe)
|
||||||
|
if (isValidFileId(fileName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a quickKey (safe) - format: name|size|lastModified
|
||||||
|
if (/^.+\|\d+\|\d+$/.test(fileName)) {
|
||||||
|
return false; // quickKeys are legitimate, not dangerous
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common patterns that suggest file.name is being used as ID
|
||||||
|
const dangerousPatterns = [
|
||||||
|
/^[^-]+-page-\d+$/, // pattern: filename-page-123
|
||||||
|
/\.(pdf|jpg|png|doc|docx)$/i, // ends with file extension
|
||||||
|
/\s/, // contains whitespace (filenames often have spaces)
|
||||||
|
/[()[\]{}]/, // contains brackets/parentheses common in filenames
|
||||||
|
/['"]/, // contains quotes
|
||||||
|
/[^a-zA-Z0-9\-._]/ // contains special characters not in UUIDs
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check dangerous patterns
|
||||||
|
const isDangerous = dangerousPatterns.some(pattern => pattern.test(fileName));
|
||||||
|
|
||||||
|
if (isDangerous && context) {
|
||||||
|
console.warn(`⚠️ Potentially dangerous file.name usage detected in ${context}: "${fileName}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDangerous;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe file ID getter that throws if file.name is used as ID
|
||||||
|
export function safeGetFileId(file: File, context: string = ''): FileId {
|
||||||
|
if (isFileWithId(file)) {
|
||||||
|
return file.fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, someone is trying to use a regular File without embedded ID
|
||||||
|
throw new Error(`Attempted to get FileId from regular File object in ${context}. Use FileWithId instead.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent accidental file.name usage as FileId
|
||||||
|
export function preventFileNameAsId(value: string, context: string = ''): never {
|
||||||
|
throw new Error(`Blocked attempt to use string "${value}" as FileId in ${context}. Use proper FileId from createFileId() or FileWithId.fileId instead.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function toFileRecord(file: File, id?: FileId): FileRecord {
|
export function toFileRecord(file: File, id?: FileId): FileRecord {
|
||||||
@ -217,21 +338,21 @@ export type FileContextAction =
|
|||||||
|
|
||||||
export interface FileContextActions {
|
export interface FileContextActions {
|
||||||
// File management - lightweight actions only
|
// File management - lightweight actions only
|
||||||
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<File[]>;
|
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<FileWithId[]>;
|
||||||
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
|
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<FileWithId[]>;
|
||||||
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<File[]>;
|
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<FileWithId[]>;
|
||||||
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
|
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
|
||||||
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
|
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
|
||||||
reorderFiles: (orderedFileIds: FileId[]) => void;
|
reorderFiles: (orderedFileIds: FileId[]) => void;
|
||||||
clearAllFiles: () => Promise<void>;
|
clearAllFiles: () => Promise<void>;
|
||||||
clearAllData: () => Promise<void>;
|
clearAllData: () => Promise<void>;
|
||||||
|
|
||||||
// File pinning
|
// File pinning - now accepts FileWithId for safer type checking
|
||||||
pinFile: (file: File) => void;
|
pinFile: (file: FileWithId) => void;
|
||||||
unpinFile: (file: File) => void;
|
unpinFile: (file: FileWithId) => void;
|
||||||
|
|
||||||
// File consumption (replace unpinned files with outputs)
|
// File consumption (replace unpinned files with outputs) - now returns FileWithId
|
||||||
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<void>;
|
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileWithId[]>;
|
||||||
// Selection management
|
// Selection management
|
||||||
setSelectedFiles: (fileIds: FileId[]) => void;
|
setSelectedFiles: (fileIds: FileId[]) => void;
|
||||||
setSelectedPages: (pageNumbers: number[]) => void;
|
setSelectedPages: (pageNumbers: number[]) => void;
|
||||||
@ -254,24 +375,24 @@ export interface FileContextActions {
|
|||||||
|
|
||||||
// File selectors (separate from actions to avoid re-renders)
|
// File selectors (separate from actions to avoid re-renders)
|
||||||
export interface FileContextSelectors {
|
export interface FileContextSelectors {
|
||||||
// File access - no state dependency, uses ref
|
// File access - now returns FileWithId for safer type checking
|
||||||
getFile: (id: FileId) => File | undefined;
|
getFile: (id: FileId) => FileWithId | undefined;
|
||||||
getFiles: (ids?: FileId[]) => File[];
|
getFiles: (ids?: FileId[]) => FileWithId[];
|
||||||
|
|
||||||
// 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 - now return FileWithId
|
||||||
getAllFileIds: () => FileId[];
|
getAllFileIds: () => FileId[];
|
||||||
getSelectedFiles: () => File[];
|
getSelectedFiles: () => FileWithId[];
|
||||||
getSelectedFileRecords: () => FileRecord[];
|
getSelectedFileRecords: () => FileRecord[];
|
||||||
|
|
||||||
// Pinned files selectors
|
// Pinned files selectors - now return FileWithId
|
||||||
getPinnedFileIds: () => FileId[];
|
getPinnedFileIds: () => FileId[];
|
||||||
getPinnedFiles: () => File[];
|
getPinnedFiles: () => FileWithId[];
|
||||||
getPinnedFileRecords: () => FileRecord[];
|
getPinnedFileRecords: () => FileRecord[];
|
||||||
isFilePinned: (file: File) => boolean;
|
isFilePinned: (file: FileWithId) => boolean;
|
||||||
|
|
||||||
// Stable signature for effect dependencies
|
// Stable signature for effect dependencies
|
||||||
getFilesSignature: () => string;
|
getFilesSignature: () => string;
|
||||||
|
59
frontend/src/types/fileIdSafety.d.ts
vendored
Normal file
59
frontend/src/types/fileIdSafety.d.ts
vendored
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Type safety declarations to prevent file.name/UUID confusion
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FileId, FileWithId } from './fileContext';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace FileIdSafety {
|
||||||
|
// Mark functions that should never accept file.name as parameters
|
||||||
|
type SafeFileIdFunction<T extends (...args: any[]) => any> = T extends (...args: infer P) => infer R
|
||||||
|
? P extends readonly [string, ...any[]]
|
||||||
|
? never // Reject string parameters in first position for FileId functions
|
||||||
|
: T
|
||||||
|
: T;
|
||||||
|
|
||||||
|
// Mark functions that should only accept FileWithId, not regular File
|
||||||
|
type FileWithIdOnlyFunction<T extends (...args: any[]) => any> = T extends (...args: infer P) => infer R
|
||||||
|
? P extends readonly [File, ...any[]]
|
||||||
|
? never // Reject File parameters in first position for FileWithId functions
|
||||||
|
: T
|
||||||
|
: T;
|
||||||
|
|
||||||
|
// Utility type to enforce FileWithId usage
|
||||||
|
type RequireFileWithId<T> = T extends File ? FileWithId : T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend Window interface to add runtime validation helpers
|
||||||
|
interface Window {
|
||||||
|
__FILE_ID_DEBUG?: boolean;
|
||||||
|
__validateFileId?: (id: string, context: string) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module augmentation for stricter type checking on dangerous functions
|
||||||
|
declare module '../utils/toolOperationTracker' {
|
||||||
|
export const createOperation: <TParams = void>(
|
||||||
|
operationType: string,
|
||||||
|
params: TParams,
|
||||||
|
selectedFiles: FileWithId[] // Must be FileWithId, not File[]
|
||||||
|
) => { operation: FileOperation; operationId: string; fileId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Augment FileContext types to prevent bypassing FileWithId
|
||||||
|
declare module '../contexts/FileContext' {
|
||||||
|
export interface StrictFileContextActions {
|
||||||
|
pinFile: (file: FileWithId) => void; // Must be FileWithId
|
||||||
|
unpinFile: (file: FileWithId) => void; // Must be FileWithId
|
||||||
|
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<FileWithId[]>; // Returns FileWithId
|
||||||
|
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileWithId[]>; // Returns FileWithId
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrictFileContextSelectors {
|
||||||
|
getFile: (id: FileId) => FileWithId | undefined; // Returns FileWithId
|
||||||
|
getFiles: (ids?: FileId[]) => FileWithId[]; // Returns FileWithId[]
|
||||||
|
isFilePinned: (file: FileWithId) => boolean; // Must be FileWithId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
80
frontend/src/utils/fileIdSafety.ts
Normal file
80
frontend/src/utils/fileIdSafety.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Runtime validation helpers for file ID safety
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isValidFileId, isDangerousFileNameAsId } from '../types/fileContext';
|
||||||
|
|
||||||
|
// Enable debug mode in development
|
||||||
|
const DEBUG_FILE_ID = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime validation for FileId usage
|
||||||
|
*/
|
||||||
|
export function validateFileIdUsage(id: string, context: string = ''): void {
|
||||||
|
if (!DEBUG_FILE_ID) return;
|
||||||
|
|
||||||
|
// Check if it's a valid UUID
|
||||||
|
if (!isValidFileId(id)) {
|
||||||
|
console.error(`🚨 Invalid FileId detected in ${context}: "${id}". Expected UUID format.`);
|
||||||
|
|
||||||
|
// Check if it looks like a dangerous file.name usage
|
||||||
|
if (isDangerousFileNameAsId(id, context)) {
|
||||||
|
console.error(`💀 DANGEROUS: file.name used as FileId in ${context}! This will cause ID collisions.`);
|
||||||
|
console.trace('Stack trace:');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime check for File vs FileWithId usage
|
||||||
|
*/
|
||||||
|
export function validateFileWithIdUsage(file: File, context: string = ''): void {
|
||||||
|
if (!DEBUG_FILE_ID) return;
|
||||||
|
|
||||||
|
// Check if file has embedded fileId
|
||||||
|
if (!('fileId' in file)) {
|
||||||
|
console.warn(`⚠️ Regular File object used where FileWithId expected in ${context}: "${file.name}"`);
|
||||||
|
console.warn('Consider using FileWithId for better type safety');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development-only assertion that fails on dangerous patterns
|
||||||
|
*/
|
||||||
|
export function assertSafeFileIdUsage(id: string, context: string = ''): void {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
if (isDangerousFileNameAsId(id, context)) {
|
||||||
|
throw new Error(`ASSERTION FAILED: Dangerous file.name as FileId detected in ${context}: "${id}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install global runtime validators (development only)
|
||||||
|
*/
|
||||||
|
export function installFileIdSafetyValidators(): void {
|
||||||
|
if (process.env.NODE_ENV !== 'development') return;
|
||||||
|
|
||||||
|
// Add to window for debugging
|
||||||
|
window.__FILE_ID_DEBUG = true;
|
||||||
|
window.__validateFileId = validateFileIdUsage;
|
||||||
|
|
||||||
|
// Monkey patch console.warn to highlight file ID issues
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
console.warn = (...args: any[]) => {
|
||||||
|
const message = args.join(' ');
|
||||||
|
if (message.includes('file.name') && message.includes('ID')) {
|
||||||
|
console.error('🚨 FILE ID SAFETY WARNING:', ...args);
|
||||||
|
console.trace('Location:');
|
||||||
|
} else {
|
||||||
|
originalWarn.apply(console, args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('🛡️ File ID safety validators installed (development mode)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-install in development
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
installFileIdSafetyValidators();
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { FileOperation } from '../types/fileContext';
|
import { FileOperation, FileWithId, safeGetFileId, FileId } from '../types/fileContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates operation tracking data for FileContext integration
|
* Creates operation tracking data for FileContext integration
|
||||||
@ -6,23 +6,26 @@ import { FileOperation } from '../types/fileContext';
|
|||||||
export const createOperation = <TParams = void>(
|
export const createOperation = <TParams = void>(
|
||||||
operationType: string,
|
operationType: string,
|
||||||
params: TParams,
|
params: TParams,
|
||||||
selectedFiles: File[]
|
selectedFiles: FileWithId[]
|
||||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
): { operation: FileOperation; operationId: string; fileId: string } => {
|
||||||
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(',');
|
|
||||||
|
// Use proper FileIds instead of file.name - fixed dangerous pattern
|
||||||
|
const fileIds = selectedFiles.map(file => file.fileId);
|
||||||
|
const fileId = fileIds.join(',');
|
||||||
|
|
||||||
const operation: FileOperation = {
|
const operation: FileOperation = {
|
||||||
id: operationId,
|
id: operationId,
|
||||||
type: operationType,
|
type: operationType,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
fileIds: selectedFiles.map(f => f.name),
|
fileIds, // Now properly uses FileId[] instead of file.name[]
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
metadata: {
|
metadata: {
|
||||||
originalFileName: selectedFiles[0]?.name,
|
originalFileName: selectedFiles[0]?.name,
|
||||||
parameters: params,
|
parameters: params,
|
||||||
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
|
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
|
||||||
}
|
}
|
||||||
} as any /* FIX ME*/;
|
};
|
||||||
|
|
||||||
return { operation, operationId, fileId };
|
return { operation, operationId, fileId };
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user