mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Feature/v2/filehistory (#4370)
File History --------- Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
@@ -1,19 +1,21 @@
|
||||
/**
|
||||
* 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 } from '../types/file';
|
||||
import { FileId, BaseFileMetadata } from '../types/file';
|
||||
import { StirlingFile, StirlingFileStub, createStirlingFile } from '../types/fileContext';
|
||||
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
|
||||
|
||||
export interface StoredFile {
|
||||
id: FileId;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -37,47 +39,49 @@ class FileStorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a file in IndexedDB with external UUID
|
||||
* Store a StirlingFile with its metadata from StirlingFileStub
|
||||
*/
|
||||
async storeFile(file: File, fileId: FileId, thumbnail?: string): Promise<StoredFile> {
|
||||
async storeStirlingFile(stirlingFile: StirlingFile, stub: StirlingFileStub): 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
|
||||
thumbnail: stub.thumbnailUrl,
|
||||
isLeaf: stub.isLeaf ?? true,
|
||||
|
||||
// History data from stub
|
||||
versionNumber: stub.versionNumber ?? 1,
|
||||
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
|
||||
parentFileId: stub.parentFileId ?? undefined,
|
||||
toolHistory: stub.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('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);
|
||||
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);
|
||||
@@ -87,9 +91,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) => {
|
||||
@@ -97,77 +101,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<Omit<StoredFile, 'data'>[]> {
|
||||
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: Omit<StoredFile, 'data'>[] = [];
|
||||
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
|
||||
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) => {
|
||||
@@ -181,317 +275,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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: FileId; 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: 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();
|
||||
@@ -503,13 +287,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 = () => {
|
||||
@@ -533,31 +316,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,45 +87,135 @@ class IndexedDBManager {
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = request.result;
|
||||
const oldVersion = event.oldVersion;
|
||||
const transaction = request.transaction;
|
||||
|
||||
console.log(`Upgrading ${config.name} from v${oldVersion} to v${config.version}`);
|
||||
|
||||
// Create or update object stores
|
||||
config.stores.forEach(storeConfig => {
|
||||
let store: IDBObjectStore | undefined;
|
||||
|
||||
if (db.objectStoreNames.contains(storeConfig.name)) {
|
||||
// Store exists - for now, just continue (could add migration logic here)
|
||||
// Store exists - get reference for migration
|
||||
console.log(`Object store '${storeConfig.name}' already exists`);
|
||||
return;
|
||||
store = transaction?.objectStore(storeConfig.name);
|
||||
|
||||
// Add new indexes if they don't exist
|
||||
if (storeConfig.indexes && store) {
|
||||
storeConfig.indexes.forEach(indexConfig => {
|
||||
if (!store?.indexNames.contains(indexConfig.name)) {
|
||||
store?.createIndex(
|
||||
indexConfig.name,
|
||||
indexConfig.keyPath,
|
||||
{ unique: indexConfig.unique }
|
||||
);
|
||||
console.log(`Created index '${indexConfig.name}' on '${storeConfig.name}'`);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create new object store
|
||||
const options: IDBObjectStoreParameters = {};
|
||||
if (storeConfig.keyPath) {
|
||||
options.keyPath = storeConfig.keyPath;
|
||||
}
|
||||
if (storeConfig.autoIncrement) {
|
||||
options.autoIncrement = storeConfig.autoIncrement;
|
||||
}
|
||||
|
||||
store = db.createObjectStore(storeConfig.name, options);
|
||||
console.log(`Created object store '${storeConfig.name}'`);
|
||||
|
||||
// Create indexes
|
||||
if (storeConfig.indexes) {
|
||||
storeConfig.indexes.forEach(indexConfig => {
|
||||
store?.createIndex(
|
||||
indexConfig.name,
|
||||
indexConfig.keyPath,
|
||||
{ unique: indexConfig.unique }
|
||||
);
|
||||
console.log(`Created index '${indexConfig.name}' on '${storeConfig.name}'`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create new object store
|
||||
const options: IDBObjectStoreParameters = {};
|
||||
if (storeConfig.keyPath) {
|
||||
options.keyPath = storeConfig.keyPath;
|
||||
}
|
||||
if (storeConfig.autoIncrement) {
|
||||
options.autoIncrement = storeConfig.autoIncrement;
|
||||
}
|
||||
|
||||
const store = db.createObjectStore(storeConfig.name, options);
|
||||
console.log(`Created object store '${storeConfig.name}'`);
|
||||
|
||||
// Create indexes
|
||||
if (storeConfig.indexes) {
|
||||
storeConfig.indexes.forEach(indexConfig => {
|
||||
store.createIndex(
|
||||
indexConfig.name,
|
||||
indexConfig.keyPath,
|
||||
{ unique: indexConfig.unique }
|
||||
);
|
||||
console.log(`Created index '${indexConfig.name}' on '${storeConfig.name}'`);
|
||||
});
|
||||
// Perform data migration for files database
|
||||
if (config.name === 'stirling-pdf-files' && storeConfig.name === 'files' && store) {
|
||||
this.migrateFileHistoryFields(store, oldVersion);
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate existing file records to include new file history fields
|
||||
*/
|
||||
private migrateFileHistoryFields(store: IDBObjectStore, oldVersion: number): void {
|
||||
// Only migrate if upgrading from a version before file history was added (version < 3)
|
||||
if (oldVersion >= 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting file history migration for existing records...');
|
||||
|
||||
const cursor = store.openCursor();
|
||||
let migratedCount = 0;
|
||||
|
||||
cursor.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
const record = cursor.value;
|
||||
let needsUpdate = false;
|
||||
|
||||
// Add missing file history fields with sensible defaults
|
||||
if (record.isLeaf === undefined) {
|
||||
record.isLeaf = true; // Existing files are unprocessed, should appear in recent files
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.versionNumber === undefined) {
|
||||
record.versionNumber = 1; // Existing files are first version
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.originalFileId === undefined) {
|
||||
record.originalFileId = record.id; // Existing files are their own root
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.parentFileId === undefined) {
|
||||
record.parentFileId = undefined; // No parent for existing files
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.toolHistory === undefined) {
|
||||
record.toolHistory = []; // No history for existing files
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Update the record if any fields were missing
|
||||
if (needsUpdate) {
|
||||
try {
|
||||
cursor.update(record);
|
||||
migratedCount++;
|
||||
} catch (error) {
|
||||
console.error('Failed to migrate record:', record.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
cursor.continue();
|
||||
} else {
|
||||
// Migration complete
|
||||
console.log(`File history migration completed. Migrated ${migratedCount} records.`);
|
||||
}
|
||||
};
|
||||
|
||||
cursor.onerror = (event) => {
|
||||
console.error('File history migration failed:', (event.target as IDBRequest).error);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database connection (must be already opened)
|
||||
*/
|
||||
@@ -201,13 +291,16 @@ class IndexedDBManager {
|
||||
export const DATABASE_CONFIGS = {
|
||||
FILES: {
|
||||
name: 'stirling-pdf-files',
|
||||
version: 2,
|
||||
version: 3,
|
||||
stores: [{
|
||||
name: 'files',
|
||||
keyPath: 'id',
|
||||
indexes: [
|
||||
{ name: 'name', keyPath: 'name', unique: false },
|
||||
{ name: 'lastModified', keyPath: 'lastModified', unique: false }
|
||||
{ name: 'lastModified', keyPath: 'lastModified', unique: false },
|
||||
{ name: 'originalFileId', keyPath: 'originalFileId', unique: false },
|
||||
{ name: 'parentFileId', keyPath: 'parentFileId', unique: false },
|
||||
{ name: 'versionNumber', keyPath: 'versionNumber', unique: false }
|
||||
]
|
||||
}]
|
||||
} as DatabaseConfig,
|
||||
@@ -219,7 +312,8 @@ export const DATABASE_CONFIGS = {
|
||||
name: 'drafts',
|
||||
keyPath: 'id'
|
||||
}]
|
||||
} as DatabaseConfig
|
||||
} as DatabaseConfig,
|
||||
|
||||
} as const;
|
||||
|
||||
export const indexedDBManager = IndexedDBManager.getInstance();
|
||||
|
||||
@@ -29,7 +29,7 @@ export class PDFExportService {
|
||||
|
||||
// Load original PDF and create new document
|
||||
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
|
||||
const sourceDoc = await PDFLibDocument.load(originalPDFBytes);
|
||||
const sourceDoc = await PDFLibDocument.load(originalPDFBytes, { ignoreEncryption: true });
|
||||
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
|
||||
const exportFilename = this.generateFilename(filename || pdfDocument.name);
|
||||
|
||||
@@ -86,7 +86,7 @@ export class PDFExportService {
|
||||
for (const [fileId, file] of sourceFiles) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const doc = await PDFLibDocument.load(arrayBuffer);
|
||||
const doc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true });
|
||||
loadedDocs.set(fileId, doc);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load source file ${fileId}:`, error);
|
||||
|
||||
Reference in New Issue
Block a user