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 "./index.css";
|
||||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||||
|
|
||||||
|
// Import file ID debugging helpers (development only)
|
||||||
|
import "./utils/fileIdSafety";
|
||||||
|
|
||||||
// Loading component for i18next suspense
|
// Loading component for i18next suspense
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
<div
|
<div
|
||||||
|
@ -24,7 +24,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
|
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
|
||||||
|
|
||||||
// Wrapper for storeFile that generates UUID
|
// Wrapper for storeFile that generates UUID
|
||||||
const storeFileWithId = useCallback(async (file: File) => {
|
const storeStirlingFile = useCallback(async (file: File) => {
|
||||||
const fileId = createFileId(); // Generate UUID for storage
|
const fileId = createFileId(); // Generate UUID for storage
|
||||||
return await storeFile(file, fileId);
|
return await storeFile(file, fileId);
|
||||||
}, [storeFile]);
|
}, [storeFile]);
|
||||||
|
@ -16,12 +16,12 @@ import styles from './FileEditor.module.css';
|
|||||||
import FileEditorThumbnail from './FileEditorThumbnail';
|
import FileEditorThumbnail from './FileEditorThumbnail';
|
||||||
import FilePickerModal from '../shared/FilePickerModal';
|
import FilePickerModal from '../shared/FilePickerModal';
|
||||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||||
import { FileId } from '../../types/file';
|
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||||
|
|
||||||
|
|
||||||
interface FileEditorProps {
|
interface FileEditorProps {
|
||||||
onOpenPageEditor?: (file: File) => void;
|
onOpenPageEditor?: (file: StirlingFile) => void;
|
||||||
onMergeFiles?: (files: File[]) => void;
|
onMergeFiles?: (files: StirlingFile[]) => void;
|
||||||
toolMode?: boolean;
|
toolMode?: boolean;
|
||||||
showUpload?: boolean;
|
showUpload?: boolean;
|
||||||
showBulkActions?: boolean;
|
showBulkActions?: boolean;
|
||||||
@ -50,7 +50,7 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// Extract needed values from state (memoized to prevent infinite loops)
|
// Extract needed values from state (memoized to prevent infinite loops)
|
||||||
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
|
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
|
||||||
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
|
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||||
const selectedFileIds = state.ui.selectedFileIds;
|
const selectedFileIds = state.ui.selectedFileIds;
|
||||||
const isProcessing = state.ui.isProcessing;
|
const isProcessing = state.ui.isProcessing;
|
||||||
|
|
||||||
@ -92,10 +92,10 @@ const FileEditor = ({
|
|||||||
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
||||||
contextSelectedIdsRef.current = contextSelectedIds;
|
contextSelectedIdsRef.current = contextSelectedIds;
|
||||||
|
|
||||||
// Use activeFileRecords directly - no conversion needed
|
// Use activeStirlingFileStubs directly - no conversion needed
|
||||||
const localSelectedIds = contextSelectedIds;
|
const localSelectedIds = contextSelectedIds;
|
||||||
|
|
||||||
// Helper to convert FileRecord to FileThumbnail format
|
// Helper to convert StirlingFileStub to FileThumbnail format
|
||||||
const recordToFileItem = useCallback((record: any) => {
|
const recordToFileItem = useCallback((record: any) => {
|
||||||
const file = selectors.getFile(record.id);
|
const file = selectors.getFile(record.id);
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
@ -253,26 +253,26 @@ const FileEditor = ({
|
|||||||
}, [addFiles]);
|
}, [addFiles]);
|
||||||
|
|
||||||
const selectAll = useCallback(() => {
|
const selectAll = useCallback(() => {
|
||||||
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly
|
setSelectedFiles(activeStirlingFileStubs.map(r => r.id)); // Use StirlingFileStub IDs directly
|
||||||
}, [activeFileRecords, setSelectedFiles]);
|
}, [activeStirlingFileStubs, setSelectedFiles]);
|
||||||
|
|
||||||
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
|
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
|
||||||
|
|
||||||
const closeAllFiles = useCallback(() => {
|
const closeAllFiles = useCallback(() => {
|
||||||
if (activeFileRecords.length === 0) return;
|
if (activeStirlingFileStubs.length === 0) return;
|
||||||
|
|
||||||
// Remove all files from context but keep in storage
|
// Remove all files from context but keep in storage
|
||||||
const allFileIds = activeFileRecords.map(record => record.id);
|
const allFileIds = activeStirlingFileStubs.map(record => record.id);
|
||||||
removeFiles(allFileIds, false); // false = keep in storage
|
removeFiles(allFileIds, false); // false = keep in storage
|
||||||
|
|
||||||
// Clear selections
|
// Clear selections
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
}, [activeFileRecords, removeFiles, setSelectedFiles]);
|
}, [activeStirlingFileStubs, removeFiles, setSelectedFiles]);
|
||||||
|
|
||||||
const toggleFile = useCallback((fileId: FileId) => {
|
const toggleFile = useCallback((fileId: FileId) => {
|
||||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||||
|
|
||||||
const targetRecord = activeFileRecords.find(r => r.id === fileId);
|
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||||
if (!targetRecord) return;
|
if (!targetRecord) return;
|
||||||
|
|
||||||
const contextFileId = fileId; // No need to create a new ID
|
const contextFileId = fileId; // No need to create a new ID
|
||||||
@ -302,7 +302,7 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// Update context (this automatically updates tool selection since they use the same action)
|
// Update context (this automatically updates tool selection since they use the same action)
|
||||||
setSelectedFiles(newSelection);
|
setSelectedFiles(newSelection);
|
||||||
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]);
|
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
|
||||||
|
|
||||||
const toggleSelectionMode = useCallback(() => {
|
const toggleSelectionMode = useCallback(() => {
|
||||||
setSelectionMode(prev => {
|
setSelectionMode(prev => {
|
||||||
@ -316,7 +316,7 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// File reordering handler for drag and drop
|
// File reordering handler for drag and drop
|
||||||
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
||||||
const currentIds = activeFileRecords.map(r => r.id);
|
const currentIds = activeStirlingFileStubs.map(r => r.id);
|
||||||
|
|
||||||
// Find indices
|
// Find indices
|
||||||
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
||||||
@ -368,13 +368,13 @@ const FileEditor = ({
|
|||||||
// Update status
|
// Update status
|
||||||
const moveCount = filesToMove.length;
|
const moveCount = filesToMove.length;
|
||||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||||
}, [activeFileRecords, reorderFiles, setStatus]);
|
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// File operations using context
|
// File operations using context
|
||||||
const handleDeleteFile = useCallback((fileId: FileId) => {
|
const handleDeleteFile = useCallback((fileId: FileId) => {
|
||||||
const record = activeFileRecords.find(r => r.id === fileId);
|
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||||
const file = record ? selectors.getFile(record.id) : null;
|
const file = record ? selectors.getFile(record.id) : null;
|
||||||
|
|
||||||
if (record && file) {
|
if (record && file) {
|
||||||
@ -405,27 +405,27 @@ const FileEditor = ({
|
|||||||
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
||||||
setSelectedFiles(currentSelected);
|
setSelectedFiles(currentSelected);
|
||||||
}
|
}
|
||||||
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||||
|
|
||||||
const handleViewFile = useCallback((fileId: FileId) => {
|
const handleViewFile = useCallback((fileId: FileId) => {
|
||||||
const record = activeFileRecords.find(r => r.id === fileId);
|
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||||
if (record) {
|
if (record) {
|
||||||
// Set the file as selected in context and switch to viewer for preview
|
// Set the file as selected in context and switch to viewer for preview
|
||||||
setSelectedFiles([fileId]);
|
setSelectedFiles([fileId]);
|
||||||
navActions.setWorkbench('viewer');
|
navActions.setWorkbench('viewer');
|
||||||
}
|
}
|
||||||
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
|
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
|
||||||
|
|
||||||
const handleMergeFromHere = useCallback((fileId: FileId) => {
|
const handleMergeFromHere = useCallback((fileId: FileId) => {
|
||||||
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
|
const startIndex = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
||||||
if (startIndex === -1) return;
|
if (startIndex === -1) return;
|
||||||
|
|
||||||
const recordsToMerge = activeFileRecords.slice(startIndex);
|
const recordsToMerge = activeStirlingFileStubs.slice(startIndex);
|
||||||
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
|
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as StirlingFile[];
|
||||||
if (onMergeFiles) {
|
if (onMergeFiles) {
|
||||||
onMergeFiles(filesToMerge);
|
onMergeFiles(filesToMerge);
|
||||||
}
|
}
|
||||||
}, [activeFileRecords, selectors, onMergeFiles]);
|
}, [activeStirlingFileStubs, selectors, onMergeFiles]);
|
||||||
|
|
||||||
const handleSplitFile = useCallback((fileId: FileId) => {
|
const handleSplitFile = useCallback((fileId: FileId) => {
|
||||||
const file = selectors.getFile(fileId);
|
const file = selectors.getFile(fileId);
|
||||||
@ -467,7 +467,7 @@ const FileEditor = ({
|
|||||||
<Box p="md" pt="xl">
|
<Box p="md" pt="xl">
|
||||||
|
|
||||||
|
|
||||||
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
|
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||||
<Center h="60vh">
|
<Center h="60vh">
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
<Text size="lg" c="dimmed">📁</Text>
|
<Text size="lg" c="dimmed">📁</Text>
|
||||||
@ -475,7 +475,7 @@ const FileEditor = ({
|
|||||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
|
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||||
<Box>
|
<Box>
|
||||||
<SkeletonLoader type="controls" />
|
<SkeletonLoader type="controls" />
|
||||||
|
|
||||||
@ -522,7 +522,7 @@ const FileEditor = ({
|
|||||||
pointerEvents: 'auto'
|
pointerEvents: 'auto'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeFileRecords.map((record, index) => {
|
{activeStirlingFileStubs.map((record, index) => {
|
||||||
const fileItem = recordToFileItem(record);
|
const fileItem = recordToFileItem(record);
|
||||||
if (!fileItem) return null;
|
if (!fileItem) return null;
|
||||||
|
|
||||||
@ -531,7 +531,7 @@ const FileEditor = ({
|
|||||||
key={record.id}
|
key={record.id}
|
||||||
file={fileItem}
|
file={fileItem}
|
||||||
index={index}
|
index={index}
|
||||||
totalFiles={activeFileRecords.length}
|
totalFiles={activeStirlingFileStubs.length}
|
||||||
selectedFiles={localSelectedIds}
|
selectedFiles={localSelectedIds}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
onToggleFile={toggleFile}
|
onToggleFile={toggleFile}
|
||||||
|
@ -61,8 +61,8 @@ const FileEditorThumbnail = ({
|
|||||||
|
|
||||||
// Resolve the actual File object for pin/unpin operations
|
// Resolve the actual File object for pin/unpin operations
|
||||||
const actualFile = useMemo(() => {
|
const actualFile = useMemo(() => {
|
||||||
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
|
return activeFiles.find(f => f.fileId === file.id);
|
||||||
}, [activeFiles, file.name, file.size]);
|
}, [activeFiles, file.id]);
|
||||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||||
|
|
||||||
const downloadSelectedFile = useCallback(() => {
|
const downloadSelectedFile = useCallback(() => {
|
||||||
|
@ -61,8 +61,8 @@ const FileThumbnail = ({
|
|||||||
|
|
||||||
// Resolve the actual File object for pin/unpin operations
|
// Resolve the actual File object for pin/unpin operations
|
||||||
const actualFile = useMemo(() => {
|
const actualFile = useMemo(() => {
|
||||||
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
|
return activeFiles.find(f => f.fileId === file.id);
|
||||||
}, [activeFiles, file.name, file.size]);
|
}, [activeFiles, file.id]);
|
||||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||||
|
|
||||||
const downloadSelectedFile = useCallback(() => {
|
const downloadSelectedFile = useCallback(() => {
|
||||||
|
@ -27,9 +27,9 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
const globalProcessing = state.ui.isProcessing;
|
const globalProcessing = state.ui.isProcessing;
|
||||||
|
|
||||||
// Get primary file record outside useMemo to track processedFile changes
|
// Get primary file record outside useMemo to track processedFile changes
|
||||||
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null;
|
const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
|
||||||
const processedFilePages = primaryFileRecord?.processedFile?.pages;
|
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
|
||||||
const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages;
|
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
||||||
|
|
||||||
// Compute merged document with stable signature (prevents infinite loops)
|
// Compute merged document with stable signature (prevents infinite loops)
|
||||||
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
||||||
@ -38,16 +38,16 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
|
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
|
||||||
|
|
||||||
// If we have file IDs but no file record, something is wrong - return null to show loading
|
// If we have file IDs but no file record, something is wrong - return null to show loading
|
||||||
if (!primaryFileRecord) {
|
if (!primaryStirlingFileStub) {
|
||||||
console.log('🎬 PageEditor: No primary file record found, showing loading');
|
console.log('🎬 PageEditor: No primary file record found, showing loading');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
activeFileIds.length === 1
|
activeFileIds.length === 1
|
||||||
? (primaryFileRecord.name ?? 'document.pdf')
|
? (primaryStirlingFileStub.name ?? 'document.pdf')
|
||||||
: activeFileIds
|
: activeFileIds
|
||||||
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
||||||
.join(' + ');
|
.join(' + ');
|
||||||
|
|
||||||
// Build page insertion map from files with insertion positions
|
// Build page insertion map from files with insertion positions
|
||||||
@ -55,7 +55,7 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
const originalFileIds: FileId[] = [];
|
const originalFileIds: FileId[] = [];
|
||||||
|
|
||||||
activeFileIds.forEach(fileId => {
|
activeFileIds.forEach(fileId => {
|
||||||
const record = selectors.getFileRecord(fileId);
|
const record = selectors.getStirlingFileStub(fileId);
|
||||||
if (record?.insertAfterPageId !== undefined) {
|
if (record?.insertAfterPageId !== undefined) {
|
||||||
if (!insertionMap.has(record.insertAfterPageId)) {
|
if (!insertionMap.has(record.insertAfterPageId)) {
|
||||||
insertionMap.set(record.insertAfterPageId, []);
|
insertionMap.set(record.insertAfterPageId, []);
|
||||||
@ -72,12 +72,12 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
|
|
||||||
// Helper function to create pages from a file
|
// Helper function to create pages from a file
|
||||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
||||||
const fileRecord = selectors.getFileRecord(fileId);
|
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||||
if (!fileRecord) {
|
if (!stirlingFileStub) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const processedFile = fileRecord.processedFile;
|
const processedFile = stirlingFileStub.processedFile;
|
||||||
let filePages: PDFPage[] = [];
|
let filePages: PDFPage[] = [];
|
||||||
|
|
||||||
if (processedFile?.pages && processedFile.pages.length > 0) {
|
if (processedFile?.pages && processedFile.pages.length > 0) {
|
||||||
@ -159,7 +159,7 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return mergedDoc;
|
return mergedDoc;
|
||||||
}, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
|
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
|
||||||
|
|
||||||
// Large document detection for smart loading
|
// Large document detection for smart loading
|
||||||
const isVeryLargeDocument = useMemo(() => {
|
const isVeryLargeDocument = useMemo(() => {
|
||||||
|
@ -6,13 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
|
|||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
|
|
||||||
import { FileRecord } from "../../types/fileContext";
|
import { StirlingFileStub } from "../../types/fileContext";
|
||||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||||
|
|
||||||
interface FileCardProps {
|
interface FileCardProps {
|
||||||
file: File;
|
file: File;
|
||||||
record?: FileRecord;
|
record?: StirlingFileStub;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
onView?: () => void;
|
onView?: () => void;
|
||||||
@ -25,7 +25,7 @@ interface FileCardProps {
|
|||||||
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||||
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
|
const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
|
||||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
||||||
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
@ -4,15 +4,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import SortIcon from "@mui/icons-material/Sort";
|
import SortIcon from "@mui/icons-material/Sort";
|
||||||
import FileCard from "./FileCard";
|
import FileCard from "./FileCard";
|
||||||
import { FileRecord } from "../../types/fileContext";
|
import { StirlingFileStub } from "../../types/fileContext";
|
||||||
import { FileId } from "../../types/file";
|
import { FileId } from "../../types/file";
|
||||||
|
|
||||||
interface FileGridProps {
|
interface FileGridProps {
|
||||||
files: Array<{ file: File; record?: FileRecord }>;
|
files: Array<{ file: File; record?: StirlingFileStub }>;
|
||||||
onRemove?: (index: number) => void;
|
onRemove?: (index: number) => void;
|
||||||
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
|
onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||||
onView?: (item: { file: File; record?: FileRecord }) => void;
|
onView?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||||
onEdit?: (item: { file: File; record?: FileRecord }) => void;
|
onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||||
onSelect?: (fileId: FileId) => void;
|
onSelect?: (fileId: FileId) => void;
|
||||||
selectedFiles?: FileId[];
|
selectedFiles?: FileId[];
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
@ -123,9 +123,17 @@ const FileGrid = ({
|
|||||||
h="30rem"
|
h="30rem"
|
||||||
style={{ overflowY: "auto", width: "100%" }}
|
style={{ overflowY: "auto", width: "100%" }}
|
||||||
>
|
>
|
||||||
{displayFiles.map((item, idx) => {
|
{displayFiles
|
||||||
const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */;
|
.filter(item => {
|
||||||
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
|
if (!item.record?.id) {
|
||||||
|
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((item, idx) => {
|
||||||
|
const fileId = item.record!.id; // Safe to assert after filter
|
||||||
|
const originalIdx = files.findIndex(f => f.record?.id === fileId);
|
||||||
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
||||||
return (
|
return (
|
||||||
<FileCard
|
<FileCard
|
||||||
|
@ -34,7 +34,6 @@ export default function RightRail() {
|
|||||||
|
|
||||||
const activeFiles = selectors.getFiles();
|
const activeFiles = selectors.getFiles();
|
||||||
const filesSignature = selectors.getFilesSignature();
|
const filesSignature = selectors.getFilesSignature();
|
||||||
const fileRecords = selectors.getFileRecords();
|
|
||||||
|
|
||||||
// Compute selection state and total items
|
// Compute selection state and total items
|
||||||
const getSelectionState = useCallback(() => {
|
const getSelectionState = useCallback(() => {
|
||||||
@ -85,7 +84,7 @@ export default function RightRail() {
|
|||||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||||
// Download selected files (or all if none selected)
|
// Download selected files (or all if none selected)
|
||||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||||
|
|
||||||
filesToDownload.forEach(file => {
|
filesToDownload.forEach(file => {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = URL.createObjectURL(file);
|
link.href = URL.createObjectURL(file);
|
||||||
@ -206,8 +205,8 @@ export default function RightRail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||||
<div
|
<div
|
||||||
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||||
aria-hidden={currentView === 'viewer'}
|
aria-hidden={currentView === 'viewer'}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||||
@ -358,14 +357,14 @@ export default function RightRail() {
|
|||||||
<LanguageSelector position="left-start" offset={6} compact />
|
<LanguageSelector position="left-start" offset={6} compact />
|
||||||
|
|
||||||
<Tooltip content={
|
<Tooltip content={
|
||||||
currentView === 'pageEditor'
|
currentView === 'pageEditor'
|
||||||
? t('rightRail.exportAll', 'Export PDF')
|
? t('rightRail.exportAll', 'Export PDF')
|
||||||
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
|
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
|
||||||
} position="left" offset={12} arrow>
|
} position="left" offset={12} arrow>
|
||||||
<div>
|
<div>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="right-rail-icon"
|
||||||
onClick={handleExportAll}
|
onClick={handleExportAll}
|
||||||
disabled={currentView === 'viewer' || totalItems === 0}
|
disabled={currentView === 'viewer' || totalItems === 0}
|
||||||
|
@ -22,13 +22,13 @@ import {
|
|||||||
OUTPUT_OPTIONS,
|
OUTPUT_OPTIONS,
|
||||||
FIT_OPTIONS
|
FIT_OPTIONS
|
||||||
} from "../../../constants/convertConstants";
|
} from "../../../constants/convertConstants";
|
||||||
import { FileId } from "../../../types/file";
|
import { StirlingFile } from "../../../types/fileContext";
|
||||||
|
|
||||||
interface ConvertSettingsProps {
|
interface ConvertSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||||
selectedFiles: File[];
|
selectedFiles: StirlingFile[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ const ConvertSettings = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filterFilesByExtension = (extension: string) => {
|
const filterFilesByExtension = (extension: string) => {
|
||||||
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
|
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as StirlingFile[];
|
||||||
return files.filter(file => {
|
return files.filter(file => {
|
||||||
const fileExtension = detectFileExtension(file.name);
|
const fileExtension = detectFileExtension(file.name);
|
||||||
|
|
||||||
@ -143,21 +143,8 @@ const ConvertSettings = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateFileSelection = (files: File[]) => {
|
const updateFileSelection = (files: StirlingFile[]) => {
|
||||||
// Map File objects to their actual IDs in FileContext
|
const fileIds = files.map(file => file.fileId);
|
||||||
const fileIds = files.map(file => {
|
|
||||||
// Find the file ID by matching file properties
|
|
||||||
const fileRecord = state.files.ids
|
|
||||||
.map(id => selectors.getFileRecord(id))
|
|
||||||
.find(record =>
|
|
||||||
record &&
|
|
||||||
record.name === file.name &&
|
|
||||||
record.size === file.size &&
|
|
||||||
record.lastModified === file.lastModified
|
|
||||||
);
|
|
||||||
return fileRecord?.id;
|
|
||||||
}).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
|
|
||||||
|
|
||||||
setSelectedFiles(fileIds);
|
setSelectedFiles(fileIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
||||||
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
|
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
|
||||||
|
import { StirlingFile } from '../../../types/fileContext';
|
||||||
|
|
||||||
interface ConvertToPdfaSettingsProps {
|
interface ConvertToPdfaSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
selectedFiles: File[];
|
selectedFiles: StirlingFile[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
|
|||||||
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
|
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
|
||||||
import { useAllFiles } from "../../../contexts/FileContext";
|
import { useAllFiles } from "../../../contexts/FileContext";
|
||||||
import { useFileManager } from "../../../hooks/useFileManager";
|
import { useFileManager } from "../../../hooks/useFileManager";
|
||||||
|
import { StirlingFile } from "../../../types/fileContext";
|
||||||
|
|
||||||
export interface FileStatusIndicatorProps {
|
export interface FileStatusIndicatorProps {
|
||||||
selectedFiles?: File[];
|
selectedFiles?: StirlingFile[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,7 +18,7 @@ const FileStatusIndicator = ({
|
|||||||
}: FileStatusIndicatorProps) => {
|
}: FileStatusIndicatorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
||||||
const { files: workbenchFiles } = useAllFiles();
|
const { files: stirlingFileStubs } = useAllFiles();
|
||||||
const { loadRecentFiles } = useFileManager();
|
const { loadRecentFiles } = useFileManager();
|
||||||
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
||||||
|
|
||||||
@ -55,7 +56,7 @@ const FileStatusIndicator = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are no files in the workbench
|
// Check if there are no files in the workbench
|
||||||
if (workbenchFiles.length === 0) {
|
if (stirlingFileStubs.length === 0) {
|
||||||
// If no recent files, show upload button
|
// If no recent files, show upload button
|
||||||
if (!hasRecentFiles) {
|
if (!hasRecentFiles) {
|
||||||
return (
|
return (
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import FileStatusIndicator from './FileStatusIndicator';
|
import FileStatusIndicator from './FileStatusIndicator';
|
||||||
|
import { StirlingFile } from '../../../types/fileContext';
|
||||||
|
|
||||||
export interface FilesToolStepProps {
|
export interface FilesToolStepProps {
|
||||||
selectedFiles: File[];
|
selectedFiles: StirlingFile[];
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onCollapsedClick?: () => void;
|
onCollapsedClick?: () => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
|
|||||||
import OperationButton from './OperationButton';
|
import OperationButton from './OperationButton';
|
||||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||||
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
|
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
|
||||||
|
import { StirlingFile } from '../../../types/fileContext';
|
||||||
|
|
||||||
export interface FilesStepConfig {
|
export interface FilesStepConfig {
|
||||||
selectedFiles: File[];
|
selectedFiles: StirlingFile[];
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onCollapsedClick?: () => void;
|
onCollapsedClick?: () => void;
|
||||||
|
@ -15,6 +15,7 @@ import { fileStorage } from "../../services/fileStorage";
|
|||||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||||
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
|
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
|
||||||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||||
|
import { isFileObject } from "../../types/fileContext";
|
||||||
import { FileId } from "../../types/file";
|
import { FileId } from "../../types/file";
|
||||||
|
|
||||||
|
|
||||||
@ -201,7 +202,7 @@ const Viewer = ({
|
|||||||
const effectiveFile = React.useMemo(() => {
|
const effectiveFile = React.useMemo(() => {
|
||||||
if (previewFile) {
|
if (previewFile) {
|
||||||
// Validate the preview file
|
// Validate the preview file
|
||||||
if (!(previewFile instanceof File)) {
|
if (!isFileObject(previewFile)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,7 +19,10 @@ import {
|
|||||||
FileContextStateValue,
|
FileContextStateValue,
|
||||||
FileContextActionsValue,
|
FileContextActionsValue,
|
||||||
FileContextActions,
|
FileContextActions,
|
||||||
FileRecord
|
FileId,
|
||||||
|
StirlingFileStub,
|
||||||
|
StirlingFile,
|
||||||
|
createStirlingFile
|
||||||
} from '../types/fileContext';
|
} from '../types/fileContext';
|
||||||
|
|
||||||
// Import modular components
|
// Import modular components
|
||||||
@ -29,7 +32,6 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions
|
|||||||
import { FileLifecycleManager } from './file/lifecycle';
|
import { FileLifecycleManager } from './file/lifecycle';
|
||||||
import { FileStateContext, FileActionsContext } from './file/contexts';
|
import { FileStateContext, FileActionsContext } from './file/contexts';
|
||||||
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
||||||
import { FileId } from '../types/file';
|
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === 'development';
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
@ -79,7 +81,7 @@ function FileContextInner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// File operations using unified addFiles helper with persistence
|
// File operations using unified addFiles helper with persistence
|
||||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<File[]> => {
|
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||||
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
|
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||||
|
|
||||||
// Auto-select the newly added files if requested
|
// Auto-select the newly added files if requested
|
||||||
@ -98,15 +100,15 @@ function FileContextInner({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return addedFilesWithIds.map(({ file }) => file);
|
return addedFilesWithIds.map(({ file, id }) => createStirlingFile(file, id));
|
||||||
}, [indexedDB, enablePersistence]);
|
}, [indexedDB, enablePersistence]);
|
||||||
|
|
||||||
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => {
|
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<StirlingFile[]> => {
|
||||||
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
|
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||||
return result.map(({ file }) => file);
|
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<File[]> => {
|
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||||
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||||
|
|
||||||
// Auto-select the newly added files if requested
|
// Auto-select the newly added files if requested
|
||||||
@ -114,7 +116,7 @@ function FileContextInner({
|
|||||||
selectFiles(result);
|
selectFiles(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.map(({ file }) => file);
|
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Action creators
|
// Action creators
|
||||||
@ -122,11 +124,11 @@ function FileContextInner({
|
|||||||
|
|
||||||
// Helper functions for pinned files
|
// Helper functions for pinned files
|
||||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
|
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
|
||||||
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch, indexedDB);
|
return consumeFiles(inputFileIds, outputFiles, filesRef, dispatch, indexedDB);
|
||||||
}, [indexedDB]);
|
}, [indexedDB]);
|
||||||
|
|
||||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => {
|
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
|
||||||
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
|
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
|
||||||
}, [indexedDB]);
|
}, [indexedDB]);
|
||||||
|
|
||||||
// Helper to find FileId from File object
|
// Helper to find FileId from File object
|
||||||
@ -140,24 +142,14 @@ function FileContextInner({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// File-to-ID wrapper functions for pinning
|
// File pinning functions - use StirlingFile directly
|
||||||
const pinFileWrapper = useCallback((file: File) => {
|
const pinFileWrapper = useCallback((file: StirlingFile) => {
|
||||||
const fileId = findFileId(file);
|
baseActions.pinFile(file.fileId);
|
||||||
if (fileId) {
|
}, [baseActions]);
|
||||||
baseActions.pinFile(fileId);
|
|
||||||
} else {
|
|
||||||
console.warn('File not found for pinning:', file.name);
|
|
||||||
}
|
|
||||||
}, [baseActions, findFileId]);
|
|
||||||
|
|
||||||
const unpinFileWrapper = useCallback((file: File) => {
|
const unpinFileWrapper = useCallback((file: StirlingFile) => {
|
||||||
const fileId = findFileId(file);
|
baseActions.unpinFile(file.fileId);
|
||||||
if (fileId) {
|
}, [baseActions]);
|
||||||
baseActions.unpinFile(fileId);
|
|
||||||
} else {
|
|
||||||
console.warn('File not found for unpinning:', file.name);
|
|
||||||
}
|
|
||||||
}, [baseActions, findFileId]);
|
|
||||||
|
|
||||||
// Complete actions object
|
// Complete actions object
|
||||||
const actions = useMemo<FileContextActions>(() => ({
|
const actions = useMemo<FileContextActions>(() => ({
|
||||||
@ -178,8 +170,8 @@ function FileContextInner({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
|
updateStirlingFileStub: (fileId: FileId, updates: Partial<StirlingFileStub>) =>
|
||||||
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
|
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef),
|
||||||
reorderFiles: (orderedFileIds: FileId[]) => {
|
reorderFiles: (orderedFileIds: FileId[]) => {
|
||||||
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
|
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
|
||||||
},
|
},
|
||||||
@ -303,7 +295,7 @@ export {
|
|||||||
useFileSelection,
|
useFileSelection,
|
||||||
useFileManagement,
|
useFileManagement,
|
||||||
useFileUI,
|
useFileUI,
|
||||||
useFileRecord,
|
useStirlingFileStub,
|
||||||
useAllFiles,
|
useAllFiles,
|
||||||
useSelectedFiles,
|
useSelectedFiles,
|
||||||
// Primary API hooks for tools
|
// Primary API hooks for tools
|
||||||
|
@ -6,7 +6,7 @@ import { FileId } from '../../types/file';
|
|||||||
import {
|
import {
|
||||||
FileContextState,
|
FileContextState,
|
||||||
FileContextAction,
|
FileContextAction,
|
||||||
FileRecord
|
StirlingFileStub
|
||||||
} from '../../types/fileContext';
|
} from '../../types/fileContext';
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
@ -29,7 +29,7 @@ export const initialFileContextState: FileContextState = {
|
|||||||
function processFileSwap(
|
function processFileSwap(
|
||||||
state: FileContextState,
|
state: FileContextState,
|
||||||
filesToRemove: FileId[],
|
filesToRemove: FileId[],
|
||||||
filesToAdd: FileRecord[]
|
filesToAdd: StirlingFileStub[]
|
||||||
): FileContextState {
|
): FileContextState {
|
||||||
// Only remove unpinned files
|
// Only remove unpinned files
|
||||||
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
|
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
|
||||||
@ -70,11 +70,11 @@ function processFileSwap(
|
|||||||
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'ADD_FILES': {
|
case 'ADD_FILES': {
|
||||||
const { fileRecords } = action.payload;
|
const { stirlingFileStubs } = action.payload;
|
||||||
const newIds: FileId[] = [];
|
const newIds: FileId[] = [];
|
||||||
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
const newById: Record<FileId, StirlingFileStub> = { ...state.files.byId };
|
||||||
|
|
||||||
fileRecords.forEach(record => {
|
stirlingFileStubs.forEach(record => {
|
||||||
// Only add if not already present (dedupe by stable ID)
|
// Only add if not already present (dedupe by stable ID)
|
||||||
if (!newById[record.id]) {
|
if (!newById[record.id]) {
|
||||||
newIds.push(record.id);
|
newIds.push(record.id);
|
||||||
@ -233,13 +233,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'CONSUME_FILES': {
|
case 'CONSUME_FILES': {
|
||||||
const { inputFileIds, outputFileRecords } = action.payload;
|
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||||
return processFileSwap(state, inputFileIds, outputFileRecords);
|
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'UNDO_CONSUME_FILES': {
|
case 'UNDO_CONSUME_FILES': {
|
||||||
const { inputFileRecords, outputFileIds } = action.payload;
|
const { inputStirlingFileStubs, outputFileIds } = action.payload;
|
||||||
return processFileSwap(state, outputFileIds, inputFileRecords);
|
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'RESET_CONTEXT': {
|
case 'RESET_CONTEXT': {
|
||||||
|
@ -3,10 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FileRecord,
|
StirlingFileStub,
|
||||||
FileContextAction,
|
FileContextAction,
|
||||||
FileContextState,
|
FileContextState,
|
||||||
toFileRecord,
|
toStirlingFileStub,
|
||||||
createFileId,
|
createFileId,
|
||||||
createQuickKey
|
createQuickKey
|
||||||
} from '../../types/fileContext';
|
} from '../../types/fileContext';
|
||||||
@ -109,8 +109,8 @@ export async function addFiles(
|
|||||||
await addFilesMutex.lock();
|
await addFilesMutex.lock();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileRecords: FileRecord[] = [];
|
const stirlingFileStubs: StirlingFileStub[] = [];
|
||||||
const addedFiles: AddedFile[] = [];
|
const addedFiles: AddedFile[] = [];
|
||||||
|
|
||||||
// Build quickKey lookup from existing files for deduplication
|
// Build quickKey lookup from existing files for deduplication
|
||||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||||
@ -163,7 +163,7 @@ export async function addFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create record with immediate thumbnail and page metadata
|
// Create record with immediate thumbnail and page metadata
|
||||||
const record = toFileRecord(file, fileId);
|
const record = toStirlingFileStub(file, fileId);
|
||||||
if (thumbnail) {
|
if (thumbnail) {
|
||||||
record.thumbnailUrl = thumbnail;
|
record.thumbnailUrl = thumbnail;
|
||||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||||
@ -184,7 +184,7 @@ export async function addFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
stirlingFileStubs.push(record);
|
||||||
addedFiles.push({ file, id: fileId, thumbnail });
|
addedFiles.push({ file, id: fileId, thumbnail });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -205,7 +205,7 @@ export async function addFiles(
|
|||||||
const fileId = createFileId();
|
const fileId = createFileId();
|
||||||
filesRef.current.set(fileId, file);
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
const record = toFileRecord(file, fileId);
|
const record = toStirlingFileStub(file, fileId);
|
||||||
if (thumbnail) {
|
if (thumbnail) {
|
||||||
record.thumbnailUrl = thumbnail;
|
record.thumbnailUrl = thumbnail;
|
||||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||||
@ -226,7 +226,7 @@ export async function addFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
stirlingFileStubs.push(record);
|
||||||
addedFiles.push({ file, id: fileId, thumbnail });
|
addedFiles.push({ file, id: fileId, thumbnail });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -254,7 +254,7 @@ export async function addFiles(
|
|||||||
|
|
||||||
filesRef.current.set(fileId, file);
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
const record = toFileRecord(file, fileId);
|
const record = toStirlingFileStub(file, fileId);
|
||||||
|
|
||||||
// Generate processedFile metadata for stored files
|
// Generate processedFile metadata for stored files
|
||||||
let pageCount: number = 1;
|
let pageCount: number = 1;
|
||||||
@ -301,7 +301,7 @@ export async function addFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
stirlingFileStubs.push(record);
|
||||||
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -310,9 +310,9 @@ export async function addFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch ADD_FILES action if we have new files
|
// Dispatch ADD_FILES action if we have new files
|
||||||
if (fileRecords.length > 0) {
|
if (stirlingFileStubs.length > 0) {
|
||||||
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
|
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
|
||||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
|
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${stirlingFileStubs.length} files`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return addedFiles;
|
return addedFiles;
|
||||||
@ -328,7 +328,7 @@ export async function addFiles(
|
|||||||
async function processFilesIntoRecords(
|
async function processFilesIntoRecords(
|
||||||
files: File[],
|
files: File[],
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||||
): Promise<Array<{ record: FileRecord; file: File; fileId: FileId; thumbnail?: string }>> {
|
): Promise<Array<{ record: StirlingFileStub; file: File; fileId: FileId; thumbnail?: string }>> {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
files.map(async (file) => {
|
files.map(async (file) => {
|
||||||
const fileId = createFileId();
|
const fileId = createFileId();
|
||||||
@ -347,7 +347,7 @@ async function processFilesIntoRecords(
|
|||||||
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
|
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = toFileRecord(file, fileId);
|
const record = toStirlingFileStub(file, fileId);
|
||||||
if (thumbnail) {
|
if (thumbnail) {
|
||||||
record.thumbnailUrl = thumbnail;
|
record.thumbnailUrl = thumbnail;
|
||||||
}
|
}
|
||||||
@ -365,10 +365,10 @@ async function processFilesIntoRecords(
|
|||||||
* Helper function to persist files to IndexedDB
|
* Helper function to persist files to IndexedDB
|
||||||
*/
|
*/
|
||||||
async function persistFilesToIndexedDB(
|
async function persistFilesToIndexedDB(
|
||||||
fileRecords: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
stirlingFileStubs: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
||||||
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
|
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => {
|
await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => {
|
||||||
try {
|
try {
|
||||||
await indexedDB.saveFile(file, fileId, thumbnail);
|
await indexedDB.saveFile(file, fileId, thumbnail);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -383,7 +383,6 @@ async function persistFilesToIndexedDB(
|
|||||||
export async function consumeFiles(
|
export async function consumeFiles(
|
||||||
inputFileIds: FileId[],
|
inputFileIds: FileId[],
|
||||||
outputFiles: File[],
|
outputFiles: File[],
|
||||||
stateRef: React.MutableRefObject<FileContextState>,
|
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
dispatch: React.Dispatch<FileContextAction>,
|
dispatch: React.Dispatch<FileContextAction>,
|
||||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
|
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
|
||||||
@ -391,11 +390,11 @@ export async function consumeFiles(
|
|||||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||||
|
|
||||||
// Process output files with thumbnails and metadata
|
// Process output files with thumbnails and metadata
|
||||||
const outputFileRecords = await processFilesIntoRecords(outputFiles, filesRef);
|
const outputStirlingFileStubs = await processFilesIntoRecords(outputFiles, filesRef);
|
||||||
|
|
||||||
// Persist output files to IndexedDB if available
|
// Persist output files to IndexedDB if available
|
||||||
if (indexedDB) {
|
if (indexedDB) {
|
||||||
await persistFilesToIndexedDB(outputFileRecords, indexedDB);
|
await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch the consume action
|
// Dispatch the consume action
|
||||||
@ -403,21 +402,21 @@ export async function consumeFiles(
|
|||||||
type: 'CONSUME_FILES',
|
type: 'CONSUME_FILES',
|
||||||
payload: {
|
payload: {
|
||||||
inputFileIds,
|
inputFileIds,
|
||||||
outputFileRecords: outputFileRecords.map(({ record }) => record)
|
outputStirlingFileStubs: outputStirlingFileStubs.map(({ record }) => record)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`);
|
||||||
|
|
||||||
// Return the output file IDs for undo tracking
|
// Return the output file IDs for undo tracking
|
||||||
return outputFileRecords.map(({ fileId }) => fileId);
|
return outputStirlingFileStubs.map(({ fileId }) => fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to restore files to filesRef and manage IndexedDB cleanup
|
* Helper function to restore files to filesRef and manage IndexedDB cleanup
|
||||||
*/
|
*/
|
||||||
async function restoreFilesAndCleanup(
|
async function restoreFilesAndCleanup(
|
||||||
filesToRestore: Array<{ file: File; record: FileRecord }>,
|
filesToRestore: Array<{ file: File; record: StirlingFileStub }>,
|
||||||
fileIdsToRemove: FileId[],
|
fileIdsToRemove: FileId[],
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
|
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||||
@ -440,7 +439,7 @@ async function restoreFilesAndCleanup(
|
|||||||
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
|
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the file to filesRef
|
// Restore the file to filesRef
|
||||||
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
|
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
|
||||||
filesRef.current.set(record.id, file);
|
filesRef.current.set(record.id, file);
|
||||||
@ -455,7 +454,7 @@ async function restoreFilesAndCleanup(
|
|||||||
throw error; // Re-throw to trigger rollback
|
throw error; // Re-throw to trigger rollback
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Execute all IndexedDB operations
|
// Execute all IndexedDB operations
|
||||||
await Promise.all(indexedDBPromises);
|
await Promise.all(indexedDBPromises);
|
||||||
}
|
}
|
||||||
@ -466,28 +465,28 @@ async function restoreFilesAndCleanup(
|
|||||||
*/
|
*/
|
||||||
export async function undoConsumeFiles(
|
export async function undoConsumeFiles(
|
||||||
inputFiles: File[],
|
inputFiles: File[],
|
||||||
inputFileRecords: FileRecord[],
|
inputStirlingFileStubs: StirlingFileStub[],
|
||||||
outputFileIds: FileId[],
|
outputFileIds: FileId[],
|
||||||
stateRef: React.MutableRefObject<FileContextState>,
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
dispatch: React.Dispatch<FileContextAction>,
|
dispatch: React.Dispatch<FileContextAction>,
|
||||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
|
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputFileRecords.length} input files, removing ${outputFileIds.length} output files`);
|
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputStirlingFileStubs.length} input files, removing ${outputFileIds.length} output files`);
|
||||||
|
|
||||||
// Validate inputs
|
// Validate inputs
|
||||||
if (inputFiles.length !== inputFileRecords.length) {
|
if (inputFiles.length !== inputStirlingFileStubs.length) {
|
||||||
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`);
|
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputStirlingFileStubs.length})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a backup of current filesRef state for rollback
|
// Create a backup of current filesRef state for rollback
|
||||||
const backupFilesRef = new Map(filesRef.current);
|
const backupFilesRef = new Map(filesRef.current);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Prepare files to restore
|
// Prepare files to restore
|
||||||
const filesToRestore = inputFiles.map((file, index) => ({
|
const filesToRestore = inputFiles.map((file, index) => ({
|
||||||
file,
|
file,
|
||||||
record: inputFileRecords[index]
|
record: inputStirlingFileStubs[index]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Restore input files and clean up output files
|
// Restore input files and clean up output files
|
||||||
@ -502,13 +501,13 @@ export async function undoConsumeFiles(
|
|||||||
dispatch({
|
dispatch({
|
||||||
type: 'UNDO_CONSUME_FILES',
|
type: 'UNDO_CONSUME_FILES',
|
||||||
payload: {
|
payload: {
|
||||||
inputFileRecords,
|
inputStirlingFileStubs,
|
||||||
outputFileIds
|
outputFileIds
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputFileRecords.length} inputs, removed ${outputFileIds.length} outputs`);
|
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputStirlingFileStubs.length} inputs, removed ${outputFileIds.length} outputs`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Rollback filesRef to previous state
|
// Rollback filesRef to previous state
|
||||||
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
|
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
FileContextStateValue,
|
FileContextStateValue,
|
||||||
FileContextActionsValue
|
FileContextActionsValue
|
||||||
} from './contexts';
|
} from './contexts';
|
||||||
import { FileRecord } from '../../types/fileContext';
|
import { StirlingFileStub, StirlingFile } from '../../types/fileContext';
|
||||||
import { FileId } from '../../types/file';
|
import { FileId } from '../../types/file';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -38,13 +38,13 @@ export function useFileActions(): FileContextActionsValue {
|
|||||||
/**
|
/**
|
||||||
* Hook for current/primary file (first in list)
|
* Hook for current/primary file (first in list)
|
||||||
*/
|
*/
|
||||||
export function useCurrentFile(): { file?: File; record?: FileRecord } {
|
export function useCurrentFile(): { file?: File; record?: StirlingFileStub } {
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
const primaryFileId = state.files.ids[0];
|
const primaryFileId = state.files.ids[0];
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
||||||
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined
|
record: primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : undefined
|
||||||
}), [primaryFileId, selectors]);
|
}), [primaryFileId, selectors]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ export function useFileManagement() {
|
|||||||
addFiles: actions.addFiles,
|
addFiles: actions.addFiles,
|
||||||
removeFiles: actions.removeFiles,
|
removeFiles: actions.removeFiles,
|
||||||
clearAllFiles: actions.clearAllFiles,
|
clearAllFiles: actions.clearAllFiles,
|
||||||
updateFileRecord: actions.updateFileRecord,
|
updateStirlingFileStub: actions.updateStirlingFileStub,
|
||||||
reorderFiles: actions.reorderFiles
|
reorderFiles: actions.reorderFiles
|
||||||
}), [actions]);
|
}), [actions]);
|
||||||
}
|
}
|
||||||
@ -111,24 +111,24 @@ export function useFileUI() {
|
|||||||
/**
|
/**
|
||||||
* Hook for specific file by ID (optimized for individual file access)
|
* Hook for specific file by ID (optimized for individual file access)
|
||||||
*/
|
*/
|
||||||
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
|
export function useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub } {
|
||||||
const { selectors } = useFileState();
|
const { selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
file: selectors.getFile(fileId),
|
file: selectors.getFile(fileId),
|
||||||
record: selectors.getFileRecord(fileId)
|
record: selectors.getStirlingFileStub(fileId)
|
||||||
}), [fileId, selectors]);
|
}), [fileId, selectors]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for all files (use sparingly - causes re-renders on file list changes)
|
* Hook for all files (use sparingly - causes re-renders on file list changes)
|
||||||
*/
|
*/
|
||||||
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
files: selectors.getFiles(),
|
files: selectors.getFiles(),
|
||||||
records: selectors.getFileRecords(),
|
records: selectors.getStirlingFileStubs(),
|
||||||
fileIds: state.files.ids
|
fileIds: state.files.ids
|
||||||
}), [state.files.ids, selectors]);
|
}), [state.files.ids, selectors]);
|
||||||
}
|
}
|
||||||
@ -136,12 +136,12 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds:
|
|||||||
/**
|
/**
|
||||||
* Hook for selected files (optimized for selection-based UI)
|
* Hook for selected files (optimized for selection-based UI)
|
||||||
*/
|
*/
|
||||||
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
files: selectors.getSelectedFiles(),
|
files: selectors.getSelectedFiles(),
|
||||||
records: selectors.getSelectedFileRecords(),
|
records: selectors.getSelectedStirlingFileStubs(),
|
||||||
fileIds: state.ui.selectedFileIds
|
fileIds: state.ui.selectedFileIds
|
||||||
}), [state.ui.selectedFileIds, selectors]);
|
}), [state.ui.selectedFileIds, selectors]);
|
||||||
}
|
}
|
||||||
|
@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
import { FileId } from '../../types/file';
|
import { FileId } from '../../types/file';
|
||||||
import {
|
import {
|
||||||
FileRecord,
|
StirlingFileStub,
|
||||||
FileContextState,
|
FileContextState,
|
||||||
FileContextSelectors
|
FileContextSelectors,
|
||||||
|
StirlingFile,
|
||||||
|
createStirlingFile
|
||||||
} from '../../types/fileContext';
|
} from '../../types/fileContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,16 +19,24 @@ export function createFileSelectors(
|
|||||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||||
): FileContextSelectors {
|
): FileContextSelectors {
|
||||||
return {
|
return {
|
||||||
getFile: (id: FileId) => filesRef.current.get(id),
|
getFile: (id: FileId) => {
|
||||||
|
const file = filesRef.current.get(id);
|
||||||
|
return file ? createStirlingFile(file, id) : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
getFiles: (ids?: FileId[]) => {
|
getFiles: (ids?: FileId[]) => {
|
||||||
const currentIds = ids || stateRef.current.files.ids;
|
const currentIds = ids || stateRef.current.files.ids;
|
||||||
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
|
return currentIds
|
||||||
|
.map(id => {
|
||||||
|
const file = filesRef.current.get(id);
|
||||||
|
return file ? createStirlingFile(file, id) : undefined;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as StirlingFile[];
|
||||||
},
|
},
|
||||||
|
|
||||||
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
|
getStirlingFileStub: (id: FileId) => stateRef.current.files.byId[id],
|
||||||
|
|
||||||
getFileRecords: (ids?: FileId[]) => {
|
getStirlingFileStubs: (ids?: FileId[]) => {
|
||||||
const currentIds = ids || stateRef.current.files.ids;
|
const currentIds = ids || stateRef.current.files.ids;
|
||||||
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
||||||
},
|
},
|
||||||
@ -35,11 +45,14 @@ export function createFileSelectors(
|
|||||||
|
|
||||||
getSelectedFiles: () => {
|
getSelectedFiles: () => {
|
||||||
return stateRef.current.ui.selectedFileIds
|
return stateRef.current.ui.selectedFileIds
|
||||||
.map(id => filesRef.current.get(id))
|
.map(id => {
|
||||||
.filter(Boolean) as File[];
|
const file = filesRef.current.get(id);
|
||||||
|
return file ? createStirlingFile(file, id) : undefined;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as StirlingFile[];
|
||||||
},
|
},
|
||||||
|
|
||||||
getSelectedFileRecords: () => {
|
getSelectedStirlingFileStubs: () => {
|
||||||
return stateRef.current.ui.selectedFileIds
|
return stateRef.current.ui.selectedFileIds
|
||||||
.map(id => stateRef.current.files.byId[id])
|
.map(id => stateRef.current.files.byId[id])
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@ -52,26 +65,21 @@ export function createFileSelectors(
|
|||||||
|
|
||||||
getPinnedFiles: () => {
|
getPinnedFiles: () => {
|
||||||
return Array.from(stateRef.current.pinnedFiles)
|
return Array.from(stateRef.current.pinnedFiles)
|
||||||
.map(id => filesRef.current.get(id))
|
.map(id => {
|
||||||
.filter(Boolean) as File[];
|
const file = filesRef.current.get(id);
|
||||||
|
return file ? createStirlingFile(file, id) : undefined;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as StirlingFile[];
|
||||||
},
|
},
|
||||||
|
|
||||||
getPinnedFileRecords: () => {
|
getPinnedStirlingFileStubs: () => {
|
||||||
return Array.from(stateRef.current.pinnedFiles)
|
return Array.from(stateRef.current.pinnedFiles)
|
||||||
.map(id => stateRef.current.files.byId[id])
|
.map(id => stateRef.current.files.byId[id])
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
},
|
},
|
||||||
|
|
||||||
isFilePinned: (file: File) => {
|
isFilePinned: (file: StirlingFile) => {
|
||||||
// Find FileId by matching File object properties
|
return stateRef.current.pinnedFiles.has(file.fileId);
|
||||||
const fileId = (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
|
|
||||||
const storedFile = filesRef.current.get(id);
|
|
||||||
return storedFile &&
|
|
||||||
storedFile.name === file.name &&
|
|
||||||
storedFile.size === file.size &&
|
|
||||||
storedFile.lastModified === file.lastModified;
|
|
||||||
});
|
|
||||||
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Stable signature for effects - prevents unnecessary re-renders
|
// Stable signature for effects - prevents unnecessary re-renders
|
||||||
@ -90,9 +98,9 @@ export function createFileSelectors(
|
|||||||
/**
|
/**
|
||||||
* Helper for building quickKey sets for deduplication
|
* Helper for building quickKey sets for deduplication
|
||||||
*/
|
*/
|
||||||
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
|
export function buildQuickKeySet(stirlingFileStubs: Record<FileId, StirlingFileStub>): Set<string> {
|
||||||
const quickKeys = new Set<string>();
|
const quickKeys = new Set<string>();
|
||||||
Object.values(fileRecords).forEach(record => {
|
Object.values(stirlingFileStubs).forEach(record => {
|
||||||
if (record.quickKey) {
|
if (record.quickKey) {
|
||||||
quickKeys.add(record.quickKey);
|
quickKeys.add(record.quickKey);
|
||||||
}
|
}
|
||||||
@ -119,7 +127,7 @@ export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; siz
|
|||||||
export function getPrimaryFile(
|
export function getPrimaryFile(
|
||||||
stateRef: React.MutableRefObject<FileContextState>,
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||||
): { file?: File; record?: FileRecord } {
|
): { file?: File; record?: StirlingFileStub } {
|
||||||
const primaryFileId = stateRef.current.files.ids[0];
|
const primaryFileId = stateRef.current.files.ids[0];
|
||||||
if (!primaryFileId) return {};
|
if (!primaryFileId) return {};
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FileId } from '../../types/file';
|
import { FileId } from '../../types/file';
|
||||||
import { FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
|
import { FileContextAction, StirlingFileStub, ProcessedFilePage } from '../../types/fileContext';
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === 'development';
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
@ -166,7 +166,7 @@ export class FileLifecycleManager {
|
|||||||
/**
|
/**
|
||||||
* Update file record with race condition guards
|
* Update file record with race condition guards
|
||||||
*/
|
*/
|
||||||
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => {
|
updateStirlingFileStub = (fileId: FileId, updates: Partial<StirlingFileStub>, stateRef?: React.MutableRefObject<any>): void => {
|
||||||
// Guard against updating removed files (race condition protection)
|
// Guard against updating removed files (race condition protection)
|
||||||
if (!this.filesRef.current.has(fileId)) {
|
if (!this.filesRef.current.has(fileId)) {
|
||||||
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
|
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
|
||||||
|
@ -4,10 +4,11 @@ import { useEndpointEnabled } from '../../useEndpointConfig';
|
|||||||
import { BaseToolProps } from '../../../types/tool';
|
import { BaseToolProps } from '../../../types/tool';
|
||||||
import { ToolOperationHook } from './useToolOperation';
|
import { ToolOperationHook } from './useToolOperation';
|
||||||
import { BaseParametersHook } from './useBaseParameters';
|
import { BaseParametersHook } from './useBaseParameters';
|
||||||
|
import { StirlingFile } from '../../../types/fileContext';
|
||||||
|
|
||||||
interface BaseToolReturn<TParams> {
|
interface BaseToolReturn<TParams> {
|
||||||
// File management
|
// File management
|
||||||
selectedFiles: File[];
|
selectedFiles: StirlingFile[];
|
||||||
|
|
||||||
// Tool-specific hooks
|
// Tool-specific hooks
|
||||||
params: BaseParametersHook<TParams>;
|
params: BaseParametersHook<TParams>;
|
||||||
|
@ -6,10 +6,8 @@ import { useToolState, type ProcessingProgress } from './useToolState';
|
|||||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||||
import { useToolResources } from './useToolResources';
|
import { useToolResources } from './useToolResources';
|
||||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||||
import { createOperation } from '../../../utils/toolOperationTracker';
|
import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext';
|
||||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||||
import { FileId } from '../../../types/file';
|
|
||||||
import { FileRecord } from '../../../types/fileContext';
|
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export type { ProcessingProgress, ResponseHandler };
|
export type { ProcessingProgress, ResponseHandler };
|
||||||
@ -104,7 +102,7 @@ export interface ToolOperationHook<TParams = void> {
|
|||||||
progress: ProcessingProgress | null;
|
progress: ProcessingProgress | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
executeOperation: (params: TParams, selectedFiles: File[]) => Promise<void>;
|
executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise<void>;
|
||||||
resetResults: () => void;
|
resetResults: () => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
cancelOperation: () => void;
|
cancelOperation: () => void;
|
||||||
@ -130,7 +128,7 @@ export const useToolOperation = <TParams>(
|
|||||||
config: ToolOperationConfig<TParams>
|
config: ToolOperationConfig<TParams>
|
||||||
): ToolOperationHook<TParams> => {
|
): ToolOperationHook<TParams> => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext();
|
const { addFiles, consumeFiles, undoConsumeFiles, actions: fileActions, selectors } = useFileContext();
|
||||||
|
|
||||||
// Composed hooks
|
// Composed hooks
|
||||||
const { state, actions } = useToolState();
|
const { state, actions } = useToolState();
|
||||||
@ -140,13 +138,13 @@ export const useToolOperation = <TParams>(
|
|||||||
// Track last operation for undo functionality
|
// Track last operation for undo functionality
|
||||||
const lastOperationRef = useRef<{
|
const lastOperationRef = useRef<{
|
||||||
inputFiles: File[];
|
inputFiles: File[];
|
||||||
inputFileRecords: FileRecord[];
|
inputStirlingFileStubs: StirlingFileStub[];
|
||||||
outputFileIds: FileId[];
|
outputFileIds: FileId[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
const executeOperation = useCallback(async (
|
||||||
params: TParams,
|
params: TParams,
|
||||||
selectedFiles: File[]
|
selectedFiles: StirlingFile[]
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// Validation
|
// Validation
|
||||||
if (selectedFiles.length === 0) {
|
if (selectedFiles.length === 0) {
|
||||||
@ -160,9 +158,6 @@ export const useToolOperation = <TParams>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup operation tracking
|
|
||||||
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
actions.setLoading(true);
|
actions.setLoading(true);
|
||||||
@ -173,6 +168,9 @@ export const useToolOperation = <TParams>(
|
|||||||
try {
|
try {
|
||||||
let processedFiles: File[];
|
let processedFiles: File[];
|
||||||
|
|
||||||
|
// Convert StirlingFile to regular File objects for API processing
|
||||||
|
const validRegularFiles = extractFiles(validFiles);
|
||||||
|
|
||||||
switch (config.toolType) {
|
switch (config.toolType) {
|
||||||
case ToolType.singleFile: {
|
case ToolType.singleFile: {
|
||||||
// Individual file processing - separate API call per file
|
// Individual file processing - separate API call per file
|
||||||
@ -184,7 +182,7 @@ export const useToolOperation = <TParams>(
|
|||||||
};
|
};
|
||||||
processedFiles = await processFiles(
|
processedFiles = await processFiles(
|
||||||
params,
|
params,
|
||||||
validFiles,
|
validRegularFiles,
|
||||||
apiCallsConfig,
|
apiCallsConfig,
|
||||||
actions.setProgress,
|
actions.setProgress,
|
||||||
actions.setStatus
|
actions.setStatus
|
||||||
@ -195,7 +193,7 @@ export const useToolOperation = <TParams>(
|
|||||||
case ToolType.multiFile: {
|
case ToolType.multiFile: {
|
||||||
// Multi-file processing - single API call with all files
|
// Multi-file processing - single API call with all files
|
||||||
actions.setStatus('Processing files...');
|
actions.setStatus('Processing files...');
|
||||||
const formData = config.buildFormData(params, validFiles);
|
const formData = config.buildFormData(params, validRegularFiles);
|
||||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||||
|
|
||||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||||
@ -203,11 +201,11 @@ export const useToolOperation = <TParams>(
|
|||||||
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
||||||
if (config.responseHandler) {
|
if (config.responseHandler) {
|
||||||
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
||||||
processedFiles = await config.responseHandler(response.data, validFiles);
|
processedFiles = await config.responseHandler(response.data, validRegularFiles);
|
||||||
} else if (response.data.type === 'application/pdf' ||
|
} else if (response.data.type === 'application/pdf' ||
|
||||||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
||||||
// Single PDF response (e.g. split with merge option) - use original filename
|
// Single PDF response (e.g. split with merge option) - use original filename
|
||||||
const originalFileName = validFiles[0]?.name || 'document.pdf';
|
const originalFileName = validRegularFiles[0]?.name || 'document.pdf';
|
||||||
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
||||||
processedFiles = [singleFile];
|
processedFiles = [singleFile];
|
||||||
} else {
|
} else {
|
||||||
@ -224,7 +222,7 @@ export const useToolOperation = <TParams>(
|
|||||||
|
|
||||||
case ToolType.custom:
|
case ToolType.custom:
|
||||||
actions.setStatus('Processing files...');
|
actions.setStatus('Processing files...');
|
||||||
processedFiles = await config.customProcessor(params, validFiles);
|
processedFiles = await config.customProcessor(params, validRegularFiles);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,21 +242,17 @@ export const useToolOperation = <TParams>(
|
|||||||
|
|
||||||
// Replace input files with processed files (consumeFiles handles pinning)
|
// Replace input files with processed files (consumeFiles handles pinning)
|
||||||
const inputFileIds: FileId[] = [];
|
const inputFileIds: FileId[] = [];
|
||||||
const inputFileRecords: FileRecord[] = [];
|
const inputStirlingFileStubs: StirlingFileStub[] = [];
|
||||||
|
|
||||||
// Build parallel arrays of IDs and records for undo tracking
|
// Build parallel arrays of IDs and records for undo tracking
|
||||||
for (const file of validFiles) {
|
for (const file of validFiles) {
|
||||||
const fileId = findFileId(file);
|
const fileId = file.fileId;
|
||||||
if (fileId) {
|
const record = selectors.getStirlingFileStub(fileId);
|
||||||
const record = selectors.getFileRecord(fileId);
|
if (record) {
|
||||||
if (record) {
|
inputFileIds.push(fileId);
|
||||||
inputFileIds.push(fileId);
|
inputStirlingFileStubs.push(record);
|
||||||
inputFileRecords.push(record);
|
|
||||||
} else {
|
|
||||||
console.warn(`No file record found for file: ${file.name}`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.warn(`No file ID found for file: ${file.name}`);
|
console.warn(`No file stub found for file: ${file.name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,24 +260,22 @@ export const useToolOperation = <TParams>(
|
|||||||
|
|
||||||
// Store operation data for undo (only store what we need to avoid memory bloat)
|
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||||
lastOperationRef.current = {
|
lastOperationRef.current = {
|
||||||
inputFiles: validFiles, // Keep original File objects for undo
|
inputFiles: extractFiles(validFiles), // Convert to File objects for undo
|
||||||
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues
|
inputStirlingFileStubs: inputStirlingFileStubs.map(record => ({ ...record })), // Deep copy to avoid reference issues
|
||||||
outputFileIds
|
outputFileIds
|
||||||
};
|
};
|
||||||
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||||
actions.setError(errorMessage);
|
actions.setError(errorMessage);
|
||||||
actions.setStatus('');
|
actions.setStatus('');
|
||||||
markOperationFailed(fileId, operationId, errorMessage);
|
|
||||||
} finally {
|
} finally {
|
||||||
actions.setLoading(false);
|
actions.setLoading(false);
|
||||||
actions.setProgress(null);
|
actions.setProgress(null);
|
||||||
}
|
}
|
||||||
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||||
|
|
||||||
const cancelOperation = useCallback(() => {
|
const cancelOperation = useCallback(() => {
|
||||||
cancelApiCalls();
|
cancelApiCalls();
|
||||||
@ -312,10 +304,10 @@ export const useToolOperation = <TParams>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current;
|
const { inputFiles, inputStirlingFileStubs, outputFileIds } = lastOperationRef.current;
|
||||||
|
|
||||||
// Validate that we have data to undo
|
// Validate that we have data to undo
|
||||||
if (inputFiles.length === 0 || inputFileRecords.length === 0) {
|
if (inputFiles.length === 0 || inputStirlingFileStubs.length === 0) {
|
||||||
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
|
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -327,7 +319,8 @@ export const useToolOperation = <TParams>(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Undo the consume operation
|
// Undo the consume operation
|
||||||
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds);
|
await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
|
||||||
|
|
||||||
|
|
||||||
// Clear results and operation tracking
|
// Clear results and operation tracking
|
||||||
resetResults();
|
resetResults();
|
||||||
|
@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
||||||
import { FileMetadata } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
import { FileId } from '../types/file';
|
import { FileId } from '../types/fileContext';
|
||||||
|
|
||||||
export const useFileManager = () => {
|
export const useFileManager = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { isFileObject } from '../types/fileContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to convert a File object to { file: File; url: string } format
|
* Hook to convert a File object to { file: File; url: string } format
|
||||||
@ -8,8 +9,8 @@ export function useFileWithUrl(file: File | Blob | null): { file: File | Blob; u
|
|||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
|
|
||||||
// Validate that file is a proper File or Blob object
|
// Validate that file is a proper File, StirlingFile, or Blob object
|
||||||
if (!(file instanceof File) && !(file instanceof Blob)) {
|
if (!isFileObject(file) && !(file instanceof Blob)) {
|
||||||
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
|
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { FileMetadata } from "../types/file";
|
import { FileMetadata } from "../types/file";
|
||||||
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
||||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||||
|
import { FileId } from "../types/fileContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate optimal scale for thumbnail generation
|
* Calculate optimal scale for thumbnail generation
|
||||||
@ -53,7 +54,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
|||||||
|
|
||||||
// Try to load file from IndexedDB using new context
|
// Try to load file from IndexedDB using new context
|
||||||
if (file.id && indexedDB) {
|
if (file.id && indexedDB) {
|
||||||
const loadedFile = await indexedDB.loadFile(file.id);
|
const loadedFile = await indexedDB.loadFile(file.id as FileId);
|
||||||
if (!loadedFile) {
|
if (!loadedFile) {
|
||||||
throw new Error('File not found in IndexedDB');
|
throw new Error('File not found in IndexedDB');
|
||||||
}
|
}
|
||||||
@ -70,7 +71,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
|||||||
// Save thumbnail to IndexedDB for persistence
|
// Save thumbnail to IndexedDB for persistence
|
||||||
if (file.id && indexedDB && thumbnail) {
|
if (file.id && indexedDB && thumbnail) {
|
||||||
try {
|
try {
|
||||||
await indexedDB.updateThumbnail(file.id, thumbnail);
|
await indexedDB.updateThumbnail(file.id as FileId, thumbnail);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to save thumbnail to IndexedDB:', error);
|
console.warn('Failed to save thumbnail to IndexedDB:', error);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||||
|
import { createQuickKey } from '../types/fileContext';
|
||||||
|
|
||||||
export function usePDFProcessor() {
|
export function usePDFProcessor() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -75,7 +76,7 @@ export function usePDFProcessor() {
|
|||||||
// Create pages without thumbnails initially - load them lazily
|
// Create pages without thumbnails initially - load them lazily
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
originalPageNumber: i,
|
originalPageNumber: i,
|
||||||
thumbnail: null, // Will be loaded lazily
|
thumbnail: null, // Will be loaded lazily
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||||
|
import { StirlingFile } from '../types/fileContext';
|
||||||
|
|
||||||
export interface PdfSignatureDetectionResult {
|
export interface PdfSignatureDetectionResult {
|
||||||
hasDigitalSignatures: boolean;
|
hasDigitalSignatures: boolean;
|
||||||
isChecking: boolean;
|
isChecking: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => {
|
export const usePdfSignatureDetection = (files: StirlingFile[]): PdfSignatureDetectionResult => {
|
||||||
const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false);
|
const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false);
|
||||||
const [isChecking, setIsChecking] = useState(false);
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
||||||
|
import { createQuickKey } from '../types/fileContext';
|
||||||
import { FileId } from '../types/file';
|
import { FileId } from '../types/file';
|
||||||
|
|
||||||
// Request queue to handle concurrent thumbnail requests
|
// Request queue to handle concurrent thumbnail requests
|
||||||
@ -71,8 +72,8 @@ async function processRequestQueue() {
|
|||||||
|
|
||||||
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
|
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
|
||||||
|
|
||||||
// Use file name as fileId for PDF document caching
|
// Use quickKey for PDF document caching (same metadata, consistent format)
|
||||||
const fileId = file.name + '_' + file.size + '_' + file.lastModified as FileId;
|
const fileId = createQuickKey(file) as FileId;
|
||||||
|
|
||||||
const results = await thumbnailGenerationService.generateThumbnails(
|
const results = await thumbnailGenerationService.generateThumbnails(
|
||||||
fileId,
|
fileId,
|
||||||
|
@ -5,6 +5,7 @@ import { FileHasher } from '../utils/fileHash';
|
|||||||
import { FileAnalyzer } from './fileAnalyzer';
|
import { FileAnalyzer } from './fileAnalyzer';
|
||||||
import { ProcessingErrorHandler } from './processingErrorHandler';
|
import { ProcessingErrorHandler } from './processingErrorHandler';
|
||||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||||
|
import { createQuickKey } from '../types/fileContext';
|
||||||
|
|
||||||
export class EnhancedPDFProcessingService {
|
export class EnhancedPDFProcessingService {
|
||||||
private static instance: EnhancedPDFProcessingService;
|
private static instance: EnhancedPDFProcessingService;
|
||||||
@ -201,7 +202,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||||
|
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
@ -251,7 +252,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||||
|
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
@ -266,7 +267,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
// Create placeholder pages for remaining pages
|
// Create placeholder pages for remaining pages
|
||||||
for (let i = priorityCount + 1; i <= totalPages; i++) {
|
for (let i = priorityCount + 1; i <= totalPages; i++) {
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail: null, // Will be loaded lazily
|
thumbnail: null, // Will be loaded lazily
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
@ -313,7 +314,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||||
|
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
@ -334,7 +335,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
// Create placeholders for remaining pages
|
// Create placeholders for remaining pages
|
||||||
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
|
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
@ -368,7 +369,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
const pages: PDFPage[] = [];
|
const pages: PDFPage[] = [];
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
|
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
|
||||||
import { ProcessingCache } from './processingCache';
|
import { ProcessingCache } from './processingCache';
|
||||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||||
|
import { createQuickKey } from '../types/fileContext';
|
||||||
|
|
||||||
export class PDFProcessingService {
|
export class PDFProcessingService {
|
||||||
private static instance: PDFProcessingService;
|
private static instance: PDFProcessingService;
|
||||||
@ -113,7 +114,7 @@ export class PDFProcessingService {
|
|||||||
const thumbnail = canvas.toDataURL();
|
const thumbnail = canvas.toDataURL();
|
||||||
|
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${createQuickKey(file)}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
|
@ -18,6 +18,8 @@ import { FileContextProvider } from '../../contexts/FileContext';
|
|||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import i18n from '../../i18n/config';
|
import i18n from '../../i18n/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { createTestStirlingFile } from '../utils/testFileHelpers';
|
||||||
|
import { StirlingFile } from '../../types/fileContext';
|
||||||
|
|
||||||
// Mock axios
|
// Mock axios
|
||||||
vi.mock('axios');
|
vi.mock('axios');
|
||||||
@ -55,9 +57,9 @@ const createTestFile = (name: string, content: string, type: string): File => {
|
|||||||
return new File([content], name, { type });
|
return new File([content], name, { type });
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPDFFile = (): File => {
|
const createPDFFile = (): StirlingFile => {
|
||||||
const pdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\ntrailer\n<<\n/Size 2\n/Root 1 0 R\n>>\nstartxref\n0\n%%EOF';
|
const pdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\ntrailer\n<<\n/Size 2\n/Root 1 0 R\n>>\nstartxref\n0\n%%EOF';
|
||||||
return createTestFile('test.pdf', pdfContent, 'application/pdf');
|
return createTestStirlingFile('test.pdf', pdfContent, 'application/pdf');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test wrapper component
|
// Test wrapper component
|
||||||
@ -162,7 +164,7 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const testFile = createTestFile('invalid.txt', 'not a pdf', 'text/plain');
|
const testFile = createTestStirlingFile('invalid.txt', 'not a pdf', 'text/plain');
|
||||||
const parameters: ConvertParameters = {
|
const parameters: ConvertParameters = {
|
||||||
fromExtension: 'pdf',
|
fromExtension: 'pdf',
|
||||||
toExtension: 'png',
|
toExtension: 'png',
|
||||||
@ -426,7 +428,7 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
const files = [
|
const files = [
|
||||||
createPDFFile(),
|
createPDFFile(),
|
||||||
createTestFile('test2.pdf', '%PDF-1.4...', 'application/pdf')
|
createTestStirlingFile('test2.pdf', '%PDF-1.4...', 'application/pdf')
|
||||||
]
|
]
|
||||||
const parameters: ConvertParameters = {
|
const parameters: ConvertParameters = {
|
||||||
fromExtension: 'pdf',
|
fromExtension: 'pdf',
|
||||||
@ -527,7 +529,7 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const corruptedFile = createTestFile('corrupted.pdf', 'not-a-pdf', 'application/pdf');
|
const corruptedFile = createTestStirlingFile('corrupted.pdf', 'not-a-pdf', 'application/pdf');
|
||||||
const parameters: ConvertParameters = {
|
const parameters: ConvertParameters = {
|
||||||
fromExtension: 'pdf',
|
fromExtension: 'pdf',
|
||||||
toExtension: 'png',
|
toExtension: 'png',
|
||||||
|
@ -14,6 +14,8 @@ import i18n from '../../i18n/config';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { detectFileExtension } from '../../utils/fileUtils';
|
import { detectFileExtension } from '../../utils/fileUtils';
|
||||||
import { FIT_OPTIONS } from '../../constants/convertConstants';
|
import { FIT_OPTIONS } from '../../constants/convertConstants';
|
||||||
|
import { createTestStirlingFile, createTestFilesWithId } from '../utils/testFileHelpers';
|
||||||
|
import { StirlingFile } from '../../types/fileContext';
|
||||||
|
|
||||||
// Mock axios
|
// Mock axios
|
||||||
vi.mock('axios');
|
vi.mock('axios');
|
||||||
@ -81,7 +83,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create mock DOCX file
|
// Create mock DOCX file
|
||||||
const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
|
const docxFile = createTestStirlingFile('document.docx', 'docx content', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||||
|
|
||||||
// Test auto-detection
|
// Test auto-detection
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -117,7 +119,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create mock unknown file
|
// Create mock unknown file
|
||||||
const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' });
|
const unknownFile = createTestStirlingFile('document.xyz', 'unknown content', 'application/octet-stream');
|
||||||
|
|
||||||
// Test auto-detection
|
// Test auto-detection
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -156,11 +158,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create mock image files
|
// Create mock image files
|
||||||
const imageFiles = [
|
const imageFiles = createTestFilesWithId([
|
||||||
new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }),
|
{ name: 'photo1.jpg', content: 'jpg content', type: 'image/jpeg' },
|
||||||
new File(['png content'], 'photo2.png', { type: 'image/png' }),
|
{ name: 'photo2.png', content: 'png content', type: 'image/png' },
|
||||||
new File(['gif content'], 'photo3.gif', { type: 'image/gif' })
|
{ name: 'photo3.gif', content: 'gif content', type: 'image/gif' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Test smart detection for all images
|
// Test smart detection for all images
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -202,11 +204,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create mixed file types
|
// Create mixed file types
|
||||||
const mixedFiles = [
|
const mixedFiles = createTestFilesWithId([
|
||||||
new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }),
|
{ name: 'document.pdf', content: 'pdf content', type: 'application/pdf' },
|
||||||
new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
{ name: 'spreadsheet.xlsx', content: 'docx content', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
||||||
new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
|
{ name: 'presentation.pptx', content: 'pptx content', type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Test smart detection for mixed types
|
// Test smart detection for mixed types
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -243,10 +245,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create mock web files
|
// Create mock web files
|
||||||
const webFiles = [
|
const webFiles = createTestFilesWithId([
|
||||||
new File(['<html>content</html>'], 'page1.html', { type: 'text/html' }),
|
{ name: 'page1.html', content: '<html>content</html>', type: 'text/html' },
|
||||||
new File(['zip content'], 'site.zip', { type: 'application/zip' })
|
{ name: 'site.zip', content: 'zip content', type: 'application/zip' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Test smart detection for web files
|
// Test smart detection for web files
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -288,7 +290,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const htmlFile = new File(['<html>content</html>'], 'page.html', { type: 'text/html' });
|
const htmlFile = createTestStirlingFile('page.html', '<html>content</html>', 'text/html');
|
||||||
|
|
||||||
// Set up HTML conversion parameters
|
// Set up HTML conversion parameters
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -318,7 +320,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' });
|
const emlFile = createTestStirlingFile('email.eml', 'email content', 'message/rfc822');
|
||||||
|
|
||||||
// Set up email conversion parameters
|
// Set up email conversion parameters
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -355,7 +357,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdfFile = new File(['pdf content'], 'document.pdf', { type: 'application/pdf' });
|
const pdfFile = createTestStirlingFile('document.pdf', 'pdf content', 'application/pdf');
|
||||||
|
|
||||||
// Set up PDF/A conversion parameters
|
// Set up PDF/A conversion parameters
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -392,10 +394,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageFiles = [
|
const imageFiles = createTestFilesWithId([
|
||||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
{ name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
|
||||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
{ name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Set up image conversion parameters
|
// Set up image conversion parameters
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -432,10 +434,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
wrapper: TestWrapper
|
wrapper: TestWrapper
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageFiles = [
|
const imageFiles = createTestFilesWithId([
|
||||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
{ name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
|
||||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
{ name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Set up for separate processing
|
// Set up for separate processing
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -477,10 +479,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
|||||||
})
|
})
|
||||||
.mockRejectedValueOnce(new Error('File 2 failed'));
|
.mockRejectedValueOnce(new Error('File 2 failed'));
|
||||||
|
|
||||||
const mixedFiles = [
|
const mixedFiles = createTestFilesWithId([
|
||||||
new File(['file1'], 'doc1.txt', { type: 'text/plain' }),
|
{ name: 'doc1.txt', content: 'file1', type: 'text/plain' },
|
||||||
new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' })
|
{ name: 'doc2.xyz', content: 'file2', type: 'application/octet-stream' }
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Set up for separate processing (mixed smart detection)
|
// Set up for separate processing (mixed smart detection)
|
||||||
act(() => {
|
act(() => {
|
||||||
|
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 { PageOperation } from './pageEditor';
|
||||||
import { FileId, FileMetadata } from './file';
|
import { FileId, FileMetadata } from './file';
|
||||||
|
|
||||||
|
// Re-export FileId for convenience
|
||||||
|
export type { FileId };
|
||||||
|
|
||||||
export type ModeType =
|
export type ModeType =
|
||||||
| 'viewer'
|
| 'viewer'
|
||||||
| 'pageEditor'
|
| 'pageEditor'
|
||||||
@ -41,25 +44,32 @@ export interface ProcessedFileMetadata {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRecord {
|
/**
|
||||||
id: FileId;
|
* StirlingFileStub - Metadata record for files in the active workbench session
|
||||||
name: string;
|
*
|
||||||
size: number;
|
* Contains UI display data and processing state. Actual File objects stored
|
||||||
type: string;
|
* separately in refs for memory efficiency. Supports multi-tool workflows
|
||||||
lastModified: number;
|
* where files persist across tool operations.
|
||||||
quickKey?: string; // Fast deduplication key: name|size|lastModified
|
*/
|
||||||
thumbnailUrl?: string;
|
export interface StirlingFileStub {
|
||||||
blobUrl?: string;
|
id: FileId; // UUID primary key for collision-free operations
|
||||||
createdAt?: number;
|
name: string; // Display name for UI
|
||||||
processedFile?: ProcessedFileMetadata;
|
size: number; // File size for progress indicators
|
||||||
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
type: string; // MIME type for format validation
|
||||||
isPinned?: boolean;
|
lastModified: number; // Original timestamp for deduplication
|
||||||
|
quickKey?: string; // Fast deduplication key: name|size|lastModified
|
||||||
|
thumbnailUrl?: string; // Generated thumbnail blob URL for visual display
|
||||||
|
blobUrl?: string; // File access blob URL for downloads/processing
|
||||||
|
createdAt?: number; // When added to workbench for sorting
|
||||||
|
processedFile?: ProcessedFileMetadata; // PDF page data and processing results
|
||||||
|
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
||||||
|
isPinned?: boolean; // Protected from tool consumption (replace/remove)
|
||||||
// Note: File object stored in provider ref, not in state
|
// Note: File object stored in provider ref, not in state
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileContextNormalizedFiles {
|
export interface FileContextNormalizedFiles {
|
||||||
ids: FileId[];
|
ids: FileId[];
|
||||||
byId: Record<FileId, FileRecord>;
|
byId: Record<FileId, StirlingFileStub>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions - UUID-based primary keys (zero collisions, synchronous)
|
// Helper functions - UUID-based primary keys (zero collisions, synchronous)
|
||||||
@ -82,9 +92,68 @@ export function createQuickKey(file: File): string {
|
|||||||
return `${file.name}|${file.size}|${file.lastModified}`;
|
return `${file.name}|${file.size}|${file.lastModified}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stirling PDF file with embedded UUID - replaces loose File + FileId parameter passing
|
||||||
|
export interface StirlingFile extends File {
|
||||||
|
readonly fileId: FileId;
|
||||||
|
readonly quickKey: string; // Fast deduplication key: name|size|lastModified
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type guard to check if a File object has an embedded fileId
|
||||||
|
export function isStirlingFile(file: File): file is StirlingFile {
|
||||||
|
return 'fileId' in file && typeof (file as any).fileId === 'string' &&
|
||||||
|
'quickKey' in file && typeof (file as any).quickKey === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a StirlingFile from a regular File object
|
||||||
|
export function createStirlingFile(file: File, id?: FileId): StirlingFile {
|
||||||
|
const fileId = id || createFileId();
|
||||||
|
const quickKey = createQuickKey(file);
|
||||||
|
|
||||||
|
// Use Object.defineProperty to add properties while preserving the original File object
|
||||||
|
// This maintains proper method binding and avoids "Illegal invocation" errors
|
||||||
|
Object.defineProperty(file, 'fileId', {
|
||||||
|
value: fileId,
|
||||||
|
writable: false,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(file, 'quickKey', {
|
||||||
|
value: quickKey,
|
||||||
|
writable: false,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return file as StirlingFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract FileIds from StirlingFile array
|
||||||
|
export function extractFileIds(files: StirlingFile[]): FileId[] {
|
||||||
|
return files.map(file => file.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract regular File objects from StirlingFile array
|
||||||
|
export function extractFiles(files: StirlingFile[]): File[] {
|
||||||
|
return files as File[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if an object is a File or StirlingFile (replaces instanceof File checks)
|
||||||
|
export function isFileObject(obj: any): obj is File | StirlingFile {
|
||||||
|
return obj &&
|
||||||
|
typeof obj.name === 'string' &&
|
||||||
|
typeof obj.size === 'number' &&
|
||||||
|
typeof obj.type === 'string' &&
|
||||||
|
typeof obj.lastModified === 'number' &&
|
||||||
|
typeof obj.arrayBuffer === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function toFileRecord(file: File, id?: FileId): FileRecord {
|
|
||||||
|
export function toStirlingFileStub(
|
||||||
|
file: File,
|
||||||
|
id?: FileId
|
||||||
|
): StirlingFileStub {
|
||||||
const fileId = id || createFileId();
|
const fileId = id || createFileId();
|
||||||
return {
|
return {
|
||||||
id: fileId,
|
id: fileId,
|
||||||
@ -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
|
// Only revoke blob: URLs to prevent errors on other schemes
|
||||||
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
|
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
|
||||||
try {
|
try {
|
||||||
@ -171,7 +240,7 @@ export interface FileContextState {
|
|||||||
// Core file management - lightweight file IDs only
|
// Core file management - lightweight file IDs only
|
||||||
files: {
|
files: {
|
||||||
ids: FileId[];
|
ids: FileId[];
|
||||||
byId: Record<FileId, FileRecord>;
|
byId: Record<FileId, StirlingFileStub>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pinned files - files that won't be consumed by tools
|
// Pinned files - files that won't be consumed by tools
|
||||||
@ -190,16 +259,16 @@ export interface FileContextState {
|
|||||||
// Action types for reducer pattern
|
// Action types for reducer pattern
|
||||||
export type FileContextAction =
|
export type FileContextAction =
|
||||||
// File management actions
|
// File management actions
|
||||||
| { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } }
|
| { type: 'ADD_FILES'; payload: { stirlingFileStubs: StirlingFileStub[] } }
|
||||||
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
|
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
|
||||||
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
|
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<StirlingFileStub> } }
|
||||||
| { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } }
|
| { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } }
|
||||||
|
|
||||||
// Pinned files actions
|
// Pinned files actions
|
||||||
| { type: 'PIN_FILE'; payload: { fileId: FileId } }
|
| { type: 'PIN_FILE'; payload: { fileId: FileId } }
|
||||||
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
|
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
|
||||||
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } }
|
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputStirlingFileStubs: StirlingFileStub[] } }
|
||||||
| { type: 'UNDO_CONSUME_FILES'; payload: { inputFileRecords: FileRecord[]; outputFileIds: FileId[] } }
|
| { type: 'UNDO_CONSUME_FILES'; payload: { inputStirlingFileStubs: StirlingFileStub[]; outputFileIds: FileId[] } }
|
||||||
|
|
||||||
// UI actions
|
// UI actions
|
||||||
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
|
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
|
||||||
@ -215,22 +284,22 @@ export type FileContextAction =
|
|||||||
|
|
||||||
export interface FileContextActions {
|
export interface FileContextActions {
|
||||||
// File management - lightweight actions only
|
// File management - lightweight actions only
|
||||||
addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<File[]>;
|
addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>;
|
||||||
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
|
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<StirlingFile[]>;
|
||||||
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<File[]>;
|
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<StirlingFile[]>;
|
||||||
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
|
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
|
||||||
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
|
updateStirlingFileStub: (id: FileId, updates: Partial<StirlingFileStub>) => void;
|
||||||
reorderFiles: (orderedFileIds: FileId[]) => void;
|
reorderFiles: (orderedFileIds: FileId[]) => void;
|
||||||
clearAllFiles: () => Promise<void>;
|
clearAllFiles: () => Promise<void>;
|
||||||
clearAllData: () => Promise<void>;
|
clearAllData: () => Promise<void>;
|
||||||
|
|
||||||
// File pinning
|
// File pinning - accepts StirlingFile for safer type checking
|
||||||
pinFile: (file: File) => void;
|
pinFile: (file: StirlingFile) => void;
|
||||||
unpinFile: (file: File) => void;
|
unpinFile: (file: StirlingFile) => void;
|
||||||
|
|
||||||
// File consumption (replace unpinned files with outputs)
|
// File consumption (replace unpinned files with outputs)
|
||||||
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileId[]>;
|
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileId[]>;
|
||||||
undoConsumeFiles: (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]) => Promise<void>;
|
undoConsumeFiles: (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]) => Promise<void>;
|
||||||
// Selection management
|
// Selection management
|
||||||
setSelectedFiles: (fileIds: FileId[]) => void;
|
setSelectedFiles: (fileIds: FileId[]) => void;
|
||||||
setSelectedPages: (pageNumbers: number[]) => void;
|
setSelectedPages: (pageNumbers: number[]) => void;
|
||||||
@ -253,26 +322,17 @@ export interface FileContextActions {
|
|||||||
|
|
||||||
// File selectors (separate from actions to avoid re-renders)
|
// File selectors (separate from actions to avoid re-renders)
|
||||||
export interface FileContextSelectors {
|
export interface FileContextSelectors {
|
||||||
// File access - no state dependency, uses ref
|
getFile: (id: FileId) => StirlingFile | undefined;
|
||||||
getFile: (id: FileId) => File | undefined;
|
getFiles: (ids?: FileId[]) => StirlingFile[];
|
||||||
getFiles: (ids?: FileId[]) => File[];
|
getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined;
|
||||||
|
getStirlingFileStubs: (ids?: FileId[]) => StirlingFileStub[];
|
||||||
// Record access - uses normalized state
|
|
||||||
getFileRecord: (id: FileId) => FileRecord | undefined;
|
|
||||||
getFileRecords: (ids?: FileId[]) => FileRecord[];
|
|
||||||
|
|
||||||
// Derived selectors
|
|
||||||
getAllFileIds: () => FileId[];
|
getAllFileIds: () => FileId[];
|
||||||
getSelectedFiles: () => File[];
|
getSelectedFiles: () => StirlingFile[];
|
||||||
getSelectedFileRecords: () => FileRecord[];
|
getSelectedStirlingFileStubs: () => StirlingFileStub[];
|
||||||
|
|
||||||
// Pinned files selectors
|
|
||||||
getPinnedFileIds: () => FileId[];
|
getPinnedFileIds: () => FileId[];
|
||||||
getPinnedFiles: () => File[];
|
getPinnedFiles: () => StirlingFile[];
|
||||||
getPinnedFileRecords: () => FileRecord[];
|
getPinnedStirlingFileStubs: () => StirlingFileStub[];
|
||||||
isFilePinned: (file: File) => boolean;
|
isFilePinned: (file: StirlingFile) => boolean;
|
||||||
|
|
||||||
// Stable signature for effect dependencies
|
|
||||||
getFilesSignature: () => string;
|
getFilesSignature: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,6 +353,3 @@ export interface FileContextActionsValue {
|
|||||||
actions: FileContextActions;
|
actions: FileContextActions;
|
||||||
dispatch: (action: FileContextAction) => void;
|
dispatch: (action: FileContextAction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: URL parameter types will be redesigned for new routing system
|
|
||||||
|
|
||||||
|
49
frontend/src/types/fileIdSafety.d.ts
vendored
Normal file
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