mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
# Description of Changes A new universal file context rather than the splintered ones for the main views, tools and manager we had before (manager still has its own but its better integreated with the core context) File context has been split it into a handful of different files managing various file related issues separately to reduce the monolith - FileReducer.ts - State management fileActions.ts - File operations fileSelectors.ts - Data access patterns lifecycle.ts - Resource cleanup and memory management fileHooks.ts - React hooks interface contexts.ts - Context providers Improved thumbnail generation Improved indexxedb handling Stopped handling files as blobs were not necessary to improve performance A new library handling drag and drop https://github.com/atlassian/pragmatic-drag-and-drop (Out of scope yes but I broke the old one with the new filecontext and it needed doing so it was a might as well) A new library handling virtualisation on page editor @tanstack/react-virtual, as above. Quickly ripped out the last remnants of the old URL params stuff and replaced with the beginnings of what will later become the new URL navigation system (for now it just restores the tool name in url behavior) Fixed selected file not regestered when opening a tool Fixed png thumbnails Closes #(issue_number) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Reece Browne <you@example.com>
571 lines
17 KiB
TypeScript
571 lines
17 KiB
TypeScript
/**
|
|
* IndexedDB File Storage Service
|
|
* Provides high-capacity file storage for PDF processing
|
|
* Now uses centralized IndexedDB manager
|
|
*/
|
|
|
|
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
|
|
|
|
export interface StoredFile {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
size: number;
|
|
lastModified: number;
|
|
data: ArrayBuffer;
|
|
thumbnail?: string;
|
|
url?: string; // For compatibility with existing components
|
|
}
|
|
|
|
export interface StorageStats {
|
|
used: number;
|
|
available: number;
|
|
fileCount: number;
|
|
quota?: number;
|
|
}
|
|
|
|
class FileStorageService {
|
|
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
|
private readonly storeName = 'files';
|
|
|
|
/**
|
|
* Get database connection using centralized manager
|
|
*/
|
|
private async getDatabase(): Promise<IDBDatabase> {
|
|
return indexedDBManager.openDatabase(this.dbConfig);
|
|
}
|
|
|
|
/**
|
|
* Store a file in IndexedDB with external UUID
|
|
*/
|
|
async storeFile(file: File, fileId: string, thumbnail?: string): Promise<StoredFile> {
|
|
const db = await this.getDatabase();
|
|
|
|
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,
|
|
data: arrayBuffer,
|
|
thumbnail
|
|
};
|
|
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const transaction = db.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
|
|
// Debug logging
|
|
console.log('Object store keyPath:', store.keyPath);
|
|
console.log('Storing file with UUID:', {
|
|
id: storedFile.id, // Now a UUID from FileContext
|
|
name: storedFile.name,
|
|
hasData: !!storedFile.data,
|
|
dataSize: storedFile.data.byteLength
|
|
});
|
|
|
|
const request = store.add(storedFile);
|
|
|
|
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);
|
|
};
|
|
} catch (error) {
|
|
console.error('Transaction error:', error);
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Retrieve a file from IndexedDB
|
|
*/
|
|
async getFile(id: string): Promise<StoredFile | 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 = () => 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);
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get metadata of all stored files (without loading data into memory)
|
|
*/
|
|
async getAllFileMetadata(): Promise<Omit<StoredFile, 'data'>[]> {
|
|
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: Omit<StoredFile, 'data'>[] = [];
|
|
|
|
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
|
|
});
|
|
}
|
|
cursor.continue();
|
|
} else {
|
|
// Metadata loaded efficiently without file data
|
|
resolve(files);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete a file from IndexedDB
|
|
*/
|
|
async deleteFile(id: string): 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.delete(id);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update the lastModified timestamp of a file (for most recently used sorting)
|
|
*/
|
|
async touchFile(id: string): 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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Convert StoredFile to the format expected by FileContext.addStoredFiles()
|
|
* This is the recommended way to load stored files into FileContext
|
|
*/
|
|
createFileWithMetadata(storedFile: StoredFile): { file: File; originalId: string; metadata: { thumbnail?: string } } {
|
|
const file = this.createFileFromStored(storedFile);
|
|
|
|
return {
|
|
file,
|
|
originalId: storedFile.id,
|
|
metadata: {
|
|
thumbnail: storedFile.thumbnail
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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: string): 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: string): 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
|
|
*/
|
|
async updateThumbnail(id: string, thumbnail: string): Promise<boolean> {
|
|
const db = await this.getDatabase();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const transaction = db.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const getRequest = store.get(id);
|
|
|
|
getRequest.onsuccess = () => {
|
|
const storedFile = getRequest.result;
|
|
if (storedFile) {
|
|
storedFile.thumbnail = thumbnail;
|
|
const updateRequest = store.put(storedFile);
|
|
|
|
updateRequest.onsuccess = () => {
|
|
console.log('Thumbnail updated for file:', id);
|
|
resolve(true);
|
|
};
|
|
updateRequest.onerror = () => {
|
|
console.error('Failed to update thumbnail:', updateRequest.error);
|
|
resolve(false);
|
|
};
|
|
} else {
|
|
resolve(false);
|
|
}
|
|
};
|
|
|
|
getRequest.onerror = () => {
|
|
console.error('Failed to get file for thumbnail update:', getRequest.error);
|
|
resolve(false);
|
|
};
|
|
} catch (error) {
|
|
console.error('Transaction error during thumbnail update:', error);
|
|
resolve(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if storage quota is running low
|
|
*/
|
|
async isStorageLow(): Promise<boolean> {
|
|
const stats = await this.getStorageStats();
|
|
if (!stats.quota) return false;
|
|
|
|
const usagePercent = stats.used / stats.quota;
|
|
return usagePercent > 0.8; // Consider low if over 80% used
|
|
}
|
|
|
|
/**
|
|
* Clean up old files if storage is low
|
|
*/
|
|
async cleanupOldFiles(maxFiles: number = 50): Promise<void> {
|
|
const files = await this.getAllFileMetadata();
|
|
|
|
if (files.length <= maxFiles) return;
|
|
|
|
// Sort by last modified (oldest first)
|
|
files.sort((a, b) => a.lastModified - b.lastModified);
|
|
|
|
// Delete oldest files
|
|
const filesToDelete = files.slice(0, files.length - maxFiles);
|
|
for (const file of filesToDelete) {
|
|
await this.deleteFile(file.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const fileStorage = new FileStorageService();
|
|
|
|
// Helper hook for React components
|
|
export function useFileStorage() {
|
|
return fileStorage;
|
|
}
|