mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-08 17:51:20 +02:00
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:
parent
5caec41d96
commit
87c63efcec
@ -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
|
||||
|
@ -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]);
|
||||
|
@ -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}
|
||||
|
@ -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(() => {
|
||||
|
@ -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(() => {
|
||||
|
@ -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(() => {
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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 (
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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': {
|
||||
|
@ -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);
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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 {};
|
||||
|
||||
|
@ -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}`);
|
||||
|
@ -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>;
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
|
@ -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(() => {
|
||||
|
28
frontend/src/tests/utils/testFileHelpers.ts
Normal file
28
frontend/src/tests/utils/testFileHelpers.ts
Normal 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)
|
||||
);
|
||||
}
|
@ -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
49
frontend/src/types/fileIdSafety.d.ts
vendored
Normal 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 {};
|
14
frontend/src/utils/fileIdSafety.ts
Normal file
14
frontend/src/utils/fileIdSafety.ts
Normal 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);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user