mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -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; // 0–100
|
||||
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 0–1). 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();
|
||||
Reference in New Issue
Block a user