Feature/v2/filewithid implementation (#4369)

Added Filewithid type
Updated code where file was being used to use filewithid
Updated places we identified files by name or composite keys to use UUID
Updated places we should have been using quickkey
Updated pageeditor issue where we parsed pagenumber from pageid instead
of using pagenumber directly

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
Reece Browne 2025-09-05 11:33:03 +01:00 committed by GitHub
parent 5caec41d96
commit 87c63efcec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 493 additions and 339 deletions

View File

@ -14,6 +14,9 @@ import "./styles/cookieconsent.css";
import "./index.css"; import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext"; import { RightRailProvider } from "./contexts/RightRailContext";
// Import file ID debugging helpers (development only)
import "./utils/fileIdSafety";
// Loading component for i18next suspense // Loading component for i18next suspense
const LoadingFallback = () => ( const LoadingFallback = () => (
<div <div

View File

@ -24,7 +24,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager(); const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
// Wrapper for storeFile that generates UUID // Wrapper for storeFile that generates UUID
const storeFileWithId = useCallback(async (file: File) => { const storeStirlingFile = useCallback(async (file: File) => {
const fileId = createFileId(); // Generate UUID for storage const fileId = createFileId(); // Generate UUID for storage
return await storeFile(file, fileId); return await storeFile(file, fileId);
}, [storeFile]); }, [storeFile]);

View File

@ -16,12 +16,12 @@ 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'; import { FileId, StirlingFile } from '../../types/fileContext';
interface FileEditorProps { interface FileEditorProps {
onOpenPageEditor?: (file: File) => void; onOpenPageEditor?: (file: StirlingFile) => void;
onMergeFiles?: (files: File[]) => void; onMergeFiles?: (files: StirlingFile[]) => void;
toolMode?: boolean; toolMode?: boolean;
showUpload?: boolean; showUpload?: boolean;
showBulkActions?: boolean; showBulkActions?: boolean;
@ -50,7 +50,7 @@ const FileEditor = ({
// 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 activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
const selectedFileIds = state.ui.selectedFileIds; const selectedFileIds = state.ui.selectedFileIds;
const isProcessing = state.ui.isProcessing; const isProcessing = state.ui.isProcessing;
@ -92,10 +92,10 @@ const FileEditor = ({
const contextSelectedIdsRef = useRef<FileId[]>([]); const contextSelectedIdsRef = useRef<FileId[]>([]);
contextSelectedIdsRef.current = contextSelectedIds; contextSelectedIdsRef.current = contextSelectedIds;
// Use activeFileRecords directly - no conversion needed // Use activeStirlingFileStubs directly - no conversion needed
const localSelectedIds = contextSelectedIds; const localSelectedIds = contextSelectedIds;
// Helper to convert FileRecord to FileThumbnail format // Helper to convert StirlingFileStub to FileThumbnail format
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;
@ -253,26 +253,26 @@ const FileEditor = ({
}, [addFiles]); }, [addFiles]);
const selectAll = useCallback(() => { const selectAll = useCallback(() => {
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly setSelectedFiles(activeStirlingFileStubs.map(r => r.id)); // Use StirlingFileStub IDs directly
}, [activeFileRecords, setSelectedFiles]); }, [activeStirlingFileStubs, setSelectedFiles]);
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]); const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
const closeAllFiles = useCallback(() => { const closeAllFiles = useCallback(() => {
if (activeFileRecords.length === 0) return; if (activeStirlingFileStubs.length === 0) return;
// 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 = activeStirlingFileStubs.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]); }, [activeStirlingFileStubs, removeFiles, setSelectedFiles]);
const toggleFile = useCallback((fileId: FileId) => { const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current; const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeFileRecords.find(r => r.id === fileId); const targetRecord = activeStirlingFileStubs.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
@ -302,7 +302,7 @@ const FileEditor = ({
// Update context (this automatically updates tool selection since they use the same action) // Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection); setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]); }, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
const toggleSelectionMode = useCallback(() => { const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => { setSelectionMode(prev => {
@ -316,7 +316,7 @@ const FileEditor = ({
// File reordering handler for drag and drop // File reordering handler for drag and drop
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => { const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeFileRecords.map(r => r.id); const currentIds = activeStirlingFileStubs.map(r => r.id);
// Find indices // Find indices
const sourceIndex = currentIds.findIndex(id => id === sourceFileId); const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
@ -368,13 +368,13 @@ const FileEditor = ({
// Update status // Update status
const moveCount = filesToMove.length; const moveCount = filesToMove.length;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeFileRecords, reorderFiles, setStatus]); }, [activeStirlingFileStubs, reorderFiles, setStatus]);
// File operations using context // File operations using context
const handleDeleteFile = useCallback((fileId: FileId) => { const handleDeleteFile = useCallback((fileId: FileId) => {
const record = activeFileRecords.find(r => r.id === fileId); const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null; const file = record ? selectors.getFile(record.id) : null;
if (record && file) { if (record && file) {
@ -405,27 +405,27 @@ const FileEditor = ({
const currentSelected = selectedFileIds.filter(id => id !== contextFileId); const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
setSelectedFiles(currentSelected); setSelectedFiles(currentSelected);
} }
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]); }, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleViewFile = useCallback((fileId: FileId) => { const handleViewFile = useCallback((fileId: FileId) => {
const record = activeFileRecords.find(r => r.id === fileId); const record = activeStirlingFileStubs.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
setSelectedFiles([fileId]); setSelectedFiles([fileId]);
navActions.setWorkbench('viewer'); navActions.setWorkbench('viewer');
} }
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]); }, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
const handleMergeFromHere = useCallback((fileId: FileId) => { const handleMergeFromHere = useCallback((fileId: FileId) => {
const startIndex = activeFileRecords.findIndex(r => r.id === fileId); const startIndex = activeStirlingFileStubs.findIndex(r => r.id === fileId);
if (startIndex === -1) return; if (startIndex === -1) return;
const recordsToMerge = activeFileRecords.slice(startIndex); const recordsToMerge = activeStirlingFileStubs.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 StirlingFile[];
if (onMergeFiles) { if (onMergeFiles) {
onMergeFiles(filesToMerge); onMergeFiles(filesToMerge);
} }
}, [activeFileRecords, selectors, onMergeFiles]); }, [activeStirlingFileStubs, selectors, onMergeFiles]);
const handleSplitFile = useCallback((fileId: FileId) => { const handleSplitFile = useCallback((fileId: FileId) => {
const file = selectors.getFile(fileId); const file = selectors.getFile(fileId);
@ -467,7 +467,7 @@ const FileEditor = ({
<Box p="md" pt="xl"> <Box p="md" pt="xl">
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? ( {activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
<Center h="60vh"> <Center h="60vh">
<Stack align="center" gap="md"> <Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text> <Text size="lg" c="dimmed">📁</Text>
@ -475,7 +475,7 @@ const FileEditor = ({
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text> <Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack> </Stack>
</Center> </Center>
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? ( ) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
<Box> <Box>
<SkeletonLoader type="controls" /> <SkeletonLoader type="controls" />
@ -522,7 +522,7 @@ const FileEditor = ({
pointerEvents: 'auto' pointerEvents: 'auto'
}} }}
> >
{activeFileRecords.map((record, index) => { {activeStirlingFileStubs.map((record, index) => {
const fileItem = recordToFileItem(record); const fileItem = recordToFileItem(record);
if (!fileItem) return null; if (!fileItem) return null;
@ -531,7 +531,7 @@ const FileEditor = ({
key={record.id} key={record.id}
file={fileItem} file={fileItem}
index={index} index={index}
totalFiles={activeFileRecords.length} totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds} selectedFiles={localSelectedIds}
selectionMode={selectionMode} selectionMode={selectionMode}
onToggleFile={toggleFile} onToggleFile={toggleFile}

View File

@ -61,8 +61,8 @@ const FileEditorThumbnail = ({
// Resolve the actual File object for pin/unpin operations // Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => { const actualFile = useMemo(() => {
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size); return activeFiles.find(f => f.fileId === file.id);
}, [activeFiles, file.name, file.size]); }, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false; const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => { const downloadSelectedFile = useCallback(() => {

View File

@ -61,8 +61,8 @@ const FileThumbnail = ({
// Resolve the actual File object for pin/unpin operations // Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => { const actualFile = useMemo(() => {
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size); return activeFiles.find(f => f.fileId === file.id);
}, [activeFiles, file.name, file.size]); }, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false; const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => { const downloadSelectedFile = useCallback(() => {

View File

@ -27,9 +27,9 @@ export function usePageDocument(): PageDocumentHook {
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 primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
const processedFilePages = primaryFileRecord?.processedFile?.pages; const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages; const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
// Compute merged document with stable signature (prevents infinite loops) // Compute merged document with stable signature (prevents infinite loops)
const mergedPdfDocument = useMemo((): PDFDocument | null => { const mergedPdfDocument = useMemo((): PDFDocument | null => {
@ -38,16 +38,16 @@ export function usePageDocument(): PageDocumentHook {
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 (!primaryStirlingFileStub) {
console.log('🎬 PageEditor: No primary file record found, showing loading'); console.log('🎬 PageEditor: No primary file record found, showing loading');
return null; return null;
} }
const name = const name =
activeFileIds.length === 1 activeFileIds.length === 1
? (primaryFileRecord.name ?? 'document.pdf') ? (primaryStirlingFileStub.name ?? 'document.pdf')
: activeFileIds : activeFileIds
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, '')) .map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.join(' + '); .join(' + ');
// Build page insertion map from files with insertion positions // Build page insertion map from files with insertion positions
@ -55,7 +55,7 @@ export function usePageDocument(): PageDocumentHook {
const originalFileIds: FileId[] = []; const originalFileIds: FileId[] = [];
activeFileIds.forEach(fileId => { activeFileIds.forEach(fileId => {
const record = selectors.getFileRecord(fileId); const record = selectors.getStirlingFileStub(fileId);
if (record?.insertAfterPageId !== undefined) { if (record?.insertAfterPageId !== undefined) {
if (!insertionMap.has(record.insertAfterPageId)) { if (!insertionMap.has(record.insertAfterPageId)) {
insertionMap.set(record.insertAfterPageId, []); insertionMap.set(record.insertAfterPageId, []);
@ -72,12 +72,12 @@ export function usePageDocument(): PageDocumentHook {
// Helper function to create pages from a file // Helper function to create pages from a file
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => { const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
const fileRecord = selectors.getFileRecord(fileId); const stirlingFileStub = selectors.getStirlingFileStub(fileId);
if (!fileRecord) { if (!stirlingFileStub) {
return []; return [];
} }
const processedFile = fileRecord.processedFile; const processedFile = stirlingFileStub.processedFile;
let filePages: PDFPage[] = []; let filePages: PDFPage[] = [];
if (processedFile?.pages && processedFile.pages.length > 0) { if (processedFile?.pages && processedFile.pages.length > 0) {
@ -159,7 +159,7 @@ export function usePageDocument(): PageDocumentHook {
}; };
return mergedDoc; return mergedDoc;
}, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]); }, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
// Large document detection for smart loading // Large document detection for smart loading
const isVeryLargeDocument = useMemo(() => { const isVeryLargeDocument = useMemo(() => {

View File

@ -6,13 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import { FileRecord } from "../../types/fileContext"; import { StirlingFileStub } from "../../types/fileContext";
import { getFileSize, getFileDate } from "../../utils/fileUtils"; import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail"; import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
interface FileCardProps { interface FileCardProps {
file: File; file: File;
record?: FileRecord; record?: StirlingFileStub;
onRemove: () => void; onRemove: () => void;
onDoubleClick?: () => void; onDoubleClick?: () => void;
onView?: () => void; onView?: () => void;
@ -25,7 +25,7 @@ interface FileCardProps {
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => { const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup // 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 { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
const thumb = record?.thumbnailUrl || indexedDBThumb; const thumb = record?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);

View File

@ -4,15 +4,15 @@ import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search"; 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 { StirlingFileStub } from "../../types/fileContext";
import { FileId } from "../../types/file"; import { FileId } from "../../types/file";
interface FileGridProps { interface FileGridProps {
files: Array<{ file: File; record?: FileRecord }>; files: Array<{ file: File; record?: StirlingFileStub }>;
onRemove?: (index: number) => void; onRemove?: (index: number) => void;
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void; onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
onView?: (item: { file: File; record?: FileRecord }) => void; onView?: (item: { file: File; record?: StirlingFileStub }) => void;
onEdit?: (item: { file: File; record?: FileRecord }) => void; onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
onSelect?: (fileId: FileId) => void; onSelect?: (fileId: FileId) => void;
selectedFiles?: FileId[]; selectedFiles?: FileId[];
showSearch?: boolean; showSearch?: boolean;
@ -123,9 +123,17 @@ const FileGrid = ({
h="30rem" h="30rem"
style={{ overflowY: "auto", width: "100%" }} style={{ overflowY: "auto", width: "100%" }}
> >
{displayFiles.map((item, idx) => { {displayFiles
const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */; .filter(item => {
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId); if (!item.record?.id) {
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
return false;
}
return true;
})
.map((item, idx) => {
const fileId = item.record!.id; // Safe to assert after filter
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

View File

@ -34,7 +34,6 @@ export default function RightRail() {
const activeFiles = selectors.getFiles(); const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature(); const filesSignature = selectors.getFilesSignature();
const fileRecords = selectors.getFileRecords();
// Compute selection state and total items // Compute selection state and total items
const getSelectionState = useCallback(() => { const getSelectionState = useCallback(() => {
@ -85,7 +84,7 @@ export default function RightRail() {
if (currentView === 'fileEditor' || currentView === 'viewer') { if (currentView === 'fileEditor' || currentView === 'viewer') {
// Download selected files (or all if none selected) // Download selected files (or all if none selected)
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles; const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
filesToDownload.forEach(file => { filesToDownload.forEach(file => {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = URL.createObjectURL(file); link.href = URL.createObjectURL(file);
@ -206,8 +205,8 @@ export default function RightRail() {
)} )}
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */} {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
<div <div
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`} className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
aria-hidden={currentView === 'viewer'} aria-hidden={currentView === 'viewer'}
> >
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
@ -358,14 +357,14 @@ export default function RightRail() {
<LanguageSelector position="left-start" offset={6} compact /> <LanguageSelector position="left-start" offset={6} compact />
<Tooltip content={ <Tooltip content={
currentView === 'pageEditor' currentView === 'pageEditor'
? t('rightRail.exportAll', 'Export PDF') ? t('rightRail.exportAll', 'Export PDF')
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All')) : (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
} position="left" offset={12} arrow> } position="left" offset={12} arrow>
<div> <div>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
radius="md" radius="md"
className="right-rail-icon" className="right-rail-icon"
onClick={handleExportAll} onClick={handleExportAll}
disabled={currentView === 'viewer' || totalItems === 0} disabled={currentView === 'viewer' || totalItems === 0}

View File

@ -22,13 +22,13 @@ import {
OUTPUT_OPTIONS, OUTPUT_OPTIONS,
FIT_OPTIONS FIT_OPTIONS
} from "../../../constants/convertConstants"; } from "../../../constants/convertConstants";
import { FileId } from "../../../types/file"; import { StirlingFile } from "../../../types/fileContext";
interface ConvertSettingsProps { interface ConvertSettingsProps {
parameters: ConvertParameters; parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void; onParameterChange: (key: keyof ConvertParameters, value: any) => void;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>; getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
selectedFiles: File[]; selectedFiles: StirlingFile[];
disabled?: boolean; disabled?: boolean;
} }
@ -129,7 +129,7 @@ const ConvertSettings = ({
}; };
const filterFilesByExtension = (extension: string) => { 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 StirlingFile[];
return files.filter(file => { return files.filter(file => {
const fileExtension = detectFileExtension(file.name); const fileExtension = detectFileExtension(file.name);
@ -143,21 +143,8 @@ const ConvertSettings = ({
}); });
}; };
const updateFileSelection = (files: File[]) => { const updateFileSelection = (files: StirlingFile[]) => {
// Map File objects to their actual IDs in FileContext const fileIds = files.map(file => file.fileId);
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
setSelectedFiles(fileIds); setSelectedFiles(fileIds);
}; };

View File

@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters'; import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection'; import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
import { StirlingFile } from '../../../types/fileContext';
interface ConvertToPdfaSettingsProps { interface ConvertToPdfaSettingsProps {
parameters: ConvertParameters; parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void; onParameterChange: (key: keyof ConvertParameters, value: any) => void;
selectedFiles: File[]; selectedFiles: StirlingFile[];
disabled?: boolean; disabled?: boolean;
} }

View File

@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
import { useFilesModalContext } from "../../../contexts/FilesModalContext"; import { useFilesModalContext } from "../../../contexts/FilesModalContext";
import { useAllFiles } from "../../../contexts/FileContext"; import { useAllFiles } from "../../../contexts/FileContext";
import { useFileManager } from "../../../hooks/useFileManager"; import { useFileManager } from "../../../hooks/useFileManager";
import { StirlingFile } from "../../../types/fileContext";
export interface FileStatusIndicatorProps { export interface FileStatusIndicatorProps {
selectedFiles?: File[]; selectedFiles?: StirlingFile[];
placeholder?: string; placeholder?: string;
} }
@ -17,7 +18,7 @@ const FileStatusIndicator = ({
}: FileStatusIndicatorProps) => { }: FileStatusIndicatorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { openFilesModal, onFilesSelect } = useFilesModalContext(); const { openFilesModal, onFilesSelect } = useFilesModalContext();
const { files: workbenchFiles } = useAllFiles(); const { files: stirlingFileStubs } = useAllFiles();
const { loadRecentFiles } = useFileManager(); const { loadRecentFiles } = useFileManager();
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null); const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
@ -55,7 +56,7 @@ const FileStatusIndicator = ({
} }
// Check if there are no files in the workbench // Check if there are no files in the workbench
if (workbenchFiles.length === 0) { if (stirlingFileStubs.length === 0) {
// If no recent files, show upload button // If no recent files, show upload button
if (!hasRecentFiles) { if (!hasRecentFiles) {
return ( return (

View File

@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import FileStatusIndicator from './FileStatusIndicator'; import FileStatusIndicator from './FileStatusIndicator';
import { StirlingFile } from '../../../types/fileContext';
export interface FilesToolStepProps { export interface FilesToolStepProps {
selectedFiles: File[]; selectedFiles: StirlingFile[];
isCollapsed?: boolean; isCollapsed?: boolean;
onCollapsedClick?: () => void; onCollapsedClick?: () => void;
placeholder?: string; placeholder?: string;

View File

@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
import OperationButton from './OperationButton'; import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation'; import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle'; import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
import { StirlingFile } from '../../../types/fileContext';
export interface FilesStepConfig { export interface FilesStepConfig {
selectedFiles: File[]; selectedFiles: StirlingFile[];
isCollapsed?: boolean; isCollapsed?: boolean;
placeholder?: string; placeholder?: string;
onCollapsedClick?: () => void; onCollapsedClick?: () => void;

View File

@ -15,6 +15,7 @@ import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader'; import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext"; import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl"; import { useFileWithUrl } from "../../hooks/useFileWithUrl";
import { isFileObject } from "../../types/fileContext";
import { FileId } from "../../types/file"; import { FileId } from "../../types/file";
@ -201,7 +202,7 @@ const Viewer = ({
const effectiveFile = React.useMemo(() => { const effectiveFile = React.useMemo(() => {
if (previewFile) { if (previewFile) {
// Validate the preview file // Validate the preview file
if (!(previewFile instanceof File)) { if (!isFileObject(previewFile)) {
return null; return null;
} }

View File

@ -19,7 +19,10 @@ import {
FileContextStateValue, FileContextStateValue,
FileContextActionsValue, FileContextActionsValue,
FileContextActions, FileContextActions,
FileRecord FileId,
StirlingFileStub,
StirlingFile,
createStirlingFile
} from '../types/fileContext'; } from '../types/fileContext';
// Import modular components // Import modular components
@ -29,7 +32,6 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions
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';
@ -79,7 +81,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; selectFiles?: boolean }): Promise<File[]> => { const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager); const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested // Auto-select the newly added files if requested
@ -98,15 +100,15 @@ function FileContextInner({
})); }));
} }
return addedFilesWithIds.map(({ file }) => file); return addedFilesWithIds.map(({ file, id }) => createStirlingFile(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<StirlingFile[]> => {
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); return result.map(({ file, id }) => createStirlingFile(file, id));
}, []); }, []);
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<File[]> => { const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => {
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager); const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested // Auto-select the newly added files if requested
@ -114,7 +116,7 @@ function FileContextInner({
selectFiles(result); selectFiles(result);
} }
return result.map(({ file }) => file); return result.map(({ file, id }) => createStirlingFile(file, id));
}, []); }, []);
// Action creators // Action creators
@ -122,11 +124,11 @@ function FileContextInner({
// Helper functions for pinned files // Helper functions for pinned files
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => { const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch, indexedDB); return consumeFiles(inputFileIds, outputFiles, filesRef, dispatch, indexedDB);
}, [indexedDB]); }, [indexedDB]);
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => { const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB); return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
}, [indexedDB]); }, [indexedDB]);
// Helper to find FileId from File object // Helper to find FileId from File object
@ -140,24 +142,14 @@ function FileContextInner({
}); });
}, []); }, []);
// File-to-ID wrapper functions for pinning // File pinning functions - use StirlingFile directly
const pinFileWrapper = useCallback((file: File) => { const pinFileWrapper = useCallback((file: StirlingFile) => {
const fileId = findFileId(file); baseActions.pinFile(file.fileId);
if (fileId) { }, [baseActions]);
baseActions.pinFile(fileId);
} else {
console.warn('File not found for pinning:', file.name);
}
}, [baseActions, findFileId]);
const unpinFileWrapper = useCallback((file: File) => { const unpinFileWrapper = useCallback((file: StirlingFile) => {
const fileId = findFileId(file); baseActions.unpinFile(file.fileId);
if (fileId) { }, [baseActions]);
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>(() => ({
@ -178,8 +170,8 @@ function FileContextInner({
} }
} }
}, },
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) => updateStirlingFileStub: (fileId: FileId, updates: Partial<StirlingFileStub>) =>
lifecycleManager.updateFileRecord(fileId, updates, stateRef), lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef),
reorderFiles: (orderedFileIds: FileId[]) => { reorderFiles: (orderedFileIds: FileId[]) => {
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } }); dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
}, },
@ -303,7 +295,7 @@ export {
useFileSelection, useFileSelection,
useFileManagement, useFileManagement,
useFileUI, useFileUI,
useFileRecord, useStirlingFileStub,
useAllFiles, useAllFiles,
useSelectedFiles, useSelectedFiles,
// Primary API hooks for tools // Primary API hooks for tools

View File

@ -6,7 +6,7 @@ import { FileId } from '../../types/file';
import { import {
FileContextState, FileContextState,
FileContextAction, FileContextAction,
FileRecord StirlingFileStub
} from '../../types/fileContext'; } from '../../types/fileContext';
// Initial state // Initial state
@ -29,7 +29,7 @@ export const initialFileContextState: FileContextState = {
function processFileSwap( function processFileSwap(
state: FileContextState, state: FileContextState,
filesToRemove: FileId[], filesToRemove: FileId[],
filesToAdd: FileRecord[] filesToAdd: StirlingFileStub[]
): FileContextState { ): FileContextState {
// Only remove unpinned files // Only remove unpinned files
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id)); const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
@ -70,11 +70,11 @@ function processFileSwap(
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
switch (action.type) { switch (action.type) {
case 'ADD_FILES': { case 'ADD_FILES': {
const { fileRecords } = action.payload; const { stirlingFileStubs } = action.payload;
const newIds: FileId[] = []; const newIds: FileId[] = [];
const newById: Record<FileId, FileRecord> = { ...state.files.byId }; const newById: Record<FileId, StirlingFileStub> = { ...state.files.byId };
fileRecords.forEach(record => { stirlingFileStubs.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]) {
newIds.push(record.id); newIds.push(record.id);
@ -233,13 +233,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
case 'CONSUME_FILES': { case 'CONSUME_FILES': {
const { inputFileIds, outputFileRecords } = action.payload; const { inputFileIds, outputStirlingFileStubs } = action.payload;
return processFileSwap(state, inputFileIds, outputFileRecords); return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
} }
case 'UNDO_CONSUME_FILES': { case 'UNDO_CONSUME_FILES': {
const { inputFileRecords, outputFileIds } = action.payload; const { inputStirlingFileStubs, outputFileIds } = action.payload;
return processFileSwap(state, outputFileIds, inputFileRecords); return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
} }
case 'RESET_CONTEXT': { case 'RESET_CONTEXT': {

View File

@ -3,10 +3,10 @@
*/ */
import { import {
FileRecord, StirlingFileStub,
FileContextAction, FileContextAction,
FileContextState, FileContextState,
toFileRecord, toStirlingFileStub,
createFileId, createFileId,
createQuickKey createQuickKey
} from '../../types/fileContext'; } from '../../types/fileContext';
@ -109,8 +109,8 @@ export async function addFiles(
await addFilesMutex.lock(); await addFilesMutex.lock();
try { try {
const fileRecords: FileRecord[] = []; const stirlingFileStubs: StirlingFileStub[] = [];
const addedFiles: AddedFile[] = []; const addedFiles: AddedFile[] = [];
// 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);
@ -163,7 +163,7 @@ export async function addFiles(
} }
// Create record with immediate thumbnail and page metadata // Create record with immediate thumbnail and page metadata
const record = toFileRecord(file, fileId); const record = toStirlingFileStub(file, fileId);
if (thumbnail) { if (thumbnail) {
record.thumbnailUrl = thumbnail; record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation) // Track blob URLs for cleanup (images return blob URLs that need revocation)
@ -184,7 +184,7 @@ export async function addFiles(
} }
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); stirlingFileStubs.push(record);
addedFiles.push({ file, id: fileId, thumbnail }); addedFiles.push({ file, id: fileId, thumbnail });
} }
break; break;
@ -205,7 +205,7 @@ export async function addFiles(
const fileId = createFileId(); const fileId = createFileId();
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId); const record = toStirlingFileStub(file, fileId);
if (thumbnail) { if (thumbnail) {
record.thumbnailUrl = thumbnail; record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation) // Track blob URLs for cleanup (images return blob URLs that need revocation)
@ -226,7 +226,7 @@ export async function addFiles(
} }
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); stirlingFileStubs.push(record);
addedFiles.push({ file, id: fileId, thumbnail }); addedFiles.push({ file, id: fileId, thumbnail });
} }
break; break;
@ -254,7 +254,7 @@ export async function addFiles(
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId); const record = toStirlingFileStub(file, fileId);
// Generate processedFile metadata for stored files // Generate processedFile metadata for stored files
let pageCount: number = 1; let pageCount: number = 1;
@ -301,7 +301,7 @@ export async function addFiles(
} }
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); stirlingFileStubs.push(record);
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail }); addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
} }
@ -310,9 +310,9 @@ export async function addFiles(
} }
// Dispatch ADD_FILES action if we have new files // Dispatch ADD_FILES action if we have new files
if (fileRecords.length > 0) { if (stirlingFileStubs.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { fileRecords } }); dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`); if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${stirlingFileStubs.length} files`);
} }
return addedFiles; return addedFiles;
@ -328,7 +328,7 @@ export async function addFiles(
async function processFilesIntoRecords( async function processFilesIntoRecords(
files: File[], files: File[],
filesRef: React.MutableRefObject<Map<FileId, File>> filesRef: React.MutableRefObject<Map<FileId, File>>
): Promise<Array<{ record: FileRecord; file: File; fileId: FileId; thumbnail?: string }>> { ): Promise<Array<{ record: StirlingFileStub; file: File; fileId: FileId; thumbnail?: string }>> {
return Promise.all( return Promise.all(
files.map(async (file) => { files.map(async (file) => {
const fileId = createFileId(); const fileId = createFileId();
@ -347,7 +347,7 @@ async function processFilesIntoRecords(
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error); if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
} }
const record = toFileRecord(file, fileId); const record = toStirlingFileStub(file, fileId);
if (thumbnail) { if (thumbnail) {
record.thumbnailUrl = thumbnail; record.thumbnailUrl = thumbnail;
} }
@ -365,10 +365,10 @@ async function processFilesIntoRecords(
* Helper function to persist files to IndexedDB * Helper function to persist files to IndexedDB
*/ */
async function persistFilesToIndexedDB( async function persistFilesToIndexedDB(
fileRecords: Array<{ file: File; fileId: FileId; thumbnail?: string }>, stirlingFileStubs: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
): Promise<void> { ): Promise<void> {
await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => { await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => {
try { try {
await indexedDB.saveFile(file, fileId, thumbnail); await indexedDB.saveFile(file, fileId, thumbnail);
} catch (error) { } catch (error) {
@ -383,7 +383,6 @@ async function persistFilesToIndexedDB(
export async function consumeFiles( export async function consumeFiles(
inputFileIds: FileId[], inputFileIds: FileId[],
outputFiles: File[], outputFiles: File[],
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>, filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>, dispatch: React.Dispatch<FileContextAction>,
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
@ -391,11 +390,11 @@ export async function consumeFiles(
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 with thumbnails and metadata // Process output files with thumbnails and metadata
const outputFileRecords = await processFilesIntoRecords(outputFiles, filesRef); const outputStirlingFileStubs = await processFilesIntoRecords(outputFiles, filesRef);
// Persist output files to IndexedDB if available // Persist output files to IndexedDB if available
if (indexedDB) { if (indexedDB) {
await persistFilesToIndexedDB(outputFileRecords, indexedDB); await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB);
} }
// Dispatch the consume action // Dispatch the consume action
@ -403,21 +402,21 @@ export async function consumeFiles(
type: 'CONSUME_FILES', type: 'CONSUME_FILES',
payload: { payload: {
inputFileIds, inputFileIds,
outputFileRecords: outputFileRecords.map(({ record }) => record) outputStirlingFileStubs: outputStirlingFileStubs.map(({ record }) => record)
} }
}); });
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`); if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`);
// Return the output file IDs for undo tracking // Return the output file IDs for undo tracking
return outputFileRecords.map(({ fileId }) => fileId); return outputStirlingFileStubs.map(({ fileId }) => fileId);
} }
/** /**
* Helper function to restore files to filesRef and manage IndexedDB cleanup * Helper function to restore files to filesRef and manage IndexedDB cleanup
*/ */
async function restoreFilesAndCleanup( async function restoreFilesAndCleanup(
filesToRestore: Array<{ file: File; record: FileRecord }>, filesToRestore: Array<{ file: File; record: StirlingFileStub }>,
fileIdsToRemove: FileId[], fileIdsToRemove: FileId[],
filesRef: React.MutableRefObject<Map<FileId, File>>, filesRef: React.MutableRefObject<Map<FileId, File>>,
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
@ -440,7 +439,7 @@ async function restoreFilesAndCleanup(
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`); if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
return; return;
} }
// Restore the file to filesRef // Restore the file to filesRef
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`); if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
filesRef.current.set(record.id, file); filesRef.current.set(record.id, file);
@ -455,7 +454,7 @@ async function restoreFilesAndCleanup(
throw error; // Re-throw to trigger rollback throw error; // Re-throw to trigger rollback
}) })
); );
// Execute all IndexedDB operations // Execute all IndexedDB operations
await Promise.all(indexedDBPromises); await Promise.all(indexedDBPromises);
} }
@ -466,28 +465,28 @@ async function restoreFilesAndCleanup(
*/ */
export async function undoConsumeFiles( export async function undoConsumeFiles(
inputFiles: File[], inputFiles: File[],
inputFileRecords: FileRecord[], inputStirlingFileStubs: StirlingFileStub[],
outputFileIds: FileId[], outputFileIds: FileId[],
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>,
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
): Promise<void> { ): Promise<void> {
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputFileRecords.length} input files, removing ${outputFileIds.length} output files`); if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputStirlingFileStubs.length} input files, removing ${outputFileIds.length} output files`);
// Validate inputs // Validate inputs
if (inputFiles.length !== inputFileRecords.length) { if (inputFiles.length !== inputStirlingFileStubs.length) {
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`); throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputStirlingFileStubs.length})`);
} }
// Create a backup of current filesRef state for rollback // Create a backup of current filesRef state for rollback
const backupFilesRef = new Map(filesRef.current); const backupFilesRef = new Map(filesRef.current);
try { try {
// Prepare files to restore // Prepare files to restore
const filesToRestore = inputFiles.map((file, index) => ({ const filesToRestore = inputFiles.map((file, index) => ({
file, file,
record: inputFileRecords[index] record: inputStirlingFileStubs[index]
})); }));
// Restore input files and clean up output files // Restore input files and clean up output files
@ -502,13 +501,13 @@ export async function undoConsumeFiles(
dispatch({ dispatch({
type: 'UNDO_CONSUME_FILES', type: 'UNDO_CONSUME_FILES',
payload: { payload: {
inputFileRecords, inputStirlingFileStubs,
outputFileIds outputFileIds
} }
}); });
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputFileRecords.length} inputs, removed ${outputFileIds.length} outputs`); if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputStirlingFileStubs.length} inputs, removed ${outputFileIds.length} outputs`);
} catch (error) { } catch (error) {
// Rollback filesRef to previous state // Rollback filesRef to previous state
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error); if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);

View File

@ -9,7 +9,7 @@ import {
FileContextStateValue, FileContextStateValue,
FileContextActionsValue FileContextActionsValue
} from './contexts'; } from './contexts';
import { FileRecord } from '../../types/fileContext'; import { StirlingFileStub, StirlingFile } from '../../types/fileContext';
import { FileId } from '../../types/file'; import { FileId } from '../../types/file';
/** /**
@ -38,13 +38,13 @@ export function useFileActions(): FileContextActionsValue {
/** /**
* Hook for current/primary file (first in list) * Hook for current/primary file (first in list)
*/ */
export function useCurrentFile(): { file?: File; record?: FileRecord } { export function useCurrentFile(): { file?: File; record?: StirlingFileStub } {
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,
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined record: primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : undefined
}), [primaryFileId, selectors]); }), [primaryFileId, selectors]);
} }
@ -87,7 +87,7 @@ export function useFileManagement() {
addFiles: actions.addFiles, addFiles: actions.addFiles,
removeFiles: actions.removeFiles, removeFiles: actions.removeFiles,
clearAllFiles: actions.clearAllFiles, clearAllFiles: actions.clearAllFiles,
updateFileRecord: actions.updateFileRecord, updateStirlingFileStub: actions.updateStirlingFileStub,
reorderFiles: actions.reorderFiles reorderFiles: actions.reorderFiles
}), [actions]); }), [actions]);
} }
@ -111,24 +111,24 @@ export function useFileUI() {
/** /**
* Hook for specific file by ID (optimized for individual file access) * Hook for specific file by ID (optimized for individual file access)
*/ */
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } { export function useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub } {
const { selectors } = useFileState(); const { selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
file: selectors.getFile(fileId), file: selectors.getFile(fileId),
record: selectors.getFileRecord(fileId) record: selectors.getStirlingFileStub(fileId)
}), [fileId, selectors]); }), [fileId, selectors]);
} }
/** /**
* Hook for all files (use sparingly - causes re-renders on file list changes) * 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: StirlingFile[]; records: StirlingFileStub[]; 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.getStirlingFileStubs(),
fileIds: state.files.ids fileIds: state.files.ids
}), [state.files.ids, selectors]); }), [state.files.ids, selectors]);
} }
@ -136,12 +136,12 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds:
/** /**
* Hook for selected files (optimized for selection-based UI) * Hook for selected files (optimized for selection-based UI)
*/ */
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } { export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; 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.getSelectedStirlingFileStubs(),
fileIds: state.ui.selectedFileIds fileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]); }), [state.ui.selectedFileIds, selectors]);
} }

View File

@ -4,9 +4,11 @@
import { FileId } from '../../types/file'; import { FileId } from '../../types/file';
import { import {
FileRecord, StirlingFileStub,
FileContextState, FileContextState,
FileContextSelectors FileContextSelectors,
StirlingFile,
createStirlingFile
} from '../../types/fileContext'; } from '../../types/fileContext';
/** /**
@ -17,16 +19,24 @@ 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 ? createStirlingFile(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 ? createStirlingFile(file, id) : undefined;
})
.filter(Boolean) as StirlingFile[];
}, },
getFileRecord: (id: FileId) => stateRef.current.files.byId[id], getStirlingFileStub: (id: FileId) => stateRef.current.files.byId[id],
getFileRecords: (ids?: FileId[]) => { getStirlingFileStubs: (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);
}, },
@ -35,11 +45,14 @@ 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 ? createStirlingFile(file, id) : undefined;
})
.filter(Boolean) as StirlingFile[];
}, },
getSelectedFileRecords: () => { getSelectedStirlingFileStubs: () => {
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);
@ -52,26 +65,21 @@ 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 ? createStirlingFile(file, id) : undefined;
})
.filter(Boolean) as StirlingFile[];
}, },
getPinnedFileRecords: () => { getPinnedStirlingFileStubs: () => {
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: StirlingFile) => {
// Find FileId by matching File object properties return stateRef.current.pinnedFiles.has(file.fileId);
const fileId = (Object.keys(stateRef.current.files.byId) as FileId[]).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
@ -90,9 +98,9 @@ export function createFileSelectors(
/** /**
* Helper for building quickKey sets for deduplication * Helper for building quickKey sets for deduplication
*/ */
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> { export function buildQuickKeySet(stirlingFileStubs: Record<FileId, StirlingFileStub>): Set<string> {
const quickKeys = new Set<string>(); const quickKeys = new Set<string>();
Object.values(fileRecords).forEach(record => { Object.values(stirlingFileStubs).forEach(record => {
if (record.quickKey) { if (record.quickKey) {
quickKeys.add(record.quickKey); quickKeys.add(record.quickKey);
} }
@ -119,7 +127,7 @@ 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?: File; record?: StirlingFileStub } {
const primaryFileId = stateRef.current.files.ids[0]; const primaryFileId = stateRef.current.files.ids[0];
if (!primaryFileId) return {}; if (!primaryFileId) return {};

View File

@ -3,7 +3,7 @@
*/ */
import { FileId } from '../../types/file'; import { FileId } from '../../types/file';
import { FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext'; import { FileContextAction, StirlingFileStub, ProcessedFilePage } from '../../types/fileContext';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
@ -166,7 +166,7 @@ export class FileLifecycleManager {
/** /**
* Update file record with race condition guards * Update file record with race condition guards
*/ */
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => { updateStirlingFileStub = (fileId: FileId, updates: Partial<StirlingFileStub>, stateRef?: React.MutableRefObject<any>): void => {
// Guard against updating removed files (race condition protection) // Guard against updating removed files (race condition protection)
if (!this.filesRef.current.has(fileId)) { if (!this.filesRef.current.has(fileId)) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`); if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);

View File

@ -4,10 +4,11 @@ import { useEndpointEnabled } from '../../useEndpointConfig';
import { BaseToolProps } from '../../../types/tool'; import { BaseToolProps } from '../../../types/tool';
import { ToolOperationHook } from './useToolOperation'; import { ToolOperationHook } from './useToolOperation';
import { BaseParametersHook } from './useBaseParameters'; import { BaseParametersHook } from './useBaseParameters';
import { StirlingFile } from '../../../types/fileContext';
interface BaseToolReturn<TParams> { interface BaseToolReturn<TParams> {
// File management // File management
selectedFiles: File[]; selectedFiles: StirlingFile[];
// Tool-specific hooks // Tool-specific hooks
params: BaseParametersHook<TParams>; params: BaseParametersHook<TParams>;

View File

@ -6,10 +6,8 @@ import { useToolState, type ProcessingProgress } from './useToolState';
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls'; import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
import { useToolResources } from './useToolResources'; import { useToolResources } from './useToolResources';
import { extractErrorMessage } from '../../../utils/toolErrorHandler'; import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { createOperation } from '../../../utils/toolOperationTracker'; import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext';
import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { ResponseHandler } from '../../../utils/toolResponseProcessor';
import { FileId } from '../../../types/file';
import { FileRecord } from '../../../types/fileContext';
// Re-export for backwards compatibility // Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler }; export type { ProcessingProgress, ResponseHandler };
@ -104,7 +102,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: StirlingFile[]) => Promise<void>;
resetResults: () => void; resetResults: () => void;
clearError: () => void; clearError: () => void;
cancelOperation: () => void; cancelOperation: () => void;
@ -130,7 +128,7 @@ export const useToolOperation = <TParams>(
config: ToolOperationConfig<TParams> config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => { ): ToolOperationHook<TParams> => {
const { t } = useTranslation(); const { t } = useTranslation();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext(); const { addFiles, consumeFiles, undoConsumeFiles, actions: fileActions, selectors } = useFileContext();
// Composed hooks // Composed hooks
const { state, actions } = useToolState(); const { state, actions } = useToolState();
@ -140,13 +138,13 @@ export const useToolOperation = <TParams>(
// Track last operation for undo functionality // Track last operation for undo functionality
const lastOperationRef = useRef<{ const lastOperationRef = useRef<{
inputFiles: File[]; inputFiles: File[];
inputFileRecords: FileRecord[]; inputStirlingFileStubs: StirlingFileStub[];
outputFileIds: FileId[]; outputFileIds: FileId[];
} | null>(null); } | null>(null);
const executeOperation = useCallback(async ( const executeOperation = useCallback(async (
params: TParams, params: TParams,
selectedFiles: File[] selectedFiles: StirlingFile[]
): Promise<void> => { ): Promise<void> => {
// Validation // Validation
if (selectedFiles.length === 0) { if (selectedFiles.length === 0) {
@ -160,9 +158,6 @@ export const useToolOperation = <TParams>(
return; return;
} }
// Setup operation tracking
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
recordOperation(fileId, operation);
// Reset state // Reset state
actions.setLoading(true); actions.setLoading(true);
@ -173,6 +168,9 @@ export const useToolOperation = <TParams>(
try { try {
let processedFiles: File[]; let processedFiles: File[];
// Convert StirlingFile to regular File objects for API processing
const validRegularFiles = extractFiles(validFiles);
switch (config.toolType) { switch (config.toolType) {
case ToolType.singleFile: { case ToolType.singleFile: {
// Individual file processing - separate API call per file // Individual file processing - separate API call per file
@ -184,7 +182,7 @@ export const useToolOperation = <TParams>(
}; };
processedFiles = await processFiles( processedFiles = await processFiles(
params, params,
validFiles, validRegularFiles,
apiCallsConfig, apiCallsConfig,
actions.setProgress, actions.setProgress,
actions.setStatus actions.setStatus
@ -195,7 +193,7 @@ export const useToolOperation = <TParams>(
case ToolType.multiFile: { 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(params, validFiles); const formData = config.buildFormData(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' });
@ -203,11 +201,11 @@ export const useToolOperation = <TParams>(
// 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 {
@ -224,7 +222,7 @@ export const useToolOperation = <TParams>(
case ToolType.custom: case ToolType.custom:
actions.setStatus('Processing files...'); actions.setStatus('Processing files...');
processedFiles = await config.customProcessor(params, validFiles); processedFiles = await config.customProcessor(params, validRegularFiles);
break; break;
} }
@ -244,21 +242,17 @@ export const useToolOperation = <TParams>(
// Replace input files with processed files (consumeFiles handles pinning) // Replace input files with processed files (consumeFiles handles pinning)
const inputFileIds: FileId[] = []; const inputFileIds: FileId[] = [];
const inputFileRecords: FileRecord[] = []; const inputStirlingFileStubs: StirlingFileStub[] = [];
// Build parallel arrays of IDs and records for undo tracking // Build parallel arrays of IDs and records for undo tracking
for (const file of validFiles) { for (const file of validFiles) {
const fileId = findFileId(file); const fileId = file.fileId;
if (fileId) { const record = selectors.getStirlingFileStub(fileId);
const record = selectors.getFileRecord(fileId); if (record) {
if (record) { inputFileIds.push(fileId);
inputFileIds.push(fileId); inputStirlingFileStubs.push(record);
inputFileRecords.push(record);
} else {
console.warn(`No file record found for file: ${file.name}`);
}
} else { } else {
console.warn(`No file ID found for file: ${file.name}`); console.warn(`No file stub found for file: ${file.name}`);
} }
} }
@ -266,24 +260,22 @@ export const useToolOperation = <TParams>(
// Store operation data for undo (only store what we need to avoid memory bloat) // Store operation data for undo (only store what we need to avoid memory bloat)
lastOperationRef.current = { lastOperationRef.current = {
inputFiles: validFiles, // Keep original File objects for undo inputFiles: extractFiles(validFiles), // Convert to File objects for undo
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues inputStirlingFileStubs: inputStirlingFileStubs.map(record => ({ ...record })), // Deep copy to avoid reference issues
outputFileIds outputFileIds
}; };
markOperationApplied(fileId, operationId);
} }
} catch (error: any) { } catch (error: any) {
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error); const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
actions.setError(errorMessage); actions.setError(errorMessage);
actions.setStatus(''); actions.setStatus('');
markOperationFailed(fileId, operationId, errorMessage);
} finally { } finally {
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, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
const cancelOperation = useCallback(() => { const cancelOperation = useCallback(() => {
cancelApiCalls(); cancelApiCalls();
@ -312,10 +304,10 @@ export const useToolOperation = <TParams>(
return; return;
} }
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current; const { inputFiles, inputStirlingFileStubs, outputFileIds } = lastOperationRef.current;
// Validate that we have data to undo // Validate that we have data to undo
if (inputFiles.length === 0 || inputFileRecords.length === 0) { if (inputFiles.length === 0 || inputStirlingFileStubs.length === 0) {
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data')); actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
return; return;
} }
@ -327,7 +319,8 @@ export const useToolOperation = <TParams>(
try { try {
// Undo the consume operation // Undo the consume operation
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds); await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
// Clear results and operation tracking // Clear results and operation tracking
resetResults(); resetResults();

View File

@ -2,7 +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'; import { FileId } from '../types/fileContext';
export const useFileManager = () => { export const useFileManager = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);

View File

@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { isFileObject } from '../types/fileContext';
/** /**
* Hook to convert a File object to { file: File; url: string } format * Hook to convert a File object to { file: File; url: string } format
@ -8,8 +9,8 @@ export function useFileWithUrl(file: File | Blob | null): { file: File | Blob; u
return useMemo(() => { return useMemo(() => {
if (!file) return null; if (!file) return null;
// Validate that file is a proper File or Blob object // Validate that file is a proper File, StirlingFile, or Blob object
if (!(file instanceof File) && !(file instanceof Blob)) { if (!isFileObject(file) && !(file instanceof Blob)) {
console.warn('useFileWithUrl: Expected File or Blob, got:', file); console.warn('useFileWithUrl: Expected File or Blob, got:', file);
return null; return null;
} }

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { FileMetadata } from "../types/file"; import { FileMetadata } from "../types/file";
import { useIndexedDB } from "../contexts/IndexedDBContext"; import { useIndexedDB } from "../contexts/IndexedDBContext";
import { generateThumbnailForFile } from "../utils/thumbnailUtils"; import { generateThumbnailForFile } from "../utils/thumbnailUtils";
import { FileId } from "../types/fileContext";
/** /**
* Calculate optimal scale for thumbnail generation * Calculate optimal scale for thumbnail generation
@ -53,7 +54,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
// Try to load file from IndexedDB using new context // Try to load file from IndexedDB using new context
if (file.id && indexedDB) { if (file.id && indexedDB) {
const loadedFile = await indexedDB.loadFile(file.id); const loadedFile = await indexedDB.loadFile(file.id as FileId);
if (!loadedFile) { if (!loadedFile) {
throw new Error('File not found in IndexedDB'); throw new Error('File not found in IndexedDB');
} }
@ -70,7 +71,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
// Save thumbnail to IndexedDB for persistence // Save thumbnail to IndexedDB for persistence
if (file.id && indexedDB && thumbnail) { if (file.id && indexedDB && thumbnail) {
try { try {
await indexedDB.updateThumbnail(file.id, thumbnail); await indexedDB.updateThumbnail(file.id as FileId, thumbnail);
} catch (error) { } catch (error) {
console.warn('Failed to save thumbnail to IndexedDB:', error); console.warn('Failed to save thumbnail to IndexedDB:', error);
} }

View File

@ -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

View File

@ -1,13 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import { pdfWorkerManager } from '../services/pdfWorkerManager'; import { pdfWorkerManager } from '../services/pdfWorkerManager';
import { StirlingFile } from '../types/fileContext';
export interface PdfSignatureDetectionResult { export interface PdfSignatureDetectionResult {
hasDigitalSignatures: boolean; hasDigitalSignatures: boolean;
isChecking: boolean; isChecking: boolean;
} }
export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => { export const usePdfSignatureDetection = (files: StirlingFile[]): PdfSignatureDetectionResult => {
const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false); const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false);
const [isChecking, setIsChecking] = useState(false); const [isChecking, setIsChecking] = useState(false);

View File

@ -1,5 +1,6 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
import { createQuickKey } from '../types/fileContext';
import { FileId } from '../types/file'; import { FileId } from '../types/file';
// Request queue to handle concurrent thumbnail requests // Request queue to handle concurrent thumbnail requests
@ -71,8 +72,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 as FileId; const fileId = createQuickKey(file) as FileId;
const results = await thumbnailGenerationService.generateThumbnails( const results = await thumbnailGenerationService.generateThumbnails(
fileId, fileId,

View File

@ -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,

View File

@ -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,

View File

@ -18,6 +18,8 @@ import { FileContextProvider } from '../../contexts/FileContext';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n/config'; import i18n from '../../i18n/config';
import axios from 'axios'; import axios from 'axios';
import { createTestStirlingFile } from '../utils/testFileHelpers';
import { StirlingFile } from '../../types/fileContext';
// Mock axios // Mock axios
vi.mock('axios'); vi.mock('axios');
@ -55,9 +57,9 @@ const createTestFile = (name: string, content: string, type: string): File => {
return new File([content], name, { type }); return new File([content], name, { type });
}; };
const createPDFFile = (): File => { const createPDFFile = (): StirlingFile => {
const pdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\ntrailer\n<<\n/Size 2\n/Root 1 0 R\n>>\nstartxref\n0\n%%EOF'; const pdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\ntrailer\n<<\n/Size 2\n/Root 1 0 R\n>>\nstartxref\n0\n%%EOF';
return createTestFile('test.pdf', pdfContent, 'application/pdf'); return createTestStirlingFile('test.pdf', pdfContent, 'application/pdf');
}; };
// Test wrapper component // Test wrapper component
@ -162,7 +164,7 @@ describe('Convert Tool Integration Tests', () => {
wrapper: TestWrapper wrapper: TestWrapper
}); });
const testFile = createTestFile('invalid.txt', 'not a pdf', 'text/plain'); const testFile = createTestStirlingFile('invalid.txt', 'not a pdf', 'text/plain');
const parameters: ConvertParameters = { const parameters: ConvertParameters = {
fromExtension: 'pdf', fromExtension: 'pdf',
toExtension: 'png', toExtension: 'png',
@ -426,7 +428,7 @@ describe('Convert Tool Integration Tests', () => {
}); });
const files = [ const files = [
createPDFFile(), createPDFFile(),
createTestFile('test2.pdf', '%PDF-1.4...', 'application/pdf') createTestStirlingFile('test2.pdf', '%PDF-1.4...', 'application/pdf')
] ]
const parameters: ConvertParameters = { const parameters: ConvertParameters = {
fromExtension: 'pdf', fromExtension: 'pdf',
@ -527,7 +529,7 @@ describe('Convert Tool Integration Tests', () => {
wrapper: TestWrapper wrapper: TestWrapper
}); });
const corruptedFile = createTestFile('corrupted.pdf', 'not-a-pdf', 'application/pdf'); const corruptedFile = createTestStirlingFile('corrupted.pdf', 'not-a-pdf', 'application/pdf');
const parameters: ConvertParameters = { const parameters: ConvertParameters = {
fromExtension: 'pdf', fromExtension: 'pdf',
toExtension: 'png', toExtension: 'png',

View File

@ -14,6 +14,8 @@ import i18n from '../../i18n/config';
import axios from 'axios'; import axios from 'axios';
import { detectFileExtension } from '../../utils/fileUtils'; import { detectFileExtension } from '../../utils/fileUtils';
import { FIT_OPTIONS } from '../../constants/convertConstants'; import { FIT_OPTIONS } from '../../constants/convertConstants';
import { createTestStirlingFile, createTestFilesWithId } from '../utils/testFileHelpers';
import { StirlingFile } from '../../types/fileContext';
// Mock axios // Mock axios
vi.mock('axios'); vi.mock('axios');
@ -81,7 +83,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
}); });
// Create mock DOCX file // Create mock DOCX file
const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); const docxFile = createTestStirlingFile('document.docx', 'docx content', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
// Test auto-detection // Test auto-detection
act(() => { act(() => {
@ -117,7 +119,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
}); });
// Create mock unknown file // Create mock unknown file
const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' }); const unknownFile = createTestStirlingFile('document.xyz', 'unknown content', 'application/octet-stream');
// Test auto-detection // Test auto-detection
act(() => { act(() => {
@ -156,11 +158,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
}); });
// Create mock image files // Create mock image files
const imageFiles = [ const imageFiles = createTestFilesWithId([
new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }), { name: 'photo1.jpg', content: 'jpg content', type: 'image/jpeg' },
new File(['png content'], 'photo2.png', { type: 'image/png' }), { name: 'photo2.png', content: 'png content', type: 'image/png' },
new File(['gif content'], 'photo3.gif', { type: 'image/gif' }) { name: 'photo3.gif', content: 'gif content', type: 'image/gif' }
]; ]);
// Test smart detection for all images // Test smart detection for all images
act(() => { act(() => {
@ -202,11 +204,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
}); });
// Create mixed file types // Create mixed file types
const mixedFiles = [ const mixedFiles = createTestFilesWithId([
new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }), { name: 'document.pdf', content: 'pdf content', type: 'application/pdf' },
new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), { name: 'spreadsheet.xlsx', content: 'docx content', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }) { name: 'presentation.pptx', content: 'pptx content', type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }
]; ]);
// Test smart detection for mixed types // Test smart detection for mixed types
act(() => { act(() => {
@ -243,10 +245,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
}); });
// Create mock web files // Create mock web files
const webFiles = [ const webFiles = createTestFilesWithId([
new File(['<html>content</html>'], 'page1.html', { type: 'text/html' }), { name: 'page1.html', content: '<html>content</html>', type: 'text/html' },
new File(['zip content'], 'site.zip', { type: 'application/zip' }) { name: 'site.zip', content: 'zip content', type: 'application/zip' }
]; ]);
// Test smart detection for web files // Test smart detection for web files
act(() => { act(() => {
@ -288,7 +290,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper wrapper: TestWrapper
}); });
const htmlFile = new File(['<html>content</html>'], 'page.html', { type: 'text/html' }); const htmlFile = createTestStirlingFile('page.html', '<html>content</html>', 'text/html');
// Set up HTML conversion parameters // Set up HTML conversion parameters
act(() => { act(() => {
@ -318,7 +320,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper wrapper: TestWrapper
}); });
const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' }); const emlFile = createTestStirlingFile('email.eml', 'email content', 'message/rfc822');
// Set up email conversion parameters // Set up email conversion parameters
act(() => { act(() => {
@ -355,7 +357,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper wrapper: TestWrapper
}); });
const pdfFile = new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }); const pdfFile = createTestStirlingFile('document.pdf', 'pdf content', 'application/pdf');
// Set up PDF/A conversion parameters // Set up PDF/A conversion parameters
act(() => { act(() => {
@ -392,10 +394,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper wrapper: TestWrapper
}); });
const imageFiles = [ const imageFiles = createTestFilesWithId([
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }), { name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' }) { name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
]; ]);
// Set up image conversion parameters // Set up image conversion parameters
act(() => { act(() => {
@ -432,10 +434,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper wrapper: TestWrapper
}); });
const imageFiles = [ const imageFiles = createTestFilesWithId([
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }), { name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' }) { name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
]; ]);
// Set up for separate processing // Set up for separate processing
act(() => { act(() => {
@ -477,10 +479,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
}) })
.mockRejectedValueOnce(new Error('File 2 failed')); .mockRejectedValueOnce(new Error('File 2 failed'));
const mixedFiles = [ const mixedFiles = createTestFilesWithId([
new File(['file1'], 'doc1.txt', { type: 'text/plain' }), { name: 'doc1.txt', content: 'file1', type: 'text/plain' },
new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' }) { name: 'doc2.xyz', content: 'file2', type: 'application/octet-stream' }
]; ]);
// Set up for separate processing (mixed smart detection) // Set up for separate processing (mixed smart detection)
act(() => { act(() => {

View File

@ -0,0 +1,28 @@
/**
* Test utilities for creating StirlingFile objects in tests
*/
import { StirlingFile, createStirlingFile } from '../../types/fileContext';
/**
* Create a StirlingFile object for testing purposes
*/
export function createTestStirlingFile(
name: string,
content: string = 'test content',
type: string = 'application/pdf'
): StirlingFile {
const file = new File([content], name, { type });
return createStirlingFile(file);
}
/**
* Create multiple StirlingFile objects for testing
*/
export function createTestFilesWithId(
files: Array<{ name: string; content?: string; type?: string }>
): StirlingFile[] {
return files.map(({ name, content = 'test content', type = 'application/pdf' }) =>
createTestStirlingFile(name, content, type)
);
}

View File

@ -5,6 +5,9 @@
import { PageOperation } from './pageEditor'; import { PageOperation } from './pageEditor';
import { FileId, FileMetadata } from './file'; import { FileId, FileMetadata } from './file';
// Re-export FileId for convenience
export type { FileId };
export type ModeType = export type ModeType =
| 'viewer' | 'viewer'
| 'pageEditor' | 'pageEditor'
@ -41,25 +44,32 @@ export interface ProcessedFileMetadata {
[key: string]: any; [key: string]: any;
} }
export interface FileRecord { /**
id: FileId; * StirlingFileStub - Metadata record for files in the active workbench session
name: string; *
size: number; * Contains UI display data and processing state. Actual File objects stored
type: string; * separately in refs for memory efficiency. Supports multi-tool workflows
lastModified: number; * where files persist across tool operations.
quickKey?: string; // Fast deduplication key: name|size|lastModified */
thumbnailUrl?: string; export interface StirlingFileStub {
blobUrl?: string; id: FileId; // UUID primary key for collision-free operations
createdAt?: number; name: string; // Display name for UI
processedFile?: ProcessedFileMetadata; size: number; // File size for progress indicators
insertAfterPageId?: string; // Page ID after which this file should be inserted type: string; // MIME type for format validation
isPinned?: boolean; lastModified: number; // Original timestamp for deduplication
quickKey?: string; // Fast deduplication key: name|size|lastModified
thumbnailUrl?: string; // Generated thumbnail blob URL for visual display
blobUrl?: string; // File access blob URL for downloads/processing
createdAt?: number; // When added to workbench for sorting
processedFile?: ProcessedFileMetadata; // PDF page data and processing results
insertAfterPageId?: string; // Page ID after which this file should be inserted
isPinned?: boolean; // Protected from tool consumption (replace/remove)
// Note: File object stored in provider ref, not in state // Note: File object stored in provider ref, not in state
} }
export interface FileContextNormalizedFiles { export interface FileContextNormalizedFiles {
ids: FileId[]; ids: FileId[];
byId: Record<FileId, FileRecord>; byId: Record<FileId, StirlingFileStub>;
} }
// Helper functions - UUID-based primary keys (zero collisions, synchronous) // Helper functions - UUID-based primary keys (zero collisions, synchronous)
@ -82,9 +92,68 @@ export function createQuickKey(file: File): string {
return `${file.name}|${file.size}|${file.lastModified}`; return `${file.name}|${file.size}|${file.lastModified}`;
} }
// Stirling PDF file with embedded UUID - replaces loose File + FileId parameter passing
export interface StirlingFile 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 isStirlingFile(file: File): file is StirlingFile {
return 'fileId' in file && typeof (file as any).fileId === 'string' &&
'quickKey' in file && typeof (file as any).quickKey === 'string';
}
// Create a StirlingFile from a regular File object
export function createStirlingFile(file: File, id?: FileId): StirlingFile {
const fileId = id || createFileId();
const quickKey = createQuickKey(file);
// Use Object.defineProperty to add properties while preserving the original File object
// This maintains proper method binding and avoids "Illegal invocation" errors
Object.defineProperty(file, 'fileId', {
value: fileId,
writable: false,
enumerable: true,
configurable: false
});
Object.defineProperty(file, 'quickKey', {
value: quickKey,
writable: false,
enumerable: true,
configurable: false
});
return file as StirlingFile;
}
// Extract FileIds from StirlingFile array
export function extractFileIds(files: StirlingFile[]): FileId[] {
return files.map(file => file.fileId);
}
// Extract regular File objects from StirlingFile array
export function extractFiles(files: StirlingFile[]): File[] {
return files as File[];
}
// Check if an object is a File or StirlingFile (replaces instanceof File checks)
export function isFileObject(obj: any): obj is File | StirlingFile {
return obj &&
typeof obj.name === 'string' &&
typeof obj.size === 'number' &&
typeof obj.type === 'string' &&
typeof obj.lastModified === 'number' &&
typeof obj.arrayBuffer === 'function';
}
export function toFileRecord(file: File, id?: FileId): FileRecord {
export function toStirlingFileStub(
file: File,
id?: FileId
): StirlingFileStub {
const fileId = id || createFileId(); const fileId = id || createFileId();
return { return {
id: fileId, id: fileId,
@ -97,7 +166,7 @@ export function toFileRecord(file: File, id?: FileId): FileRecord {
}; };
} }
export function revokeFileResources(record: FileRecord): void { export function revokeFileResources(record: StirlingFileStub): void {
// Only revoke blob: URLs to prevent errors on other schemes // Only revoke blob: URLs to prevent errors on other schemes
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) { if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
try { try {
@ -171,7 +240,7 @@ export interface FileContextState {
// Core file management - lightweight file IDs only // Core file management - lightweight file IDs only
files: { files: {
ids: FileId[]; ids: FileId[];
byId: Record<FileId, FileRecord>; byId: Record<FileId, StirlingFileStub>;
}; };
// Pinned files - files that won't be consumed by tools // Pinned files - files that won't be consumed by tools
@ -190,16 +259,16 @@ 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: { stirlingFileStubs: StirlingFileStub[] } }
| { 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<StirlingFileStub> } }
| { 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[]; outputStirlingFileStubs: StirlingFileStub[] } }
| { type: 'UNDO_CONSUME_FILES'; payload: { inputFileRecords: FileRecord[]; outputFileIds: FileId[] } } | { type: 'UNDO_CONSUME_FILES'; payload: { inputStirlingFileStubs: StirlingFileStub[]; outputFileIds: FileId[] } }
// UI actions // UI actions
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } } | { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
@ -215,22 +284,22 @@ 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; selectFiles?: boolean }) => Promise<File[]>; addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>; addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<StirlingFile[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<File[]>; addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<StirlingFile[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void; updateStirlingFileStub: (id: FileId, updates: Partial<StirlingFileStub>) => 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 - accepts StirlingFile for safer type checking
pinFile: (file: File) => void; pinFile: (file: StirlingFile) => void;
unpinFile: (file: File) => void; unpinFile: (file: StirlingFile) => void;
// File consumption (replace unpinned files with outputs) // File consumption (replace unpinned files with outputs)
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileId[]>; consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileId[]>;
undoConsumeFiles: (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]) => Promise<void>; undoConsumeFiles: (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]) => Promise<void>;
// Selection management // Selection management
setSelectedFiles: (fileIds: FileId[]) => void; setSelectedFiles: (fileIds: FileId[]) => void;
setSelectedPages: (pageNumbers: number[]) => void; setSelectedPages: (pageNumbers: number[]) => void;
@ -253,26 +322,17 @@ 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 getFile: (id: FileId) => StirlingFile | undefined;
getFile: (id: FileId) => File | undefined; getFiles: (ids?: FileId[]) => StirlingFile[];
getFiles: (ids?: FileId[]) => File[]; getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined;
getStirlingFileStubs: (ids?: FileId[]) => StirlingFileStub[];
// Record access - uses normalized state
getFileRecord: (id: FileId) => FileRecord | undefined;
getFileRecords: (ids?: FileId[]) => FileRecord[];
// Derived selectors
getAllFileIds: () => FileId[]; getAllFileIds: () => FileId[];
getSelectedFiles: () => File[]; getSelectedFiles: () => StirlingFile[];
getSelectedFileRecords: () => FileRecord[]; getSelectedStirlingFileStubs: () => StirlingFileStub[];
// Pinned files selectors
getPinnedFileIds: () => FileId[]; getPinnedFileIds: () => FileId[];
getPinnedFiles: () => File[]; getPinnedFiles: () => StirlingFile[];
getPinnedFileRecords: () => FileRecord[]; getPinnedStirlingFileStubs: () => StirlingFileStub[];
isFilePinned: (file: File) => boolean; isFilePinned: (file: StirlingFile) => boolean;
// Stable signature for effect dependencies
getFilesSignature: () => string; getFilesSignature: () => string;
} }
@ -293,6 +353,3 @@ export interface FileContextActionsValue {
actions: FileContextActions; actions: FileContextActions;
dispatch: (action: FileContextAction) => void; dispatch: (action: FileContextAction) => void;
} }
// TODO: URL parameter types will be redesigned for new routing system

49
frontend/src/types/fileIdSafety.d.ts vendored Normal file
View File

@ -0,0 +1,49 @@
/**
* Type safety declarations to prevent file.name/UUID confusion
*/
import { FileId, StirlingFile, OperationType, FileOperation } 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 StirlingFile, not regular File
type StirlingFileOnlyFunction<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 StirlingFile functions
: T
: T;
// Utility type to enforce StirlingFile usage
type RequireStirlingFile<T> = T extends File ? StirlingFile : T;
}
// Extend Window interface for debugging
interface Window {
__FILE_ID_DEBUG?: boolean;
}
}
// Augment FileContext types to prevent bypassing StirlingFile
declare module '../contexts/FileContext' {
export interface StrictFileContextActions {
pinFile: (file: StirlingFile) => void; // Must be StirlingFile
unpinFile: (file: StirlingFile) => void; // Must be StirlingFile
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<StirlingFile[]>; // Returns StirlingFile
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<StirlingFile[]>; // Returns StirlingFile
}
export interface StrictFileContextSelectors {
getFile: (id: FileId) => StirlingFile | undefined; // Returns StirlingFile
getFiles: (ids?: FileId[]) => StirlingFile[]; // Returns StirlingFile[]
isFilePinned: (file: StirlingFile) => boolean; // Must be StirlingFile
}
}
export {};

View File

@ -0,0 +1,14 @@
/**
* Runtime validation utilities for FileId safety
*/
import { FileId } from '../types/fileContext';
// 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);
}