diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js
new file mode 100644
index 000000000..6146714b4
--- /dev/null
+++ b/frontend/.eslintrc.js
@@ -0,0 +1,54 @@
+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'
+ },
+ 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'
+ ]
+ }
+ }
+};
\ No newline at end of file
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8a30d3869..97503ba21 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -14,6 +14,9 @@ import "./styles/cookieconsent.css";
import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext";
+// Import file ID safety validators (development only)
+import "./utils/fileIdSafety";
+
// Loading component for i18next suspense
const LoadingFallback = () => (
void;
- onMergeFiles?: (files: File[]) => void;
+ onOpenPageEditor?: (file: FileWithId) => void;
+ onMergeFiles?: (files: FileWithId[]) => void;
toolMode?: boolean;
showUpload?: boolean;
showBulkActions?: boolean;
@@ -421,7 +421,7 @@ const FileEditor = ({
if (startIndex === -1) return;
const recordsToMerge = activeFileRecords.slice(startIndex);
- const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
+ const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as FileWithId[];
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx
index e82483898..bfeb404c5 100644
--- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx
+++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx
@@ -61,8 +61,8 @@ const FileEditorThumbnail = ({
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
- return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
- }, [activeFiles, file.name, file.size]);
+ return activeFiles.find(f => f.fileId === file.id);
+ }, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => {
diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx
index 1eda1f6c8..ad81ce463 100644
--- a/frontend/src/components/pageEditor/FileThumbnail.tsx
+++ b/frontend/src/components/pageEditor/FileThumbnail.tsx
@@ -61,8 +61,8 @@ const FileThumbnail = ({
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
- return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
- }, [activeFiles, file.name, file.size]);
+ return activeFiles.find(f => f.fileId === file.id);
+ }, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => {
diff --git a/frontend/src/components/shared/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx
index 73ae01dba..b433be2ca 100644
--- a/frontend/src/components/shared/FileCard.tsx
+++ b/frontend/src/components/shared/FileCard.tsx
@@ -25,7 +25,7 @@ interface FileCardProps {
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation();
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
- const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
+ const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
const thumb = record?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false);
diff --git a/frontend/src/components/shared/FileGrid.tsx b/frontend/src/components/shared/FileGrid.tsx
index 1a43196d6..2da1e2b09 100644
--- a/frontend/src/components/shared/FileGrid.tsx
+++ b/frontend/src/components/shared/FileGrid.tsx
@@ -124,8 +124,12 @@ const FileGrid = ({
style={{ overflowY: "auto", width: "100%" }}
>
{displayFiles.map((item, idx) => {
- 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);
+ if (!item.record?.id) {
+ console.error('FileGrid: File missing FileRecord with proper ID:', item.file.name);
+ return null;
+ }
+ const fileId = item.record.id;
+ const originalIdx = files.findIndex(f => f.record?.id === fileId);
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
return (
void;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
- selectedFiles: File[];
+ selectedFiles: FileWithId[];
disabled?: boolean;
}
@@ -129,7 +129,7 @@ const ConvertSettings = ({
};
const filterFilesByExtension = (extension: string) => {
- const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
+ const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as FileWithId[];
return files.filter(file => {
const fileExtension = detectFileExtension(file.name);
@@ -143,21 +143,8 @@ const ConvertSettings = ({
});
};
- const updateFileSelection = (files: File[]) => {
- // Map File objects to their actual IDs in FileContext
- const fileIds = files.map(file => {
- // Find the file ID by matching file properties
- const fileRecord = state.files.ids
- .map(id => selectors.getFileRecord(id))
- .find(record =>
- record &&
- record.name === file.name &&
- record.size === file.size &&
- record.lastModified === file.lastModified
- );
- return fileRecord?.id;
- }).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
-
+ const updateFileSelection = (files: FileWithId[]) => {
+ const fileIds = files.map(file => file.fileId);
setSelectedFiles(fileIds);
};
diff --git a/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx
index e1a662bd2..ccad7ce67 100644
--- a/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx
+++ b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx
@@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
+import { FileWithId } from '../../../types/fileContext';
interface ConvertToPdfaSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
- selectedFiles: File[];
+ selectedFiles: FileWithId[];
disabled?: boolean;
}
diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx
index 9b375fc2f..214159b05 100644
--- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx
+++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx
@@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
import { useAllFiles } from "../../../contexts/FileContext";
import { useFileManager } from "../../../hooks/useFileManager";
+import { FileWithId } from "../../../types/fileContext";
export interface FileStatusIndicatorProps {
- selectedFiles?: File[];
+ selectedFiles?: FileWithId[];
placeholder?: string;
}
diff --git a/frontend/src/components/tools/shared/FilesToolStep.tsx b/frontend/src/components/tools/shared/FilesToolStep.tsx
index b062e9c02..11f7d4e02 100644
--- a/frontend/src/components/tools/shared/FilesToolStep.tsx
+++ b/frontend/src/components/tools/shared/FilesToolStep.tsx
@@ -1,9 +1,10 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FileStatusIndicator from './FileStatusIndicator';
+import { FileWithId } from '../../../types/fileContext';
export interface FilesToolStepProps {
- selectedFiles: File[];
+ selectedFiles: FileWithId[];
isCollapsed?: boolean;
onCollapsedClick?: () => void;
placeholder?: string;
diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx
index b6a7594c6..1bee8b9fc 100644
--- a/frontend/src/components/tools/shared/createToolFlow.tsx
+++ b/frontend/src/components/tools/shared/createToolFlow.tsx
@@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
+import { FileWithId } from '../../../types/fileContext';
export interface FilesStepConfig {
- selectedFiles: File[];
+ selectedFiles: FileWithId[];
isCollapsed?: boolean;
placeholder?: string;
onCollapsedClick?: () => void;
diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx
index 0932e995b..53d9e98e2 100644
--- a/frontend/src/components/viewer/Viewer.tsx
+++ b/frontend/src/components/viewer/Viewer.tsx
@@ -15,6 +15,7 @@ import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
+import { isFileObject } from "../../types/fileContext";
import { FileId } from "../../types/file";
@@ -201,7 +202,7 @@ const Viewer = ({
const effectiveFile = React.useMemo(() => {
if (previewFile) {
// Validate the preview file
- if (!(previewFile instanceof File)) {
+ if (!isFileObject(previewFile)) {
return null;
}
diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx
index 39faa0643..97ac3c645 100644
--- a/frontend/src/contexts/FileContext.tsx
+++ b/frontend/src/contexts/FileContext.tsx
@@ -19,7 +19,10 @@ import {
FileContextStateValue,
FileContextActionsValue,
FileContextActions,
- FileRecord
+ FileId,
+ FileRecord,
+ FileWithId,
+ createFileWithId
} from '../types/fileContext';
// Import modular components
@@ -29,7 +32,6 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions
import { FileLifecycleManager } from './file/lifecycle';
import { FileStateContext, FileActionsContext } from './file/contexts';
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
-import { FileId } from '../types/file';
const DEBUG = process.env.NODE_ENV === 'development';
@@ -79,7 +81,7 @@ function FileContextInner({
}
// File operations using unified addFiles helper with persistence
- const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise => {
+ const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise => {
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested
@@ -98,15 +100,15 @@ function FileContextInner({
}));
}
- return addedFilesWithIds.map(({ file }) => file);
+ return addedFilesWithIds.map(({ file, id }) => createFileWithId(file, id));
}, [indexedDB, enablePersistence]);
- const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise => {
+ const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise => {
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
- return result.map(({ file }) => file);
+ return result.map(({ file, id }) => createFileWithId(file, id));
}, []);
- const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise => {
+ const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise => {
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested
@@ -114,7 +116,7 @@ function FileContextInner({
selectFiles(result);
}
- return result.map(({ file }) => file);
+ return result.map(({ file, id }) => createFileWithId(file, id));
}, []);
// Action creators
@@ -140,24 +142,14 @@ function FileContextInner({
});
}, []);
- // File-to-ID wrapper functions for pinning
- const pinFileWrapper = useCallback((file: File) => {
- const fileId = findFileId(file);
- if (fileId) {
- baseActions.pinFile(fileId);
- } else {
- console.warn('File not found for pinning:', file.name);
- }
- }, [baseActions, findFileId]);
+ // File pinning functions - use FileWithId directly
+ const pinFileWrapper = useCallback((file: FileWithId) => {
+ baseActions.pinFile(file.fileId);
+ }, [baseActions]);
- 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]);
+ const unpinFileWrapper = useCallback((file: FileWithId) => {
+ baseActions.unpinFile(file.fileId);
+ }, [baseActions]);
// Complete actions object
const actions = useMemo(() => ({
diff --git a/frontend/src/contexts/file/fileHooks.ts b/frontend/src/contexts/file/fileHooks.ts
index e1b8e5cc4..609d38ab5 100644
--- a/frontend/src/contexts/file/fileHooks.ts
+++ b/frontend/src/contexts/file/fileHooks.ts
@@ -9,7 +9,7 @@ import {
FileContextStateValue,
FileContextActionsValue
} from './contexts';
-import { FileRecord } from '../../types/fileContext';
+import { FileRecord, FileWithId } from '../../types/fileContext';
import { FileId } from '../../types/file';
/**
@@ -123,7 +123,7 @@ export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecor
/**
* Hook for all files (use sparingly - causes re-renders on file list changes)
*/
-export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
+export function useAllFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
@@ -136,7 +136,7 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds:
/**
* Hook for selected files (optimized for selection-based UI)
*/
-export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
+export function useSelectedFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
diff --git a/frontend/src/contexts/file/fileSelectors.ts b/frontend/src/contexts/file/fileSelectors.ts
index 2111693cf..e5876813b 100644
--- a/frontend/src/contexts/file/fileSelectors.ts
+++ b/frontend/src/contexts/file/fileSelectors.ts
@@ -6,7 +6,9 @@ import { FileId } from '../../types/file';
import {
FileRecord,
FileContextState,
- FileContextSelectors
+ FileContextSelectors,
+ FileWithId,
+ createFileWithId
} from '../../types/fileContext';
/**
@@ -17,11 +19,19 @@ export function createFileSelectors(
filesRef: React.MutableRefObject