diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx
index e82483898..94c2e0b8f 100644
--- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx
+++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx
@@ -51,7 +51,7 @@ const FileEditorThumbnail = ({
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
- const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
+ const { pinFile, unpinFile, isFilePinned, activeFiles, selectors } = useFileContext();
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
@@ -65,6 +65,13 @@ const FileEditorThumbnail = ({
}, [activeFiles, file.name, file.size]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
+ // Get file record to access tool history
+ const fileRecord = selectors.getFileRecord(file.id);
+ const toolHistory = fileRecord?.toolHistory || [];
+ const hasToolHistory = toolHistory.length > 0;
+ const versionNumber = fileRecord?.versionNumber || 0;
+
+
const downloadSelectedFile = useCallback(() => {
// Prefer parent-provided handler if available
if (typeof onDownloadFile === 'function') {
@@ -351,7 +358,8 @@ const FileEditorThumbnail = ({
lineClamp={3}
title={`${extUpper || 'FILE'} • ${prettySize}`}
>
- {/* e.g., Jan 29, 2025 - PDF file - 3 Pages */}
+ {/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
+ {hasToolHistory ? ` v${versionNumber} - ` : ''}
{dateLabel}
{extUpper ? ` - ${extUpper} file` : ''}
{pageLabel ? ` - ${pageLabel}` : ''}
@@ -400,6 +408,26 @@ const FileEditorThumbnail = ({
+
+ {/* Tool chain display at bottom */}
+ {hasToolHistory && (
+
+ {toolHistory.map(tool => tool.toolName).join(' → ')}
+
+ )}
);
diff --git a/frontend/src/contexts/IndexedDBContext.tsx b/frontend/src/contexts/IndexedDBContext.tsx
index 544ddd41f..bf72d55c3 100644
--- a/frontend/src/contexts/IndexedDBContext.tsx
+++ b/frontend/src/contexts/IndexedDBContext.tsx
@@ -142,10 +142,40 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const loadAllMetadata = useCallback(async (): Promise => {
const metadata = await fileStorage.getAllFileMetadata();
- // For each PDF file, extract history metadata
- const metadataWithHistory = await Promise.all(metadata.map(async (m) => {
- // For non-PDF files, return basic metadata
- if (!m.type.includes('pdf')) {
+ // Separate PDF and non-PDF files for different processing
+ const pdfFiles = metadata.filter(m => m.type.includes('pdf'));
+ const nonPdfFiles = metadata.filter(m => !m.type.includes('pdf'));
+
+ // Process non-PDF files immediately (no history extraction needed)
+ const nonPdfMetadata: FileMetadata[] = nonPdfFiles.map(m => ({
+ id: m.id,
+ name: m.name,
+ type: m.type,
+ size: m.size,
+ lastModified: m.lastModified,
+ thumbnail: m.thumbnail
+ }));
+
+ // Process PDF files with controlled concurrency to avoid memory issues
+ const BATCH_SIZE = 5; // Process 5 PDFs at a time to avoid overwhelming memory
+ const pdfMetadata: FileMetadata[] = [];
+
+ for (let i = 0; i < pdfFiles.length; i += BATCH_SIZE) {
+ const batch = pdfFiles.slice(i, i + BATCH_SIZE);
+
+ const batchResults = await Promise.all(batch.map(async (m) => {
+ try {
+ // For PDF files, load and extract history with timeout
+ const storedFile = await fileStorage.getFile(m.id);
+ if (storedFile?.data) {
+ const file = new File([storedFile.data], m.name, { type: m.type });
+ return await createFileMetadataWithHistory(file, m.id, m.thumbnail);
+ }
+ } catch (error) {
+ if (DEBUG) console.warn('🗂️ Failed to extract history from stored file:', m.name, error);
+ }
+
+ // Fallback to basic metadata if history extraction fails
return {
id: m.id,
name: m.name,
@@ -154,34 +184,12 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
lastModified: m.lastModified,
thumbnail: m.thumbnail
};
- }
+ }));
+
+ pdfMetadata.push(...batchResults);
+ }
- try {
- // For PDF files, load and extract history
- const storedFile = await fileStorage.getFile(m.id);
- if (storedFile?.data) {
- const file = new File([storedFile.data], m.name, { type: m.type });
- const enhancedMetadata = await createFileMetadataWithHistory(file, m.id, m.thumbnail);
-
-
- return enhancedMetadata;
- }
- } catch (error) {
- if (DEBUG) console.warn('🗂️ IndexedDB.loadAllMetadata: Failed to extract history from stored file:', m.name, error);
- }
-
- // Fallback to basic metadata if history extraction fails
- return {
- id: m.id,
- name: m.name,
- type: m.type,
- size: m.size,
- lastModified: m.lastModified,
- thumbnail: m.thumbnail
- };
- }));
-
- return metadataWithHistory;
+ return [...nonPdfMetadata, ...pdfMetadata];
}, []);
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise => {
diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts
index 93c06c4d2..162398de8 100644
--- a/frontend/src/contexts/file/FileReducer.ts
+++ b/frontend/src/contexts/file/FileReducer.ts
@@ -125,16 +125,18 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
return state; // File doesn't exist, no-op
}
+ const updatedRecord = {
+ ...existingRecord,
+ ...updates
+ };
+
return {
...state,
files: {
...state.files,
byId: {
...state.files.byId,
- [id]: {
- ...existingRecord,
- ...updates
- }
+ [id]: updatedRecord
}
}
};
diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts
index f577f1ef8..2b05ede0c 100644
--- a/frontend/src/contexts/file/fileActions.ts
+++ b/frontend/src/contexts/file/fileActions.ts
@@ -202,7 +202,7 @@ export async function addFiles(
});
}
}).catch(error => {
- if (DEBUG) console.warn(`📄 addFiles(raw): Failed to extract history for ${file.name}:`, error);
+ if (DEBUG) console.warn(`📄 Failed to extract history for ${file.name}:`, error);
});
existingQuickKeys.add(quickKey);
@@ -248,9 +248,18 @@ export async function addFiles(
}
// Extract file history from PDF metadata (async)
+ if (DEBUG) console.log(`📄 addFiles(processed): Starting async history extraction for ${file.name}`);
extractFileHistory(file, record).then(updatedRecord => {
+ if (DEBUG) console.log(`📄 addFiles(processed): History extraction completed for ${file.name}:`, {
+ hasChanges: updatedRecord !== record,
+ originalFileId: updatedRecord.originalFileId,
+ versionNumber: updatedRecord.versionNumber,
+ toolHistoryLength: updatedRecord.toolHistory?.length || 0
+ });
+
if (updatedRecord !== record && (updatedRecord.originalFileId || updatedRecord.versionNumber)) {
// History was found, dispatch update to trigger re-render
+ if (DEBUG) console.log(`📄 addFiles(processed): Dispatching UPDATE_FILE_RECORD for ${file.name}`);
dispatch({
type: 'UPDATE_FILE_RECORD',
payload: {
@@ -263,9 +272,11 @@ export async function addFiles(
}
}
});
+ } else {
+ if (DEBUG) console.log(`📄 addFiles(processed): No history found for ${file.name}, skipping update`);
}
}).catch(error => {
- if (DEBUG) console.warn(`📄 addFiles(processed): Failed to extract history for ${file.name}:`, error);
+ if (DEBUG) console.error(`📄 addFiles(processed): Failed to extract history for ${file.name}:`, error);
});
existingQuickKeys.add(quickKey);
@@ -343,6 +354,27 @@ export async function addFiles(
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
}
+ // Extract file history from PDF metadata (async) - same as raw files
+ extractFileHistory(file, record).then(updatedRecord => {
+ if (updatedRecord !== record && (updatedRecord.originalFileId || updatedRecord.versionNumber)) {
+ // History was found, dispatch update to trigger re-render
+ dispatch({
+ type: 'UPDATE_FILE_RECORD',
+ payload: {
+ id: fileId,
+ updates: {
+ originalFileId: updatedRecord.originalFileId,
+ versionNumber: updatedRecord.versionNumber,
+ parentFileId: updatedRecord.parentFileId,
+ toolHistory: updatedRecord.toolHistory
+ }
+ }
+ });
+ }
+ }).catch(error => {
+ if (DEBUG) console.warn(`📄 Failed to extract history for ${file.name}:`, error);
+ });
+
existingQuickKeys.add(quickKey);
fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
@@ -399,6 +431,25 @@ async function processFilesIntoRecords(
record.processedFile = createProcessedFile(pageCount, thumbnail);
}
+ // Extract file history from PDF metadata (synchronous during consumeFiles)
+ if (file.type.includes('pdf')) {
+ try {
+ const updatedRecord = await extractFileHistory(file, record);
+
+ if (updatedRecord !== record && (updatedRecord.originalFileId || updatedRecord.versionNumber)) {
+ // Update the record directly with history data
+ Object.assign(record, {
+ originalFileId: updatedRecord.originalFileId,
+ versionNumber: updatedRecord.versionNumber,
+ parentFileId: updatedRecord.parentFileId,
+ toolHistory: updatedRecord.toolHistory
+ });
+ }
+ } catch (error) {
+ if (DEBUG) console.warn(`📄 Failed to extract history for ${file.name}:`, error);
+ }
+ }
+
return { record, file, fileId, thumbnail };
})
);
diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts
index fe3e314e0..357079d15 100644
--- a/frontend/src/services/pdfExportService.ts
+++ b/frontend/src/services/pdfExportService.ts
@@ -29,7 +29,7 @@ export class PDFExportService {
// Load original PDF and create new document
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
- const sourceDoc = await PDFLibDocument.load(originalPDFBytes);
+ const sourceDoc = await PDFLibDocument.load(originalPDFBytes, { ignoreEncryption: true });
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, false);
@@ -86,7 +86,7 @@ export class PDFExportService {
for (const [fileId, file] of sourceFiles) {
try {
const arrayBuffer = await file.arrayBuffer();
- const doc = await PDFLibDocument.load(arrayBuffer);
+ const doc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true });
loadedDocs.set(fileId, doc);
} catch (error) {
console.warn(`Failed to load source file ${fileId}:`, error);
diff --git a/frontend/src/services/pdfMetadataService.ts b/frontend/src/services/pdfMetadataService.ts
index f5c821dc2..72e5f22ac 100644
--- a/frontend/src/services/pdfMetadataService.ts
+++ b/frontend/src/services/pdfMetadataService.ts
@@ -8,6 +8,7 @@
import { PDFDocument } from 'pdf-lib';
import { FileId } from '../types/file';
+import { ContentCache, type CacheConfig } from '../utils/ContentCache';
const DEBUG = process.env.NODE_ENV === 'development';
@@ -42,6 +43,21 @@ export interface PDFHistoryMetadata {
export class PDFMetadataService {
private static readonly HISTORY_KEYWORD = 'stirling-history';
private static readonly FORMAT_VERSION = '1.0';
+
+ private metadataCache: ContentCache;
+
+ constructor(cacheConfig?: Partial) {
+ const defaultConfig: CacheConfig = {
+ ttl: 5 * 60 * 1000, // 5 minutes
+ maxSize: 100, // 100 files
+ enableWarnings: DEBUG
+ };
+
+ this.metadataCache = new ContentCache({
+ ...defaultConfig,
+ ...cacheConfig
+ });
+ }
/**
* Inject file history metadata into a PDF
@@ -54,7 +70,7 @@ export class PDFMetadataService {
versionNumber: number = 1
): Promise {
try {
- const pdfDoc = await PDFDocument.load(pdfBytes);
+ const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
const historyMetadata: PDFHistoryMetadata = {
stirlingHistory: {
@@ -120,8 +136,29 @@ export class PDFMetadataService {
* Extract file history metadata from a PDF
*/
async extractHistoryMetadata(pdfBytes: ArrayBuffer): Promise {
+ const cacheKey = this.metadataCache.generateKeyFromBuffer(pdfBytes);
+
+ // Check cache first
+ const cached = this.metadataCache.get(cacheKey);
+ if (cached !== null) {
+ return cached;
+ }
+
+ // Extract from PDF
+ const metadata = await this.extractHistoryMetadataInternal(pdfBytes);
+
+ // Cache the result
+ this.metadataCache.set(cacheKey, metadata);
+
+ return metadata;
+ }
+
+ /**
+ * Internal method for actual PDF metadata extraction
+ */
+ private async extractHistoryMetadataInternal(pdfBytes: ArrayBuffer): Promise {
try {
- const pdfDoc = await PDFDocument.load(pdfBytes);
+ const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
const keywords = pdfDoc.getKeywords();
// Look for history keyword directly in array or convert to string
@@ -167,7 +204,7 @@ export class PDFMetadataService {
return metadata;
} catch (error) {
- if (DEBUG) console.error('📄 pdfMetadataService.extractHistoryMetadata: Failed to extract:', error);
+ if (DEBUG) console.error('📄 Failed to extract PDF metadata:', error);
return null;
}
}
@@ -327,5 +364,9 @@ export class PDFMetadataService {
}
}
-// Export singleton instance
-export const pdfMetadataService = new PDFMetadataService();
\ No newline at end of file
+// Export singleton instance with optimized cache settings
+export const pdfMetadataService = new PDFMetadataService({
+ ttl: 10 * 60 * 1000, // 10 minutes for PDF metadata (longer than default)
+ maxSize: 50, // Smaller cache for memory efficiency
+ enableWarnings: DEBUG
+});
\ No newline at end of file
diff --git a/frontend/src/utils/ContentCache.ts b/frontend/src/utils/ContentCache.ts
new file mode 100644
index 000000000..d20c258d1
--- /dev/null
+++ b/frontend/src/utils/ContentCache.ts
@@ -0,0 +1,173 @@
+/**
+ * Generic content cache with TTL and size limits
+ * Reusable for any cached data with configurable parameters
+ */
+
+const DEBUG = process.env.NODE_ENV === 'development';
+
+interface CacheEntry {
+ value: T;
+ timestamp: number;
+}
+
+export interface CacheConfig {
+ /** Time-to-live in milliseconds */
+ ttl: number;
+ /** Maximum number of cache entries */
+ maxSize: number;
+ /** Enable cleanup warnings in development */
+ enableWarnings?: boolean;
+}
+
+export class ContentCache {
+ private cache = new Map>();
+ private hits = 0;
+ private misses = 0;
+
+ constructor(private readonly config: CacheConfig) {}
+
+ /**
+ * Get cached value if valid
+ */
+ get(key: string): T | null {
+ const entry = this.cache.get(key);
+
+ if (!entry) {
+ this.misses++;
+ return null;
+ }
+
+ // Check if expired
+ if (Date.now() - entry.timestamp > this.config.ttl) {
+ this.cache.delete(key);
+ this.misses++;
+ return null;
+ }
+
+ this.hits++;
+ return entry.value;
+ }
+
+ /**
+ * Set cached value
+ */
+ set(key: string, value: T): void {
+ // Clean up before adding if at capacity
+ if (this.cache.size >= this.config.maxSize) {
+ this.evictOldest();
+ }
+
+ this.cache.set(key, {
+ value,
+ timestamp: Date.now()
+ });
+ }
+
+ /**
+ * Generate cache key from ArrayBuffer content
+ */
+ generateKeyFromBuffer(data: ArrayBuffer): string {
+ // Use file size + hash of first/last bytes as cache key
+ const view = new Uint8Array(data);
+ const size = data.byteLength;
+ const start = Array.from(view.slice(0, 16)).join(',');
+ const end = Array.from(view.slice(-16)).join(',');
+ return `${size}-${this.simpleHash(start + end)}`;
+ }
+
+ /**
+ * Generate cache key from string content
+ */
+ generateKeyFromString(content: string): string {
+ return this.simpleHash(content);
+ }
+
+ /**
+ * Check if key exists and is valid
+ */
+ has(key: string): boolean {
+ return this.get(key) !== null;
+ }
+
+ /**
+ * Clear all cache entries
+ */
+ clear(): void {
+ this.cache.clear();
+ this.hits = 0;
+ this.misses = 0;
+ }
+
+ /**
+ * Get cache statistics
+ */
+ getStats(): {
+ size: number;
+ maxSize: number;
+ hitRate: number;
+ hits: number;
+ misses: number;
+ } {
+ const total = this.hits + this.misses;
+ const hitRate = total > 0 ? this.hits / total : 0;
+
+ return {
+ size: this.cache.size,
+ maxSize: this.config.maxSize,
+ hitRate,
+ hits: this.hits,
+ misses: this.misses
+ };
+ }
+
+ /**
+ * Cleanup expired entries
+ */
+ cleanup(): void {
+ const now = Date.now();
+ let cleaned = 0;
+
+ for (const [key, entry] of this.cache.entries()) {
+ if (now - entry.timestamp > this.config.ttl) {
+ this.cache.delete(key);
+ cleaned++;
+ }
+ }
+
+ if (DEBUG && this.config.enableWarnings && this.cache.size > this.config.maxSize * 0.8) {
+ console.warn(`📦 ContentCache: High cache usage (${this.cache.size}/${this.config.maxSize}), cleaned ${cleaned} expired entries`);
+ }
+ }
+
+ /**
+ * Evict oldest entry when at capacity
+ */
+ private evictOldest(): void {
+ let oldestKey: string | null = null;
+ let oldestTime = Date.now();
+
+ for (const [key, entry] of this.cache.entries()) {
+ if (entry.timestamp < oldestTime) {
+ oldestTime = entry.timestamp;
+ oldestKey = key;
+ }
+ }
+
+ if (oldestKey) {
+ this.cache.delete(oldestKey);
+ }
+ }
+
+ /**
+ * Simple hash function for cache keys
+ */
+ private simpleHash(str: string): string {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ const char = str.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash = hash & hash; // Convert to 32-bit integer
+ }
+ return Math.abs(hash).toString(36);
+ }
+}
\ No newline at end of file