Semi working

This commit is contained in:
Connor Yoh 2025-09-10 18:31:47 +01:00
parent f88c3e25d1
commit 3cea686acd
23 changed files with 748 additions and 1314 deletions

View File

@ -1,7 +1,7 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Modal } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { StoredFileMetadata, fileStorage } from '../services/fileStorage';
import { StirlingFileStub } from '../types/fileContext';
import { useFileManager } from '../hooks/useFileManager';
import { useFilesModalContext } from '../contexts/FilesModalContext';
import { Tool } from '../types/tool';
@ -15,8 +15,8 @@ interface FileManagerProps {
}
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext();
const [recentFiles, setRecentFiles] = useState<StoredFileMetadata[]>([]);
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext();
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
@ -34,36 +34,26 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
setRecentFiles(files);
}, [loadRecentFiles]);
const handleFilesSelected = useCallback(async (files: StoredFileMetadata[]) => {
const handleRecentFilesSelected = useCallback(async (files: StirlingFileStub[]) => {
try {
// Use stored files flow that preserves original IDs
// Load full StoredFile objects for selected files
const storedFiles = await Promise.all(
files.map(async (metadata) => {
const storedFile = await fileStorage.getFile(metadata.id);
if (!storedFile) {
throw new Error(`File not found in storage: ${metadata.name}`);
}
return storedFile;
})
);
onStoredFilesSelect(storedFiles);
// Use StirlingFileStubs directly - preserves all metadata!
onRecentFileSelect(files);
} catch (error) {
console.error('Failed to process selected files:', error);
}
}, [onStoredFilesSelect]);
}, [onRecentFileSelect]);
const handleNewFileUpload = useCallback(async (files: File[]) => {
if (files.length > 0) {
try {
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
onFilesSelect(files);
onFileUpload(files);
await refreshRecentFiles();
} catch (error) {
console.error('Failed to process dropped files:', error);
}
}
}, [onFilesSelect, refreshRecentFiles]);
}, [onFileUpload, refreshRecentFiles]);
const handleRemoveFileByIndex = useCallback(async (index: number) => {
await handleRemoveFile(index, recentFiles, setRecentFiles);
@ -149,9 +139,8 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
>
<FileManagerProvider
recentFiles={recentFiles}
onFilesSelected={handleFilesSelected}
onRecentFilesSelected={handleRecentFilesSelected}
onNewFilesSelect={handleNewFileUpload}
onStoredFilesSelect={onStoredFilesSelect}
onClose={closeFilesModal}
isFileSupported={isFileSupported}
isOpen={isFilesModalOpen}

View File

@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next';
import { getFileSize } from '../../utils/fileUtils';
import { StoredFileMetadata } from '../../services/fileStorage';
import { StirlingFileStub } from '../../types/fileContext';
interface CompactFileDetailsProps {
currentFile: StoredFileMetadata | null;
currentFile: StirlingFileStub | null;
thumbnail: string | null;
selectedFiles: StoredFileMetadata[];
selectedFiles: StirlingFileStub[];
currentFileIndex: number;
numberOfFiles: number;
isAnimating: boolean;

View File

@ -2,11 +2,11 @@ import React from 'react';
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
import { StoredFileMetadata } from '../../services/fileStorage';
import { StirlingFileStub } from '../../types/fileContext';
import ToolChain from '../shared/ToolChain';
interface FileInfoCardProps {
currentFile: StoredFileMetadata | null;
currentFile: StirlingFileStub | null;
modalHeight: string;
}

View File

@ -7,12 +7,12 @@ import AddIcon from '@mui/icons-material/Add';
import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { StoredFileMetadata } from '../../services/fileStorage';
import { StirlingFileStub } from '../../types/fileContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import ToolChain from '../shared/ToolChain';
interface FileListItemProps {
file: StoredFileMetadata;
file: StirlingFileStub;
isSelected: boolean;
isSupported: boolean;
onSelect: (shiftKey?: boolean) => void;

View File

@ -42,7 +42,7 @@ export default function Workbench() {
// Get tool registry to look up selected tool
const { toolRegistry } = useToolManagement();
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
const { addToActiveFiles } = useFileHandler();
const { addFiles } = useFileHandler();
const handlePreviewClose = () => {
setPreviewFile(null);
@ -81,7 +81,7 @@ export default function Workbench() {
setCurrentView("pageEditor");
},
onMergeFiles: (filesToMerge) => {
filesToMerge.forEach(addToActiveFiles);
addFiles(filesToMerge);
setCurrentView("viewer");
}
})}

View File

@ -1,7 +1,7 @@
import React from 'react';
import { Box, Center } from '@mantine/core';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import { StoredFileMetadata } from '../../services/fileStorage';
import { StirlingFileStub } from '../../types/fileContext';
import DocumentThumbnail from './filePreview/DocumentThumbnail';
import DocumentStack from './filePreview/DocumentStack';
import HoverOverlay from './filePreview/HoverOverlay';
@ -9,7 +9,7 @@ import NavigationArrows from './filePreview/NavigationArrows';
export interface FilePreviewProps {
// Core file data
file: File | StoredFileMetadata | null;
file: File | StirlingFileStub | null;
thumbnail?: string | null;
// Optional features
@ -22,7 +22,7 @@ export interface FilePreviewProps {
isAnimating?: boolean;
// Event handlers
onFileClick?: (file: File | StoredFileMetadata | null) => void;
onFileClick?: (file: File | StirlingFileStub | null) => void;
onPrevious?: () => void;
onNext?: () => void;
}

View File

@ -7,7 +7,7 @@ import { useFileHandler } from '../../hooks/useFileHandler';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
const LandingPage = () => {
const { addMultipleFiles } = useFileHandler();
const { addFiles } = useFileHandler();
const fileInputRef = React.useRef<HTMLInputElement>(null);
const { colorScheme } = useMantineColorScheme();
const { t } = useTranslation();
@ -15,7 +15,7 @@ const LandingPage = () => {
const [isUploadHover, setIsUploadHover] = React.useState(false);
const handleFileDrop = async (files: File[]) => {
await addMultipleFiles(files);
await addFiles(files);
};
const handleOpenFilesModal = () => {
@ -29,7 +29,7 @@ const LandingPage = () => {
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
await addMultipleFiles(files);
await addFiles(files);
}
// Reset the input so the same file can be selected again
event.target.value = '';

View File

@ -1,10 +1,10 @@
import React from 'react';
import { Box, Center, Image } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { StoredFileMetadata } from '../../../services/fileStorage';
import { StirlingFileStub } from '../../../types/fileContext';
export interface DocumentThumbnailProps {
file: File | StoredFileMetadata | null;
file: File | StirlingFileStub | null;
thumbnail?: string | null;
style?: React.CSSProperties;
onClick?: () => void;

View File

@ -17,7 +17,7 @@ const FileStatusIndicator = ({
selectedFiles = [],
}: FileStatusIndicatorProps) => {
const { t } = useTranslation();
const { openFilesModal, onFilesSelect } = useFilesModalContext();
const { openFilesModal, onFileUpload } = useFilesModalContext();
const { files: stirlingFileStubs } = useAllFiles();
const { loadRecentFiles } = useFileManager();
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
@ -44,7 +44,7 @@ const FileStatusIndicator = ({
input.onchange = (event) => {
const files = Array.from((event.target as HTMLInputElement).files || []);
if (files.length > 0) {
onFilesSelect(files);
onFileUpload(files);
}
};
input.click();

View File

@ -372,11 +372,12 @@ const Viewer = ({
else if (effectiveFile.url?.startsWith('indexeddb:')) {
const fileId = effectiveFile.url.replace('indexeddb:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */;
// Get data directly from IndexedDB
const arrayBuffer = await fileStorage.getFileData(fileId);
if (!arrayBuffer) {
// Get file directly from IndexedDB
const file = await fileStorage.getStirlingFile(fileId);
if (!file) {
throw new Error('File not found in IndexedDB - may have been purged by browser');
}
const arrayBuffer = await file.arrayBuffer();
// Store reference for cleanup
currentArrayBufferRef.current = arrayBuffer;

View File

@ -28,11 +28,10 @@ import {
// Import modular components
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
import { createFileSelectors } from './file/fileSelectors';
import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
import { AddedFile, addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
import { FileLifecycleManager } from './file/lifecycle';
import { FileStateContext, FileActionsContext } from './file/contexts';
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
import { StoredFile, StoredFileMetadata } from '../services/fileStorage';
const DEBUG = process.env.NODE_ENV === 'development';
@ -130,28 +129,22 @@ function FileContextInner({
return result.map(({ file, id }) => createStirlingFile(file, id));
}, []);
const addStoredFiles = useCallback(async (storedFiles: StoredFile[], options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => {
// Convert StoredFile[] to the format expected by addFiles
const filesWithMetadata = storedFiles.map(storedFile => ({
file: new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
}),
originalId: storedFile.id,
metadata: {
...storedFile,
data: undefined // Remove data field for metadata
} as StoredFileMetadata
}));
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
// StirlingFileStubs preserve all metadata - perfect for FileManager use case!
const result = await addStirlingFileStubs(stirlingFileStubs, options, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested
if (options?.selectFiles && result.length > 0) {
selectFiles(result);
// Convert StirlingFile[] to AddedFile[] format for selectFiles
const addedFilesWithIds = result.map(stirlingFile => ({
file: stirlingFile,
id: stirlingFile.fileId
}));
selectFiles(addedFilesWithIds);
}
return result.map(({ file, id }) => createStirlingFile(file, id));
return result;
}, []);
@ -159,9 +152,9 @@ function FileContextInner({
const baseActions = useMemo(() => createFileActions(dispatch), []);
// Helper functions for pinned files
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
return consumeFiles(inputFileIds, outputFiles, filesRef, dispatch, indexedDB);
}, [indexedDB]);
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]): Promise<FileId[]> => {
return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch);
}, []);
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
@ -181,7 +174,7 @@ function FileContextInner({
...baseActions,
addFiles: addRawFiles,
addProcessedFiles,
addStoredFiles,
addStirlingFileStubs: addStirlingFileStubsAction,
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
// Remove from memory and cleanup resources
lifecycleManager.removeFiles(fileIds, stateRef);
@ -237,7 +230,7 @@ function FileContextInner({
baseActions,
addRawFiles,
addProcessedFiles,
addStoredFiles,
addStirlingFileStubsAction,
lifecycleManager,
setHasUnsavedChanges,
consumeFilesWrapper,

View File

@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { StoredFileMetadata, StoredFile } from '../services/fileStorage';
import { fileStorage } from '../services/fileStorage';
import { StirlingFileStub } from '../types/fileContext';
import { downloadFiles } from '../utils/downloadUtils';
import { FileId } from '../types/file';
import { groupFilesByOriginal } from '../utils/fileHistoryUtils';
@ -11,32 +11,32 @@ interface FileManagerContextValue {
activeSource: 'recent' | 'local' | 'drive';
selectedFileIds: FileId[];
searchTerm: string;
selectedFiles: StoredFileMetadata[];
filteredFiles: StoredFileMetadata[];
selectedFiles: StirlingFileStub[];
filteredFiles: StirlingFileStub[];
fileInputRef: React.RefObject<HTMLInputElement | null>;
selectedFilesSet: Set<string>;
expandedFileIds: Set<string>;
fileGroups: Map<string, StoredFileMetadata[]>;
fileGroups: Map<string, StirlingFileStub[]>;
// Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
onLocalFileClick: () => void;
onFileSelect: (file: StoredFileMetadata, index: number, shiftKey?: boolean) => void;
onFileSelect: (file: StirlingFileStub, index: number, shiftKey?: boolean) => void;
onFileRemove: (index: number) => void;
onFileDoubleClick: (file: StoredFileMetadata) => void;
onFileDoubleClick: (file: StirlingFileStub) => void;
onOpenFiles: () => void;
onSearchChange: (value: string) => void;
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectAll: () => void;
onDeleteSelected: () => void;
onDownloadSelected: () => void;
onDownloadSingle: (file: StoredFileMetadata) => void;
onDownloadSingle: (file: StirlingFileStub) => void;
onToggleExpansion: (fileId: string) => void;
onAddToRecents: (file: StoredFileMetadata) => void;
onAddToRecents: (file: StirlingFileStub) => void;
onNewFilesSelect: (files: File[]) => void;
// External props
recentFiles: StoredFileMetadata[];
recentFiles: StirlingFileStub[];
isFileSupported: (fileName: string) => boolean;
modalHeight: string;
}
@ -47,10 +47,9 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
// Provider component props
interface FileManagerProviderProps {
children: React.ReactNode;
recentFiles: StoredFileMetadata[];
onFilesSelected: (files: StoredFileMetadata[]) => void; // For selecting stored files
recentFiles: StirlingFileStub[];
onRecentFilesSelected: (files: StirlingFileStub[]) => void; // For selecting stored files
onNewFilesSelect: (files: File[]) => void; // For uploading new local files
onStoredFilesSelect: (storedFiles: StoredFile[]) => void; // For adding stored files directly
onClose: () => void;
isFileSupported: (fileName: string) => boolean;
isOpen: boolean;
@ -62,9 +61,8 @@ interface FileManagerProviderProps {
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
children,
recentFiles,
onFilesSelected,
onRecentFilesSelected,
onNewFilesSelect,
onStoredFilesSelect: onStoredFilesSelect,
onClose,
isFileSupported,
isOpen,
@ -77,7 +75,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const [searchTerm, setSearchTerm] = useState('');
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [expandedFileIds, setExpandedFileIds] = useState<Set<string>>(new Set());
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, StoredFileMetadata[]>>(new Map()); // Cache for loaded history
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, StirlingFileStub[]>>(new Map()); // Cache for loaded history
const fileInputRef = useRef<HTMLInputElement>(null);
// Track blob URLs for cleanup
@ -91,7 +89,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const fileGroups = useMemo(() => {
if (!recentFiles || recentFiles.length === 0) return new Map();
// Convert StoredFileMetadata to FileRecord-like objects for grouping utility
// Convert StirlingFileStub to FileRecord-like objects for grouping utility
const recordsForGrouping = recentFiles.map(file => ({
...file,
originalFileId: file.originalFileId,
@ -145,7 +143,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileInputRef.current?.click();
}, []);
const handleFileSelect = useCallback((file: StoredFileMetadata, currentIndex: number, shiftKey?: boolean) => {
const handleFileSelect = useCallback((file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => {
const fileId = file.id;
if (!fileId) return;
@ -189,9 +187,9 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Helper function to safely determine which files can be deleted
const getSafeFilesToDelete = useCallback((
leafFileIds: string[],
allStoredMetadata: Omit<import('../services/fileStorage').StoredFile, 'data'>[]
allStoredStubs: StirlingFileStub[]
): string[] => {
const fileMap = new Map(allStoredMetadata.map(f => [f.id as string, f]));
const fileMap = new Map(allStoredStubs.map(f => [f.id as string, f]));
const filesToDelete = new Set<string>();
const filesToPreserve = new Set<string>();
@ -208,7 +206,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const originalFileId = currentFile.originalFileId || currentFile.id;
// Find all files in this history chain
const chainFiles = allStoredMetadata.filter(file =>
const chainFiles = allStoredStubs.filter((file: StirlingFileStub) =>
(file.originalFileId || file.id) === originalFileId
);
@ -218,13 +216,13 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
// Now identify files that must be preserved because they're referenced by OTHER lineages
for (const file of allStoredMetadata) {
for (const file of allStoredStubs) {
const fileOriginalId = file.originalFileId || file.id;
// If this file is a leaf node (not being deleted) and its lineage overlaps with files we want to delete
if (file.isLeaf !== false && !leafFileIds.includes(file.id)) {
// Find all files in this preserved lineage
const preservedChainFiles = allStoredMetadata.filter(chainFile =>
const preservedChainFiles = allStoredStubs.filter((chainFile: StirlingFileStub) =>
(chainFile.originalFileId || chainFile.id) === fileOriginalId
);
@ -251,10 +249,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const deletedFileId = fileToRemove.id;
// Get all stored files to analyze lineages
const allStoredMetadata = await fileStorage.getAllFileMetadata();
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
// Get safe files to delete (respecting shared lineages)
const filesToDelete = getSafeFilesToDelete([deletedFileId as string], allStoredMetadata);
const filesToDelete = getSafeFilesToDelete([deletedFileId as string], allStoredStubs);
console.log(`Safely deleting files for ${fileToRemove.name}:`, filesToDelete);
@ -289,33 +287,33 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Delete safe files from IndexedDB
try {
for (const fileId of filesToDelete) {
await fileStorage.deleteFile(fileId as FileId);
await fileStorage.deleteStirlingFile(fileId as FileId);
}
} catch (error) {
console.error('Failed to delete files from chain:', error);
}
// Call the parent's deletion logic for the main file only
await onFileRemove(index);
onFileRemove(index);
// Refresh to ensure consistent state
await refreshRecentFiles();
}
}, [filteredFiles, onFileRemove, refreshRecentFiles, getSafeFilesToDelete]);
const handleFileDoubleClick = useCallback((file: StoredFileMetadata) => {
const handleFileDoubleClick = useCallback((file: StirlingFileStub) => {
if (isFileSupported(file.name)) {
onFilesSelected([file]);
onRecentFilesSelected([file]);
onClose();
}
}, [isFileSupported, onFilesSelected, onClose]);
}, [isFileSupported, onRecentFilesSelected, onClose]);
const handleOpenFiles = useCallback(() => {
if (selectedFiles.length > 0) {
onFilesSelected(selectedFiles);
onRecentFilesSelected(selectedFiles);
onClose();
}
}, [selectedFiles, onFilesSelected, onClose]);
}, [selectedFiles, onRecentFilesSelected, onClose]);
const handleSearchChange = useCallback((value: string) => {
setSearchTerm(value);
@ -354,10 +352,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
try {
// Get all stored files to analyze lineages
const allStoredMetadata = await fileStorage.getAllFileMetadata();
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
// Get safe files to delete (respecting shared lineages)
const filesToDelete = getSafeFilesToDelete(selectedFileIds, allStoredMetadata);
const filesToDelete = getSafeFilesToDelete(selectedFileIds, allStoredStubs);
console.log(`Bulk safely deleting files and their history chains:`, filesToDelete);
@ -391,7 +389,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Delete safe files from IndexedDB
for (const fileId of filesToDelete) {
await fileStorage.deleteFile(fileId as FileId);
await fileStorage.deleteStirlingFile(fileId as FileId);
}
// Refresh the file list to get updated data
@ -420,7 +418,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}, [selectedFileIds, filteredFiles]);
const handleDownloadSingle = useCallback(async (file: StoredFileMetadata) => {
const handleDownloadSingle = useCallback(async (file: StirlingFileStub) => {
try {
await downloadFiles([file]);
} catch (error) {
@ -448,21 +446,21 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
if (currentFileMetadata && (currentFileMetadata.versionNumber || 1) > 1) {
try {
// Get all stored file metadata for chain traversal
const allStoredMetadata = await fileStorage.getAllFileMetadata();
const fileMap = new Map(allStoredMetadata.map(f => [f.id, f]));
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
const fileMap = new Map(allStoredStubs.map(f => [f.id, f]));
// Get the current file's IndexedDB data
const currentStoredFile = fileMap.get(fileId as FileId);
if (!currentStoredFile) {
const currentStoredStub = fileMap.get(fileId as FileId);
if (!currentStoredStub) {
console.warn(`No stored file found for ${fileId}`);
return;
}
// Build complete history chain using IndexedDB metadata
const historyFiles: StoredFileMetadata[] = [];
const historyFiles: StirlingFileStub[] = [];
// Find the original file
const originalFileId = currentStoredFile.originalFileId || currentStoredFile.id;
const originalFileId = currentStoredStub.originalFileId || currentStoredStub.id;
// Collect all files in this history chain
const chainFiles = Array.from(fileMap.values()).filter(file =>
@ -472,25 +470,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Sort by version number (oldest first for history display)
chainFiles.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1));
// Convert stored files to StoredFileMetadata format with proper history info
for (const storedFile of chainFiles) {
// Load the actual file to extract PDF metadata if available
const historyMetadata: StoredFileMetadata = {
id: storedFile.id,
name: storedFile.name,
type: storedFile.type,
size: storedFile.size,
lastModified: storedFile.lastModified,
thumbnail: storedFile.thumbnail,
versionNumber: storedFile.versionNumber,
isLeaf: storedFile.isLeaf,
// Use IndexedDB data directly - it's more reliable than re-parsing PDF
originalFileId: storedFile.originalFileId,
parentFileId: storedFile.parentFileId,
toolHistory: storedFile.toolHistory
};
historyFiles.push(historyMetadata);
}
// StirlingFileStubs already have all the data we need - no conversion required!
historyFiles.push(...chainFiles);
// Cache the loaded history files
setLoadedHistoryFiles(prev => new Map(prev.set(fileId as FileId, historyFiles)));
@ -508,24 +489,17 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}, [expandedFileIds, recentFiles]);
const handleAddToRecents = useCallback(async (file: StoredFileMetadata) => {
const handleAddToRecents = useCallback(async (file: StirlingFileStub) => {
try {
console.log('Adding to recents:', file.name, 'version:', file.versionNumber);
// Mark the file as a leaf node so it appears in recent files
await fileStorage.markFileAsLeaf(file.id);
// Load file from storage and use addStoredFiles pattern
const storedFile = await fileStorage.getFile(file.id);
if (!storedFile) {
throw new Error(`File not found in storage: ${file.name}`);
}
// Use direct StoredFile approach - much more efficient
onStoredFilesSelect([storedFile]);
console.log('Successfully added to recents:', file.name, 'v' + file.versionNumber);
// Refresh the recent files list to show updated state
await refreshRecentFiles();
} catch (error) {
console.error('Failed to add to recents:', error);
}
}, [onStoredFilesSelect]);
}, [refreshRecentFiles]);
// Cleanup blob URLs when component unmounts
useEffect(() => {

View File

@ -1,15 +1,14 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { useFileHandler } from '../hooks/useFileHandler';
import { StoredFileMetadata, StoredFile } from '../services/fileStorage';
import { FileId } from '../types/file';
import { useFileActions } from './FileContext';
import { StirlingFileStub } from '../types/fileContext';
interface FilesModalContextType {
isFilesModalOpen: boolean;
openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void;
closeFilesModal: () => void;
onFileSelect: (file: File) => void;
onFilesSelect: (files: File[]) => void;
onStoredFilesSelect: (storedFiles: StoredFile[]) => void;
onFileUpload: (files: File[]) => void;
onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void;
onModalClose?: () => void;
setOnModalClose: (callback: () => void) => void;
}
@ -17,7 +16,8 @@ interface FilesModalContextType {
const FilesModalContext = createContext<FilesModalContextType | null>(null);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
const { addFiles } = useFileHandler();
const { actions } = useFileActions();
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
@ -36,39 +36,34 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
onModalClose?.();
}, [onModalClose]);
const handleFileSelect = useCallback((file: File) => {
if (customHandler) {
// Use custom handler for special cases (like page insertion)
customHandler([file], insertAfterPage);
} else {
// Use normal file handling
addToActiveFiles(file);
}
closeFilesModal();
}, [addToActiveFiles, closeFilesModal, insertAfterPage, customHandler]);
const handleFilesSelect = useCallback((files: File[]) => {
const handleFileUpload = useCallback((files: File[]) => {
if (customHandler) {
// Use custom handler for special cases (like page insertion)
customHandler(files, insertAfterPage);
} else {
// Use normal file handling
addMultipleFiles(files);
addFiles(files);
}
closeFilesModal();
}, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]);
}, [addFiles, closeFilesModal, insertAfterPage, customHandler]);
const handleStoredFilesSelect = useCallback((storedFiles: StoredFile[]) => {
const handleRecentFileSelect = useCallback((stirlingFileStubs: StirlingFileStub[]) => {
if (customHandler) {
// Use custom handler for special cases (like page insertion)
const files = storedFiles.map(storedFile => new File([storedFile.data], storedFile.name, { type: storedFile.type, lastModified: storedFile.lastModified }));
customHandler(files, insertAfterPage);
// For custom handlers, we need to load the actual files first
// This is a bit complex - for now, let's not support custom handlers with stubs
console.warn('Custom handlers not yet supported for StirlingFileStub selection');
closeFilesModal();
return;
}
// Use the new addStirlingFileStubs action to preserve metadata
if (actions.addStirlingFileStubs) {
actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
} else {
// Use normal file handling
addStoredFiles(storedFiles);
console.error('addStirlingFileStubs action not available');
}
closeFilesModal();
}, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]);
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler]);
const setModalCloseCallback = useCallback((callback: () => void) => {
setOnModalClose(() => callback);
@ -78,18 +73,16 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
isFilesModalOpen,
openFilesModal,
closeFilesModal,
onFileSelect: handleFileSelect,
onFilesSelect: handleFilesSelect,
onStoredFilesSelect: handleStoredFilesSelect,
onFileUpload: handleFileUpload,
onRecentFileSelect: handleRecentFileSelect,
onModalClose,
setOnModalClose: setModalCloseCallback,
}), [
isFilesModalOpen,
openFilesModal,
closeFilesModal,
handleFileSelect,
handleFilesSelect,
handleStoredFilesSelect,
handleFileUpload,
handleRecentFileSelect,
onModalClose,
setModalCloseCallback,
]);

View File

@ -4,23 +4,23 @@
*/
import React, { createContext, useContext, useCallback, useRef } from 'react';
const DEBUG = process.env.NODE_ENV === 'development';
import { fileStorage } from '../services/fileStorage';
import { FileId } from '../types/file';
import { StoredFileMetadata } from '../services/fileStorage';
import { StirlingFileStub, createStirlingFile } from '../types/fileContext';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
const DEBUG = process.env.NODE_ENV === 'development';
interface IndexedDBContextValue {
// Core CRUD operations
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<StoredFileMetadata>;
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<StirlingFileStub>;
loadFile: (fileId: FileId) => Promise<File | null>;
loadMetadata: (fileId: FileId) => Promise<StoredFileMetadata | null>;
loadMetadata: (fileId: FileId) => Promise<StirlingFileStub | null>;
deleteFile: (fileId: FileId) => Promise<void>;
// Batch operations
loadAllMetadata: () => Promise<StoredFileMetadata[]>;
loadLeafMetadata: () => Promise<StoredFileMetadata[]>; // Only leaf files for recent files list
loadAllMetadata: () => Promise<StirlingFileStub[]>;
loadLeafMetadata: () => Promise<StirlingFileStub[]>; // Only leaf files for recent files list
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
clearAll: () => Promise<void>;
@ -58,51 +58,25 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`);
}, []);
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<StoredFileMetadata> => {
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<StirlingFileStub> => {
// Use existing thumbnail or generate new one if none provided
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
// Extract history data if attached to the file by tool operations
const historyData = (file as any).__historyData as {
versionNumber: number;
originalFileId: string;
parentFileId: FileId | undefined;
toolHistory: Array<{ toolName: string; timestamp: number; }>;
} | undefined;
if (historyData) {
console.log('🏛️ INDEXEDDB CONTEXT - Found history data on file:', {
fileName: file.name,
versionNumber: historyData.versionNumber,
originalFileId: historyData.originalFileId,
parentFileId: historyData.parentFileId,
toolChainLength: historyData.toolHistory.length
});
}
// Store in IndexedDB with history data
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail, true, historyData);
// Store in IndexedDB (no history data - that's handled by direct fileStorage calls now)
const stirlingFile = createStirlingFile(file, fileId);
await fileStorage.storeStirlingFile(stirlingFile, thumbnail, true);
const storedFile = await fileStorage.getStirlingFileStub(fileId);
// Cache the file object for immediate reuse
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
evictLRUEntries();
// Return metadata with history information from the stored file
const metadata: StoredFileMetadata = {
id: fileId,
name: file.name,
type: file.type,
size: file.size,
lastModified: file.lastModified,
thumbnail,
isLeaf: true,
versionNumber: storedFile.versionNumber,
originalFileId: storedFile.originalFileId,
parentFileId: storedFile.parentFileId || undefined,
toolHistory: storedFile.toolHistory
};
// Return StirlingFileStub from the stored file (no conversion needed)
if (!storedFile) {
throw new Error(`Failed to retrieve stored file after saving: ${file.name}`);
}
return metadata;
return storedFile;
}, []);
const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => {
@ -115,14 +89,11 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
}
// Load from IndexedDB
const storedFile = await fileStorage.getFile(fileId);
const storedFile = await fileStorage.getStirlingFile(fileId);
if (!storedFile) return null;
// Reconstruct File object
const file = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
// StirlingFile is already a File object, no reconstruction needed
const file = storedFile;
// Cache for future use with LRU eviction
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
@ -131,34 +102,9 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
return file;
}, [evictLRUEntries]);
const loadMetadata = useCallback(async (fileId: FileId): Promise<StoredFileMetadata | null> => {
// Try to get from cache first (no IndexedDB hit)
const cached = fileCache.current.get(fileId);
if (cached) {
const file = cached.file;
return {
id: fileId,
name: file.name,
type: file.type,
size: file.size,
lastModified: file.lastModified
};
}
// Load metadata from IndexedDB (efficient - no data field)
const metadata = await fileStorage.getAllFileMetadata();
const fileMetadata = metadata.find(m => m.id === fileId);
if (!fileMetadata) return null;
return {
id: fileMetadata.id,
name: fileMetadata.name,
type: fileMetadata.type,
size: fileMetadata.size,
lastModified: fileMetadata.lastModified,
thumbnail: fileMetadata.thumbnail
};
const loadMetadata = useCallback(async (fileId: FileId): Promise<StirlingFileStub | null> => {
// Load stub directly from storage service
return await fileStorage.getStirlingFileStub(fileId);
}, []);
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
@ -166,142 +112,22 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
fileCache.current.delete(fileId);
// Remove from IndexedDB
await fileStorage.deleteFile(fileId);
await fileStorage.deleteStirlingFile(fileId);
}, []);
const loadLeafMetadata = useCallback(async (): Promise<StoredFileMetadata[]> => {
const metadata = await fileStorage.getLeafFileMetadata(); // Only get leaf files
const loadLeafMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
const metadata = await fileStorage.getLeafStirlingFileStubs(); // Only get leaf files
// Separate PDF and non-PDF files for different processing
const pdfFiles = metadata.filter(m => m.type.includes('pdf'));
const nonPdfFiles = metadata.filter(m => !m.type.includes('pdf'));
// All files are already StirlingFileStub objects, no processing needed
return metadata;
// Process non-PDF files immediately (no history extraction needed)
const nonPdfMetadata: StoredFileMetadata[] = nonPdfFiles.map(m => ({
id: m.id,
name: m.name,
type: m.type,
size: m.size,
lastModified: m.lastModified,
thumbnail: m.thumbnail,
isLeaf: m.isLeaf
}));
// Process PDF files with controlled concurrency to avoid memory issues
const BATCH_SIZE = 5; // Process 5 PDFs at a time to avoid overwhelming memory
const pdfMetadata: StoredFileMetadata[] = [];
for (let i = 0; i < pdfFiles.length; i += BATCH_SIZE) {
const batch = pdfFiles.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (m) => {
try {
// For PDF files, use history data from IndexedDB instead of extracting from PDF
const storedFile = await fileStorage.getFile(m.id);
if (storedFile) {
return {
id: m.id,
name: m.name,
type: m.type,
size: m.size,
lastModified: m.lastModified,
thumbnail: m.thumbnail,
isLeaf: m.isLeaf,
versionNumber: storedFile.versionNumber,
historyInfo: {
originalFileId: storedFile.originalFileId,
parentFileId: storedFile.parentFileId || undefined,
versionNumber: storedFile.versionNumber,
toolChain: storedFile.toolHistory
}
};
}
} catch (error) {
if (DEBUG) console.warn('🗂️ Failed to load stored file data for leaf file:', m.name, error);
}
// Fallback to basic metadata without history
return {
id: m.id,
name: m.name,
type: m.type,
size: m.size,
lastModified: m.lastModified,
thumbnail: m.thumbnail,
isLeaf: m.isLeaf
};
}));
pdfMetadata.push(...batchResults);
}
return [...nonPdfMetadata, ...pdfMetadata];
}, []);
const loadAllMetadata = useCallback(async (): Promise<StoredFileMetadata[]> => {
const metadata = await fileStorage.getAllFileMetadata();
const loadAllMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
const metadata = await fileStorage.getAllStirlingFileStubs();
// Separate PDF and non-PDF files for different processing
const pdfFiles = metadata.filter(m => m.type.includes('pdf'));
const nonPdfFiles = metadata.filter(m => !m.type.includes('pdf'));
// Process non-PDF files immediately (no history extraction needed)
const nonPdfMetadata: StoredFileMetadata[] = nonPdfFiles.map(m => ({
id: m.id,
name: m.name,
type: m.type,
size: m.size,
lastModified: m.lastModified,
thumbnail: m.thumbnail
}));
// Process PDF files with controlled concurrency to avoid memory issues
const BATCH_SIZE = 5; // Process 5 PDFs at a time to avoid overwhelming memory
const pdfMetadata: StoredFileMetadata[] = [];
for (let i = 0; i < pdfFiles.length; i += BATCH_SIZE) {
const batch = pdfFiles.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (m) => {
try {
// For PDF files, use history data from IndexedDB instead of extracting from PDF
const storedFile = await fileStorage.getFile(m.id);
if (storedFile) {
return {
id: m.id,
name: m.name,
type: m.type,
size: m.size,
lastModified: m.lastModified,
thumbnail: m.thumbnail,
versionNumber: storedFile.versionNumber,
historyInfo: {
originalFileId: storedFile.originalFileId,
parentFileId: storedFile.parentFileId || undefined,
versionNumber: storedFile.versionNumber,
toolChain: storedFile.toolHistory
}
};
}
} catch (error) {
if (DEBUG) console.warn('🗂️ Failed to load stored file data for metadata:', m.name, error);
}
// Fallback to basic metadata if history loading fails
return {
id: m.id,
name: m.name,
type: m.type,
size: m.size,
lastModified: m.lastModified,
thumbnail: m.thumbnail
};
}));
pdfMetadata.push(...batchResults);
}
return [...nonPdfMetadata, ...pdfMetadata];
// All files are already StirlingFileStub objects, no processing needed
return metadata;
}, []);
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
@ -309,7 +135,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
fileIds.forEach(id => fileCache.current.delete(id));
// Remove from IndexedDB in parallel
await Promise.all(fileIds.map(id => fileStorage.deleteFile(id)));
await Promise.all(fileIds.map(id => fileStorage.deleteStirlingFile(id)));
}, []);
const clearAll = useCallback(async (): Promise<void> => {

View File

@ -11,10 +11,11 @@ import {
createQuickKey
} from '../../types/fileContext';
import { FileId } from '../../types/file';
import { StoredFileMetadata } from '../../services/fileStorage';
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
import { FileLifecycleManager } from './lifecycle';
import { buildQuickKeySet } from './fileSelectors';
import { StirlingFile } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
const DEBUG = process.env.NODE_ENV === 'development';
@ -69,10 +70,60 @@ export function createProcessedFile(pageCount: number, thumbnail?: string) {
};
}
/**
* Create a child StirlingFileStub from a parent stub with proper history management.
* Used when a tool processes an existing file to create a new version with incremented history.
*
* @param parentStub - The parent StirlingFileStub to create a child from
* @param operation - Tool operation information (toolName, timestamp)
* @returns New child StirlingFileStub with proper version history
*/
export function createChildStub(
parentStub: StirlingFileStub,
operation: { toolName: string; timestamp: number },
resultingFile: File,
thumbnail?: string
): StirlingFileStub {
const newFileId = createFileId();
// Build new tool history by appending to parent's history
const parentToolHistory = parentStub.toolHistory || [];
const newToolHistory = [...parentToolHistory, operation];
// Calculate new version number
const newVersionNumber = (parentStub.versionNumber || 1) + 1;
// Determine original file ID (root of the version chain)
const originalFileId = parentStub.originalFileId || parentStub.id;
// Update the child stub's name to match the processed file
return {
// Copy all parent metadata
...parentStub,
// Update identity and version info
id: newFileId,
versionNumber: newVersionNumber,
parentFileId: parentStub.id,
originalFileId: originalFileId,
toolHistory: newToolHistory,
createdAt: Date.now(),
isLeaf: true, // New child is the current leaf node
name: resultingFile.name,
size: resultingFile.size,
type: resultingFile.type,
lastModified: resultingFile.lastModified,
thumbnailUrl: thumbnail
// Preserve thumbnails and processing metadata from parent
// These will be updated if the child has new thumbnails, but fallback to parent
};
}
/**
* File addition types
*/
type AddFileKind = 'raw' | 'processed' | 'stored';
type AddFileKind = 'raw' | 'processed';
interface AddFileOptions {
// For 'raw' files
@ -81,11 +132,11 @@ interface AddFileOptions {
// For 'processed' files
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
// For 'stored' files
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: StoredFileMetadata }>;
// Insertion position
insertAfterPageId?: string;
// Auto-selection after adding
selectFiles?: boolean;
}
export interface AddedFile {
@ -163,9 +214,8 @@ export async function addFiles(
}
// Create record with immediate thumbnail and page metadata
const record = toStirlingFileStub(file, fileId);
const record = toStirlingFileStub(file, fileId, thumbnail);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation)
if (thumbnail.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(thumbnail);
@ -207,9 +257,8 @@ export async function addFiles(
const fileId = createFileId();
filesRef.current.set(fileId, file);
const record = toStirlingFileStub(file, fileId);
const record = toStirlingFileStub(file, fileId, thumbnail);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation)
if (thumbnail.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(thumbnail);
@ -235,97 +284,6 @@ export async function addFiles(
}
break;
}
case 'stored': {
const { filesWithMetadata = [] } = options;
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
for (const { file, originalId, metadata } of filesWithMetadata) {
const quickKey = createQuickKey(file);
if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
continue;
}
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
// Try to preserve original ID, but generate new if it conflicts
let fileId = originalId;
if (filesRef.current.has(originalId)) {
fileId = createFileId();
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
}
filesRef.current.set(fileId, file);
const record = toStirlingFileStub(file, fileId);
// Generate processedFile metadata for stored files
let pageCount: number = 1;
// Only process PDFs through PDF worker manager, non-PDFs have no page count
if (file.type.startsWith('application/pdf')) {
try {
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
// Get page count from PDF
const arrayBuffer = await file.arrayBuffer();
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
pageCount = pdf.numPages;
pdfWorkerManager.destroyDocument(pdf);
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
} catch (error) {
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
}
} else {
pageCount = 0; // Non-PDFs have no page count
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
}
// Restore metadata from storage
if (metadata.thumbnail) {
record.thumbnailUrl = metadata.thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation)
if (metadata.thumbnail.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(metadata.thumbnail);
}
}
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId;
}
// Create processedFile metadata with correct page count
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
}
// Use history data from IndexedDB instead of extracting from PDF metadata
if (metadata.versionNumber || metadata.toolHistory) {
record.versionNumber = metadata.versionNumber;
record.originalFileId = metadata.originalFileId;
record.parentFileId = metadata.parentFileId;
record.toolHistory = metadata.toolHistory;
if (DEBUG) console.log(`📄 addFiles(stored): Applied IndexedDB history data to ${file.name}:`, {
versionNumber: record.versionNumber,
originalFileId: record.originalFileId,
parentFileId: record.parentFileId,
toolHistoryLength: record.toolHistory?.length || 0
});
}
existingQuickKeys.add(quickKey);
stirlingFileStubs.push(record);
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
}
break;
}
}
// Dispatch ADD_FILES action if we have new files
@ -341,113 +299,94 @@ export async function addFiles(
}
}
/**
* Helper function to process files into records with thumbnails and metadata
*/
async function processFilesIntoRecords(
files: File[],
filesRef: React.MutableRefObject<Map<FileId, File>>
): Promise<Array<{ record: StirlingFileStub; file: File; fileId: FileId; thumbnail?: string }>> {
return Promise.all(
files.map(async (file) => {
const fileId = createFileId();
filesRef.current.set(fileId, file);
// Generate thumbnail and page count
let thumbnail: string | undefined;
let pageCount: number = 1;
try {
if (DEBUG) console.log(`📄 Generating thumbnail for file ${file.name}`);
const result = await generateThumbnailWithMetadata(file);
thumbnail = result.thumbnail;
pageCount = result.pageCount;
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
}
const record = toStirlingFileStub(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
}
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
}
// History metadata is now managed in IndexedDB, not in PDF metadata
return { record, file, fileId, thumbnail };
})
);
}
/**
* Consume files helper - replace unpinned input files with output files
* Now accepts pre-created StirlingFiles and StirlingFileStubs to preserve all metadata
*/
export async function consumeFiles(
inputFileIds: FileId[],
outputFiles: File[],
outputStirlingFiles: StirlingFile[],
outputStirlingFileStubs: StirlingFileStub[],
filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>,
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; markFileAsProcessed: (fileId: FileId) => Promise<boolean> } | null
dispatch: React.Dispatch<FileContextAction>
): Promise<FileId[]> {
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputStirlingFiles.length} output files with pre-created stubs`);
// Process output files with thumbnails and metadata
const outputStirlingFileStubs = await processFilesIntoRecords(outputFiles, filesRef);
// Mark input files as processed in IndexedDB (no longer leaf nodes) and save output files
if (indexedDB) {
// Mark input files as processed (isLeaf = false)
await Promise.all(
inputFileIds.map(async (fileId) => {
try {
await indexedDB.markFileAsProcessed(fileId);
if (DEBUG) console.log(`📄 Marked file ${fileId} as processed (no longer leaf)`);
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to mark file ${fileId} as processed:`, error);
}
})
);
// Save output files to IndexedDB and update the StirlingFileStub records with version info
await Promise.all(outputStirlingFileStubs.map(async ({ file, fileId, record }) => {
try {
const metadata = await indexedDB.saveFile(file, fileId, record.thumbnailUrl);
// Update the record directly with version information from IndexedDB
if (metadata.versionNumber || metadata.historyInfo) {
record.versionNumber = metadata.versionNumber;
record.originalFileId = metadata.historyInfo?.originalFileId;
record.parentFileId = metadata.historyInfo?.parentFileId;
record.toolHistory = metadata.historyInfo?.toolChain;
if (DEBUG) console.log(`📄 Updated output record for ${file.name} with IndexedDB history data:`, {
versionNumber: metadata.versionNumber,
originalFileId: metadata.historyInfo?.originalFileId,
toolChainLength: metadata.historyInfo?.toolChain?.length || 0
});
}
} catch (error) {
console.error('Failed to persist output file to IndexedDB:', file.name, error);
}
}));
// Validate that we have matching files and stubs
if (outputStirlingFiles.length !== outputStirlingFileStubs.length) {
throw new Error(`Mismatch between output files (${outputStirlingFiles.length}) and stubs (${outputStirlingFileStubs.length})`);
}
// Dispatch the consume action with updated records
// Store StirlingFiles in filesRef using their existing IDs (no ID generation needed)
for (let i = 0; i < outputStirlingFiles.length; i++) {
const stirlingFile = outputStirlingFiles[i];
const stub = outputStirlingFileStubs[i];
if (stirlingFile.fileId !== stub.id) {
console.warn(`📄 consumeFiles: ID mismatch between StirlingFile (${stirlingFile.fileId}) and stub (${stub.id})`);
}
filesRef.current.set(stirlingFile.fileId, stirlingFile);
if (DEBUG) console.log(`📄 consumeFiles: Stored StirlingFile ${stirlingFile.name} with ID ${stirlingFile.fileId}`);
}
// Mark input files as processed in storage (no longer leaf nodes)
await Promise.all(
inputFileIds.map(async (fileId) => {
try {
await fileStorage.markFileAsProcessed(fileId);
if (DEBUG) console.log(`📄 Marked file ${fileId} as processed (no longer leaf)`);
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to mark file ${fileId} as processed:`, error);
}
})
);
// Save output files directly to fileStorage with complete metadata
for (let i = 0; i < outputStirlingFiles.length; i++) {
const stirlingFile = outputStirlingFiles[i];
const stub = outputStirlingFileStubs[i];
try {
// Use fileStorage directly with complete metadata from stub
await fileStorage.storeStirlingFile(
stirlingFile,
stub.thumbnailUrl,
true, // isLeaf - new files are leaf nodes
{
versionNumber: stub.versionNumber || 1,
originalFileId: stub.originalFileId || stub.id,
parentFileId: stub.parentFileId,
toolHistory: stub.toolHistory || []
}
);
if (DEBUG) console.log(`📄 Saved StirlingFile ${stirlingFile.name} directly to storage with complete metadata:`, {
fileId: stirlingFile.fileId,
versionNumber: stub.versionNumber,
originalFileId: stub.originalFileId,
parentFileId: stub.parentFileId,
toolChainLength: stub.toolHistory?.length || 0
});
} catch (error) {
console.error('Failed to persist output file to fileStorage:', stirlingFile.name, error);
}
}
// Dispatch the consume action with pre-created stubs (no processing needed)
dispatch({
type: 'CONSUME_FILES',
payload: {
inputFileIds,
outputStirlingFileStubs: outputStirlingFileStubs.map(({ record }) => record)
outputStirlingFileStubs: outputStirlingFileStubs
}
});
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 outputStirlingFileStubs.map(({ fileId }) => fileId);
return outputStirlingFileStubs.map(stub => stub.id);
}
/**
@ -558,6 +497,71 @@ export async function undoConsumeFiles(
/**
* Action factory functions
*/
/**
* Add files using existing StirlingFileStubs from storage - preserves all metadata
* Use this when loading files that already exist in storage (FileManager, etc.)
* StirlingFileStubs come with proper thumbnails, history, processing state
*/
export async function addStirlingFileStubs(
stirlingFileStubs: StirlingFileStub[],
options: { insertAfterPageId?: string; selectFiles?: boolean } = {},
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>,
_lifecycleManager: FileLifecycleManager
): Promise<StirlingFile[]> {
await addFilesMutex.lock();
try {
if (DEBUG) console.log(`📄 addStirlingFileStubs: Adding ${stirlingFileStubs.length} StirlingFileStubs preserving metadata`);
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
const validStubs: StirlingFileStub[] = [];
const loadedFiles: StirlingFile[] = [];
for (const stub of stirlingFileStubs) {
// Check for duplicates using quickKey
if (existingQuickKeys.has(stub.quickKey || '')) {
if (DEBUG) console.log(`📄 Skipping duplicate StirlingFileStub: ${stub.name}`);
continue;
}
// Load the actual StirlingFile from storage
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
if (!stirlingFile) {
console.warn(`📄 Failed to load StirlingFile for stub: ${stub.name} (${stub.id})`);
continue;
}
// Store the loaded file in filesRef
filesRef.current.set(stub.id, stirlingFile);
// Use the original stub (preserves thumbnails, history, metadata!)
const record = { ...stub };
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId;
}
existingQuickKeys.add(stub.quickKey || '');
validStubs.push(record);
loadedFiles.push(stirlingFile);
}
// Dispatch ADD_FILES action if we have new files
if (validStubs.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs: validStubs } });
if (DEBUG) console.log(`📄 addStirlingFileStubs: Successfully added ${validStubs.length} files with preserved metadata`);
}
return loadedFiles;
} finally {
addFilesMutex.unlock();
}
}
export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) => ({
setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }),
setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }),

View File

@ -6,8 +6,9 @@ import { useToolState, type ProcessingProgress } from './useToolState';
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
import { useToolResources } from './useToolResources';
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext';
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, toStirlingFileStub } from '../../../types/fileContext';
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
import { createChildStub } from '../../../contexts/file/fileActions';
// Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler };
@ -258,46 +259,25 @@ export const useToolOperation = <TParams>(
}
}
// Prepare output files with history data before saving
const processedFilesWithHistory = processedFiles.map(file => {
// Find the corresponding input file for history chain
const inputStub = inputStirlingFileStubs.find(stub =>
inputFileIds.includes(stub.id)
) || inputStirlingFileStubs[0]; // Fallback to first input if not found
// Create new tool operation
const newToolOperation = {
toolName: config.operationType,
timestamp: Date.now()
};
console.log("tool complete inputs ")
const outputStirlingFileStubs = processedFiles.length != inputStirlingFileStubs.length
? processedFiles.map((file, index) => toStirlingFileStub(file, undefined, thumbnails[index]))
: processedFiles.map((resultingFile, index) =>
createChildStub(inputStirlingFileStubs[index], newToolOperation, resultingFile, thumbnails[index])
);
// Create new tool operation
const newToolOperation = {
toolName: config.operationType,
timestamp: Date.now()
};
// Build complete tool chain
const existingToolChain = inputStub?.toolHistory || [];
const toolHistory = [...existingToolChain, newToolOperation];
// Calculate version number
const versionNumber = inputStub?.versionNumber ? inputStub.versionNumber + 1 : 1;
// Attach history data to file
(file as any).__historyData = {
versionNumber,
originalFileId: inputStub?.originalFileId || inputStub?.id,
parentFileId: inputStub?.id || null,
toolHistory
};
console.log('🏛️ FILE HISTORY - Prepared file with history:', {
fileName: file.name,
versionNumber,
originalFileId: inputStub?.originalFileId || inputStub?.id,
parentFileId: inputStub?.id,
toolChainLength: toolHistory.length
});
return file;
// Create StirlingFile objects from processed files and child stubs
const outputStirlingFiles = processedFiles.map((file, index) => {
const childStub = outputStirlingFileStubs[index];
return createStirlingFile(file, childStub.id);
});
const outputFileIds = await consumeFiles(inputFileIds, processedFilesWithHistory);
const outputFileIds = await consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs);
// Store operation data for undo (only store what we need to avoid memory bloat)
lastOperationRef.current = {

View File

@ -1,39 +1,15 @@
import { useCallback } from 'react';
import { useFileState, useFileActions } from '../contexts/FileContext';
import { StoredFileMetadata, StoredFile } from '../services/fileStorage';
import { FileId } from '../types/file';
import { useFileActions } from '../contexts/FileContext';
export const useFileHandler = () => {
const { state } = useFileState(); // Still needed for addStoredFiles
const { actions } = useFileActions();
const addToActiveFiles = useCallback(async (file: File) => {
// Let FileContext handle deduplication with quickKey logic
await actions.addFiles([file], { selectFiles: true });
}, [actions.addFiles]);
const addMultipleFiles = useCallback(async (files: File[]) => {
const addFiles = useCallback(async (files: File[]) => {
// Let FileContext handle deduplication with quickKey logic
await actions.addFiles(files, { selectFiles: true });
}, [actions.addFiles]);
// Add stored files preserving their original IDs to prevent session duplicates
const addStoredFiles = useCallback(async (storedFiles: StoredFile[]) => {
// Filter out files that already exist with the same ID (exact match)
const newFiles = storedFiles.filter(({ id }) => {
return state.files.byId[id] === undefined;
});
if (newFiles.length > 0) {
await actions.addStoredFiles(newFiles, { selectFiles: true });
}
console.log(`📁 Added ${newFiles.length} stored files (${storedFiles.length - newFiles.length} skipped as duplicates)`);
}, [state.files.byId, actions.addStoredFiles]);
return {
addToActiveFiles,
addMultipleFiles,
addStoredFiles,
addFiles,
};
};

View File

@ -1,28 +1,29 @@
import { useState, useCallback } from 'react';
import { useIndexedDB } from '../contexts/IndexedDBContext';
import { StoredFileMetadata, StoredFile, fileStorage } from '../services/fileStorage';
import { fileStorage } from '../services/fileStorage';
import { StirlingFileStub, StirlingFile } from '../types/fileContext';
import { FileId } from '../types/fileContext';
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
const indexedDB = useIndexedDB();
const convertToFile = useCallback(async (fileMetadata: StoredFileMetadata): Promise<File> => {
const convertToFile = useCallback(async (fileStub: StirlingFileStub): Promise<File> => {
if (!indexedDB) {
throw new Error('IndexedDB context not available');
}
// Regular file loading
if (fileMetadata.id) {
const file = await indexedDB.loadFile(fileMetadata.id);
if (fileStub.id) {
const file = await indexedDB.loadFile(fileStub.id);
if (file) {
return file;
}
}
throw new Error(`File not found in storage: ${fileMetadata.name} (ID: ${fileMetadata.id})`);
throw new Error(`File not found in storage: ${fileStub.name} (ID: ${fileStub.id})`);
}, [indexedDB]);
const loadRecentFiles = useCallback(async (): Promise<StoredFileMetadata[]> => {
const loadRecentFiles = useCallback(async (): Promise<StirlingFileStub[]> => {
setLoading(true);
try {
if (!indexedDB) {
@ -30,11 +31,10 @@ export const useFileManager = () => {
}
// Load only leaf files metadata (processed files that haven't been used as input for other tools)
const storedFileMetadata = await indexedDB.loadLeafMetadata();
const stirlingFileStubs = await fileStorage.getLeafStirlingFileStubs();
// For now, only regular files - drafts will be handled separately in the future
const allFiles = storedFileMetadata;
const sortedFiles = allFiles.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
const sortedFiles = stirlingFileStubs.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
return sortedFiles;
} catch (error) {
@ -45,7 +45,7 @@ export const useFileManager = () => {
}
}, [indexedDB]);
const handleRemoveFile = useCallback(async (index: number, files: StoredFileMetadata[], setFiles: (files: StoredFileMetadata[]) => void) => {
const handleRemoveFile = useCallback(async (index: number, files: StirlingFileStub[], setFiles: (files: StirlingFileStub[]) => void) => {
const file = files[index];
if (!file.id) {
throw new Error('File ID is required for removal');
@ -70,10 +70,10 @@ export const useFileManager = () => {
// Store file with provided UUID from FileContext (thumbnail generated internally)
const metadata = await indexedDB.saveFile(file, fileId);
// Convert file to ArrayBuffer for StoredFile interface compatibility
// Convert file to ArrayBuffer for storage compatibility
const arrayBuffer = await file.arrayBuffer();
// Return StoredFile format for compatibility with old API
// This method is deprecated - use FileStorage directly instead
return {
id: fileId,
name: file.name,
@ -81,7 +81,7 @@ export const useFileManager = () => {
size: file.size,
lastModified: file.lastModified,
data: arrayBuffer,
thumbnail: metadata.thumbnail
thumbnail: metadata.thumbnailUrl
};
} catch (error) {
console.error('Failed to store file:', error);
@ -105,24 +105,24 @@ export const useFileManager = () => {
setSelectedFiles([]);
};
const selectMultipleFiles = async (files: StoredFileMetadata[], onStoredFilesSelect: (storedFiles: StoredFile[]) => void) => {
const selectMultipleFiles = async (files: StirlingFileStub[], onStirlingFilesSelect: (stirlingFiles: StirlingFile[]) => void) => {
if (selectedFiles.length === 0) return;
try {
// Filter by UUID and load full StoredFile objects directly
// Filter by UUID and load full StirlingFile objects directly
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id));
const storedFiles = await Promise.all(
selectedFileObjects.map(async (metadata) => {
const storedFile = await fileStorage.getFile(metadata.id);
if (!storedFile) {
throw new Error(`File not found in storage: ${metadata.name}`);
const stirlingFiles = await Promise.all(
selectedFileObjects.map(async (stub) => {
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
if (!stirlingFile) {
throw new Error(`File not found in storage: ${stub.name}`);
}
return storedFile;
return stirlingFile;
})
);
onStoredFilesSelect(storedFiles);
onStirlingFilesSelect(stirlingFiles);
clearSelection();
} catch (error) {
console.error('Failed to load selected files:', error);

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { StoredFileMetadata } from "../services/fileStorage";
import { StirlingFileStub } from "../types/fileContext";
import { useIndexedDB } from "../contexts/IndexedDBContext";
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
import { FileId } from "../types/fileContext";
@ -9,7 +9,7 @@ import { FileId } from "../types/fileContext";
* Hook for IndexedDB-aware thumbnail loading
* Handles thumbnail generation for files not in IndexedDB
*/
export function useIndexedDBThumbnail(file: StoredFileMetadata | undefined | null): {
export function useIndexedDBThumbnail(file: StirlingFileStub | undefined | null): {
thumbnail: string | null;
isGenerating: boolean
} {
@ -27,8 +27,8 @@ export function useIndexedDBThumbnail(file: StoredFileMetadata | undefined | nul
}
// First priority: use stored thumbnail
if (file.thumbnail) {
setThumb(file.thumbnail);
if (file.thumbnailUrl) {
setThumb(file.thumbnailUrl);
return;
}
@ -77,7 +77,7 @@ export function useIndexedDBThumbnail(file: StoredFileMetadata | undefined | nul
loadThumbnail();
return () => { cancelled = true; };
}, [file, file?.thumbnail, file?.id, indexedDB, generating]);
}, [file, file?.thumbnailUrl, file?.id, indexedDB, generating]);
return { thumbnail: thumb, isGenerating: generating };
}

View File

@ -1,24 +1,25 @@
/**
* IndexedDB File Storage Service
* Provides high-capacity file storage for PDF processing
* Now uses centralized IndexedDB manager
* Stirling File Storage Service
* Single-table architecture with typed query methods
* Forces correct usage patterns through service API design
*/
import { FileId, BaseFileMetadata } from '../types/file';
import { StirlingFile, StirlingFileStub, createStirlingFile } from '../types/fileContext';
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
export interface StoredFile extends BaseFileMetadata {
/**
* Storage record - single source of truth
* Contains all data needed for both StirlingFile and StirlingFileStub
*/
export interface StoredStirlingFileRecord extends BaseFileMetadata {
data: ArrayBuffer;
fileId: FileId; // Matches runtime StirlingFile.fileId exactly
quickKey: string; // Matches runtime StirlingFile.quickKey exactly
thumbnail?: string;
url?: string; // For compatibility with existing components
}
/**
* Lightweight metadata version of StoredFile (without ArrayBuffer data)
* Used for efficient file browsing in FileManager without loading file data
*/
export type StoredFileMetadata = Omit<StoredFile, 'data'>;
export interface StorageStats {
used: number;
available: number;
@ -38,11 +39,10 @@ class FileStorageService {
}
/**
* Store a file in IndexedDB with external UUID
* Store a StirlingFile with its metadata
*/
async storeFile(
file: File,
fileId: FileId,
async storeStirlingFile(
stirlingFile: StirlingFile,
thumbnail?: string,
isLeaf: boolean = true,
historyData?: {
@ -54,51 +54,47 @@ class FileStorageService {
timestamp: number;
}>;
}
): Promise<StoredFile> {
): Promise<void> {
const db = await this.getDatabase();
const arrayBuffer = await stirlingFile.arrayBuffer();
const arrayBuffer = await file.arrayBuffer();
const storedFile: StoredFile = {
id: fileId, // Use provided UUID
name: file.name,
type: file.type,
size: file.size,
lastModified: file.lastModified,
const record: StoredStirlingFileRecord = {
id: stirlingFile.fileId,
fileId: stirlingFile.fileId, // Explicit field for clarity
quickKey: stirlingFile.quickKey,
name: stirlingFile.name,
type: stirlingFile.type,
size: stirlingFile.size,
lastModified: stirlingFile.lastModified,
data: arrayBuffer,
thumbnail,
isLeaf,
// History data - use provided data or defaults for original files
versionNumber: historyData?.versionNumber ?? 1,
originalFileId: historyData?.originalFileId ?? fileId,
originalFileId: historyData?.originalFileId ?? stirlingFile.fileId,
parentFileId: historyData?.parentFileId ?? undefined,
toolHistory: historyData?.toolHistory ?? []
};
return new Promise((resolve, reject) => {
try {
// Verify store exists before creating transaction
if (!db.objectStoreNames.contains(this.storeName)) {
throw new Error(`Object store '${this.storeName}' not found. Available stores: ${Array.from(db.objectStoreNames).join(', ')}`);
}
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
// Debug logging
console.log('📄 LEAF FLAG DEBUG - Storing file:', {
id: storedFile.id,
name: storedFile.name,
isLeaf: storedFile.isLeaf,
dataSize: storedFile.data.byteLength
});
const request = store.add(storedFile);
const request = store.add(record);
request.onerror = () => {
console.error('IndexedDB add error:', request.error);
console.error('Failed object:', storedFile);
reject(request.error);
};
request.onsuccess = () => {
console.log('File stored successfully with ID:', storedFile.id);
resolve(storedFile);
resolve();
};
} catch (error) {
console.error('Transaction error:', error);
@ -108,9 +104,9 @@ class FileStorageService {
}
/**
* Retrieve a file from IndexedDB
* Get StirlingFile with full data - for loading into workbench
*/
async getFile(id: FileId): Promise<StoredFile | null> {
async getStirlingFile(id: FileId): Promise<StirlingFile | null> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
@ -118,81 +114,167 @@ class FileStorageService {
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || null);
});
}
/**
* Get all stored files (WARNING: loads all data into memory)
*/
async getAllFiles(): Promise<StoredFile[]> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => {
// Filter out null/corrupted entries
const files = request.result.filter(file =>
file &&
file.data &&
file.name &&
typeof file.size === 'number'
);
resolve(files);
const record = request.result as StoredStirlingFileRecord | undefined;
if (!record) {
resolve(null);
return;
}
// Create File from stored data
const blob = new Blob([record.data], { type: record.type });
const file = new File([blob], record.name, {
type: record.type,
lastModified: record.lastModified
});
// Convert to StirlingFile with preserved IDs
const stirlingFile = createStirlingFile(file, record.fileId);
resolve(stirlingFile);
};
});
}
/**
* Get metadata of all stored files (without loading data into memory)
* Get multiple StirlingFiles - for batch loading
*/
async getAllFileMetadata(): Promise<StoredFileMetadata[]> {
async getStirlingFiles(ids: FileId[]): Promise<StirlingFile[]> {
const results = await Promise.all(ids.map(id => this.getStirlingFile(id)));
return results.filter((file): file is StirlingFile => file !== null);
}
/**
* Get StirlingFileStub (metadata only) - for UI browsing
*/
async getStirlingFileStub(id: FileId): Promise<StirlingFileStub | null> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const record = request.result as StoredStirlingFileRecord | undefined;
if (!record) {
resolve(null);
return;
}
// Create StirlingFileStub from metadata (no file data)
const stub: StirlingFileStub = {
id: record.id,
name: record.name,
type: record.type,
size: record.size,
lastModified: record.lastModified,
quickKey: record.quickKey,
thumbnailUrl: record.thumbnail,
isLeaf: record.isLeaf,
versionNumber: record.versionNumber,
originalFileId: record.originalFileId,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory,
createdAt: Date.now() // Current session
};
resolve(stub);
};
});
}
/**
* Get all StirlingFileStubs (metadata only) - for FileManager browsing
*/
async getAllStirlingFileStubs(): Promise<StirlingFileStub[]> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.openCursor();
const files: StoredFileMetadata[] = [];
const stubs: StirlingFileStub[] = [];
request.onerror = () => reject(request.error);
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
const storedFile = cursor.value;
// Only extract metadata, skip the data field
if (storedFile && storedFile.name && typeof storedFile.size === 'number') {
files.push({
id: storedFile.id,
name: storedFile.name,
type: storedFile.type,
size: storedFile.size,
lastModified: storedFile.lastModified,
thumbnail: storedFile.thumbnail,
versionNumber: storedFile.versionNumber || 1,
originalFileId: storedFile.originalFileId || storedFile.id,
parentFileId: storedFile.parentFileId || undefined,
toolHistory: storedFile.toolHistory || []
const record = cursor.value as StoredStirlingFileRecord;
if (record && record.name && typeof record.size === 'number') {
// Extract metadata only - no file data
stubs.push({
id: record.id,
name: record.name,
type: record.type,
size: record.size,
lastModified: record.lastModified,
quickKey: record.quickKey,
thumbnailUrl: record.thumbnail,
isLeaf: record.isLeaf,
versionNumber: record.versionNumber || 1,
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
createdAt: Date.now()
});
}
cursor.continue();
} else {
// Metadata loaded efficiently without file data
resolve(files);
resolve(stubs);
}
};
});
}
/**
* Delete a file from IndexedDB
* Get leaf StirlingFileStubs only - for unprocessed files
*/
async deleteFile(id: FileId): Promise<void> {
async getLeafStirlingFileStubs(): Promise<StirlingFileStub[]> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.openCursor();
const leafStubs: StirlingFileStub[] = [];
request.onerror = () => reject(request.error);
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
const record = cursor.value as StoredStirlingFileRecord;
// Only include leaf files (default to true if undefined)
if (record && record.name && typeof record.size === 'number' && record.isLeaf !== false) {
leafStubs.push({
id: record.id,
name: record.name,
type: record.type,
size: record.size,
lastModified: record.lastModified,
quickKey: record.quickKey,
thumbnailUrl: record.thumbnail,
isLeaf: record.isLeaf,
versionNumber: record.versionNumber || 1,
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
createdAt: Date.now()
});
}
cursor.continue();
} else {
resolve(leafStubs);
}
};
});
}
/**
* Delete StirlingFile - single operation, no sync issues
*/
async deleteStirlingFile(id: FileId): Promise<void> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
@ -206,403 +288,7 @@ class FileStorageService {
}
/**
* Update the lastModified timestamp of a file (for most recently used sorting)
*/
async touchFile(id: FileId): Promise<boolean> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const file = getRequest.result;
if (file) {
// Update lastModified to current timestamp
file.lastModified = Date.now();
const updateRequest = store.put(file);
updateRequest.onsuccess = () => resolve(true);
updateRequest.onerror = () => reject(updateRequest.error);
} else {
resolve(false); // File not found
}
};
getRequest.onerror = () => reject(getRequest.error);
});
}
/**
* Mark a file as no longer being a leaf (it has been processed)
*/
async markFileAsProcessed(id: FileId): Promise<boolean> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const file = getRequest.result;
if (file) {
console.log('📄 LEAF FLAG DEBUG - Marking as processed:', {
id: file.id,
name: file.name,
wasLeaf: file.isLeaf,
nowLeaf: false
});
file.isLeaf = false;
const updateRequest = store.put(file);
updateRequest.onsuccess = () => resolve(true);
updateRequest.onerror = () => reject(updateRequest.error);
} else {
console.warn('📄 LEAF FLAG DEBUG - File not found for processing:', id);
resolve(false); // File not found
}
};
getRequest.onerror = () => reject(getRequest.error);
});
}
/**
* Get only leaf files (files that haven't been processed yet)
*/
async getLeafFiles(): Promise<StoredFile[]> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.openCursor();
const leafFiles: StoredFile[] = [];
request.onerror = () => reject(request.error);
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
const storedFile = cursor.value;
if (storedFile && storedFile.isLeaf !== false) { // Default to true if undefined
leafFiles.push(storedFile);
}
cursor.continue();
} else {
resolve(leafFiles);
}
};
});
}
/**
* Get metadata of only leaf files (without loading data into memory)
*/
async getLeafFileMetadata(): Promise<StoredFileMetadata[]> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.openCursor();
const files: StoredFileMetadata[] = [];
request.onerror = () => reject(request.error);
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
const storedFile = cursor.value;
// Only include leaf files (default to true if undefined for backward compatibility)
if (storedFile && storedFile.name && typeof storedFile.size === 'number' && storedFile.isLeaf !== false) {
files.push({
id: storedFile.id,
name: storedFile.name,
type: storedFile.type,
size: storedFile.size,
lastModified: storedFile.lastModified,
thumbnail: storedFile.thumbnail,
isLeaf: storedFile.isLeaf,
versionNumber: storedFile.versionNumber || 1,
originalFileId: storedFile.originalFileId || storedFile.id,
parentFileId: storedFile.parentFileId || undefined,
toolHistory: storedFile.toolHistory || []
});
}
cursor.continue();
} else {
resolve(files);
}
};
});
}
/**
* Clear all stored files
*/
async clearAll(): Promise<void> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
/**
* Get storage statistics (only our IndexedDB usage)
*/
async getStorageStats(): Promise<StorageStats> {
let used = 0;
let available = 0;
let quota: number | undefined;
let fileCount = 0;
try {
// Get browser quota for context
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
quota = estimate.quota;
available = estimate.quota || 0;
}
// Calculate our actual IndexedDB usage from file metadata
const files = await this.getAllFileMetadata();
used = files.reduce((total, file) => total + (file?.size || 0), 0);
fileCount = files.length;
// Adjust available space
if (quota) {
available = quota - used;
}
} catch (error) {
console.warn('Could not get storage stats:', error);
// If we can't read metadata, database might be purged
used = 0;
fileCount = 0;
}
return {
used,
available,
fileCount,
quota
};
}
/**
* Get file count quickly without loading metadata
*/
async getFileCount(): Promise<number> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.count();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
/**
* Check all IndexedDB databases to see if files are in another version
*/
async debugAllDatabases(): Promise<void> {
console.log('=== Checking All IndexedDB Databases ===');
if ('databases' in indexedDB) {
try {
const databases = await indexedDB.databases();
console.log('Found databases:', databases);
for (const dbInfo of databases) {
if (dbInfo.name?.includes('stirling') || dbInfo.name?.includes('pdf')) {
console.log(`Checking database: ${dbInfo.name} (version: ${dbInfo.version})`);
try {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(dbInfo.name!, dbInfo.version);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
console.log(`Database ${dbInfo.name} object stores:`, Array.from(db.objectStoreNames));
db.close();
} catch (error) {
console.error(`Failed to open database ${dbInfo.name}:`, error);
}
}
}
} catch (error) {
console.error('Failed to list databases:', error);
}
} else {
console.log('indexedDB.databases() not supported');
}
// Also check our specific database with different versions
for (let version = 1; version <= 3; version++) {
try {
console.log(`Trying to open ${this.dbConfig.name} version ${version}...`);
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(this.dbConfig.name, version);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onupgradeneeded = () => {
// Don't actually upgrade, just check
request.transaction?.abort();
};
});
console.log(`Version ${version} object stores:`, Array.from(db.objectStoreNames));
if (db.objectStoreNames.contains('files')) {
const transaction = db.transaction(['files'], 'readonly');
const store = transaction.objectStore('files');
const countRequest = store.count();
countRequest.onsuccess = () => {
console.log(`Version ${version} files store has ${countRequest.result} entries`);
};
}
db.close();
} catch (error) {
if (error instanceof Error) {
console.log(`Version ${version} not accessible:`, error.message);
}
}
}
}
/**
* Debug method to check what's actually in the database
*/
async debugDatabaseContents(): Promise<void> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
// First try getAll to see if there's anything
const getAllRequest = store.getAll();
getAllRequest.onsuccess = () => {
console.log('=== Raw getAll() result ===');
console.log('Raw entries found:', getAllRequest.result.length);
getAllRequest.result.forEach((item, index) => {
console.log(`Raw entry ${index}:`, {
keys: Object.keys(item || {}),
id: item?.id,
name: item?.name,
size: item?.size,
type: item?.type,
hasData: !!item?.data,
dataSize: item?.data?.byteLength,
fullObject: item
});
});
};
// Then try cursor
const cursorRequest = store.openCursor();
console.log('=== IndexedDB Cursor Debug ===');
let count = 0;
cursorRequest.onerror = () => {
console.error('Cursor error:', cursorRequest.error);
reject(cursorRequest.error);
};
cursorRequest.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
count++;
const value = cursor.value;
console.log(`Cursor File ${count}:`, {
id: value?.id,
name: value?.name,
size: value?.size,
type: value?.type,
hasData: !!value?.data,
dataSize: value?.data?.byteLength,
hasThumbnail: !!value?.thumbnail,
allKeys: Object.keys(value || {})
});
cursor.continue();
} else {
console.log(`=== End Cursor Debug - Found ${count} files ===`);
resolve();
}
};
});
}
/**
* Convert StoredFile back to pure File object without mutations
* Returns a clean File object - use FileContext.addStoredFiles() for proper metadata handling
*/
createFileFromStored(storedFile: StoredFile): File {
if (!storedFile || !storedFile.data) {
throw new Error('Invalid stored file: missing data');
}
if (!storedFile.name || typeof storedFile.size !== 'number') {
throw new Error('Invalid stored file: missing metadata');
}
const blob = new Blob([storedFile.data], { type: storedFile.type });
const file = new File([blob], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
// Use FileContext.addStoredFiles() to properly associate with metadata
return file;
}
/**
* Create blob URL for stored file
*/
createBlobUrl(storedFile: StoredFile): string {
const blob = new Blob([storedFile.data], { type: storedFile.type });
return URL.createObjectURL(blob);
}
/**
* Get file data as ArrayBuffer for streaming/chunked processing
*/
async getFileData(id: FileId): Promise<ArrayBuffer | null> {
try {
const storedFile = await this.getFile(id);
return storedFile ? storedFile.data : null;
} catch (error) {
console.warn(`Failed to get file data for ${id}:`, error);
return null;
}
}
/**
* Create a temporary blob URL that gets revoked automatically
*/
async createTemporaryBlobUrl(id: FileId): Promise<string | null> {
const data = await this.getFileData(id);
if (!data) return null;
const blob = new Blob([data], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
// Auto-revoke after a short delay to free memory
setTimeout(() => {
URL.revokeObjectURL(url);
}, 10000); // 10 seconds
return url;
}
/**
* Update thumbnail for an existing file
* Update thumbnail for existing file
*/
async updateThumbnail(id: FileId, thumbnail: string): Promise<boolean> {
const db = await this.getDatabase();
@ -614,13 +300,12 @@ class FileStorageService {
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const storedFile = getRequest.result;
if (storedFile) {
storedFile.thumbnail = thumbnail;
const updateRequest = store.put(storedFile);
const record = getRequest.result as StoredStirlingFileRecord;
if (record) {
record.thumbnail = thumbnail;
const updateRequest = store.put(record);
updateRequest.onsuccess = () => {
console.log('Thumbnail updated for file:', id);
resolve(true);
};
updateRequest.onerror = () => {
@ -644,31 +329,161 @@ class FileStorageService {
}
/**
* Check if storage quota is running low
* Clear all stored files
*/
async isStorageLow(): Promise<boolean> {
const stats = await this.getStorageStats();
if (!stats.quota) return false;
async clearAll(): Promise<void> {
const db = await this.getDatabase();
const usagePercent = stats.used / stats.quota;
return usagePercent > 0.8; // Consider low if over 80% used
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
/**
* Clean up old files if storage is low
* Get storage statistics
*/
async cleanupOldFiles(maxFiles: number = 50): Promise<void> {
const files = await this.getAllFileMetadata();
async getStorageStats(): Promise<StorageStats> {
let used = 0;
let available = 0;
let quota: number | undefined;
let fileCount = 0;
if (files.length <= maxFiles) return;
try {
// Get browser quota for context
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
quota = estimate.quota;
available = estimate.quota || 0;
}
// Sort by last modified (oldest first)
files.sort((a, b) => a.lastModified - b.lastModified);
// Calculate our actual IndexedDB usage from file metadata
const stubs = await this.getAllStirlingFileStubs();
used = stubs.reduce((total, stub) => total + (stub?.size || 0), 0);
fileCount = stubs.length;
// Delete oldest files
const filesToDelete = files.slice(0, files.length - maxFiles);
for (const file of filesToDelete) {
await this.deleteFile(file.id);
// Adjust available space
if (quota) {
available = quota - used;
}
} catch (error) {
console.warn('Could not get storage stats:', error);
used = 0;
fileCount = 0;
}
return {
used,
available,
fileCount,
quota
};
}
/**
* Create blob URL for stored file data
*/
async createBlobUrl(id: FileId): Promise<string | null> {
try {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const record = request.result as StoredStirlingFileRecord | undefined;
if (record) {
const blob = new Blob([record.data], { type: record.type });
const url = URL.createObjectURL(blob);
resolve(url);
} else {
resolve(null);
}
};
});
} catch (error) {
console.warn(`Failed to create blob URL for ${id}:`, error);
return null;
}
}
/**
* Mark a file as processed (no longer a leaf file)
* Used when a file becomes input to a tool operation
*/
async markFileAsProcessed(fileId: FileId): Promise<boolean> {
try {
const db = await this.getDatabase();
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const record = await new Promise<StoredStirlingFileRecord | undefined>((resolve, reject) => {
const request = store.get(fileId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
if (!record) {
return false; // File not found
}
// Update the isLeaf flag to false
record.isLeaf = false;
await new Promise<void>((resolve, reject) => {
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
return true;
} catch (error) {
console.error('Failed to mark file as processed:', error);
return false;
}
}
/**
* Mark a file as leaf (opposite of markFileAsProcessed)
* Used when promoting a file back to "recent" status
*/
async markFileAsLeaf(fileId: FileId): Promise<boolean> {
try {
const db = await this.getDatabase();
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const record = await new Promise<StoredStirlingFileRecord | undefined>((resolve, reject) => {
const request = store.get(fileId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
if (!record) {
return false; // File not found
}
// Update the isLeaf flag to true
record.isLeaf = true;
await new Promise<void>((resolve, reject) => {
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
return true;
} catch (error) {
console.error('Failed to mark file as leaf:', error);
return false;
}
}
}

View File

@ -4,7 +4,6 @@
import { PageOperation } from './pageEditor';
import { FileId, BaseFileMetadata } from './file';
import { StoredFileMetadata, StoredFile } from '../services/fileStorage';
// Re-export FileId for convenience
export type { FileId };
@ -159,7 +158,9 @@ export function isFileObject(obj: any): obj is File | StirlingFile {
export function toStirlingFileStub(
file: File,
id?: FileId
id?: FileId,
thumbnail?: string
): StirlingFileStub {
const fileId = id || createFileId();
return {
@ -170,7 +171,8 @@ export function toStirlingFileStub(
lastModified: file.lastModified,
quickKey: createQuickKey(file),
createdAt: Date.now(),
isLeaf: true // New files are leaf nodes by default
isLeaf: true, // New files are leaf nodes by default
thumbnailUrl: thumbnail
};
}
@ -293,7 +295,7 @@ export interface FileContextActions {
// File management - lightweight actions only
addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<StirlingFile[]>;
addStoredFiles: (storedFiles: StoredFile[], options?: { selectFiles?: boolean }) => Promise<StirlingFile[]>;
addStirlingFileStubs: (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
updateStirlingFileStub: (id: FileId, updates: Partial<StirlingFileStub>) => void;
reorderFiles: (orderedFileIds: FileId[]) => void;
@ -305,7 +307,7 @@ export interface FileContextActions {
unpinFile: (file: StirlingFile) => void;
// File consumption (replace unpinned files with outputs)
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileId[]>;
consumeFiles: (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]) => Promise<FileId[]>;
undoConsumeFiles: (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]) => Promise<void>;
// Selection management
setSelectedFiles: (fileIds: FileId[]) => void;

View File

@ -1,4 +1,4 @@
import { StoredFileMetadata } from '../services/fileStorage';
import { StirlingFileStub } from '../types/fileContext';
import { fileStorage } from '../services/fileStorage';
import { zipFileService } from '../services/zipFileService';
@ -26,23 +26,23 @@ export function downloadBlob(blob: Blob, filename: string): void {
* @param file - The file object with storage information
* @throws Error if file cannot be retrieved from storage
*/
export async function downloadFileFromStorage(file: StoredFileMetadata): Promise<void> {
export async function downloadFileFromStorage(file: StirlingFileStub): Promise<void> {
const lookupKey = file.id;
const storedFile = await fileStorage.getFile(lookupKey);
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
if (!storedFile) {
if (!stirlingFile) {
throw new Error(`File "${file.name}" not found in storage`);
}
const blob = new Blob([storedFile.data], { type: storedFile.type });
downloadBlob(blob, storedFile.name);
// StirlingFile is already a File object, just download it
downloadBlob(stirlingFile, stirlingFile.name);
}
/**
* Downloads multiple files as individual downloads
* @param files - Array of files to download
*/
export async function downloadMultipleFiles(files: StoredFileMetadata[]): Promise<void> {
export async function downloadMultipleFiles(files: StirlingFileStub[]): Promise<void> {
for (const file of files) {
await downloadFileFromStorage(file);
}
@ -53,27 +53,24 @@ export async function downloadMultipleFiles(files: StoredFileMetadata[]): Promis
* @param files - Array of files to include in ZIP
* @param zipFilename - Optional custom ZIP filename (defaults to timestamped name)
*/
export async function downloadFilesAsZip(files: StoredFileMetadata[], zipFilename?: string): Promise<void> {
export async function downloadFilesAsZip(files: StirlingFileStub[], zipFilename?: string): Promise<void> {
if (files.length === 0) {
throw new Error('No files provided for ZIP download');
}
// Convert stored files to File objects
const fileObjects: File[] = [];
const filesToZip: File[] = [];
for (const fileWithUrl of files) {
const lookupKey = fileWithUrl.id;
const storedFile = await fileStorage.getFile(lookupKey);
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
if (storedFile) {
const file = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
fileObjects.push(file);
if (stirlingFile) {
// StirlingFile is already a File object!
filesToZip.push(stirlingFile);
}
}
if (fileObjects.length === 0) {
if (filesToZip.length === 0) {
throw new Error('No valid files found in storage for ZIP download');
}
@ -82,7 +79,7 @@ export async function downloadFilesAsZip(files: StoredFileMetadata[], zipFilenam
`files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`;
// Create and download ZIP
const { zipFile } = await zipFileService.createZipFromFiles(fileObjects, finalZipFilename);
const { zipFile } = await zipFileService.createZipFromFiles(filesToZip, finalZipFilename);
downloadBlob(zipFile, finalZipFilename);
}
@ -94,7 +91,7 @@ export async function downloadFilesAsZip(files: StoredFileMetadata[], zipFilenam
* @param options - Download options
*/
export async function downloadFiles(
files: StoredFileMetadata[],
files: StirlingFileStub[],
options: {
forceZip?: boolean;
zipFilename?: string;

View File

@ -5,13 +5,6 @@
* Handles file history operations and lineage tracking.
*/
import { StirlingFileStub } from '../types/fileContext';
import { FileId } from '../types/file';
import { StoredFileMetadata } from '../services/fileStorage';
/**
* Group files by processing branches - each branch ends in a leaf file
@ -75,49 +68,6 @@ export function groupFilesByOriginal(StirlingFileStubs: StirlingFileStub[]): Map
return groups;
}
/**
* Get the latest version of each file group (optimized version using leaf flags)
*/
export function getLatestVersions(fileStubs: StirlingFileStub[]): StirlingFileStub[] {
// If we have leaf flags, use them for much faster filtering
const hasLeafFlags = fileStubs.some(fileStub => fileStub.isLeaf !== undefined);
if (hasLeafFlags) {
// Fast path: just return files marked as leaf nodes
return fileStubs.filter(fileStub => fileStub.isLeaf !== false); // Default to true if undefined
} else {
// Fallback to expensive calculation for backward compatibility
const groups = groupFilesByOriginal(fileStubs);
const latestVersions: StirlingFileStub[] = [];
for (const [_, fileStubs] of groups) {
if (fileStubs.length > 0) {
// First item is the latest version (sorted desc by version number)
latestVersions.push(fileStubs[0]);
}
}
return latestVersions;
}
}
/**
* Get version history for a file
*/
export function getVersionHistory(
targetFileStub: StirlingFileStub,
allFileStubs: StirlingFileStub[]
): StirlingFileStub[] {
const originalId = targetFileStub.originalFileId || targetFileStub.id;
return allFileStubs
.filter(fileStub => {
const fileStubOriginalId = fileStub.originalFileId || fileStub.id;
return fileStubOriginalId === originalId;
})
.sort((a, b) => (b.versionNumber || 0) - (a.versionNumber || 0));
}
/**
* Check if a file has version history
*/
@ -125,70 +75,4 @@ export function hasVersionHistory(fileStub: StirlingFileStub): boolean {
return !!(fileStub.originalFileId && fileStub.versionNumber && fileStub.versionNumber > 0);
}
/**
* Generate a descriptive name for a file version
*/
export function generateVersionName(fileStub: StirlingFileStub): string {
const baseName = fileStub.name.replace(/\.pdf$/i, '');
if (!hasVersionHistory(fileStub)) {
return fileStub.name;
}
const versionInfo = fileStub.versionNumber ? ` (v${fileStub.versionNumber})` : '';
const toolInfo = fileStub.toolHistory && fileStub.toolHistory.length > 0
? ` - ${fileStub.toolHistory[fileStub.toolHistory.length - 1].toolName}`
: '';
return `${baseName}${versionInfo}${toolInfo}.pdf`;
}
/**
* Get recent files efficiently using leaf flags from IndexedDB
* This is much faster than loading all files and calculating leaf nodes
*/
export async function getRecentLeafFiles(): Promise<import('../services/fileStorage').StoredFile[]> {
try {
const { fileStorage } = await import('../services/fileStorage');
return await fileStorage.getLeafFiles();
} catch (error) {
console.warn('Failed to get recent leaf files from IndexedDB:', error);
return [];
}
}
/**
* Get recent file metadata efficiently using leaf flags from IndexedDB
* This is much faster than loading all files and calculating leaf nodes
*/
export async function getRecentLeafFileMetadata(): Promise<StoredFileMetadata[]> {
try {
const { fileStorage } = await import('../services/fileStorage');
return await fileStorage.getLeafFileMetadata();
} catch (error) {
console.warn('Failed to get recent leaf file metadata from IndexedDB:', error);
return [];
}
}
/**
* Create basic metadata for storing files
* History information is managed separately in IndexedDB
*/
export async function createFileMetadataWithHistory(
file: File,
fileId: FileId,
thumbnail?: string
): Promise<StoredFileMetadata> {
return {
id: fileId,
name: file.name,
type: file.type,
size: file.size,
lastModified: file.lastModified,
thumbnail,
isLeaf: true // New files are leaf nodes by default
};
}