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:
James Brunton
2025-10-28 10:29:36 +00:00
committed by GitHub
parent 960d48f80c
commit d2b38ef4b8
725 changed files with 2485 additions and 2226 deletions

View 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;

View File

@@ -0,0 +1,5 @@
import { AxiosInstance } from 'axios';
export function setupApiInterceptors(_client: AxiosInstance): void {
// Core version: no interceptors to add
}

View 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();

View 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();

View 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();

View 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);
}

View 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;
}
}
}

View 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();

View 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;
}

View 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 };
}

View 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,
};
}

View 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
}

View 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();

View 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' })];
}
}

View 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();

View 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
};
}

View 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();

View 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();

View 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();

View 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());
}
}

View 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));
}
}

View 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';

View 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;
}

View 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();

View 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();