Feature/v2/filewithid implementation (#4369)

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

---------

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

View File

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

View File

@ -24,7 +24,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
// 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
return await storeFile(file, fileId);
}, [storeFile]);

View File

@ -16,12 +16,12 @@ import styles from './FileEditor.module.css';
import FileEditorThumbnail from './FileEditorThumbnail';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
import { FileId } from '../../types/file';
import { FileId, StirlingFile } from '../../types/fileContext';
interface FileEditorProps {
onOpenPageEditor?: (file: File) => void;
onMergeFiles?: (files: File[]) => void;
onOpenPageEditor?: (file: StirlingFile) => void;
onMergeFiles?: (files: StirlingFile[]) => void;
toolMode?: boolean;
showUpload?: boolean;
showBulkActions?: boolean;
@ -50,7 +50,7 @@ const FileEditor = ({
// Extract needed values from state (memoized to prevent infinite loops)
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 isProcessing = state.ui.isProcessing;
@ -92,10 +92,10 @@ const FileEditor = ({
const contextSelectedIdsRef = useRef<FileId[]>([]);
contextSelectedIdsRef.current = contextSelectedIds;
// Use activeFileRecords directly - no conversion needed
// Use activeStirlingFileStubs directly - no conversion needed
const localSelectedIds = contextSelectedIds;
// Helper to convert FileRecord to FileThumbnail format
// Helper to convert StirlingFileStub to FileThumbnail format
const recordToFileItem = useCallback((record: any) => {
const file = selectors.getFile(record.id);
if (!file) return null;
@ -253,26 +253,26 @@ const FileEditor = ({
}, [addFiles]);
const selectAll = useCallback(() => {
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly
}, [activeFileRecords, setSelectedFiles]);
setSelectedFiles(activeStirlingFileStubs.map(r => r.id)); // Use StirlingFileStub IDs directly
}, [activeStirlingFileStubs, setSelectedFiles]);
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
const closeAllFiles = useCallback(() => {
if (activeFileRecords.length === 0) return;
if (activeStirlingFileStubs.length === 0) return;
// 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
// Clear selections
setSelectedFiles([]);
}, [activeFileRecords, removeFiles, setSelectedFiles]);
}, [activeStirlingFileStubs, removeFiles, setSelectedFiles]);
const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeFileRecords.find(r => r.id === fileId);
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
if (!targetRecord) return;
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)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]);
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
@ -316,7 +316,7 @@ const FileEditor = ({
// File reordering handler for drag and drop
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
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
@ -368,13 +368,13 @@ const FileEditor = ({
// Update status
const moveCount = filesToMove.length;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeFileRecords, reorderFiles, setStatus]);
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
// File operations using context
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;
if (record && file) {
@ -405,27 +405,27 @@ const FileEditor = ({
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
setSelectedFiles(currentSelected);
}
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleViewFile = useCallback((fileId: FileId) => {
const record = activeFileRecords.find(r => r.id === fileId);
const record = activeStirlingFileStubs.find(r => r.id === fileId);
if (record) {
// Set the file as selected in context and switch to viewer for preview
setSelectedFiles([fileId]);
navActions.setWorkbench('viewer');
}
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
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;
const recordsToMerge = activeFileRecords.slice(startIndex);
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
const recordsToMerge = activeStirlingFileStubs.slice(startIndex);
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as StirlingFile[];
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
}, [activeFileRecords, selectors, onMergeFiles]);
}, [activeStirlingFileStubs, selectors, onMergeFiles]);
const handleSplitFile = useCallback((fileId: FileId) => {
const file = selectors.getFile(fileId);
@ -467,7 +467,7 @@ const FileEditor = ({
<Box p="md" pt="xl">
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
<Center h="60vh">
<Stack align="center" gap="md">
<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>
</Stack>
</Center>
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
<Box>
<SkeletonLoader type="controls" />
@ -522,7 +522,7 @@ const FileEditor = ({
pointerEvents: 'auto'
}}
>
{activeFileRecords.map((record, index) => {
{activeStirlingFileStubs.map((record, index) => {
const fileItem = recordToFileItem(record);
if (!fileItem) return null;
@ -531,7 +531,7 @@ const FileEditor = ({
key={record.id}
file={fileItem}
index={index}
totalFiles={activeFileRecords.length}
totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,7 +34,6 @@ export default function RightRail() {
const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature();
const fileRecords = selectors.getFileRecords();
// Compute selection state and total items
const getSelectionState = useCallback(() => {
@ -85,7 +84,7 @@ export default function RightRail() {
if (currentView === 'fileEditor' || currentView === 'viewer') {
// Download selected files (or all if none selected)
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
filesToDownload.forEach(file => {
const link = document.createElement('a');
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 */}
<div
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
<div
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
aria-hidden={currentView === 'viewer'}
>
<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 />
<Tooltip content={
currentView === 'pageEditor'
? t('rightRail.exportAll', 'Export PDF')
currentView === 'pageEditor'
? t('rightRail.exportAll', 'Export PDF')
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
disabled={currentView === 'viewer' || totalItems === 0}

View File

@ -22,13 +22,13 @@ import {
OUTPUT_OPTIONS,
FIT_OPTIONS
} from "../../../constants/convertConstants";
import { FileId } from "../../../types/file";
import { StirlingFile } from "../../../types/fileContext";
interface ConvertSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
selectedFiles: File[];
selectedFiles: StirlingFile[];
disabled?: boolean;
}
@ -129,7 +129,7 @@ const ConvertSettings = ({
};
const filterFilesByExtension = (extension: string) => {
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as StirlingFile[];
return files.filter(file => {
const fileExtension = detectFileExtension(file.name);
@ -143,21 +143,8 @@ const ConvertSettings = ({
});
};
const updateFileSelection = (files: File[]) => {
// Map File objects to their actual IDs in FileContext
const fileIds = files.map(file => {
// Find the file ID by matching file properties
const fileRecord = state.files.ids
.map(id => selectors.getFileRecord(id))
.find(record =>
record &&
record.name === file.name &&
record.size === file.size &&
record.lastModified === file.lastModified
);
return fileRecord?.id;
}).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
const updateFileSelection = (files: StirlingFile[]) => {
const fileIds = files.map(file => file.fileId);
setSelectedFiles(fileIds);
};

View File

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

View File

@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
import { useAllFiles } from "../../../contexts/FileContext";
import { useFileManager } from "../../../hooks/useFileManager";
import { StirlingFile } from "../../../types/fileContext";
export interface FileStatusIndicatorProps {
selectedFiles?: File[];
selectedFiles?: StirlingFile[];
placeholder?: string;
}
@ -17,7 +18,7 @@ const FileStatusIndicator = ({
}: FileStatusIndicatorProps) => {
const { t } = useTranslation();
const { openFilesModal, onFilesSelect } = useFilesModalContext();
const { files: workbenchFiles } = useAllFiles();
const { files: stirlingFileStubs } = useAllFiles();
const { loadRecentFiles } = useFileManager();
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
@ -55,7 +56,7 @@ const FileStatusIndicator = ({
}
// 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 (!hasRecentFiles) {
return (

View File

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

View File

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

View File

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

View File

@ -19,7 +19,10 @@ import {
FileContextStateValue,
FileContextActionsValue,
FileContextActions,
FileRecord
FileId,
StirlingFileStub,
StirlingFile,
createStirlingFile
} from '../types/fileContext';
// Import modular components
@ -29,7 +32,6 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions
import { FileLifecycleManager } from './file/lifecycle';
import { FileStateContext, FileActionsContext } from './file/contexts';
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
import { FileId } from '../types/file';
const DEBUG = process.env.NODE_ENV === 'development';
@ -79,7 +81,7 @@ function FileContextInner({
}
// File operations using unified addFiles helper with persistence
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<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);
// 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]);
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);
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);
// Auto-select the newly added files if requested
@ -114,7 +116,7 @@ function FileContextInner({
selectFiles(result);
}
return result.map(({ file }) => file);
return result.map(({ file, id }) => createStirlingFile(file, id));
}, []);
// Action creators
@ -122,11 +124,11 @@ function FileContextInner({
// Helper functions for pinned files
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]);
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => {
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
}, [indexedDB]);
// Helper to find FileId from File object
@ -140,24 +142,14 @@ function FileContextInner({
});
}, []);
// File-to-ID wrapper functions for pinning
const pinFileWrapper = useCallback((file: File) => {
const fileId = findFileId(file);
if (fileId) {
baseActions.pinFile(fileId);
} else {
console.warn('File not found for pinning:', file.name);
}
}, [baseActions, findFileId]);
// File pinning functions - use StirlingFile directly
const pinFileWrapper = useCallback((file: StirlingFile) => {
baseActions.pinFile(file.fileId);
}, [baseActions]);
const unpinFileWrapper = useCallback((file: File) => {
const fileId = findFileId(file);
if (fileId) {
baseActions.unpinFile(fileId);
} else {
console.warn('File not found for unpinning:', file.name);
}
}, [baseActions, findFileId]);
const unpinFileWrapper = useCallback((file: StirlingFile) => {
baseActions.unpinFile(file.fileId);
}, [baseActions]);
// Complete actions object
const actions = useMemo<FileContextActions>(() => ({
@ -178,8 +170,8 @@ function FileContextInner({
}
}
},
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
updateStirlingFileStub: (fileId: FileId, updates: Partial<StirlingFileStub>) =>
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef),
reorderFiles: (orderedFileIds: FileId[]) => {
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
},
@ -303,7 +295,7 @@ export {
useFileSelection,
useFileManagement,
useFileUI,
useFileRecord,
useStirlingFileStub,
useAllFiles,
useSelectedFiles,
// Primary API hooks for tools

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import {
FileContextStateValue,
FileContextActionsValue
} from './contexts';
import { FileRecord } from '../../types/fileContext';
import { StirlingFileStub, StirlingFile } from '../../types/fileContext';
import { FileId } from '../../types/file';
/**
@ -38,13 +38,13 @@ export function useFileActions(): FileContextActionsValue {
/**
* 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 primaryFileId = state.files.ids[0];
return useMemo(() => ({
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined
record: primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : undefined
}), [primaryFileId, selectors]);
}
@ -87,7 +87,7 @@ export function useFileManagement() {
addFiles: actions.addFiles,
removeFiles: actions.removeFiles,
clearAllFiles: actions.clearAllFiles,
updateFileRecord: actions.updateFileRecord,
updateStirlingFileStub: actions.updateStirlingFileStub,
reorderFiles: actions.reorderFiles
}), [actions]);
}
@ -111,24 +111,24 @@ export function useFileUI() {
/**
* 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();
return useMemo(() => ({
file: selectors.getFile(fileId),
record: selectors.getFileRecord(fileId)
record: selectors.getStirlingFileStub(fileId)
}), [fileId, selectors]);
}
/**
* 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();
return useMemo(() => ({
files: selectors.getFiles(),
records: selectors.getFileRecords(),
records: selectors.getStirlingFileStubs(),
fileIds: state.files.ids
}), [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)
*/
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getSelectedFiles(),
records: selectors.getSelectedFileRecords(),
records: selectors.getSelectedStirlingFileStubs(),
fileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]);
}

View File

@ -4,9 +4,11 @@
import { FileId } from '../../types/file';
import {
FileRecord,
StirlingFileStub,
FileContextState,
FileContextSelectors
FileContextSelectors,
StirlingFile,
createStirlingFile
} from '../../types/fileContext';
/**
@ -17,16 +19,24 @@ export function createFileSelectors(
filesRef: React.MutableRefObject<Map<FileId, File>>
): FileContextSelectors {
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[]) => {
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;
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
},
@ -35,11 +45,14 @@ export function createFileSelectors(
getSelectedFiles: () => {
return stateRef.current.ui.selectedFileIds
.map(id => filesRef.current.get(id))
.filter(Boolean) as File[];
.map(id => {
const file = filesRef.current.get(id);
return file ? createStirlingFile(file, id) : undefined;
})
.filter(Boolean) as StirlingFile[];
},
getSelectedFileRecords: () => {
getSelectedStirlingFileStubs: () => {
return stateRef.current.ui.selectedFileIds
.map(id => stateRef.current.files.byId[id])
.filter(Boolean);
@ -52,26 +65,21 @@ export function createFileSelectors(
getPinnedFiles: () => {
return Array.from(stateRef.current.pinnedFiles)
.map(id => filesRef.current.get(id))
.filter(Boolean) as File[];
.map(id => {
const file = filesRef.current.get(id);
return file ? createStirlingFile(file, id) : undefined;
})
.filter(Boolean) as StirlingFile[];
},
getPinnedFileRecords: () => {
getPinnedStirlingFileStubs: () => {
return Array.from(stateRef.current.pinnedFiles)
.map(id => stateRef.current.files.byId[id])
.filter(Boolean);
},
isFilePinned: (file: File) => {
// Find FileId by matching File object properties
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;
isFilePinned: (file: StirlingFile) => {
return stateRef.current.pinnedFiles.has(file.fileId);
},
// Stable signature for effects - prevents unnecessary re-renders
@ -90,9 +98,9 @@ export function createFileSelectors(
/**
* 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>();
Object.values(fileRecords).forEach(record => {
Object.values(stirlingFileStubs).forEach(record => {
if (record.quickKey) {
quickKeys.add(record.quickKey);
}
@ -119,7 +127,7 @@ export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; siz
export function getPrimaryFile(
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>
): { file?: File; record?: FileRecord } {
): { file?: File; record?: StirlingFileStub } {
const primaryFileId = stateRef.current.files.ids[0];
if (!primaryFileId) return {};

View File

@ -3,7 +3,7 @@
*/
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';
@ -166,7 +166,7 @@ export class FileLifecycleManager {
/**
* 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)
if (!this.filesRef.current.has(fileId)) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
import { useIndexedDB } from '../contexts/IndexedDBContext';
import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
import { FileId } from '../types/file';
import { FileId } from '../types/fileContext';
export const useFileManager = () => {
const [loading, setLoading] = useState(false);

View File

@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { isFileObject } from '../types/fileContext';
/**
* 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(() => {
if (!file) return null;
// Validate that file is a proper File or Blob object
if (!(file instanceof File) && !(file instanceof Blob)) {
// Validate that file is a proper File, StirlingFile, or Blob object
if (!isFileObject(file) && !(file instanceof Blob)) {
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
return null;
}

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { FileMetadata } from "../types/file";
import { useIndexedDB } from "../contexts/IndexedDBContext";
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
import { FileId } from "../types/fileContext";
/**
* 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
if (file.id && indexedDB) {
const loadedFile = await indexedDB.loadFile(file.id);
const loadedFile = await indexedDB.loadFile(file.id as FileId);
if (!loadedFile) {
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
if (file.id && indexedDB && thumbnail) {
try {
await indexedDB.updateThumbnail(file.id, thumbnail);
await indexedDB.updateThumbnail(file.id as FileId, thumbnail);
} catch (error) {
console.warn('Failed to save thumbnail to IndexedDB:', error);
}

View File

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { PDFDocument, PDFPage } from '../types/pageEditor';
import { pdfWorkerManager } from '../services/pdfWorkerManager';
import { createQuickKey } from '../types/fileContext';
export function usePDFProcessor() {
const [loading, setLoading] = useState(false);
@ -75,7 +76,7 @@ export function usePDFProcessor() {
// Create pages without thumbnails initially - load them lazily
for (let i = 1; i <= totalPages; i++) {
pages.push({
id: `${file.name}-page-${i}`,
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
originalPageNumber: i,
thumbnail: null, // Will be loaded lazily

View File

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

View File

@ -1,5 +1,6 @@
import { useCallback, useRef } from 'react';
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
import { createQuickKey } from '../types/fileContext';
import { FileId } from '../types/file';
// 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 ? '...' : ''}`);
// Use file name as fileId for PDF document caching
const fileId = file.name + '_' + file.size + '_' + file.lastModified as FileId;
// Use quickKey for PDF document caching (same metadata, consistent format)
const fileId = createQuickKey(file) as FileId;
const results = await thumbnailGenerationService.generateThumbnails(
fileId,

View File

@ -5,6 +5,7 @@ import { FileHasher } from '../utils/fileHash';
import { FileAnalyzer } from './fileAnalyzer';
import { ProcessingErrorHandler } from './processingErrorHandler';
import { pdfWorkerManager } from './pdfWorkerManager';
import { createQuickKey } from '../types/fileContext';
export class EnhancedPDFProcessingService {
private static instance: EnhancedPDFProcessingService;
@ -201,7 +202,7 @@ export class EnhancedPDFProcessingService {
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
pages.push({
id: `${file.name}-page-${i}`,
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail,
rotation: 0,
@ -251,7 +252,7 @@ export class EnhancedPDFProcessingService {
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
pages.push({
id: `${file.name}-page-${i}`,
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail,
rotation: 0,
@ -266,7 +267,7 @@ export class EnhancedPDFProcessingService {
// Create placeholder pages for remaining pages
for (let i = priorityCount + 1; i <= totalPages; i++) {
pages.push({
id: `${file.name}-page-${i}`,
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null, // Will be loaded lazily
rotation: 0,
@ -313,7 +314,7 @@ export class EnhancedPDFProcessingService {
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
pages.push({
id: `${file.name}-page-${i}`,
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail,
rotation: 0,
@ -334,7 +335,7 @@ export class EnhancedPDFProcessingService {
// Create placeholders for remaining pages
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
pages.push({
id: `${file.name}-page-${i}`,
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null,
rotation: 0,
@ -368,7 +369,7 @@ export class EnhancedPDFProcessingService {
const pages: PDFPage[] = [];
for (let i = 1; i <= totalPages; i++) {
pages.push({
id: `${file.name}-page-${i}`,
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null,
rotation: 0,

View File

@ -1,6 +1,7 @@
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
import { ProcessingCache } from './processingCache';
import { pdfWorkerManager } from './pdfWorkerManager';
import { createQuickKey } from '../types/fileContext';
export class PDFProcessingService {
private static instance: PDFProcessingService;
@ -113,7 +114,7 @@ export class PDFProcessingService {
const thumbnail = canvas.toDataURL();
pages.push({
id: `${file.name}-page-${i}`,
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail,
rotation: 0,

View File

@ -18,6 +18,8 @@ import { FileContextProvider } from '../../contexts/FileContext';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n/config';
import axios from 'axios';
import { createTestStirlingFile } from '../utils/testFileHelpers';
import { StirlingFile } from '../../types/fileContext';
// Mock axios
vi.mock('axios');
@ -55,9 +57,9 @@ const createTestFile = (name: string, content: string, type: string): File => {
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';
return createTestFile('test.pdf', pdfContent, 'application/pdf');
return createTestStirlingFile('test.pdf', pdfContent, 'application/pdf');
};
// Test wrapper component
@ -162,7 +164,7 @@ describe('Convert Tool Integration Tests', () => {
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 = {
fromExtension: 'pdf',
toExtension: 'png',
@ -426,7 +428,7 @@ describe('Convert Tool Integration Tests', () => {
});
const files = [
createPDFFile(),
createTestFile('test2.pdf', '%PDF-1.4...', 'application/pdf')
createTestStirlingFile('test2.pdf', '%PDF-1.4...', 'application/pdf')
]
const parameters: ConvertParameters = {
fromExtension: 'pdf',
@ -527,7 +529,7 @@ describe('Convert Tool Integration Tests', () => {
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 = {
fromExtension: 'pdf',
toExtension: 'png',

View File

@ -14,6 +14,8 @@ import i18n from '../../i18n/config';
import axios from 'axios';
import { detectFileExtension } from '../../utils/fileUtils';
import { FIT_OPTIONS } from '../../constants/convertConstants';
import { createTestStirlingFile, createTestFilesWithId } from '../utils/testFileHelpers';
import { StirlingFile } from '../../types/fileContext';
// Mock axios
vi.mock('axios');
@ -81,7 +83,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// 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
act(() => {
@ -117,7 +119,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// 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
act(() => {
@ -156,11 +158,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// Create mock image files
const imageFiles = [
new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }),
new File(['png content'], 'photo2.png', { type: 'image/png' }),
new File(['gif content'], 'photo3.gif', { type: 'image/gif' })
];
const imageFiles = createTestFilesWithId([
{ name: 'photo1.jpg', content: 'jpg content', type: 'image/jpeg' },
{ name: 'photo2.png', content: 'png content', type: 'image/png' },
{ name: 'photo3.gif', content: 'gif content', type: 'image/gif' }
]);
// Test smart detection for all images
act(() => {
@ -202,11 +204,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// Create mixed file types
const mixedFiles = [
new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }),
new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
];
const mixedFiles = createTestFilesWithId([
{ name: 'document.pdf', content: 'pdf content', type: 'application/pdf' },
{ name: 'spreadsheet.xlsx', content: 'docx content', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
{ name: 'presentation.pptx', content: 'pptx content', type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }
]);
// Test smart detection for mixed types
act(() => {
@ -243,10 +245,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// Create mock web files
const webFiles = [
new File(['<html>content</html>'], 'page1.html', { type: 'text/html' }),
new File(['zip content'], 'site.zip', { type: 'application/zip' })
];
const webFiles = createTestFilesWithId([
{ name: 'page1.html', content: '<html>content</html>', type: 'text/html' },
{ name: 'site.zip', content: 'zip content', type: 'application/zip' }
]);
// Test smart detection for web files
act(() => {
@ -288,7 +290,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
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
act(() => {
@ -318,7 +320,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
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
act(() => {
@ -355,7 +357,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
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
act(() => {
@ -392,10 +394,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper
});
const imageFiles = [
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
];
const imageFiles = createTestFilesWithId([
{ name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
{ name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
]);
// Set up image conversion parameters
act(() => {
@ -432,10 +434,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper
});
const imageFiles = [
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
];
const imageFiles = createTestFilesWithId([
{ name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
{ name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
]);
// Set up for separate processing
act(() => {
@ -477,10 +479,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
})
.mockRejectedValueOnce(new Error('File 2 failed'));
const mixedFiles = [
new File(['file1'], 'doc1.txt', { type: 'text/plain' }),
new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' })
];
const mixedFiles = createTestFilesWithId([
{ name: 'doc1.txt', content: 'file1', type: 'text/plain' },
{ name: 'doc2.xyz', content: 'file2', type: 'application/octet-stream' }
]);
// Set up for separate processing (mixed smart detection)
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 { FileId, FileMetadata } from './file';
// Re-export FileId for convenience
export type { FileId };
export type ModeType =
| 'viewer'
| 'pageEditor'
@ -41,25 +44,32 @@ export interface ProcessedFileMetadata {
[key: string]: any;
}
export interface FileRecord {
id: FileId;
name: string;
size: number;
type: string;
lastModified: number;
quickKey?: string; // Fast deduplication key: name|size|lastModified
thumbnailUrl?: string;
blobUrl?: string;
createdAt?: number;
processedFile?: ProcessedFileMetadata;
insertAfterPageId?: string; // Page ID after which this file should be inserted
isPinned?: boolean;
/**
* StirlingFileStub - Metadata record for files in the active workbench session
*
* Contains UI display data and processing state. Actual File objects stored
* separately in refs for memory efficiency. Supports multi-tool workflows
* where files persist across tool operations.
*/
export interface StirlingFileStub {
id: FileId; // UUID primary key for collision-free operations
name: string; // Display name for UI
size: number; // File size for progress indicators
type: string; // MIME type for format validation
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
}
export interface FileContextNormalizedFiles {
ids: FileId[];
byId: Record<FileId, FileRecord>;
byId: Record<FileId, StirlingFileStub>;
}
// Helper functions - UUID-based primary keys (zero collisions, synchronous)
@ -82,9 +92,68 @@ export function createQuickKey(file: File): string {
return `${file.name}|${file.size}|${file.lastModified}`;
}
// 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();
return {
id: fileId,
@ -97,7 +166,7 @@ export function toFileRecord(file: File, id?: FileId): FileRecord {
};
}
export function revokeFileResources(record: FileRecord): void {
export function revokeFileResources(record: StirlingFileStub): void {
// Only revoke blob: URLs to prevent errors on other schemes
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
try {
@ -171,7 +240,7 @@ export interface FileContextState {
// Core file management - lightweight file IDs only
files: {
ids: FileId[];
byId: Record<FileId, FileRecord>;
byId: Record<FileId, StirlingFileStub>;
};
// Pinned files - files that won't be consumed by tools
@ -190,16 +259,16 @@ export interface FileContextState {
// Action types for reducer pattern
export type FileContextAction =
// File management actions
| { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } }
| { type: 'ADD_FILES'; payload: { stirlingFileStubs: StirlingFileStub[] } }
| { 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[] } }
// Pinned files actions
| { type: 'PIN_FILE'; payload: { fileId: FileId } }
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } }
| { type: 'UNDO_CONSUME_FILES'; payload: { inputFileRecords: FileRecord[]; outputFileIds: FileId[] } }
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputStirlingFileStubs: StirlingFileStub[] } }
| { type: 'UNDO_CONSUME_FILES'; payload: { inputStirlingFileStubs: StirlingFileStub[]; outputFileIds: FileId[] } }
// UI actions
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
@ -215,22 +284,22 @@ export type FileContextAction =
export interface FileContextActions {
// File management - lightweight actions only
addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<File[]>;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<File[]>;
addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<StirlingFile[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<StirlingFile[]>;
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;
clearAllFiles: () => Promise<void>;
clearAllData: () => Promise<void>;
// File pinning
pinFile: (file: File) => void;
unpinFile: (file: File) => void;
// File pinning - accepts StirlingFile for safer type checking
pinFile: (file: StirlingFile) => void;
unpinFile: (file: StirlingFile) => void;
// File consumption (replace unpinned files with outputs)
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
setSelectedFiles: (fileIds: FileId[]) => void;
setSelectedPages: (pageNumbers: number[]) => void;
@ -253,26 +322,17 @@ export interface FileContextActions {
// File selectors (separate from actions to avoid re-renders)
export interface FileContextSelectors {
// File access - no state dependency, uses ref
getFile: (id: FileId) => File | undefined;
getFiles: (ids?: FileId[]) => File[];
// Record access - uses normalized state
getFileRecord: (id: FileId) => FileRecord | undefined;
getFileRecords: (ids?: FileId[]) => FileRecord[];
// Derived selectors
getFile: (id: FileId) => StirlingFile | undefined;
getFiles: (ids?: FileId[]) => StirlingFile[];
getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined;
getStirlingFileStubs: (ids?: FileId[]) => StirlingFileStub[];
getAllFileIds: () => FileId[];
getSelectedFiles: () => File[];
getSelectedFileRecords: () => FileRecord[];
// Pinned files selectors
getSelectedFiles: () => StirlingFile[];
getSelectedStirlingFileStubs: () => StirlingFileStub[];
getPinnedFileIds: () => FileId[];
getPinnedFiles: () => File[];
getPinnedFileRecords: () => FileRecord[];
isFilePinned: (file: File) => boolean;
// Stable signature for effect dependencies
getPinnedFiles: () => StirlingFile[];
getPinnedStirlingFileStubs: () => StirlingFileStub[];
isFilePinned: (file: StirlingFile) => boolean;
getFilesSignature: () => string;
}
@ -293,6 +353,3 @@ export interface FileContextActionsValue {
actions: FileContextActions;
dispatch: (action: FileContextAction) => void;
}
// TODO: URL parameter types will be redesigned for new routing system

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

@ -0,0 +1,49 @@
/**
* Type safety declarations to prevent file.name/UUID confusion
*/
import { FileId, StirlingFile, OperationType, FileOperation } from './fileContext';
declare global {
namespace FileIdSafety {
// Mark functions that should never accept file.name as parameters
type SafeFileIdFunction<T extends (...args: any[]) => any> = T extends (...args: infer P) => infer R
? P extends readonly [string, ...any[]]
? never // Reject string parameters in first position for FileId functions
: T
: T;
// Mark functions that should only accept StirlingFile, not regular File
type StirlingFileOnlyFunction<T extends (...args: any[]) => any> = T extends (...args: infer P) => infer R
? P extends readonly [File, ...any[]]
? never // Reject File parameters in first position for StirlingFile functions
: T
: T;
// Utility type to enforce StirlingFile usage
type RequireStirlingFile<T> = T extends File ? StirlingFile : T;
}
// Extend Window interface for debugging
interface Window {
__FILE_ID_DEBUG?: boolean;
}
}
// Augment FileContext types to prevent bypassing StirlingFile
declare module '../contexts/FileContext' {
export interface StrictFileContextActions {
pinFile: (file: StirlingFile) => void; // Must be StirlingFile
unpinFile: (file: StirlingFile) => void; // Must be StirlingFile
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<StirlingFile[]>; // Returns StirlingFile
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<StirlingFile[]>; // Returns StirlingFile
}
export interface StrictFileContextSelectors {
getFile: (id: FileId) => StirlingFile | undefined; // Returns StirlingFile
getFiles: (ids?: FileId[]) => StirlingFile[]; // Returns StirlingFile[]
isFilePinned: (file: StirlingFile) => boolean; // Must be StirlingFile
}
}
export {};

View File

@ -0,0 +1,14 @@
/**
* Runtime validation utilities for FileId safety
*/
import { FileId } from '../types/fileContext';
// Validate that a string is a proper FileId (has UUID format)
export function isValidFileId(id: string): id is FileId {
// Check UUID v4 format: 8-4-4-4-12 hex digits
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(id);
}