mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
Restructure frontend code to allow for extensions (#4721)
# Description of Changes Move frontend code into `core` folder and add infrastructure for `proprietary` folder to include premium, non-OSS features
This commit is contained in:
25
frontend/src/core/services/apiClient.ts
Normal file
25
frontend/src/core/services/apiClient.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import axios from 'axios';
|
||||
import { handleHttpError } from '@app/services/httpErrorHandler';
|
||||
import { setupApiInterceptors } from '@app/services/apiClientSetup';
|
||||
|
||||
// Create axios instance with default config
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/',
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
// Setup interceptors (core does nothing, proprietary adds JWT auth)
|
||||
setupApiInterceptors(apiClient);
|
||||
|
||||
// ---------- Install error interceptor ----------
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
await handleHttpError(error); // Handle error (shows toast unless suppressed)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// ---------- Exports ----------
|
||||
export default apiClient;
|
||||
5
frontend/src/core/services/apiClientSetup.ts
Normal file
5
frontend/src/core/services/apiClientSetup.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
|
||||
export function setupApiInterceptors(_client: AxiosInstance): void {
|
||||
// Core version: no interceptors to add
|
||||
}
|
||||
183
frontend/src/core/services/automationStorage.ts
Normal file
183
frontend/src/core/services/automationStorage.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Service for managing automation configurations in IndexedDB
|
||||
*/
|
||||
|
||||
export interface AutomationConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
operations: Array<{
|
||||
operation: string;
|
||||
parameters: any;
|
||||
}>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
class AutomationStorage {
|
||||
private dbName = 'StirlingPDF_Automations';
|
||||
private dbVersion = 1;
|
||||
private storeName = 'automations';
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to open automation storage database'));
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
|
||||
store.createIndex('name', 'name', { unique: false });
|
||||
store.createIndex('createdAt', 'createdAt', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async ensureDB(): Promise<IDBDatabase> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
return this.db;
|
||||
}
|
||||
|
||||
async saveAutomation(automation: Omit<AutomationConfig, 'id' | 'createdAt' | 'updatedAt'>): Promise<AutomationConfig> {
|
||||
const db = await this.ensureDB();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const automationWithMeta: AutomationConfig = {
|
||||
id: `automation-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
...automation,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.add(automationWithMeta);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(automationWithMeta);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to save automation'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async updateAutomation(automation: AutomationConfig): Promise<AutomationConfig> {
|
||||
const db = await this.ensureDB();
|
||||
|
||||
const updatedAutomation: AutomationConfig = {
|
||||
...automation,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.put(updatedAutomation);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(updatedAutomation);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to update automation'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getAutomation(id: string): Promise<AutomationConfig | null> {
|
||||
const db = await this.ensureDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result || null);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to get automation'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getAllAutomations(): Promise<AutomationConfig[]> {
|
||||
const db = await this.ensureDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const automations = request.result || [];
|
||||
// Sort by creation date, newest first
|
||||
automations.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
resolve(automations);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to get automations'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAutomation(id: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to delete automation'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async searchAutomations(query: string): Promise<AutomationConfig[]> {
|
||||
const automations = await this.getAllAutomations();
|
||||
|
||||
if (!query.trim()) {
|
||||
return automations;
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return automations.filter(automation =>
|
||||
automation.name.toLowerCase().includes(lowerQuery) ||
|
||||
(automation.description && automation.description.toLowerCase().includes(lowerQuery)) ||
|
||||
automation.operations.some(op => op.operation.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const automationStorage = new AutomationStorage();
|
||||
165
frontend/src/core/services/documentManipulationService.ts
Normal file
165
frontend/src/core/services/documentManipulationService.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||
|
||||
/**
|
||||
* Service for applying DOM changes to PDF document state
|
||||
* Reads current DOM state and updates the document accordingly
|
||||
*/
|
||||
export class DocumentManipulationService {
|
||||
/**
|
||||
* Apply all DOM changes (rotations, splits, reordering) to document state
|
||||
* Returns single document or multiple documents if splits are present
|
||||
*/
|
||||
applyDOMChangesToDocument(pdfDocument: PDFDocument, currentDisplayOrder?: PDFDocument, splitPositions?: Set<number>): PDFDocument | PDFDocument[] {
|
||||
// Use current display order (from React state) if provided, otherwise use original order
|
||||
const baseDocument = currentDisplayOrder || pdfDocument;
|
||||
|
||||
// Apply DOM changes to each page (rotation only now, splits are position-based)
|
||||
let updatedPages = baseDocument.pages.map(page => this.applyPageChanges(page));
|
||||
|
||||
// Convert position-based splits to page-based splits for export
|
||||
if (splitPositions && splitPositions.size > 0) {
|
||||
updatedPages = updatedPages.map((page, index) => ({
|
||||
...page,
|
||||
splitAfter: splitPositions.has(index)
|
||||
}));
|
||||
}
|
||||
|
||||
// Create final document with reordered pages and applied changes
|
||||
const finalDocument = {
|
||||
...pdfDocument, // Use original document metadata but updated pages
|
||||
pages: updatedPages // Use reordered pages with applied changes
|
||||
};
|
||||
|
||||
// Check for splits and return multiple documents if needed
|
||||
if (splitPositions && splitPositions.size > 0) {
|
||||
return this.createSplitDocuments(finalDocument);
|
||||
}
|
||||
|
||||
return finalDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document has split markers
|
||||
*/
|
||||
private hasSplitMarkers(document: PDFDocument): boolean {
|
||||
return document.pages.some(page => page.splitAfter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple documents from split markers
|
||||
*/
|
||||
private createSplitDocuments(document: PDFDocument): PDFDocument[] {
|
||||
const documents: PDFDocument[] = [];
|
||||
const splitPoints: number[] = [];
|
||||
|
||||
// Find split points
|
||||
document.pages.forEach((page, index) => {
|
||||
if (page.splitAfter) {
|
||||
splitPoints.push(index + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Add end point if not already there
|
||||
if (splitPoints.length === 0 || splitPoints[splitPoints.length - 1] !== document.pages.length) {
|
||||
splitPoints.push(document.pages.length);
|
||||
}
|
||||
|
||||
let startIndex = 0;
|
||||
let partNumber = 1;
|
||||
|
||||
for (const endIndex of splitPoints) {
|
||||
const segmentPages = document.pages.slice(startIndex, endIndex);
|
||||
|
||||
if (segmentPages.length > 0) {
|
||||
documents.push({
|
||||
...document,
|
||||
id: `${document.id}_part_${partNumber}`,
|
||||
name: `${document.name.replace(/\.pdf$/i, '')}_part_${partNumber}.pdf`,
|
||||
pages: segmentPages,
|
||||
totalPages: segmentPages.length
|
||||
});
|
||||
partNumber++;
|
||||
}
|
||||
|
||||
startIndex = endIndex;
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply DOM changes for a single page
|
||||
*/
|
||||
private applyPageChanges(page: PDFPage): PDFPage {
|
||||
// Find the DOM element for this page
|
||||
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
|
||||
if (!pageElement) {
|
||||
return page;
|
||||
}
|
||||
|
||||
const updatedPage = { ...page };
|
||||
|
||||
// Apply rotation changes from DOM
|
||||
updatedPage.rotation = this.getRotationFromDOM(pageElement, page);
|
||||
|
||||
return updatedPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read rotation from DOM element
|
||||
*/
|
||||
private getRotationFromDOM(pageElement: Element, originalPage: PDFPage): number {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
const originalRotation = parseInt(img.getAttribute('data-original-rotation') || '0');
|
||||
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotationMatch = currentTransform.match(/rotate\((-?\d+)deg\)/);
|
||||
const visualRotation = rotationMatch ? parseInt(rotationMatch[1]) : originalRotation;
|
||||
|
||||
const userChange = ((visualRotation - originalRotation) % 360 + 360) % 360;
|
||||
|
||||
let finalRotation = (originalPage.rotation + userChange) % 360;
|
||||
if (finalRotation === 360) finalRotation = 0;
|
||||
|
||||
return finalRotation;
|
||||
}
|
||||
|
||||
return originalPage.rotation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all DOM changes (useful for "discard changes" functionality)
|
||||
*/
|
||||
resetDOMToDocumentState(pdfDocument: PDFDocument): void {
|
||||
console.log('DocumentManipulationService: Resetting DOM to match document state');
|
||||
|
||||
pdfDocument.pages.forEach(page => {
|
||||
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Reset rotation to match document state
|
||||
img.style.transform = `rotate(${page.rotation}deg)`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if DOM state differs from document state
|
||||
*/
|
||||
hasUnsavedChanges(pdfDocument: PDFDocument): boolean {
|
||||
return pdfDocument.pages.some(page => {
|
||||
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
|
||||
if (pageElement) {
|
||||
const domRotation = this.getRotationFromDOM(pageElement, page);
|
||||
return domRotation !== page.rotation;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const documentManipulationService = new DocumentManipulationService();
|
||||
567
frontend/src/core/services/enhancedPDFProcessingService.ts
Normal file
567
frontend/src/core/services/enhancedPDFProcessingService.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
import { ProcessedFile, ProcessingState, PDFPage, ProcessingConfig, ProcessingMetrics } from '@app/types/processing';
|
||||
import { ProcessingCache } from '@app/services/processingCache';
|
||||
import { FileHasher } from '@app/utils/fileHash';
|
||||
import { FileAnalyzer } from '@app/services/fileAnalyzer';
|
||||
import { ProcessingErrorHandler } from '@app/services/processingErrorHandler';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import { createQuickKey } from '@app/types/fileContext';
|
||||
|
||||
export class EnhancedPDFProcessingService {
|
||||
private static instance: EnhancedPDFProcessingService;
|
||||
private cache = new ProcessingCache();
|
||||
private processing = new Map<string, ProcessingState>();
|
||||
private processingListeners = new Set<(states: Map<string, ProcessingState>) => void>();
|
||||
private metrics: ProcessingMetrics = {
|
||||
totalFiles: 0,
|
||||
completedFiles: 0,
|
||||
failedFiles: 0,
|
||||
averageProcessingTime: 0,
|
||||
cacheHitRate: 0,
|
||||
memoryUsage: 0
|
||||
};
|
||||
|
||||
private defaultConfig: ProcessingConfig = {
|
||||
strategy: 'immediate_full',
|
||||
chunkSize: 20,
|
||||
thumbnailQuality: 'medium',
|
||||
priorityPageCount: 10,
|
||||
useWebWorker: false,
|
||||
maxRetries: 3
|
||||
};
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): EnhancedPDFProcessingService {
|
||||
if (!EnhancedPDFProcessingService.instance) {
|
||||
EnhancedPDFProcessingService.instance = new EnhancedPDFProcessingService();
|
||||
}
|
||||
return EnhancedPDFProcessingService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a file with intelligent strategy selection
|
||||
*/
|
||||
async processFile(file: File, customConfig?: Partial<ProcessingConfig>): Promise<ProcessedFile | null> {
|
||||
const fileKey = await this.generateFileKey(file);
|
||||
|
||||
// Check cache first
|
||||
const cached = this.cache.get(fileKey);
|
||||
if (cached) {
|
||||
this.updateMetrics('cacheHit');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check if already processing
|
||||
if (this.processing.has(fileKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Analyze file to determine optimal strategy
|
||||
const analysis = await FileAnalyzer.analyzeFile(file);
|
||||
if (analysis.isCorrupted) {
|
||||
throw new Error(`File ${file.name} appears to be corrupted`);
|
||||
}
|
||||
|
||||
// Create processing config
|
||||
const config: ProcessingConfig = {
|
||||
...this.defaultConfig,
|
||||
strategy: analysis.recommendedStrategy,
|
||||
...customConfig
|
||||
};
|
||||
|
||||
// Start processing
|
||||
this.startProcessing(file, fileKey, config, analysis.estimatedProcessingTime);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start processing a file with the specified configuration
|
||||
*/
|
||||
private async startProcessing(
|
||||
file: File,
|
||||
fileKey: string,
|
||||
config: ProcessingConfig,
|
||||
estimatedTime: number
|
||||
): Promise<void> {
|
||||
// Create cancellation token
|
||||
const cancellationToken = new AbortController();
|
||||
|
||||
// Set initial state
|
||||
const state: ProcessingState = {
|
||||
fileKey,
|
||||
fileName: file.name,
|
||||
status: 'processing',
|
||||
progress: 0,
|
||||
strategy: config.strategy,
|
||||
startedAt: Date.now(),
|
||||
estimatedTimeRemaining: estimatedTime,
|
||||
cancellationToken
|
||||
};
|
||||
|
||||
this.processing.set(fileKey, state);
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('started');
|
||||
|
||||
try {
|
||||
// Execute processing with retry logic
|
||||
const processedFile = await ProcessingErrorHandler.executeWithRetry(
|
||||
() => this.executeProcessingStrategy(file, config, state),
|
||||
(error) => {
|
||||
state.error = error;
|
||||
this.notifyListeners();
|
||||
},
|
||||
config.maxRetries
|
||||
);
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(fileKey, processedFile);
|
||||
|
||||
// Update state to completed
|
||||
state.status = 'completed';
|
||||
state.progress = 100;
|
||||
state.completedAt = Date.now();
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('completed', Date.now() - state.startedAt);
|
||||
|
||||
// Remove from processing map after brief delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
this.notifyListeners();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Processing failed for', file.name, ':', error);
|
||||
|
||||
const processingError = ProcessingErrorHandler.createProcessingError(error);
|
||||
state.status = 'error';
|
||||
state.error = processingError;
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('failed');
|
||||
|
||||
// Remove failed processing after delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
this.notifyListeners();
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual processing based on strategy
|
||||
*/
|
||||
private async executeProcessingStrategy(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
switch (config.strategy) {
|
||||
case 'immediate_full':
|
||||
return this.processImmediateFull(file, config, state);
|
||||
|
||||
case 'priority_pages':
|
||||
return this.processPriorityPages(file, config, state);
|
||||
|
||||
case 'progressive_chunked':
|
||||
return this.processProgressiveChunked(file, config, state);
|
||||
|
||||
case 'metadata_only':
|
||||
return this.processMetadataOnly(file, config, state);
|
||||
|
||||
default:
|
||||
return this.processImmediateFull(file, config, state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pages immediately (for small files)
|
||||
*/
|
||||
private async processImmediateFull(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
|
||||
try {
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 10;
|
||||
this.notifyListeners();
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
// Check for cancellation
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
const rotation = page.rotate || 0;
|
||||
|
||||
pages.push({
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation,
|
||||
selected: false
|
||||
});
|
||||
|
||||
// Update progress
|
||||
state.progress = 10 + (i / totalPages) * 85;
|
||||
state.currentPage = i;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
} finally {
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process priority pages first, then queue the rest
|
||||
*/
|
||||
private async processPriorityPages(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 10;
|
||||
this.notifyListeners();
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
const priorityCount = Math.min(config.priorityPageCount, totalPages);
|
||||
|
||||
// Process priority pages first
|
||||
for (let i = 1; i <= priorityCount; i++) {
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
pages.push({
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: page.rotate || 0,
|
||||
selected: false
|
||||
});
|
||||
|
||||
state.progress = 10 + (i / priorityCount) * 60;
|
||||
state.currentPage = i;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
// Create placeholder pages for remaining pages
|
||||
for (let i = priorityCount + 1; i <= totalPages; i++) {
|
||||
// Load page just to get rotation
|
||||
const page = await pdf.getPage(i);
|
||||
const rotation = page.rotate || 0;
|
||||
|
||||
pages.push({
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null, // Will be loaded lazily
|
||||
rotation,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process in chunks with breaks between chunks
|
||||
*/
|
||||
private async processProgressiveChunked(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 10;
|
||||
this.notifyListeners();
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
const chunkSize = config.chunkSize;
|
||||
let processedPages = 0;
|
||||
|
||||
// Process first chunk immediately
|
||||
const firstChunkEnd = Math.min(chunkSize, totalPages);
|
||||
|
||||
for (let i = 1; i <= firstChunkEnd; i++) {
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
pages.push({
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: page.rotate || 0,
|
||||
selected: false
|
||||
});
|
||||
|
||||
processedPages++;
|
||||
state.progress = 10 + (processedPages / totalPages) * 70;
|
||||
state.currentPage = i;
|
||||
this.notifyListeners();
|
||||
|
||||
// Small delay to prevent UI blocking
|
||||
if (i % 5 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
// Create placeholders for remaining pages
|
||||
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
|
||||
// Load page just to get rotation
|
||||
const page = await pdf.getPage(i);
|
||||
const rotation = page.rotate || 0;
|
||||
|
||||
pages.push({
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process metadata only (for very large files)
|
||||
*/
|
||||
private async processMetadataOnly(
|
||||
file: File,
|
||||
_config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 50;
|
||||
this.notifyListeners();
|
||||
|
||||
// Create placeholder pages without thumbnails
|
||||
const pages: PDFPage[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
// Load page just to get rotation
|
||||
const page = await pdf.getPage(i);
|
||||
const rotation = page.rotate || 0;
|
||||
|
||||
pages.push({
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a page thumbnail with specified quality
|
||||
*/
|
||||
private async renderPageThumbnail(page: any, quality: 'low' | 'medium' | 'high'): Promise<string> {
|
||||
const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor
|
||||
const scale = scales[quality];
|
||||
|
||||
const viewport = page.getViewport({ scale, rotation: 0 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
return canvas.toDataURL('image/jpeg', 0.8); // Use JPEG for better compression
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ProcessedFile object
|
||||
*/
|
||||
private createProcessedFile(file: File, pages: PDFPage[], totalPages: number): ProcessedFile {
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
pages,
|
||||
totalPages,
|
||||
metadata: {
|
||||
title: file.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate a unique, collision-resistant cache key
|
||||
*/
|
||||
private async generateFileKey(file: File): Promise<string> {
|
||||
return await FileHasher.generateHybridHash(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel processing for a specific file
|
||||
*/
|
||||
cancelProcessing(fileKey: string): void {
|
||||
const state = this.processing.get(fileKey);
|
||||
if (state && state.cancellationToken) {
|
||||
state.cancellationToken.abort();
|
||||
state.status = 'cancelled';
|
||||
this.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update processing metrics
|
||||
*/
|
||||
private updateMetrics(event: 'started' | 'completed' | 'failed' | 'cacheHit', processingTime?: number): void {
|
||||
switch (event) {
|
||||
case 'started':
|
||||
this.metrics.totalFiles++;
|
||||
break;
|
||||
case 'completed':
|
||||
this.metrics.completedFiles++;
|
||||
if (processingTime) {
|
||||
// Update rolling average
|
||||
const totalProcessingTime = this.metrics.averageProcessingTime * (this.metrics.completedFiles - 1) + processingTime;
|
||||
this.metrics.averageProcessingTime = totalProcessingTime / this.metrics.completedFiles;
|
||||
}
|
||||
break;
|
||||
case 'failed':
|
||||
this.metrics.failedFiles++;
|
||||
break;
|
||||
case 'cacheHit': {
|
||||
// Update cache hit rate
|
||||
const totalAttempts = this.metrics.totalFiles + 1;
|
||||
this.metrics.cacheHitRate = (this.metrics.cacheHitRate * this.metrics.totalFiles + 1) / totalAttempts;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processing metrics
|
||||
*/
|
||||
getMetrics(): ProcessingMetrics {
|
||||
return { ...this.metrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* State subscription for components
|
||||
*/
|
||||
onProcessingChange(callback: (states: Map<string, ProcessingState>) => void): () => void {
|
||||
this.processingListeners.add(callback);
|
||||
return () => this.processingListeners.delete(callback);
|
||||
}
|
||||
|
||||
getProcessingStates(): Map<string, ProcessingState> {
|
||||
return new Map(this.processing);
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
this.processingListeners.forEach(callback => callback(this.processing));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup method for removed files
|
||||
*/
|
||||
cleanup(removedFiles: File[]): void {
|
||||
removedFiles.forEach(async (file) => {
|
||||
const key = await this.generateFileKey(file);
|
||||
this.cache.delete(key);
|
||||
this.cancelProcessing(key);
|
||||
this.processing.delete(key);
|
||||
});
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all processing for view switches
|
||||
*/
|
||||
clearAllProcessing(): void {
|
||||
// Cancel all ongoing processing
|
||||
this.processing.forEach((state) => {
|
||||
if (state.cancellationToken) {
|
||||
state.cancellationToken.abort();
|
||||
}
|
||||
});
|
||||
|
||||
// Clear processing states
|
||||
this.processing.clear();
|
||||
this.notifyListeners();
|
||||
|
||||
// Force memory cleanup hint
|
||||
setTimeout(() => window.gc?.(), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getCacheStats() {
|
||||
return this.cache.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache and processing
|
||||
*/
|
||||
clearAll(): void {
|
||||
this.cache.clear();
|
||||
this.processing.clear();
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency cleanup - destroy all PDF workers
|
||||
*/
|
||||
emergencyCleanup(): void {
|
||||
this.clearAllProcessing();
|
||||
this.clearAll();
|
||||
pdfWorkerManager.destroyAllDocuments();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance();
|
||||
47
frontend/src/core/services/errorUtils.ts
Normal file
47
frontend/src/core/services/errorUtils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const FILE_EVENTS = {
|
||||
markError: 'files:markError',
|
||||
} as const;
|
||||
|
||||
const UUID_REGEX = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g;
|
||||
|
||||
export function tryParseJson<T = any>(input: unknown): T | undefined {
|
||||
if (typeof input !== 'string') return input as T | undefined;
|
||||
try { return JSON.parse(input) as T; } catch { return undefined; }
|
||||
}
|
||||
|
||||
export async function normalizeAxiosErrorData(data: any): Promise<any> {
|
||||
if (!data) return undefined;
|
||||
if (typeof data?.text === 'function') {
|
||||
const text = await data.text();
|
||||
return tryParseJson(text) ?? text;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function extractErrorFileIds(payload: any): string[] | undefined {
|
||||
if (!payload) return undefined;
|
||||
if (Array.isArray(payload?.errorFileIds)) return payload.errorFileIds as string[];
|
||||
if (typeof payload === 'string') {
|
||||
const matches = payload.match(UUID_REGEX);
|
||||
if (matches && matches.length > 0) return Array.from(new Set(matches));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function broadcastErroredFiles(fileIds: string[]) {
|
||||
if (!fileIds || fileIds.length === 0) return;
|
||||
window.dispatchEvent(new CustomEvent(FILE_EVENTS.markError, { detail: { fileIds } }));
|
||||
}
|
||||
|
||||
export function isZeroByte(file: File | { size?: number } | null | undefined): boolean {
|
||||
if (!file) return true;
|
||||
const size = (file as any).size;
|
||||
return typeof size === 'number' ? size <= 0 : true;
|
||||
}
|
||||
|
||||
export function isEmptyOutput(files: File[] | null | undefined): boolean {
|
||||
if (!files || files.length === 0) return true;
|
||||
return files.every(f => (f as any)?.size === 0);
|
||||
}
|
||||
|
||||
|
||||
241
frontend/src/core/services/fileAnalyzer.ts
Normal file
241
frontend/src/core/services/fileAnalyzer.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { FileAnalysis, ProcessingStrategy } from '@app/types/processing';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
|
||||
export class FileAnalyzer {
|
||||
private static readonly SIZE_THRESHOLDS = {
|
||||
SMALL: 10 * 1024 * 1024, // 10MB
|
||||
MEDIUM: 50 * 1024 * 1024, // 50MB
|
||||
LARGE: 200 * 1024 * 1024, // 200MB
|
||||
};
|
||||
|
||||
private static readonly PAGE_THRESHOLDS = {
|
||||
FEW: 10, // < 10 pages - immediate full processing
|
||||
MANY: 50, // < 50 pages - priority pages
|
||||
MASSIVE: 100, // < 100 pages - progressive chunked
|
||||
// >100 pages = metadata only
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyze a file to determine optimal processing strategy
|
||||
*/
|
||||
static async analyzeFile(file: File): Promise<FileAnalysis> {
|
||||
const analysis: FileAnalysis = {
|
||||
fileSize: file.size,
|
||||
isEncrypted: false,
|
||||
isCorrupted: false,
|
||||
recommendedStrategy: 'metadata_only',
|
||||
estimatedProcessingTime: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Quick validation and page count estimation
|
||||
const quickAnalysis = await this.quickPDFAnalysis(file);
|
||||
analysis.estimatedPageCount = quickAnalysis.pageCount;
|
||||
analysis.isEncrypted = quickAnalysis.isEncrypted;
|
||||
analysis.isCorrupted = quickAnalysis.isCorrupted;
|
||||
|
||||
// Determine strategy based on file characteristics
|
||||
analysis.recommendedStrategy = this.determineStrategy(file.size, quickAnalysis.pageCount);
|
||||
|
||||
// Estimate processing time
|
||||
analysis.estimatedProcessingTime = this.estimateProcessingTime(
|
||||
file.size,
|
||||
quickAnalysis.pageCount,
|
||||
analysis.recommendedStrategy
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('File analysis failed:', error);
|
||||
analysis.isCorrupted = true;
|
||||
analysis.recommendedStrategy = 'metadata_only';
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick PDF analysis without full processing
|
||||
*/
|
||||
private static async quickPDFAnalysis(file: File): Promise<{
|
||||
pageCount: number;
|
||||
isEncrypted: boolean;
|
||||
isCorrupted: boolean;
|
||||
}> {
|
||||
try {
|
||||
// For small files, read the whole file
|
||||
// For large files, try the whole file first (PDF.js needs the complete structure)
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
stopAtErrors: false, // Don't stop at minor errors
|
||||
verbosity: 0 // Suppress PDF.js warnings
|
||||
});
|
||||
|
||||
const pageCount = pdf.numPages;
|
||||
const isEncrypted = (pdf as any).isEncrypted;
|
||||
|
||||
// Clean up using worker manager
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
return {
|
||||
pageCount,
|
||||
isEncrypted,
|
||||
isCorrupted: false
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// Try to determine if it's corruption vs encryption
|
||||
const errorMessage = error instanceof Error ? error.message.toLowerCase() : '';
|
||||
const isEncrypted = errorMessage.includes('password') || errorMessage.includes('encrypted');
|
||||
|
||||
return {
|
||||
pageCount: 0,
|
||||
isEncrypted,
|
||||
isCorrupted: !isEncrypted // If not encrypted, probably corrupted
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best processing strategy based on file characteristics
|
||||
*/
|
||||
private static determineStrategy(fileSize: number, pageCount?: number): ProcessingStrategy {
|
||||
// Handle corrupted or encrypted files
|
||||
if (!pageCount || pageCount === 0) {
|
||||
return 'metadata_only';
|
||||
}
|
||||
|
||||
// Small files with few pages - process everything immediately
|
||||
if (fileSize <= this.SIZE_THRESHOLDS.SMALL && pageCount <= this.PAGE_THRESHOLDS.FEW) {
|
||||
return 'immediate_full';
|
||||
}
|
||||
|
||||
// Medium files or many pages - priority pages first, then progressive
|
||||
if (fileSize <= this.SIZE_THRESHOLDS.MEDIUM && pageCount <= this.PAGE_THRESHOLDS.MANY) {
|
||||
return 'priority_pages';
|
||||
}
|
||||
|
||||
// Large files or massive page counts - chunked processing
|
||||
if (fileSize <= this.SIZE_THRESHOLDS.LARGE && pageCount <= this.PAGE_THRESHOLDS.MASSIVE) {
|
||||
return 'progressive_chunked';
|
||||
}
|
||||
|
||||
// Very large files - metadata only
|
||||
return 'metadata_only';
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate processing time based on file characteristics and strategy
|
||||
*/
|
||||
private static estimateProcessingTime(
|
||||
_fileSize: number,
|
||||
pageCount: number = 0,
|
||||
strategy: ProcessingStrategy
|
||||
): number {
|
||||
const baseTimes = {
|
||||
immediate_full: 200, // 200ms per page
|
||||
priority_pages: 150, // 150ms per page (optimized)
|
||||
progressive_chunked: 100, // 100ms per page (chunked)
|
||||
metadata_only: 50 // 50ms total
|
||||
};
|
||||
|
||||
const baseTime = baseTimes[strategy];
|
||||
|
||||
switch (strategy) {
|
||||
case 'metadata_only':
|
||||
return baseTime;
|
||||
|
||||
case 'immediate_full':
|
||||
return pageCount * baseTime;
|
||||
|
||||
case 'priority_pages': {
|
||||
// Estimate time for priority pages (first 10)
|
||||
const priorityPages = Math.min(pageCount, 10);
|
||||
return priorityPages * baseTime;
|
||||
}
|
||||
|
||||
case 'progressive_chunked': {
|
||||
// Estimate time for first chunk (20 pages)
|
||||
const firstChunk = Math.min(pageCount, 20);
|
||||
return firstChunk * baseTime;
|
||||
}
|
||||
|
||||
default:
|
||||
return pageCount * baseTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processing recommendations for a set of files
|
||||
*/
|
||||
static async analyzeMultipleFiles(files: File[]): Promise<{
|
||||
analyses: Map<File, FileAnalysis>;
|
||||
recommendations: {
|
||||
totalEstimatedTime: number;
|
||||
suggestedBatchSize: number;
|
||||
shouldUseWebWorker: boolean;
|
||||
memoryWarning: boolean;
|
||||
};
|
||||
}> {
|
||||
const analyses = new Map<File, FileAnalysis>();
|
||||
let totalEstimatedTime = 0;
|
||||
let totalSize = 0;
|
||||
let totalPages = 0;
|
||||
|
||||
// Analyze each file
|
||||
for (const file of files) {
|
||||
const analysis = await this.analyzeFile(file);
|
||||
analyses.set(file, analysis);
|
||||
totalEstimatedTime += analysis.estimatedProcessingTime;
|
||||
totalSize += file.size;
|
||||
totalPages += analysis.estimatedPageCount || 0;
|
||||
}
|
||||
|
||||
// Generate recommendations
|
||||
const recommendations = {
|
||||
totalEstimatedTime,
|
||||
suggestedBatchSize: this.calculateBatchSize(files.length, totalSize),
|
||||
shouldUseWebWorker: totalPages > 100 || totalSize > this.SIZE_THRESHOLDS.MEDIUM,
|
||||
memoryWarning: totalSize > this.SIZE_THRESHOLDS.LARGE || totalPages > this.PAGE_THRESHOLDS.MASSIVE
|
||||
};
|
||||
|
||||
return { analyses, recommendations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal batch size for processing multiple files
|
||||
*/
|
||||
private static calculateBatchSize(fileCount: number, totalSize: number): number {
|
||||
// Process small batches for large total sizes
|
||||
if (totalSize > this.SIZE_THRESHOLDS.LARGE) {
|
||||
return Math.max(1, Math.floor(fileCount / 4));
|
||||
}
|
||||
|
||||
if (totalSize > this.SIZE_THRESHOLDS.MEDIUM) {
|
||||
return Math.max(2, Math.floor(fileCount / 2));
|
||||
}
|
||||
|
||||
// Process all at once for smaller total sizes
|
||||
return fileCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file appears to be a valid PDF
|
||||
*/
|
||||
static async isValidPDF(file: File): Promise<boolean> {
|
||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read first few bytes to check PDF header
|
||||
const header = file.slice(0, 8);
|
||||
const headerBytes = new Uint8Array(await header.arrayBuffer());
|
||||
const headerString = String.fromCharCode(...headerBytes);
|
||||
|
||||
return headerString.startsWith('%PDF-');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
209
frontend/src/core/services/fileProcessingService.ts
Normal file
209
frontend/src/core/services/fileProcessingService.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Centralized file processing service
|
||||
* Handles metadata discovery, page counting, and thumbnail generation
|
||||
* Called when files are added to FileContext, before any view sees them
|
||||
*/
|
||||
|
||||
import { generateThumbnailForFile } from '@app/utils/thumbnailUtils';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
export interface ProcessedFileMetadata {
|
||||
totalPages: number;
|
||||
pages: Array<{
|
||||
pageNumber: number;
|
||||
thumbnail?: string;
|
||||
rotation: number;
|
||||
splitBefore: boolean;
|
||||
}>;
|
||||
thumbnailUrl?: string; // Page 1 thumbnail for FileEditor
|
||||
lastProcessed: number;
|
||||
}
|
||||
|
||||
export interface FileProcessingResult {
|
||||
success: boolean;
|
||||
metadata?: ProcessedFileMetadata;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ProcessingOperation {
|
||||
promise: Promise<FileProcessingResult>;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
class FileProcessingService {
|
||||
private processingCache = new Map<string, ProcessingOperation>();
|
||||
|
||||
/**
|
||||
* Process a file to extract metadata, page count, and generate thumbnails
|
||||
* This is the single source of truth for file processing
|
||||
*/
|
||||
async processFile(file: File, fileId: FileId): Promise<FileProcessingResult> {
|
||||
// Check if we're already processing this file
|
||||
const existingOperation = this.processingCache.get(fileId);
|
||||
if (existingOperation) {
|
||||
console.log(`📁 FileProcessingService: Using cached processing for ${file.name}`);
|
||||
return existingOperation.promise;
|
||||
}
|
||||
|
||||
// Create abort controller for this operation
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Create processing promise
|
||||
const processingPromise = this.performProcessing(file, fileId, abortController);
|
||||
|
||||
// Store operation with abort controller
|
||||
const operation: ProcessingOperation = {
|
||||
promise: processingPromise,
|
||||
abortController
|
||||
};
|
||||
this.processingCache.set(fileId, operation);
|
||||
|
||||
// Clean up cache after completion
|
||||
processingPromise.finally(() => {
|
||||
this.processingCache.delete(fileId);
|
||||
});
|
||||
|
||||
return processingPromise;
|
||||
}
|
||||
|
||||
private async performProcessing(file: File, fileId: FileId, abortController: AbortController): Promise<FileProcessingResult> {
|
||||
console.log(`📁 FileProcessingService: Starting processing for ${file.name} (${fileId})`);
|
||||
|
||||
try {
|
||||
// Check for cancellation at start
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
let totalPages = 1;
|
||||
let thumbnailUrl: string | undefined;
|
||||
|
||||
// Handle PDF files
|
||||
if (file.type === 'application/pdf') {
|
||||
// Read arrayBuffer once and reuse for both PDF.js and fallback
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
// Check for cancellation after async operation
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
// Discover page count using PDF.js (most accurate)
|
||||
try {
|
||||
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true
|
||||
});
|
||||
|
||||
totalPages = pdfDoc.numPages;
|
||||
console.log(`📁 FileProcessingService: PDF.js discovered ${totalPages} pages for ${file.name}`);
|
||||
|
||||
// Clean up immediately
|
||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
|
||||
// Check for cancellation after PDF.js processing
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
} catch (pdfError) {
|
||||
console.warn(`📁 FileProcessingService: PDF.js failed for ${file.name}, setting pages to 0:`, pdfError);
|
||||
totalPages = 0; // Unknown page count - UI will hide page count display
|
||||
}
|
||||
}
|
||||
|
||||
// Generate page 1 thumbnail
|
||||
try {
|
||||
thumbnailUrl = await generateThumbnailForFile(file);
|
||||
console.log(`📁 FileProcessingService: Generated thumbnail for ${file.name}`);
|
||||
|
||||
// Check for cancellation after thumbnail generation
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.warn(`📁 FileProcessingService: Thumbnail generation failed for ${file.name}:`, thumbError);
|
||||
}
|
||||
|
||||
// Create page structure
|
||||
const pages = Array.from({ length: totalPages }, (_, index) => ({
|
||||
pageNumber: index + 1,
|
||||
thumbnail: index === 0 ? thumbnailUrl : undefined, // Only page 1 gets thumbnail initially
|
||||
rotation: 0,
|
||||
splitBefore: false
|
||||
}));
|
||||
|
||||
const metadata: ProcessedFileMetadata = {
|
||||
totalPages,
|
||||
pages,
|
||||
thumbnailUrl, // For FileEditor display
|
||||
lastProcessed: Date.now()
|
||||
};
|
||||
|
||||
console.log(`📁 FileProcessingService: Processing complete for ${file.name} - ${totalPages} pages`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error(`📁 FileProcessingService: Processing failed for ${file.name}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown processing error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all processing caches
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.processingCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is currently being processed
|
||||
*/
|
||||
isProcessing(fileId: FileId): boolean {
|
||||
return this.processingCache.has(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel processing for a specific file
|
||||
*/
|
||||
cancelProcessing(fileId: FileId): boolean {
|
||||
const operation = this.processingCache.get(fileId);
|
||||
if (operation) {
|
||||
operation.abortController.abort();
|
||||
console.log(`📁 FileProcessingService: Cancelled processing for ${fileId}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all ongoing processing operations
|
||||
*/
|
||||
cancelAllProcessing(): void {
|
||||
this.processingCache.forEach((operation, fileId) => {
|
||||
operation.abortController.abort();
|
||||
console.log(`📁 FileProcessingService: Cancelled processing for ${fileId}`);
|
||||
});
|
||||
console.log(`📁 FileProcessingService: Cancelled ${this.processingCache.size} processing operations`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency cleanup - cancel all processing and destroy workers
|
||||
*/
|
||||
emergencyCleanup(): void {
|
||||
this.cancelAllProcessing();
|
||||
this.clearCache();
|
||||
pdfWorkerManager.destroyAllDocuments();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const fileProcessingService = new FileProcessingService();
|
||||
484
frontend/src/core/services/fileStorage.ts
Normal file
484
frontend/src/core/services/fileStorage.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* Stirling File Storage Service
|
||||
* Single-table architecture with typed query methods
|
||||
* Forces correct usage patterns through service API design
|
||||
*/
|
||||
|
||||
import { FileId, BaseFileMetadata } from '@app/types/file';
|
||||
import { StirlingFile, StirlingFileStub, createStirlingFile } from '@app/types/fileContext';
|
||||
import { indexedDBManager, DATABASE_CONFIGS } from '@app/services/indexedDBManager';
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
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 StirlingFile with its metadata from StirlingFileStub
|
||||
*/
|
||||
async storeStirlingFile(stirlingFile: StirlingFile, stub: StirlingFileStub): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
const arrayBuffer = await stirlingFile.arrayBuffer();
|
||||
|
||||
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: 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);
|
||||
|
||||
const request = store.add(record);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('IndexedDB add error:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Transaction error:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get StirlingFile with full data - for loading into workbench
|
||||
*/
|
||||
async getStirlingFile(id: FileId): Promise<StirlingFile | 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 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 multiple StirlingFiles - for batch loading
|
||||
*/
|
||||
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 stubs: StirlingFileStub[] = [];
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
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 {
|
||||
resolve(stubs);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get leaf StirlingFileStubs only - for unprocessed files
|
||||
*/
|
||||
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) => {
|
||||
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 thumbnail for existing file
|
||||
*/
|
||||
async updateThumbnail(id: FileId, 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 record = getRequest.result as StoredStirlingFileRecord;
|
||||
if (record) {
|
||||
record.thumbnail = thumbnail;
|
||||
const updateRequest = store.put(record);
|
||||
|
||||
updateRequest.onsuccess = () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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 stubs = await this.getAllStirlingFileStubs();
|
||||
used = stubs.reduce((total, stub) => total + (stub?.size || 0), 0);
|
||||
fileCount = stubs.length;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const fileStorage = new FileStorageService();
|
||||
|
||||
// Helper hook for React components
|
||||
export function useFileStorage() {
|
||||
return fileStorage;
|
||||
}
|
||||
34
frontend/src/core/services/fileStubHelpers.ts
Normal file
34
frontend/src/core/services/fileStubHelpers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { StirlingFile, StirlingFileStub } from '@app/types/fileContext';
|
||||
import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
|
||||
import { createStirlingFile } from '@app/types/fileContext';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
|
||||
/**
|
||||
* Create StirlingFiles and StirlingFileStubs from exported files
|
||||
* Used when saving page editor changes to create version history
|
||||
*/
|
||||
export async function createStirlingFilesAndStubs(
|
||||
files: File[],
|
||||
parentStub: StirlingFileStub,
|
||||
toolId: ToolId
|
||||
): Promise<{ stirlingFiles: StirlingFile[], stubs: StirlingFileStub[] }> {
|
||||
const stirlingFiles: StirlingFile[] = [];
|
||||
const stubs: StirlingFileStub[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const processedFileMetadata = await generateProcessedFileMetadata(file);
|
||||
const childStub = createChildStub(
|
||||
parentStub,
|
||||
{ toolId, timestamp: Date.now() },
|
||||
file,
|
||||
processedFileMetadata?.thumbnailUrl,
|
||||
processedFileMetadata
|
||||
);
|
||||
|
||||
const stirlingFile = createStirlingFile(file, childStub.id);
|
||||
stirlingFiles.push(stirlingFile);
|
||||
stubs.push(childStub);
|
||||
}
|
||||
|
||||
return { stirlingFiles, stubs };
|
||||
}
|
||||
295
frontend/src/core/services/googleDrivePickerService.ts
Normal file
295
frontend/src/core/services/googleDrivePickerService.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Google Drive Picker Service
|
||||
* Handles Google Drive file picker integration
|
||||
*/
|
||||
|
||||
import { loadScript } from '@app/utils/scriptLoader';
|
||||
|
||||
const SCOPES = 'https://www.googleapis.com/auth/drive.readonly';
|
||||
const SESSION_STORAGE_ID = 'googleDrivePickerAccessToken';
|
||||
|
||||
interface GoogleDriveConfig {
|
||||
clientId: string;
|
||||
apiKey: string;
|
||||
appId: string;
|
||||
}
|
||||
|
||||
interface PickerOptions {
|
||||
multiple?: boolean;
|
||||
mimeTypes?: string | null;
|
||||
}
|
||||
|
||||
// Expandable mime types for Google Picker
|
||||
const expandableMimeTypes: Record<string, string[]> = {
|
||||
'image/*': ['image/jpeg', 'image/png', 'image/svg+xml'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert file input accept attribute to Google Picker mime types
|
||||
*/
|
||||
function fileInputToGooglePickerMimeTypes(accept?: string): string | null {
|
||||
if (!accept || accept === '' || accept.includes('*/*')) {
|
||||
// Setting null will accept all supported mimetypes
|
||||
return null;
|
||||
}
|
||||
|
||||
const mimeTypes: string[] = [];
|
||||
accept.split(',').forEach((part) => {
|
||||
const trimmedPart = part.trim();
|
||||
if (!(trimmedPart in expandableMimeTypes)) {
|
||||
mimeTypes.push(trimmedPart);
|
||||
return;
|
||||
}
|
||||
|
||||
expandableMimeTypes[trimmedPart].forEach((mimeType) => {
|
||||
mimeTypes.push(mimeType);
|
||||
});
|
||||
});
|
||||
|
||||
return mimeTypes.join(',').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
class GoogleDrivePickerService {
|
||||
private config: GoogleDriveConfig | null = null;
|
||||
private tokenClient: any = null;
|
||||
private accessToken: string | null = null;
|
||||
private gapiLoaded = false;
|
||||
private gisLoaded = false;
|
||||
|
||||
constructor() {
|
||||
this.accessToken = sessionStorage.getItem(SESSION_STORAGE_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the service with credentials
|
||||
*/
|
||||
async initialize(config: GoogleDriveConfig): Promise<void> {
|
||||
this.config = config;
|
||||
|
||||
// Load Google APIs
|
||||
await Promise.all([
|
||||
this.loadGapi(),
|
||||
this.loadGis(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google API client
|
||||
*/
|
||||
private async loadGapi(): Promise<void> {
|
||||
if (this.gapiLoaded) return;
|
||||
|
||||
await loadScript({
|
||||
src: 'https://apis.google.com/js/api.js',
|
||||
id: 'gapi-script',
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
window.gapi.load('client:picker', async () => {
|
||||
await window.gapi.client.load('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest');
|
||||
this.gapiLoaded = true;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google Identity Services
|
||||
*/
|
||||
private async loadGis(): Promise<void> {
|
||||
if (this.gisLoaded) return;
|
||||
|
||||
await loadScript({
|
||||
src: 'https://accounts.google.com/gsi/client',
|
||||
id: 'gis-script',
|
||||
});
|
||||
|
||||
if (!this.config) {
|
||||
throw new Error('Google Drive config not initialized');
|
||||
}
|
||||
|
||||
this.tokenClient = window.google.accounts.oauth2.initTokenClient({
|
||||
client_id: this.config.clientId,
|
||||
scope: SCOPES,
|
||||
callback: () => {}, // Will be overridden during picker creation
|
||||
});
|
||||
|
||||
this.gisLoaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Google Drive picker
|
||||
*/
|
||||
async openPicker(options: PickerOptions = {}): Promise<File[]> {
|
||||
if (!this.config) {
|
||||
throw new Error('Google Drive service not initialized');
|
||||
}
|
||||
|
||||
// Request access token
|
||||
await this.requestAccessToken();
|
||||
|
||||
// Create and show picker
|
||||
return this.createPicker(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request access token from Google
|
||||
*/
|
||||
private requestAccessToken(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.tokenClient) {
|
||||
reject(new Error('Token client not initialized'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.tokenClient.callback = (response: any) => {
|
||||
if (response.error !== undefined) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
}
|
||||
if(response.access_token == null){
|
||||
reject(new Error("No acces token in response"));
|
||||
}
|
||||
|
||||
this.accessToken = response.access_token;
|
||||
sessionStorage.setItem(SESSION_STORAGE_ID, this.accessToken ?? "");
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.tokenClient.requestAccessToken({
|
||||
prompt: this.accessToken === null ? 'consent' : '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and display the Google Picker
|
||||
*/
|
||||
private createPicker(options: PickerOptions): Promise<File[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.config || !this.accessToken) {
|
||||
reject(new Error('Not initialized or no access token'));
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeTypes = fileInputToGooglePickerMimeTypes(options.mimeTypes || undefined);
|
||||
|
||||
const view1 = new window.google.picker.DocsView().setIncludeFolders(true);
|
||||
if (mimeTypes !== null) {
|
||||
view1.setMimeTypes(mimeTypes);
|
||||
}
|
||||
|
||||
const view2 = new window.google.picker.DocsView()
|
||||
.setIncludeFolders(true)
|
||||
.setEnableDrives(true);
|
||||
if (mimeTypes !== null) {
|
||||
view2.setMimeTypes(mimeTypes);
|
||||
}
|
||||
|
||||
const builder = new window.google.picker.PickerBuilder()
|
||||
.setDeveloperKey(this.config.apiKey)
|
||||
.setAppId(this.config.appId)
|
||||
.setOAuthToken(this.accessToken)
|
||||
.addView(view1)
|
||||
.addView(view2)
|
||||
.setCallback((data: any) => this.pickerCallback(data, resolve, reject));
|
||||
|
||||
if (options.multiple) {
|
||||
builder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED);
|
||||
}
|
||||
|
||||
const picker = builder.build();
|
||||
picker.setVisible(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle picker selection callback
|
||||
*/
|
||||
private async pickerCallback(
|
||||
data: any,
|
||||
resolve: (files: File[]) => void,
|
||||
reject: (error: Error) => void
|
||||
): Promise<void> {
|
||||
if (data.action === window.google.picker.Action.PICKED) {
|
||||
try {
|
||||
const files = await Promise.all(
|
||||
data[window.google.picker.Response.DOCUMENTS].map(async (pickedFile: any) => {
|
||||
const fileId = pickedFile[window.google.picker.Document.ID];
|
||||
const res = await window.gapi.client.drive.files.get({
|
||||
fileId: fileId,
|
||||
alt: 'media',
|
||||
});
|
||||
|
||||
// Convert response body to File object
|
||||
const file = new File(
|
||||
[new Uint8Array(res.body.length).map((_: any, i: number) => res.body.charCodeAt(i))],
|
||||
pickedFile.name,
|
||||
{
|
||||
type: pickedFile.mimeType,
|
||||
lastModified: pickedFile.lastModified,
|
||||
}
|
||||
);
|
||||
return file;
|
||||
})
|
||||
);
|
||||
|
||||
resolve(files);
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error('Failed to download files'));
|
||||
}
|
||||
} else if (data.action === window.google.picker.Action.CANCEL) {
|
||||
resolve([]); // User cancelled, return empty array
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out and revoke access token
|
||||
*/
|
||||
signOut(): void {
|
||||
if (this.accessToken) {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_ID);
|
||||
window.google?.accounts.oauth2.revoke(this.accessToken, () => {});
|
||||
this.accessToken = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let serviceInstance: GoogleDrivePickerService | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the Google Drive picker service instance
|
||||
*/
|
||||
export function getGoogleDrivePickerService(): GoogleDrivePickerService {
|
||||
if (!serviceInstance) {
|
||||
serviceInstance = new GoogleDrivePickerService();
|
||||
}
|
||||
return serviceInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Google Drive credentials are configured
|
||||
*/
|
||||
export function isGoogleDriveConfigured(): boolean {
|
||||
const clientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID;
|
||||
const apiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY;
|
||||
const appId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID;
|
||||
|
||||
return !!(clientId && apiKey && appId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google Drive configuration from environment variables
|
||||
*/
|
||||
export function getGoogleDriveConfig(): GoogleDriveConfig | null {
|
||||
if (!isGoogleDriveConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clientId: import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID,
|
||||
apiKey: import.meta.env.VITE_GOOGLE_DRIVE_API_KEY,
|
||||
appId: import.meta.env.VITE_GOOGLE_DRIVE_APP_ID,
|
||||
};
|
||||
}
|
||||
147
frontend/src/core/services/httpErrorHandler.ts
Normal file
147
frontend/src/core/services/httpErrorHandler.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// frontend/src/services/httpErrorHandler.ts
|
||||
import axios from 'axios';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from '@app/services/errorUtils';
|
||||
import { showSpecialErrorToast } from '@app/services/specialErrorToasts';
|
||||
|
||||
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
|
||||
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
|
||||
|
||||
function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
|
||||
return s && s.length > max ? `${s.slice(0, max)}…` : s;
|
||||
}
|
||||
|
||||
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
|
||||
const s = (msg || '').trim();
|
||||
if (!s) return true;
|
||||
// Common unhelpful payloads we see
|
||||
if (s === '{}' || s === '[]') return true;
|
||||
if (/^request failed/i.test(s)) return true;
|
||||
if (/^network error/i.test(s)) return true;
|
||||
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
|
||||
return false;
|
||||
}
|
||||
|
||||
function titleForStatus(status?: number): string {
|
||||
if (!status) return 'Network error';
|
||||
if (status >= 500) return 'Server error';
|
||||
if (status >= 400) return 'Request error';
|
||||
return 'Request failed';
|
||||
}
|
||||
|
||||
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const _statusText = error.response?.statusText || '';
|
||||
let parsed: any = undefined;
|
||||
const raw = error.response?.data;
|
||||
if (typeof raw === 'string') {
|
||||
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
|
||||
} else {
|
||||
parsed = raw;
|
||||
}
|
||||
const extractIds = (): string[] | undefined => {
|
||||
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
|
||||
const rawText = typeof raw === 'string' ? raw : '';
|
||||
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
|
||||
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
|
||||
};
|
||||
|
||||
const body = ((): string => {
|
||||
const data = parsed;
|
||||
if (!data) return typeof raw === 'string' ? raw : '';
|
||||
const ids = extractIds();
|
||||
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
|
||||
if (data?.message) return data.message as string;
|
||||
if (typeof raw === 'string') return raw;
|
||||
try { return JSON.stringify(data); } catch { return ''; }
|
||||
})();
|
||||
const ids = extractIds();
|
||||
const title = titleForStatus(status);
|
||||
if (ids && ids.length > 0) {
|
||||
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
|
||||
}
|
||||
if (status === 422) {
|
||||
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
try {
|
||||
const msg = (error?.message || String(error)) as string;
|
||||
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
|
||||
} catch (e) {
|
||||
// ignore extraction errors
|
||||
console.debug('extractAxiosErrorMessage', e);
|
||||
return { title: 'Network error', body: FRIENDLY_FALLBACK };
|
||||
}
|
||||
}
|
||||
|
||||
// Module-scoped state to reduce global variable usage
|
||||
const recentSpecialByEndpoint: Record<string, number> = {};
|
||||
const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast
|
||||
|
||||
/**
|
||||
* Handles HTTP errors with toast notifications and file error broadcasting
|
||||
* Returns true if the error should be suppressed (deduplicated), false otherwise
|
||||
*/
|
||||
export async function handleHttpError(error: any): Promise<boolean> {
|
||||
// Compute title/body (friendly) from the error object
|
||||
const { title, body } = extractAxiosErrorMessage(error);
|
||||
|
||||
// Normalize response data ONCE, reuse for both ID extraction and special-toast matching
|
||||
const raw = (error?.response?.data) as any;
|
||||
let normalized: unknown = raw;
|
||||
try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); }
|
||||
|
||||
// 1) If server sends structured file IDs for failures, also mark them errored in UI
|
||||
try {
|
||||
const ids = extractErrorFileIds(normalized);
|
||||
if (ids && ids.length > 0) {
|
||||
broadcastErroredFiles(ids);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', e);
|
||||
}
|
||||
|
||||
// 2) Generic-vs-special dedupe by endpoint
|
||||
const url: string | undefined = error?.config?.url;
|
||||
const status: number | undefined = error?.response?.status;
|
||||
const now = Date.now();
|
||||
const isSpecial =
|
||||
status === 422 ||
|
||||
status === 409 || // often actionable conflicts
|
||||
/Failed files:/.test(body) ||
|
||||
/invalid\/corrupted file\(s\)/i.test(body);
|
||||
|
||||
if (isSpecial && url) {
|
||||
recentSpecialByEndpoint[url] = now;
|
||||
}
|
||||
if (!isSpecial && url) {
|
||||
const last = recentSpecialByEndpoint[url] || 0;
|
||||
if (now - last < SPECIAL_SUPPRESS_MS) {
|
||||
return true; // Suppress this error (deduplicated)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Show specialized friendly toasts if matched; otherwise show the generic one
|
||||
let rawString: string | undefined;
|
||||
try {
|
||||
rawString =
|
||||
typeof normalized === 'string'
|
||||
? normalized
|
||||
: JSON.stringify(normalized);
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', e);
|
||||
}
|
||||
|
||||
const handled = showSpecialErrorToast(rawString, { status });
|
||||
if (!handled) {
|
||||
const displayBody = clampText(body);
|
||||
alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false });
|
||||
}
|
||||
|
||||
return false; // Error was handled with toast, continue normal rejection
|
||||
}
|
||||
328
frontend/src/core/services/indexedDBManager.ts
Normal file
328
frontend/src/core/services/indexedDBManager.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Centralized IndexedDB Manager
|
||||
* Handles all database initialization, schema management, and migrations
|
||||
* Prevents race conditions and duplicate schema upgrades
|
||||
*/
|
||||
|
||||
export interface DatabaseConfig {
|
||||
name: string;
|
||||
version: number;
|
||||
stores: {
|
||||
name: string;
|
||||
keyPath?: string | string[];
|
||||
autoIncrement?: boolean;
|
||||
indexes?: {
|
||||
name: string;
|
||||
keyPath: string | string[];
|
||||
unique: boolean;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
class IndexedDBManager {
|
||||
private static instance: IndexedDBManager;
|
||||
private databases = new Map<string, IDBDatabase>();
|
||||
private initPromises = new Map<string, Promise<IDBDatabase>>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): IndexedDBManager {
|
||||
if (!IndexedDBManager.instance) {
|
||||
IndexedDBManager.instance = new IndexedDBManager();
|
||||
}
|
||||
return IndexedDBManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open or get existing database connection
|
||||
*/
|
||||
async openDatabase(config: DatabaseConfig): Promise<IDBDatabase> {
|
||||
const existingDb = this.databases.get(config.name);
|
||||
if (existingDb) {
|
||||
return existingDb;
|
||||
}
|
||||
|
||||
const existingPromise = this.initPromises.get(config.name);
|
||||
if (existingPromise) {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const initPromise = this.performDatabaseInit(config);
|
||||
this.initPromises.set(config.name, initPromise);
|
||||
|
||||
try {
|
||||
const db = await initPromise;
|
||||
this.databases.set(config.name, db);
|
||||
return db;
|
||||
} catch (error) {
|
||||
this.initPromises.delete(config.name);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private performDatabaseInit(config: DatabaseConfig): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`Opening IndexedDB: ${config.name} v${config.version}`);
|
||||
const request = indexedDB.open(config.name, config.version);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error(`Failed to open ${config.name}:`, request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
console.log(`Successfully opened ${config.name}`);
|
||||
|
||||
// Set up close handler to clean up our references
|
||||
db.onclose = () => {
|
||||
console.log(`Database ${config.name} closed`);
|
||||
this.databases.delete(config.name);
|
||||
this.initPromises.delete(config.name);
|
||||
};
|
||||
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
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 - get reference for migration
|
||||
console.log(`Object store '${storeConfig.name}' already exists`);
|
||||
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}'`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
*/
|
||||
getDatabase(name: string): IDBDatabase | null {
|
||||
return this.databases.get(name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
closeDatabase(name: string): void {
|
||||
const db = this.databases.get(name);
|
||||
if (db) {
|
||||
db.close();
|
||||
this.databases.delete(name);
|
||||
this.initPromises.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all database connections
|
||||
*/
|
||||
closeAllDatabases(): void {
|
||||
this.databases.forEach((db, name) => {
|
||||
console.log(`Closing database: ${name}`);
|
||||
db.close();
|
||||
});
|
||||
this.databases.clear();
|
||||
this.initPromises.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete database completely
|
||||
*/
|
||||
async deleteDatabase(name: string): Promise<void> {
|
||||
// Close connection if open
|
||||
this.closeDatabase(name);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const deleteRequest = indexedDB.deleteDatabase(name);
|
||||
|
||||
deleteRequest.onerror = () => reject(deleteRequest.error);
|
||||
deleteRequest.onsuccess = () => {
|
||||
console.log(`Deleted database: ${name}`);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a database exists and what version it is
|
||||
*/
|
||||
async getDatabaseVersion(name: string): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
const request = indexedDB.open(name);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const version = db.version;
|
||||
db.close();
|
||||
resolve(version);
|
||||
};
|
||||
request.onerror = () => resolve(null);
|
||||
request.onupgradeneeded = () => {
|
||||
// Cancel the upgrade
|
||||
request.transaction?.abort();
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-defined database configurations
|
||||
export const DATABASE_CONFIGS = {
|
||||
FILES: {
|
||||
name: 'stirling-pdf-files',
|
||||
version: 3,
|
||||
stores: [{
|
||||
name: 'files',
|
||||
keyPath: 'id',
|
||||
indexes: [
|
||||
{ name: 'name', keyPath: 'name', 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,
|
||||
|
||||
DRAFTS: {
|
||||
name: 'stirling-pdf-drafts',
|
||||
version: 1,
|
||||
stores: [{
|
||||
name: 'drafts',
|
||||
keyPath: 'id'
|
||||
}]
|
||||
} as DatabaseConfig,
|
||||
|
||||
PREFERENCES: {
|
||||
name: 'stirling-pdf-preferences',
|
||||
version: 1,
|
||||
stores: [{
|
||||
name: 'preferences',
|
||||
keyPath: 'key'
|
||||
}]
|
||||
} as DatabaseConfig,
|
||||
|
||||
} as const;
|
||||
|
||||
export const indexedDBManager = IndexedDBManager.getInstance();
|
||||
46
frontend/src/core/services/pdfExportHelpers.ts
Normal file
46
frontend/src/core/services/pdfExportHelpers.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { PDFDocument } from '@app/types/pageEditor';
|
||||
import { pdfExportService } from '@app/services/pdfExportService';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
/**
|
||||
* Export processed documents to File objects
|
||||
* Handles both single documents and split documents (multiple PDFs)
|
||||
*/
|
||||
export async function exportProcessedDocumentsToFiles(
|
||||
processedDocuments: PDFDocument | PDFDocument[],
|
||||
sourceFiles: Map<FileId, File> | null,
|
||||
exportFilename: string
|
||||
): Promise<File[]> {
|
||||
console.log('exportProcessedDocumentsToFiles called with:', {
|
||||
isArray: Array.isArray(processedDocuments),
|
||||
numDocs: Array.isArray(processedDocuments) ? processedDocuments.length : 1,
|
||||
hasSourceFiles: sourceFiles !== null,
|
||||
sourceFilesSize: sourceFiles?.size
|
||||
});
|
||||
|
||||
if (Array.isArray(processedDocuments)) {
|
||||
// Multiple documents (splits)
|
||||
const files: File[] = [];
|
||||
const baseName = exportFilename.replace(/\.pdf$/i, '');
|
||||
|
||||
for (let i = 0; i < processedDocuments.length; i++) {
|
||||
const doc = processedDocuments[i];
|
||||
const partFilename = `${baseName}_part_${i + 1}.pdf`;
|
||||
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { selectedOnly: false, filename: partFilename })
|
||||
: await pdfExportService.exportPDF(doc, [], { selectedOnly: false, filename: partFilename });
|
||||
|
||||
files.push(new File([result.blob], result.filename, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
return files;
|
||||
} else {
|
||||
// Single document
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(processedDocuments, sourceFiles, [], { selectedOnly: false, filename: exportFilename })
|
||||
: await pdfExportService.exportPDF(processedDocuments, [], { selectedOnly: false, filename: exportFilename });
|
||||
|
||||
return [new File([result.blob], result.filename, { type: 'application/pdf' })];
|
||||
}
|
||||
}
|
||||
275
frontend/src/core/services/pdfExportService.ts
Normal file
275
frontend/src/core/services/pdfExportService.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
|
||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||
|
||||
export interface ExportOptions {
|
||||
selectedOnly?: boolean;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export class PDFExportService {
|
||||
/**
|
||||
* Export PDF document with applied operations (single file source)
|
||||
*/
|
||||
async exportPDF(
|
||||
pdfDocument: PDFDocument,
|
||||
selectedPageIds: string[] = [],
|
||||
options: ExportOptions = {}
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const { selectedOnly = false, filename } = options;
|
||||
|
||||
try {
|
||||
// Determine which pages to export
|
||||
const pagesToExport = selectedOnly && selectedPageIds.length > 0
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
if (pagesToExport.length === 0) {
|
||||
throw new Error('No pages to export');
|
||||
}
|
||||
|
||||
// Load original PDF and create new document
|
||||
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
|
||||
const sourceDoc = await PDFLibDocument.load(originalPDFBytes, { ignoreEncryption: true });
|
||||
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
|
||||
const exportFilename = this.generateFilename(filename || pdfDocument.name);
|
||||
|
||||
return { blob, filename: exportFilename };
|
||||
} catch (error) {
|
||||
console.error('PDF export error:', error);
|
||||
throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export PDF document with applied operations (multi-file source)
|
||||
*/
|
||||
async exportPDFMultiFile(
|
||||
pdfDocument: PDFDocument,
|
||||
sourceFiles: Map<string, File>,
|
||||
selectedPageIds: string[] = [],
|
||||
options: ExportOptions = {}
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const { selectedOnly = false, filename } = options;
|
||||
|
||||
try {
|
||||
// Determine which pages to export
|
||||
const pagesToExport = selectedOnly && selectedPageIds.length > 0
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
if (pagesToExport.length === 0) {
|
||||
throw new Error('No pages to export');
|
||||
}
|
||||
|
||||
const blob = await this.createMultiSourceDocument(sourceFiles, pagesToExport);
|
||||
const exportFilename = this.generateFilename(filename || pdfDocument.name);
|
||||
|
||||
return { blob, filename: exportFilename };
|
||||
} catch (error) {
|
||||
console.error('Multi-file PDF export error:', error);
|
||||
throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PDF document from multiple source files
|
||||
*/
|
||||
private async createMultiSourceDocument(
|
||||
sourceFiles: Map<string, File>,
|
||||
pages: PDFPage[]
|
||||
): Promise<Blob> {
|
||||
const newDoc = await PDFLibDocument.create();
|
||||
|
||||
// Load all source documents once and cache them
|
||||
const loadedDocs = new Map<string, PDFLibDocument>();
|
||||
|
||||
for (const [fileId, file] of sourceFiles) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const doc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true });
|
||||
loadedDocs.set(fileId, doc);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load source file ${fileId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
if (page.isBlankPage || page.originalPageNumber === -1) {
|
||||
// Create a blank page
|
||||
const blankPage = newDoc.addPage(PageSizes.A4);
|
||||
|
||||
blankPage.setRotation(degrees(page.rotation));
|
||||
} else if (page.originalFileId && loadedDocs.has(page.originalFileId)) {
|
||||
// Get the correct source document for this page
|
||||
const sourceDoc = loadedDocs.get(page.originalFileId)!;
|
||||
const sourcePageIndex = page.originalPageNumber - 1;
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||
// Copy the page from the correct source document
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set metadata
|
||||
newDoc.setCreator('Stirling PDF');
|
||||
newDoc.setProducer('Stirling PDF');
|
||||
newDoc.setCreationDate(new Date());
|
||||
newDoc.setModificationDate(new Date());
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single PDF document with all operations applied (single source)
|
||||
*/
|
||||
private async createSingleDocument(
|
||||
sourceDoc: PDFLibDocument,
|
||||
pages: PDFPage[]
|
||||
): Promise<Blob> {
|
||||
const newDoc = await PDFLibDocument.create();
|
||||
|
||||
for (const page of pages) {
|
||||
if (page.isBlankPage || page.originalPageNumber === -1) {
|
||||
// Create a blank page
|
||||
const blankPage = newDoc.addPage(PageSizes.A4);
|
||||
|
||||
blankPage.setRotation(degrees(page.rotation));
|
||||
} else {
|
||||
// Get the original page from source document using originalPageNumber
|
||||
const sourcePageIndex = page.originalPageNumber - 1;
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||
// Copy the page
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set metadata
|
||||
newDoc.setCreator('Stirling PDF');
|
||||
newDoc.setProducer('Stirling PDF');
|
||||
newDoc.setCreationDate(new Date());
|
||||
newDoc.setModificationDate(new Date());
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate appropriate filename for export
|
||||
*/
|
||||
private generateFilename(originalName: string): string {
|
||||
const baseName = originalName.replace(/\.pdf$/i, '');
|
||||
return `${baseName}.pdf`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Download a single file
|
||||
*/
|
||||
downloadFile(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up the URL after a short delay
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download multiple files as a ZIP
|
||||
*/
|
||||
async downloadAsZip(blobs: Blob[], filenames: string[]): Promise<void> {
|
||||
blobs.forEach((blob, index) => {
|
||||
setTimeout(() => {
|
||||
this.downloadFile(blob, filenames[index]);
|
||||
}, index * 500); // Stagger downloads
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate PDF operations before export
|
||||
*/
|
||||
validateExport(pdfDocument: PDFDocument, selectedPageIds: string[], selectedOnly: boolean): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (selectedOnly && selectedPageIds.length === 0) {
|
||||
errors.push('No pages selected for export');
|
||||
}
|
||||
|
||||
if (pdfDocument.pages.length === 0) {
|
||||
errors.push('No pages available to export');
|
||||
}
|
||||
|
||||
const pagesToExport = selectedOnly
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
if (pagesToExport.length === 0) {
|
||||
errors.push('No valid pages to export after applying filters');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export preview information
|
||||
*/
|
||||
getExportInfo(pdfDocument: PDFDocument, selectedPageIds: string[], selectedOnly: boolean): {
|
||||
pageCount: number;
|
||||
splitCount: number;
|
||||
estimatedSize: string;
|
||||
} {
|
||||
const pagesToExport = selectedOnly
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
const splitCount = pagesToExport.reduce((count, page) => {
|
||||
return count + (page.splitAfter ? 1 : 0);
|
||||
}, 1); // At least 1 document
|
||||
|
||||
// Rough size estimation (very approximate)
|
||||
const avgPageSize = pdfDocument.file.size / pdfDocument.totalPages;
|
||||
const estimatedBytes = avgPageSize * pagesToExport.length;
|
||||
const estimatedSize = this.formatFileSize(estimatedBytes);
|
||||
|
||||
return {
|
||||
pageCount: pagesToExport.length,
|
||||
splitCount,
|
||||
estimatedSize
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
private formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pdfExportService = new PDFExportService();
|
||||
181
frontend/src/core/services/pdfMetadataService.ts
Normal file
181
frontend/src/core/services/pdfMetadataService.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import { FileAnalyzer } from '@app/services/fileAnalyzer';
|
||||
import { TrappedStatus, CustomMetadataEntry, ExtractedPDFMetadata } from '@app/types/metadata';
|
||||
import { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
|
||||
|
||||
export interface MetadataExtractionResult {
|
||||
success: true;
|
||||
metadata: ExtractedPDFMetadata;
|
||||
}
|
||||
|
||||
export interface MetadataExtractionError {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type MetadataExtractionResponse = MetadataExtractionResult | MetadataExtractionError;
|
||||
|
||||
/**
|
||||
* Utility to format PDF date strings to required format (yyyy/MM/dd HH:mm:ss)
|
||||
* Handles PDF date format: "D:YYYYMMDDHHmmSSOHH'mm'" or standard date strings
|
||||
*/
|
||||
function formatPDFDate(dateString: string): string {
|
||||
if (!dateString) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let date: Date;
|
||||
|
||||
// Check if it's a PDF date format (starts with "D:")
|
||||
if (dateString.startsWith('D:')) {
|
||||
// Parse PDF date format: D:YYYYMMDDHHmmSSOHH'mm'
|
||||
const dateStr = dateString.substring(2); // Remove "D:"
|
||||
|
||||
// Extract date parts
|
||||
const year = parseInt(dateStr.substring(0, 4));
|
||||
const month = parseInt(dateStr.substring(4, 6));
|
||||
const day = parseInt(dateStr.substring(6, 8));
|
||||
const hour = parseInt(dateStr.substring(8, 10)) || 0;
|
||||
const minute = parseInt(dateStr.substring(10, 12)) || 0;
|
||||
const second = parseInt(dateStr.substring(12, 14)) || 0;
|
||||
|
||||
// Create date object (month is 0-indexed)
|
||||
date = new Date(year, month - 1, day, hour, minute, second);
|
||||
} else {
|
||||
// Try parsing as regular date string
|
||||
date = new Date(dateString);
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PDF.js trapped value to TrappedStatus enum
|
||||
* PDF.js returns trapped as { name: "True" | "False" } object
|
||||
*/
|
||||
function convertTrappedStatus(trapped: unknown): TrappedStatus {
|
||||
if (trapped && typeof trapped === 'object' && 'name' in trapped) {
|
||||
const name = (trapped as Record<string, string>).name?.toLowerCase();
|
||||
if (name === 'true') return TrappedStatus.TRUE;
|
||||
if (name === 'false') return TrappedStatus.FALSE;
|
||||
}
|
||||
return TrappedStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract custom metadata fields from PDF.js info object
|
||||
* Custom metadata is nested under the "Custom" key
|
||||
*/
|
||||
function extractCustomMetadata(custom: unknown): CustomMetadataEntry[] {
|
||||
const customMetadata: CustomMetadataEntry[] = [];
|
||||
let customIdCounter = 1;
|
||||
|
||||
|
||||
// Check if there's a Custom object containing the custom metadata
|
||||
if (typeof custom === 'object' && custom !== null) {
|
||||
const customObj = custom as Record<string, unknown>;
|
||||
|
||||
Object.entries(customObj).forEach(([key, value]) => {
|
||||
if (value != null && value !== '') {
|
||||
const entry = {
|
||||
key,
|
||||
value: String(value),
|
||||
id: `custom${customIdCounter++}`
|
||||
};
|
||||
customMetadata.push(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return customMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely cleanup PDF document with error handling
|
||||
*/
|
||||
function cleanupPdfDocument(pdfDoc: PDFDocumentProxy | null): void {
|
||||
if (pdfDoc) {
|
||||
try {
|
||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to cleanup PDF document:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getStringMetadata(info: Record<string, unknown>, key: string): string {
|
||||
if (typeof info[key] === 'string') {
|
||||
return info[key];
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all metadata from a PDF file
|
||||
* Returns a result object with success/error state
|
||||
*/
|
||||
export async function extractPDFMetadata(file: File): Promise<MetadataExtractionResponse> {
|
||||
// Use existing PDF validation
|
||||
const isValidPDF = await FileAnalyzer.isValidPDF(file);
|
||||
if (!isValidPDF) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'File is not a valid PDF'
|
||||
};
|
||||
}
|
||||
|
||||
let pdfDoc: PDFDocumentProxy | null = null;
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
let metadata;
|
||||
|
||||
try {
|
||||
arrayBuffer = await file.arrayBuffer();
|
||||
pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true
|
||||
});
|
||||
metadata = await pdfDoc.getMetadata();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
cleanupPdfDocument(pdfDoc);
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to read PDF: ${errorMessage}`
|
||||
};
|
||||
}
|
||||
|
||||
const info = metadata.info as Record<string, unknown>;
|
||||
|
||||
// Safely extract metadata with proper type checking
|
||||
const extractedMetadata: ExtractedPDFMetadata = {
|
||||
title: getStringMetadata(info, 'Title'),
|
||||
author: getStringMetadata(info, 'Author'),
|
||||
subject: getStringMetadata(info, 'Subject'),
|
||||
keywords: getStringMetadata(info, 'Keywords'),
|
||||
creator: getStringMetadata(info, 'Creator'),
|
||||
producer: getStringMetadata(info, 'Producer'),
|
||||
creationDate: formatPDFDate(getStringMetadata(info, 'CreationDate')),
|
||||
modificationDate: formatPDFDate(getStringMetadata(info, 'ModDate')),
|
||||
trapped: convertTrappedStatus(info.Trapped),
|
||||
customMetadata: extractCustomMetadata(info.Custom),
|
||||
};
|
||||
|
||||
cleanupPdfDocument(pdfDoc);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata: extractedMetadata
|
||||
};
|
||||
}
|
||||
187
frontend/src/core/services/pdfProcessingService.ts
Normal file
187
frontend/src/core/services/pdfProcessingService.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { ProcessedFile, ProcessingState, PDFPage } from '@app/types/processing';
|
||||
import { ProcessingCache } from '@app/services/processingCache';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import { createQuickKey } from '@app/types/fileContext';
|
||||
|
||||
export class PDFProcessingService {
|
||||
private static instance: PDFProcessingService;
|
||||
private cache = new ProcessingCache();
|
||||
private processing = new Map<string, ProcessingState>();
|
||||
private processingListeners = new Set<(states: Map<string, ProcessingState>) => void>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): PDFProcessingService {
|
||||
if (!PDFProcessingService.instance) {
|
||||
PDFProcessingService.instance = new PDFProcessingService();
|
||||
}
|
||||
return PDFProcessingService.instance;
|
||||
}
|
||||
|
||||
async getProcessedFile(file: File): Promise<ProcessedFile | null> {
|
||||
const fileKey = this.generateFileKey(file);
|
||||
|
||||
// Check cache first
|
||||
const cached = this.cache.get(fileKey);
|
||||
if (cached) {
|
||||
console.log('Cache hit for:', file.name);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check if already processing
|
||||
if (this.processing.has(fileKey)) {
|
||||
console.log('Already processing:', file.name);
|
||||
return null; // Will be available when processing completes
|
||||
}
|
||||
|
||||
// Start processing
|
||||
this.startProcessing(file, fileKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async startProcessing(file: File, fileKey: string): Promise<void> {
|
||||
// Set initial state
|
||||
const state: ProcessingState = {
|
||||
fileKey,
|
||||
fileName: file.name,
|
||||
status: 'processing',
|
||||
progress: 0,
|
||||
startedAt: Date.now(),
|
||||
strategy: 'immediate_full'
|
||||
};
|
||||
|
||||
this.processing.set(fileKey, state);
|
||||
this.notifyListeners();
|
||||
|
||||
try {
|
||||
// Process the file with progress updates
|
||||
const processedFile = await this.processFileWithProgress(file, (progress) => {
|
||||
state.progress = progress;
|
||||
this.notifyListeners();
|
||||
});
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(fileKey, processedFile);
|
||||
|
||||
// Update state to completed
|
||||
state.status = 'completed';
|
||||
state.progress = 100;
|
||||
state.completedAt = Date.now();
|
||||
this.notifyListeners();
|
||||
|
||||
// Remove from processing map after brief delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
this.notifyListeners();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Processing failed for', file.name, ':', error);
|
||||
state.status = 'error';
|
||||
state.error = (error instanceof Error ? error.message : 'Unknown error') as any;
|
||||
this.notifyListeners();
|
||||
|
||||
// Remove failed processing after delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
this.notifyListeners();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
private async processFileWithProgress(
|
||||
file: File,
|
||||
onProgress: (progress: number) => void
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
onProgress(10); // PDF loaded
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (context) {
|
||||
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
pages.push({
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const progress = 10 + (i / totalPages) * 85; // 10-95%
|
||||
onProgress(progress);
|
||||
}
|
||||
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
onProgress(100);
|
||||
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
pages,
|
||||
totalPages,
|
||||
metadata: {
|
||||
title: file.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// State subscription for components
|
||||
onProcessingChange(callback: (states: Map<string, ProcessingState>) => void): () => void {
|
||||
this.processingListeners.add(callback);
|
||||
return () => this.processingListeners.delete(callback);
|
||||
}
|
||||
|
||||
getProcessingStates(): Map<string, ProcessingState> {
|
||||
return new Map(this.processing);
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
this.processingListeners.forEach(callback => callback(this.processing));
|
||||
}
|
||||
|
||||
generateFileKey(file: File): string {
|
||||
return `${file.name}-${file.size}-${file.lastModified}`;
|
||||
}
|
||||
|
||||
// Cleanup method for activeFiles changes
|
||||
cleanup(removedFiles: File[]): void {
|
||||
removedFiles.forEach(file => {
|
||||
const key = this.generateFileKey(file);
|
||||
this.cache.delete(key);
|
||||
this.processing.delete(key);
|
||||
});
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
// Get cache stats (for debugging)
|
||||
getCacheStats() {
|
||||
return this.cache.getStats();
|
||||
}
|
||||
|
||||
// Clear all cache and processing
|
||||
clearAll(): void {
|
||||
this.cache.clear();
|
||||
this.processing.clear();
|
||||
this.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pdfProcessingService = PDFProcessingService.getInstance();
|
||||
189
frontend/src/core/services/pdfWorkerManager.ts
Normal file
189
frontend/src/core/services/pdfWorkerManager.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* PDF.js Worker Manager - Centralized worker lifecycle management
|
||||
*
|
||||
* Prevents infinite worker creation by managing PDF.js workers globally
|
||||
* and ensuring proper cleanup when operations complete.
|
||||
*/
|
||||
|
||||
import { GlobalWorkerOptions, getDocument, PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
|
||||
class PDFWorkerManager {
|
||||
private static instance: PDFWorkerManager;
|
||||
private activeDocuments = new Set<PDFDocumentProxy>();
|
||||
private workerCount = 0;
|
||||
private maxWorkers = 10; // Limit concurrent workers
|
||||
private isInitialized = false;
|
||||
|
||||
private constructor() {
|
||||
this.initializeWorker();
|
||||
}
|
||||
|
||||
static getInstance(): PDFWorkerManager {
|
||||
if (!PDFWorkerManager.instance) {
|
||||
PDFWorkerManager.instance = new PDFWorkerManager();
|
||||
}
|
||||
return PDFWorkerManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize PDF.js worker once globally
|
||||
*/
|
||||
private initializeWorker(): void {
|
||||
if (!this.isInitialized) {
|
||||
GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PDF document with proper lifecycle management
|
||||
* Supports ArrayBuffer, Uint8Array, URL string, or {data: ArrayBuffer} object
|
||||
*/
|
||||
async createDocument(
|
||||
data: ArrayBuffer | Uint8Array | string | { data: ArrayBuffer },
|
||||
options: {
|
||||
disableAutoFetch?: boolean;
|
||||
disableStream?: boolean;
|
||||
stopAtErrors?: boolean;
|
||||
verbosity?: number;
|
||||
} = {}
|
||||
): Promise<PDFDocumentProxy> {
|
||||
// Wait if we've hit the worker limit
|
||||
if (this.activeDocuments.size >= this.maxWorkers) {
|
||||
await this.waitForAvailableWorker();
|
||||
}
|
||||
|
||||
// Normalize input data to PDF.js format
|
||||
let pdfData: any;
|
||||
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
||||
pdfData = { data };
|
||||
} else if (typeof data === 'string') {
|
||||
pdfData = data; // URL string
|
||||
} else if (data && typeof data === 'object' && 'data' in data) {
|
||||
pdfData = data; // Already in {data: ArrayBuffer} format
|
||||
} else {
|
||||
pdfData = data; // Pass through as-is
|
||||
}
|
||||
|
||||
const loadingTask = getDocument(
|
||||
typeof pdfData === 'string' ? {
|
||||
url: pdfData,
|
||||
disableAutoFetch: options.disableAutoFetch ?? true,
|
||||
disableStream: options.disableStream ?? true,
|
||||
stopAtErrors: options.stopAtErrors ?? false,
|
||||
verbosity: options.verbosity ?? 0
|
||||
} : {
|
||||
...pdfData,
|
||||
disableAutoFetch: options.disableAutoFetch ?? true,
|
||||
disableStream: options.disableStream ?? true,
|
||||
stopAtErrors: options.stopAtErrors ?? false,
|
||||
verbosity: options.verbosity ?? 0
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const pdf = await loadingTask.promise;
|
||||
this.activeDocuments.add(pdf);
|
||||
this.workerCount++;
|
||||
|
||||
return pdf;
|
||||
} catch (error) {
|
||||
// If document creation fails, make sure to clean up the loading task
|
||||
if (loadingTask) {
|
||||
try {
|
||||
loadingTask.destroy();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Properly destroy a PDF document and clean up resources
|
||||
*/
|
||||
destroyDocument(pdf: PDFDocumentProxy): void {
|
||||
if (this.activeDocuments.has(pdf)) {
|
||||
try {
|
||||
pdf.destroy();
|
||||
this.activeDocuments.delete(pdf);
|
||||
this.workerCount = Math.max(0, this.workerCount - 1);
|
||||
} catch {
|
||||
// Still remove from tracking even if destroy failed
|
||||
this.activeDocuments.delete(pdf);
|
||||
this.workerCount = Math.max(0, this.workerCount - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all active PDF documents
|
||||
*/
|
||||
destroyAllDocuments(): void {
|
||||
const documentsToDestroy = Array.from(this.activeDocuments);
|
||||
documentsToDestroy.forEach(pdf => {
|
||||
this.destroyDocument(pdf);
|
||||
});
|
||||
|
||||
this.activeDocuments.clear();
|
||||
this.workerCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a worker to become available
|
||||
*/
|
||||
private async waitForAvailableWorker(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const checkAvailability = () => {
|
||||
if (this.activeDocuments.size < this.maxWorkers) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkAvailability, 100);
|
||||
}
|
||||
};
|
||||
checkAvailability();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current worker statistics
|
||||
*/
|
||||
getWorkerStats() {
|
||||
return {
|
||||
active: this.activeDocuments.size,
|
||||
max: this.maxWorkers,
|
||||
total: this.workerCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force cleanup of all workers (emergency cleanup)
|
||||
*/
|
||||
emergencyCleanup(): void {
|
||||
// Force destroy all documents
|
||||
this.activeDocuments.forEach(pdf => {
|
||||
try {
|
||||
pdf.destroy();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
this.activeDocuments.clear();
|
||||
this.workerCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set maximum concurrent workers
|
||||
*/
|
||||
setMaxWorkers(max: number): void {
|
||||
this.maxWorkers = Math.max(1, Math.min(max, 15)); // Between 1-15 workers for multi-file support
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pdfWorkerManager = PDFWorkerManager.getInstance();
|
||||
86
frontend/src/core/services/preferencesService.ts
Normal file
86
frontend/src/core/services/preferencesService.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { type ToolPanelMode, DEFAULT_TOOL_PANEL_MODE } from '@app/constants/toolPanel';
|
||||
import { type ThemeMode, getSystemTheme } from '@app/constants/theme';
|
||||
|
||||
export interface UserPreferences {
|
||||
autoUnzip: boolean;
|
||||
autoUnzipFileLimit: number;
|
||||
defaultToolPanelMode: ToolPanelMode;
|
||||
theme: ThemeMode;
|
||||
toolPanelModePromptSeen: boolean;
|
||||
showLegacyToolDescriptions: boolean;
|
||||
hasCompletedOnboarding: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
autoUnzip: true,
|
||||
autoUnzipFileLimit: 4,
|
||||
defaultToolPanelMode: DEFAULT_TOOL_PANEL_MODE,
|
||||
theme: getSystemTheme(),
|
||||
toolPanelModePromptSeen: false,
|
||||
showLegacyToolDescriptions: false,
|
||||
hasCompletedOnboarding: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf_preferences';
|
||||
|
||||
class PreferencesService {
|
||||
getPreference<K extends keyof UserPreferences>(
|
||||
key: K
|
||||
): UserPreferences[K] {
|
||||
// Explicitly re-read every time in case preferences have changed in another tab etc.
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const preferences = JSON.parse(stored) as Partial<UserPreferences>;
|
||||
if (key in preferences && preferences[key] !== undefined) {
|
||||
return preferences[key]!;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading preference:', key, error);
|
||||
}
|
||||
return DEFAULT_PREFERENCES[key];
|
||||
}
|
||||
|
||||
setPreference<K extends keyof UserPreferences>(
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const preferences = stored ? JSON.parse(stored) : {};
|
||||
preferences[key] = value;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
|
||||
} catch (error) {
|
||||
console.error('Error writing preference:', key, error);
|
||||
}
|
||||
}
|
||||
|
||||
getAllPreferences(): UserPreferences {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const preferences = JSON.parse(stored) as Partial<UserPreferences>;
|
||||
// Merge with defaults to ensure all preferences exist
|
||||
return {
|
||||
...DEFAULT_PREFERENCES,
|
||||
...preferences,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading preferences', error);
|
||||
}
|
||||
return { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
|
||||
clearAllPreferences(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error clearing preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const preferencesService = new PreferencesService();
|
||||
138
frontend/src/core/services/processingCache.ts
Normal file
138
frontend/src/core/services/processingCache.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { ProcessedFile, CacheConfig, CacheEntry, CacheStats } from '@app/types/processing';
|
||||
|
||||
export class ProcessingCache {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private totalSize = 0;
|
||||
|
||||
constructor(private config: CacheConfig = {
|
||||
maxFiles: 20,
|
||||
maxSizeBytes: 2 * 1024 * 1024 * 1024, // 2GB
|
||||
ttlMs: 30 * 60 * 1000 // 30 minutes
|
||||
}) {}
|
||||
|
||||
set(key: string, data: ProcessedFile): void {
|
||||
// Remove expired entries first
|
||||
this.cleanup();
|
||||
|
||||
// Calculate entry size (rough estimate)
|
||||
const size = this.calculateSize(data);
|
||||
|
||||
// Make room if needed
|
||||
this.makeRoom(size);
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
size,
|
||||
lastAccessed: Date.now(),
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
this.totalSize += size;
|
||||
}
|
||||
|
||||
get(key: string): ProcessedFile | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
// Check TTL
|
||||
if (Date.now() - entry.createdAt > this.config.ttlMs) {
|
||||
this.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last accessed
|
||||
entry.lastAccessed = Date.now();
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
|
||||
// Check TTL
|
||||
if (Date.now() - entry.createdAt > this.config.ttlMs) {
|
||||
this.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private makeRoom(neededSize: number): void {
|
||||
// Remove oldest entries until we have space
|
||||
while (
|
||||
this.cache.size >= this.config.maxFiles ||
|
||||
this.totalSize + neededSize > this.config.maxSizeBytes
|
||||
) {
|
||||
const oldestKey = this.findOldestEntry();
|
||||
if (oldestKey) {
|
||||
this.delete(oldestKey);
|
||||
} else break;
|
||||
}
|
||||
}
|
||||
|
||||
private findOldestEntry(): string | null {
|
||||
let oldest: { key: string; lastAccessed: number } | null = null;
|
||||
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (!oldest || entry.lastAccessed < oldest.lastAccessed) {
|
||||
oldest = { key, lastAccessed: entry.lastAccessed };
|
||||
}
|
||||
}
|
||||
|
||||
return oldest?.key || null;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (now - entry.createdAt > this.config.ttlMs) {
|
||||
this.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private calculateSize(data: ProcessedFile): number {
|
||||
// Rough size estimation
|
||||
let size = 0;
|
||||
|
||||
// Estimate size of thumbnails (main memory consumer)
|
||||
data.pages.forEach(page => {
|
||||
if (page.thumbnail) {
|
||||
// Base64 thumbnail is roughly 50KB each
|
||||
size += 50 * 1024;
|
||||
}
|
||||
});
|
||||
|
||||
// Add some overhead for other data
|
||||
size += 10 * 1024; // 10KB overhead
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) {
|
||||
this.totalSize -= entry.size;
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
this.totalSize = 0;
|
||||
}
|
||||
|
||||
getStats(): CacheStats {
|
||||
return {
|
||||
entries: this.cache.size,
|
||||
totalSizeBytes: this.totalSize,
|
||||
maxSizeBytes: this.config.maxSizeBytes
|
||||
};
|
||||
}
|
||||
|
||||
// Get all cached keys (for debugging and cleanup)
|
||||
getKeys(): string[] {
|
||||
return Array.from(this.cache.keys());
|
||||
}
|
||||
}
|
||||
282
frontend/src/core/services/processingErrorHandler.ts
Normal file
282
frontend/src/core/services/processingErrorHandler.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { ProcessingError } from '@app/types/processing';
|
||||
|
||||
export class ProcessingErrorHandler {
|
||||
private static readonly DEFAULT_MAX_RETRIES = 3;
|
||||
private static readonly RETRY_DELAYS = [1000, 2000, 4000]; // Progressive backoff in ms
|
||||
|
||||
/**
|
||||
* Create a ProcessingError from an unknown error
|
||||
*/
|
||||
static createProcessingError(
|
||||
error: unknown,
|
||||
retryCount: number = 0,
|
||||
maxRetries: number = this.DEFAULT_MAX_RETRIES
|
||||
): ProcessingError {
|
||||
const originalError = error instanceof Error ? error : new Error(String(error));
|
||||
const message = originalError.message;
|
||||
|
||||
// Determine error type based on error message and properties
|
||||
const errorType = this.determineErrorType(originalError, message);
|
||||
|
||||
// Determine if error is recoverable
|
||||
const recoverable = this.isRecoverable(errorType, retryCount, maxRetries);
|
||||
|
||||
return {
|
||||
type: errorType,
|
||||
message: this.formatErrorMessage(errorType, message),
|
||||
recoverable,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
originalError
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the type of error based on error characteristics
|
||||
*/
|
||||
private static determineErrorType(error: Error, message: string): ProcessingError['type'] {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
// Network-related errors
|
||||
if (lowerMessage.includes('network') ||
|
||||
lowerMessage.includes('fetch') ||
|
||||
lowerMessage.includes('connection')) {
|
||||
return 'network';
|
||||
}
|
||||
|
||||
// Memory-related errors
|
||||
if (lowerMessage.includes('memory') ||
|
||||
lowerMessage.includes('quota') ||
|
||||
lowerMessage.includes('allocation') ||
|
||||
error.name === 'QuotaExceededError') {
|
||||
return 'memory';
|
||||
}
|
||||
|
||||
// Timeout errors
|
||||
if (lowerMessage.includes('timeout') ||
|
||||
lowerMessage.includes('aborted') ||
|
||||
error.name === 'AbortError') {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
// Cancellation
|
||||
if (lowerMessage.includes('cancel') ||
|
||||
lowerMessage.includes('abort') ||
|
||||
error.name === 'AbortError') {
|
||||
return 'cancelled';
|
||||
}
|
||||
|
||||
// PDF corruption/parsing errors
|
||||
if (lowerMessage.includes('pdf') ||
|
||||
lowerMessage.includes('parse') ||
|
||||
lowerMessage.includes('invalid') ||
|
||||
lowerMessage.includes('corrupt') ||
|
||||
lowerMessage.includes('malformed')) {
|
||||
return 'corruption';
|
||||
}
|
||||
|
||||
// Default to parsing error
|
||||
return 'parsing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error is recoverable based on type and retry count
|
||||
*/
|
||||
private static isRecoverable(
|
||||
errorType: ProcessingError['type'],
|
||||
retryCount: number,
|
||||
maxRetries: number
|
||||
): boolean {
|
||||
// Never recoverable
|
||||
if (errorType === 'cancelled' || errorType === 'corruption') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recoverable if we haven't exceeded retry count
|
||||
if (retryCount >= maxRetries) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Memory errors are usually not recoverable
|
||||
if (errorType === 'memory') {
|
||||
return retryCount < 1; // Only one retry for memory errors
|
||||
}
|
||||
|
||||
// Network and timeout errors are usually recoverable
|
||||
return errorType === 'network' || errorType === 'timeout' || errorType === 'parsing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error message for user display
|
||||
*/
|
||||
private static formatErrorMessage(errorType: ProcessingError['type'], originalMessage: string): string {
|
||||
switch (errorType) {
|
||||
case 'network':
|
||||
return 'Network connection failed. Please check your internet connection and try again.';
|
||||
|
||||
case 'memory':
|
||||
return 'Insufficient memory to process this file. Try closing other applications or processing a smaller file.';
|
||||
|
||||
case 'timeout':
|
||||
return 'Processing timed out. This file may be too large or complex to process.';
|
||||
|
||||
case 'cancelled':
|
||||
return 'Processing was cancelled by user.';
|
||||
|
||||
case 'corruption':
|
||||
return 'This PDF file appears to be corrupted or encrypted. Please try a different file.';
|
||||
|
||||
case 'parsing':
|
||||
return `Failed to process PDF: ${originalMessage}`;
|
||||
|
||||
default:
|
||||
return `Processing failed: ${originalMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an operation with automatic retry logic
|
||||
*/
|
||||
static async executeWithRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
onError?: (error: ProcessingError) => void,
|
||||
maxRetries: number = this.DEFAULT_MAX_RETRIES
|
||||
): Promise<T> {
|
||||
let lastError: ProcessingError | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = this.createProcessingError(error, attempt, maxRetries);
|
||||
|
||||
// Notify error handler
|
||||
if (onError) {
|
||||
onError(lastError);
|
||||
}
|
||||
|
||||
// Don't retry if not recoverable
|
||||
if (!lastError.recoverable) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Don't retry on last attempt
|
||||
if (attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait before retry with progressive backoff
|
||||
const delay = this.RETRY_DELAYS[Math.min(attempt, this.RETRY_DELAYS.length - 1)];
|
||||
await this.delay(delay);
|
||||
|
||||
console.log(`Retrying operation (attempt ${attempt + 2}/${maxRetries + 1}) after ${delay}ms delay`);
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
throw lastError || new Error('Operation failed after all retries');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeout wrapper for operations
|
||||
*/
|
||||
static withTimeout<T>(
|
||||
operation: () => Promise<T>,
|
||||
timeoutMs: number,
|
||||
timeoutMessage: string = 'Operation timed out'
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(timeoutMessage));
|
||||
}, timeoutMs);
|
||||
|
||||
operation()
|
||||
.then(result => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(result);
|
||||
})
|
||||
.catch(error => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AbortController that times out after specified duration
|
||||
*/
|
||||
static createTimeoutController(timeoutMs: number): AbortController {
|
||||
const controller = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error indicates the operation should be retried
|
||||
*/
|
||||
static shouldRetry(error: ProcessingError): boolean {
|
||||
return error.recoverable && error.retryCount < error.maxRetries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly suggestions based on error type
|
||||
*/
|
||||
static getErrorSuggestions(error: ProcessingError): string[] {
|
||||
switch (error.type) {
|
||||
case 'network':
|
||||
return [
|
||||
'Check your internet connection',
|
||||
'Try refreshing the page',
|
||||
'Try again in a few moments'
|
||||
];
|
||||
|
||||
case 'memory':
|
||||
return [
|
||||
'Close other browser tabs or applications',
|
||||
'Try processing a smaller file',
|
||||
'Restart your browser',
|
||||
'Use a device with more memory'
|
||||
];
|
||||
|
||||
case 'timeout':
|
||||
return [
|
||||
'Try processing a smaller file',
|
||||
'Break large files into smaller sections',
|
||||
'Check your internet connection speed'
|
||||
];
|
||||
|
||||
case 'corruption':
|
||||
return [
|
||||
'Verify the PDF file opens in other applications',
|
||||
'Try re-downloading the file',
|
||||
'Try a different PDF file',
|
||||
'Contact the file creator if it appears corrupted'
|
||||
];
|
||||
|
||||
case 'parsing':
|
||||
return [
|
||||
'Verify this is a valid PDF file',
|
||||
'Try a different PDF file',
|
||||
'Contact support if the problem persists'
|
||||
];
|
||||
|
||||
default:
|
||||
return [
|
||||
'Try refreshing the page',
|
||||
'Try again in a few moments',
|
||||
'Contact support if the problem persists'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for delays
|
||||
*/
|
||||
private static delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
140
frontend/src/core/services/signatureDetectionService.ts
Normal file
140
frontend/src/core/services/signatureDetectionService.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Service for detecting signatures in PDF files using PDF.js
|
||||
* This provides a quick client-side check to determine if a PDF contains signatures
|
||||
* without needing to make API calls
|
||||
*/
|
||||
|
||||
// PDF.js types (simplified)
|
||||
declare global {
|
||||
interface Window {
|
||||
pdfjsLib?: any;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SignatureDetectionResult {
|
||||
hasSignatures: boolean;
|
||||
signatureCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FileSignatureStatus {
|
||||
file: File;
|
||||
result: SignatureDetectionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect signatures in a single PDF file using PDF.js
|
||||
*/
|
||||
const detectSignaturesInFile = async (file: File): Promise<SignatureDetectionResult> => {
|
||||
try {
|
||||
// Ensure PDF.js is available
|
||||
if (!window.pdfjsLib) {
|
||||
return {
|
||||
hasSignatures: false,
|
||||
error: 'PDF.js not available'
|
||||
};
|
||||
}
|
||||
|
||||
// Convert file to ArrayBuffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
// Load the PDF document
|
||||
const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
|
||||
let totalSignatures = 0;
|
||||
|
||||
// Check each page for signature annotations
|
||||
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
|
||||
const page = await pdf.getPage(pageNum);
|
||||
const annotations = await page.getAnnotations();
|
||||
|
||||
// Count signature annotations (Type: /Sig)
|
||||
const signatureAnnotations = annotations.filter((annotation: any) =>
|
||||
annotation.subtype === 'Widget' &&
|
||||
annotation.fieldType === 'Sig'
|
||||
);
|
||||
|
||||
totalSignatures += signatureAnnotations.length;
|
||||
}
|
||||
|
||||
// Also check for document-level signatures in AcroForm
|
||||
const metadata = await pdf.getMetadata();
|
||||
if (metadata?.info?.Signature || metadata?.metadata?.has('dc:signature')) {
|
||||
totalSignatures = Math.max(totalSignatures, 1);
|
||||
}
|
||||
|
||||
// Clean up PDF.js document
|
||||
pdf.destroy();
|
||||
|
||||
return {
|
||||
hasSignatures: totalSignatures > 0,
|
||||
signatureCount: totalSignatures
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.warn('PDF signature detection failed:', error);
|
||||
return {
|
||||
hasSignatures: false,
|
||||
signatureCount: 0,
|
||||
error: error instanceof Error ? error.message : 'Detection failed'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect if PDF files contain signatures using PDF.js client-side processing
|
||||
*/
|
||||
export const detectSignaturesInFiles = async (files: File[]): Promise<FileSignatureStatus[]> => {
|
||||
const results: FileSignatureStatus[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const result = await detectSignaturesInFile(file);
|
||||
results.push({ file, result });
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for managing signature detection state
|
||||
*/
|
||||
export const useSignatureDetection = () => {
|
||||
const [detectionResults, setDetectionResults] = React.useState<FileSignatureStatus[]>([]);
|
||||
const [isDetecting, setIsDetecting] = React.useState(false);
|
||||
|
||||
const detectSignatures = async (files: File[]) => {
|
||||
if (files.length === 0) {
|
||||
setDetectionResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDetecting(true);
|
||||
try {
|
||||
const results = await detectSignaturesInFiles(files);
|
||||
setDetectionResults(results);
|
||||
} finally {
|
||||
setIsDetecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileSignatureStatus = (file: File): SignatureDetectionResult | null => {
|
||||
const result = detectionResults.find(r => r.file === file);
|
||||
return result ? result.result : null;
|
||||
};
|
||||
|
||||
const hasAnySignatures = detectionResults.some(r => r.result.hasSignatures);
|
||||
const totalSignatures = detectionResults.reduce((sum, r) => sum + (r.result.signatureCount || 0), 0);
|
||||
|
||||
return {
|
||||
detectionResults,
|
||||
isDetecting,
|
||||
detectSignatures,
|
||||
getFileSignatureStatus,
|
||||
hasAnySignatures,
|
||||
totalSignatures,
|
||||
reset: () => setDetectionResults([])
|
||||
};
|
||||
};
|
||||
|
||||
// Import React for the hook
|
||||
import React from 'react';
|
||||
57
frontend/src/core/services/specialErrorToasts.ts
Normal file
57
frontend/src/core/services/specialErrorToasts.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { alert } from '@app/components/toast';
|
||||
|
||||
interface ErrorToastMapping {
|
||||
regex: RegExp;
|
||||
i18nKey: string;
|
||||
defaultMessage: string;
|
||||
}
|
||||
|
||||
// Centralized list of special backend error message patterns → friendly, translated toasts
|
||||
const MAPPINGS: ErrorToastMapping[] = [
|
||||
{
|
||||
regex: /pdf contains an encryption dictionary/i,
|
||||
i18nKey: 'errors.encryptedPdfMustRemovePassword',
|
||||
defaultMessage: 'This PDF is encrypted. Please unlock it using the Unlock PDF Forms tool.'
|
||||
},
|
||||
{
|
||||
regex: /the pdf document is passworded and either the password was not provided or was incorrect/i,
|
||||
i18nKey: 'errors.incorrectPasswordProvided',
|
||||
defaultMessage: 'The PDF password is incorrect or not provided.'
|
||||
},
|
||||
];
|
||||
|
||||
function titleForStatus(status?: number): string {
|
||||
if (!status) return 'Network error';
|
||||
if (status >= 500) return 'Server error';
|
||||
if (status >= 400) return 'Request error';
|
||||
return 'Request failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a raw backend error string against known patterns and show a friendly toast.
|
||||
* Returns true if a special toast was shown, false otherwise.
|
||||
*/
|
||||
export function showSpecialErrorToast(rawError: string | undefined, options?: { status?: number }): boolean {
|
||||
const message = (rawError || '').toString();
|
||||
if (!message) return false;
|
||||
|
||||
for (const mapping of MAPPINGS) {
|
||||
if (mapping.regex.test(message)) {
|
||||
// Best-effort translation without hard dependency on i18n config
|
||||
let body = mapping.defaultMessage;
|
||||
try {
|
||||
const anyGlobal: any = (globalThis as any);
|
||||
const i18next = anyGlobal?.i18next;
|
||||
if (i18next && typeof i18next.t === 'function') {
|
||||
body = i18next.t(mapping.i18nKey, { defaultValue: mapping.defaultMessage });
|
||||
}
|
||||
} catch { /* ignore translation errors */ }
|
||||
const title = titleForStatus(options?.status);
|
||||
alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
316
frontend/src/core/services/thumbnailGenerationService.ts
Normal file
316
frontend/src/core/services/thumbnailGenerationService.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* High-performance thumbnail generation service using main thread processing
|
||||
*/
|
||||
|
||||
import { FileId } from '@app/types/file';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
|
||||
interface ThumbnailResult {
|
||||
pageNumber: number;
|
||||
thumbnail: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ThumbnailGenerationOptions {
|
||||
scale?: number;
|
||||
quality?: number;
|
||||
batchSize?: number;
|
||||
parallelBatches?: number;
|
||||
}
|
||||
|
||||
interface CachedThumbnail {
|
||||
thumbnail: string;
|
||||
lastUsed: number;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
interface CachedPDFDocument {
|
||||
pdf: PDFDocumentProxy;
|
||||
lastUsed: number;
|
||||
refCount: number;
|
||||
}
|
||||
|
||||
export class ThumbnailGenerationService {
|
||||
// Session-based thumbnail cache
|
||||
private thumbnailCache = new Map<FileId | string /* FIX ME: Page ID */, CachedThumbnail>();
|
||||
private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit
|
||||
private currentCacheSize = 0;
|
||||
|
||||
// PDF document cache to reuse PDF instances and avoid creating multiple workers
|
||||
private pdfDocumentCache = new Map<FileId, CachedPDFDocument>();
|
||||
private maxPdfCacheSize = 10; // Keep up to 10 PDF documents cached
|
||||
|
||||
constructor(private maxWorkers: number = 10) {
|
||||
// PDF rendering requires DOM access, so we use optimized main thread processing
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a cached PDF document
|
||||
*/
|
||||
private async getCachedPDFDocument(fileId: FileId, pdfArrayBuffer: ArrayBuffer): Promise<any> {
|
||||
const cached = this.pdfDocumentCache.get(fileId);
|
||||
if (cached) {
|
||||
cached.lastUsed = Date.now();
|
||||
cached.refCount++;
|
||||
return cached.pdf;
|
||||
}
|
||||
|
||||
// Evict old PDFs if cache is full
|
||||
while (this.pdfDocumentCache.size >= this.maxPdfCacheSize) {
|
||||
this.evictLeastRecentlyUsedPDF();
|
||||
}
|
||||
|
||||
// Use centralized worker manager instead of direct getDocument
|
||||
const pdf = await pdfWorkerManager.createDocument(pdfArrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
stopAtErrors: false
|
||||
});
|
||||
|
||||
this.pdfDocumentCache.set(fileId, {
|
||||
pdf,
|
||||
lastUsed: Date.now(),
|
||||
refCount: 1
|
||||
});
|
||||
|
||||
return pdf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a reference to a cached PDF document
|
||||
*/
|
||||
private releasePDFDocument(fileId: FileId): void {
|
||||
const cached = this.pdfDocumentCache.get(fileId);
|
||||
if (cached) {
|
||||
cached.refCount--;
|
||||
// Don't destroy immediately - keep in cache for potential reuse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict the least recently used PDF document
|
||||
*/
|
||||
private evictLeastRecentlyUsedPDF(): void {
|
||||
let oldestEntry: [FileId, CachedPDFDocument] | null = null;
|
||||
let oldestTime = Date.now();
|
||||
|
||||
for (const [key, value] of this.pdfDocumentCache.entries()) {
|
||||
if (value.lastUsed < oldestTime && value.refCount === 0) {
|
||||
oldestTime = value.lastUsed;
|
||||
oldestEntry = [key, value];
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestEntry) {
|
||||
pdfWorkerManager.destroyDocument(oldestEntry[1].pdf); // Use worker manager for cleanup
|
||||
this.pdfDocumentCache.delete(oldestEntry[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnails for multiple pages using main thread processing
|
||||
*/
|
||||
async generateThumbnails(
|
||||
fileId: FileId,
|
||||
pdfArrayBuffer: ArrayBuffer,
|
||||
pageNumbers: number[],
|
||||
options: ThumbnailGenerationOptions = {},
|
||||
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
||||
): Promise<ThumbnailResult[]> {
|
||||
// Input validation
|
||||
if (!fileId || typeof fileId !== 'string' || fileId.trim() === '') {
|
||||
throw new Error('generateThumbnails: fileId must be a non-empty string');
|
||||
}
|
||||
|
||||
if (!pdfArrayBuffer || pdfArrayBuffer.byteLength === 0) {
|
||||
throw new Error('generateThumbnails: pdfArrayBuffer must not be empty');
|
||||
}
|
||||
|
||||
if (!pageNumbers || pageNumbers.length === 0) {
|
||||
throw new Error('generateThumbnails: pageNumbers must not be empty');
|
||||
}
|
||||
|
||||
const {
|
||||
scale = 0.2,
|
||||
quality = 0.8
|
||||
} = options;
|
||||
|
||||
return await this.generateThumbnailsMainThread(fileId, pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main thread thumbnail generation with batching for UI responsiveness
|
||||
*/
|
||||
private async generateThumbnailsMainThread(
|
||||
fileId: FileId,
|
||||
pdfArrayBuffer: ArrayBuffer,
|
||||
pageNumbers: number[],
|
||||
scale: number,
|
||||
quality: number,
|
||||
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
||||
): Promise<ThumbnailResult[]> {
|
||||
const pdf = await this.getCachedPDFDocument(fileId, pdfArrayBuffer);
|
||||
|
||||
const allResults: ThumbnailResult[] = [];
|
||||
let completed = 0;
|
||||
const batchSize = 3; // Smaller batches for better UI responsiveness
|
||||
|
||||
// Process pages in small batches
|
||||
for (let i = 0; i < pageNumbers.length; i += batchSize) {
|
||||
const batch = pageNumbers.slice(i, i + batchSize);
|
||||
|
||||
// Process batch sequentially (to avoid canvas conflicts)
|
||||
for (const pageNumber of batch) {
|
||||
try {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale, rotation: 0 });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL('image/jpeg', quality);
|
||||
|
||||
allResults.push({ pageNumber, thumbnail, success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for page ${pageNumber}:`, error);
|
||||
allResults.push({
|
||||
pageNumber,
|
||||
thumbnail: '',
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
completed += batch.length;
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
completed,
|
||||
total: pageNumbers.length,
|
||||
thumbnails: allResults.slice(-batch.length).filter(r => r.success)
|
||||
});
|
||||
}
|
||||
|
||||
// Yield control to prevent UI blocking
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
}
|
||||
|
||||
// Release reference to PDF document (don't destroy - keep in cache)
|
||||
this.releasePDFDocument(fileId);
|
||||
|
||||
this.cleanupCompletedDocument(fileId);
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache management
|
||||
*/
|
||||
getThumbnailFromCache(pageId: string): string | null {
|
||||
const cached = this.thumbnailCache.get(pageId);
|
||||
if (cached) {
|
||||
cached.lastUsed = Date.now();
|
||||
return cached.thumbnail;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
addThumbnailToCache(pageId: string, thumbnail: string): void {
|
||||
const sizeBytes = thumbnail.length * 2; // Rough estimate for base64 string
|
||||
|
||||
// Enforce cache size limits
|
||||
while (this.currentCacheSize + sizeBytes > this.maxCacheSizeBytes && this.thumbnailCache.size > 0) {
|
||||
this.evictLeastRecentlyUsed();
|
||||
}
|
||||
|
||||
this.thumbnailCache.set(pageId, {
|
||||
thumbnail,
|
||||
lastUsed: Date.now(),
|
||||
sizeBytes
|
||||
});
|
||||
|
||||
this.currentCacheSize += sizeBytes;
|
||||
}
|
||||
|
||||
private evictLeastRecentlyUsed(): void {
|
||||
let oldestEntry: [string, CachedThumbnail] | null = null;
|
||||
let oldestTime = Date.now();
|
||||
|
||||
for (const [key, value] of this.thumbnailCache.entries()) {
|
||||
if (value.lastUsed < oldestTime) {
|
||||
oldestTime = value.lastUsed;
|
||||
oldestEntry = [key, value];
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestEntry) {
|
||||
this.thumbnailCache.delete(oldestEntry[0]);
|
||||
this.currentCacheSize -= oldestEntry[1].sizeBytes;
|
||||
}
|
||||
}
|
||||
|
||||
getCacheStats() {
|
||||
return {
|
||||
size: this.thumbnailCache.size,
|
||||
sizeBytes: this.currentCacheSize,
|
||||
maxSizeBytes: this.maxCacheSizeBytes
|
||||
};
|
||||
}
|
||||
|
||||
stopGeneration(): void {
|
||||
// No-op since we removed workers
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.thumbnailCache.clear();
|
||||
this.currentCacheSize = 0;
|
||||
}
|
||||
|
||||
clearPDFCache(): void {
|
||||
// Destroy all cached PDF documents using worker manager
|
||||
for (const [, cached] of this.pdfDocumentCache) {
|
||||
pdfWorkerManager.destroyDocument(cached.pdf);
|
||||
}
|
||||
this.pdfDocumentCache.clear();
|
||||
}
|
||||
|
||||
clearPDFCacheForFile(fileId: FileId): void {
|
||||
const cached = this.pdfDocumentCache.get(fileId);
|
||||
if (cached) {
|
||||
pdfWorkerManager.destroyDocument(cached.pdf);
|
||||
this.pdfDocumentCache.delete(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a PDF document from cache when thumbnail generation is complete
|
||||
* This frees up workers faster for better performance
|
||||
*/
|
||||
cleanupCompletedDocument(fileId: FileId): void {
|
||||
const cached = this.pdfDocumentCache.get(fileId);
|
||||
if (cached && cached.refCount <= 0) {
|
||||
pdfWorkerManager.destroyDocument(cached.pdf);
|
||||
this.pdfDocumentCache.delete(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.clearCache();
|
||||
this.clearPDFCache();
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
export const thumbnailGenerationService = new ThumbnailGenerationService();
|
||||
663
frontend/src/core/services/zipFileService.ts
Normal file
663
frontend/src/core/services/zipFileService.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
import JSZip, { JSZipObject } from 'jszip';
|
||||
import { StirlingFileStub, createStirlingFile } from '@app/types/fileContext';
|
||||
import { generateThumbnailForFile } from '@app/utils/thumbnailUtils';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
|
||||
// Undocumented interface in JSZip for JSZipObject._data
|
||||
interface CompressedObject {
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
crc32: number;
|
||||
compression: object;
|
||||
compressedContent: string|ArrayBuffer|Uint8Array|Buffer;
|
||||
}
|
||||
|
||||
const getData = (zipEntry: JSZipObject): CompressedObject | undefined => {
|
||||
return (zipEntry as any)._data as CompressedObject;
|
||||
};
|
||||
|
||||
export interface ZipExtractionResult {
|
||||
success: boolean;
|
||||
extractedFiles: File[];
|
||||
errors: string[];
|
||||
totalFiles: number;
|
||||
extractedCount: number;
|
||||
}
|
||||
|
||||
export interface ZipValidationResult {
|
||||
isValid: boolean;
|
||||
fileCount: number;
|
||||
totalSizeBytes: number;
|
||||
containsPDFs: boolean;
|
||||
containsFiles: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ZipExtractionProgress {
|
||||
currentFile: string;
|
||||
extractedCount: number;
|
||||
totalFiles: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export class ZipFileService {
|
||||
private readonly maxFileSize = 100 * 1024 * 1024; // 100MB per file
|
||||
private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit
|
||||
|
||||
// ZIP file validation constants
|
||||
private static readonly VALID_ZIP_TYPES = [
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-zip',
|
||||
'application/octet-stream' // Some browsers use this for ZIP files
|
||||
];
|
||||
private static readonly VALID_ZIP_EXTENSIONS = ['.zip'];
|
||||
|
||||
/**
|
||||
* Validate a ZIP file without extracting it
|
||||
*/
|
||||
async validateZipFile(file: File): Promise<ZipValidationResult> {
|
||||
const result: ZipValidationResult = {
|
||||
isValid: false,
|
||||
fileCount: 0,
|
||||
totalSizeBytes: 0,
|
||||
containsPDFs: false,
|
||||
containsFiles: false,
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Check file size
|
||||
if (file.size > this.maxTotalSize) {
|
||||
result.errors.push(`ZIP file too large: ${this.formatFileSize(file.size)} (max: ${this.formatFileSize(this.maxTotalSize)})`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if (!this.isZipFile(file)) {
|
||||
result.errors.push('File is not a valid ZIP archive');
|
||||
return result;
|
||||
}
|
||||
|
||||
// Load and validate ZIP contents
|
||||
const zip = new JSZip();
|
||||
const zipContents = await zip.loadAsync(file);
|
||||
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
let containsPDFs = false;
|
||||
|
||||
// Analyze ZIP contents
|
||||
for (const [filename, zipEntry] of Object.entries(zipContents.files)) {
|
||||
if (zipEntry.dir) {
|
||||
continue; // Skip directories
|
||||
}
|
||||
|
||||
fileCount++;
|
||||
const uncompressedSize = getData(zipEntry)?.uncompressedSize || 0;
|
||||
totalSize += uncompressedSize;
|
||||
|
||||
// Check if file is a PDF
|
||||
if (this.isPdfFile(filename)) {
|
||||
containsPDFs = true;
|
||||
}
|
||||
|
||||
// Check individual file size
|
||||
if (uncompressedSize > this.maxFileSize) {
|
||||
result.errors.push(`File "${filename}" too large: ${this.formatFileSize(uncompressedSize)} (max: ${this.formatFileSize(this.maxFileSize)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check total uncompressed size
|
||||
if (totalSize > this.maxTotalSize) {
|
||||
result.errors.push(`Total uncompressed size too large: ${this.formatFileSize(totalSize)} (max: ${this.formatFileSize(this.maxTotalSize)})`);
|
||||
}
|
||||
|
||||
result.fileCount = fileCount;
|
||||
result.totalSizeBytes = totalSize;
|
||||
result.containsPDFs = containsPDFs;
|
||||
result.containsFiles = fileCount > 0;
|
||||
|
||||
// ZIP is valid if it has files and no size errors
|
||||
result.isValid = result.errors.length === 0 && result.containsFiles;
|
||||
|
||||
if (!result.containsFiles) {
|
||||
result.errors.push('ZIP file does not contain any files');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
result.errors.push(`Failed to validate ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ZIP file from an array of files
|
||||
*/
|
||||
async createZipFromFiles(files: File[], zipFilename: string): Promise<{ zipFile: File; size: number }> {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
|
||||
// Add each file to the ZIP
|
||||
for (const file of files) {
|
||||
const content = await file.arrayBuffer();
|
||||
zip.file(file.name, content);
|
||||
}
|
||||
|
||||
// Generate ZIP blob
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
});
|
||||
|
||||
const zipFile = new File([zipBlob], zipFilename, {
|
||||
type: 'application/zip',
|
||||
lastModified: Date.now()
|
||||
});
|
||||
|
||||
return { zipFile, size: zipFile.size };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract PDF files from a ZIP archive
|
||||
*/
|
||||
async extractPdfFiles(
|
||||
file: File,
|
||||
onProgress?: (progress: ZipExtractionProgress) => void
|
||||
): Promise<ZipExtractionResult> {
|
||||
const result: ZipExtractionResult = {
|
||||
success: false,
|
||||
extractedFiles: [],
|
||||
errors: [],
|
||||
totalFiles: 0,
|
||||
extractedCount: 0
|
||||
};
|
||||
|
||||
try {
|
||||
// Validate ZIP file first
|
||||
const validation = await this.validateZipFile(file);
|
||||
if (!validation.isValid) {
|
||||
result.errors = validation.errors;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Load ZIP contents
|
||||
const zip = new JSZip();
|
||||
const zipContents = await zip.loadAsync(file);
|
||||
|
||||
// Get all PDF files
|
||||
const pdfFiles = Object.entries(zipContents.files).filter(([filename, zipEntry]) =>
|
||||
!zipEntry.dir && this.isPdfFile(filename)
|
||||
);
|
||||
|
||||
result.totalFiles = pdfFiles.length;
|
||||
|
||||
// Extract each PDF file
|
||||
for (let i = 0; i < pdfFiles.length; i++) {
|
||||
const [filename, zipEntry] = pdfFiles[i];
|
||||
|
||||
try {
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentFile: filename,
|
||||
extractedCount: i,
|
||||
totalFiles: pdfFiles.length,
|
||||
progress: (i / pdfFiles.length) * 100
|
||||
});
|
||||
}
|
||||
|
||||
// Extract file content
|
||||
const content = await zipEntry.async('uint8array');
|
||||
|
||||
// Create File object
|
||||
const extractedFile = new File([content as any], this.sanitizeFilename(filename), {
|
||||
type: 'application/pdf',
|
||||
lastModified: zipEntry.date?.getTime() || Date.now()
|
||||
});
|
||||
|
||||
// Validate extracted PDF
|
||||
if (await this.isValidPdfFile(extractedFile)) {
|
||||
result.extractedFiles.push(extractedFile);
|
||||
result.extractedCount++;
|
||||
} else {
|
||||
result.errors.push(`File "${filename}" is not a valid PDF`);
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push(`Failed to extract "${filename}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress report
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentFile: '',
|
||||
extractedCount: result.extractedCount,
|
||||
totalFiles: result.totalFiles,
|
||||
progress: 100
|
||||
});
|
||||
}
|
||||
|
||||
result.success = result.extractedCount > 0;
|
||||
return result;
|
||||
} catch (error) {
|
||||
result.errors.push(`Failed to extract ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a ZIP file based on type and extension
|
||||
*/
|
||||
public isZipFile(file: File): boolean {
|
||||
const hasValidType = ZipFileService.VALID_ZIP_TYPES.includes(file.type);
|
||||
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some(ext =>
|
||||
file.name.toLowerCase().endsWith(ext)
|
||||
);
|
||||
|
||||
return hasValidType || hasValidExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a StirlingFileStub represents a ZIP file (for UI checks without loading full file)
|
||||
*/
|
||||
public isZipFileStub(stub: StirlingFileStub): boolean {
|
||||
const hasValidType = stub.type && ZipFileService.VALID_ZIP_TYPES.includes(stub.type);
|
||||
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some(ext =>
|
||||
stub.name.toLowerCase().endsWith(ext)
|
||||
);
|
||||
|
||||
return hasValidType || hasValidExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename indicates a PDF file
|
||||
*/
|
||||
private isPdfFile(filename: string): boolean {
|
||||
return filename.toLowerCase().endsWith('.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename indicates an HTML file
|
||||
*/
|
||||
private isHtmlFile(filename: string): boolean {
|
||||
const lowerName = filename.toLowerCase();
|
||||
return lowerName.endsWith('.html') || lowerName.endsWith('.htm') || lowerName.endsWith('.xhtml');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a ZIP file contains HTML files
|
||||
* Used to determine if the ZIP should be kept intact (HTML) or extracted (other files)
|
||||
*/
|
||||
async containsHtmlFiles(file: Blob | File): Promise<boolean> {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
const zipContents = await zip.loadAsync(file);
|
||||
|
||||
// Check if any file is an HTML file
|
||||
for (const [filename, zipEntry] of Object.entries(zipContents.files)) {
|
||||
if (!zipEntry.dir && this.isHtmlFile(filename)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error checking for HTML files:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a file is actually a PDF by checking its header
|
||||
*/
|
||||
private async isValidPdfFile(file: File): Promise<boolean> {
|
||||
try {
|
||||
// Read first few bytes to check PDF header
|
||||
const buffer = await file.slice(0, 8).arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
// Check for PDF header: %PDF-
|
||||
return bytes[0] === 0x25 && // %
|
||||
bytes[1] === 0x50 && // P
|
||||
bytes[2] === 0x44 && // D
|
||||
bytes[3] === 0x46 && // F
|
||||
bytes[4] === 0x2D; // -
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename for safe use
|
||||
*/
|
||||
private sanitizeFilename(filename: string): string {
|
||||
// Remove directory path and get just the filename
|
||||
const basename = filename.split('/').pop() || filename;
|
||||
|
||||
// Remove or replace unsafe characters
|
||||
return basename
|
||||
.replace(/[<>:"/\\|?*]/g, '_') // Replace unsafe chars with underscore
|
||||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
|
||||
.replace(/^_|_$/g, ''); // Remove leading/trailing underscores
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
private formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a ZIP file should be extracted based on user preferences
|
||||
*
|
||||
* @param zipBlob - The ZIP file to check
|
||||
* @param autoUnzip - User preference for auto-unzipping
|
||||
* @param autoUnzipFileLimit - Maximum number of files to auto-extract
|
||||
* @param skipAutoUnzip - Bypass preference check (for automation)
|
||||
* @returns true if the ZIP should be extracted, false otherwise
|
||||
*/
|
||||
async shouldUnzip(
|
||||
zipBlob: Blob | File,
|
||||
autoUnzip: boolean,
|
||||
autoUnzipFileLimit: number,
|
||||
skipAutoUnzip: boolean = false
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Automation always extracts
|
||||
if (skipAutoUnzip) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if auto-unzip is enabled
|
||||
if (!autoUnzip) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load ZIP and count files
|
||||
const zip = new JSZip();
|
||||
const zipContents = await zip.loadAsync(zipBlob);
|
||||
|
||||
// Count non-directory entries
|
||||
const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length;
|
||||
|
||||
// Only extract if within limit
|
||||
return fileCount <= autoUnzipFileLimit;
|
||||
} catch (error) {
|
||||
console.error('Error checking shouldUnzip:', error);
|
||||
// On error, default to not extracting (safer)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract files from ZIP with HTML detection and preference checking
|
||||
* This is the unified method that handles the common pattern of:
|
||||
* 1. Check for HTML files → keep zipped if present
|
||||
* 2. Check user preferences → respect autoUnzipFileLimit
|
||||
* 3. Extract files if appropriate
|
||||
*
|
||||
* @param zipBlob - The ZIP blob to process
|
||||
* @param options - Extraction options
|
||||
* @returns Array of files (either extracted or the ZIP itself)
|
||||
*/
|
||||
async extractWithPreferences(
|
||||
zipBlob: Blob,
|
||||
options: {
|
||||
autoUnzip: boolean;
|
||||
autoUnzipFileLimit: number;
|
||||
skipAutoUnzip?: boolean;
|
||||
}
|
||||
): Promise<File[]> {
|
||||
try {
|
||||
// Create File object if not already
|
||||
const zipFile = zipBlob instanceof File
|
||||
? zipBlob
|
||||
: new File([zipBlob], 'result.zip', { type: 'application/zip' });
|
||||
|
||||
// Check if ZIP contains HTML files - if so, keep as ZIP
|
||||
const containsHtml = await this.containsHtmlFiles(zipFile);
|
||||
if (containsHtml) {
|
||||
return [zipFile];
|
||||
}
|
||||
|
||||
// Check if we should extract based on preferences
|
||||
const shouldExtract = await this.shouldUnzip(
|
||||
zipBlob,
|
||||
options.autoUnzip,
|
||||
options.autoUnzipFileLimit,
|
||||
options.skipAutoUnzip || false
|
||||
);
|
||||
|
||||
if (!shouldExtract) {
|
||||
return [zipFile];
|
||||
}
|
||||
|
||||
// Extract all files
|
||||
const extractionResult = await this.extractAllFiles(zipFile);
|
||||
return extractionResult.success ? extractionResult.extractedFiles : [zipFile];
|
||||
} catch (error) {
|
||||
console.error('Error in extractWithPreferences:', error);
|
||||
// On error, return ZIP as-is
|
||||
const zipFile = zipBlob instanceof File
|
||||
? zipBlob
|
||||
: new File([zipBlob], 'result.zip', { type: 'application/zip' });
|
||||
return [zipFile];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all files from a ZIP archive (not limited to PDFs)
|
||||
*/
|
||||
async extractAllFiles(
|
||||
file: File | Blob,
|
||||
onProgress?: (progress: ZipExtractionProgress) => void
|
||||
): Promise<ZipExtractionResult> {
|
||||
const result: ZipExtractionResult = {
|
||||
success: false,
|
||||
extractedFiles: [],
|
||||
errors: [],
|
||||
totalFiles: 0,
|
||||
extractedCount: 0
|
||||
};
|
||||
|
||||
try {
|
||||
// Load ZIP contents
|
||||
const zip = new JSZip();
|
||||
const zipContents = await zip.loadAsync(file);
|
||||
|
||||
// Get all files (not directories)
|
||||
const allFiles = Object.entries(zipContents.files).filter(([, zipEntry]) =>
|
||||
!zipEntry.dir
|
||||
);
|
||||
|
||||
result.totalFiles = allFiles.length;
|
||||
|
||||
// Extract each file
|
||||
for (let i = 0; i < allFiles.length; i++) {
|
||||
const [filename, zipEntry] = allFiles[i];
|
||||
|
||||
try {
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentFile: filename,
|
||||
extractedCount: i,
|
||||
totalFiles: allFiles.length,
|
||||
progress: (i / allFiles.length) * 100
|
||||
});
|
||||
}
|
||||
|
||||
// Extract file content
|
||||
const content = await zipEntry.async('blob');
|
||||
|
||||
// Create File object with appropriate MIME type
|
||||
const mimeType = this.getMimeTypeFromExtension(filename);
|
||||
const extractedFile = new File([content], filename, { type: mimeType });
|
||||
|
||||
result.extractedFiles.push(extractedFile);
|
||||
result.extractedCount++;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
result.errors.push(`Failed to extract "${filename}": ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress report
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentFile: '',
|
||||
extractedCount: result.extractedCount,
|
||||
totalFiles: result.totalFiles,
|
||||
progress: 100
|
||||
});
|
||||
}
|
||||
|
||||
result.success = result.extractedFiles.length > 0;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
result.errors.push(`Failed to process ZIP file: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type based on file extension
|
||||
*/
|
||||
private getMimeTypeFromExtension(fileName: string): string {
|
||||
const ext = fileName.toLowerCase().split('.').pop();
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
// Images
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp',
|
||||
'svg': 'image/svg+xml',
|
||||
'tiff': 'image/tiff',
|
||||
'tif': 'image/tiff',
|
||||
|
||||
// Documents
|
||||
'pdf': 'application/pdf',
|
||||
'txt': 'text/plain',
|
||||
'html': 'text/html',
|
||||
'css': 'text/css',
|
||||
'js': 'application/javascript',
|
||||
'json': 'application/json',
|
||||
'xml': 'application/xml',
|
||||
|
||||
// Office documents
|
||||
'doc': 'application/msword',
|
||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xls': 'application/vnd.ms-excel',
|
||||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
|
||||
// Archives
|
||||
'zip': 'application/zip',
|
||||
'rar': 'application/x-rar-compressed',
|
||||
};
|
||||
|
||||
return mimeTypes[ext || ''] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all files from ZIP and store them in IndexedDB with preserved history metadata
|
||||
* Used by both FileManager and FileEditor to avoid code duplication
|
||||
*
|
||||
* Note: HTML files will NOT be extracted - the ZIP is kept intact when HTML is detected
|
||||
*
|
||||
* @param zipFile - The ZIP file to extract from
|
||||
* @param zipStub - The StirlingFileStub for the ZIP (contains metadata to preserve)
|
||||
* @returns Object with success status, extracted stubs, and any errors
|
||||
*/
|
||||
async extractAndStoreFilesWithHistory(
|
||||
zipFile: File,
|
||||
zipStub: StirlingFileStub
|
||||
): Promise<{ success: boolean; extractedStubs: StirlingFileStub[]; errors: string[] }> {
|
||||
const result = {
|
||||
success: false,
|
||||
extractedStubs: [] as StirlingFileStub[],
|
||||
errors: [] as string[]
|
||||
};
|
||||
|
||||
try {
|
||||
// Check if ZIP contains HTML files - if so, don't extract
|
||||
const hasHtml = await this.containsHtmlFiles(zipFile);
|
||||
if (hasHtml) {
|
||||
result.errors.push('ZIP contains HTML files and will not be auto-extracted. Download the ZIP to access the files.');
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract all files from ZIP (not just PDFs)
|
||||
const extractionResult = await this.extractAllFiles(zipFile);
|
||||
|
||||
if (!extractionResult.success || extractionResult.extractedFiles.length === 0) {
|
||||
result.errors = extractionResult.errors;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Process each extracted file
|
||||
for (const extractedFile of extractionResult.extractedFiles) {
|
||||
try {
|
||||
// Generate thumbnail (works for PDFs and images)
|
||||
const thumbnail = await generateThumbnailForFile(extractedFile);
|
||||
|
||||
// Create StirlingFile
|
||||
const newStirlingFile = createStirlingFile(extractedFile);
|
||||
|
||||
// Create StirlingFileStub with ZIP's history metadata
|
||||
const stub: StirlingFileStub = {
|
||||
id: newStirlingFile.fileId,
|
||||
name: extractedFile.name,
|
||||
size: extractedFile.size,
|
||||
type: extractedFile.type,
|
||||
lastModified: extractedFile.lastModified,
|
||||
quickKey: newStirlingFile.quickKey,
|
||||
createdAt: Date.now(),
|
||||
isLeaf: true,
|
||||
// Preserve ZIP's history - unzipping is NOT a tool operation
|
||||
originalFileId: zipStub.originalFileId,
|
||||
parentFileId: zipStub.parentFileId,
|
||||
versionNumber: zipStub.versionNumber,
|
||||
toolHistory: zipStub.toolHistory || [],
|
||||
thumbnailUrl: thumbnail
|
||||
};
|
||||
|
||||
// Store in IndexedDB
|
||||
await fileStorage.storeStirlingFile(newStirlingFile, stub);
|
||||
|
||||
result.extractedStubs.push(stub);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
result.errors.push(`Failed to process "${extractedFile.name}": ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
result.success = result.extractedStubs.length > 0;
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
result.errors.push(`Failed to extract ZIP file: ${errorMessage}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const zipFileService = new ZipFileService();
|
||||
Reference in New Issue
Block a user