Merge branch 'V2' into auto-rename

This commit is contained in:
ConnorYoh 2025-09-05 11:33:38 +01:00 committed by GitHub
commit 241a9325b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 524 additions and 366 deletions

View File

@ -15,11 +15,14 @@ export default defineConfig(
}, },
{ {
rules: { rules: {
"no-empty-pattern": "off", // Temporarily disabled until codebase conformant
"no-undef": "off", // Temporarily disabled until codebase conformant "no-undef": "off", // Temporarily disabled until codebase conformant
"no-case-declarations": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-empty-object-type": [
"@typescript-eslint/ban-ts-comment": "off", // Temporarily disabled until codebase conformant "error",
"@typescript-eslint/no-empty-object-type": "off", // Temporarily disabled until codebase conformant {
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
allowInterfaces: 'with-single-extends',
},
],
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-unused-vars": "off", // Temporarily disabled until codebase conformant "@typescript-eslint/no-unused-vars": "off", // Temporarily disabled until codebase conformant

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

@ -17,8 +17,7 @@ import {
getActiveNavButton, getActiveNavButton,
} from './quickAccessBar/QuickAccessBar'; } from './quickAccessBar/QuickAccessBar';
const QuickAccessBar = forwardRef<HTMLDivElement>(({ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext();

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

@ -3,8 +3,6 @@ import { Stack, Text, Divider, Card, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSuggestedTools } from '../../../hooks/useSuggestedTools'; import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
export interface SuggestedToolsSectionProps {}
export function SuggestedToolsSection(): React.ReactElement { export function SuggestedToolsSection(): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const suggestedTools = useSuggestedTools(); const suggestedTools = useSuggestedTools();

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

@ -347,9 +347,9 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
const malformedFiles: Array<{name: string}> = [ const malformedFiles: Array<{name: string}> = [
{ name: 'valid.pdf' }, { name: 'valid.pdf' },
// @ts-ignore - Testing runtime resilience // @ts-expect-error - Testing runtime resilience
{ name: null }, { name: null },
// @ts-ignore // @ts-expect-error - Testing runtime resilience
{ name: undefined } { name: undefined }
]; ];

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 };
@ -111,7 +109,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;
@ -137,7 +135,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();
@ -147,13 +145,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) {
@ -167,9 +165,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);
@ -180,8 +175,11 @@ 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
const apiCallsConfig: ApiCallsConfig<TParams> = { const apiCallsConfig: ApiCallsConfig<TParams> = {
endpoint: config.endpoint, endpoint: config.endpoint,
@ -192,17 +190,18 @@ export const useToolOperation = <TParams>(
}; };
processedFiles = await processFiles( processedFiles = await processFiles(
params, params,
validFiles, validRegularFiles,
apiCallsConfig, apiCallsConfig,
actions.setProgress, actions.setProgress,
actions.setStatus actions.setStatus
); );
break; break;
}
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' });
@ -210,11 +209,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 {
@ -227,10 +226,11 @@ export const useToolOperation = <TParams>(
} }
} }
break; break;
}
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;
} }
@ -250,46 +250,40 @@ 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}`);
} }
} }
const outputFileIds = await consumeFiles(inputFileIds, processedFiles); const outputFileIds = await consumeFiles(inputFileIds, processedFiles);
// 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();
@ -318,10 +312,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;
} }
@ -333,18 +327,19 @@ 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();
lastOperationRef.current = null; lastOperationRef.current = null;
// Show success message // Show success message
actions.setStatus(t('undoSuccess', 'Operation undone successfully')); actions.setStatus(t('undoSuccess', 'Operation undone successfully'));
} catch (error: any) { } catch (error: any) {
let errorMessage = extractErrorMessage(error); let errorMessage = extractErrorMessage(error);
// Provide more specific error messages based on error type // Provide more specific error messages based on error type
if (error.message?.includes('Mismatch between input files')) { if (error.message?.includes('Mismatch between input files')) {
errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted'); errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted');
@ -353,9 +348,9 @@ export const useToolOperation = <TParams>(
} else if (error.name === 'QuotaExceededError') { } else if (error.name === 'QuotaExceededError') {
errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space'); errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space');
} }
actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`); actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`);
// Don't clear the operation data if undo failed - user might want to try again // Don't clear the operation data if undo failed - user might want to try again
} }
}, [undoConsumeFiles, resetResults, actions, t]); }, [undoConsumeFiles, resetResults, actions, t]);

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,
@ -459,11 +460,12 @@ export class EnhancedPDFProcessingService {
case 'failed': case 'failed':
this.metrics.failedFiles++; this.metrics.failedFiles++;
break; break;
case 'cacheHit': case 'cacheHit': {
// Update cache hit rate // Update cache hit rate
const totalAttempts = this.metrics.totalFiles + 1; const totalAttempts = this.metrics.totalFiles + 1;
this.metrics.cacheHitRate = (this.metrics.cacheHitRate * this.metrics.totalFiles + 1) / totalAttempts; this.metrics.cacheHitRate = (this.metrics.cacheHitRate * this.metrics.totalFiles + 1) / totalAttempts;
break; break;
}
} }
} }

View File

@ -148,15 +148,17 @@ export class FileAnalyzer {
case 'immediate_full': case 'immediate_full':
return pageCount * baseTime; return pageCount * baseTime;
case 'priority_pages': case 'priority_pages': {
// Estimate time for priority pages (first 10) // Estimate time for priority pages (first 10)
const priorityPages = Math.min(pageCount, 10); const priorityPages = Math.min(pageCount, 10);
return priorityPages * baseTime; return priorityPages * baseTime;
}
case 'progressive_chunked': case 'progressive_chunked': {
// Estimate time for first chunk (20 pages) // Estimate time for first chunk (20 pages)
const firstChunk = Math.min(pageCount, 20); const firstChunk = Math.min(pageCount, 20);
return firstChunk * baseTime; return firstChunk * baseTime;
}
default: default:
return pageCount * baseTime; return pageCount * baseTime;

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'
@ -42,25 +45,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)
@ -83,9 +93,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,
@ -98,7 +167,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 {
@ -172,7 +241,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
@ -191,16 +260,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[] } }
@ -216,22 +285,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;
@ -254,26 +323,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;
} }
@ -294,6 +354,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

@ -1,7 +1,6 @@
// Base parameter interfaces for reusable patterns // Base parameter interfaces for reusable patterns
export interface BaseParameters { // Base interface that all tool parameters should extend
// Base interface that all tool parameters should extend // Provides a foundation for adding common properties across all tools
// Provides a foundation for adding common properties across all tools // Examples of future additions: userId, sessionId, commonFlags, etc.
// Examples of future additions: userId, sessionId, commonFlags, etc. export type BaseParameters = object
}

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);
}