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 <[email protected]>
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
Reece Browne
2025-09-05 11:33:03 +01:00
committed by GitHub
co-authored by Copilot James Brunton
parent 5caec41d96
commit 87c63efcec
37 changed files with 493 additions and 339 deletions
+107 -50
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
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 {};