mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
Add frontend autoformatting and set CI to require formatted code for all languages (#6052)
# Description of Changes Changes the strategy for autoformatting to reject PRs if they are not formatted correctly instead of allowing them to merge and then spawning a new PR to fix the formatting. The old strategy just caused more work for us because we'd have to manually approve the followup PR and get it merged, which required 2 reviewers so in practice it rarely got done and just meant everyone's PRs ended up containing reformatting for unrelated files, which makes code review unnecessarily difficult. If the PR's code is not formatted correctly after this PR, a comment will be added automatically to tell the author how to run the formatter script to fix their code so it can go in. This also enables autoformatting for the frontend code, using Prettier. I've enabled it for pretty much everything in the frontend folder, other than 3rd party files and files it doesn't make sense for. I also excluded Markdown because it sounds likely to be more annoying to have to autoformat the Markdown in the frontend folder but nowhere else. Open to changing this though if people disagree. > [!note] > > Advice to reviewers: The first commit contains all of the actual logic I've introduced (CI changes, Prettier config, etc.) > The second commit is just the reformatting of the entire frontend folder. > The first commit needs proper review, the second one just give it a spot-check that it's doing what you'd expect.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { MfaSetupResponse } from '@app/responses/Mfa/MfaResponse';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
export interface AccountData {
|
||||
username: string;
|
||||
@@ -28,7 +28,7 @@ export const accountService = {
|
||||
* 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');
|
||||
const response = await apiClient.get<LoginPageData>("/api/v1/proprietary/ui-data/login");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ export const accountService = {
|
||||
* Get current user account data
|
||||
*/
|
||||
async getAccountData(): Promise<AccountData> {
|
||||
const response = await apiClient.get<AccountData>('/api/v1/proprietary/ui-data/account', { suppressErrorToast: true });
|
||||
const response = await apiClient.get<AccountData>("/api/v1/proprietary/ui-data/account", { suppressErrorToast: true });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -45,9 +45,9 @@ export const accountService = {
|
||||
*/
|
||||
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);
|
||||
formData.append("currentPassword", currentPassword);
|
||||
formData.append("newPassword", newPassword);
|
||||
await apiClient.post("/api/v1/user/change-password", formData);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -55,10 +55,10 @@ export const accountService = {
|
||||
*/
|
||||
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'});
|
||||
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" });
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -66,25 +66,25 @@ export const accountService = {
|
||||
*/
|
||||
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);
|
||||
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 });
|
||||
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 });
|
||||
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 });
|
||||
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 });
|
||||
await apiClient.post("/api/v1/auth/mfa/setup/cancel", undefined, { suppressErrorToast: true });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import { handleHttpError } from '@app/services/httpErrorHandler';
|
||||
import { setupApiInterceptors } from '@app/services/apiClientSetup';
|
||||
import { getApiBaseUrl } from '@app/services/apiClientConfig';
|
||||
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',
|
||||
responseType: "json",
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
@@ -19,9 +19,8 @@ apiClient.interceptors.response.use(
|
||||
async (error) => {
|
||||
await handleHttpError(error); // Handle error (shows toast unless suppressed)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
// ---------- Exports ----------
|
||||
export default apiClient;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
// Runtime override to fix hardcoded localhost in builds
|
||||
if (typeof window !== 'undefined' && (window as any).STIRLING_PDF_API_BASE_URL) {
|
||||
if (typeof window !== "undefined" && (window as any).STIRLING_PDF_API_BASE_URL) {
|
||||
return (window as any).STIRLING_PDF_API_BASE_URL;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import { getBrowserId } from '@app/utils/browserIdentifier';
|
||||
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;
|
||||
config.headers["X-Browser-Id"] = browserId;
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
export interface AuditSystemStatus {
|
||||
enabled: boolean;
|
||||
@@ -73,7 +73,7 @@ const auditService = {
|
||||
* Get audit system status
|
||||
*/
|
||||
async getSystemStatus(): Promise<AuditSystemStatus> {
|
||||
const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-dashboard', {
|
||||
const response = await apiClient.get("/api/v1/proprietary/ui-data/audit-dashboard", {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
const data = response.data;
|
||||
@@ -94,8 +94,8 @@ const auditService = {
|
||||
/**
|
||||
* 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', {
|
||||
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;
|
||||
@@ -105,7 +105,7 @@ const auditService = {
|
||||
* 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', {
|
||||
const response = await apiClient.get<AuditEventsResponse>("/api/v1/proprietary/ui-data/audit-events", {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
@@ -114,8 +114,8 @@ const auditService = {
|
||||
/**
|
||||
* 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', {
|
||||
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;
|
||||
@@ -124,13 +124,10 @@ const auditService = {
|
||||
/**
|
||||
* 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', {
|
||||
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',
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
@@ -139,7 +136,7 @@ const auditService = {
|
||||
* Get available event types for filtering
|
||||
*/
|
||||
async getEventTypes(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-event-types');
|
||||
const response = await apiClient.get<string[]>("/api/v1/proprietary/ui-data/audit-event-types");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -147,7 +144,7 @@ const auditService = {
|
||||
* Get list of users for filtering
|
||||
*/
|
||||
async getUsers(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-users');
|
||||
const response = await apiClient.get<string[]>("/api/v1/proprietary/ui-data/audit-users");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -155,7 +152,7 @@ const auditService = {
|
||||
* Clear all audit data from the database (irreversible)
|
||||
*/
|
||||
async clearAllAuditData(): Promise<void> {
|
||||
await apiClient.post('/api/v1/proprietary/ui-data/audit-clear-all', {});
|
||||
await apiClient.post("/api/v1/proprietary/ui-data/audit-clear-all", {});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ export interface AutomationConfig {
|
||||
}
|
||||
|
||||
class AutomationStorage {
|
||||
private dbName = 'StirlingPDF_Automations';
|
||||
private dbName = "StirlingPDF_Automations";
|
||||
private dbVersion = 1;
|
||||
private storeName = 'automations';
|
||||
private storeName = "automations";
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
@@ -25,7 +25,7 @@ class AutomationStorage {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to open automation storage database'));
|
||||
reject(new Error("Failed to open automation storage database"));
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
@@ -35,11 +35,11 @@ class AutomationStorage {
|
||||
|
||||
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 });
|
||||
const store = db.createObjectStore(this.storeName, { keyPath: "id" });
|
||||
store.createIndex("name", "name", { unique: false });
|
||||
store.createIndex("createdAt", "createdAt", { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -49,27 +49,27 @@ class AutomationStorage {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized');
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
|
||||
return this.db;
|
||||
}
|
||||
|
||||
async saveAutomation(automation: Omit<AutomationConfig, 'id' | 'createdAt' | 'updatedAt'>): Promise<AutomationConfig> {
|
||||
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
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.add(automationWithMeta);
|
||||
|
||||
@@ -78,21 +78,21 @@ class AutomationStorage {
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to save automation'));
|
||||
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()
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.put(updatedAutomation);
|
||||
|
||||
@@ -101,7 +101,7 @@ class AutomationStorage {
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to update automation'));
|
||||
reject(new Error("Failed to update automation"));
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -110,7 +110,7 @@ class AutomationStorage {
|
||||
const db = await this.ensureDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
@@ -119,7 +119,7 @@ class AutomationStorage {
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to get automation'));
|
||||
reject(new Error("Failed to get automation"));
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -128,7 +128,7 @@ class AutomationStorage {
|
||||
const db = await this.ensureDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.getAll();
|
||||
|
||||
@@ -140,7 +140,7 @@ class AutomationStorage {
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to get automations'));
|
||||
reject(new Error("Failed to get automations"));
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -149,7 +149,7 @@ class AutomationStorage {
|
||||
const db = await this.ensureDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.delete(id);
|
||||
|
||||
@@ -158,26 +158,27 @@ class AutomationStorage {
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to delete automation'));
|
||||
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))
|
||||
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();
|
||||
export const automationStorage = new AutomationStorage();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||
import { convertSplitPageIdsToIndexes } from '@app/components/pageEditor/utils/splitPositions';
|
||||
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
||||
import { convertSplitPageIdsToIndexes } from "@app/components/pageEditor/utils/splitPositions";
|
||||
|
||||
/**
|
||||
* Service for applying DOM changes to PDF document state
|
||||
@@ -10,36 +10,41 @@ 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[] {
|
||||
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));
|
||||
|
||||
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>();
|
||||
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)
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -47,7 +52,7 @@ export class DocumentManipulationService {
|
||||
* Check if document has split markers
|
||||
*/
|
||||
private hasSplitMarkers(document: PDFDocument): boolean {
|
||||
return document.pages.some(page => page.splitAfter);
|
||||
return document.pages.some((page) => page.splitAfter);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,9 +84,9 @@ export class DocumentManipulationService {
|
||||
documents.push({
|
||||
...document,
|
||||
id: `${document.id}_part_${partNumber}`,
|
||||
name: `${document.name.replace(/\.pdf$/i, '')}_part_${partNumber}.pdf`,
|
||||
name: `${document.name.replace(/\.pdf$/i, "")}_part_${partNumber}.pdf`,
|
||||
pages: segmentPages,
|
||||
totalPages: segmentPages.length
|
||||
totalPages: segmentPages.length,
|
||||
});
|
||||
partNumber++;
|
||||
}
|
||||
@@ -114,15 +119,15 @@ export class DocumentManipulationService {
|
||||
* Read rotation from DOM element
|
||||
*/
|
||||
private getRotationFromDOM(pageElement: Element, originalPage: PDFPage): number {
|
||||
const img = pageElement.querySelector('img');
|
||||
const img = pageElement.querySelector("img");
|
||||
if (img) {
|
||||
const originalRotation = parseInt(img.getAttribute('data-original-rotation') || '0');
|
||||
const originalRotation = parseInt(img.getAttribute("data-original-rotation") || "0");
|
||||
|
||||
const currentTransform = img.style.transform || '';
|
||||
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;
|
||||
const userChange = (((visualRotation - originalRotation) % 360) + 360) % 360;
|
||||
|
||||
let finalRotation = (originalPage.rotation + userChange) % 360;
|
||||
if (finalRotation === 360) finalRotation = 0;
|
||||
@@ -137,12 +142,12 @@ export class DocumentManipulationService {
|
||||
* 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 => {
|
||||
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');
|
||||
const img = pageElement.querySelector("img");
|
||||
if (img) {
|
||||
// Reset rotation to match document state
|
||||
img.style.transform = `rotate(${page.rotation}deg)`;
|
||||
@@ -155,7 +160,7 @@ export class DocumentManipulationService {
|
||||
* Check if DOM state differs from document state
|
||||
*/
|
||||
hasUnsavedChanges(pdfDocument: PDFDocument): boolean {
|
||||
return pdfDocument.pages.some(page => {
|
||||
return pdfDocument.pages.some((page) => {
|
||||
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
|
||||
if (pageElement) {
|
||||
const domRotation = this.getRotationFromDOM(pageElement, page);
|
||||
|
||||
@@ -24,11 +24,7 @@ export async function downloadFile(request: DownloadRequest): Promise<DownloadRe
|
||||
return { savedPath: request.localPath };
|
||||
}
|
||||
|
||||
export async function downloadFromUrl(
|
||||
url: string,
|
||||
filename: string,
|
||||
localPath?: string
|
||||
): Promise<DownloadResult> {
|
||||
export async function downloadFromUrl(url: string, filename: string, localPath?: string): Promise<DownloadResult> {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
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;
|
||||
@@ -17,16 +17,16 @@ export class EnhancedPDFProcessingService {
|
||||
failedFiles: 0,
|
||||
averageProcessingTime: 0,
|
||||
cacheHitRate: 0,
|
||||
memoryUsage: 0
|
||||
memoryUsage: 0,
|
||||
};
|
||||
|
||||
private defaultConfig: ProcessingConfig = {
|
||||
strategy: 'immediate_full',
|
||||
strategy: "immediate_full",
|
||||
chunkSize: 20,
|
||||
thumbnailQuality: 'medium',
|
||||
thumbnailQuality: "medium",
|
||||
priorityPageCount: 10,
|
||||
useWebWorker: false,
|
||||
maxRetries: 3
|
||||
maxRetries: 3,
|
||||
};
|
||||
|
||||
private constructor() {}
|
||||
@@ -47,7 +47,7 @@ export class EnhancedPDFProcessingService {
|
||||
// Check cache first
|
||||
const cached = this.cache.get(fileKey);
|
||||
if (cached) {
|
||||
this.updateMetrics('cacheHit');
|
||||
this.updateMetrics("cacheHit");
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export class EnhancedPDFProcessingService {
|
||||
const config: ProcessingConfig = {
|
||||
...this.defaultConfig,
|
||||
strategy: analysis.recommendedStrategy,
|
||||
...customConfig
|
||||
...customConfig,
|
||||
};
|
||||
|
||||
// Start processing
|
||||
@@ -77,12 +77,7 @@ export class EnhancedPDFProcessingService {
|
||||
/**
|
||||
* Start processing a file with the specified configuration
|
||||
*/
|
||||
private async startProcessing(
|
||||
file: File,
|
||||
fileKey: string,
|
||||
config: ProcessingConfig,
|
||||
estimatedTime: number
|
||||
): Promise<void> {
|
||||
private async startProcessing(file: File, fileKey: string, config: ProcessingConfig, estimatedTime: number): Promise<void> {
|
||||
// Create cancellation token
|
||||
const cancellationToken = new AbortController();
|
||||
|
||||
@@ -90,17 +85,17 @@ export class EnhancedPDFProcessingService {
|
||||
const state: ProcessingState = {
|
||||
fileKey,
|
||||
fileName: file.name,
|
||||
status: 'processing',
|
||||
status: "processing",
|
||||
progress: 0,
|
||||
strategy: config.strategy,
|
||||
startedAt: Date.now(),
|
||||
estimatedTimeRemaining: estimatedTime,
|
||||
cancellationToken
|
||||
cancellationToken,
|
||||
};
|
||||
|
||||
this.processing.set(fileKey, state);
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('started');
|
||||
this.updateMetrics("started");
|
||||
|
||||
try {
|
||||
// Execute processing with retry logic
|
||||
@@ -110,33 +105,32 @@ export class EnhancedPDFProcessingService {
|
||||
state.error = error;
|
||||
this.notifyListeners();
|
||||
},
|
||||
config.maxRetries
|
||||
config.maxRetries,
|
||||
);
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(fileKey, processedFile);
|
||||
|
||||
// Update state to completed
|
||||
state.status = 'completed';
|
||||
state.status = "completed";
|
||||
state.progress = 100;
|
||||
state.completedAt = Date.now();
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('completed', Date.now() - state.startedAt);
|
||||
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);
|
||||
console.error("Processing failed for", file.name, ":", error);
|
||||
|
||||
const processingError = ProcessingErrorHandler.createProcessingError(error);
|
||||
state.status = 'error';
|
||||
state.status = "error";
|
||||
state.error = processingError;
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('failed');
|
||||
this.updateMetrics("failed");
|
||||
|
||||
// Remove failed processing after delay
|
||||
setTimeout(() => {
|
||||
@@ -152,19 +146,19 @@ export class EnhancedPDFProcessingService {
|
||||
private async executeProcessingStrategy(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
state: ProcessingState,
|
||||
): Promise<ProcessedFile> {
|
||||
switch (config.strategy) {
|
||||
case 'immediate_full':
|
||||
case "immediate_full":
|
||||
return this.processImmediateFull(file, config, state);
|
||||
|
||||
case 'priority_pages':
|
||||
case "priority_pages":
|
||||
return this.processPriorityPages(file, config, state);
|
||||
|
||||
case 'progressive_chunked':
|
||||
case "progressive_chunked":
|
||||
return this.processProgressiveChunked(file, config, state);
|
||||
|
||||
case 'metadata_only':
|
||||
case "metadata_only":
|
||||
return this.processMetadataOnly(file, config, state);
|
||||
|
||||
default:
|
||||
@@ -175,11 +169,7 @@ export class EnhancedPDFProcessingService {
|
||||
/**
|
||||
* Process all pages immediately (for small files)
|
||||
*/
|
||||
private async processImmediateFull(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
private async processImmediateFull(file: File, config: ProcessingConfig, state: ProcessingState): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
|
||||
@@ -194,7 +184,7 @@ export class EnhancedPDFProcessingService {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
// Check for cancellation
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
throw new Error('Processing cancelled');
|
||||
throw new Error("Processing cancelled");
|
||||
}
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
@@ -207,7 +197,7 @@ export class EnhancedPDFProcessingService {
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation,
|
||||
selected: false
|
||||
selected: false,
|
||||
});
|
||||
|
||||
// Update progress
|
||||
@@ -227,11 +217,7 @@ export class EnhancedPDFProcessingService {
|
||||
/**
|
||||
* Process priority pages first, then queue the rest
|
||||
*/
|
||||
private async processPriorityPages(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
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;
|
||||
@@ -246,7 +232,7 @@ export class EnhancedPDFProcessingService {
|
||||
for (let i = 1; i <= priorityCount; i++) {
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
throw new Error('Processing cancelled');
|
||||
throw new Error("Processing cancelled");
|
||||
}
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
@@ -257,7 +243,7 @@ export class EnhancedPDFProcessingService {
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: page.rotate || 0,
|
||||
selected: false
|
||||
selected: false,
|
||||
});
|
||||
|
||||
state.progress = 10 + (i / priorityCount) * 60;
|
||||
@@ -272,7 +258,7 @@ export class EnhancedPDFProcessingService {
|
||||
pageNumber: i,
|
||||
thumbnail: null, // Will be loaded lazily
|
||||
rotation: 0,
|
||||
selected: false
|
||||
selected: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -289,7 +275,7 @@ export class EnhancedPDFProcessingService {
|
||||
private async processProgressiveChunked(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
state: ProcessingState,
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
@@ -308,7 +294,7 @@ export class EnhancedPDFProcessingService {
|
||||
for (let i = 1; i <= firstChunkEnd; i++) {
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
throw new Error('Processing cancelled');
|
||||
throw new Error("Processing cancelled");
|
||||
}
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
@@ -319,7 +305,7 @@ export class EnhancedPDFProcessingService {
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: page.rotate || 0,
|
||||
selected: false
|
||||
selected: false,
|
||||
});
|
||||
|
||||
processedPages++;
|
||||
@@ -329,7 +315,7 @@ export class EnhancedPDFProcessingService {
|
||||
|
||||
// Small delay to prevent UI blocking
|
||||
if (i % 5 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +326,7 @@ export class EnhancedPDFProcessingService {
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
selected: false
|
||||
selected: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -354,11 +340,7 @@ export class EnhancedPDFProcessingService {
|
||||
/**
|
||||
* Process metadata only (for very large files)
|
||||
*/
|
||||
private async processMetadataOnly(
|
||||
file: File,
|
||||
_config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
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;
|
||||
@@ -374,7 +356,7 @@ export class EnhancedPDFProcessingService {
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
selected: false
|
||||
selected: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -388,22 +370,22 @@ export class EnhancedPDFProcessingService {
|
||||
/**
|
||||
* Render a page thumbnail with specified quality
|
||||
*/
|
||||
private async renderPageThumbnail(page: any, quality: 'low' | 'medium' | 'high'): Promise<string> {
|
||||
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');
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas 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
|
||||
return canvas.toDataURL("image/jpeg", 0.8); // Use JPEG for better compression
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -417,12 +399,11 @@ export class EnhancedPDFProcessingService {
|
||||
metadata: {
|
||||
title: file.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString()
|
||||
}
|
||||
modifiedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate a unique, collision-resistant cache key
|
||||
*/
|
||||
@@ -437,7 +418,7 @@ export class EnhancedPDFProcessingService {
|
||||
const state = this.processing.get(fileKey);
|
||||
if (state && state.cancellationToken) {
|
||||
state.cancellationToken.abort();
|
||||
state.status = 'cancelled';
|
||||
state.status = "cancelled";
|
||||
this.notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -445,12 +426,12 @@ export class EnhancedPDFProcessingService {
|
||||
/**
|
||||
* Update processing metrics
|
||||
*/
|
||||
private updateMetrics(event: 'started' | 'completed' | 'failed' | 'cacheHit', processingTime?: number): void {
|
||||
private updateMetrics(event: "started" | "completed" | "failed" | "cacheHit", processingTime?: number): void {
|
||||
switch (event) {
|
||||
case 'started':
|
||||
case "started":
|
||||
this.metrics.totalFiles++;
|
||||
break;
|
||||
case 'completed':
|
||||
case "completed":
|
||||
this.metrics.completedFiles++;
|
||||
if (processingTime) {
|
||||
// Update rolling average
|
||||
@@ -458,10 +439,10 @@ export class EnhancedPDFProcessingService {
|
||||
this.metrics.averageProcessingTime = totalProcessingTime / this.metrics.completedFiles;
|
||||
}
|
||||
break;
|
||||
case 'failed':
|
||||
case "failed":
|
||||
this.metrics.failedFiles++;
|
||||
break;
|
||||
case 'cacheHit': {
|
||||
case "cacheHit": {
|
||||
// Update cache hit rate
|
||||
const totalAttempts = this.metrics.totalFiles + 1;
|
||||
this.metrics.cacheHitRate = (this.metrics.cacheHitRate * this.metrics.totalFiles + 1) / totalAttempts;
|
||||
@@ -490,7 +471,7 @@ export class EnhancedPDFProcessingService {
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
this.processingListeners.forEach(callback => callback(this.processing));
|
||||
this.processingListeners.forEach((callback) => callback(this.processing));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
export const FILE_EVENTS = {
|
||||
markError: 'files:markError',
|
||||
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; }
|
||||
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') {
|
||||
if (typeof data?.text === "function") {
|
||||
const text = await data.text();
|
||||
return tryParseJson(text) ?? text;
|
||||
}
|
||||
@@ -21,7 +25,7 @@ export async function normalizeAxiosErrorData(data: any): Promise<any> {
|
||||
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') {
|
||||
if (typeof payload === "string") {
|
||||
const matches = payload.match(UUID_REGEX);
|
||||
if (matches && matches.length > 0) return Array.from(new Set(matches));
|
||||
}
|
||||
@@ -36,12 +40,10 @@ export function broadcastErroredFiles(fileIds: string[]) {
|
||||
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;
|
||||
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);
|
||||
return files.every((f) => (f as any)?.size === 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { FileAnalysis, ProcessingStrategy } from '@app/types/processing';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import { FileAnalysis, ProcessingStrategy } from "@app/types/processing";
|
||||
import { pdfWorkerManager } from "@app/services/pdfWorkerManager";
|
||||
|
||||
export class FileAnalyzer {
|
||||
private static readonly SIZE_THRESHOLDS = {
|
||||
SMALL: 10 * 1024 * 1024, // 10MB
|
||||
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
|
||||
FEW: 10, // < 10 pages - immediate full processing
|
||||
MANY: 50, // < 50 pages - priority pages
|
||||
MASSIVE: 100, // < 100 pages - progressive chunked
|
||||
// >100 pages = metadata only
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export class FileAnalyzer {
|
||||
fileSize: file.size,
|
||||
isEncrypted: false,
|
||||
isCorrupted: false,
|
||||
recommendedStrategy: 'metadata_only',
|
||||
recommendedStrategy: "metadata_only",
|
||||
estimatedProcessingTime: 0,
|
||||
};
|
||||
|
||||
@@ -41,13 +41,12 @@ export class FileAnalyzer {
|
||||
analysis.estimatedProcessingTime = this.estimateProcessingTime(
|
||||
file.size,
|
||||
quickAnalysis.pageCount,
|
||||
analysis.recommendedStrategy
|
||||
analysis.recommendedStrategy,
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('File analysis failed:', error);
|
||||
console.error("File analysis failed:", error);
|
||||
analysis.isCorrupted = true;
|
||||
analysis.recommendedStrategy = 'metadata_only';
|
||||
analysis.recommendedStrategy = "metadata_only";
|
||||
}
|
||||
|
||||
return analysis;
|
||||
@@ -68,7 +67,7 @@ export class FileAnalyzer {
|
||||
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
stopAtErrors: false, // Don't stop at minor errors
|
||||
verbosity: 0 // Suppress PDF.js warnings
|
||||
verbosity: 0, // Suppress PDF.js warnings
|
||||
});
|
||||
|
||||
const pageCount = pdf.numPages;
|
||||
@@ -80,18 +79,17 @@ export class FileAnalyzer {
|
||||
return {
|
||||
pageCount,
|
||||
isEncrypted,
|
||||
isCorrupted: 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');
|
||||
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
|
||||
isCorrupted: !isEncrypted, // If not encrypted, probably corrupted
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -102,59 +100,55 @@ export class FileAnalyzer {
|
||||
private static determineStrategy(fileSize: number, pageCount?: number): ProcessingStrategy {
|
||||
// Handle corrupted or encrypted files
|
||||
if (!pageCount || pageCount === 0) {
|
||||
return 'metadata_only';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
return "progressive_chunked";
|
||||
}
|
||||
|
||||
// Very large files - metadata only
|
||||
return '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 {
|
||||
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)
|
||||
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
|
||||
metadata_only: 50, // 50ms total
|
||||
};
|
||||
|
||||
const baseTime = baseTimes[strategy];
|
||||
|
||||
switch (strategy) {
|
||||
case 'metadata_only':
|
||||
case "metadata_only":
|
||||
return baseTime;
|
||||
|
||||
case 'immediate_full':
|
||||
case "immediate_full":
|
||||
return pageCount * baseTime;
|
||||
|
||||
case 'priority_pages': {
|
||||
case "priority_pages": {
|
||||
// Estimate time for priority pages (first 10)
|
||||
const priorityPages = Math.min(pageCount, 10);
|
||||
return priorityPages * baseTime;
|
||||
}
|
||||
|
||||
case 'progressive_chunked': {
|
||||
case "progressive_chunked": {
|
||||
// Estimate time for first chunk (20 pages)
|
||||
const firstChunk = Math.min(pageCount, 20);
|
||||
return firstChunk * baseTime;
|
||||
@@ -196,7 +190,7 @@ export class FileAnalyzer {
|
||||
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
|
||||
memoryWarning: totalSize > this.SIZE_THRESHOLDS.LARGE || totalPages > this.PAGE_THRESHOLDS.MASSIVE,
|
||||
};
|
||||
|
||||
return { analyses, recommendations };
|
||||
@@ -223,7 +217,7 @@ export class FileAnalyzer {
|
||||
* 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')) {
|
||||
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -233,7 +227,7 @@ export class FileAnalyzer {
|
||||
const headerBytes = new Uint8Array(await header.arrayBuffer());
|
||||
const headerString = String.fromCharCode(...headerBytes);
|
||||
|
||||
return headerString.startsWith('%PDF-');
|
||||
return headerString.startsWith("%PDF-");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -20,9 +20,7 @@ export interface FileDialogOptions {
|
||||
* 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[]> {
|
||||
export async function openFileDialog(_options?: FileDialogOptions): Promise<FileWithPath[]> {
|
||||
// Web build: no native file dialog support
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* 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';
|
||||
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
||||
import { pdfWorkerManager } from "@app/services/pdfWorkerManager";
|
||||
import { FileId } from "@app/types/file";
|
||||
|
||||
export interface ProcessedFileMetadata {
|
||||
totalPages: number;
|
||||
@@ -55,7 +55,7 @@ class FileProcessingService {
|
||||
// Store operation with abort controller
|
||||
const operation: ProcessingOperation = {
|
||||
promise: processingPromise,
|
||||
abortController
|
||||
abortController,
|
||||
};
|
||||
this.processingCache.set(fileId, operation);
|
||||
|
||||
@@ -67,33 +67,37 @@ class FileProcessingService {
|
||||
return processingPromise;
|
||||
}
|
||||
|
||||
private async performProcessing(file: File, fileId: FileId, abortController: AbortController): Promise<FileProcessingResult> {
|
||||
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');
|
||||
throw new Error("Processing cancelled");
|
||||
}
|
||||
|
||||
let totalPages = 1;
|
||||
let thumbnailUrl: string | undefined;
|
||||
|
||||
// Handle PDF files
|
||||
if (file.type === 'application/pdf') {
|
||||
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');
|
||||
throw new Error("Processing cancelled");
|
||||
}
|
||||
|
||||
// Discover page count using PDF.js (most accurate)
|
||||
try {
|
||||
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true
|
||||
disableStream: true,
|
||||
});
|
||||
|
||||
totalPages = pdfDoc.numPages;
|
||||
@@ -104,7 +108,7 @@ class FileProcessingService {
|
||||
|
||||
// Check for cancellation after PDF.js processing
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Processing cancelled');
|
||||
throw new Error("Processing cancelled");
|
||||
}
|
||||
} catch (pdfError) {
|
||||
console.warn(`📁 FileProcessingService: PDF.js failed for ${file.name}, setting pages to 0:`, pdfError);
|
||||
@@ -119,7 +123,7 @@ class FileProcessingService {
|
||||
|
||||
// Check for cancellation after thumbnail generation
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Processing cancelled');
|
||||
throw new Error("Processing cancelled");
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.warn(`📁 FileProcessingService: Thumbnail generation failed for ${file.name}:`, thumbError);
|
||||
@@ -130,29 +134,28 @@ class FileProcessingService {
|
||||
pageNumber: index + 1,
|
||||
thumbnail: index === 0 ? thumbnailUrl : undefined, // Only page 1 gets thumbnail initially
|
||||
rotation: 0,
|
||||
splitBefore: false
|
||||
splitBefore: false,
|
||||
}));
|
||||
|
||||
const metadata: ProcessedFileMetadata = {
|
||||
totalPages,
|
||||
pages,
|
||||
thumbnailUrl, // For FileEditor display
|
||||
lastProcessed: Date.now()
|
||||
lastProcessed: Date.now(),
|
||||
};
|
||||
|
||||
console.log(`📁 FileProcessingService: Processing complete for ${file.name} - ${totalPages} pages`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata
|
||||
metadata,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error(`📁 FileProcessingService: Processing failed for ${file.name}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown processing error'
|
||||
error: error instanceof Error ? error.message : "Unknown processing error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* 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';
|
||||
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
|
||||
@@ -29,7 +29,7 @@ export interface StorageStats {
|
||||
|
||||
class FileStorageService {
|
||||
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||
private readonly storeName = 'files';
|
||||
private readonly storeName = "files";
|
||||
|
||||
/**
|
||||
* Get database connection using centralized manager
|
||||
@@ -70,30 +70,32 @@ class FileStorageService {
|
||||
versionNumber: stub.versionNumber ?? 1,
|
||||
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
|
||||
parentFileId: stub.parentFileId ?? undefined,
|
||||
toolHistory: stub.toolHistory ?? []
|
||||
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(', ')}`);
|
||||
throw new Error(
|
||||
`Object store '${this.storeName}' not found. Available stores: ${Array.from(db.objectStoreNames).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
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);
|
||||
console.error("IndexedDB add error:", request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Transaction error:', error);
|
||||
console.error("Transaction error:", error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
@@ -106,7 +108,7 @@ class FileStorageService {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
@@ -122,7 +124,7 @@ class FileStorageService {
|
||||
const blob = new Blob([record.data], { type: record.type });
|
||||
const file = new File([blob], record.name, {
|
||||
type: record.type,
|
||||
lastModified: record.lastModified
|
||||
lastModified: record.lastModified,
|
||||
});
|
||||
|
||||
// Convert to StirlingFile with preserved IDs
|
||||
@@ -136,7 +138,7 @@ class FileStorageService {
|
||||
* Get multiple StirlingFiles - for batch loading
|
||||
*/
|
||||
async getStirlingFiles(ids: FileId[]): Promise<StirlingFile[]> {
|
||||
const results = await Promise.all(ids.map(id => this.getStirlingFile(id)));
|
||||
const results = await Promise.all(ids.map((id) => this.getStirlingFile(id)));
|
||||
return results.filter((file): file is StirlingFile => file !== null);
|
||||
}
|
||||
|
||||
@@ -147,7 +149,7 @@ class FileStorageService {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
@@ -181,7 +183,7 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory,
|
||||
createdAt: record.createdAt || Date.now()
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
};
|
||||
|
||||
resolve(stub);
|
||||
@@ -196,7 +198,7 @@ class FileStorageService {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.openCursor();
|
||||
const stubs: StirlingFileStub[] = [];
|
||||
@@ -206,7 +208,7 @@ class FileStorageService {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
const record = cursor.value as StoredStirlingFileRecord;
|
||||
if (record && record.name && typeof record.size === 'number') {
|
||||
if (record && record.name && typeof record.size === "number") {
|
||||
// Extract metadata only - no file data
|
||||
stubs.push({
|
||||
id: record.id,
|
||||
@@ -229,7 +231,7 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
createdAt: record.createdAt || Date.now()
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
@@ -257,7 +259,7 @@ class FileStorageService {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.openCursor();
|
||||
const leafStubs: StirlingFileStub[] = [];
|
||||
@@ -268,7 +270,7 @@ class FileStorageService {
|
||||
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) {
|
||||
if (record && record.name && typeof record.size === "number" && record.isLeaf !== false) {
|
||||
leafStubs.push({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
@@ -290,7 +292,7 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
createdAt: record.createdAt || Date.now()
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
@@ -308,7 +310,7 @@ class FileStorageService {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.delete(id);
|
||||
|
||||
@@ -325,7 +327,7 @@ class FileStorageService {
|
||||
|
||||
return new Promise((resolve, _reject) => {
|
||||
try {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const getRequest = store.get(id);
|
||||
|
||||
@@ -339,7 +341,7 @@ class FileStorageService {
|
||||
resolve(true);
|
||||
};
|
||||
updateRequest.onerror = () => {
|
||||
console.error('Failed to update thumbnail:', updateRequest.error);
|
||||
console.error("Failed to update thumbnail:", updateRequest.error);
|
||||
resolve(false);
|
||||
};
|
||||
} else {
|
||||
@@ -348,11 +350,11 @@ class FileStorageService {
|
||||
};
|
||||
|
||||
getRequest.onerror = () => {
|
||||
console.error('Failed to get file for thumbnail update:', getRequest.error);
|
||||
console.error("Failed to get file for thumbnail update:", getRequest.error);
|
||||
resolve(false);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Transaction error during thumbnail update:', error);
|
||||
console.error("Transaction error during thumbnail update:", error);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
@@ -365,7 +367,7 @@ class FileStorageService {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.clear();
|
||||
|
||||
@@ -385,7 +387,7 @@ class FileStorageService {
|
||||
|
||||
try {
|
||||
// Get browser quota for context
|
||||
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||
if ("storage" in navigator && "estimate" in navigator.storage) {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
quota = estimate.quota;
|
||||
available = estimate.quota || 0;
|
||||
@@ -400,9 +402,8 @@ class FileStorageService {
|
||||
if (quota) {
|
||||
available = quota - used;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Could not get storage stats:', error);
|
||||
console.warn("Could not get storage stats:", error);
|
||||
used = 0;
|
||||
fileCount = 0;
|
||||
}
|
||||
@@ -411,7 +412,7 @@ class FileStorageService {
|
||||
used,
|
||||
available,
|
||||
fileCount,
|
||||
quota
|
||||
quota,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -423,7 +424,7 @@ class FileStorageService {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
@@ -452,7 +453,7 @@ class FileStorageService {
|
||||
async markFileAsProcessed(fileId: FileId): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const record = await new Promise<StoredStirlingFileRecord | undefined>((resolve, reject) => {
|
||||
@@ -476,7 +477,7 @@ class FileStorageService {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to mark file as processed:', error);
|
||||
console.error("Failed to mark file as processed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -488,7 +489,7 @@ class FileStorageService {
|
||||
async markFileAsLeaf(fileId: FileId): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const record = await new Promise<StoredStirlingFileRecord | undefined>((resolve, reject) => {
|
||||
@@ -512,7 +513,7 @@ class FileStorageService {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to mark file as leaf:', error);
|
||||
console.error("Failed to mark file as leaf:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -523,7 +524,7 @@ class FileStorageService {
|
||||
async updateFileMetadata(fileId: FileId, updates: Partial<StoredStirlingFileRecord>): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
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);
|
||||
@@ -544,7 +545,7 @@ class FileStorageService {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update file metadata:', error);
|
||||
console.error("Failed to update file metadata:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
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
|
||||
@@ -10,8 +10,8 @@ import { ToolId } from '@app/types/toolId';
|
||||
export async function createStirlingFilesAndStubs(
|
||||
files: File[],
|
||||
parentStub: StirlingFileStub,
|
||||
toolId: ToolId
|
||||
): Promise<{ stirlingFiles: StirlingFile[], stubs: StirlingFileStub[] }> {
|
||||
toolId: ToolId,
|
||||
): Promise<{ stirlingFiles: StirlingFile[]; stubs: StirlingFileStub[] }> {
|
||||
const stirlingFiles: StirlingFile[] = [];
|
||||
const stubs: StirlingFileStub[] = [];
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function createStirlingFilesAndStubs(
|
||||
{ toolId, timestamp: Date.now() },
|
||||
file,
|
||||
processedFileMetadata?.thumbnailUrl,
|
||||
processedFileMetadata
|
||||
processedFileMetadata,
|
||||
);
|
||||
|
||||
const stirlingFile = createStirlingFile(file, childStub.id);
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* Handles Google Drive file picker integration
|
||||
*/
|
||||
|
||||
import { loadScript } from '@app/utils/scriptLoader';
|
||||
import { loadScript } from "@app/utils/scriptLoader";
|
||||
|
||||
const SCOPES = 'https://www.googleapis.com/auth/drive.readonly';
|
||||
const SESSION_STORAGE_ID = 'googleDrivePickerAccessToken';
|
||||
const SCOPES = "https://www.googleapis.com/auth/drive.readonly";
|
||||
const SESSION_STORAGE_ID = "googleDrivePickerAccessToken";
|
||||
|
||||
interface GoogleDriveConfig {
|
||||
clientId: string;
|
||||
@@ -28,20 +28,20 @@ interface PickerOptions {
|
||||
|
||||
// Expandable mime types for Google Picker
|
||||
const expandableMimeTypes: Record<string, string[]> = {
|
||||
'image/*': ['image/jpeg', 'image/png', 'image/svg+xml'],
|
||||
"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('*/*')) {
|
||||
if (!accept || accept === "" || accept.includes("*/*")) {
|
||||
// Setting null will accept all supported mimetypes
|
||||
return null;
|
||||
}
|
||||
|
||||
const mimeTypes: string[] = [];
|
||||
accept.split(',').forEach((part) => {
|
||||
accept.split(",").forEach((part) => {
|
||||
const trimmedPart = part.trim();
|
||||
if (!(trimmedPart in expandableMimeTypes)) {
|
||||
mimeTypes.push(trimmedPart);
|
||||
@@ -53,7 +53,7 @@ function fileInputToGooglePickerMimeTypes(accept?: string): string | null {
|
||||
});
|
||||
});
|
||||
|
||||
return mimeTypes.join(',').replace(/\s+/g, '');
|
||||
return mimeTypes.join(",").replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
class GoogleDrivePickerService {
|
||||
@@ -74,10 +74,7 @@ class GoogleDrivePickerService {
|
||||
this.config = config;
|
||||
|
||||
// Load Google APIs
|
||||
await Promise.all([
|
||||
this.loadGapi(),
|
||||
this.loadGis(),
|
||||
]);
|
||||
await Promise.all([this.loadGapi(), this.loadGis()]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,13 +84,13 @@ class GoogleDrivePickerService {
|
||||
if (this.gapiLoaded) return;
|
||||
|
||||
await loadScript({
|
||||
src: 'https://apis.google.com/js/api.js',
|
||||
id: 'gapi-script',
|
||||
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');
|
||||
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();
|
||||
});
|
||||
@@ -107,12 +104,12 @@ class GoogleDrivePickerService {
|
||||
if (this.gisLoaded) return;
|
||||
|
||||
await loadScript({
|
||||
src: 'https://accounts.google.com/gsi/client',
|
||||
id: 'gis-script',
|
||||
src: "https://accounts.google.com/gsi/client",
|
||||
id: "gis-script",
|
||||
});
|
||||
|
||||
if (!this.config) {
|
||||
throw new Error('Google Drive config not initialized');
|
||||
throw new Error("Google Drive config not initialized");
|
||||
}
|
||||
|
||||
this.tokenClient = window.google.accounts.oauth2.initTokenClient({
|
||||
@@ -129,7 +126,7 @@ class GoogleDrivePickerService {
|
||||
*/
|
||||
async openPicker(options: PickerOptions = {}): Promise<File[]> {
|
||||
if (!this.config) {
|
||||
throw new Error('Google Drive service not initialized');
|
||||
throw new Error("Google Drive service not initialized");
|
||||
}
|
||||
|
||||
// Request access token
|
||||
@@ -145,7 +142,7 @@ class GoogleDrivePickerService {
|
||||
private requestAccessToken(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.tokenClient) {
|
||||
reject(new Error('Token client not initialized'));
|
||||
reject(new Error("Token client not initialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,7 +151,7 @@ class GoogleDrivePickerService {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
}
|
||||
if(response.access_token == null){
|
||||
if (response.access_token == null) {
|
||||
reject(new Error("No acces token in response"));
|
||||
}
|
||||
|
||||
@@ -164,7 +161,7 @@ class GoogleDrivePickerService {
|
||||
};
|
||||
|
||||
this.tokenClient.requestAccessToken({
|
||||
prompt: this.accessToken === null ? 'consent' : '',
|
||||
prompt: this.accessToken === null ? "consent" : "",
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -175,7 +172,7 @@ class GoogleDrivePickerService {
|
||||
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'));
|
||||
reject(new Error("Not initialized or no access token"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,9 +183,7 @@ class GoogleDrivePickerService {
|
||||
view1.setMimeTypes(mimeTypes);
|
||||
}
|
||||
|
||||
const view2 = new window.google.picker.DocsView()
|
||||
.setIncludeFolders(true)
|
||||
.setEnableDrives(true);
|
||||
const view2 = new window.google.picker.DocsView().setIncludeFolders(true).setEnableDrives(true);
|
||||
if (mimeTypes !== null) {
|
||||
view2.setMimeTypes(mimeTypes);
|
||||
}
|
||||
@@ -213,11 +208,7 @@ class GoogleDrivePickerService {
|
||||
/**
|
||||
* Handle picker selection callback
|
||||
*/
|
||||
private async pickerCallback(
|
||||
data: any,
|
||||
resolve: (files: File[]) => void,
|
||||
reject: (error: Error) => void
|
||||
): Promise<void> {
|
||||
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(
|
||||
@@ -225,7 +216,7 @@ class GoogleDrivePickerService {
|
||||
const fileId = pickedFile[window.google.picker.Document.ID];
|
||||
const res = await window.gapi.client.drive.files.get({
|
||||
fileId: fileId,
|
||||
alt: 'media',
|
||||
alt: "media",
|
||||
});
|
||||
|
||||
// Convert response body to File object
|
||||
@@ -235,15 +226,15 @@ class GoogleDrivePickerService {
|
||||
{
|
||||
type: pickedFile.mimeType,
|
||||
lastModified: pickedFile.lastModified,
|
||||
}
|
||||
},
|
||||
);
|
||||
return file;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
resolve(files);
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error('Failed to download files'));
|
||||
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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// 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';
|
||||
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> = {};
|
||||
@@ -26,30 +26,31 @@ export async function handleHttpError(error: any): Promise<boolean> {
|
||||
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/');
|
||||
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');
|
||||
console.debug("[httpErrorHandler] 401 detected, redirecting to login");
|
||||
// Store the current location so we can redirect back after login
|
||||
const currentLocation = window.location.pathname + window.location.search;
|
||||
// Redirect to login with state (only show expired when a JWT existed)
|
||||
let hadStoredJwt = false;
|
||||
try {
|
||||
hadStoredJwt = Boolean(localStorage.getItem('stirling_jwt'));
|
||||
hadStoredJwt = Boolean(localStorage.getItem("stirling_jwt"));
|
||||
} catch {
|
||||
// ignore storage access failures
|
||||
}
|
||||
const expiredPrefix = hadStoredJwt ? 'expired=true&' : '';
|
||||
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);
|
||||
console.debug("[httpErrorHandler] Suppressing 401 on auth page:", pathname);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -59,9 +60,13 @@ export async function handleHttpError(error: any): Promise<boolean> {
|
||||
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;
|
||||
const raw = error?.response?.data as any;
|
||||
let normalized: unknown = raw;
|
||||
try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); }
|
||||
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 {
|
||||
@@ -70,7 +75,7 @@ export async function handleHttpError(error: any): Promise<boolean> {
|
||||
broadcastErroredFiles(ids);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', e);
|
||||
console.debug("extractErrorFileIds", e);
|
||||
}
|
||||
|
||||
// 2) Generic-vs-special dedupe by endpoint
|
||||
@@ -95,18 +100,15 @@ export async function handleHttpError(error: any): Promise<boolean> {
|
||||
// 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);
|
||||
rawString = typeof normalized === "string" ? normalized : JSON.stringify(normalized);
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', 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 });
|
||||
alert({ alertType: "error", title, body: displayBody, expandable: true, isPersistentPopup: false });
|
||||
}
|
||||
|
||||
return false; // Error was handled with toast, continue normal rejection
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import axios from "axios";
|
||||
|
||||
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
|
||||
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 {
|
||||
@@ -8,10 +8,10 @@ export function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
|
||||
}
|
||||
|
||||
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
|
||||
const s = (msg || '').trim();
|
||||
const s = (msg || "").trim();
|
||||
if (!s) return true;
|
||||
// Common unhelpful payloads we see
|
||||
if (s === '{}' || s === '[]') return true;
|
||||
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.
|
||||
@@ -19,46 +19,54 @@ function isUnhelpfulMessage(msg: string | null | undefined): boolean {
|
||||
}
|
||||
|
||||
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';
|
||||
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 || '';
|
||||
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 */ }
|
||||
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 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 : '';
|
||||
if (!data) return typeof raw === "string" ? raw : "";
|
||||
const ids = extractIds();
|
||||
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
|
||||
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 ''; }
|
||||
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)' };
|
||||
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 fallbackMsg = "Process failed due to invalid/corrupted file(s)";
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
@@ -67,10 +75,10 @@ export function extractAxiosErrorMessage(error: any): { title: string; body: str
|
||||
}
|
||||
try {
|
||||
const msg = (error?.message || String(error)) as string;
|
||||
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
|
||||
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 };
|
||||
console.debug("extractAxiosErrorMessage", e);
|
||||
return { title: "Network error", body: FRIENDLY_FALLBACK };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class IndexedDBManager {
|
||||
console.log(`Upgrading ${config.name} from v${oldVersion} to v${config.version}`);
|
||||
|
||||
// Create or update object stores
|
||||
config.stores.forEach(storeConfig => {
|
||||
config.stores.forEach((storeConfig) => {
|
||||
let store: IDBObjectStore | undefined;
|
||||
|
||||
if (db.objectStoreNames.contains(storeConfig.name)) {
|
||||
@@ -102,13 +102,9 @@ class IndexedDBManager {
|
||||
|
||||
// Add new indexes if they don't exist
|
||||
if (storeConfig.indexes && store) {
|
||||
storeConfig.indexes.forEach(indexConfig => {
|
||||
storeConfig.indexes.forEach((indexConfig) => {
|
||||
if (!store?.indexNames.contains(indexConfig.name)) {
|
||||
store?.createIndex(
|
||||
indexConfig.name,
|
||||
indexConfig.keyPath,
|
||||
{ unique: indexConfig.unique }
|
||||
);
|
||||
store?.createIndex(indexConfig.name, indexConfig.keyPath, { unique: indexConfig.unique });
|
||||
console.log(`Created index '${indexConfig.name}' on '${storeConfig.name}'`);
|
||||
}
|
||||
});
|
||||
@@ -128,19 +124,15 @@ class IndexedDBManager {
|
||||
|
||||
// Create indexes
|
||||
if (storeConfig.indexes) {
|
||||
storeConfig.indexes.forEach(indexConfig => {
|
||||
store?.createIndex(
|
||||
indexConfig.name,
|
||||
indexConfig.keyPath,
|
||||
{ unique: indexConfig.unique }
|
||||
);
|
||||
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) {
|
||||
if (config.name === "stirling-pdf-files" && storeConfig.name === "files" && store) {
|
||||
this.migrateFileHistoryFields(store, oldVersion);
|
||||
}
|
||||
});
|
||||
@@ -157,7 +149,7 @@ class IndexedDBManager {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting file history migration for existing records...');
|
||||
console.log("Starting file history migration for existing records...");
|
||||
|
||||
const cursor = store.openCursor();
|
||||
let migratedCount = 0;
|
||||
@@ -200,7 +192,7 @@ class IndexedDBManager {
|
||||
cursor.update(record);
|
||||
migratedCount++;
|
||||
} catch (error) {
|
||||
console.error('Failed to migrate record:', record.id, error);
|
||||
console.error("Failed to migrate record:", record.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +204,7 @@ class IndexedDBManager {
|
||||
};
|
||||
|
||||
cursor.onerror = (event) => {
|
||||
console.error('File history migration failed:', (event.target as IDBRequest).error);
|
||||
console.error("File history migration failed:", (event.target as IDBRequest).error);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -290,39 +282,44 @@ class IndexedDBManager {
|
||||
// Pre-defined database configurations
|
||||
export const DATABASE_CONFIGS = {
|
||||
FILES: {
|
||||
name: 'stirling-pdf-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 }
|
||||
]
|
||||
}]
|
||||
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',
|
||||
name: "stirling-pdf-drafts",
|
||||
version: 1,
|
||||
stores: [{
|
||||
name: 'drafts',
|
||||
keyPath: 'id'
|
||||
}]
|
||||
stores: [
|
||||
{
|
||||
name: "drafts",
|
||||
keyPath: "id",
|
||||
},
|
||||
],
|
||||
} as DatabaseConfig,
|
||||
|
||||
PREFERENCES: {
|
||||
name: 'stirling-pdf-preferences',
|
||||
name: "stirling-pdf-preferences",
|
||||
version: 1,
|
||||
stores: [{
|
||||
name: 'preferences',
|
||||
keyPath: 'key'
|
||||
}]
|
||||
stores: [
|
||||
{
|
||||
name: "preferences",
|
||||
keyPath: "key",
|
||||
},
|
||||
],
|
||||
} as DatabaseConfig,
|
||||
|
||||
} as const;
|
||||
|
||||
export const indexedDBManager = IndexedDBManager.getInstance();
|
||||
|
||||
@@ -18,10 +18,7 @@ export interface MultiFileSaveResult {
|
||||
* Core stub - always returns failure
|
||||
* Desktop builds override this with actual implementation
|
||||
*/
|
||||
export async function saveToLocalPath(
|
||||
_data: Blob | File,
|
||||
_filePath: string
|
||||
): Promise<SaveResult> {
|
||||
export async function saveToLocalPath(_data: Blob | File, _filePath: string): Promise<SaveResult> {
|
||||
return { success: false, error: "Local file save not available in web mode" };
|
||||
}
|
||||
|
||||
@@ -29,10 +26,7 @@ export async function saveToLocalPath(
|
||||
* Show native save dialog
|
||||
* Core stub - always returns null
|
||||
*/
|
||||
export async function showSaveDialog(
|
||||
_defaultFilename: string,
|
||||
_defaultDirectory?: string
|
||||
): Promise<string | null> {
|
||||
export async function showSaveDialog(_defaultFilename: string, _defaultDirectory?: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -42,7 +36,7 @@ export async function showSaveDialog(
|
||||
*/
|
||||
export async function saveMultipleFilesWithPrompt(
|
||||
_files: (Blob | File)[],
|
||||
_defaultDirectory?: string
|
||||
_defaultDirectory?: string,
|
||||
): Promise<MultiFileSaveResult> {
|
||||
return { success: false, savedCount: 0, error: "Multi-file save not available in web mode" };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { openFileDialog } from '@app/services/fileDialogService';
|
||||
import { pendingFilePathMappings } from '@app/services/pendingFilePathMappings';
|
||||
import { getDocumentFileDialogFilter } from '@app/utils/fileDialogUtils';
|
||||
import { openFileDialog } from "@app/services/fileDialogService";
|
||||
import { pendingFilePathMappings } from "@app/services/pendingFilePathMappings";
|
||||
import { getDocumentFileDialogFilter } from "@app/utils/fileDialogUtils";
|
||||
|
||||
interface OpenFilesFromDiskOptions {
|
||||
multiple?: boolean;
|
||||
@@ -14,7 +14,7 @@ interface OpenFilesFromDiskOptions {
|
||||
export async function openFilesFromDisk(options: OpenFilesFromDiskOptions = {}): Promise<File[]> {
|
||||
const filesWithPaths = await openFileDialog({
|
||||
multiple: options.multiple ?? true,
|
||||
filters: options.filters ?? getDocumentFileDialogFilter()
|
||||
filters: options.filters ?? getDocumentFileDialogFilter(),
|
||||
});
|
||||
|
||||
if (filesWithPaths.length > 0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FileId, StirlingFileStub } from '@app/types/fileContext';
|
||||
import { downloadFromUrl, DownloadResult } from '@app/services/downloadService';
|
||||
import type { FileId, StirlingFileStub } from "@app/types/fileContext";
|
||||
import { downloadFromUrl, DownloadResult } from "@app/services/downloadService";
|
||||
|
||||
export interface OperationSaveContext {
|
||||
downloadUrl: string | null;
|
||||
@@ -16,8 +16,8 @@ export async function saveOperationResults(context: OperationSaveContext): Promi
|
||||
|
||||
const result = await downloadFromUrl(
|
||||
context.downloadUrl,
|
||||
context.downloadFilename || 'download',
|
||||
context.downloadLocalPath || undefined
|
||||
context.downloadFilename || "download",
|
||||
context.downloadLocalPath || undefined,
|
||||
);
|
||||
|
||||
if (context.outputFileIds && result.savedPath) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PDFDocument } from '@app/types/pageEditor';
|
||||
import { pdfExportService } from '@app/services/pdfExportService';
|
||||
import { FileId } from '@app/types/file';
|
||||
import { PDFDocument } from "@app/types/pageEditor";
|
||||
import { pdfExportService } from "@app/services/pdfExportService";
|
||||
import { FileId } from "@app/types/file";
|
||||
|
||||
/**
|
||||
* Export processed documents to File objects
|
||||
@@ -9,19 +9,19 @@ import { FileId } from '@app/types/file';
|
||||
export async function exportProcessedDocumentsToFiles(
|
||||
processedDocuments: PDFDocument | PDFDocument[],
|
||||
sourceFiles: Map<FileId, File> | null,
|
||||
exportFilename: string
|
||||
exportFilename: string,
|
||||
): Promise<File[]> {
|
||||
console.log('exportProcessedDocumentsToFiles called with:', {
|
||||
console.log("exportProcessedDocumentsToFiles called with:", {
|
||||
isArray: Array.isArray(processedDocuments),
|
||||
numDocs: Array.isArray(processedDocuments) ? processedDocuments.length : 1,
|
||||
hasSourceFiles: sourceFiles !== null,
|
||||
sourceFilesSize: sourceFiles?.size
|
||||
sourceFilesSize: sourceFiles?.size,
|
||||
});
|
||||
|
||||
if (Array.isArray(processedDocuments)) {
|
||||
// Multiple documents (splits)
|
||||
const files: File[] = [];
|
||||
const baseName = exportFilename.replace(/\.pdf$/i, '');
|
||||
const baseName = exportFilename.replace(/\.pdf$/i, "");
|
||||
|
||||
for (let i = 0; i < processedDocuments.length; i++) {
|
||||
const doc = processedDocuments[i];
|
||||
@@ -31,16 +31,19 @@ export async function exportProcessedDocumentsToFiles(
|
||||
? 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' }));
|
||||
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.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' })];
|
||||
return [new File([result.blob], result.filename, { type: "application/pdf" })];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
importPages,
|
||||
setPageRotation,
|
||||
addNewPage,
|
||||
} from '@app/services/pdfiumService';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||
} 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.890;
|
||||
const A4_HEIGHT = 841.89;
|
||||
|
||||
export interface ExportOptions {
|
||||
selectedOnly?: boolean;
|
||||
@@ -26,17 +26,18 @@ export class PDFExportService {
|
||||
async exportPDF(
|
||||
pdfDocument: PDFDocument,
|
||||
selectedPageIds: string[] = [],
|
||||
options: ExportOptions = {}
|
||||
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;
|
||||
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');
|
||||
throw new Error("No pages to export");
|
||||
}
|
||||
|
||||
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
|
||||
@@ -45,11 +46,8 @@ export class PDFExportService {
|
||||
|
||||
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 }
|
||||
);
|
||||
console.error("PDF export error:", error);
|
||||
throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,17 +58,18 @@ export class PDFExportService {
|
||||
pdfDocument: PDFDocument,
|
||||
sourceFiles: Map<string, File>,
|
||||
selectedPageIds: string[] = [],
|
||||
options: ExportOptions = {}
|
||||
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;
|
||||
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');
|
||||
throw new Error("No pages to export");
|
||||
}
|
||||
|
||||
const blob = await this.createMultiSourceDocument(sourceFiles, pagesToExport);
|
||||
@@ -78,26 +77,20 @@ export class PDFExportService {
|
||||
|
||||
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 }
|
||||
);
|
||||
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> {
|
||||
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');
|
||||
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>();
|
||||
@@ -153,7 +146,7 @@ export class PDFExportService {
|
||||
|
||||
// Save the assembled document
|
||||
const resultBuf = await saveRawDocument(destDocPtr);
|
||||
return new Blob([resultBuf], { type: 'application/pdf' });
|
||||
return new Blob([resultBuf], { type: "application/pdf" });
|
||||
} finally {
|
||||
// Cleanup all loaded source documents
|
||||
for (const docPtr of loadedDocs.values()) {
|
||||
@@ -166,10 +159,7 @@ export class PDFExportService {
|
||||
/**
|
||||
* Create a single PDF document with all operations applied (single source) using PDFium.
|
||||
*/
|
||||
private async createSingleDocument(
|
||||
sourceData: ArrayBuffer,
|
||||
pages: PDFPage[]
|
||||
): Promise<Blob> {
|
||||
private async createSingleDocument(sourceData: ArrayBuffer, pages: PDFPage[]): Promise<Blob> {
|
||||
const m = await getPdfiumModule();
|
||||
|
||||
// Open source document
|
||||
@@ -177,7 +167,7 @@ export class PDFExportService {
|
||||
const destDocPtr = m.FPDF_CreateNewDocument();
|
||||
if (!destDocPtr) {
|
||||
await closeRawDocument(srcDocPtr);
|
||||
throw new Error('PDFium: failed to create destination document');
|
||||
throw new Error("PDFium: failed to create destination document");
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -214,7 +204,7 @@ export class PDFExportService {
|
||||
}
|
||||
|
||||
const resultBuf = await saveRawDocument(destDocPtr);
|
||||
return new Blob([resultBuf], { type: 'application/pdf' });
|
||||
return new Blob([resultBuf], { type: "application/pdf" });
|
||||
} finally {
|
||||
await closeRawDocument(srcDocPtr);
|
||||
m.FPDF_CloseDocument(destDocPtr);
|
||||
@@ -225,7 +215,7 @@ export class PDFExportService {
|
||||
* Generate appropriate filename for export
|
||||
*/
|
||||
private generateFilename(originalName: string): string {
|
||||
const baseName = originalName.replace(/\.pdf$/i, '');
|
||||
const baseName = originalName.replace(/\.pdf$/i, "");
|
||||
return `${baseName}.pdf`;
|
||||
}
|
||||
|
||||
@@ -254,19 +244,19 @@ export class PDFExportService {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (selectedOnly && selectedPageIds.length === 0) {
|
||||
errors.push('No pages selected for export');
|
||||
errors.push("No pages selected for export");
|
||||
}
|
||||
|
||||
if (pdfDocument.pages.length === 0) {
|
||||
errors.push('No pages available to export');
|
||||
errors.push("No pages available to export");
|
||||
}
|
||||
|
||||
const pagesToExport = selectedOnly
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
? pdfDocument.pages.filter((page) => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
if (pagesToExport.length === 0) {
|
||||
errors.push('No valid pages to export after applying filters');
|
||||
errors.push("No valid pages to export after applying filters");
|
||||
}
|
||||
|
||||
return errors;
|
||||
@@ -275,13 +265,17 @@ export class PDFExportService {
|
||||
/**
|
||||
* Get export preview information
|
||||
*/
|
||||
getExportInfo(pdfDocument: PDFDocument, selectedPageIds: string[], selectedOnly: boolean): {
|
||||
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.filter((page) => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
const splitCount = pagesToExport.reduce((count, page) => {
|
||||
@@ -295,7 +289,7 @@ export class PDFExportService {
|
||||
return {
|
||||
pageCount: pagesToExport.length,
|
||||
splitCount,
|
||||
estimatedSize
|
||||
estimatedSize,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,11 +297,11 @@ export class PDFExportService {
|
||||
* Format file size for display
|
||||
*/
|
||||
private formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
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];
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,10 +311,14 @@ export class PDFExportService {
|
||||
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;
|
||||
case 90:
|
||||
return 1;
|
||||
case 180:
|
||||
return 2;
|
||||
case 270:
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
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;
|
||||
@@ -21,13 +21,13 @@ export type MetadataExtractionResponse = MetadataExtractionResult | MetadataExtr
|
||||
*/
|
||||
function formatPDFDate(dateString: string): string {
|
||||
if (!dateString) {
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
|
||||
let date: Date;
|
||||
|
||||
// Check if it's a PDF date format (starts with "D:")
|
||||
if (dateString.startsWith('D:')) {
|
||||
if (dateString.startsWith("D:")) {
|
||||
// Parse PDF date format: D:YYYYMMDDHHmmSSOHH'mm'
|
||||
const dateStr = dateString.substring(2); // Remove "D:"
|
||||
|
||||
@@ -47,15 +47,15 @@ function formatPDFDate(dateString: string): string {
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return '';
|
||||
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');
|
||||
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}`;
|
||||
}
|
||||
@@ -65,10 +65,10 @@ function formatPDFDate(dateString: string): string {
|
||||
* PDF.js returns trapped as { name: "True" | "False" } object
|
||||
*/
|
||||
function convertTrappedStatus(trapped: unknown): TrappedStatus {
|
||||
if (trapped && typeof trapped === 'object' && 'name' in trapped) {
|
||||
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;
|
||||
if (name === "true") return TrappedStatus.TRUE;
|
||||
if (name === "false") return TrappedStatus.FALSE;
|
||||
}
|
||||
return TrappedStatus.UNKNOWN;
|
||||
}
|
||||
@@ -81,17 +81,16 @@ 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) {
|
||||
if (typeof custom === "object" && custom !== null) {
|
||||
const customObj = custom as Record<string, unknown>;
|
||||
|
||||
Object.entries(customObj).forEach(([key, value]) => {
|
||||
if (value != null && value !== '') {
|
||||
if (value != null && value !== "") {
|
||||
const entry = {
|
||||
key,
|
||||
value: String(value),
|
||||
id: `custom${customIdCounter++}`
|
||||
id: `custom${customIdCounter++}`,
|
||||
};
|
||||
customMetadata.push(entry);
|
||||
}
|
||||
@@ -109,16 +108,16 @@ function cleanupPdfDocument(pdfDoc: PDFDocumentProxy | null): void {
|
||||
try {
|
||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to cleanup PDF document:', cleanupError);
|
||||
console.warn("Failed to cleanup PDF document:", cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getStringMetadata(info: Record<string, unknown>, key: string): string {
|
||||
if (typeof info[key] === 'string') {
|
||||
if (typeof info[key] === "string") {
|
||||
return info[key];
|
||||
} else {
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +131,7 @@ export async function extractPDFMetadata(file: File): Promise<MetadataExtraction
|
||||
if (!isValidPDF) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'File is not a valid PDF'
|
||||
error: "File is not a valid PDF",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,15 +143,15 @@ export async function extractPDFMetadata(file: File): Promise<MetadataExtraction
|
||||
arrayBuffer = await file.arrayBuffer();
|
||||
pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true
|
||||
disableStream: true,
|
||||
});
|
||||
metadata = await pdfDoc.getMetadata();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
cleanupPdfDocument(pdfDoc);
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to read PDF: ${errorMessage}`
|
||||
error: `Failed to read PDF: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,14 +159,14 @@ export async function extractPDFMetadata(file: File): Promise<MetadataExtraction
|
||||
|
||||
// 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')),
|
||||
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),
|
||||
};
|
||||
@@ -176,6 +175,6 @@ export async function extractPDFMetadata(file: File): Promise<MetadataExtraction
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata: extractedMetadata
|
||||
metadata: extractedMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
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;
|
||||
@@ -24,13 +24,13 @@ export class PDFProcessingService {
|
||||
// Check cache first
|
||||
const cached = this.cache.get(fileKey);
|
||||
if (cached) {
|
||||
console.log('Cache hit for:', file.name);
|
||||
console.log("Cache hit for:", file.name);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check if already processing
|
||||
if (this.processing.has(fileKey)) {
|
||||
console.log('Already processing:', file.name);
|
||||
console.log("Already processing:", file.name);
|
||||
return null; // Will be available when processing completes
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ export class PDFProcessingService {
|
||||
const state: ProcessingState = {
|
||||
fileKey,
|
||||
fileName: file.name,
|
||||
status: 'processing',
|
||||
status: "processing",
|
||||
progress: 0,
|
||||
startedAt: Date.now(),
|
||||
strategy: 'immediate_full'
|
||||
strategy: "immediate_full",
|
||||
};
|
||||
|
||||
this.processing.set(fileKey, state);
|
||||
@@ -64,7 +64,7 @@ export class PDFProcessingService {
|
||||
this.cache.set(fileKey, processedFile);
|
||||
|
||||
// Update state to completed
|
||||
state.status = 'completed';
|
||||
state.status = "completed";
|
||||
state.progress = 100;
|
||||
state.completedAt = Date.now();
|
||||
this.notifyListeners();
|
||||
@@ -74,11 +74,10 @@ export class PDFProcessingService {
|
||||
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;
|
||||
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
|
||||
@@ -89,10 +88,7 @@ export class PDFProcessingService {
|
||||
}
|
||||
}
|
||||
|
||||
private async processFileWithProgress(
|
||||
file: File,
|
||||
onProgress: (progress: number) => void
|
||||
): Promise<ProcessedFile> {
|
||||
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;
|
||||
@@ -104,11 +100,11 @@ export class PDFProcessingService {
|
||||
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');
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
const context = canvas.getContext("2d");
|
||||
if (context) {
|
||||
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
@@ -118,7 +114,7 @@ export class PDFProcessingService {
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
selected: false
|
||||
selected: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,8 +133,8 @@ export class PDFProcessingService {
|
||||
metadata: {
|
||||
title: file.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString()
|
||||
}
|
||||
modifiedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,7 +149,7 @@ export class PDFProcessingService {
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
this.processingListeners.forEach(callback => callback(this.processing));
|
||||
this.processingListeners.forEach((callback) => callback(this.processing));
|
||||
}
|
||||
|
||||
generateFileKey(file: File): string {
|
||||
@@ -162,7 +158,7 @@ export class PDFProcessingService {
|
||||
|
||||
// Cleanup method for activeFiles changes
|
||||
cleanup(removedFiles: File[]): void {
|
||||
removedFiles.forEach(file => {
|
||||
removedFiles.forEach((file) => {
|
||||
const key = this.generateFileKey(file);
|
||||
this.cache.delete(key);
|
||||
this.processing.delete(key);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* and ensuring proper cleanup when operations complete.
|
||||
*/
|
||||
|
||||
import { GlobalWorkerOptions, getDocument, PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import { GlobalWorkerOptions, getDocument, PDFDocumentProxy } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
|
||||
class PDFWorkerManager {
|
||||
private static instance: PDFWorkerManager;
|
||||
@@ -30,10 +30,7 @@ class PDFWorkerManager {
|
||||
*/
|
||||
private initializeWorker(): void {
|
||||
if (!this.isInitialized) {
|
||||
GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||
(GlobalWorkerOptions as any).docBaseUrl = undefined;
|
||||
this.isInitialized = true;
|
||||
}
|
||||
@@ -50,7 +47,7 @@ class PDFWorkerManager {
|
||||
disableStream?: boolean;
|
||||
stopAtErrors?: boolean;
|
||||
verbosity?: number;
|
||||
} = {}
|
||||
} = {},
|
||||
): Promise<PDFDocumentProxy> {
|
||||
// Wait if we've hit the worker limit
|
||||
if (this.activeDocuments.size >= this.maxWorkers) {
|
||||
@@ -61,32 +58,34 @@ class PDFWorkerManager {
|
||||
let pdfData: any;
|
||||
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
||||
pdfData = { data };
|
||||
} else if (typeof data === 'string') {
|
||||
} else if (typeof data === "string") {
|
||||
pdfData = data; // URL string
|
||||
} else if (data && typeof data === 'object' && 'data' in data) {
|
||||
} 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,
|
||||
}
|
||||
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 {
|
||||
@@ -130,7 +129,7 @@ class PDFWorkerManager {
|
||||
*/
|
||||
destroyAllDocuments(): void {
|
||||
const documentsToDestroy = Array.from(this.activeDocuments);
|
||||
documentsToDestroy.forEach(pdf => {
|
||||
documentsToDestroy.forEach((pdf) => {
|
||||
this.destroyDocument(pdf);
|
||||
});
|
||||
|
||||
@@ -161,7 +160,7 @@ class PDFWorkerManager {
|
||||
return {
|
||||
active: this.activeDocuments.size,
|
||||
max: this.maxWorkers,
|
||||
total: this.workerCount
|
||||
total: this.workerCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,7 +169,7 @@ class PDFWorkerManager {
|
||||
*/
|
||||
emergencyCleanup(): void {
|
||||
// Force destroy all documents
|
||||
this.activeDocuments.forEach(pdf => {
|
||||
this.activeDocuments.forEach((pdf) => {
|
||||
try {
|
||||
pdf.destroy();
|
||||
} catch {
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
*
|
||||
* 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';
|
||||
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()`)
|
||||
@@ -39,14 +39,14 @@ function colorToRGBA(c: PdfiumColor): [number, number, number, number] {
|
||||
|
||||
/** 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',
|
||||
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 {
|
||||
@@ -77,20 +77,20 @@ export class PdfiumFont {
|
||||
|
||||
/** 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';
|
||||
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') {
|
||||
if (typeof OffscreenCanvas !== "undefined") {
|
||||
this._canvas = new OffscreenCanvas(1, 1);
|
||||
this._ctx = this._canvas.getContext('2d')!;
|
||||
this._ctx = this._canvas.getContext("2d")!;
|
||||
} else {
|
||||
this._canvas = document.createElement('canvas');
|
||||
this._ctx = this._canvas.getContext('2d')!;
|
||||
this._canvas = document.createElement("canvas");
|
||||
this._ctx = this._canvas.getContext("2d")!;
|
||||
}
|
||||
return this._ctx;
|
||||
}
|
||||
@@ -182,7 +182,7 @@ export class PdfiumPage {
|
||||
drawText(text: string, options: DrawTextOptions): void {
|
||||
const { x, y, size, font, color } = options;
|
||||
const m = this._m;
|
||||
const fontName = font?.name ?? 'Helvetica';
|
||||
const fontName = font?.name ?? "Helvetica";
|
||||
|
||||
const textObjPtr = m.FPDFPageObj_NewTextObj(this._docPtr, fontName, size);
|
||||
if (!textObjPtr) return;
|
||||
@@ -274,9 +274,14 @@ export class PdfiumPage {
|
||||
drawImage(image: PdfiumImage, options: DrawImageOptions): void {
|
||||
const { x, y, width, height } = options;
|
||||
embedBitmapImageOnPage(
|
||||
this._m, this._docPtr, this._pagePtr,
|
||||
this._m,
|
||||
this._docPtr,
|
||||
this._pagePtr,
|
||||
{ rgba: image._rgba, width: image.width, height: image.height },
|
||||
x, y, width, height,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -310,7 +315,7 @@ export class PdfiumDocument {
|
||||
static async create(): Promise<PdfiumDocument> {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = m.FPDF_CreateNewDocument();
|
||||
if (!docPtr) throw new Error('PDFium: failed to create document');
|
||||
if (!docPtr) throw new Error("PDFium: failed to create document");
|
||||
return new PdfiumDocument(m, docPtr);
|
||||
}
|
||||
|
||||
@@ -319,7 +324,7 @@ export class PdfiumDocument {
|
||||
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');
|
||||
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;
|
||||
@@ -335,12 +340,12 @@ export class PdfiumDocument {
|
||||
|
||||
/** 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');
|
||||
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');
|
||||
return this._decodeImage(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), "image/jpeg");
|
||||
}
|
||||
|
||||
/** Get the number of pages. */
|
||||
@@ -377,13 +382,13 @@ export class PdfiumDocument {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Canvas 2D context unavailable'));
|
||||
reject(new Error("Canvas 2D context unavailable"));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
@@ -397,7 +402,7 @@ export class PdfiumDocument {
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to decode image'));
|
||||
reject(new Error("Failed to decode image"));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
* `getSignatures`, `saveAsCopy`, …) wrap the `PdfEngine` interface so callers
|
||||
* never have to deal with raw pointers or Tasks.
|
||||
*/
|
||||
import { init, type WrappedPdfiumModule } from '@embedpdf/pdfium';
|
||||
import type { FormField, WidgetCoordinates } from '@app/tools/formFill/types';
|
||||
import { init, type WrappedPdfiumModule } from "@embedpdf/pdfium";
|
||||
import type { FormField, WidgetCoordinates } from "@app/tools/formFill/types";
|
||||
|
||||
// PDF form field type constants (matching PDFium C API FPDF_FORMFIELD_* values)
|
||||
const FPDF_FORMFIELD_UNKNOWN = 0;
|
||||
@@ -45,8 +45,8 @@ let _module: WrappedPdfiumModule | null = null;
|
||||
* Resolve the absolute WASM URL using the same pattern as LocalEmbedPDF.
|
||||
*/
|
||||
function wasmUrl(): string {
|
||||
const base = (import.meta as any).env?.BASE_URL ?? '/';
|
||||
return `${base}pdfium/pdfium.wasm`.replace(/\/\//g, '/');
|
||||
const base = (import.meta as any).env?.BASE_URL ?? "/";
|
||||
return `${base}pdfium/pdfium.wasm`.replace(/\/\//g, "/");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +62,11 @@ export async function getPdfiumModule(): Promise<WrappedPdfiumModule> {
|
||||
locateFile: () => wasmUrl(),
|
||||
} as any).then((m) => {
|
||||
// Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up
|
||||
try { m.PDFiumExt_Init(); } catch { /* already initialized */ }
|
||||
try {
|
||||
m.PDFiumExt_Init();
|
||||
} catch {
|
||||
/* already initialized */
|
||||
}
|
||||
_module = m;
|
||||
return m;
|
||||
});
|
||||
@@ -80,7 +84,6 @@ export function resetPdfiumModule(): void {
|
||||
_docDataPtrs.clear();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Map of document pointer → WASM data buffer pointer.
|
||||
* FPDF_LoadMemDocument does NOT copy the data — it keeps a reference, so the
|
||||
@@ -101,13 +104,9 @@ const _docDataPtrs = new Map<number, number>();
|
||||
* FS_RECTF memory layout: { left (f32), top (f32), right (f32), bottom (f32) }
|
||||
* In PDF coords (origin lower-left): top > bottom.
|
||||
*/
|
||||
export function readAnnotRectAdjusted(
|
||||
m: WrappedPdfiumModule,
|
||||
annotPtr: number,
|
||||
rectBuf: number,
|
||||
): boolean {
|
||||
export function readAnnotRectAdjusted(m: WrappedPdfiumModule, annotPtr: number, rectBuf: number): boolean {
|
||||
const ext = (m as any).EPDFAnnot_GetRect;
|
||||
if (typeof ext === 'function') {
|
||||
if (typeof ext === "function") {
|
||||
return ext.call(m, annotPtr, rectBuf);
|
||||
}
|
||||
return m.FPDFAnnot_GetRect(annotPtr, rectBuf);
|
||||
@@ -124,10 +123,10 @@ export function parseRectToCss(
|
||||
rectBuf: number,
|
||||
pageHeight: number,
|
||||
): { x: number; y: number; width: number; height: number } {
|
||||
const left = m.pdfium.getValue(rectBuf, 'float');
|
||||
const top = m.pdfium.getValue(rectBuf + 4, 'float'); // FS_RECTF.top (larger y)
|
||||
const right = m.pdfium.getValue(rectBuf + 8, 'float');
|
||||
const bottom = m.pdfium.getValue(rectBuf + 12, 'float'); // FS_RECTF.bottom (smaller y)
|
||||
const left = m.pdfium.getValue(rectBuf, "float");
|
||||
const top = m.pdfium.getValue(rectBuf + 4, "float"); // FS_RECTF.top (larger y)
|
||||
const right = m.pdfium.getValue(rectBuf + 8, "float");
|
||||
const bottom = m.pdfium.getValue(rectBuf + 12, "float"); // FS_RECTF.bottom (smaller y)
|
||||
|
||||
const pdfLeft = Math.min(left, right);
|
||||
const pdfTop = Math.max(top, bottom);
|
||||
@@ -136,7 +135,7 @@ export function parseRectToCss(
|
||||
|
||||
return {
|
||||
x: pdfLeft,
|
||||
y: pageHeight - pdfTop, // flip: CSS y = pageHeight − PDF top
|
||||
y: pageHeight - pdfTop, // flip: CSS y = pageHeight − PDF top
|
||||
width: pdfWidth,
|
||||
height: pdfHeight,
|
||||
};
|
||||
@@ -157,17 +156,14 @@ interface PageBox {
|
||||
* The returned values use the standard PDF coordinate system:
|
||||
* left < right, bottom < top, origin at lower-left.
|
||||
*/
|
||||
export function readEffectivePageBox(
|
||||
m: WrappedPdfiumModule,
|
||||
pagePtr: number,
|
||||
): PageBox {
|
||||
export function readEffectivePageBox(m: WrappedPdfiumModule, pagePtr: number): PageBox {
|
||||
const buf = m.pdfium.wasmExports.malloc(4 * 4); // 4 floats
|
||||
|
||||
const read = (): PageBox | null => {
|
||||
const l = m.pdfium.getValue(buf, 'float');
|
||||
const b = m.pdfium.getValue(buf + 4, 'float');
|
||||
const r = m.pdfium.getValue(buf + 8, 'float');
|
||||
const t = m.pdfium.getValue(buf + 12, 'float');
|
||||
const l = m.pdfium.getValue(buf, "float");
|
||||
const b = m.pdfium.getValue(buf + 4, "float");
|
||||
const r = m.pdfium.getValue(buf + 8, "float");
|
||||
const t = m.pdfium.getValue(buf + 12, "float");
|
||||
const w = Math.abs(r - l);
|
||||
const h = Math.abs(t - b);
|
||||
if (w < 0.01 || h < 0.01) return null; // degenerate
|
||||
@@ -208,26 +204,21 @@ export function readEffectivePageBox(
|
||||
* so it is never stale even if malloc triggered a memory growth.
|
||||
*/
|
||||
function copyToWasmHeap(m: WrappedPdfiumModule, bytes: Uint8Array, ptr: number): void {
|
||||
new Uint8Array(
|
||||
(m.pdfium.wasmExports as any).memory.buffer,
|
||||
).set(bytes, ptr);
|
||||
new Uint8Array((m.pdfium.wasmExports as any).memory.buffer).set(bytes, ptr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a PDF into PDFium memory and return the document pointer.
|
||||
* Caller MUST call `closeRawDocument(docPtr)` when finished.
|
||||
*/
|
||||
export async function openRawDocument(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
password?: string,
|
||||
): Promise<number> {
|
||||
export async function openRawDocument(data: ArrayBuffer | Uint8Array, password?: string): Promise<number> {
|
||||
const m = await getPdfiumModule();
|
||||
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
const len = bytes.length;
|
||||
const ptr = m.pdfium.wasmExports.malloc(len);
|
||||
copyToWasmHeap(m, bytes, ptr);
|
||||
|
||||
const docPtr = m.FPDF_LoadMemDocument(ptr, len, password ?? '');
|
||||
const docPtr = m.FPDF_LoadMemDocument(ptr, len, password ?? "");
|
||||
if (!docPtr) {
|
||||
m.pdfium.wasmExports.free(ptr);
|
||||
const err = m.FPDF_GetLastError();
|
||||
@@ -242,10 +233,7 @@ export async function openRawDocument(
|
||||
* Open a raw document — convenience alias that delegates to {@link openRawDocument}.
|
||||
* Kept for API compatibility with callers that were updated to use the "Safe" variant.
|
||||
*/
|
||||
export async function openRawDocumentSafe(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
password?: string,
|
||||
): Promise<number> {
|
||||
export async function openRawDocumentSafe(data: ArrayBuffer | Uint8Array, password?: string): Promise<number> {
|
||||
return openRawDocument(data, password);
|
||||
}
|
||||
|
||||
@@ -281,10 +269,7 @@ export async function getRawPageCount(docPtr: number): Promise<number> {
|
||||
/**
|
||||
* Get raw page dimensions { width, height } for a page.
|
||||
*/
|
||||
export async function getRawPageSize(
|
||||
docPtr: number,
|
||||
pageIndex: number,
|
||||
): Promise<{ width: number; height: number }> {
|
||||
export async function getRawPageSize(docPtr: number, pageIndex: number): Promise<{ width: number; height: number }> {
|
||||
const m = await getPdfiumModule();
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) throw new Error(`PDFium: failed to load page ${pageIndex}`);
|
||||
@@ -299,7 +284,7 @@ export async function getRawPageSize(
|
||||
* bytes (including the trailing NUL pair).
|
||||
*/
|
||||
export function readUtf16(m: WrappedPdfiumModule, ptr: number, byteLen: number): string {
|
||||
if (byteLen <= 2 || !ptr) return '';
|
||||
if (byteLen <= 2 || !ptr) return "";
|
||||
return m.pdfium.UTF16ToString(ptr);
|
||||
}
|
||||
|
||||
@@ -315,7 +300,6 @@ export function writeUtf16(m: WrappedPdfiumModule, str: string): number {
|
||||
return ptr;
|
||||
}
|
||||
|
||||
|
||||
export interface PdfiumFormField {
|
||||
name: string;
|
||||
type: PDF_FORM_FIELD_TYPE;
|
||||
@@ -331,7 +315,7 @@ export interface PdfiumFormField {
|
||||
|
||||
export interface PdfiumWidgetRect {
|
||||
pageIndex: number;
|
||||
x: number; // CSS upper-left origin (after y-flip)
|
||||
x: number; // CSS upper-left origin (after y-flip)
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -347,16 +331,13 @@ export interface PdfiumWidgetRect {
|
||||
* Returns an array of parsed form fields with their widget rectangles already
|
||||
* converted to CSS coordinate space (upper-left origin).
|
||||
*/
|
||||
export async function extractFormFields(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
password?: string,
|
||||
): Promise<PdfiumFormField[]> {
|
||||
export async function extractFormFields(data: ArrayBuffer | Uint8Array, password?: string): Promise<PdfiumFormField[]> {
|
||||
const m = await getPdfiumModule();
|
||||
let docPtr: number;
|
||||
try {
|
||||
docPtr = await openRawDocumentSafe(data, password);
|
||||
} catch (err) {
|
||||
console.error('[extractFormFields] openRawDocumentSafe failed:', err);
|
||||
console.error("[extractFormFields] openRawDocumentSafe failed:", err);
|
||||
if (err instanceof WebAssembly.RuntimeError) resetPdfiumModule();
|
||||
throw err;
|
||||
}
|
||||
@@ -367,8 +348,13 @@ export async function extractFormFields(
|
||||
const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(docPtr, formInfoPtr);
|
||||
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
console.debug('[extractFormFields] docPtr=%d formEnvPtr=%d pageCount=%d dataSize=%d',
|
||||
docPtr, formEnvPtr, pageCount, data instanceof Uint8Array ? data.length : data.byteLength);
|
||||
console.debug(
|
||||
"[extractFormFields] docPtr=%d formEnvPtr=%d pageCount=%d dataSize=%d",
|
||||
docPtr,
|
||||
formEnvPtr,
|
||||
pageCount,
|
||||
data instanceof Uint8Array ? data.length : data.byteLength,
|
||||
);
|
||||
// Map: fieldName → PdfiumFormField (to merge widgets across pages)
|
||||
const fieldMap = new Map<string, PdfiumFormField>();
|
||||
|
||||
@@ -377,7 +363,7 @@ export async function extractFormFields(
|
||||
try {
|
||||
pagePtr = m.FPDF_LoadPage(docPtr, pageIdx);
|
||||
} catch (err) {
|
||||
console.warn('[extractFormFields] FPDF_LoadPage crashed for page', pageIdx, err);
|
||||
console.warn("[extractFormFields] FPDF_LoadPage crashed for page", pageIdx, err);
|
||||
if (err instanceof WebAssembly.RuntimeError) {
|
||||
resetPdfiumModule();
|
||||
throw err;
|
||||
@@ -385,7 +371,7 @@ export async function extractFormFields(
|
||||
continue;
|
||||
}
|
||||
if (!pagePtr) {
|
||||
console.warn('[extractFormFields] FPDF_LoadPage returned 0 for page', pageIdx);
|
||||
console.warn("[extractFormFields] FPDF_LoadPage returned 0 for page", pageIdx);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -394,7 +380,7 @@ export async function extractFormFields(
|
||||
try {
|
||||
m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr);
|
||||
} catch (err) {
|
||||
console.warn('[extractFormFields] FORM_OnAfterLoadPage crashed for page', pageIdx, err);
|
||||
console.warn("[extractFormFields] FORM_OnAfterLoadPage crashed for page", pageIdx, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,9 +393,14 @@ export async function extractFormFields(
|
||||
|
||||
if (pageIdx === 0) {
|
||||
console.debug(
|
||||
'[extractFormFields] page 0 box: left=%.2f bottom=%.2f right=%.2f top=%.2f cropW=%.2f cropH=%.2f FPDF_H=%.2f',
|
||||
pageBox.left, pageBox.bottom, pageBox.right, pageBox.top,
|
||||
cropWidth, cropHeight, m.FPDF_GetPageHeightF(pagePtr),
|
||||
"[extractFormFields] page 0 box: left=%.2f bottom=%.2f right=%.2f top=%.2f cropW=%.2f cropH=%.2f FPDF_H=%.2f",
|
||||
pageBox.left,
|
||||
pageBox.bottom,
|
||||
pageBox.right,
|
||||
pageBox.top,
|
||||
cropWidth,
|
||||
cropHeight,
|
||||
m.FPDF_GetPageHeightF(pagePtr),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -417,34 +408,54 @@ export async function extractFormFields(
|
||||
try {
|
||||
this_extractAnnotation(m, formEnvPtr, pagePtr, pageIdx, pageBox, cropWidth, cropHeight, annotIdx, fieldMap);
|
||||
} catch (annotErr) {
|
||||
console.warn('[extractFormFields] Annotation %d on page %d crashed:', annotIdx, pageIdx, annotErr);
|
||||
console.warn("[extractFormFields] Annotation %d on page %d crashed:", annotIdx, pageIdx, annotErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (formEnvPtr) {
|
||||
try { m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr); } catch { /* best-effort */ }
|
||||
try {
|
||||
m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
try {
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
try { m.FPDF_ClosePage(pagePtr); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Cleanup form environment
|
||||
if (formEnvPtr) {
|
||||
try { m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr); } catch { /* */ }
|
||||
try {
|
||||
m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
if (formInfoPtr) {
|
||||
try { m.PDFiumExt_CloseFormFillInfo(formInfoPtr); } catch { /* */ }
|
||||
try {
|
||||
m.PDFiumExt_CloseFormFillInfo(formInfoPtr);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
console.debug('[extractFormFields] Extracted %d fields', fieldMap.size);
|
||||
console.debug("[extractFormFields] Extracted %d fields", fieldMap.size);
|
||||
return Array.from(fieldMap.values());
|
||||
} catch (err) {
|
||||
if (err instanceof WebAssembly.RuntimeError) {
|
||||
console.error('[extractFormFields] WASM RuntimeError — resetting module:', err);
|
||||
console.error("[extractFormFields] WASM RuntimeError — resetting module:", err);
|
||||
resetPdfiumModule();
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
try { closeDocAndFreeBuffer(m, docPtr); } catch { /* best-effort */ }
|
||||
try {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,13 +489,11 @@ function this_extractAnnotation(
|
||||
if (subtype !== 20) return;
|
||||
|
||||
// Get form field type
|
||||
const fieldType = formEnvPtr
|
||||
? m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr)
|
||||
: 0;
|
||||
const fieldType = formEnvPtr ? m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr) : 0;
|
||||
|
||||
// Get field name (requires a valid form environment pointer)
|
||||
const nameLen = formEnvPtr ? m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0) : 0;
|
||||
let fieldName = '';
|
||||
let fieldName = "";
|
||||
if (nameLen > 0) {
|
||||
const nameBuf = m.pdfium.wasmExports.malloc(nameLen);
|
||||
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nameBuf, nameLen);
|
||||
@@ -494,7 +503,7 @@ function this_extractAnnotation(
|
||||
|
||||
// Get field value (requires a valid form environment pointer)
|
||||
const valLen = formEnvPtr ? m.FPDFAnnot_GetFormFieldValue(formEnvPtr, annotPtr, 0, 0) : 0;
|
||||
let fieldValue = '';
|
||||
let fieldValue = "";
|
||||
if (valLen > 0) {
|
||||
const valBuf = m.pdfium.wasmExports.malloc(valLen);
|
||||
m.FPDFAnnot_GetFormFieldValue(formEnvPtr, annotPtr, valBuf, valLen);
|
||||
@@ -504,8 +513,8 @@ function this_extractAnnotation(
|
||||
|
||||
// Get field flags (requires a valid form environment pointer)
|
||||
const fieldFlags = formEnvPtr ? m.FPDFAnnot_GetFormFieldFlags(formEnvPtr, annotPtr) : 0;
|
||||
const isReadOnly = (fieldFlags & 1) !== 0; // FORMFLAG_READONLY = 1
|
||||
const isRequired = (fieldFlags & 2) !== 0; // FORMFLAG_REQUIRED = 2
|
||||
const isReadOnly = (fieldFlags & 1) !== 0; // FORMFLAG_READONLY = 1
|
||||
const isRequired = (fieldFlags & 2) !== 0; // FORMFLAG_REQUIRED = 2
|
||||
|
||||
// Is checked (for checkboxes/radios)
|
||||
const isChecked = formEnvPtr ? m.FPDFAnnot_IsChecked(formEnvPtr, annotPtr) : false;
|
||||
@@ -519,16 +528,16 @@ function this_extractAnnotation(
|
||||
hasRect = m.FPDFAnnot_GetRect(annotPtr, rectBuf);
|
||||
} catch {
|
||||
m.pdfium.wasmExports.free(rectBuf);
|
||||
throw new Error('FPDFAnnot_GetRect crashed');
|
||||
throw new Error("FPDFAnnot_GetRect crashed");
|
||||
}
|
||||
|
||||
let widgetRect: PdfiumWidgetRect | null = null;
|
||||
if (hasRect) {
|
||||
// Standard FS_RECTF: {left, bottom, right, top} — raw MediaBox coordinates
|
||||
const rawLeft = m.pdfium.getValue(rectBuf, 'float');
|
||||
const rawBottom = m.pdfium.getValue(rectBuf + 4, 'float');
|
||||
const rawRight = m.pdfium.getValue(rectBuf + 8, 'float');
|
||||
const rawTop = m.pdfium.getValue(rectBuf + 12, 'float');
|
||||
const rawLeft = m.pdfium.getValue(rectBuf, "float");
|
||||
const rawBottom = m.pdfium.getValue(rectBuf + 4, "float");
|
||||
const rawRight = m.pdfium.getValue(rectBuf + 8, "float");
|
||||
const rawTop = m.pdfium.getValue(rectBuf + 12, "float");
|
||||
|
||||
const annotLeft = Math.min(rawLeft, rawRight);
|
||||
const annotBottom = Math.min(rawBottom, rawTop);
|
||||
@@ -548,9 +557,18 @@ function this_extractAnnotation(
|
||||
// Diagnostic log for first annotation on first page
|
||||
if (pageIdx === 0 && annotIdx < 2) {
|
||||
console.debug(
|
||||
'[extractFormFields] annot[%d] raw rect: L=%.2f B=%.2f R=%.2f T=%.2f → css: x=%.2f y=%.2f w=%.2f h=%.2f (cropAdj: relX=%.2f relY=%.2f)',
|
||||
annotIdx, annotLeft, annotBottom, annotRight, annotTop,
|
||||
cssX, cssY, pdfW, pdfH, relativeX, relativeY,
|
||||
"[extractFormFields] annot[%d] raw rect: L=%.2f B=%.2f R=%.2f T=%.2f → css: x=%.2f y=%.2f w=%.2f h=%.2f (cropAdj: relX=%.2f relY=%.2f)",
|
||||
annotIdx,
|
||||
annotLeft,
|
||||
annotBottom,
|
||||
annotRight,
|
||||
annotTop,
|
||||
cssX,
|
||||
cssY,
|
||||
pdfW,
|
||||
pdfH,
|
||||
relativeX,
|
||||
relativeY,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -564,10 +582,10 @@ function this_extractAnnotation(
|
||||
|
||||
// Get font size from default appearance
|
||||
try {
|
||||
const daLen = m.FPDFAnnot_GetStringValue(annotPtr, 'DA', 0, 0);
|
||||
const daLen = m.FPDFAnnot_GetStringValue(annotPtr, "DA", 0, 0);
|
||||
if (daLen > 0) {
|
||||
const daBuf = m.pdfium.wasmExports.malloc(daLen);
|
||||
m.FPDFAnnot_GetStringValue(annotPtr, 'DA', daBuf, daLen);
|
||||
m.FPDFAnnot_GetStringValue(annotPtr, "DA", daBuf, daLen);
|
||||
const daStr = readUtf16(m, daBuf, daLen);
|
||||
m.pdfium.wasmExports.free(daBuf);
|
||||
const tfMatch = daStr.match(/(\d+(?:\.\d+)?)\s+Tf/);
|
||||
@@ -609,7 +627,7 @@ function this_extractAnnotation(
|
||||
const optCount = m.FPDFAnnot_GetOptionCount(formEnvPtr, annotPtr);
|
||||
for (let oi = 0; oi < optCount; oi++) {
|
||||
const optLabelLen = m.FPDFAnnot_GetOptionLabel(formEnvPtr, annotPtr, oi, 0, 0);
|
||||
let optLabel = '';
|
||||
let optLabel = "";
|
||||
if (optLabelLen > 0) {
|
||||
const optBuf = m.pdfium.wasmExports.malloc(optLabelLen);
|
||||
m.FPDFAnnot_GetOptionLabel(formEnvPtr, annotPtr, oi, optBuf, optLabelLen);
|
||||
@@ -666,10 +684,7 @@ export interface PdfiumSignature {
|
||||
/**
|
||||
* Extract digital signature objects from a PDF.
|
||||
*/
|
||||
export async function extractSignatures(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
password?: string,
|
||||
): Promise<PdfiumSignature[]> {
|
||||
export async function extractSignatures(data: ArrayBuffer | Uint8Array, password?: string): Promise<PdfiumSignature[]> {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(data, password);
|
||||
|
||||
@@ -689,7 +704,7 @@ export async function extractSignatures(
|
||||
m.FPDFSignatureObj_GetContents(sigPtr, buf, contentsLen);
|
||||
contents = new Uint8Array(contentsLen);
|
||||
for (let j = 0; j < contentsLen; j++) {
|
||||
contents[j] = m.pdfium.getValue(buf + j, 'i8') & 0xff;
|
||||
contents[j] = m.pdfium.getValue(buf + j, "i8") & 0xff;
|
||||
}
|
||||
m.pdfium.wasmExports.free(buf);
|
||||
}
|
||||
@@ -701,14 +716,14 @@ export async function extractSignatures(
|
||||
const brBuf = m.pdfium.wasmExports.malloc(brLen * 4);
|
||||
m.FPDFSignatureObj_GetByteRange(sigPtr, brBuf, brLen);
|
||||
for (let j = 0; j < brLen; j++) {
|
||||
byteRange.push(m.pdfium.getValue(brBuf + j * 4, 'i32'));
|
||||
byteRange.push(m.pdfium.getValue(brBuf + j * 4, "i32"));
|
||||
}
|
||||
m.pdfium.wasmExports.free(brBuf);
|
||||
}
|
||||
|
||||
// SubFilter
|
||||
const sfLen = m.FPDFSignatureObj_GetSubFilter(sigPtr, 0, 0);
|
||||
let subFilter = '';
|
||||
let subFilter = "";
|
||||
if (sfLen > 0) {
|
||||
const sfBuf = m.pdfium.wasmExports.malloc(sfLen);
|
||||
m.FPDFSignatureObj_GetSubFilter(sigPtr, sfBuf, sfLen);
|
||||
@@ -718,7 +733,7 @@ export async function extractSignatures(
|
||||
|
||||
// Reason
|
||||
const reasonLen = m.FPDFSignatureObj_GetReason(sigPtr, 0, 0);
|
||||
let reason = '';
|
||||
let reason = "";
|
||||
if (reasonLen > 0) {
|
||||
const reasonBuf = m.pdfium.wasmExports.malloc(reasonLen);
|
||||
m.FPDFSignatureObj_GetReason(sigPtr, reasonBuf, reasonLen);
|
||||
@@ -728,7 +743,7 @@ export async function extractSignatures(
|
||||
|
||||
// Time
|
||||
const timeLen = m.FPDFSignatureObj_GetTime(sigPtr, 0, 0);
|
||||
let time = '';
|
||||
let time = "";
|
||||
if (timeLen > 0) {
|
||||
const timeBuf = m.pdfium.wasmExports.malloc(timeLen);
|
||||
m.FPDFSignatureObj_GetTime(sigPtr, timeBuf, timeLen);
|
||||
@@ -748,7 +763,6 @@ export async function extractSignatures(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render a single page to an ImageData-like bitmap.
|
||||
*/
|
||||
@@ -788,10 +802,10 @@ export async function renderPageToBitmap(
|
||||
const srcOff = y * stride + x * 4;
|
||||
const dstOff = (y * w + x) * 4;
|
||||
// BGRA → RGBA
|
||||
pixelData[dstOff] = m.pdfium.getValue(bufferPtr + srcOff + 2, 'i8') & 0xff;
|
||||
pixelData[dstOff + 1] = m.pdfium.getValue(bufferPtr + srcOff + 1, 'i8') & 0xff;
|
||||
pixelData[dstOff + 2] = m.pdfium.getValue(bufferPtr + srcOff, 'i8') & 0xff;
|
||||
pixelData[dstOff + 3] = m.pdfium.getValue(bufferPtr + srcOff + 3, 'i8') & 0xff;
|
||||
pixelData[dstOff] = m.pdfium.getValue(bufferPtr + srcOff + 2, "i8") & 0xff;
|
||||
pixelData[dstOff + 1] = m.pdfium.getValue(bufferPtr + srcOff + 1, "i8") & 0xff;
|
||||
pixelData[dstOff + 2] = m.pdfium.getValue(bufferPtr + srcOff, "i8") & 0xff;
|
||||
pixelData[dstOff + 3] = m.pdfium.getValue(bufferPtr + srcOff + 3, "i8") & 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,7 +822,7 @@ export interface PdfiumLink {
|
||||
id: string;
|
||||
annotIndex: number;
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
type: 'internal' | 'external' | 'unknown';
|
||||
type: "internal" | "external" | "unknown";
|
||||
targetPage?: number;
|
||||
uri?: string;
|
||||
}
|
||||
@@ -859,7 +873,7 @@ export async function extractLinksFromPage(
|
||||
|
||||
// Try to get link object
|
||||
const linkPtr = m.FPDFAnnot_GetLink(annotPtr);
|
||||
let linkType: 'internal' | 'external' | 'unknown' = 'unknown';
|
||||
let linkType: "internal" | "external" | "unknown" = "unknown";
|
||||
let targetPage: number | undefined;
|
||||
let uri: string | undefined;
|
||||
|
||||
@@ -876,24 +890,24 @@ export async function extractLinksFromPage(
|
||||
m.FPDFAction_GetURIPath(docPtr, actionPtr, uriBuf, uriLen);
|
||||
uri = m.pdfium.UTF8ToString(uriBuf);
|
||||
m.pdfium.wasmExports.free(uriBuf);
|
||||
linkType = 'external';
|
||||
linkType = "external";
|
||||
}
|
||||
} else if (actionType === 1) {
|
||||
// PDFACTION_GOTO = 1
|
||||
const destPtr = m.FPDFAction_GetDest(docPtr, actionPtr);
|
||||
if (destPtr) {
|
||||
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
|
||||
linkType = 'internal';
|
||||
linkType = "internal";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for direct destination (no action)
|
||||
if (linkType === 'unknown') {
|
||||
if (linkType === "unknown") {
|
||||
const destPtr = m.FPDFLink_GetDest(docPtr, linkPtr);
|
||||
if (destPtr) {
|
||||
targetPage = m.FPDFDest_GetDestPageIndex(docPtr, destPtr);
|
||||
linkType = 'internal';
|
||||
linkType = "internal";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -917,14 +931,13 @@ export async function extractLinksFromPage(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new empty PDF document, returning its raw data.
|
||||
*/
|
||||
export async function createEmptyDocument(): Promise<ArrayBuffer> {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = m.FPDF_CreateNewDocument();
|
||||
if (!docPtr) throw new Error('PDFium: failed to create new document');
|
||||
if (!docPtr) throw new Error("PDFium: failed to create new document");
|
||||
const writerPtr = m.PDFiumExt_OpenFileWriter();
|
||||
m.PDFiumExt_SaveAsCopy(docPtr, writerPtr);
|
||||
const size = m.PDFiumExt_GetFileWriterSize(writerPtr);
|
||||
@@ -933,7 +946,7 @@ export async function createEmptyDocument(): Promise<ArrayBuffer> {
|
||||
const result = new ArrayBuffer(size);
|
||||
const view = new Uint8Array(result);
|
||||
for (let i = 0; i < size; i++) {
|
||||
view[i] = m.pdfium.getValue(outBuf + i, 'i8') & 0xff;
|
||||
view[i] = m.pdfium.getValue(outBuf + i, "i8") & 0xff;
|
||||
}
|
||||
m.pdfium.wasmExports.free(outBuf);
|
||||
m.PDFiumExt_CloseFileWriter(writerPtr);
|
||||
@@ -954,7 +967,7 @@ export async function saveRawDocument(docPtr: number): Promise<ArrayBuffer> {
|
||||
const result = new ArrayBuffer(size);
|
||||
const view = new Uint8Array(result);
|
||||
for (let i = 0; i < size; i++) {
|
||||
view[i] = m.pdfium.getValue(outBuf + i, 'i8') & 0xff;
|
||||
view[i] = m.pdfium.getValue(outBuf + i, "i8") & 0xff;
|
||||
}
|
||||
m.pdfium.wasmExports.free(outBuf);
|
||||
m.PDFiumExt_CloseFileWriter(writerPtr);
|
||||
@@ -972,18 +985,14 @@ export async function importPages(
|
||||
insertIndex?: number,
|
||||
): Promise<boolean> {
|
||||
const m = await getPdfiumModule();
|
||||
return m.FPDF_ImportPages(destDocPtr, srcDocPtr, pageRange ?? '', insertIndex ?? 0);
|
||||
return m.FPDF_ImportPages(destDocPtr, srcDocPtr, pageRange ?? "", insertIndex ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set page rotation on a raw document.
|
||||
* @param rotation 0, 1, 2, 3 for 0°, 90°, 180°, 270°
|
||||
*/
|
||||
export async function setPageRotation(
|
||||
docPtr: number,
|
||||
pageIndex: number,
|
||||
rotation: number,
|
||||
): Promise<void> {
|
||||
export async function setPageRotation(docPtr: number, pageIndex: number, rotation: number): Promise<void> {
|
||||
const m = await getPdfiumModule();
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
|
||||
if (!pagePtr) throw new Error(`PDFium: failed to load page ${pageIndex}`);
|
||||
@@ -994,12 +1003,7 @@ export async function setPageRotation(
|
||||
/**
|
||||
* Create a new page in a document.
|
||||
*/
|
||||
export async function addNewPage(
|
||||
docPtr: number,
|
||||
insertIndex: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<void> {
|
||||
export async function addNewPage(docPtr: number, insertIndex: number, width: number, height: number): Promise<void> {
|
||||
const m = await getPdfiumModule();
|
||||
const pagePtr = m.FPDFPage_New(docPtr, insertIndex, width, height);
|
||||
if (pagePtr) m.FPDF_ClosePage(pagePtr);
|
||||
@@ -1008,14 +1012,11 @@ export async function addNewPage(
|
||||
/**
|
||||
* Get metadata from a document.
|
||||
*/
|
||||
export async function getMetadata(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
password?: string,
|
||||
): Promise<Record<string, string>> {
|
||||
export async function getMetadata(data: ArrayBuffer | Uint8Array, password?: string): Promise<Record<string, string>> {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(data, password);
|
||||
try {
|
||||
const tags = ['Title', 'Author', 'Subject', 'Keywords', 'Creator', 'Producer'];
|
||||
const tags = ["Title", "Author", "Subject", "Keywords", "Creator", "Producer"];
|
||||
const meta: Record<string, string> = {};
|
||||
for (const tag of tags) {
|
||||
const len = m.FPDF_GetMetaText(docPtr, tag, 0, 0);
|
||||
@@ -1072,14 +1073,13 @@ export async function extractSignatureFieldRects(
|
||||
if (!annotPtr) continue;
|
||||
|
||||
const subtype = m.FPDFAnnot_GetSubtype(annotPtr);
|
||||
if (subtype !== 20) { // WIDGET
|
||||
if (subtype !== 20) {
|
||||
// WIDGET
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldType = formEnvPtr
|
||||
? m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr)
|
||||
: 0;
|
||||
const fieldType = formEnvPtr ? m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr) : 0;
|
||||
|
||||
if (fieldType !== PDF_FORM_FIELD_TYPE.SIGNATURE) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
@@ -1088,7 +1088,7 @@ export async function extractSignatureFieldRects(
|
||||
|
||||
// Get field name
|
||||
const nameLen = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0);
|
||||
let name = '';
|
||||
let name = "";
|
||||
if (nameLen > 0) {
|
||||
const nameBuf = m.pdfium.wasmExports.malloc(nameLen);
|
||||
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nameBuf, nameLen);
|
||||
@@ -1100,10 +1100,10 @@ export async function extractSignatureFieldRects(
|
||||
const rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
|
||||
const hasRect = m.FPDFAnnot_GetRect(annotPtr, rectBuf);
|
||||
if (hasRect) {
|
||||
const rawLeft = m.pdfium.getValue(rectBuf, 'float');
|
||||
const rawBottom = m.pdfium.getValue(rectBuf + 4, 'float');
|
||||
const rawRight = m.pdfium.getValue(rectBuf + 8, 'float');
|
||||
const rawTop = m.pdfium.getValue(rectBuf + 12, 'float');
|
||||
const rawLeft = m.pdfium.getValue(rectBuf, "float");
|
||||
const rawBottom = m.pdfium.getValue(rectBuf + 4, "float");
|
||||
const rawRight = m.pdfium.getValue(rectBuf + 8, "float");
|
||||
const rawTop = m.pdfium.getValue(rectBuf + 12, "float");
|
||||
|
||||
const aLeft = Math.min(rawLeft, rawRight);
|
||||
const aBottom = Math.min(rawBottom, rawTop);
|
||||
@@ -1204,12 +1204,10 @@ async function renderWidgetAppearance(
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
ok = !!m.EPDF_RenderAnnotBitmap(
|
||||
bitmapPtr, pagePtr, annotPtr,
|
||||
AP_MODE_NORMAL, matrixPtr,
|
||||
RENDER_FLAG_REVERSE_BYTE_ORDER,
|
||||
);
|
||||
} catch { /* Extension not available */ }
|
||||
ok = !!m.EPDF_RenderAnnotBitmap(bitmapPtr, pagePtr, annotPtr, AP_MODE_NORMAL, matrixPtr, RENDER_FLAG_REVERSE_BYTE_ORDER);
|
||||
} catch {
|
||||
/* Extension not available */
|
||||
}
|
||||
m.pdfium.wasmExports.free(matrixPtr);
|
||||
m.FPDFBitmap_Destroy(bitmapPtr);
|
||||
|
||||
@@ -1218,7 +1216,10 @@ async function renderWidgetAppearance(
|
||||
const rgba = new Uint8ClampedArray(pdfiumWasm.HEAPU8.buffer.slice(heapPtr, heapPtr + bytes));
|
||||
let hasVisible = false;
|
||||
for (let i = 3; i < rgba.length; i += 4) {
|
||||
if (rgba[i] > 0) { hasVisible = true; break; }
|
||||
if (rgba[i] > 0) {
|
||||
hasVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasVisible) imageData = new ImageData(rgba, wDev, hDev);
|
||||
}
|
||||
@@ -1238,14 +1239,19 @@ async function renderWidgetAppearance(
|
||||
try {
|
||||
m.FPDF_RenderPageBitmap(bmp2, pagePtr, startX, startY, fullW, fullH, 0, 0x01 | 0x10);
|
||||
m.FPDF_FFLDraw(formEnvPtr, bmp2, pagePtr, startX, startY, fullW, fullH, 0, 0x01 | 0x10);
|
||||
} catch { /* fallback not available */ }
|
||||
} catch {
|
||||
/* fallback not available */
|
||||
}
|
||||
|
||||
m.FPDFBitmap_Destroy(bmp2);
|
||||
|
||||
const rgba2 = new Uint8ClampedArray(pdfiumWasm.HEAPU8.buffer.slice(heap2, heap2 + bytes));
|
||||
let hasVisible2 = false;
|
||||
for (let i = 3; i < rgba2.length; i += 4) {
|
||||
if (rgba2[i] > 0) { hasVisible2 = true; break; }
|
||||
if (rgba2[i] > 0) {
|
||||
hasVisible2 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasVisible2) imageData = new ImageData(rgba2, wDev, hDev);
|
||||
m.pdfium.wasmExports.free(heap2);
|
||||
@@ -1302,14 +1308,13 @@ export async function renderSignatureFieldAppearances(
|
||||
if (!annotPtr) continue;
|
||||
|
||||
const subtype = m.FPDFAnnot_GetSubtype(annotPtr);
|
||||
if (subtype !== 20) { // FPDF_ANNOT_WIDGET
|
||||
if (subtype !== 20) {
|
||||
// FPDF_ANNOT_WIDGET
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldType = formEnvPtr
|
||||
? m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr)
|
||||
: 0;
|
||||
const fieldType = formEnvPtr ? m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr) : 0;
|
||||
|
||||
if (fieldType !== PDF_FORM_FIELD_TYPE.SIGNATURE) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
@@ -1318,7 +1323,7 @@ export async function renderSignatureFieldAppearances(
|
||||
|
||||
// --- field name ---
|
||||
const nameLen = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0);
|
||||
let name = '';
|
||||
let name = "";
|
||||
if (nameLen > 0) {
|
||||
const nameBuf = m.pdfium.wasmExports.malloc(nameLen);
|
||||
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nameBuf, nameLen);
|
||||
@@ -1344,10 +1349,10 @@ export async function renderSignatureFieldAppearances(
|
||||
}
|
||||
|
||||
// Standard FS_RECTF layout: {left, bottom, right, top}
|
||||
const rawLeft = m.pdfium.getValue(rectBuf, 'float');
|
||||
const rawBottom = m.pdfium.getValue(rectBuf + 4, 'float');
|
||||
const rawRight = m.pdfium.getValue(rectBuf + 8, 'float');
|
||||
const rawTop = m.pdfium.getValue(rectBuf + 12, 'float');
|
||||
const rawLeft = m.pdfium.getValue(rectBuf, "float");
|
||||
const rawBottom = m.pdfium.getValue(rectBuf + 4, "float");
|
||||
const rawRight = m.pdfium.getValue(rectBuf + 8, "float");
|
||||
const rawTop = m.pdfium.getValue(rectBuf + 12, "float");
|
||||
m.pdfium.wasmExports.free(rectBuf);
|
||||
|
||||
// Normalise
|
||||
@@ -1367,9 +1372,7 @@ export async function renderSignatureFieldAppearances(
|
||||
let imageData: ImageData | null = null;
|
||||
|
||||
if (pdfW > 0.5 && pdfH > 0.5) {
|
||||
const dpr = typeof window !== 'undefined'
|
||||
? Math.min(window.devicePixelRatio || 1, 3)
|
||||
: 1;
|
||||
const dpr = typeof window !== "undefined" ? Math.min(window.devicePixelRatio || 1, 3) : 1;
|
||||
const wDev = Math.max(1, Math.round(pdfW * dpr));
|
||||
const hDev = Math.max(1, Math.round(pdfH * dpr));
|
||||
const stride = wDev * 4;
|
||||
@@ -1385,16 +1388,17 @@ export async function renderSignatureFieldAppearances(
|
||||
const sx = wDev / pdfW;
|
||||
const sy = hDev / pdfH;
|
||||
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
|
||||
const matrixView = new Float32Array(
|
||||
pdfiumWasm.HEAPF32.buffer, matrixPtr, 6,
|
||||
);
|
||||
const matrixView = new Float32Array(pdfiumWasm.HEAPF32.buffer, matrixPtr, 6);
|
||||
matrixView.set([sx, 0, 0, -sy, -sx * annotLeft, sy * annotTop]);
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
ok = !!m.EPDF_RenderAnnotBitmap(
|
||||
bitmapPtr, pagePtr, annotPtr,
|
||||
AP_MODE_NORMAL, matrixPtr,
|
||||
bitmapPtr,
|
||||
pagePtr,
|
||||
annotPtr,
|
||||
AP_MODE_NORMAL,
|
||||
matrixPtr,
|
||||
RENDER_FLAG_REVERSE_BYTE_ORDER,
|
||||
);
|
||||
} catch {
|
||||
@@ -1405,12 +1409,13 @@ export async function renderSignatureFieldAppearances(
|
||||
m.FPDFBitmap_Destroy(bitmapPtr);
|
||||
|
||||
if (ok) {
|
||||
const rgba = new Uint8ClampedArray(
|
||||
pdfiumWasm.HEAPU8.buffer.slice(heapPtr, heapPtr + bytes),
|
||||
);
|
||||
const rgba = new Uint8ClampedArray(pdfiumWasm.HEAPU8.buffer.slice(heapPtr, heapPtr + bytes));
|
||||
let hasVisible = false;
|
||||
for (let i = 3; i < rgba.length; i += 4) {
|
||||
if (rgba[i] > 0) { hasVisible = true; break; }
|
||||
if (rgba[i] > 0) {
|
||||
hasVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasVisible) {
|
||||
imageData = new ImageData(rgba, wDev, hDev);
|
||||
@@ -1435,27 +1440,30 @@ export async function renderSignatureFieldAppearances(
|
||||
try {
|
||||
// Draw page content first (provides background under signature)
|
||||
m.FPDF_RenderPageBitmap(
|
||||
bmp2, pagePtr, startX, startY, fullW, fullH, 0,
|
||||
bmp2,
|
||||
pagePtr,
|
||||
startX,
|
||||
startY,
|
||||
fullW,
|
||||
fullH,
|
||||
0,
|
||||
0x01 | 0x10, // FPDF_ANNOT | FPDF_REVERSE_BYTE_ORDER
|
||||
);
|
||||
// Draw form fill layer on top (includes signature appearances)
|
||||
m.FPDF_FFLDraw(
|
||||
formEnvPtr, bmp2, pagePtr,
|
||||
startX, startY, fullW, fullH,
|
||||
0, 0x01 | 0x10,
|
||||
);
|
||||
m.FPDF_FFLDraw(formEnvPtr, bmp2, pagePtr, startX, startY, fullW, fullH, 0, 0x01 | 0x10);
|
||||
} catch {
|
||||
// FPDF_FFLDraw not available or failed.
|
||||
}
|
||||
|
||||
m.FPDFBitmap_Destroy(bmp2);
|
||||
|
||||
const rgba2 = new Uint8ClampedArray(
|
||||
pdfiumWasm.HEAPU8.buffer.slice(heap2, heap2 + bytes),
|
||||
);
|
||||
const rgba2 = new Uint8ClampedArray(pdfiumWasm.HEAPU8.buffer.slice(heap2, heap2 + bytes));
|
||||
let hasVisible2 = false;
|
||||
for (let i = 3; i < rgba2.length; i += 4) {
|
||||
if (rgba2[i] > 0) { hasVisible2 = true; break; }
|
||||
if (rgba2[i] > 0) {
|
||||
hasVisible2 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasVisible2) {
|
||||
imageData = new ImageData(rgba2, wDev, hDev);
|
||||
@@ -1527,7 +1535,10 @@ export async function renderButtonFieldAppearances(
|
||||
if (!annotPtr) continue;
|
||||
|
||||
const subtype = m.FPDFAnnot_GetSubtype(annotPtr);
|
||||
if (subtype !== 20) { m.FPDFPage_CloseAnnot(annotPtr); continue; }
|
||||
if (subtype !== 20) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldType = formEnvPtr ? m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr) : 0;
|
||||
if (fieldType !== PDF_FORM_FIELD_TYPE.PUSHBUTTON) {
|
||||
@@ -1536,7 +1547,7 @@ export async function renderButtonFieldAppearances(
|
||||
}
|
||||
|
||||
const nameLen = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0);
|
||||
let btnName = '';
|
||||
let btnName = "";
|
||||
if (nameLen > 0) {
|
||||
const nameBuf = m.pdfium.wasmExports.malloc(nameLen);
|
||||
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nameBuf, nameLen);
|
||||
@@ -1546,16 +1557,23 @@ export async function renderButtonFieldAppearances(
|
||||
|
||||
const rectBuf = m.pdfium.wasmExports.malloc(4 * 4);
|
||||
let hasRect = false;
|
||||
try { hasRect = m.FPDFAnnot_GetRect(annotPtr, rectBuf); }
|
||||
catch { m.pdfium.wasmExports.free(rectBuf); m.FPDFPage_CloseAnnot(annotPtr); continue; }
|
||||
try {
|
||||
hasRect = m.FPDFAnnot_GetRect(annotPtr, rectBuf);
|
||||
} catch {
|
||||
m.pdfium.wasmExports.free(rectBuf);
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
if (!hasRect) {
|
||||
m.pdfium.wasmExports.free(rectBuf); m.FPDFPage_CloseAnnot(annotPtr); continue;
|
||||
m.pdfium.wasmExports.free(rectBuf);
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawLeft = m.pdfium.getValue(rectBuf, 'float');
|
||||
const rawBottom = m.pdfium.getValue(rectBuf + 4, 'float');
|
||||
const rawRight = m.pdfium.getValue(rectBuf + 8, 'float');
|
||||
const rawTop = m.pdfium.getValue(rectBuf + 12, 'float');
|
||||
const rawLeft = m.pdfium.getValue(rectBuf, "float");
|
||||
const rawBottom = m.pdfium.getValue(rectBuf + 4, "float");
|
||||
const rawRight = m.pdfium.getValue(rectBuf + 8, "float");
|
||||
const rawTop = m.pdfium.getValue(rectBuf + 12, "float");
|
||||
m.pdfium.wasmExports.free(rectBuf);
|
||||
|
||||
const annotLeft = Math.min(rawLeft, rawRight);
|
||||
@@ -1573,7 +1591,7 @@ export async function renderButtonFieldAppearances(
|
||||
let imageData: ImageData | null = null;
|
||||
|
||||
if (pdfW > 0.5 && pdfH > 0.5) {
|
||||
const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 3) : 1;
|
||||
const dpr = typeof window !== "undefined" ? Math.min(window.devicePixelRatio || 1, 3) : 1;
|
||||
const wDev = Math.max(1, Math.round(pdfW * dpr));
|
||||
const hDev = Math.max(1, Math.round(pdfH * dpr));
|
||||
const stride = wDev * 4;
|
||||
@@ -1583,18 +1601,39 @@ export async function renderButtonFieldAppearances(
|
||||
m.FPDFBitmap_FillRect(bitmapPtr, 0, 0, wDev, hDev, 0x00000000);
|
||||
|
||||
imageData = await renderWidgetAppearance(
|
||||
m, bitmapPtr, heapPtr, wDev, hDev, stride, bytes,
|
||||
pagePtr, annotPtr, annotLeft, annotTopVal, pdfW, pdfH,
|
||||
cssX, cssY, cropWidth, cropHeight, formEnvPtr, dpr,
|
||||
m,
|
||||
bitmapPtr,
|
||||
heapPtr,
|
||||
wDev,
|
||||
hDev,
|
||||
stride,
|
||||
bytes,
|
||||
pagePtr,
|
||||
annotPtr,
|
||||
annotLeft,
|
||||
annotTopVal,
|
||||
pdfW,
|
||||
pdfH,
|
||||
cssX,
|
||||
cssY,
|
||||
cropWidth,
|
||||
cropHeight,
|
||||
formEnvPtr,
|
||||
dpr,
|
||||
);
|
||||
}
|
||||
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
buttonResults.push({
|
||||
pageIndex: pageIdx, x: cssX, y: cssY,
|
||||
width: pdfW, height: pdfH,
|
||||
fieldName: btnName, imageData,
|
||||
sourcePageWidth: cropWidth, sourcePageHeight: cropHeight,
|
||||
pageIndex: pageIdx,
|
||||
x: cssX,
|
||||
y: cssY,
|
||||
width: pdfW,
|
||||
height: pdfH,
|
||||
fieldName: btnName,
|
||||
imageData,
|
||||
sourcePageWidth: cropWidth,
|
||||
sourcePageHeight: cropHeight,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1641,21 +1680,21 @@ export async function fetchSignatureFieldsWithAppearances(
|
||||
// Convert ImageData to data URL for the appearance
|
||||
let appearanceDataUrl: string | undefined;
|
||||
if (appearance.imageData) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = appearance.imageData.width;
|
||||
canvas.height = appearance.imageData.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.putImageData(appearance.imageData, 0, 0);
|
||||
appearanceDataUrl = canvas.toDataURL('image/png');
|
||||
appearanceDataUrl = canvas.toDataURL("image/png");
|
||||
}
|
||||
}
|
||||
|
||||
formFields.push({
|
||||
name: appearance.fieldName,
|
||||
label: appearance.fieldName,
|
||||
type: 'signature',
|
||||
value: '',
|
||||
type: "signature",
|
||||
value: "",
|
||||
options: null,
|
||||
displayOptions: null,
|
||||
required: false,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { type ToolPanelMode, DEFAULT_TOOL_PANEL_MODE } from '@app/constants/toolPanel';
|
||||
import { type ThemeMode, getSystemTheme } from '@app/constants/theme';
|
||||
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 LogoVariant = "modern" | "classic";
|
||||
|
||||
export type PdfRenderMode = 'normal' | 'dark' | 'sepia';
|
||||
export type PdfRenderMode = "normal" | "dark" | "sepia";
|
||||
|
||||
export type StartupView = 'tools' | 'read' | 'automate';
|
||||
export type StartupView = "tools" | "read" | "automate";
|
||||
|
||||
export type ViewerZoomSetting = 'auto' | 'fitWidth' | 'fitPage' | '50' | '75' | '100' | '125' | '150' | '200';
|
||||
export type ViewerZoomSetting = "auto" | "fitWidth" | "fitPage" | "50" | "75" | "100" | "125" | "150" | "200";
|
||||
|
||||
export interface UserPreferences {
|
||||
autoUnzip: boolean;
|
||||
@@ -32,8 +32,8 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
autoUnzip: true,
|
||||
autoUnzipFileLimit: 4,
|
||||
defaultToolPanelMode: DEFAULT_TOOL_PANEL_MODE,
|
||||
defaultStartupView: 'tools',
|
||||
defaultViewerZoom: 'auto',
|
||||
defaultStartupView: "tools",
|
||||
defaultViewerZoom: "auto",
|
||||
theme: getSystemTheme(),
|
||||
toolPanelModePromptSeen: false,
|
||||
hasSelectedToolPanelMode: false,
|
||||
@@ -44,10 +44,10 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
hideUnavailableTools: false,
|
||||
hideUnavailableConversions: false,
|
||||
logoVariant: null,
|
||||
pdfRenderMode: 'normal',
|
||||
pdfRenderMode: "normal",
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf_preferences';
|
||||
const STORAGE_KEY = "stirlingpdf_preferences";
|
||||
|
||||
class PreferencesService {
|
||||
private serverDefaults: Partial<UserPreferences> = {};
|
||||
@@ -56,9 +56,7 @@ class PreferencesService {
|
||||
this.serverDefaults = defaults;
|
||||
}
|
||||
|
||||
getPreference<K extends keyof UserPreferences>(
|
||||
key: K
|
||||
): UserPreferences[K] {
|
||||
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);
|
||||
@@ -69,7 +67,7 @@ class PreferencesService {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading preference:', key, 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) {
|
||||
@@ -78,17 +76,14 @@ class PreferencesService {
|
||||
return DEFAULT_PREFERENCES[key];
|
||||
}
|
||||
|
||||
setPreference<K extends keyof UserPreferences>(
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
): void {
|
||||
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);
|
||||
console.error("Error writing preference:", key, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +100,7 @@ class PreferencesService {
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading preferences', error);
|
||||
console.error("Error reading preferences", error);
|
||||
}
|
||||
// Merge server defaults with hardcoded defaults
|
||||
return { ...DEFAULT_PREFERENCES, ...this.serverDefaults };
|
||||
@@ -115,7 +110,7 @@ class PreferencesService {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error clearing preferences:', error);
|
||||
console.error("Error clearing preferences:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
import { ProcessedFile, CacheConfig, CacheEntry, CacheStats } from '@app/types/processing';
|
||||
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
|
||||
}) {}
|
||||
|
||||
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()
|
||||
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;
|
||||
@@ -48,22 +50,19 @@ export class ProcessingCache {
|
||||
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
|
||||
) {
|
||||
while (this.cache.size >= this.config.maxFiles || this.totalSize + neededSize > this.config.maxSizeBytes) {
|
||||
const oldestKey = this.findOldestEntry();
|
||||
if (oldestKey) {
|
||||
this.delete(oldestKey);
|
||||
@@ -73,13 +72,13 @@ export class ProcessingCache {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -95,18 +94,18 @@ export class ProcessingCache {
|
||||
private calculateSize(data: ProcessedFile): number {
|
||||
// Rough size estimation
|
||||
let size = 0;
|
||||
|
||||
|
||||
// Estimate size of thumbnails (main memory consumer)
|
||||
data.pages.forEach(page => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -127,7 +126,7 @@ export class ProcessingCache {
|
||||
return {
|
||||
entries: this.cache.size,
|
||||
totalSizeBytes: this.totalSize,
|
||||
maxSizeBytes: this.config.maxSizeBytes
|
||||
maxSizeBytes: this.config.maxSizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,4 +134,4 @@ export class ProcessingCache {
|
||||
getKeys(): string[] {
|
||||
return Array.from(this.cache.keys());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProcessingError } from '@app/types/processing';
|
||||
import { ProcessingError } from "@app/types/processing";
|
||||
|
||||
export class ProcessingErrorHandler {
|
||||
private static readonly DEFAULT_MAX_RETRIES = 3;
|
||||
@@ -8,16 +8,16 @@ export class ProcessingErrorHandler {
|
||||
* Create a ProcessingError from an unknown error
|
||||
*/
|
||||
static createProcessingError(
|
||||
error: unknown,
|
||||
retryCount: number = 0,
|
||||
maxRetries: number = this.DEFAULT_MAX_RETRIES
|
||||
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);
|
||||
|
||||
@@ -27,68 +27,62 @@ export class ProcessingErrorHandler {
|
||||
recoverable,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
originalError
|
||||
originalError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the type of error based on error characteristics
|
||||
*/
|
||||
private static determineErrorType(error: Error, message: string): ProcessingError['type'] {
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
if (
|
||||
lowerMessage.includes("pdf") ||
|
||||
lowerMessage.includes("parse") ||
|
||||
lowerMessage.includes("invalid") ||
|
||||
lowerMessage.includes("corrupt") ||
|
||||
lowerMessage.includes("malformed")
|
||||
) {
|
||||
return "corruption";
|
||||
}
|
||||
|
||||
// Default to parsing error
|
||||
return 'parsing';
|
||||
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 {
|
||||
private static isRecoverable(errorType: ProcessingError["type"], retryCount: number, maxRetries: number): boolean {
|
||||
// Never recoverable
|
||||
if (errorType === 'cancelled' || errorType === 'corruption') {
|
||||
if (errorType === "cancelled" || errorType === "corruption") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -98,37 +92,37 @@ export class ProcessingErrorHandler {
|
||||
}
|
||||
|
||||
// Memory errors are usually not recoverable
|
||||
if (errorType === 'memory') {
|
||||
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';
|
||||
return errorType === "network" || errorType === "timeout" || errorType === "parsing";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error message for user display
|
||||
*/
|
||||
private static formatErrorMessage(errorType: ProcessingError['type'], originalMessage: string): string {
|
||||
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':
|
||||
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}`;
|
||||
}
|
||||
@@ -140,7 +134,7 @@ export class ProcessingErrorHandler {
|
||||
static async executeWithRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
onError?: (error: ProcessingError) => void,
|
||||
maxRetries: number = this.DEFAULT_MAX_RETRIES
|
||||
maxRetries: number = this.DEFAULT_MAX_RETRIES,
|
||||
): Promise<T> {
|
||||
let lastError: ProcessingError | null = null;
|
||||
|
||||
@@ -149,7 +143,7 @@ export class ProcessingErrorHandler {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = this.createProcessingError(error, attempt, maxRetries);
|
||||
|
||||
|
||||
// Notify error handler
|
||||
if (onError) {
|
||||
onError(lastError);
|
||||
@@ -168,13 +162,13 @@ export class ProcessingErrorHandler {
|
||||
// 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');
|
||||
throw lastError || new Error("Operation failed after all retries");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,7 +177,7 @@ export class ProcessingErrorHandler {
|
||||
static withTimeout<T>(
|
||||
operation: () => Promise<T>,
|
||||
timeoutMs: number,
|
||||
timeoutMessage: string = 'Operation timed out'
|
||||
timeoutMessage: string = "Operation timed out",
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
@@ -191,11 +185,11 @@ export class ProcessingErrorHandler {
|
||||
}, timeoutMs);
|
||||
|
||||
operation()
|
||||
.then(result => {
|
||||
.then((result) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(result);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
});
|
||||
@@ -207,7 +201,7 @@ export class ProcessingErrorHandler {
|
||||
*/
|
||||
static createTimeoutController(timeoutMs: number): AbortController {
|
||||
const controller = new AbortController();
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
@@ -227,49 +221,37 @@ export class ProcessingErrorHandler {
|
||||
*/
|
||||
static getErrorSuggestions(error: ProcessingError): string[] {
|
||||
switch (error.type) {
|
||||
case 'network':
|
||||
case "network":
|
||||
return ["Check your internet connection", "Try refreshing the page", "Try again in a few moments"];
|
||||
|
||||
case "memory":
|
||||
return [
|
||||
'Check your internet connection',
|
||||
'Try refreshing the page',
|
||||
'Try again in a few moments'
|
||||
"Close other browser tabs or applications",
|
||||
"Try processing a smaller file",
|
||||
"Restart your browser",
|
||||
"Use a device with more memory",
|
||||
];
|
||||
|
||||
case 'memory':
|
||||
|
||||
case "timeout":
|
||||
return [
|
||||
'Close other browser tabs or applications',
|
||||
'Try processing a smaller file',
|
||||
'Restart your browser',
|
||||
'Use a device with more memory'
|
||||
"Try processing a smaller file",
|
||||
"Break large files into smaller sections",
|
||||
"Check your internet connection speed",
|
||||
];
|
||||
|
||||
case 'timeout':
|
||||
|
||||
case "corruption":
|
||||
return [
|
||||
'Try processing a smaller file',
|
||||
'Break large files into smaller sections',
|
||||
'Check your internet connection speed'
|
||||
"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 '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'
|
||||
];
|
||||
|
||||
|
||||
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'
|
||||
];
|
||||
return ["Try refreshing the page", "Try again in a few moments", "Contact support if the problem persists"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +259,6 @@ export class ProcessingErrorHandler {
|
||||
* Utility function for delays
|
||||
*/
|
||||
private static delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import JSZip from 'jszip';
|
||||
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';
|
||||
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;
|
||||
@@ -28,8 +28,8 @@ export interface ShareBundleManifest {
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
const trimmed = name?.trim();
|
||||
if (!trimmed) return 'file';
|
||||
return trimmed.replace(/[\\/:*?"<>|]/g, '_');
|
||||
if (!trimmed) return "file";
|
||||
return trimmed.replace(/[\\/:*?"<>|]/g, "_");
|
||||
}
|
||||
|
||||
export async function buildHistoryBundle(originalFileIds: FileId[] | FileId): Promise<{
|
||||
@@ -43,7 +43,7 @@ export async function buildHistoryBundle(originalFileIds: FileId[] | FileId): Pr
|
||||
for (const rootId of uniqueRoots) {
|
||||
const stubs = await fileStorage.getHistoryChainStubs(rootId);
|
||||
if (stubs.length === 0) {
|
||||
throw new Error('No history chain found for file.');
|
||||
throw new Error("No history chain found for file.");
|
||||
}
|
||||
allStubs.push({ rootId, stubs });
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export async function buildHistoryBundle(originalFileIds: FileId[] | FileId): Pr
|
||||
}
|
||||
|
||||
const logicalId = stub.id;
|
||||
const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || 'file')}`;
|
||||
const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || "file")}`;
|
||||
const buffer = await file.arrayBuffer();
|
||||
zip.file(filePath, buffer);
|
||||
|
||||
@@ -87,32 +87,30 @@ export async function buildHistoryBundle(originalFileIds: FileId[] | FileId): Pr
|
||||
entries,
|
||||
};
|
||||
|
||||
zip.file('stirling-share.json', JSON.stringify(manifest, null, 2));
|
||||
zip.file("stirling-share.json", JSON.stringify(manifest, null, 2));
|
||||
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
type: "blob",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 },
|
||||
});
|
||||
|
||||
const firstStubName = allStubs[0]?.stubs[0]?.name || 'shared';
|
||||
const firstStubName = allStubs[0]?.stubs[0]?.name || "shared";
|
||||
const rootName = sanitizeFilename(firstStubName);
|
||||
const bundleFile = new File([zipBlob], `${rootName}-history.zip`, {
|
||||
type: 'application/zip',
|
||||
type: "application/zip",
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
return { bundleFile, manifest };
|
||||
}
|
||||
|
||||
export async function buildSharePackage(
|
||||
stubs: StirlingFileStub[]
|
||||
): Promise<{
|
||||
export async function buildSharePackage(stubs: StirlingFileStub[]): Promise<{
|
||||
bundleFile: File;
|
||||
manifest: ShareBundleManifest;
|
||||
}> {
|
||||
if (stubs.length === 0) {
|
||||
throw new Error('No files provided for sharing.');
|
||||
throw new Error("No files provided for sharing.");
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
@@ -125,7 +123,7 @@ export async function buildSharePackage(
|
||||
}
|
||||
|
||||
const logicalId = stub.id as string;
|
||||
const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || 'file')}`;
|
||||
const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || "file")}`;
|
||||
const buffer = await file.arrayBuffer();
|
||||
zip.file(filePath, buffer);
|
||||
|
||||
@@ -152,16 +150,16 @@ export async function buildSharePackage(
|
||||
entries,
|
||||
};
|
||||
|
||||
zip.file('stirling-share.json', JSON.stringify(manifest, null, 2));
|
||||
zip.file("stirling-share.json", JSON.stringify(manifest, null, 2));
|
||||
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
type: "blob",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 },
|
||||
});
|
||||
|
||||
const bundleFile = new File([zipBlob], `shared-files.zip`, {
|
||||
type: 'application/zip',
|
||||
type: "application/zip",
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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';
|
||||
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') {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : Date.now();
|
||||
}
|
||||
const parsed = new Date(String(value)).getTime();
|
||||
@@ -17,29 +17,32 @@ function resolveUpdatedAt(value: unknown): number {
|
||||
|
||||
export async function uploadHistoryChain(
|
||||
originalFileId: FileId,
|
||||
existingRemoteId?: number
|
||||
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.');
|
||||
throw new Error("No history chain found.");
|
||||
}
|
||||
|
||||
const finalStub =
|
||||
chain.slice().reverse().find((stub) => stub.isLeaf !== false) || chain[chain.length - 1];
|
||||
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.');
|
||||
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',
|
||||
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);
|
||||
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);
|
||||
@@ -47,10 +50,10 @@ export async function uploadHistoryChain(
|
||||
return { remoteId: existingRemoteId, updatedAt, chain };
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/api/v1/storage/files', formData);
|
||||
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.');
|
||||
throw new Error("Missing stored file ID in response.");
|
||||
}
|
||||
|
||||
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
|
||||
@@ -59,7 +62,7 @@ export async function uploadHistoryChain(
|
||||
|
||||
export async function uploadHistoryChains(
|
||||
originalFileIds: FileId[],
|
||||
existingRemoteId?: number
|
||||
existingRemoteId?: number,
|
||||
): Promise<{ remoteId: number; updatedAt: number; chain: StirlingFileStub[] }> {
|
||||
const uniqueRoots = Array.from(new Set(originalFileIds));
|
||||
const chainMap = new Map<FileId, StirlingFileStub[]>();
|
||||
@@ -70,11 +73,14 @@ export async function uploadHistoryChains(
|
||||
for (const rootId of uniqueRoots) {
|
||||
const chain = await fileStorage.getHistoryChainStubs(rootId);
|
||||
if (chain.length === 0) {
|
||||
throw new Error('No history chain found.');
|
||||
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];
|
||||
chain
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((stub) => stub.isLeaf !== false) || chain[chain.length - 1];
|
||||
if (finalStub) {
|
||||
leafStubs.push(finalStub);
|
||||
}
|
||||
@@ -90,7 +96,7 @@ export async function uploadHistoryChains(
|
||||
if (leafStubs.length === 1) {
|
||||
const finalFile = await fileStorage.getStirlingFile(leafStubs[0].id);
|
||||
if (!finalFile) {
|
||||
throw new Error('Missing final file data for sharing.');
|
||||
throw new Error("Missing final file data for sharing.");
|
||||
}
|
||||
shareFile = finalFile;
|
||||
} else {
|
||||
@@ -99,14 +105,14 @@ export async function uploadHistoryChains(
|
||||
}
|
||||
|
||||
const { bundleFile, manifest } = await buildHistoryBundle(uniqueRoots);
|
||||
const auditLog = new File([JSON.stringify(manifest, null, 2)], 'audit-log.json', {
|
||||
type: 'application/json',
|
||||
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);
|
||||
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);
|
||||
@@ -114,10 +120,10 @@ export async function uploadHistoryChains(
|
||||
return { remoteId: existingRemoteId, updatedAt, chain: combinedChain };
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/api/v1/storage/files', formData);
|
||||
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.');
|
||||
throw new Error("Missing stored file ID in response.");
|
||||
}
|
||||
|
||||
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import JSZip from 'jszip';
|
||||
import JSZip from "jszip";
|
||||
|
||||
import type { ShareBundleManifest } from '@app/services/serverStorageBundle';
|
||||
import type { ShareBundleManifest } from "@app/services/serverStorageBundle";
|
||||
|
||||
const MANIFEST_FILENAME = 'stirling-share.json';
|
||||
const MANIFEST_FILENAME = "stirling-share.json";
|
||||
|
||||
export function parseContentDispositionFilename(disposition?: string): string | null {
|
||||
if (!disposition) return null;
|
||||
@@ -20,27 +20,26 @@ export function parseContentDispositionFilename(disposition?: string): string |
|
||||
}
|
||||
|
||||
export function isZipBundle(contentType: string, filename: string): boolean {
|
||||
return contentType.includes('zip') || filename.toLowerCase().endsWith('.zip');
|
||||
return contentType.includes("zip") || filename.toLowerCase().endsWith(".zip");
|
||||
}
|
||||
|
||||
export function getShareBundleEntryRootId(
|
||||
manifest: ShareBundleManifest,
|
||||
entry: ShareBundleManifest['entries'][number]
|
||||
entry: ShareBundleManifest["entries"][number],
|
||||
): string {
|
||||
return entry.rootLogicalId || manifest.rootLogicalId;
|
||||
}
|
||||
|
||||
export function resolveShareBundleOrder(manifest: ShareBundleManifest): {
|
||||
rootOrder: string[];
|
||||
sortedEntries: ShareBundleManifest['entries'];
|
||||
sortedEntries: ShareBundleManifest["entries"];
|
||||
} {
|
||||
const entryRootId = (entry: ShareBundleManifest['entries'][number]) =>
|
||||
getShareBundleEntryRootId(manifest, entry);
|
||||
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'] = [];
|
||||
const sortedEntries: ShareBundleManifest["entries"] = [];
|
||||
for (const rootId of rootOrder) {
|
||||
const rootEntries = manifest.entries
|
||||
.filter((entry) => entryRootId(entry) === rootId)
|
||||
@@ -50,12 +49,10 @@ export function resolveShareBundleOrder(manifest: ShareBundleManifest): {
|
||||
return { rootOrder, sortedEntries };
|
||||
}
|
||||
|
||||
export async function loadShareBundleEntries(
|
||||
blob: Blob
|
||||
): Promise<{
|
||||
export async function loadShareBundleEntries(blob: Blob): Promise<{
|
||||
manifest: ShareBundleManifest;
|
||||
rootOrder: string[];
|
||||
sortedEntries: ShareBundleManifest['entries'];
|
||||
sortedEntries: ShareBundleManifest["entries"];
|
||||
files: File[];
|
||||
} | null> {
|
||||
const zip = await JSZip.loadAsync(blob);
|
||||
@@ -64,7 +61,7 @@ export async function loadShareBundleEntries(
|
||||
return null;
|
||||
}
|
||||
|
||||
const manifestText = await manifestEntry.async('text');
|
||||
const manifestText = await manifestEntry.async("text");
|
||||
const manifest = JSON.parse(manifestText) as ShareBundleManifest;
|
||||
const { rootOrder, sortedEntries } = resolveShareBundleOrder(manifest);
|
||||
|
||||
@@ -74,23 +71,19 @@ export async function loadShareBundleEntries(
|
||||
if (!zipEntry) {
|
||||
throw new Error(`Missing file entry ${entry.filePath}`);
|
||||
}
|
||||
const fileBlob = await zipEntry.async('blob');
|
||||
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[]> {
|
||||
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 })];
|
||||
}
|
||||
@@ -107,9 +100,7 @@ export async function extractLatestFilesFromBundle(
|
||||
latestByRoot.set(getShareBundleEntryRootId(manifest, entry), files[i]);
|
||||
}
|
||||
|
||||
const latestFiles = rootOrder
|
||||
.map((rootId) => latestByRoot.get(rootId))
|
||||
.filter((file): file is File => Boolean(file));
|
||||
const latestFiles = rootOrder.map((rootId) => latestByRoot.get(rootId)).filter((file): file is File => Boolean(file));
|
||||
|
||||
if (latestFiles.length > 0) {
|
||||
return latestFiles;
|
||||
|
||||
@@ -31,52 +31,50 @@ const detectSignaturesInFile = async (file: File): Promise<SignatureDetectionRes
|
||||
if (!window.pdfjsLib) {
|
||||
return {
|
||||
hasSignatures: false,
|
||||
error: 'PDF.js not available'
|
||||
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'
|
||||
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')) {
|
||||
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
|
||||
signatureCount: totalSignatures,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.warn('PDF signature detection failed:', error);
|
||||
console.warn("PDF signature detection failed:", error);
|
||||
return {
|
||||
hasSignatures: false,
|
||||
signatureCount: 0,
|
||||
error: error instanceof Error ? error.message : 'Detection failed'
|
||||
error: error instanceof Error ? error.message : "Detection failed",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -86,12 +84,12 @@ const detectSignaturesInFile = async (file: File): Promise<SignatureDetectionRes
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -101,13 +99,13 @@ export const detectSignaturesInFiles = async (files: File[]): Promise<FileSignat
|
||||
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);
|
||||
@@ -116,15 +114,15 @@ export const useSignatureDetection = () => {
|
||||
setIsDetecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getFileSignatureStatus = (file: File): SignatureDetectionResult | null => {
|
||||
const result = detectionResults.find(r => r.file === file);
|
||||
const result = detectionResults.find((r) => r.file === file);
|
||||
return result ? result.result : null;
|
||||
};
|
||||
|
||||
const hasAnySignatures = detectionResults.some(r => r.result.hasSignatures);
|
||||
|
||||
const hasAnySignatures = detectionResults.some((r) => r.result.hasSignatures);
|
||||
const totalSignatures = detectionResults.reduce((sum, r) => sum + (r.result.signatureCount || 0), 0);
|
||||
|
||||
|
||||
return {
|
||||
detectionResults,
|
||||
isDetecting,
|
||||
@@ -132,9 +130,9 @@ export const useSignatureDetection = () => {
|
||||
getFileSignatureStatus,
|
||||
hasAnySignatures,
|
||||
totalSignatures,
|
||||
reset: () => setDetectionResults([])
|
||||
reset: () => setDetectionResults([]),
|
||||
};
|
||||
};
|
||||
|
||||
// Import React for the hook
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { SavedSignature } from '@app/types/signature';
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import type { SavedSignature } from "@app/types/signature";
|
||||
|
||||
export type StorageType = 'backend' | 'localStorage';
|
||||
export type StorageType = "backend" | "localStorage";
|
||||
|
||||
interface SignatureStorageCapabilities {
|
||||
supportsBackend: boolean;
|
||||
@@ -40,33 +40,33 @@ class SignatureStorageService {
|
||||
private async _performDetection(): Promise<SignatureStorageCapabilities> {
|
||||
try {
|
||||
// Probe the proprietary signatures endpoint (requires authentication)
|
||||
await apiClient.get('/api/v1/proprietary/signatures', {
|
||||
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)');
|
||||
console.log("[SignatureStorage] Backend signature API detected and accessible (authenticated)");
|
||||
return {
|
||||
supportsBackend: true,
|
||||
storageType: 'backend',
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
console.log("[SignatureStorage] Backend signature API not available, using localStorage");
|
||||
}
|
||||
|
||||
return {
|
||||
supportsBackend: false,
|
||||
storageType: 'localStorage',
|
||||
storageType: "localStorage",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -98,11 +98,11 @@ class SignatureStorageService {
|
||||
async saveSignature(signature: SavedSignature): Promise<void> {
|
||||
const capabilities = await this.detectCapabilities();
|
||||
|
||||
if (capabilities.supportsBackend && signature.scope !== 'localStorage') {
|
||||
if (capabilities.supportsBackend && signature.scope !== "localStorage") {
|
||||
await this._saveToBackend(signature);
|
||||
} else {
|
||||
// Force scope to localStorage for browser storage
|
||||
signature.scope = 'localStorage';
|
||||
signature.scope = "localStorage";
|
||||
this._saveToLocalStorage(signature);
|
||||
}
|
||||
}
|
||||
@@ -136,21 +136,21 @@ class SignatureStorageService {
|
||||
// Backend methods
|
||||
private async _loadFromBackend(): Promise<SavedSignature[]> {
|
||||
try {
|
||||
const response = await apiClient.get<SavedSignature[]>('/api/v1/proprietary/signatures');
|
||||
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/')) {
|
||||
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',
|
||||
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',
|
||||
type: imageResponse.headers["content-type"] || "image/png",
|
||||
});
|
||||
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
@@ -172,13 +172,13 @@ class SignatureStorageService {
|
||||
|
||||
return await Promise.all(signaturePromises);
|
||||
} catch (error) {
|
||||
console.error('[SignatureStorage] Failed to load from backend:', 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);
|
||||
await apiClient.post("/api/v1/proprietary/signatures", signature);
|
||||
}
|
||||
|
||||
private async _deleteFromBackend(id: string): Promise<void> {
|
||||
@@ -190,7 +190,7 @@ class SignatureStorageService {
|
||||
}
|
||||
|
||||
// LocalStorage methods
|
||||
private readonly STORAGE_KEY = 'stirling:saved-signatures:v1';
|
||||
private readonly STORAGE_KEY = "stirling:saved-signatures:v1";
|
||||
|
||||
private _loadFromLocalStorage(): SavedSignature[] {
|
||||
try {
|
||||
@@ -200,7 +200,7 @@ class SignatureStorageService {
|
||||
// Ensure all localStorage signatures have the correct scope
|
||||
return signatures.map((sig: SavedSignature) => ({
|
||||
...sig,
|
||||
scope: 'localStorage' as const,
|
||||
scope: "localStorage" as const,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
@@ -209,7 +209,7 @@ class SignatureStorageService {
|
||||
|
||||
private _saveToLocalStorage(signature: SavedSignature): void {
|
||||
const signatures = this._loadFromLocalStorage();
|
||||
const index = signatures.findIndex(s => s.id === signature.id);
|
||||
const index = signatures.findIndex((s) => s.id === signature.id);
|
||||
|
||||
if (index >= 0) {
|
||||
signatures[index] = signature;
|
||||
@@ -222,13 +222,13 @@ class SignatureStorageService {
|
||||
|
||||
private _deleteFromLocalStorage(id: string): void {
|
||||
const signatures = this._loadFromLocalStorage();
|
||||
const filtered = signatures.filter(s => s.id !== id);
|
||||
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);
|
||||
const signature = signatures.find((s) => s.id === id);
|
||||
if (signature) {
|
||||
signature.label = label;
|
||||
signature.updatedAt = Date.now();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { alert } from '@app/components/toast';
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
interface ErrorToastMapping {
|
||||
regex: RegExp;
|
||||
@@ -10,21 +10,21 @@ interface ErrorToastMapping {
|
||||
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.'
|
||||
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.'
|
||||
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';
|
||||
if (!status) return "Network error";
|
||||
if (status >= 500) return "Server error";
|
||||
if (status >= 400) return "Request error";
|
||||
return "Request failed";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ function titleForStatus(status?: number): string {
|
||||
* Returns true if a special toast was shown, false otherwise.
|
||||
*/
|
||||
export function showSpecialErrorToast(rawError: string | undefined, options?: { status?: number }): boolean {
|
||||
const message = (rawError || '').toString();
|
||||
const message = (rawError || "").toString();
|
||||
if (!message) return false;
|
||||
|
||||
for (const mapping of MAPPINGS) {
|
||||
@@ -40,18 +40,18 @@ export function showSpecialErrorToast(rawError: string | undefined, options?: {
|
||||
// Best-effort translation without hard dependency on i18n config
|
||||
let body = mapping.defaultMessage;
|
||||
try {
|
||||
const anyGlobal: any = (globalThis as any);
|
||||
const anyGlobal: any = globalThis as any;
|
||||
const i18next = anyGlobal?.i18next;
|
||||
if (i18next && typeof i18next.t === 'function') {
|
||||
if (i18next && typeof i18next.t === "function") {
|
||||
body = i18next.t(mapping.i18nKey, { defaultValue: mapping.defaultMessage });
|
||||
}
|
||||
} catch { /* ignore translation errors */ }
|
||||
} catch {
|
||||
/* ignore translation errors */
|
||||
}
|
||||
const title = titleForStatus(options?.status);
|
||||
alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false });
|
||||
alert({ alertType: "error", title, body, expandable: true, isPersistentPopup: false });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
@@ -7,14 +7,12 @@ const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
|
||||
|
||||
// Create client only if configured, otherwise export null
|
||||
export const supabase: SupabaseClient | null = isSupabaseConfigured
|
||||
? createClient(supabaseUrl, supabaseAnonKey)
|
||||
: null;
|
||||
export const supabase: SupabaseClient | null = isSupabaseConfigured ? createClient(supabaseUrl, supabaseAnonKey) : null;
|
||||
|
||||
// Log warning if not configured (for self-hosted installations)
|
||||
if (!isSupabaseConfigured) {
|
||||
console.warn(
|
||||
'Supabase is not configured. Checkout and billing features will be disabled. ' +
|
||||
'Static plan information will be displayed instead.'
|
||||
"Supabase is not configured. Checkout and billing features will be disabled. " +
|
||||
"Static plan information will be displayed instead.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* High-performance thumbnail generation service using main thread processing
|
||||
*/
|
||||
|
||||
import { FileId } from '@app/types/file';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import { FileId } from "@app/types/file";
|
||||
import { pdfWorkerManager } from "@app/services/pdfWorkerManager";
|
||||
import { PDFDocumentProxy } from "pdfjs-dist";
|
||||
|
||||
interface ThumbnailResult {
|
||||
pageNumber: number;
|
||||
@@ -66,13 +66,13 @@ export class ThumbnailGenerationService {
|
||||
const pdf = await pdfWorkerManager.createDocument(pdfArrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
stopAtErrors: false
|
||||
stopAtErrors: false,
|
||||
});
|
||||
|
||||
this.pdfDocumentCache.set(fileId, {
|
||||
pdf,
|
||||
lastUsed: Date.now(),
|
||||
refCount: 1
|
||||
refCount: 1,
|
||||
});
|
||||
|
||||
return pdf;
|
||||
@@ -117,25 +117,22 @@ export class ThumbnailGenerationService {
|
||||
pdfArrayBuffer: ArrayBuffer,
|
||||
pageNumbers: number[],
|
||||
options: ThumbnailGenerationOptions = {},
|
||||
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
||||
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 (!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');
|
||||
throw new Error("generateThumbnails: pdfArrayBuffer must not be empty");
|
||||
}
|
||||
|
||||
if (!pageNumbers || pageNumbers.length === 0) {
|
||||
throw new Error('generateThumbnails: pageNumbers must not be empty');
|
||||
throw new Error("generateThumbnails: pageNumbers must not be empty");
|
||||
}
|
||||
|
||||
const {
|
||||
scale = 0.2,
|
||||
quality = 0.8
|
||||
} = options;
|
||||
const { scale = 0.2, quality = 0.8 } = options;
|
||||
|
||||
return await this.generateThumbnailsMainThread(fileId, pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
|
||||
}
|
||||
@@ -149,7 +146,7 @@ export class ThumbnailGenerationService {
|
||||
pageNumbers: number[],
|
||||
scale: number,
|
||||
quality: number,
|
||||
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
||||
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void,
|
||||
): Promise<ThumbnailResult[]> {
|
||||
const pdf = await this.getCachedPDFDocument(fileId, pdfArrayBuffer);
|
||||
|
||||
@@ -167,27 +164,26 @@ export class ThumbnailGenerationService {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale, rotation: 0 });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
throw new Error("Could not get canvas context");
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL('image/jpeg', quality);
|
||||
const thumbnail = canvas.toDataURL("image/jpeg", quality);
|
||||
|
||||
allResults.push({ pageNumber, thumbnail, success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for page ${pageNumber}:`, error);
|
||||
allResults.push({
|
||||
pageNumber,
|
||||
thumbnail: '',
|
||||
thumbnail: "",
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -199,12 +195,12 @@ export class ThumbnailGenerationService {
|
||||
onProgress({
|
||||
completed,
|
||||
total: pageNumbers.length,
|
||||
thumbnails: allResults.slice(-batch.length).filter(r => r.success)
|
||||
thumbnails: allResults.slice(-batch.length).filter((r) => r.success),
|
||||
});
|
||||
}
|
||||
|
||||
// Yield control to prevent UI blocking
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
|
||||
// Release reference to PDF document (don't destroy - keep in cache)
|
||||
@@ -238,7 +234,7 @@ export class ThumbnailGenerationService {
|
||||
this.thumbnailCache.set(pageId, {
|
||||
thumbnail,
|
||||
lastUsed: Date.now(),
|
||||
sizeBytes
|
||||
sizeBytes,
|
||||
});
|
||||
|
||||
this.currentCacheSize += sizeBytes;
|
||||
@@ -265,7 +261,7 @@ export class ThumbnailGenerationService {
|
||||
return {
|
||||
size: this.thumbnailCache.size,
|
||||
sizeBytes: this.currentCacheSize,
|
||||
maxSizeBytes: this.maxCacheSizeBytes
|
||||
maxSizeBytes: this.maxCacheSizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { DOWNLOAD_BASE_URL } from '@app/constants/downloads';
|
||||
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';
|
||||
max_priority: "urgent" | "normal" | "minor" | "low";
|
||||
recommended_action?: string;
|
||||
any_breaking: boolean;
|
||||
migration_guides?: Array<{
|
||||
@@ -15,7 +15,7 @@ export interface UpdateSummary {
|
||||
|
||||
export interface VersionUpdate {
|
||||
version: string;
|
||||
priority: 'urgent' | 'normal' | 'minor' | 'low';
|
||||
priority: "urgent" | "normal" | "minor" | "low";
|
||||
announcement: {
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -40,15 +40,15 @@ export interface MachineInfo {
|
||||
}
|
||||
|
||||
export class UpdateService {
|
||||
private readonly baseUrl = 'https://supabase.stirling.com/functions/v1/updates';
|
||||
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('.');
|
||||
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;
|
||||
@@ -69,26 +69,26 @@ export class UpdateService {
|
||||
*/
|
||||
getDownloadUrl(machineInfo: MachineInfo): string | null {
|
||||
// Only show download for non-Docker installations
|
||||
if (machineInfo.machineType === 'Docker' || machineInfo.machineType === 'Kubernetes') {
|
||||
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');
|
||||
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 (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';
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,29 +100,29 @@ export class UpdateService {
|
||||
*/
|
||||
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';
|
||||
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);
|
||||
console.log("Fetching update summary from:", url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
console.log('Response status:', response.status);
|
||||
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);
|
||||
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);
|
||||
console.error("Failed to fetch update summary from Supabase:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -132,29 +132,29 @@ export class UpdateService {
|
||||
*/
|
||||
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';
|
||||
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);
|
||||
console.log("Fetching full update info from:", url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
console.log('Full update response status:', response.status);
|
||||
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);
|
||||
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);
|
||||
console.error("Failed to fetch full update info from Supabase:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export class UpdateService {
|
||||
* 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';
|
||||
const url = "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle";
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
@@ -175,10 +175,10 @@ export class UpdateService {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
throw new Error('Version number not found');
|
||||
throw new Error("Version number not found");
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch latest version from build.gradle:', error);
|
||||
return '';
|
||||
console.error("Failed to fetch latest version from build.gradle:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
export interface EndpointStatistic {
|
||||
endpoint: string;
|
||||
@@ -21,34 +21,27 @@ const usageAnalyticsService = {
|
||||
/**
|
||||
* Get endpoint statistics
|
||||
*/
|
||||
async getEndpointStatistics(
|
||||
limit?: number,
|
||||
dataType: 'all' | 'api' | 'ui' = 'all'
|
||||
): Promise<EndpointStatisticsResponse> {
|
||||
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') {
|
||||
if (dataType !== "all") {
|
||||
params.dataType = dataType;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<EndpointStatisticsResponse>(
|
||||
'/api/v1/proprietary/ui-data/usage-endpoint-statistics',
|
||||
{ params }
|
||||
);
|
||||
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> {
|
||||
async getChartData(limit?: number, dataType: "all" | "api" | "ui" = "all"): Promise<UsageChartData> {
|
||||
const stats = await this.getEndpointStatistics(limit, dataType);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import JSZip, { JSZipObject } from 'jszip';
|
||||
import { StirlingFileStub, createStirlingFile } from '@app/types/fileContext';
|
||||
import { generateThumbnailForFile } from '@app/utils/thumbnailUtils';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
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;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
crc32: number;
|
||||
compression: object;
|
||||
compressedContent: string | ArrayBuffer | Uint8Array | Buffer;
|
||||
}
|
||||
|
||||
const getData = (zipEntry: JSZipObject): CompressedObject | undefined => {
|
||||
@@ -49,12 +49,12 @@ export class ZipFileService {
|
||||
|
||||
// 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
|
||||
"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'];
|
||||
private static readonly VALID_ZIP_EXTENSIONS = [".zip"];
|
||||
|
||||
/**
|
||||
* Validate a ZIP file without extracting it
|
||||
@@ -66,19 +66,21 @@ export class ZipFileService {
|
||||
totalSizeBytes: 0,
|
||||
containsPDFs: false,
|
||||
containsFiles: false,
|
||||
errors: []
|
||||
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)})`);
|
||||
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');
|
||||
result.errors.push("File is not a valid ZIP archive");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -107,13 +109,17 @@ export class ZipFileService {
|
||||
|
||||
// Check individual file size
|
||||
if (uncompressedSize > this.maxFileSize) {
|
||||
result.errors.push(`File "${filename}" too large: ${this.formatFileSize(uncompressedSize)} (max: ${this.formatFileSize(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.errors.push(
|
||||
`Total uncompressed size too large: ${this.formatFileSize(totalSize)} (max: ${this.formatFileSize(this.maxTotalSize)})`,
|
||||
);
|
||||
}
|
||||
|
||||
result.fileCount = fileCount;
|
||||
@@ -125,12 +131,12 @@ export class ZipFileService {
|
||||
result.isValid = result.errors.length === 0 && result.containsFiles;
|
||||
|
||||
if (!result.containsFiles) {
|
||||
result.errors.push('ZIP file does not contain any files');
|
||||
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'}`);
|
||||
result.errors.push(`Failed to validate ZIP file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -150,38 +156,34 @@ export class ZipFileService {
|
||||
|
||||
// Generate ZIP blob
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
type: "blob",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 },
|
||||
});
|
||||
|
||||
const zipFile = new File([zipBlob], zipFilename, {
|
||||
type: 'application/zip',
|
||||
lastModified: Date.now()
|
||||
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 }
|
||||
);
|
||||
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> {
|
||||
async extractPdfFiles(file: File, onProgress?: (progress: ZipExtractionProgress) => void): Promise<ZipExtractionResult> {
|
||||
const result: ZipExtractionResult = {
|
||||
success: false,
|
||||
extractedFiles: [],
|
||||
errors: [],
|
||||
totalFiles: 0,
|
||||
extractedCount: 0
|
||||
extractedCount: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -197,8 +199,8 @@ export class ZipFileService {
|
||||
const zipContents = await zip.loadAsync(file);
|
||||
|
||||
// Get all PDF files
|
||||
const pdfFiles = Object.entries(zipContents.files).filter(([filename, zipEntry]) =>
|
||||
!zipEntry.dir && this.isPdfFile(filename)
|
||||
const pdfFiles = Object.entries(zipContents.files).filter(
|
||||
([filename, zipEntry]) => !zipEntry.dir && this.isPdfFile(filename),
|
||||
);
|
||||
|
||||
result.totalFiles = pdfFiles.length;
|
||||
@@ -214,17 +216,17 @@ export class ZipFileService {
|
||||
currentFile: filename,
|
||||
extractedCount: i,
|
||||
totalFiles: pdfFiles.length,
|
||||
progress: (i / pdfFiles.length) * 100
|
||||
progress: (i / pdfFiles.length) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract file content
|
||||
const content = await zipEntry.async('uint8array');
|
||||
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()
|
||||
type: "application/pdf",
|
||||
lastModified: zipEntry.date?.getTime() || Date.now(),
|
||||
});
|
||||
|
||||
// Validate extracted PDF
|
||||
@@ -235,24 +237,24 @@ export class ZipFileService {
|
||||
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'}`);
|
||||
result.errors.push(`Failed to extract "${filename}": ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress report
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentFile: '',
|
||||
currentFile: "",
|
||||
extractedCount: result.extractedCount,
|
||||
totalFiles: result.totalFiles,
|
||||
progress: 100
|
||||
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'}`);
|
||||
result.errors.push(`Failed to extract ZIP file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -262,9 +264,7 @@ export class ZipFileService {
|
||||
*/
|
||||
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)
|
||||
);
|
||||
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some((ext) => file.name.toLowerCase().endsWith(ext));
|
||||
|
||||
return hasValidType || hasValidExtension;
|
||||
}
|
||||
@@ -274,9 +274,7 @@ export class ZipFileService {
|
||||
*/
|
||||
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)
|
||||
);
|
||||
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some((ext) => stub.name.toLowerCase().endsWith(ext));
|
||||
|
||||
return hasValidType || hasValidExtension;
|
||||
}
|
||||
@@ -285,7 +283,7 @@ export class ZipFileService {
|
||||
* Check if a filename indicates a PDF file
|
||||
*/
|
||||
private isPdfFile(filename: string): boolean {
|
||||
return filename.toLowerCase().endsWith('.pdf');
|
||||
return filename.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,7 +291,7 @@ export class ZipFileService {
|
||||
*/
|
||||
private isHtmlFile(filename: string): boolean {
|
||||
const lowerName = filename.toLowerCase();
|
||||
return lowerName.endsWith('.html') || lowerName.endsWith('.htm') || lowerName.endsWith('.xhtml');
|
||||
return lowerName.endsWith(".html") || lowerName.endsWith(".htm") || lowerName.endsWith(".xhtml");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,7 +312,7 @@ export class ZipFileService {
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error checking for HTML files:', error);
|
||||
console.error("Error checking for HTML files:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -329,11 +327,13 @@ export class ZipFileService {
|
||||
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; // -
|
||||
return (
|
||||
bytes[0] === 0x25 && // %
|
||||
bytes[1] === 0x50 && // P
|
||||
bytes[2] === 0x44 && // D
|
||||
bytes[3] === 0x46 && // F
|
||||
bytes[4] === 0x2d
|
||||
); // -
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -344,25 +344,25 @@ export class ZipFileService {
|
||||
*/
|
||||
private sanitizeFilename(filename: string): string {
|
||||
// Remove directory path and get just the filename
|
||||
const basename = filename.split('/').pop() || 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
|
||||
.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';
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
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];
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -379,14 +379,14 @@ export class ZipFileService {
|
||||
zipBlob: Blob | File,
|
||||
autoUnzip: boolean,
|
||||
autoUnzipFileLimit: number,
|
||||
skipAutoUnzip: boolean = false
|
||||
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;
|
||||
const fileCount = Object.values(zipContents.files).filter((entry) => !entry.dir).length;
|
||||
return { shouldExtract: true, fileCount };
|
||||
}
|
||||
|
||||
@@ -400,15 +400,15 @@ export class ZipFileService {
|
||||
const zipContents = await zip.loadAsync(zipBlob);
|
||||
|
||||
// Count non-directory entries
|
||||
const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length;
|
||||
const fileCount = Object.values(zipContents.files).filter((entry) => !entry.dir).length;
|
||||
|
||||
// Only extract if within limit
|
||||
return {
|
||||
shouldExtract: fileCount <= autoUnzipFileLimit,
|
||||
fileCount
|
||||
fileCount,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking shouldUnzip:', error);
|
||||
console.error("Error checking shouldUnzip:", error);
|
||||
// On error, default to not extracting (safer)
|
||||
return { shouldExtract: false, fileCount: 0 };
|
||||
}
|
||||
@@ -431,13 +431,11 @@ export class ZipFileService {
|
||||
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' });
|
||||
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);
|
||||
@@ -450,7 +448,7 @@ export class ZipFileService {
|
||||
zipBlob,
|
||||
options.autoUnzip,
|
||||
options.autoUnzipFileLimit,
|
||||
options.skipAutoUnzip || false
|
||||
options.skipAutoUnzip || false,
|
||||
);
|
||||
|
||||
if (!shouldExtract) {
|
||||
@@ -469,11 +467,9 @@ export class ZipFileService {
|
||||
const extractionResult = await this.extractAllFiles(zipFile);
|
||||
return extractionResult.success ? extractionResult.extractedFiles : [zipFile];
|
||||
} catch (error) {
|
||||
console.error('Error in extractWithPreferences:', 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' });
|
||||
const zipFile = zipBlob instanceof File ? zipBlob : new File([zipBlob], "result.zip", { type: "application/zip" });
|
||||
return [zipFile];
|
||||
}
|
||||
}
|
||||
@@ -483,14 +479,14 @@ export class ZipFileService {
|
||||
*/
|
||||
async extractAllFiles(
|
||||
file: File | Blob,
|
||||
onProgress?: (progress: ZipExtractionProgress) => void
|
||||
onProgress?: (progress: ZipExtractionProgress) => void,
|
||||
): Promise<ZipExtractionResult> {
|
||||
const result: ZipExtractionResult = {
|
||||
success: false,
|
||||
extractedFiles: [],
|
||||
errors: [],
|
||||
totalFiles: 0,
|
||||
extractedCount: 0
|
||||
extractedCount: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -499,9 +495,7 @@ export class ZipFileService {
|
||||
const zipContents = await zip.loadAsync(file);
|
||||
|
||||
// Get all files (not directories)
|
||||
const allFiles = Object.entries(zipContents.files).filter(([, zipEntry]) =>
|
||||
!zipEntry.dir
|
||||
);
|
||||
const allFiles = Object.entries(zipContents.files).filter(([, zipEntry]) => !zipEntry.dir);
|
||||
|
||||
result.totalFiles = allFiles.length;
|
||||
|
||||
@@ -516,12 +510,12 @@ export class ZipFileService {
|
||||
currentFile: filename,
|
||||
extractedCount: i,
|
||||
totalFiles: allFiles.length,
|
||||
progress: (i / allFiles.length) * 100
|
||||
progress: (i / allFiles.length) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract file content
|
||||
const content = await zipEntry.async('blob');
|
||||
const content = await zipEntry.async("blob");
|
||||
|
||||
// Create File object with appropriate MIME type
|
||||
const mimeType = this.getMimeTypeFromExtension(filename);
|
||||
@@ -529,9 +523,8 @@ export class ZipFileService {
|
||||
|
||||
result.extractedFiles.push(extractedFile);
|
||||
result.extractedCount++;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
result.errors.push(`Failed to extract "${filename}": ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
@@ -539,17 +532,16 @@ export class ZipFileService {
|
||||
// Final progress report
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentFile: '',
|
||||
currentFile: "",
|
||||
extractedCount: result.extractedCount,
|
||||
totalFiles: result.totalFiles,
|
||||
progress: 100
|
||||
progress: 100,
|
||||
});
|
||||
}
|
||||
|
||||
result.success = result.extractedFiles.length > 0;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
result.errors.push(`Failed to process ZIP file: ${errorMessage}`);
|
||||
}
|
||||
|
||||
@@ -560,41 +552,41 @@ export class ZipFileService {
|
||||
* Get MIME type based on file extension
|
||||
*/
|
||||
private getMimeTypeFromExtension(fileName: string): string {
|
||||
const ext = fileName.toLowerCase().split('.').pop();
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
zip: "application/zip",
|
||||
rar: "application/x-rar-compressed",
|
||||
};
|
||||
|
||||
return mimeTypes[ext || ''] || 'application/octet-stream';
|
||||
return mimeTypes[ext || ""] || "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -609,19 +601,19 @@ export class ZipFileService {
|
||||
*/
|
||||
async extractAndStoreFilesWithHistory(
|
||||
zipFile: File,
|
||||
zipStub: StirlingFileStub
|
||||
zipStub: StirlingFileStub,
|
||||
): Promise<{ success: boolean; extractedStubs: StirlingFileStub[]; errors: string[] }> {
|
||||
const result = {
|
||||
success: false,
|
||||
extractedStubs: [] as StirlingFileStub[],
|
||||
errors: [] as string[]
|
||||
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.');
|
||||
result.errors.push("ZIP contains HTML files and will not be auto-extracted. Download the ZIP to access the files.");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -657,7 +649,7 @@ export class ZipFileService {
|
||||
parentFileId: zipStub.parentFileId,
|
||||
versionNumber: zipStub.versionNumber,
|
||||
toolHistory: zipStub.toolHistory || [],
|
||||
thumbnailUrl: thumbnail
|
||||
thumbnailUrl: thumbnail,
|
||||
};
|
||||
|
||||
// Store in IndexedDB
|
||||
@@ -665,7 +657,7 @@ export class ZipFileService {
|
||||
|
||||
result.extractedStubs.push(stub);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
result.errors.push(`Failed to process "${extractedFile.name}": ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
@@ -673,7 +665,7 @@ export class ZipFileService {
|
||||
result.success = result.extractedStubs.length > 0;
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
result.errors.push(`Failed to extract ZIP file: ${errorMessage}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user