mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 01:19:24 +00:00
Semi working
This commit is contained in:
parent
f88c3e25d1
commit
3cea686acd
@ -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}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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");
|
||||
}
|
||||
})}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 = '';
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
||||
@ -94,7 +93,7 @@ function FileContextInner({
|
||||
await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => {
|
||||
try {
|
||||
const metadata = await indexedDB.saveFile(file, id, thumbnail);
|
||||
|
||||
|
||||
// Update StirlingFileStub with version information from IndexedDB
|
||||
if (metadata.versionNumber || metadata.originalFileId) {
|
||||
dispatch({
|
||||
@ -109,7 +108,7 @@ function FileContextInner({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (DEBUG) console.log(`📄 FileContext: Updated raw file ${file.name} with IndexedDB history data:`, {
|
||||
versionNumber: metadata.versionNumber,
|
||||
originalFileId: metadata.originalFileId,
|
||||
@ -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,
|
||||
|
@ -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(() => {
|
||||
|
@ -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,
|
||||
]);
|
||||
|
@ -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> => {
|
||||
|
@ -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 } }),
|
||||
|
@ -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 = {
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { StoredFileMetadata } from '../services/fileStorage';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { zipFileService } from '../services/zipFileService';
|
||||
|
||||
@ -9,14 +9,14 @@ import { zipFileService } from '../services/zipFileService';
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
|
||||
// Clean up the blob URL
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
if (!storedFile) {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
|
||||
|
||||
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,36 +53,33 @@ 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);
|
||||
|
||||
if (storedFile) {
|
||||
const file = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
fileObjects.push(file);
|
||||
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// Generate default filename if not provided
|
||||
const finalZipFilename = zipFilename ||
|
||||
const finalZipFilename = zipFilename ||
|
||||
`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;
|
||||
@ -133,8 +130,8 @@ export function downloadFileObject(file: File, filename?: string): void {
|
||||
* @param mimeType - MIME type (defaults to text/plain)
|
||||
*/
|
||||
export function downloadTextAsFile(
|
||||
content: string,
|
||||
filename: string,
|
||||
content: string,
|
||||
filename: string,
|
||||
mimeType: string = 'text/plain'
|
||||
): void {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
@ -149,4 +146,4 @@ export function downloadTextAsFile(
|
||||
export function downloadJsonAsFile(data: any, filename: string): void {
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
downloadTextAsFile(content, filename, 'application/json');
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user