V2 Make FileId type opaque and use consistently throughout project (#4307)

# Description of Changes
The `FileId` type in V2 currently is just defined to be a string. This
makes it really easy to accidentally pass strings into things accepting
file IDs (such as file names). This PR makes the `FileId` type [an
opaque
type](https://www.geeksforgeeks.org/typescript/opaque-types-in-typescript/),
so it is compatible with things accepting strings (arguably not ideal
for this...) but strings are not compatible with it without explicit
conversion.

The PR also includes changes to use `FileId` consistently throughout the
project (everywhere I could find uses of `fileId: string`), so that we
have the maximum benefit from the type safety.

> [!note]
> I've marked quite a few things as `FIX ME` where we're passing names
in as IDs. If that is intended behaviour, I'm happy to remove the fix me
and insert a cast instead, but they probably need comments explaining
why we're using a file name as an ID.
This commit is contained in:
James Brunton
2025-08-28 10:56:07 +01:00
committed by GitHub
parent 581bafbd37
commit e142af2863
32 changed files with 600 additions and 574 deletions

View File

@@ -8,6 +8,7 @@ import { useToolResources } from './useToolResources';
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { createOperation } from '../../../utils/toolOperationTracker';
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
import { FileId } from '../../../types/file';
// Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler };
@@ -231,7 +232,7 @@ export const useToolOperation = <TParams>(
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
// Replace input files with processed files (consumeFiles handles pinning)
const inputFileIds = validFiles.map(file => findFileId(file)).filter(Boolean) as string[];
const inputFileIds = validFiles.map(file => findFileId(file)).filter(Boolean) as FileId[];
await consumeFiles(inputFileIds, processedFiles);
markOperationApplied(fileId, operationId);

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useFileState, useFileActions } from '../contexts/FileContext';
import { FileMetadata } from '../types/file';
import { FileId } from '../types/file';
export const useFileHandler = () => {
const { state } = useFileState(); // Still needed for addStoredFiles
@@ -17,16 +18,16 @@ export const useFileHandler = () => {
}, [actions.addFiles]);
// Add stored files preserving their original IDs to prevent session duplicates
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => {
// Filter out files that already exist with the same ID (exact match)
const newFiles = filesWithMetadata.filter(({ originalId }) => {
return state.files.byId[originalId] === undefined;
});
if (newFiles.length > 0) {
await actions.addStoredFiles(newFiles);
}
console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`);
}, [state.files.byId, actions.addStoredFiles]);
@@ -35,4 +36,4 @@ export const useFileHandler = () => {
addMultipleFiles,
addStoredFiles,
};
};
};

View File

@@ -2,6 +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';
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
@@ -11,19 +12,19 @@ export const useFileManager = () => {
if (!indexedDB) {
throw new Error('IndexedDB context not available');
}
// Handle drafts differently from regular files
if (fileMetadata.isDraft) {
// Load draft from the drafts database
try {
const { indexedDBManager, DATABASE_CONFIGS } = await import('../services/indexedDBManager');
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
return new Promise((resolve, reject) => {
const transaction = db.transaction(['drafts'], 'readonly');
const store = transaction.objectStore('drafts');
const request = store.get(fileMetadata.id);
request.onsuccess = () => {
const draft = request.result;
if (draft && draft.pdfData) {
@@ -36,14 +37,14 @@ export const useFileManager = () => {
reject(new Error('Draft data not found'));
}
};
request.onerror = () => reject(request.error);
});
} catch (error) {
throw new Error(`Failed to load draft: ${fileMetadata.name} (${error})`);
}
}
// Regular file loading
if (fileMetadata.id) {
const file = await indexedDB.loadFile(fileMetadata.id);
@@ -60,14 +61,14 @@ export const useFileManager = () => {
if (!indexedDB) {
return [];
}
// Load regular files metadata only
const storedFileMetadata = await indexedDB.loadAllMetadata();
// For now, only regular files - drafts will be handled separately in the future
const allFiles = storedFileMetadata;
const sortedFiles = allFiles.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
return sortedFiles;
} catch (error) {
console.error('Failed to load recent files:', error);
@@ -94,7 +95,7 @@ export const useFileManager = () => {
}
}, [indexedDB]);
const storeFile = useCallback(async (file: File, fileId: string) => {
const storeFile = useCallback(async (file: File, fileId: FileId) => {
if (!indexedDB) {
throw new Error('IndexedDB context not available');
}
@@ -104,7 +105,7 @@ export const useFileManager = () => {
// Convert file to ArrayBuffer for StoredFile interface compatibility
const arrayBuffer = await file.arrayBuffer();
// Return StoredFile format for compatibility with old API
return {
id: fileId,
@@ -122,10 +123,10 @@ export const useFileManager = () => {
}, [indexedDB]);
const createFileSelectionHandlers = useCallback((
selectedFiles: string[],
setSelectedFiles: (files: string[]) => void
selectedFiles: FileId[],
setSelectedFiles: (files: FileId[]) => void
) => {
const toggleSelection = (fileId: string) => {
const toggleSelection = (fileId: FileId) => {
setSelectedFiles(
selectedFiles.includes(fileId)
? selectedFiles.filter(id => id !== fileId)
@@ -137,13 +138,13 @@ export const useFileManager = () => {
setSelectedFiles([]);
};
const selectMultipleFiles = async (files: FileMetadata[], onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void) => {
const selectMultipleFiles = async (files: FileMetadata[], onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void) => {
if (selectedFiles.length === 0) return;
try {
// Filter by UUID and convert to File objects
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id));
// Use stored files flow that preserves IDs
const filesWithMetadata = await Promise.all(
selectedFileObjects.map(async (metadata) => ({
@@ -153,7 +154,7 @@ export const useFileManager = () => {
}))
);
onStoredFilesSelect(filesWithMetadata);
clearSelection();
} catch (error) {
console.error('Failed to load selected files:', error);
@@ -168,7 +169,7 @@ export const useFileManager = () => {
};
}, [convertToFile]);
const touchFile = useCallback(async (id: string) => {
const touchFile = useCallback(async (id: FileId) => {
if (!indexedDB) {
console.warn('IndexedDB context not available for touch operation');
return;

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef } from 'react';
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
import { FileId } from '../types/file';
// Request queue to handle concurrent thumbnail requests
interface ThumbnailRequest {
@@ -35,44 +36,44 @@ async function processRequestQueue() {
while (requestQueue.length > 0) {
// Sort queue by page number to prioritize visible pages first
requestQueue.sort((a, b) => a.pageNumber - b.pageNumber);
// Take a batch of requests (same file only for efficiency)
const batchSize = Math.min(BATCH_SIZE, requestQueue.length);
const batch = requestQueue.splice(0, batchSize);
// Group by file to process efficiently
const fileGroups = new Map<File, ThumbnailRequest[]>();
// First, resolve any cached thumbnails immediately
const uncachedRequests: ThumbnailRequest[] = [];
for (const request of batch) {
const cached = thumbnailGenerationService.getThumbnailFromCache(request.pageId);
if (cached) {
request.resolve(cached);
} else {
uncachedRequests.push(request);
if (!fileGroups.has(request.file)) {
fileGroups.set(request.file, []);
}
fileGroups.get(request.file)!.push(request);
}
}
// Process each file group with batch thumbnail generation
for (const [file, requests] of fileGroups) {
if (requests.length === 0) continue;
try {
const pageNumbers = requests.map(req => req.pageNumber);
const arrayBuffer = await file.arrayBuffer();
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;
const fileId = file.name + '_' + file.size + '_' + file.lastModified as FileId;
const results = await thumbnailGenerationService.generateThumbnails(
fileId,
arrayBuffer,
@@ -83,11 +84,11 @@ async function processRequestQueue() {
console.log(`📸 Batch progress: ${progress.completed}/${progress.total} thumbnails generated`);
}
);
// Match results back to requests and resolve
for (const request of requests) {
const result = results.find(r => r.pageNumber === request.pageNumber);
if (result && result.success && result.thumbnail) {
thumbnailGenerationService.addThumbnailToCache(request.pageId, result.thumbnail);
request.resolve(result.thumbnail);
@@ -96,7 +97,7 @@ async function processRequestQueue() {
request.resolve(null);
}
}
} catch (error) {
console.warn(`Batch thumbnail generation failed for ${requests.length} pages:`, error);
// Reject all requests in this batch
@@ -115,7 +116,7 @@ async function processRequestQueue() {
*/
export function useThumbnailGeneration() {
const generateThumbnails = useCallback(async (
fileId: string,
fileId: FileId,
pdfArrayBuffer: ArrayBuffer,
pageNumbers: number[],
options: {
@@ -157,22 +158,22 @@ export function useThumbnailGeneration() {
clearTimeout(batchTimer);
batchTimer = null;
}
// Clear the queue and active requests
requestQueue.length = 0;
activeRequests.clear();
isProcessingQueue = false;
thumbnailGenerationService.destroy();
}, []);
const clearPDFCacheForFile = useCallback((fileId: string) => {
const clearPDFCacheForFile = useCallback((fileId: FileId) => {
thumbnailGenerationService.clearPDFCacheForFile(fileId);
}, []);
const requestThumbnail = useCallback(async (
pageId: string,
file: File,
pageId: string,
file: File,
pageNumber: number
): Promise<string | null> => {
// Check cache first for immediate return
@@ -202,16 +203,16 @@ export function useThumbnailGeneration() {
reject(error);
}
});
// Schedule batch processing with a small delay to collect more requests
if (batchTimer) {
clearTimeout(batchTimer);
}
// Use shorter delay for the first batch (pages 1-50) to show visible content faster
const isFirstBatch = requestQueue.length <= BATCH_SIZE && requestQueue.every(req => req.pageNumber <= BATCH_SIZE);
const delay = isFirstBatch ? PRIORITY_BATCH_DELAY : BATCH_DELAY;
batchTimer = window.setTimeout(() => {
processRequestQueue().catch(error => {
console.error('Error processing thumbnail request queue:', error);
@@ -222,7 +223,7 @@ export function useThumbnailGeneration() {
// Track this request to prevent duplicates
activeRequests.set(pageId, requestPromise);
return requestPromise;
}, []);
@@ -236,4 +237,4 @@ export function useThumbnailGeneration() {
clearPDFCacheForFile,
requestThumbnail
};
}
}

View File

@@ -3,19 +3,20 @@ import { useTranslation } from 'react-i18next';
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { FileId } from '../types/file';
interface ToolManagementResult {
selectedTool: ToolRegistryEntry | null;
toolSelectedFileIds: string[];
toolSelectedFileIds: FileId[];
toolRegistry: Record<string, ToolRegistryEntry>;
setToolSelectedFileIds: (fileIds: string[]) => void;
setToolSelectedFileIds: (fileIds: FileId[]) => void;
getSelectedTool: (toolKey: string | null) => ToolRegistryEntry | null;
}
export const useToolManagement = (): ToolManagementResult => {
const { t } = useTranslation();
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<FileId[]>([]);
// Build endpoints list from registry entries with fallback to legacy mapping
const baseRegistry = useFlatToolRegistry();