Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
@@ -0,0 +1,120 @@
import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse";
import apiClient from "@app/services/apiClient";
export interface AccountData {
username: string;
role: string;
settings: string; // JSON string of settings
changeCredsFlag: boolean;
oAuth2Login: boolean;
saml2Login: boolean;
mfaEnabled?: boolean;
}
export interface LoginPageData {
showDefaultCredentials: boolean;
firstTimeSetup: boolean;
enableLogin: boolean;
ssoAutoLogin?: boolean;
}
/**
* Account Service
* Provides functions to interact with account-related backend APIs
*/
export const accountService = {
/**
* Get login page data (includes showDefaultCredentials flag)
* This is a public endpoint - doesn't require authentication
*/
async getLoginPageData(): Promise<LoginPageData> {
const response = await apiClient.get<LoginPageData>(
"/api/v1/proprietary/ui-data/login",
);
return response.data;
},
/**
* Get current user account data
*/
async getAccountData(): Promise<AccountData> {
const response = await apiClient.get<AccountData>(
"/api/v1/proprietary/ui-data/account",
{ suppressErrorToast: true },
);
return response.data;
},
/**
* Change user password
*/
async changePassword(
currentPassword: string,
newPassword: string,
): Promise<void> {
const formData = new FormData();
formData.append("currentPassword", currentPassword);
formData.append("newPassword", newPassword);
await apiClient.post("/api/v1/user/change-password", formData);
},
/**
* Change user password on first login (resets firstLogin flag)
*/
async changePasswordOnLogin(
currentPassword: string,
newPassword: string,
confirmPassword: string,
): Promise<void> {
const formData = new FormData();
formData.append("currentPassword", currentPassword);
formData.append("newPassword", newPassword);
formData.append("confirmPassword", confirmPassword);
await apiClient.post("/api/v1/user/change-password-on-login", formData, {
responseType: "json",
});
},
/**
* Change username
*/
async changeUsername(
newUsername: string,
currentPassword: string,
): Promise<void> {
const formData = new FormData();
formData.append("currentPasswordChangeUsername", currentPassword);
formData.append("newUsername", newUsername);
await apiClient.post("/api/v1/user/change-username", formData);
},
async requestMfaSetup(): Promise<MfaSetupResponse> {
const response = await apiClient.get<MfaSetupResponse>(
"/api/v1/auth/mfa/setup",
{ suppressErrorToast: true },
);
return response.data;
},
async enableMfa(code: string): Promise<void> {
await apiClient.post(
"/api/v1/auth/mfa/enable",
{ code },
{ skipAuthRedirect: true },
);
},
async disableMfa(code: string): Promise<void> {
await apiClient.post(
"/api/v1/auth/mfa/disable",
{ code },
{ skipAuthRedirect: true },
);
},
async cancelMfaSetup(): Promise<void> {
await apiClient.post("/api/v1/auth/mfa/setup/cancel", undefined, {
suppressErrorToast: true,
});
},
};
@@ -0,0 +1,26 @@
import axios from "axios";
import { handleHttpError } from "@app/services/httpErrorHandler";
import { setupApiInterceptors } from "@app/services/apiClientSetup";
import { getApiBaseUrl } from "@app/services/apiClientConfig";
// Create axios instance with default config
const apiClient = axios.create({
baseURL: getApiBaseUrl(),
responseType: "json",
withCredentials: true,
});
// 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;
@@ -0,0 +1,22 @@
/**
* Get the base URL for API requests.
*
* Priority:
* 1. window.STIRLING_PDF_API_BASE_URL (runtime override - fixes hardcoded localhost issues)
* 2. import.meta.env.VITE_API_BASE_URL (build-time env var)
* 3. '/' (relative path - works for same-origin deployments)
*
* Note: Runtime override is needed because VITE_API_BASE_URL gets baked into the build.
* If someone builds with VITE_API_BASE_URL=http://localhost:8080, it breaks production deployments.
*/
export function getApiBaseUrl(): string {
// Runtime override to fix hardcoded localhost in builds
if (
typeof window !== "undefined" &&
(window as any).STIRLING_PDF_API_BASE_URL
) {
return (window as any).STIRLING_PDF_API_BASE_URL;
}
return import.meta.env.VITE_API_BASE_URL;
}
@@ -0,0 +1,19 @@
import type { AxiosInstance } from "axios";
import { getBrowserId } from "@app/utils/browserIdentifier";
export function setupApiInterceptors(client: AxiosInstance): void {
// Add browser ID header for WAU tracking
client.interceptors.request.use(
(config) => {
const browserId = getBrowserId();
config.headers["X-Browser-Id"] = browserId;
return config;
},
(error) => Promise.reject(error),
);
}
/** Auth headers for raw fetch() calls (SSE streams, etc.). Proprietary overrides with JWT + XSRF. */
export function getAuthHeaders(): Record<string, string> {
return {};
}
@@ -0,0 +1,185 @@
import apiClient from "@app/services/apiClient";
export interface AuditSystemStatus {
enabled: boolean;
level: string;
retentionDays: number;
totalEvents: number;
pdfMetadataEnabled: boolean;
captureFileHash: boolean;
capturePdfAuthor: boolean;
captureOperationResults: boolean;
}
export interface AuditEvent {
id: string;
timestamp: string;
eventType: string;
username: string;
ipAddress: string;
details: Record<string, any>;
}
export interface AuditEventsResponse {
events: AuditEvent[];
totalEvents: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface ChartData {
labels: string[];
values: number[];
}
export interface AuditChartsData {
eventsByType: ChartData;
eventsByUser: ChartData;
eventsOverTime: ChartData;
}
export interface AuditFilters {
eventType?: string | string[];
username?: string | string[];
startDate?: string;
endDate?: string;
outcome?: string;
page?: number;
pageSize?: number;
fields?: string;
}
export interface AuditStats {
totalEvents: number;
prevTotalEvents: number;
uniqueUsers: number;
prevUniqueUsers: number;
successRate: number; // 0100
prevSuccessRate: number;
avgLatencyMs: number;
prevAvgLatencyMs: number;
errorCount: number;
topEventType: string;
topUser: string;
eventsByType: Record<string, number>;
eventsByUser: Record<string, number>;
topTools: Record<string, number>;
hourlyDistribution: Record<string, number>; // "00""23" keys
}
const auditService = {
/**
* Get audit system status
*/
async getSystemStatus(): Promise<AuditSystemStatus> {
const response = await apiClient.get(
"/api/v1/proprietary/ui-data/audit-dashboard",
{
suppressErrorToast: true,
},
);
const data = response.data;
// Map V1 response to expected format
return {
enabled: data.auditEnabled,
level: data.auditLevel,
retentionDays: data.retentionDays,
totalEvents: 0, // Will be fetched separately
pdfMetadataEnabled: data.pdfMetadataEnabled ?? false,
captureFileHash: data.captureFileHash ?? false,
capturePdfAuthor: data.capturePdfAuthor ?? false,
captureOperationResults: data.captureOperationResults ?? false,
};
},
/**
* Get audit statistics and KPI data
*/
async getStats(
timePeriod: "day" | "week" | "month" = "week",
): Promise<AuditStats> {
const response = await apiClient.get<AuditStats>(
"/api/v1/proprietary/ui-data/audit-stats",
{
params: { period: timePeriod },
},
);
return response.data;
},
/**
* Get audit events with pagination and filters
*/
async getEvents(filters: AuditFilters = {}): Promise<AuditEventsResponse> {
const response = await apiClient.get<AuditEventsResponse>(
"/api/v1/proprietary/ui-data/audit-events",
{
params: filters,
},
);
return response.data;
},
/**
* Get chart data for dashboard
*/
async getChartsData(
timePeriod: "day" | "week" | "month" = "week",
): Promise<AuditChartsData> {
const response = await apiClient.get<AuditChartsData>(
"/api/v1/proprietary/ui-data/audit-charts",
{
params: { period: timePeriod },
},
);
return response.data;
},
/**
* Export audit data with custom field selection
*/
async exportData(
format: "csv" | "json",
filters: AuditFilters = {},
): Promise<Blob> {
const response = await apiClient.get(
"/api/v1/proprietary/ui-data/audit-export",
{
params: { format, ...filters },
responseType: "blob",
},
);
return response.data;
},
/**
* Get available event types for filtering
*/
async getEventTypes(): Promise<string[]> {
const response = await apiClient.get<string[]>(
"/api/v1/proprietary/ui-data/audit-event-types",
);
return response.data;
},
/**
* Get list of users for filtering
*/
async getUsers(): Promise<string[]> {
const response = await apiClient.get<string[]>(
"/api/v1/proprietary/ui-data/audit-users",
);
return response.data;
},
/**
* Clear all audit data from the database (irreversible)
*/
async clearAllAuditData(): Promise<void> {
await apiClient.post("/api/v1/proprietary/ui-data/audit-clear-all", {});
},
};
export default auditService;
@@ -0,0 +1,194 @@
/**
* 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();
@@ -0,0 +1,8 @@
/**
* Default backend readiness guard (web builds do not need to wait for
* anything outside the browser, so we always report ready).
* @param _endpoint - Optional endpoint path (not used in web builds)
*/
export async function ensureBackendReady(_endpoint?: string): Promise<boolean> {
return true;
}
@@ -0,0 +1,6 @@
// Stub - overridden in desktop builds
export async function notifyPdfProcessingComplete(
_fileCount: number,
): Promise<void> {
// Web builds: no-op
}
@@ -0,0 +1,190 @@
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
import { convertSplitPageIdsToIndexes } from "@app/components/pageEditor/utils/splitPositions";
/**
* 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<string>,
): 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
const resolvedSplitIndexes =
splitPositions && splitPositions.size > 0
? convertSplitPageIdsToIndexes(baseDocument, splitPositions)
: new Set<number>();
if (resolvedSplitIndexes.size > 0) {
updatedPages = updatedPages.map((page, index) => ({
...page,
splitAfter: resolvedSplitIndexes.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 (resolvedSplitIndexes.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();
@@ -0,0 +1,42 @@
export interface DownloadRequest {
data: Blob | File;
filename: string;
localPath?: string;
}
export interface DownloadResult {
savedPath?: string;
cancelled?: boolean;
}
export async function downloadFile(
request: DownloadRequest,
): Promise<DownloadResult> {
const url = URL.createObjectURL(request.data);
const link = document.createElement("a");
link.href = url;
link.download = request.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return { savedPath: request.localPath };
}
export async function downloadFromUrl(
url: string,
filename: string,
localPath?: string,
): Promise<DownloadResult> {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return { savedPath: localPath };
}
@@ -0,0 +1,599 @@
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 without touching PDF.js
for (let i = priorityCount + 1; i <= totalPages; i++) {
pages.push({
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null, // Will be loaded lazily
rotation: 0,
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 without invoking PDF.js
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
pages.push({
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null,
rotation: 0,
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++) {
pages.push({
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null,
rotation: 0,
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();
@@ -0,0 +1,55 @@
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);
}
@@ -0,0 +1,318 @@
import { FileAnalysis, ProcessingStrategy } from "@app/types/processing";
import { pdfWorkerManager } from "@app/services/pdfWorkerManager";
import type { PDFDocumentProxy } from "pdfjs-dist";
// Scan the last ~8KB of the PDF for an /Encrypt entry. The trailer lives near
// the tail of the file, so this is enough in practice while staying cheap.
// For files smaller than the window, the whole file is scanned.
function hasEncryptMarker(buffer: ArrayBuffer): boolean {
const TAIL_BYTES = 8 * 1024;
const offset = Math.max(0, buffer.byteLength - TAIL_BYTES);
const view = new Uint8Array(buffer, offset);
// "/Encrypt" as ASCII bytes
const needle = [0x2f, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74];
outer: for (let i = 0; i <= view.length - needle.length; i++) {
for (let j = 0; j < needle.length; j++) {
if (view[i + j] !== needle[j]) continue outer;
}
return true;
}
return false;
}
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
*/
/**
* Cheap encryption-only probe for the upload-time detection path.
*
* Looks for a /Encrypt entry in the last 8KB of the file (where the PDF
* trailer lives). If absent, the file is definitely not encrypted and we
* can skip a full pdf.js parse. If present, falls back to pdf.js so we can
* distinguish user-password (blocks open) from owner-password-only (opens
* fine) — only the former should prompt.
*/
static async isPDFUserPasswordProtected(file: File): Promise<boolean> {
const arrayBuffer = await file.arrayBuffer();
if (!hasEncryptMarker(arrayBuffer)) return false;
let pdf: PDFDocumentProxy | undefined;
try {
pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
stopAtErrors: false,
verbosity: 0,
});
// pdf.js opened it — owner-password-only case, no prompt needed.
return false;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message.toLowerCase() : "";
return (
errorMessage.includes("password") || errorMessage.includes("encrypted")
);
} finally {
if (pdf) pdfWorkerManager.destroyDocument(pdf);
}
}
static async quickPDFAnalysis(file: File): Promise<{
pageCount: number;
isEncrypted: boolean;
isCorrupted: boolean;
}> {
let pdf: PDFDocumentProxy | undefined;
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();
pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
stopAtErrors: false, // Don't stop at minor errors
verbosity: 0, // Suppress PDF.js warnings
});
const pageCount = pdf.numPages;
// If pdf.js opened the document successfully, the user can view it — even if
// the PDF carries encryption dictionaries (owner-password-only case). We only
// flag isEncrypted when pdf.js *fails* to open the file (caught below).
return {
pageCount,
isEncrypted: false,
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
};
} finally {
if (pdf) pdfWorkerManager.destroyDocument(pdf);
}
}
/**
* 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;
}
}
}
@@ -0,0 +1,28 @@
// Core stub - no-op implementation for web builds
// Desktop overrides this with actual Tauri implementation
export interface FileWithPath {
file: File;
path: string;
quickKey: string;
}
export interface FileDialogOptions {
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
}
/**
* Open native file dialog and read selected files
* Core stub - returns empty array (no native dialog in web)
* Desktop builds override this with actual Tauri implementation
*/
export async function openFileDialog(
_options?: FileDialogOptions,
): Promise<FileWithPath[]> {
// Web build: no native file dialog support
return [];
}
@@ -0,0 +1,242 @@
/**
* 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();
@@ -0,0 +1,674 @@
/**
* 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
*/
const THUMBNAIL_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export interface StoredStirlingFileRecord extends BaseFileMetadata {
data: ArrayBuffer;
fileId: FileId; // Matches runtime StirlingFile.fileId exactly
quickKey: string; // Matches runtime StirlingFile.quickKey exactly
thumbnail?: string;
thumbnailStoredAt?: number; // Epoch ms — sliding 30-day TTL
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);
}
/** Returns thumbnail if within TTL, otherwise undefined. */
private isThumbnailFresh(record: StoredStirlingFileRecord): boolean {
if (!record.thumbnail) return false;
if (!record.thumbnailStoredAt) return false;
return Date.now() - record.thumbnailStoredAt < THUMBNAIL_TTL_MS;
}
/** Fire-and-forget: bump thumbnailStoredAt (or clear expired thumbnail) for a set of ids. */
private async bumpThumbnailTTL(ids: FileId[], clear = false): Promise<void> {
if (ids.length === 0) return;
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
// Issue all gets up front — each onsuccess creates a put before the
// transaction can auto-commit, keeping it alive until all puts settle.
ids.forEach((id) => {
const req = store.get(id);
req.onsuccess = () => {
const record = req.result as StoredStirlingFileRecord | undefined;
if (!record) return;
if (clear) {
record.thumbnail = undefined;
record.thumbnailStoredAt = undefined;
} else {
record.thumbnailStoredAt = Date.now();
}
store.put(record);
};
req.onerror = () => reject(req.error);
});
});
}
/**
* 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,
createdAt: stub.createdAt,
data: arrayBuffer,
thumbnail: stub.thumbnailUrl,
thumbnailStoredAt: stub.thumbnailUrl ? Date.now() : undefined,
isLeaf: stub.isLeaf ?? true,
remoteStorageId: stub.remoteStorageId,
remoteStorageUpdatedAt: stub.remoteStorageUpdatedAt,
remoteOwnerUsername: stub.remoteOwnerUsername,
remoteOwnedByCurrentUser: stub.remoteOwnedByCurrentUser,
remoteAccessRole: stub.remoteAccessRole,
remoteSharedViaLink: stub.remoteSharedViaLink,
remoteHasShareLinks: stub.remoteHasShareLinks,
remoteShareToken: stub.remoteShareToken,
// 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 fresh = this.isThumbnailFresh(record);
void this.bumpThumbnailTTL([record.id], !fresh);
const stub: StirlingFileStub = {
id: record.id,
name: record.name,
type: record.type,
size: record.size,
lastModified: record.lastModified,
quickKey: record.quickKey,
thumbnailUrl: fresh ? record.thumbnail : undefined,
isLeaf: record.isLeaf,
remoteStorageId: record.remoteStorageId,
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
versionNumber: record.versionNumber,
originalFileId: record.originalFileId,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory,
createdAt: record.createdAt || Date.now(),
};
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[] = [];
const tobump: FileId[] = [];
const toexpire: FileId[] = [];
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") {
const fresh = this.isThumbnailFresh(record);
if (record.thumbnail) {
if (fresh) tobump.push(record.id);
else toexpire.push(record.id);
}
stubs.push({
id: record.id,
name: record.name,
type: record.type,
size: record.size,
lastModified: record.lastModified,
quickKey: record.quickKey,
thumbnailUrl: fresh ? record.thumbnail : undefined,
isLeaf: record.isLeaf,
remoteStorageId: record.remoteStorageId,
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
versionNumber: record.versionNumber || 1,
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
createdAt: record.createdAt || Date.now(),
});
}
cursor.continue();
} else {
void this.bumpThumbnailTTL(tobump);
void this.bumpThumbnailTTL(toexpire, true);
resolve(stubs);
}
};
});
}
/**
* Get all history stubs for a given original file ID.
*/
async getHistoryChainStubs(
originalFileId: FileId,
): Promise<StirlingFileStub[]> {
const stubs = await this.getAllStirlingFileStubs();
return stubs
.filter((stub) => (stub.originalFileId || stub.id) === originalFileId)
.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1));
}
/**
* 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[] = [];
const tobump: FileId[] = [];
const toexpire: FileId[] = [];
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
) {
const fresh = this.isThumbnailFresh(record);
if (record.thumbnail) {
if (fresh) tobump.push(record.id);
else toexpire.push(record.id);
}
leafStubs.push({
id: record.id,
name: record.name,
type: record.type,
size: record.size,
lastModified: record.lastModified,
quickKey: record.quickKey,
thumbnailUrl: fresh ? record.thumbnail : undefined,
isLeaf: record.isLeaf,
remoteStorageId: record.remoteStorageId,
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
versionNumber: record.versionNumber || 1,
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
createdAt: record.createdAt || Date.now(),
});
}
cursor.continue();
} else {
void this.bumpThumbnailTTL(tobump);
void this.bumpThumbnailTTL(toexpire, true);
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();
});
}
/**
* Delete multiple StirlingFiles in a single transaction
*/
async deleteMultipleStirlingFiles(ids: FileId[]): Promise<void> {
if (ids.length === 0) return;
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () =>
reject(transaction.error ?? new Error("Transaction aborted"));
ids.forEach((id) => store.delete(id));
});
}
/**
* 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;
record.thumbnailStoredAt = Date.now();
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: number;
let fileCount: number;
let available = 0;
let quota: number | undefined;
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;
}
}
/**
* Update metadata fields for a stored file record.
*/
async updateFileMetadata(
fileId: FileId,
updates: Partial<StoredStirlingFileRecord>,
): 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.onerror = () => reject(request.error);
request.onsuccess = () =>
resolve(request.result as StoredStirlingFileRecord | undefined);
},
);
if (!record) {
return false;
}
const updatedRecord = { ...record, ...updates };
await new Promise<void>((resolve, reject) => {
const request = store.put(updatedRecord);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
return true;
} catch (error) {
console.error("Failed to update file metadata:", error);
return false;
}
}
}
// Export singleton instance
export const fileStorage = new FileStorageService();
// Helper hook for React components
export function useFileStorage() {
return fileStorage;
}
@@ -0,0 +1,37 @@
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 };
}
@@ -0,0 +1,375 @@
/**
* 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 BackendGoogleDriveConfig {
enabled?: boolean;
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
* Supports both backend settings and environment variables
*/
export function isGoogleDriveConfigured(
backendConfig?: BackendGoogleDriveConfig,
): boolean {
// If backend config is provided, use it exclusively (don't fall back to env vars)
if (backendConfig) {
if (backendConfig.enabled === false) {
return false; // Admin explicitly disabled it
}
if (backendConfig.enabled) {
return !!(
backendConfig.clientId &&
backendConfig.apiKey &&
backendConfig.appId
);
}
return false; // No backend config provided
}
// Fall back to environment variables only when backend config is not available
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 backend settings or environment variables
* Backend settings take priority over environment variables
*/
export function getGoogleDriveConfig(
backendConfig?: BackendGoogleDriveConfig,
): GoogleDriveConfig | null {
// If backend config is provided, use it exclusively (don't fall back to env vars)
if (backendConfig) {
if (backendConfig.enabled === false) {
return null; // Admin explicitly disabled it
}
if (
backendConfig.enabled &&
backendConfig.clientId &&
backendConfig.apiKey &&
backendConfig.appId
) {
return {
clientId: backendConfig.clientId,
apiKey: backendConfig.apiKey,
appId: backendConfig.appId,
};
}
return null; // Backend config provided but incomplete
}
// Fall back to environment variables only when backend config is not available
const envClientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID;
const envApiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY;
const envAppId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID;
if (envClientId && envApiKey && envAppId) {
return {
clientId: envClientId,
apiKey: envApiKey,
appId: envAppId,
};
}
return null;
}
/**
* Extract Google Drive backend config from AppConfig object
* Eliminates duplicated config construction pattern
*/
export function extractGoogleDriveBackendConfig(
appConfig: any,
): BackendGoogleDriveConfig {
return {
enabled: appConfig?.googleDriveEnabled,
clientId: appConfig?.googleDriveClientId,
apiKey: appConfig?.googleDriveApiKey,
appId: appConfig?.googleDriveAppId,
};
}
@@ -0,0 +1,159 @@
// frontend/src/services/httpErrorHandler.ts
import { alert } from "@app/components/toast";
import {
broadcastErroredFiles,
extractErrorFileIds,
normalizeAxiosErrorData,
} from "@app/services/errorUtils";
import { showSpecialErrorToast } from "@app/services/specialErrorToasts";
import { handleSaaSError } from "@app/services/saasErrorInterceptor";
import {
clampText,
extractAxiosErrorMessage,
} from "@app/services/httpErrorUtils";
// 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
// Mirrors the key in proprietary/auth/springAuthClient.ts; AuthCallback consumes it.
const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path";
function isSafePostLoginPath(path: string): boolean {
if (
!path.startsWith("/") ||
path.startsWith("//") ||
path.startsWith("/\\")
) {
return false;
}
const lowered = path.toLowerCase();
return (
!lowered.startsWith("/login") &&
!lowered.startsWith("/auth/") &&
!lowered.startsWith("/oauth2") &&
!lowered.startsWith("/saml2")
);
}
function stashPostLoginRedirect(path: string): void {
try {
if (typeof window === "undefined" || !isSafePostLoginPath(path)) return;
window.sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, path);
} catch {
// sessionStorage unavailable (private mode) — fail open
}
}
/**
* 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> {
const skipAuthRedirect = error?.config?.skipAuthRedirect === true;
// Check if this error should skip the global toast (component will handle it)
if (error?.config?.suppressErrorToast === true) {
return false; // Don't show global toast, but continue rejection
}
// Handle 401 authentication errors
const status: number | undefined = error?.response?.status;
if (status === 401) {
const pathname = window.location.pathname;
// Check if we're already on an auth page
const isAuthPage =
pathname.includes("/login") ||
pathname.includes("/signup") ||
pathname.includes("/auth/") ||
pathname.includes("/invite/");
// If not on auth page, redirect to login with expired session message
if (!isAuthPage && !skipAuthRedirect) {
console.debug("[httpErrorHandler] 401 detected, redirecting to login");
// Spring 302-strips the ?from= query from /login, so stash the return
// path in sessionStorage (AuthCallback reads it after SSO round-trip).
const currentLocation = window.location.pathname + window.location.search;
stashPostLoginRedirect(currentLocation);
let hadStoredJwt = false;
try {
hadStoredJwt = Boolean(localStorage.getItem("stirling_jwt"));
} catch {
// ignore storage access failures
}
const expiredPrefix = hadStoredJwt ? "expired=true&" : "";
window.location.href = `/login?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`;
return true; // Suppress toast since we're redirecting
}
// On auth pages, suppress the toast (user is already trying to authenticate)
console.debug("[httpErrorHandler] Suppressing 401 on auth page:", pathname);
return true;
}
if (handleSaaSError(error)) return true;
// 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 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
}
@@ -0,0 +1,95 @@
import axios from "axios";
const FRIENDLY_FALLBACK = "There was an error processing your request.";
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
export 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";
}
export 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 };
}
}
@@ -0,0 +1,347 @@
/**
* 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();
@@ -0,0 +1,52 @@
// Core stub - no-op implementation for web builds
// Desktop overrides this with actual Tauri implementation
export interface SaveResult {
success: boolean;
error?: string;
}
export interface MultiFileSaveResult {
success: boolean;
savedCount: number;
cancelledByUser?: boolean;
error?: string;
}
/**
* Save file data to a local filesystem path
* Core stub - always returns failure
* Desktop builds override this with actual implementation
*/
export async function saveToLocalPath(
_data: Blob | File,
_filePath: string,
): Promise<SaveResult> {
return { success: false, error: "Local file save not available in web mode" };
}
/**
* Show native save dialog
* Core stub - always returns null
*/
export async function showSaveDialog(
_defaultFilename: string,
_defaultDirectory?: string,
): Promise<string | null> {
return null;
}
/**
* Prompt user to select folder and save multiple files
* Core stub - always returns failure
*/
export async function saveMultipleFilesWithPrompt(
_files: (Blob | File)[],
_defaultDirectory?: string,
): Promise<MultiFileSaveResult> {
return {
success: false,
savedCount: 0,
error: "Multi-file save not available in web mode",
};
}
@@ -0,0 +1,31 @@
import { openFileDialog } from "@app/services/fileDialogService";
import { pendingFilePathMappings } from "@app/services/pendingFilePathMappings";
import { getDocumentFileDialogFilter } from "@app/utils/fileDialogUtils";
interface OpenFilesFromDiskOptions {
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
onFallbackOpen?: () => void;
}
export async function openFilesFromDisk(
options: OpenFilesFromDiskOptions = {},
): Promise<File[]> {
const filesWithPaths = await openFileDialog({
multiple: options.multiple ?? true,
filters: options.filters ?? getDocumentFileDialogFilter(),
});
if (filesWithPaths.length > 0) {
for (const { quickKey, path } of filesWithPaths) {
pendingFilePathMappings.set(quickKey, path);
}
return filesWithPaths.map((entry) => entry.file);
}
options.onFallbackOpen?.();
return [];
}
@@ -0,0 +1,32 @@
import type { FileId, StirlingFileStub } from "@app/types/fileContext";
import { downloadFromUrl, DownloadResult } from "@app/services/downloadService";
export interface OperationSaveContext {
downloadUrl: string | null;
downloadFilename: string;
downloadLocalPath?: string | null;
outputFileIds?: string[] | null;
getFile: (fileId: FileId) => File | undefined;
getStub: (fileId: FileId) => StirlingFileStub | undefined;
markSaved: (fileId: FileId, savedPath?: string) => void;
}
export async function saveOperationResults(
context: OperationSaveContext,
): Promise<DownloadResult | null> {
if (!context.downloadUrl) return null;
const result = await downloadFromUrl(
context.downloadUrl,
context.downloadFilename || "download",
context.downloadLocalPath || undefined,
);
if (context.outputFileIds && result.savedPath) {
for (const fileId of context.outputFileIds) {
context.markSaved(fileId as FileId, result.savedPath);
}
}
return result;
}
@@ -0,0 +1,67 @@
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" }),
];
}
}
@@ -0,0 +1,369 @@
import {
getPdfiumModule,
openRawDocumentSafe,
closeRawDocument,
saveRawDocument,
importPages,
setPageRotation,
addNewPage,
} from "@app/services/pdfiumService";
import { downloadFile } from "@app/services/downloadService";
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
// A4 dimensions in PDF points (72 dpi)
const A4_WIDTH = 595.276;
const A4_HEIGHT = 841.89;
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 {
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 originalPDFBytes = await pdfDocument.file.arrayBuffer();
const blob = await this.createSingleDocument(
originalPDFBytes,
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"}`,
{ cause: 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 {
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"}`,
{ cause: error },
);
}
}
/**
* Create a PDF document from multiple source files using PDFium WASM.
*/
private async createMultiSourceDocument(
sourceFiles: Map<string, File>,
pages: PDFPage[],
): Promise<Blob> {
const m = await getPdfiumModule();
// Create destination document
const destDocPtr = m.FPDF_CreateNewDocument();
if (!destDocPtr)
throw new Error("PDFium: failed to create destination document");
// Load all source documents once and cache them
const loadedDocs = new Map<string, number>();
try {
for (const [fileId, file] of sourceFiles) {
try {
const arrayBuffer = await file.arrayBuffer();
const docPtr = await openRawDocumentSafe(arrayBuffer);
loadedDocs.set(fileId, docPtr);
} catch (error) {
console.warn(`Failed to load source file ${fileId}:`, error);
}
}
let insertIdx = 0;
for (const page of pages) {
if (page.isBlankPage || page.originalPageNumber === -1) {
// Insert a blank A4 page
await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT);
// Apply rotation
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
} else if (page.originalFileId && loadedDocs.has(page.originalFileId)) {
const srcDocPtr = loadedDocs.get(page.originalFileId)!;
const srcPageCount = m.FPDF_GetPageCount(srcDocPtr);
const sourcePageIndex = page.originalPageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < srcPageCount) {
// Import the specific page (1-based page range for FPDF_ImportPages)
const pageRange = String(sourcePageIndex + 1);
const imported = await importPages(
destDocPtr,
srcDocPtr,
pageRange,
insertIdx,
);
if (!imported) {
console.warn(
`[PDFExport] importPages failed for fileId=${page.originalFileId} pageRange=${pageRange} — page will be missing from output.`,
);
}
// Apply rotation
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
}
} else {
console.warn(
`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`,
);
}
}
// Save the assembled document
const resultBuf = await saveRawDocument(destDocPtr);
return new Blob([resultBuf], { type: "application/pdf" });
} finally {
// Cleanup all loaded source documents
for (const docPtr of loadedDocs.values()) {
await closeRawDocument(docPtr);
}
m.FPDF_CloseDocument(destDocPtr);
}
}
/**
* Create a single PDF document with all operations applied (single source) using PDFium.
*/
private async createSingleDocument(
sourceData: ArrayBuffer,
pages: PDFPage[],
): Promise<Blob> {
const m = await getPdfiumModule();
// Open source document
const srcDocPtr = await openRawDocumentSafe(sourceData);
const destDocPtr = m.FPDF_CreateNewDocument();
if (!destDocPtr) {
await closeRawDocument(srcDocPtr);
throw new Error("PDFium: failed to create destination document");
}
try {
const srcPageCount = m.FPDF_GetPageCount(srcDocPtr);
let insertIdx = 0;
for (const page of pages) {
if (page.isBlankPage || page.originalPageNumber === -1) {
await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT);
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
} else {
const sourcePageIndex = page.originalPageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < srcPageCount) {
const pageRange = String(sourcePageIndex + 1);
const imported = await importPages(
destDocPtr,
srcDocPtr,
pageRange,
insertIdx,
);
if (!imported) {
console.warn(
`[PDFExport] importPages failed for page ${page.originalPageNumber} pageRange=${pageRange} — page will be missing from output.`,
);
}
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
}
}
}
const resultBuf = await saveRawDocument(destDocPtr);
return new Blob([resultBuf], { type: "application/pdf" });
} finally {
await closeRawDocument(srcDocPtr);
m.FPDF_CloseDocument(destDocPtr);
}
}
/**
* 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 {
void downloadFile({ data: blob, filename });
}
/**
* 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);
});
}
/**
* 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);
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];
}
}
/**
* Convert degrees (0, 90, 180, 270) to PDFium rotation enum (0, 1, 2, 3).
*/
function degreesToPdfiumRotation(degrees: number): number {
const normalized = ((degrees % 360) + 360) % 360;
switch (normalized) {
case 90:
return 1;
case 180:
return 2;
case 270:
return 3;
default:
return 0;
}
}
// Export singleton instance
export const pdfExportService = new PDFExportService();
@@ -0,0 +1,189 @@
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,
};
}
@@ -0,0 +1,195 @@
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();
@@ -0,0 +1,200 @@
/**
* 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();
(GlobalWorkerOptions as any).docBaseUrl = undefined;
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,
// Suppress warnings about unimplemented widget types and other non-critical issues
isEvalSupported: false,
}
: {
...pdfData,
disableAutoFetch: options.disableAutoFetch ?? true,
disableStream: options.disableStream ?? true,
stopAtErrors: options.stopAtErrors ?? false,
verbosity: options.verbosity ?? 0,
// Suppress warnings about unimplemented widget types and other non-critical issues
isEvalSupported: false,
},
);
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();
@@ -0,0 +1,462 @@
/**
* pdfiumDocBuilder — High-level PDF document builder powered by PDFium WASM.
*
* Provides an API surface compatible with common pdf-lib patterns
* (drawText, drawRectangle, drawLine, drawImage, font metrics, etc.)
* so that code previously using pdf-lib for PDF *generation* can be
* migrated with minimal changes.
*
* Used by the signature validation report system.
*/
import {
getPdfiumModule,
writeUtf16,
saveRawDocument,
} from "@app/services/pdfiumService";
import { embedBitmapImageOnPage } from "@app/utils/pdfiumBitmapUtils";
import type { WrappedPdfiumModule } from "@embedpdf/pdfium";
// ---------------------------------------------------------------------------
// Color type (replaces pdf-lib's `rgb()`)
// ---------------------------------------------------------------------------
export interface PdfiumColor {
_r: number;
_g: number;
_b: number;
}
/**
* Create an RGB color (each component 01). Drop-in replacement for pdf-lib's `rgb()`.
*/
export function rgb(r: number, g: number, b: number): PdfiumColor {
return { _r: r, _g: g, _b: b };
}
function colorToRGBA(c: PdfiumColor): [number, number, number, number] {
return [
Math.round(c._r * 255),
Math.round(c._g * 255),
Math.round(c._b * 255),
255,
];
}
// ---------------------------------------------------------------------------
// Font abstraction
// ---------------------------------------------------------------------------
/** Standard PDF font names matching pdf-lib's StandardFonts enum. */
export const StandardFonts = {
Helvetica: "Helvetica",
HelveticaBold: "Helvetica-Bold",
HelveticaOblique: "Helvetica-Oblique",
HelveticaBoldOblique: "Helvetica-BoldOblique",
Courier: "Courier",
CourierBold: "Courier-Bold",
TimesRoman: "Times-Roman",
TimesBold: "Times-Bold",
} as const;
export class PdfiumFont {
readonly name: string;
private _canvas: OffscreenCanvas | HTMLCanvasElement | null = null;
private _ctx:
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null = null;
constructor(name: string) {
this.name = name;
}
/**
* Measure the width of `text` at the given `fontSize` (in PDF points).
* Uses canvas font metrics as a reliable cross-browser measurement.
*/
widthOfTextAtSize(text: string, fontSize: number): number {
const ctx = this._getCtx();
ctx.font = `${fontSize}px "${this._cssFontFamily()}"`;
return ctx.measureText(text).width;
}
/**
* Returns the line height for a given font size (approximation).
*/
heightAtSize(fontSize: number): number {
return fontSize * 1.2;
}
/** Map PDF font name to a CSS font-family for canvas measurement. */
private _cssFontFamily(): string {
if (this.name.startsWith("Helvetica"))
return "Helvetica, Arial, sans-serif";
if (this.name.startsWith("Courier")) return "Courier, monospace";
if (this.name.startsWith("Times")) return "Times New Roman, serif";
return "sans-serif";
}
private _getCtx():
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D {
if (this._ctx) return this._ctx;
if (typeof OffscreenCanvas !== "undefined") {
this._canvas = new OffscreenCanvas(1, 1);
this._ctx = this._canvas.getContext("2d")!;
} else {
this._canvas = document.createElement("canvas");
this._ctx = this._canvas.getContext("2d")!;
}
return this._ctx;
}
}
// ---------------------------------------------------------------------------
// Image abstraction
// ---------------------------------------------------------------------------
export class PdfiumImage {
readonly width: number;
readonly height: number;
/** Raw RGBA pixel data */
readonly _rgba: Uint8Array;
constructor(rgba: Uint8Array, width: number, height: number) {
this._rgba = rgba;
this.width = width;
this.height = height;
}
/** Returns scaled dimensions (matches pdf-lib's `PDFImage.scale()`). */
scale(factor: number): { width: number; height: number } {
return { width: this.width * factor, height: this.height * factor };
}
/** Scale to fit within maxWidth × maxHeight preserving aspect ratio (matches pdf-lib's `PDFImage.scaleToFit()`). */
scaleToFit(
maxWidth: number,
maxHeight: number,
): { width: number; height: number } {
const ratio = Math.min(maxWidth / this.width, maxHeight / this.height, 1);
return { width: this.width * ratio, height: this.height * ratio };
}
}
// ---------------------------------------------------------------------------
// Page abstraction
// ---------------------------------------------------------------------------
export interface DrawTextOptions {
x: number;
y: number;
size: number;
font?: PdfiumFont;
color?: PdfiumColor;
}
export interface DrawRectangleOptions {
x: number;
y: number;
width: number;
height: number;
color?: PdfiumColor;
borderColor?: PdfiumColor;
borderWidth?: number;
opacity?: number;
}
export interface DrawImageOptions {
x: number;
y: number;
width: number;
height: number;
}
export interface DrawLineOptions {
start: { x: number; y: number };
end: { x: number; y: number };
thickness?: number;
color?: PdfiumColor;
}
export class PdfiumPage {
readonly _m: WrappedPdfiumModule;
readonly _docPtr: number;
readonly _pagePtr: number;
readonly width: number;
readonly height: number;
constructor(
m: WrappedPdfiumModule,
docPtr: number,
pagePtr: number,
width: number,
height: number,
) {
this._m = m;
this._docPtr = docPtr;
this._pagePtr = pagePtr;
this.width = width;
this.height = height;
}
/**
* Draw text on the page. API compatible with pdf-lib's `PDFPage.drawText()`.
*/
drawText(text: string, options: DrawTextOptions): void {
const { x, y, size, font, color } = options;
const m = this._m;
const fontName = font?.name ?? "Helvetica";
const textObjPtr = m.FPDFPageObj_NewTextObj(this._docPtr, fontName, size);
if (!textObjPtr) return;
// Set text content (UTF-16)
const textPtr = writeUtf16(m, text);
m.FPDFText_SetText(textObjPtr, textPtr);
m.pdfium.wasmExports.free(textPtr);
// Set color
if (color) {
const [r, g, b, a] = colorToRGBA(color);
m.FPDFPageObj_SetFillColor(textObjPtr, r, g, b, a);
}
// Position the text object
// FPDFPageObj_Transform(obj, a, b, c, d, e, f) — applies affine transform
// Identity + translate: a=1, b=0, c=0, d=1, e=x, f=y
m.FPDFPageObj_Transform(textObjPtr, 1, 0, 0, 1, x, y);
m.FPDFPage_InsertObject(this._pagePtr, textObjPtr);
}
/**
* Draw a rectangle on the page. API compatible with pdf-lib's `PDFPage.drawRectangle()`.
*/
drawRectangle(options: DrawRectangleOptions): void {
const {
x,
y,
width,
height,
color,
borderColor,
borderWidth = 1,
} = options;
const m = this._m;
const pathPtr = m.FPDFPageObj_CreateNewPath(x, y);
if (!pathPtr) return;
m.FPDFPath_LineTo(pathPtr, x + width, y);
m.FPDFPath_LineTo(pathPtr, x + width, y + height);
m.FPDFPath_LineTo(pathPtr, x, y + height);
m.FPDFPath_Close(pathPtr);
let hasFill = false;
let hasStroke = false;
if (color) {
const [r, g, b, a] = colorToRGBA(color);
m.FPDFPageObj_SetFillColor(pathPtr, r, g, b, a);
hasFill = true;
}
if (borderColor) {
const [r, g, b, a] = colorToRGBA(borderColor);
m.FPDFPageObj_SetStrokeColor(pathPtr, r, g, b, a);
m.FPDFPageObj_SetStrokeWidth(pathPtr, borderWidth);
hasStroke = true;
}
// Fill mode: 0 = none, 1 = alternate, 2 = winding
const fillMode = hasFill ? 1 : 0;
m.FPDFPath_SetDrawMode(pathPtr, fillMode, hasStroke);
m.FPDFPage_InsertObject(this._pagePtr, pathPtr);
}
/**
* Draw a line on the page. API compatible with pdf-lib's `PDFPage.drawLine()`.
*/
drawLine(options: DrawLineOptions): void {
const { start, end, thickness = 1, color } = options;
const m = this._m;
const pathPtr = m.FPDFPageObj_CreateNewPath(start.x, start.y);
if (!pathPtr) return;
m.FPDFPath_LineTo(pathPtr, end.x, end.y);
if (color) {
const [r, g, b, a] = colorToRGBA(color);
m.FPDFPageObj_SetStrokeColor(pathPtr, r, g, b, a);
}
m.FPDFPageObj_SetStrokeWidth(pathPtr, thickness);
// fillMode=0 (no fill), stroke=true
m.FPDFPath_SetDrawMode(pathPtr, 0, true);
m.FPDFPage_InsertObject(this._pagePtr, pathPtr);
}
/**
* Draw an image on the page. API compatible with pdf-lib's `PDFPage.drawImage()`.
*/
drawImage(image: PdfiumImage, options: DrawImageOptions): void {
const { x, y, width, height } = options;
embedBitmapImageOnPage(
this._m,
this._docPtr,
this._pagePtr,
{ rgba: image._rgba, width: image.width, height: image.height },
x,
y,
width,
height,
);
}
/** Finalize the page content stream. Called internally by PdfiumDocument. */
_generateContent(): void {
this._m.FPDFPage_GenerateContent(this._pagePtr);
}
/** Close the page pointer. Called internally by PdfiumDocument. */
_close(): void {
this._m.FPDF_ClosePage(this._pagePtr);
}
}
// ---------------------------------------------------------------------------
// Document abstraction
// ---------------------------------------------------------------------------
export class PdfiumDocument {
readonly _m: WrappedPdfiumModule;
readonly _docPtr: number;
private _pages: PdfiumPage[] = [];
private _fonts: Map<string, PdfiumFont> = new Map();
private constructor(m: WrappedPdfiumModule, docPtr: number) {
this._m = m;
this._docPtr = docPtr;
}
/** Create a new empty PDF document. Drop-in replacement for `PDFDocument.create()`. */
static async create(): Promise<PdfiumDocument> {
const m = await getPdfiumModule();
const docPtr = m.FPDF_CreateNewDocument();
if (!docPtr) throw new Error("PDFium: failed to create document");
return new PdfiumDocument(m, docPtr);
}
/** Add a new page to the document. */
addPage(dimensions: [number, number]): PdfiumPage {
const [width, height] = dimensions;
const insertIdx = this._pages.length;
const pagePtr = this._m.FPDFPage_New(
this._docPtr,
insertIdx,
width,
height,
);
if (!pagePtr) throw new Error("PDFium: failed to create page");
const page = new PdfiumPage(this._m, this._docPtr, pagePtr, width, height);
this._pages.push(page);
return page;
}
/** Embed a standard PDF font. Returns a PdfiumFont for text measurement and drawing. */
async embedFont(fontName: string): Promise<PdfiumFont> {
if (this._fonts.has(fontName)) return this._fonts.get(fontName)!;
const font = new PdfiumFont(fontName);
this._fonts.set(fontName, font);
return font;
}
/** Embed a PNG image from raw bytes. */
async embedPng(bytes: Uint8Array | ArrayBuffer): Promise<PdfiumImage> {
return this._decodeImage(
bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes),
"image/png",
);
}
/** Embed a JPEG image from raw bytes. */
async embedJpg(bytes: Uint8Array | ArrayBuffer): Promise<PdfiumImage> {
return this._decodeImage(
bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes),
"image/jpeg",
);
}
/** Get the number of pages. */
getPageCount(): number {
return this._pages.length;
}
/** Save the document and return the PDF bytes. */
async save(): Promise<Uint8Array> {
// Generate content for all pages before saving
for (const page of this._pages) {
page._generateContent();
}
const buf = await saveRawDocument(this._docPtr);
// Close all pages and the document
for (const page of this._pages) {
page._close();
}
this._m.FPDF_CloseDocument(this._docPtr);
return new Uint8Array(buf);
}
/**
* Decode image bytes to RGBA pixel data via canvas.
* PDFium's image object APIs require bitmap data.
*/
private _decodeImage(
bytes: Uint8Array,
mimeType: string,
): Promise<PdfiumImage> {
return new Promise((resolve, reject) => {
const blob = new Blob([bytes as BlobPart], { type: mimeType });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error("Canvas 2D context unavailable"));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
resolve(
new PdfiumImage(
new Uint8Array(imageData.data.buffer),
canvas.width,
canvas.height,
),
);
} catch (err) {
URL.revokeObjectURL(url);
reject(err);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to decode image"));
};
img.src = url;
});
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
// Module-level storage for file path mappings (quickKey -> localFilePath)
// Used to pass file paths from Tauri file dialog to FileManagerContext
export const pendingFilePathMappings = new Map<string, string>();
@@ -0,0 +1,198 @@
import PixelCompareWorkerCtor from "@app/workers/pixelCompareWorker?worker";
import {
ADDITION_HIGHLIGHT,
REMOVAL_HIGHLIGHT,
type ComparePixelPageResult,
type CompareResultPixelData,
type PixelCompareWorkerErrors,
type PixelCompareWorkerRequest,
type PixelCompareWorkerResponse,
type PixelCompareWorkerWarnings,
type PixelRgb,
} from "@app/types/compare";
const hexToRgb = (hex: string): PixelRgb => {
const normalised = hex.replace("#", "");
const value = parseInt(
normalised.length === 3
? normalised
.split("")
.map((c) => c + c)
.join("")
: normalised,
16,
);
return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
};
// Keep pixel mode visually consistent with text mode:
// - removed (content in base, not in comparison) → REMOVAL_HIGHLIGHT (red)
// - added (content in comparison, not in base) → ADDITION_HIGHLIGHT (green)
const DEFAULT_DIFF_COLOR: PixelRgb = hexToRgb(REMOVAL_HIGHLIGHT);
const DEFAULT_DIFF_COLOR_ALT: PixelRgb = hexToRgb(ADDITION_HIGHLIGHT);
export const revokePixelResult = (
result: CompareResultPixelData | null | undefined,
): void => {
if (!result) return;
for (const page of result.pages) {
URL.revokeObjectURL(page.baseImageUrl);
URL.revokeObjectURL(page.comparisonImageUrl);
URL.revokeObjectURL(page.diffImageUrl);
}
};
export interface RunPixelCompareArgs {
baseFile: File;
comparisonFile: File;
baseFileId: string;
comparisonFileId: string;
dpi: number;
threshold: number;
onProgress?: (pageNumber: number, totalPages: number) => void;
onPageReady?: (page: ComparePixelPageResult) => void;
signal?: { cancelled: boolean };
warnings: PixelCompareWorkerWarnings;
errors: PixelCompareWorkerErrors;
// Max in-flight pages processed in parallel inside the worker.
concurrency?: number;
// Override diff highlight colours. Defaults to REMOVAL_HIGHLIGHT / ADDITION_HIGHLIGHT
// so pixel mode matches text mode (removed = red, added = green).
diffColor?: PixelRgb;
diffColorAlt?: PixelRgb;
}
export const runPixelCompare = async ({
baseFile,
comparisonFile,
baseFileId,
comparisonFileId,
dpi,
threshold,
onProgress,
onPageReady,
signal,
warnings,
errors,
concurrency,
diffColor = DEFAULT_DIFF_COLOR,
diffColorAlt = DEFAULT_DIFF_COLOR_ALT,
}: RunPixelCompareArgs): Promise<CompareResultPixelData> => {
if (signal?.cancelled) throw new Error("CANCELLED");
const worker = new PixelCompareWorkerCtor();
const emittedUrls: string[] = [];
const pages: ComparePixelPageResult[] = [];
const cleanupOnFailure = () => {
for (const url of emittedUrls) URL.revokeObjectURL(url);
};
return await new Promise<CompareResultPixelData>((resolve, reject) => {
const handleMessage = (event: MessageEvent<PixelCompareWorkerResponse>) => {
const message = event.data;
if (!message) return;
if (signal?.cancelled) {
terminateWorker();
cleanupOnFailure();
reject(new Error("CANCELLED"));
return;
}
switch (message.type) {
case "progress": {
onProgress?.(message.pageNumber, message.totalPages);
break;
}
case "page": {
const payload = message.page;
const baseUrl = URL.createObjectURL(payload.baseBlob);
const comparisonUrl = URL.createObjectURL(payload.comparisonBlob);
const diffUrl = URL.createObjectURL(payload.diffBlob);
emittedUrls.push(baseUrl, comparisonUrl, diffUrl);
const pageResult: ComparePixelPageResult = {
pageNumber: payload.pageNumber,
width: payload.width,
height: payload.height,
baseImageUrl: baseUrl,
comparisonImageUrl: comparisonUrl,
diffImageUrl: diffUrl,
diffPixels: payload.diffPixels,
totalPixels: payload.totalPixels,
diffRatio: payload.diffRatio,
sizeMismatch: payload.sizeMismatch,
...(payload.missingBase ? { missingBase: true } : {}),
...(payload.missingComparison ? { missingComparison: true } : {}),
};
pages.push(pageResult);
onPageReady?.(pageResult);
break;
}
case "success": {
pages.sort((a, b) => a.pageNumber - b.pageNumber);
terminateWorker();
resolve({
mode: "pixel",
base: { fileId: baseFileId, fileName: baseFile.name },
comparison: {
fileId: comparisonFileId,
fileName: comparisonFile.name,
},
pages,
totals: {
...message.totals,
processedAt: Date.now(),
},
warnings: message.warnings,
settings: { dpi, threshold },
});
break;
}
case "error": {
terminateWorker();
cleanupOnFailure();
reject(new Error(message.message));
break;
}
}
};
const handleError = (event: ErrorEvent) => {
terminateWorker();
cleanupOnFailure();
reject(
event.error ?? new Error(event.message || "Pixel compare worker error"),
);
};
const terminateWorker = () => {
worker.removeEventListener("message", handleMessage as EventListener);
worker.removeEventListener("error", handleError as EventListener);
try {
worker.terminate();
} catch {
/* swallow */
}
};
worker.addEventListener("message", handleMessage as EventListener);
worker.addEventListener("error", handleError as EventListener);
const request: PixelCompareWorkerRequest = {
type: "pixel-compare",
payload: {
baseFile,
comparisonFile,
dpi,
threshold,
concurrency,
warnings,
errors,
diffColor,
diffColorAlt,
},
};
worker.postMessage(request);
});
};
@@ -0,0 +1,134 @@
import {
type ToolPanelMode,
DEFAULT_TOOL_PANEL_MODE,
} from "@app/constants/toolPanel";
import { type ThemeMode, getSystemTheme } from "@app/constants/theme";
export type LogoVariant = "modern" | "classic";
export type PdfRenderMode = "normal" | "dark" | "sepia";
export type StartupView = "tools" | "read" | "automate";
export type ViewerZoomSetting =
| "auto"
| "fitWidth"
| "fitPage"
| "50"
| "75"
| "100"
| "125"
| "150"
| "200";
export interface UserPreferences {
autoUnzip: boolean;
autoUnzipFileLimit: number;
defaultToolPanelMode: ToolPanelMode;
defaultStartupView: StartupView;
defaultViewerZoom: ViewerZoomSetting;
theme: ThemeMode;
toolPanelModePromptSeen: boolean;
hasSelectedToolPanelMode: boolean;
showLegacyToolDescriptions: boolean;
hasCompletedOnboarding: boolean;
hasSeenIntroOnboarding: boolean;
hasSeenCookieBanner: boolean;
hideUnavailableTools: boolean;
hideUnavailableConversions: boolean;
logoVariant: LogoVariant | null;
pdfRenderMode: PdfRenderMode;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
autoUnzip: true,
autoUnzipFileLimit: 4,
defaultToolPanelMode: DEFAULT_TOOL_PANEL_MODE,
defaultStartupView: "tools",
defaultViewerZoom: "auto",
theme: getSystemTheme(),
toolPanelModePromptSeen: false,
hasSelectedToolPanelMode: false,
showLegacyToolDescriptions: false,
hasCompletedOnboarding: false,
hasSeenIntroOnboarding: false,
hasSeenCookieBanner: false,
hideUnavailableTools: false,
hideUnavailableConversions: false,
logoVariant: null,
pdfRenderMode: "normal",
};
const STORAGE_KEY = "stirlingpdf_preferences";
class PreferencesService {
private serverDefaults: Partial<UserPreferences> = {};
setServerDefaults(defaults: Partial<UserPreferences>): void {
this.serverDefaults = defaults;
}
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);
}
// Use server defaults if available, otherwise use hardcoded defaults
if (key in this.serverDefaults && this.serverDefaults[key] !== undefined) {
return this.serverDefaults[key]!;
}
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 server defaults first, then stored preferences
return {
...DEFAULT_PREFERENCES,
...this.serverDefaults,
...preferences,
};
}
} catch (error) {
console.error("Error reading preferences", error);
}
// Merge server defaults with hardcoded defaults
return { ...DEFAULT_PREFERENCES, ...this.serverDefaults };
}
clearAllPreferences(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error("Error clearing preferences:", error);
throw error;
}
}
}
export const preferencesService = new PreferencesService();
@@ -0,0 +1,145 @@
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());
}
}
@@ -0,0 +1,306 @@
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));
}
}
@@ -0,0 +1,8 @@
/**
* Core stub for SaaS backend error interception.
* Desktop layer shadows this with the real implementation.
* In web builds there are no SaaS requests, so this always returns false.
*/
export function handleSaaSError(_error: unknown): boolean {
return false;
}
@@ -0,0 +1,174 @@
import JSZip from "jszip";
import { fileStorage } from "@app/services/fileStorage";
import type { FileId, ToolOperation } from "@app/types/file";
import type { StirlingFileStub } from "@app/types/fileContext";
interface ShareBundleEntry {
logicalId: string;
rootLogicalId: string;
parentLogicalId?: string;
versionNumber: number;
name: string;
type: string;
size: number;
lastModified: number;
toolHistory?: ToolOperation[];
filePath: string;
isLeaf: boolean;
}
export interface ShareBundleManifest {
schemaVersion: 1;
rootLogicalId: string;
rootLogicalIds: string[];
createdAt: number;
entries: ShareBundleEntry[];
}
function sanitizeFilename(name: string): string {
const trimmed = name?.trim();
if (!trimmed) return "file";
return trimmed.replace(/[\\/:*?"<>|]/g, "_");
}
export async function buildHistoryBundle(
originalFileIds: FileId[] | FileId,
): Promise<{
bundleFile: File;
manifest: ShareBundleManifest;
}> {
const roots = Array.isArray(originalFileIds)
? originalFileIds
: [originalFileIds];
const uniqueRoots = Array.from(new Set(roots));
const allStubs: Array<{
rootId: FileId;
stubs: Awaited<ReturnType<typeof fileStorage.getHistoryChainStubs>>;
}> = [];
for (const rootId of uniqueRoots) {
const stubs = await fileStorage.getHistoryChainStubs(rootId);
if (stubs.length === 0) {
throw new Error("No history chain found for file.");
}
allStubs.push({ rootId, stubs });
}
const zip = new JSZip();
const entries: ShareBundleEntry[] = [];
for (const chain of allStubs) {
for (const stub of chain.stubs) {
const file = await fileStorage.getStirlingFile(stub.id);
if (!file) {
throw new Error(`Missing file data for ${stub.name || stub.id}`);
}
const logicalId = stub.id;
const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || "file")}`;
const buffer = await file.arrayBuffer();
zip.file(filePath, buffer);
entries.push({
logicalId,
rootLogicalId: chain.rootId,
parentLogicalId: stub.parentFileId,
versionNumber: stub.versionNumber || 1,
name: stub.name,
type: stub.type,
size: stub.size,
lastModified: stub.lastModified,
toolHistory: stub.toolHistory,
filePath,
isLeaf: Boolean(stub.isLeaf),
});
}
}
const manifest: ShareBundleManifest = {
schemaVersion: 1,
rootLogicalId: uniqueRoots[0],
rootLogicalIds: uniqueRoots,
createdAt: Date.now(),
entries,
};
zip.file("stirling-share.json", JSON.stringify(manifest, null, 2));
const zipBlob = await zip.generateAsync({
type: "blob",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
const firstStubName = allStubs[0]?.stubs[0]?.name || "shared";
const rootName = sanitizeFilename(firstStubName);
const bundleFile = new File([zipBlob], `${rootName}-history.zip`, {
type: "application/zip",
lastModified: Date.now(),
});
return { bundleFile, manifest };
}
export async function buildSharePackage(stubs: StirlingFileStub[]): Promise<{
bundleFile: File;
manifest: ShareBundleManifest;
}> {
if (stubs.length === 0) {
throw new Error("No files provided for sharing.");
}
const zip = new JSZip();
const entries: ShareBundleEntry[] = [];
for (const stub of stubs) {
const file = await fileStorage.getStirlingFile(stub.id as FileId);
if (!file) {
throw new Error(`Missing file data for ${stub.name || stub.id}`);
}
const logicalId = stub.id as string;
const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || "file")}`;
const buffer = await file.arrayBuffer();
zip.file(filePath, buffer);
entries.push({
logicalId,
rootLogicalId: logicalId,
versionNumber: stub.versionNumber || 1,
name: stub.name,
type: stub.type,
size: stub.size,
lastModified: stub.lastModified,
toolHistory: stub.toolHistory,
filePath,
isLeaf: true,
});
}
const rootLogicalIds = entries.map((entry) => entry.logicalId);
const manifest: ShareBundleManifest = {
schemaVersion: 1,
rootLogicalId: rootLogicalIds[0],
rootLogicalIds,
createdAt: Date.now(),
entries,
};
zip.file("stirling-share.json", JSON.stringify(manifest, null, 2));
const zipBlob = await zip.generateAsync({
type: "blob",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
const bundleFile = new File([zipBlob], `shared-files.zip`, {
type: "application/zip",
lastModified: Date.now(),
});
return { bundleFile, manifest };
}
@@ -0,0 +1,148 @@
import apiClient from "@app/services/apiClient";
import { fileStorage } from "@app/services/fileStorage";
import {
buildHistoryBundle,
buildSharePackage,
} from "@app/services/serverStorageBundle";
import type { FileId } from "@app/types/file";
import type { StirlingFileStub } from "@app/types/fileContext";
function resolveUpdatedAt(value: unknown): number {
if (!value) {
return Date.now();
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : Date.now();
}
const parsed = new Date(String(value)).getTime();
return Number.isFinite(parsed) ? parsed : Date.now();
}
export async function uploadHistoryChain(
originalFileId: FileId,
existingRemoteId?: number,
): Promise<{ remoteId: number; updatedAt: number; chain: StirlingFileStub[] }> {
const chain = await fileStorage.getHistoryChainStubs(originalFileId);
if (chain.length === 0) {
throw new Error("No history chain found.");
}
const finalStub =
chain
.slice()
.reverse()
.find((stub) => stub.isLeaf !== false) || chain[chain.length - 1];
const finalFile = await fileStorage.getStirlingFile(finalStub.id);
if (!finalFile) {
throw new Error("Missing final file data for sharing.");
}
const { bundleFile, manifest } = await buildHistoryBundle(originalFileId);
const auditLog = new File(
[JSON.stringify(manifest, null, 2)],
"audit-log.json",
{
type: "application/json",
lastModified: Date.now(),
},
);
const formData = new FormData();
formData.append("file", finalFile, finalFile.name);
formData.append("historyBundle", bundleFile, bundleFile.name);
formData.append("auditLog", auditLog, auditLog.name);
if (existingRemoteId) {
const response = await apiClient.put(
`/api/v1/storage/files/${existingRemoteId}`,
formData,
);
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId: existingRemoteId, updatedAt, chain };
}
const response = await apiClient.post("/api/v1/storage/files", formData);
const remoteId = response.data?.id as number | undefined;
if (!remoteId) {
throw new Error("Missing stored file ID in response.");
}
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId, updatedAt, chain };
}
export async function uploadHistoryChains(
originalFileIds: FileId[],
existingRemoteId?: number,
): Promise<{ remoteId: number; updatedAt: number; chain: StirlingFileStub[] }> {
const uniqueRoots = Array.from(new Set(originalFileIds));
const chainMap = new Map<FileId, StirlingFileStub[]>();
const combinedChain: StirlingFileStub[] = [];
const seenIds = new Set<FileId>();
const leafStubs: StirlingFileStub[] = [];
for (const rootId of uniqueRoots) {
const chain = await fileStorage.getHistoryChainStubs(rootId);
if (chain.length === 0) {
throw new Error("No history chain found.");
}
chainMap.set(rootId, chain);
const finalStub =
chain
.slice()
.reverse()
.find((stub) => stub.isLeaf !== false) || chain[chain.length - 1];
if (finalStub) {
leafStubs.push(finalStub);
}
for (const stub of chain) {
if (!seenIds.has(stub.id as FileId)) {
seenIds.add(stub.id as FileId);
combinedChain.push(stub);
}
}
}
let shareFile: File;
if (leafStubs.length === 1) {
const finalFile = await fileStorage.getStirlingFile(leafStubs[0].id);
if (!finalFile) {
throw new Error("Missing final file data for sharing.");
}
shareFile = finalFile;
} else {
const { bundleFile } = await buildSharePackage(leafStubs);
shareFile = bundleFile;
}
const { bundleFile, manifest } = await buildHistoryBundle(uniqueRoots);
const auditLog = new File(
[JSON.stringify(manifest, null, 2)],
"audit-log.json",
{
type: "application/json",
lastModified: Date.now(),
},
);
const formData = new FormData();
formData.append("file", shareFile, shareFile.name);
formData.append("historyBundle", bundleFile, bundleFile.name);
formData.append("auditLog", auditLog, auditLog.name);
if (existingRemoteId) {
const response = await apiClient.put(
`/api/v1/storage/files/${existingRemoteId}`,
formData,
);
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId: existingRemoteId, updatedAt, chain: combinedChain };
}
const response = await apiClient.post("/api/v1/storage/files", formData);
const remoteId = response.data?.id as number | undefined;
if (!remoteId) {
throw new Error("Missing stored file ID in response.");
}
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId, updatedAt, chain: combinedChain };
}
@@ -0,0 +1,119 @@
import JSZip from "jszip";
import type { ShareBundleManifest } from "@app/services/serverStorageBundle";
const MANIFEST_FILENAME = "stirling-share.json";
export function parseContentDispositionFilename(
disposition?: string,
): string | null {
if (!disposition) return null;
const filenameMatch = /filename="([^"]+)"/i.exec(disposition);
if (filenameMatch?.[1]) return filenameMatch[1];
const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1]);
} catch {
return utf8Match[1];
}
}
return null;
}
export function isZipBundle(contentType: string, filename: string): boolean {
return contentType.includes("zip") || filename.toLowerCase().endsWith(".zip");
}
export function getShareBundleEntryRootId(
manifest: ShareBundleManifest,
entry: ShareBundleManifest["entries"][number],
): string {
return entry.rootLogicalId || manifest.rootLogicalId;
}
export function resolveShareBundleOrder(manifest: ShareBundleManifest): {
rootOrder: string[];
sortedEntries: ShareBundleManifest["entries"];
} {
const entryRootId = (entry: ShareBundleManifest["entries"][number]) =>
getShareBundleEntryRootId(manifest, entry);
const rootOrder =
manifest.rootLogicalIds && manifest.rootLogicalIds.length > 0
? manifest.rootLogicalIds
: Array.from(new Set(manifest.entries.map(entryRootId)));
const sortedEntries: ShareBundleManifest["entries"] = [];
for (const rootId of rootOrder) {
const rootEntries = manifest.entries
.filter((entry) => entryRootId(entry) === rootId)
.sort((a, b) => a.versionNumber - b.versionNumber);
sortedEntries.push(...rootEntries);
}
return { rootOrder, sortedEntries };
}
export async function loadShareBundleEntries(blob: Blob): Promise<{
manifest: ShareBundleManifest;
rootOrder: string[];
sortedEntries: ShareBundleManifest["entries"];
files: File[];
} | null> {
const zip = await JSZip.loadAsync(blob);
const manifestEntry = zip.file(MANIFEST_FILENAME);
if (!manifestEntry) {
return null;
}
const manifestText = await manifestEntry.async("text");
const manifest = JSON.parse(manifestText) as ShareBundleManifest;
const { rootOrder, sortedEntries } = resolveShareBundleOrder(manifest);
const files: File[] = [];
for (const entry of sortedEntries) {
const zipEntry = zip.file(entry.filePath);
if (!zipEntry) {
throw new Error(`Missing file entry ${entry.filePath}`);
}
const fileBlob = await zipEntry.async("blob");
files.push(
new File([fileBlob], entry.name, {
type: entry.type,
lastModified: entry.lastModified,
}),
);
}
return { manifest, rootOrder, sortedEntries, files };
}
export async function extractLatestFilesFromBundle(
blob: Blob,
filename: string,
contentType: string,
): Promise<File[]> {
if (!isZipBundle(contentType, filename)) {
return [new File([blob], filename, { type: contentType || blob.type })];
}
const bundle = await loadShareBundleEntries(blob);
if (!bundle) {
return [new File([blob], filename, { type: contentType || blob.type })];
}
const { manifest, rootOrder, sortedEntries, files } = bundle;
const latestByRoot = new Map<string, File>();
for (let i = 0; i < sortedEntries.length; i += 1) {
const entry = sortedEntries[i];
latestByRoot.set(getShareBundleEntryRootId(manifest, entry), files[i]);
}
const latestFiles = rootOrder
.map((rootId) => latestByRoot.get(rootId))
.filter((file): file is File => Boolean(file));
if (latestFiles.length > 0) {
return latestFiles;
}
return [new File([blob], filename, { type: contentType || blob.type })];
}
@@ -0,0 +1,151 @@
/**
* 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";
@@ -0,0 +1,306 @@
import apiClient from "@app/services/apiClient";
import type { SavedSignature } from "@app/types/signature";
export type StorageType = "backend" | "localStorage";
interface SignatureStorageCapabilities {
supportsBackend: boolean;
storageType: StorageType;
}
/**
* Service to handle signature storage with adaptive backend/localStorage fallback
*/
class SignatureStorageService {
private capabilities: SignatureStorageCapabilities | null = null;
private detectionPromise: Promise<SignatureStorageCapabilities> | null = null;
/**
* Detect if backend supports signature storage API
*/
async detectCapabilities(): Promise<SignatureStorageCapabilities> {
// Return cached result if already detected
if (this.capabilities) {
return this.capabilities;
}
// Return in-flight detection if already running
if (this.detectionPromise) {
return this.detectionPromise;
}
// Start new detection
this.detectionPromise = this._performDetection();
this.capabilities = await this.detectionPromise;
this.detectionPromise = null;
return this.capabilities;
}
private async _performDetection(): Promise<SignatureStorageCapabilities> {
try {
// Probe the proprietary signatures endpoint (requires authentication)
await apiClient.get("/api/v1/proprietary/signatures", {
timeout: 3000,
suppressErrorToast: true,
});
// 200 = Backend available and accessible (authenticated)
console.log(
"[SignatureStorage] Backend signature API detected and accessible (authenticated)",
);
return {
supportsBackend: true,
storageType: "backend",
};
} catch (error: any) {
// Check if it's an HTTP error with status code
if (error?.response?.status === 401 || error?.response?.status === 403) {
// Backend exists but needs auth - gracefully fall back to localStorage
console.log(
"[SignatureStorage] Backend signature API requires authentication, using localStorage",
);
} else if (error?.response?.status === 404) {
// Endpoint doesn't exist (not running proprietary mode)
console.log(
"[SignatureStorage] Backend signature API not available (not in proprietary mode), using localStorage",
);
} else {
// Network error, timeout, or other error
console.log(
"[SignatureStorage] Backend signature API not available, using localStorage",
);
}
return {
supportsBackend: false,
storageType: "localStorage",
};
}
}
/**
* Get current storage type
*/
async getStorageType(): Promise<StorageType> {
const capabilities = await this.detectCapabilities();
return capabilities.storageType;
}
/**
* Load all signatures
*/
async loadSignatures(): Promise<SavedSignature[]> {
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend) {
return this._loadFromBackend();
} else {
return this._loadFromLocalStorage();
}
}
/**
* Save a signature
*/
async saveSignature(signature: SavedSignature): Promise<void> {
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend && signature.scope !== "localStorage") {
await this._saveToBackend(signature);
} else {
// Force scope to localStorage for browser storage
signature.scope = "localStorage";
this._saveToLocalStorage(signature);
}
}
/**
* Delete a signature
*/
async deleteSignature(id: string): Promise<void> {
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend) {
await this._deleteFromBackend(id);
} else {
this._deleteFromLocalStorage(id);
}
}
/**
* Update signature label
*/
async updateSignatureLabel(id: string, label: string): Promise<void> {
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend) {
await this._updateLabelInBackend(id, label);
} else {
this._updateLabelInLocalStorage(id, label);
}
}
// Backend methods
private async _loadFromBackend(): Promise<SavedSignature[]> {
try {
const response = await apiClient.get<SavedSignature[]>(
"/api/v1/proprietary/signatures",
);
const signatures = response.data;
// Fetch image data for each signature and convert to data URLs
const signaturePromises = signatures.map(async (sig) => {
if (
sig.dataUrl &&
sig.dataUrl.startsWith("/api/v1/general/signatures/")
) {
try {
// Fetch image via apiClient (unified endpoint works for both authenticated and unauthenticated)
const imageResponse = await apiClient.get<ArrayBuffer>(
sig.dataUrl,
{
responseType: "arraybuffer",
},
);
// Convert to data URL (base64) for both display and use
const blob = new Blob([imageResponse.data], {
type: imageResponse.headers["content-type"] || "image/png",
});
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
// Use data URL for everything - more reliable than blob URLs
return { ...sig, dataUrl };
} catch (error) {
console.error(
`[SignatureStorage] Failed to load image for ${sig.id}:`,
error,
);
return sig; // Return original if image fetch fails
}
}
return sig;
});
return await Promise.all(signaturePromises);
} catch (error) {
console.error("[SignatureStorage] Failed to load from backend:", error);
return [];
}
}
private async _saveToBackend(signature: SavedSignature): Promise<void> {
await apiClient.post("/api/v1/proprietary/signatures", signature);
}
private async _deleteFromBackend(id: string): Promise<void> {
await apiClient.delete(`/api/v1/proprietary/signatures/${id}`);
}
private async _updateLabelInBackend(
id: string,
label: string,
): Promise<void> {
await apiClient.post(`/api/v1/proprietary/signatures/${id}/label`, {
label,
});
}
// LocalStorage methods
private readonly STORAGE_KEY = "stirling:saved-signatures:v1";
private _loadFromLocalStorage(): SavedSignature[] {
try {
const raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return [];
const signatures = JSON.parse(raw);
// Ensure all localStorage signatures have the correct scope
return signatures.map((sig: SavedSignature) => ({
...sig,
scope: "localStorage" as const,
}));
} catch {
return [];
}
}
private _saveToLocalStorage(signature: SavedSignature): void {
const signatures = this._loadFromLocalStorage();
const index = signatures.findIndex((s) => s.id === signature.id);
if (index >= 0) {
signatures[index] = signature;
} else {
signatures.unshift(signature);
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
}
private _deleteFromLocalStorage(id: string): void {
const signatures = this._loadFromLocalStorage();
const filtered = signatures.filter((s) => s.id !== id);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
}
private _updateLabelInLocalStorage(id: string, label: string): void {
const signatures = this._loadFromLocalStorage();
const signature = signatures.find((s) => s.id === id);
if (signature) {
signature.label = label;
signature.updatedAt = Date.now();
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
}
}
/**
* Migrate signatures from localStorage to backend
*/
async migrateToBackend(): Promise<{ migrated: number; failed: number }> {
const capabilities = await this.detectCapabilities();
if (!capabilities.supportsBackend) {
return { migrated: 0, failed: 0 };
}
const localSignatures = this._loadFromLocalStorage();
if (localSignatures.length === 0) {
return { migrated: 0, failed: 0 };
}
let migrated = 0;
let failed = 0;
for (const signature of localSignatures) {
try {
await this._saveToBackend(signature);
migrated++;
} catch (error) {
console.error(
`[SignatureStorage] Failed to migrate signature ${signature.id}:`,
error,
);
failed++;
}
}
// Clear localStorage after successful migration
if (migrated > 0 && failed === 0) {
localStorage.removeItem(this.STORAGE_KEY);
console.log(
`[SignatureStorage] Successfully migrated ${migrated} signatures to backend`,
);
}
return { migrated, failed };
}
}
export const signatureStorageService = new SignatureStorageService();
@@ -0,0 +1,70 @@
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;
}
@@ -0,0 +1,330 @@
/**
* High-performance thumbnail generation service using main thread processing
*/
import { FileId } from "@app/types/file";
import {
openRawDocumentSafe,
closeRawDocument,
} from "@app/services/pdfiumService";
import { renderPdfiumPageDataUrl } from "@app/utils/pdfiumPageRender";
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 {
docPtr: number;
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 PDFium document pointer.
*/
private async getCachedPDFDocument(
fileId: FileId,
pdfArrayBuffer: ArrayBuffer,
): Promise<number> {
const cached = this.pdfDocumentCache.get(fileId);
if (cached) {
cached.lastUsed = Date.now();
cached.refCount++;
return cached.docPtr;
}
while (this.pdfDocumentCache.size >= this.maxPdfCacheSize) {
this.evictLeastRecentlyUsedPDF();
}
const docPtr = await openRawDocumentSafe(pdfArrayBuffer);
this.pdfDocumentCache.set(fileId, {
docPtr,
lastUsed: Date.now(),
refCount: 1,
});
return docPtr;
}
/**
* 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) {
void closeRawDocument(oldestEntry[1].docPtr);
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 docPtr = 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 thumbnail = await renderPdfiumPageDataUrl(
docPtr,
pageNumber - 1,
scale,
{ applyRotation: false, format: "jpeg", quality },
);
if (!thumbnail) {
throw new Error(`Could not render page ${pageNumber}`);
}
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) {
void closeRawDocument(cached.docPtr);
}
this.pdfDocumentCache.clear();
}
clearPDFCacheForFile(fileId: FileId): void {
const cached = this.pdfDocumentCache.get(fileId);
if (cached) {
void closeRawDocument(cached.docPtr);
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) {
void closeRawDocument(cached.docPtr);
this.pdfDocumentCache.delete(fileId);
}
}
destroy(): void {
this.clearCache();
this.clearPDFCache();
}
}
// Global singleton instance
export const thumbnailGenerationService = new ThumbnailGenerationService();
@@ -0,0 +1,208 @@
import { DOWNLOAD_BASE_URL } from "@app/constants/downloads";
export interface UpdateSummary {
latest_version: string | null;
latest_stable_version?: string | null;
max_priority: "urgent" | "normal" | "minor" | "low";
recommended_action?: string;
any_breaking: boolean;
migration_guides?: Array<{
version: string;
notes: string;
url: string;
}>;
}
export interface VersionUpdate {
version: string;
priority: "urgent" | "normal" | "minor" | "low";
announcement: {
title: string;
message: string;
};
compatibility: {
breaking_changes: boolean;
breaking_description?: string;
migration_guide_url?: string;
};
}
export interface FullUpdateInfo {
latest_version: string;
latest_stable_version?: string;
new_versions: VersionUpdate[];
}
export interface MachineInfo {
machineType: string;
activeSecurity: boolean;
licenseType: string;
}
export class UpdateService {
private readonly baseUrl =
"https://supabase.stirling.com/functions/v1/updates";
/**
* Compare two version strings
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
*/
compareVersions(version1: string, version2: string): number {
const v1 = version1.split(".");
const v2 = version2.split(".");
for (let i = 0; i < v1.length || i < v2.length; i++) {
const n1 = parseInt(v1[i]) || 0;
const n2 = parseInt(v2[i]) || 0;
if (n1 > n2) {
return 1;
} else if (n1 < n2) {
return -1;
}
}
return 0;
}
/**
* Get download URL based on machine type and security settings
*/
getDownloadUrl(machineInfo: MachineInfo): string | null {
// Only show download for non-Docker installations
if (
machineInfo.machineType === "Docker" ||
machineInfo.machineType === "Kubernetes"
) {
return null;
}
// Determine file based on machine type and security
if (machineInfo.machineType === "Server-jar") {
return (
DOWNLOAD_BASE_URL +
(machineInfo.activeSecurity
? "Stirling-PDF-with-login.jar"
: "Stirling-PDF.jar")
);
}
// Client installations
if (machineInfo.machineType.startsWith("Client-")) {
const os = machineInfo.machineType.replace("Client-", ""); // win, mac, unix
const type = machineInfo.activeSecurity ? "-server-security" : "-server";
if (os === "unix") {
return DOWNLOAD_BASE_URL + os + type + ".jar";
} else if (os === "win") {
return DOWNLOAD_BASE_URL + os + "-installer.exe";
} else if (os === "mac") {
return DOWNLOAD_BASE_URL + os + "-installer.dmg";
}
}
return null;
}
/**
* Fetch update summary from API
*/
async getUpdateSummary(
currentVersion: string,
machineInfo: MachineInfo,
): Promise<UpdateSummary | null> {
// Map Java License enum to API types
let type = "normal";
if (machineInfo.licenseType === "SERVER") {
type = "server";
} else if (machineInfo.licenseType === "ENTERPRISE") {
type = "enterprise";
}
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=true`;
console.log("Fetching update summary from:", url);
try {
const response = await fetch(url);
console.log("Response status:", response.status);
if (response.status === 200) {
const data = await response.json();
return data as UpdateSummary;
} else {
console.error(
"Failed to fetch update summary from Supabase:",
response.status,
);
return null;
}
} catch (error) {
console.error("Failed to fetch update summary from Supabase:", error);
return null;
}
}
/**
* Fetch full update information with detailed version info
*/
async getFullUpdateInfo(
currentVersion: string,
machineInfo: MachineInfo,
): Promise<FullUpdateInfo | null> {
// Map Java License enum to API types
let type = "normal";
if (machineInfo.licenseType === "SERVER") {
type = "server";
} else if (machineInfo.licenseType === "ENTERPRISE") {
type = "enterprise";
}
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=false`;
console.log("Fetching full update info from:", url);
try {
const response = await fetch(url);
console.log("Full update response status:", response.status);
if (response.status === 200) {
const data = await response.json();
return data as FullUpdateInfo;
} else {
console.error(
"Failed to fetch full update info from Supabase:",
response.status,
);
return null;
}
} catch (error) {
console.error("Failed to fetch full update info from Supabase:", error);
return null;
}
}
/**
* Get current version from GitHub build.gradle as fallback
*/
async getCurrentVersionFromGitHub(): Promise<string> {
const url =
"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle";
try {
const response = await fetch(url);
if (response.status === 200) {
const text = await response.text();
const versionRegex = /version\s*=\s*['"](\d+\.\d+\.\d+)['"]/;
const match = versionRegex.exec(text);
if (match) {
return match[1];
}
}
throw new Error("Version number not found");
} catch (error) {
console.error("Failed to fetch latest version from build.gradle:", error);
return "";
}
}
}
export const updateService = new UpdateService();
@@ -0,0 +1,63 @@
import apiClient from "@app/services/apiClient";
export interface EndpointStatistic {
endpoint: string;
visits: number;
percentage: number;
}
export interface EndpointStatisticsResponse {
endpoints: EndpointStatistic[];
totalEndpoints: number;
totalVisits: number;
}
export interface UsageChartData {
labels: string[];
values: number[];
}
const usageAnalyticsService = {
/**
* Get endpoint statistics
*/
async getEndpointStatistics(
limit?: number,
dataType: "all" | "api" | "ui" = "all",
): Promise<EndpointStatisticsResponse> {
const params: Record<string, any> = {};
if (limit !== undefined) {
params.limit = limit;
}
if (dataType !== "all") {
params.dataType = dataType;
}
const response = await apiClient.get<EndpointStatisticsResponse>(
"/api/v1/proprietary/ui-data/usage-endpoint-statistics",
{
params,
},
);
return response.data;
},
/**
* Get chart data for endpoint usage
*/
async getChartData(
limit?: number,
dataType: "all" | "api" | "ui" = "all",
): Promise<UsageChartData> {
const stats = await this.getEndpointStatistics(limit, dataType);
return {
labels: stats.endpoints.map((e) => e.endpoint),
values: stats.endpoints.map((e) => e.visits),
};
},
};
export default usageAnalyticsService;
@@ -0,0 +1,746 @@
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
// Warn user when extracting ZIP with more than this many files
public static readonly ZIP_WARNING_THRESHOLD = 20;
// 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"}`,
{
cause: 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
* Returns both the extraction decision and file count to avoid redundant ZIP parsing
*
* @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 Object with shouldExtract flag and fileCount
*/
async shouldUnzip(
zipBlob: Blob | File,
autoUnzip: boolean,
autoUnzipFileLimit: number,
skipAutoUnzip: boolean = false,
): Promise<{ shouldExtract: boolean; fileCount: number }> {
try {
// Automation always extracts - but still need to count files for warning
if (skipAutoUnzip) {
const zip = new JSZip();
const zipContents = await zip.loadAsync(zipBlob);
const fileCount = Object.values(zipContents.files).filter(
(entry) => !entry.dir,
).length;
return { shouldExtract: true, fileCount };
}
// Check if auto-unzip is enabled
if (!autoUnzip) {
return { shouldExtract: false, fileCount: 0 };
}
// Load ZIP and count files (single parse)
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 {
shouldExtract: fileCount <= autoUnzipFileLimit,
fileCount,
};
} catch (error) {
console.error("Error checking shouldUnzip:", error);
// On error, default to not extracting (safer)
return { shouldExtract: false, fileCount: 0 };
}
}
/**
* Extract files from ZIP with HTML detection and preference checking
* 1. Check for HTML files → keep zipped if present
* 2. Check user preferences → respect autoUnzipFileLimit
* 3. Show warning for large ZIPs (>20 files) if callback provided
* 4. 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;
confirmLargeExtraction?: (
fileCount: number,
fileName: string,
) => Promise<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 (returns both decision and count)
const { shouldExtract, fileCount } = await this.shouldUnzip(
zipBlob,
options.autoUnzip,
options.autoUnzipFileLimit,
options.skipAutoUnzip || false,
);
if (!shouldExtract) {
return [zipFile];
}
// Warn user if ZIP is large (fileCount already obtained from shouldUnzip)
if (
fileCount > ZipFileService.ZIP_WARNING_THRESHOLD &&
options.confirmLargeExtraction
) {
const userConfirmed = await options.confirmLargeExtraction(
fileCount,
zipFile.name,
);
if (!userConfirmed) {
return [zipFile]; // User cancelled, keep ZIP as-is
}
}
// 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();