Fix first batch of any types

This commit is contained in:
James Brunton 2025-11-06 11:49:42 +00:00
parent 00fb40fb74
commit 06c6b96992
20 changed files with 472 additions and 194 deletions

View File

@ -131,7 +131,7 @@ export default function AdminGeneralSection() {
}
return {
sectionData: {},
sectionData: settings,
deltaSettings
};
}

View File

@ -76,7 +76,7 @@ export default function AutomationSelection({
key={automation.id}
title={automation.name}
description={automation.description}
badgeIcon={automation.icon}
badgeIcon={automation.iconComponent}
operations={automation.operations.map(op => op.operation)}
onClick={() => onRun(automation)}
showMenu={true}

View File

@ -46,7 +46,7 @@ export function useSavedAutomations() {
const { automationStorage } = await import('@app/services/automationStorage');
// Map suggested automation icons to MUI icon keys
const getIconKey = (_suggestedIcon: {id: string}): string => {
const getIconKey = (): string => {
// Check the automation ID or name to determine the appropriate icon
switch (suggestedAutomation.id) {
case 'secure-pdf-ingestion':
@ -65,7 +65,7 @@ export function useSavedAutomations() {
const savedAutomation = {
name: suggestedAutomation.name,
description: suggestedAutomation.description,
icon: getIconKey(suggestedAutomation.icon),
icon: suggestedAutomation.icon ?? getIconKey(),
operations: suggestedAutomation.operations
};

View File

@ -13,7 +13,7 @@ const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.
export function useSuggestedAutomations(): SuggestedAutomation[] {
const { t } = useTranslation();
const suggestedAutomations = useMemo<SuggestedAutomation[]>(() => {
const suggestedAutomations = useMemo<SuggestedAutomation[]>(() => {
const now = new Date().toISOString();
return [
{
@ -65,7 +65,8 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
],
createdAt: now,
updatedAt: now,
icon: SecurityIcon,
icon: 'SecurityIcon',
iconComponent: SecurityIcon,
},
{
id: "email-preparation",
@ -112,7 +113,8 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
],
createdAt: now,
updatedAt: now,
icon: CompressIcon,
icon: 'CompressIcon',
iconComponent: CompressIcon,
},
{
id: "secure-workflow",
@ -151,7 +153,8 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
],
createdAt: now,
updatedAt: now,
icon: SecurityIcon,
icon: 'SecurityIcon',
iconComponent: SecurityIcon,
},
{
id: "process-images",
@ -185,7 +188,8 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
],
createdAt: now,
updatedAt: now,
icon: StarIcon,
icon: 'StarIcon',
iconComponent: StarIcon,
},
];
}, [t]);

View File

@ -1,8 +1,12 @@
import { useState } from 'react';
import apiClient from '@app/services/apiClient';
import { mergePendingSettings, isFieldPending, hasPendingChanges } from '@app/utils/settingsPendingHelper';
import { mergePendingSettings, isFieldPending, hasPendingChanges, SettingsWithPending } from '@app/utils/settingsPendingHelper';
interface UseAdminSettingsOptions<T> {
type SettingsRecord = Record<string, unknown>;
type CombinedSettings<T extends object> = T & SettingsRecord;
type PendingSettings<T extends object> = SettingsWithPending<CombinedSettings<T>> & CombinedSettings<T>;
interface UseAdminSettingsOptions<T extends object> {
sectionName: string;
/**
* Optional transformer to combine data from multiple endpoints.
@ -14,14 +18,14 @@ interface UseAdminSettingsOptions<T> {
* Returns an object with sectionData and optionally deltaSettings.
*/
saveTransformer?: (settings: T) => {
sectionData: any;
deltaSettings?: Record<string, any>;
sectionData: T;
deltaSettings?: SettingsRecord;
};
}
interface UseAdminSettingsReturn<T> {
interface UseAdminSettingsReturn<T extends object> {
settings: T;
rawSettings: any;
rawSettings: PendingSettings<T> | null;
loading: boolean;
saving: boolean;
setSettings: (settings: T) => void;
@ -39,14 +43,14 @@ interface UseAdminSettingsReturn<T> {
* const { settings, setSettings, saveSettings, isFieldPending } = useAdminSettings({
* sectionName: 'legal'
* });
*/
export function useAdminSettings<T = any>(
*/
export function useAdminSettings<T extends object>(
options: UseAdminSettingsOptions<T>
): UseAdminSettingsReturn<T> {
const { sectionName, fetchTransformer, saveTransformer } = options;
const [settings, setSettings] = useState<T>({} as T);
const [rawSettings, setRawSettings] = useState<any>(null);
const [rawSettings, setRawSettings] = useState<PendingSettings<T> | null>(null);
const [originalSettings, setOriginalSettings] = useState<T>({} as T); // Track original active values
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@ -55,15 +59,15 @@ export function useAdminSettings<T = any>(
try {
setLoading(true);
let rawData: any;
let rawData: PendingSettings<T>;
if (fetchTransformer) {
// Use custom fetch logic for complex sections
rawData = await fetchTransformer();
rawData = (await fetchTransformer()) as PendingSettings<T>;
} else {
// Simple single-endpoint fetch
const response = await apiClient.get(`/api/v1/admin/settings/section/${sectionName}`);
rawData = response.data || {};
rawData = (response.data || {}) as PendingSettings<T>;
}
console.log(`[useAdminSettings:${sectionName}] Raw response:`, JSON.stringify(rawData, null, 2));
@ -77,7 +81,7 @@ export function useAdminSettings<T = any>(
console.log(`[useAdminSettings:${sectionName}] Original active settings:`, JSON.stringify(activeOnly, null, 2));
// Merge pending changes into settings for display
const mergedSettings = mergePendingSettings(rawData);
const mergedSettings = mergePendingSettings(rawData) as unknown as CombinedSettings<T>;
console.log(`[useAdminSettings:${sectionName}] Merged settings:`, JSON.stringify(mergedSettings, null, 2));
setSettings(mergedSettings as T);
@ -94,7 +98,10 @@ export function useAdminSettings<T = any>(
setSaving(true);
// Compute delta: only include fields that changed from original
const delta = computeDelta(originalSettings, settings);
const delta = computeDelta(
originalSettings as SettingsRecord,
settings as SettingsRecord
);
console.log(`[useAdminSettings:${sectionName}] Delta (changed fields):`, JSON.stringify(delta, null, 2));
if (Object.keys(delta).length === 0) {
@ -107,7 +114,10 @@ export function useAdminSettings<T = any>(
const { sectionData, deltaSettings } = saveTransformer(settings);
// Save section data (with delta applied)
const sectionDelta = computeDelta(originalSettings, sectionData);
const sectionDelta = computeDelta(
originalSettings as SettingsRecord,
sectionData as unknown as SettingsRecord
);
if (Object.keys(sectionDelta).length > 0) {
await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, sectionDelta);
}
@ -148,8 +158,8 @@ export function useAdminSettings<T = any>(
* Compute delta between original and current settings.
* Returns only fields that have changed.
*/
function computeDelta(original: any, current: any): any {
const delta: any = {};
function computeDelta(original: SettingsRecord, current: SettingsRecord): SettingsRecord {
const delta: SettingsRecord = {};
for (const key in current) {
if (!Object.prototype.hasOwnProperty.call(current, key)) continue;
@ -182,7 +192,7 @@ function computeDelta(original: any, current: any): any {
/**
* Check if value is a plain object (not array, not null, not Date, etc.)
*/
function isPlainObject(value: any): boolean {
function isPlainObject(value: unknown): value is SettingsRecord {
return (
value !== null &&
typeof value === 'object' &&

View File

@ -4,14 +4,14 @@
import { useCallback, useMemo } from 'react';
type ToolParameterValues = Record<string, any>;
type ToolParameterValues = Record<string, unknown>;
/**
* Register tool parameters and get current values
*/
export function useToolParameters(
_toolName: string,
_parameters: Record<string, any>
_parameters: Record<string, unknown>
): [ToolParameterValues, (updates: Partial<ToolParameterValues>) => void] {
// Return empty values and noop updater
@ -24,10 +24,10 @@ export function useToolParameters(
/**
* Hook for managing a single tool parameter
*/
export function useToolParameter<T = any>(
export function useToolParameter<T = unknown>(
toolName: string,
paramName: string,
definition: any
definition: unknown
): [T, (value: T) => void] {
const [allParams, updateParams] = useToolParameters(toolName, { [paramName]: definition });

View File

@ -4,23 +4,35 @@ export const FILE_EVENTS = {
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 {
export function tryParseJson<T = unknown>(input: unknown): T | undefined {
if (typeof input !== 'string') return input as T | undefined;
try { return JSON.parse(input) as T; } catch { return undefined; }
}
export async function normalizeAxiosErrorData(data: any): Promise<any> {
type TextProducer = { text: () => Promise<string> };
function hasTextMethod(value: unknown): value is TextProducer {
return typeof value === 'object' && value !== null && 'text' in value && typeof (value as { text?: unknown }).text === 'function';
}
export async function normalizeAxiosErrorData(data: unknown): Promise<unknown> {
if (!data) return undefined;
if (typeof data?.text === 'function') {
if (hasTextMethod(data)) {
const text = await data.text();
return tryParseJson(text) ?? text;
}
return data;
}
export function extractErrorFileIds(payload: any): string[] | undefined {
function hasErrorFileIds(payload: unknown): payload is { errorFileIds?: unknown } {
return typeof payload === 'object' && payload !== null && 'errorFileIds' in payload;
}
export function extractErrorFileIds(payload: unknown): string[] | undefined {
if (!payload) return undefined;
if (Array.isArray(payload?.errorFileIds)) return payload.errorFileIds as string[];
if (hasErrorFileIds(payload) && Array.isArray(payload.errorFileIds)) {
return payload.errorFileIds.filter((value): value is string => typeof value === 'string');
}
if (typeof payload === 'string') {
const matches = payload.match(UUID_REGEX);
if (matches && matches.length > 0) return Array.from(new Set(matches));
@ -33,15 +45,22 @@ export function broadcastErroredFiles(fileIds: string[]) {
window.dispatchEvent(new CustomEvent(FILE_EVENTS.markError, { detail: { fileIds } }));
}
type Sized = { size?: number };
function getFileSize(file: Sized | File | null | undefined): number | undefined {
if (!file) return undefined;
if (file instanceof File) return file.size;
const { size } = file;
return typeof size === 'number' ? size : undefined;
}
export function isZeroByte(file: File | { size?: number } | null | undefined): boolean {
if (!file) return true;
const size = (file as any).size;
const size = getFileSize(file);
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(file => getFileSize(file) === 0);
}

View File

@ -19,6 +19,29 @@ interface PickerOptions {
mimeTypes?: string | null;
}
type GoogleTokenResponse = {
access_token?: string;
error?: string;
};
type GoogleTokenClient = {
callback: (response: GoogleTokenResponse) => void;
requestAccessToken: (options?: { prompt?: string }) => void;
};
type PickerResponseObject = google.picker.ResponseObject;
interface PickerDocument {
name?: string;
mimeType?: string;
lastModified?: number;
[key: string]: unknown;
}
type DriveFileResponse = {
body: string;
};
// Expandable mime types for Google Picker
const expandableMimeTypes: Record<string, string[]> = {
'image/*': ['image/jpeg', 'image/png', 'image/svg+xml'],
@ -51,7 +74,7 @@ function fileInputToGooglePickerMimeTypes(accept?: string): string | null {
class GoogleDrivePickerService {
private config: GoogleDriveConfig | null = null;
private tokenClient: any = null;
private tokenClient: GoogleTokenClient | null = null;
private accessToken: string | null = null;
private gapiLoaded = false;
private gisLoaded = false;
@ -112,7 +135,7 @@ class GoogleDrivePickerService {
client_id: this.config.clientId,
scope: SCOPES,
callback: () => {}, // Will be overridden during picker creation
});
}) as GoogleTokenClient;
this.gisLoaded = true;
}
@ -142,13 +165,14 @@ class GoogleDrivePickerService {
return;
}
this.tokenClient.callback = (response: any) => {
if (response.error !== undefined) {
this.tokenClient.callback = (response: GoogleTokenResponse) => {
if (typeof response.error === 'string') {
reject(new Error(response.error));
return;
}
if(response.access_token == null){
reject(new Error("No acces token in response"));
if (!response.access_token) {
reject(new Error('No access token in response'));
return;
}
this.accessToken = response.access_token;
@ -192,7 +216,9 @@ class GoogleDrivePickerService {
.setOAuthToken(this.accessToken)
.addView(view1)
.addView(view2)
.setCallback((data: any) => this.pickerCallback(data, resolve, reject));
.setCallback((data: PickerResponseObject) => {
void this.pickerCallback(data, resolve, reject);
});
if (options.multiple) {
builder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED);
@ -207,30 +233,55 @@ class GoogleDrivePickerService {
* Handle picker selection callback
*/
private async pickerCallback(
data: any,
data: PickerResponseObject,
resolve: (files: File[]) => void,
reject: (error: Error) => void
): Promise<void> {
if (data.action === window.google.picker.Action.PICKED) {
try {
const documentKey = window.google.picker.Response.DOCUMENTS;
const responseData = data as unknown as Record<string, unknown>;
const documents = responseData[documentKey];
if (!Array.isArray(documents)) {
reject(new Error('Picker response missing documents'));
return;
}
const files = await Promise.all(
data[window.google.picker.Response.DOCUMENTS].map(async (pickedFile: any) => {
const fileId = pickedFile[window.google.picker.Document.ID];
(documents as PickerDocument[]).map(async (pickedFile) => {
const record = pickedFile as PickerDocument;
const fileIdValue = record[window.google.picker.Document.ID];
if (typeof fileIdValue !== 'string') {
throw new Error('Invalid Google Drive file identifier');
}
const res = await window.gapi.client.drive.files.get({
fileId: fileId,
fileId: fileIdValue,
alt: 'media',
});
const driveResponse = res as DriveFileResponse;
if (typeof driveResponse.body !== 'string') {
throw new Error('Unexpected Google Drive file response');
}
// Convert response body to File object
const file = new File(
[new Uint8Array(res.body.length).map((_: any, i: number) => res.body.charCodeAt(i))],
pickedFile.name,
const buffer = new Uint8Array(driveResponse.body.length);
for (let i = 0; i < driveResponse.body.length; i += 1) {
buffer[i] = driveResponse.body.charCodeAt(i);
}
const nameValue = record.name;
const mimeTypeValue = record.mimeType;
const lastModifiedValue = record.lastModified;
return new File(
[buffer],
typeof nameValue === 'string' ? nameValue : 'drive-file',
{
type: pickedFile.mimeType,
lastModified: pickedFile.lastModified,
type: typeof mimeTypeValue === 'string' ? mimeTypeValue : 'application/octet-stream',
lastModified: typeof lastModifiedValue === 'number' ? lastModifiedValue : Date.now(),
}
);
return file;
})
);

View File

@ -1,5 +1,6 @@
// frontend/src/services/httpErrorHandler.ts
import axios from 'axios';
import type { AxiosError, AxiosRequestConfig } from 'axios';
import { alert } from '@app/components/toast';
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from '@app/services/errorUtils';
import { showSpecialErrorToast } from '@app/services/specialErrorToasts';
@ -29,33 +30,59 @@ function titleForStatus(status?: number): string {
return 'Request failed';
}
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
if (axios.isAxiosError(error)) {
type AxiosErrorData = {
message?: string;
errorFileIds?: string[];
[key: string]: unknown;
};
function isErrorWithMessage(error: unknown): error is { message: string } {
return typeof error === 'object' && error !== null && 'message' in error && typeof (error as { message?: unknown }).message === 'string';
}
function extractAxiosErrorMessage(error: unknown): { title: string; body: string } {
if (axios.isAxiosError<AxiosErrorData | string | undefined>(error)) {
const status = error.response?.status;
const _statusText = error.response?.statusText || '';
let parsed: any = undefined;
const raw = error.response?.data;
let parsed: AxiosErrorData | string | undefined;
if (typeof raw === 'string') {
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
try {
parsed = JSON.parse(raw) as AxiosErrorData;
} catch {
parsed = raw;
}
} else {
parsed = raw;
parsed = raw as AxiosErrorData | undefined;
}
const extractIds = (): string[] | undefined => {
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
const rawText = typeof raw === 'string' ? raw : '';
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const idsCandidate = (parsed as AxiosErrorData).errorFileIds;
if (Array.isArray(idsCandidate)) {
return idsCandidate.filter((id): id is string => typeof id === 'string');
}
}
if (typeof raw === 'string') {
const uuidMatches = raw.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;
}
return undefined;
};
const body = ((): string => {
const data = parsed;
if (!data) return typeof raw === 'string' ? raw : '';
if (!parsed) return typeof raw === 'string' ? raw : '';
if (typeof parsed === 'string') return parsed;
const ids = extractIds();
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
if (data?.message) return data.message as string;
if (typeof raw === 'string') return raw;
try { return JSON.stringify(data); } catch { return ''; }
if (parsed.message) return parsed.message;
try {
return JSON.stringify(parsed);
} catch {
return '';
}
})();
const ids = extractIds();
const title = titleForStatus(status);
if (ids && ids.length > 0) {
@ -69,12 +96,16 @@ function extractAxiosErrorMessage(error: any): { title: string; body: string } {
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
return { title, body: bodyMsg };
}
try {
const msg = (error?.message || String(error)) as string;
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
} catch (e) {
// ignore extraction errors
console.debug('extractAxiosErrorMessage', e);
if (isErrorWithMessage(error)) {
const msg = error.message;
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
}
const fallbackMessage = String(error);
return { title: 'Network error', body: isUnhelpfulMessage(fallbackMessage) ? FRIENDLY_FALLBACK : fallbackMessage };
} catch (parseError) {
console.debug('extractAxiosErrorMessage', parseError);
return { title: 'Network error', body: FRIENDLY_FALLBACK };
}
}
@ -87,16 +118,35 @@ const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate
* Handles HTTP errors with toast notifications and file error broadcasting
* Returns true if the error should be suppressed (deduplicated), false otherwise
*/
export async function handleHttpError(error: any): Promise<boolean> {
type ErrorWithConfig = {
config?: (AxiosRequestConfig & { suppressErrorToast?: boolean }) | null;
response?: {
status?: number;
data?: unknown;
};
};
function toAxiosError(error: unknown): AxiosError<unknown> | undefined {
return axios.isAxiosError(error) ? error : undefined;
}
export async function handleHttpError(error: unknown): Promise<boolean> {
const axiosError = toAxiosError(error);
const errorWithConfig = (axiosError ?? (typeof error === 'object' && error !== null ? (error as ErrorWithConfig) : undefined));
// Check if this error should skip the global toast (component will handle it)
if (error?.config?.suppressErrorToast === true) {
const suppressToast =
!!errorWithConfig?.config &&
typeof errorWithConfig.config === 'object' &&
(errorWithConfig.config as { suppressErrorToast?: boolean }).suppressErrorToast === true;
if (suppressToast) {
return false; // Don't show global toast, but continue rejection
}
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);
// Normalize response data ONCE, reuse for both ID extraction and special-toast matching
const raw = (error?.response?.data) as any;
const raw = errorWithConfig?.response?.data;
let normalized: unknown = raw;
try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); }
@ -111,8 +161,11 @@ export async function handleHttpError(error: any): Promise<boolean> {
}
// 2) Generic-vs-special dedupe by endpoint
const url: string | undefined = error?.config?.url;
const status: number | undefined = error?.response?.status;
const url =
errorWithConfig?.config && typeof errorWithConfig.config === 'object'
? (errorWithConfig.config as AxiosRequestConfig).url
: undefined;
const status: number | undefined = errorWithConfig?.response?.status;
const now = Date.now();
const isSpecial =
status === 422 ||
@ -148,4 +201,4 @@ export async function handleHttpError(error: any): Promise<boolean> {
}
return false; // Error was handled with toast, continue normal rejection
}
}

View File

@ -6,6 +6,7 @@
*/
import { GlobalWorkerOptions, getDocument, PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs';
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api';
class PDFWorkerManager {
private static instance: PDFWorkerManager;
@ -57,32 +58,25 @@ class PDFWorkerManager {
}
// Normalize input data to PDF.js format
let pdfData: any;
let params: DocumentInitParameters;
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
pdfData = { data };
params = { data };
} else if (typeof data === 'string') {
pdfData = data; // URL string
params = { url: data };
} else if (data && typeof data === 'object' && 'data' in data) {
pdfData = data; // Already in {data: ArrayBuffer} format
const buffer = (data as { data: ArrayBuffer }).data;
params = { data: buffer };
} else {
pdfData = data; // Pass through as-is
params = (data as DocumentInitParameters) ?? {};
}
const loadingTask = getDocument(
typeof pdfData === 'string' ? {
url: pdfData,
disableAutoFetch: options.disableAutoFetch ?? true,
disableStream: options.disableStream ?? true,
stopAtErrors: options.stopAtErrors ?? false,
verbosity: options.verbosity ?? 0
} : {
...pdfData,
disableAutoFetch: options.disableAutoFetch ?? true,
disableStream: options.disableStream ?? true,
stopAtErrors: options.stopAtErrors ?? false,
verbosity: options.verbosity ?? 0
}
);
const loadingTask = getDocument({
...params,
disableAutoFetch: options.disableAutoFetch ?? true,
disableStream: options.disableStream ?? true,
stopAtErrors: options.stopAtErrors ?? false,
verbosity: options.verbosity ?? 0
});
try {
const pdf = await loadingTask.promise;

View File

@ -4,10 +4,25 @@
* without needing to make API calls
*/
// PDF.js types (simplified)
import React from 'react';
import type {
DocumentInitParameters,
PDFDocumentLoadingTask,
PDFDocumentProxy,
PDFPageProxy
} from 'pdfjs-dist/types/src/display/api';
type PdfMetadataResult = Awaited<ReturnType<PDFDocumentProxy['getMetadata']>>;
type PdfMetadataInfo = PdfMetadataResult['info'];
type PdfMetadata = PdfMetadataResult['metadata'];
interface PdfjsLib {
getDocument: (src?: string | URL | Uint8Array | ArrayBuffer | DocumentInitParameters) => PDFDocumentLoadingTask;
}
declare global {
interface Window {
pdfjsLib?: any;
pdfjsLib?: PdfjsLib;
}
}
@ -39,17 +54,18 @@ const detectSignaturesInFile = async (file: File): Promise<SignatureDetectionRes
const arrayBuffer = await file.arrayBuffer();
// Load the PDF document
const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const loadingTask = window.pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.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 page: PDFPageProxy = await pdf.getPage(pageNum);
const annotations = await page.getAnnotations();
// Count signature annotations (Type: /Sig)
const signatureAnnotations = annotations.filter((annotation: any) =>
const signatureAnnotations = annotations.filter(annotation =>
annotation.subtype === 'Widget' &&
annotation.fieldType === 'Sig'
);
@ -59,7 +75,20 @@ const detectSignaturesInFile = async (file: File): Promise<SignatureDetectionRes
// Also check for document-level signatures in AcroForm
const metadata = await pdf.getMetadata();
if (metadata?.info?.Signature || metadata?.metadata?.has('dc:signature')) {
const info: PdfMetadataInfo | undefined = metadata?.info;
const meta: PdfMetadata | undefined = metadata?.metadata;
const hasInfoSignature =
typeof info === 'object' &&
info !== null &&
'Signature' in info &&
Boolean((info as { Signature?: unknown }).Signature);
const hasMetadataSignature =
typeof meta === 'object' &&
meta !== null &&
'has' in meta &&
typeof (meta as { has: (key: string) => boolean }).has === 'function' &&
(meta as { has: (key: string) => boolean }).has('dc:signature');
if (hasInfoSignature || hasMetadataSignature) {
totalSignatures = Math.max(totalSignatures, 1);
}
@ -135,6 +164,3 @@ export const useSignatureDetection = () => {
reset: () => setDetectionResults([])
};
};
// Import React for the hook
import React from 'react';

View File

@ -40,8 +40,10 @@ 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 i18next = anyGlobal?.i18next;
const globalWithI18n = globalThis as typeof globalThis & {
i18next?: { t?: (key: string, options: { defaultValue: string }) => string };
};
const i18next = globalWithI18n?.i18next;
if (i18next && typeof i18next.t === 'function') {
body = i18next.t(mapping.i18nKey, { defaultValue: mapping.defaultMessage });
}
@ -54,4 +56,3 @@ export function showSpecialErrorToast(rawError: string | undefined, options?: {
return false;
}

View File

@ -1,10 +1,14 @@
import type { ComponentType } from 'react';
/**
* Types for automation functionality
*/
export type AutomationParameters = Record<string, unknown>;
export interface AutomationOperation {
operation: string;
parameters: Record<string, any>;
parameters: AutomationParameters;
}
export interface AutomationConfig {
@ -22,7 +26,7 @@ export interface AutomationTool {
operation: string;
name: string;
configured: boolean;
parameters?: Record<string, any>;
parameters?: AutomationParameters;
}
export type AutomationStep = typeof import('@app/constants/automation').AUTOMATION_STEPS[keyof typeof import('@app/constants/automation').AUTOMATION_STEPS];
@ -57,12 +61,6 @@ export enum AutomationMode {
SUGGESTED = 'suggested'
}
export interface SuggestedAutomation {
id: string;
name: string;
description?: string;
operations: AutomationOperation[];
createdAt: string;
updatedAt: string;
icon: any; // MUI Icon component
export interface SuggestedAutomation extends AutomationConfig {
iconComponent?: ComponentType;
}

View File

@ -1,31 +1,75 @@
import axios from 'axios';
import type { ToolOperationConfig, SingleFileToolOperationConfig, MultiFileToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { ToolRegistry } from '@app/data/toolsTaxonomy';
import { ToolId } from '@app/types/toolId';
import { AUTOMATION_CONSTANTS } from '@app/constants/automation';
import { AutomationFileProcessor } from '@app/utils/automationFileProcessor';
import { AutomationFileProcessor, type AutomationFormParameters } from '@app/utils/automationFileProcessor';
import { ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { processResponse } from '@app/utils/toolResponseProcessor';
import type { AutomationConfig, AutomationParameters } from '@app/types/automation';
const getHeaderValue = (headers: unknown, key: string): string | undefined => {
if (!headers || typeof headers !== 'object') return undefined;
const value = (headers as Record<string, unknown>)[key];
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value.find((entry): entry is string => typeof entry === 'string' && entry.length > 0);
}
if (value && typeof value === 'object' && 'toString' in value) {
const stringValue = String(value);
return stringValue.length > 0 ? stringValue : undefined;
}
return undefined;
};
const isPdfResponse = (headers: unknown): boolean => {
const contentType = getHeaderValue(headers, 'content-type');
return typeof contentType === 'string' && contentType.includes('application/pdf');
};
const formatErrorMessage = (error: unknown): string => {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
if (typeof responseData === 'string') {
return responseData;
}
if (responseData && typeof responseData === 'object' && 'message' in responseData) {
const message = (responseData as { message?: unknown }).message;
if (typeof message === 'string') return message;
}
return error.message;
}
if (error instanceof Error) {
return error.message;
}
return String(error);
};
/**
* Process multi-file tool response (handles ZIP or single PDF responses)
*/
const processMultiFileResponse = async (
responseData: Blob,
responseHeaders: any,
responseHeaders: unknown,
files: File[],
filePrefix: string,
preserveBackendFilename?: boolean
): Promise<File[]> => {
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
if (responseData.type === 'application/pdf' ||
(responseHeaders && responseHeaders['content-type'] === 'application/pdf')) {
isPdfResponse(responseHeaders)) {
// Single PDF response - use processResponse to respect preserveBackendFilename
const normalizedHeaders =
typeof responseHeaders === 'object' && responseHeaders !== null
? (responseHeaders as Record<string, unknown>)
: undefined;
const processedFiles = await processResponse(
responseData,
files,
filePrefix,
undefined,
preserveBackendFilename ? responseHeaders : undefined
preserveBackendFilename ? normalizedHeaders : undefined
);
return processedFiles;
} else {
@ -75,9 +119,9 @@ const executeApiRequest = async (
/**
* Execute single-file tool operation (processes files one at a time)
*/
const executeSingleFileOperation = async (
config: any,
parameters: any,
const executeSingleFileOperation = async <TParams extends AutomationFormParameters>(
config: SingleFileToolOperationConfig<TParams>,
parameters: TParams,
files: File[],
filePrefix: string
): Promise<File[]> => {
@ -88,7 +132,7 @@ const executeSingleFileOperation = async (
? config.endpoint(parameters)
: config.endpoint;
const formData = (config.buildFormData as (params: any, file: File) => FormData)(parameters, file);
const formData = config.buildFormData(parameters, file);
const processedFiles = await executeApiRequest(
endpoint,
@ -106,9 +150,9 @@ const executeSingleFileOperation = async (
/**
* Execute multi-file tool operation (processes all files in one request)
*/
const executeMultiFileOperation = async (
config: any,
parameters: any,
const executeMultiFileOperation = async <TParams extends AutomationFormParameters>(
config: MultiFileToolOperationConfig<TParams>,
parameters: TParams,
files: File[],
filePrefix: string
): Promise<File[]> => {
@ -116,7 +160,7 @@ const executeMultiFileOperation = async (
? config.endpoint(parameters)
: config.endpoint;
const formData = (config.buildFormData as (params: any, files: File[]) => FormData)(parameters, files);
const formData = config.buildFormData(parameters, files);
return await executeApiRequest(
endpoint,
@ -131,9 +175,12 @@ const executeMultiFileOperation = async (
/**
* Execute a tool operation directly without using React hooks
*/
const toFormParameters = (parameters: AutomationParameters): AutomationFormParameters =>
parameters as AutomationFormParameters;
export const executeToolOperation = async (
operationName: string,
parameters: any,
parameters: AutomationParameters,
files: File[],
toolRegistry: ToolRegistry
): Promise<File[]> => {
@ -145,12 +192,12 @@ export const executeToolOperation = async (
*/
export const executeToolOperationWithPrefix = async (
operationName: string,
parameters: any,
parameters: AutomationParameters,
files: File[],
toolRegistry: ToolRegistry,
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX
): Promise<File[]> => {
const config = toolRegistry[operationName as ToolId]?.operationConfig;
const config = toolRegistry[operationName as ToolId]?.operationConfig as ToolOperationConfig<AutomationFormParameters> | undefined;
if (!config) {
throw new Error(`Tool operation not supported: ${operationName}`);
}
@ -162,16 +209,18 @@ export const executeToolOperationWithPrefix = async (
return resultFiles;
}
const formParameters = toFormParameters(parameters);
// Execute based on tool type
if (config.toolType === ToolType.multiFile) {
return await executeMultiFileOperation(config, parameters, files, filePrefix);
return await executeMultiFileOperation(config, formParameters, files, filePrefix);
} else {
return await executeSingleFileOperation(config, parameters, files, filePrefix);
return await executeSingleFileOperation(config, formParameters, files, filePrefix);
}
} catch (error: any) {
} catch (error: unknown) {
console.error(`${operationName} failed:`, error);
throw new Error(`${operationName} operation failed: ${error.response?.data || error.message}`);
throw new Error(`${operationName} operation failed: ${formatErrorMessage(error)}`);
}
};
@ -179,7 +228,7 @@ export const executeToolOperationWithPrefix = async (
* Execute an entire automation sequence
*/
export const executeAutomationSequence = async (
automation: any,
automation: AutomationConfig,
initialFiles: File[],
toolRegistry: ToolRegistry,
onStepStart?: (stepIndex: number, operationName: string) => void,
@ -205,9 +254,11 @@ export const executeAutomationSequence = async (
try {
onStepStart?.(i, operation.operation);
const operationParameters: AutomationParameters = operation.parameters ?? {};
const resultFiles = await executeToolOperationWithPrefix(
operation.operation,
operation.parameters || {},
operationParameters,
currentFiles,
toolRegistry,
i === automation.operations.length - 1 ? automationPrefix : '' // Only add prefix to final step
@ -217,10 +268,11 @@ export const executeAutomationSequence = async (
currentFiles = resultFiles;
onStepComplete?.(i, resultFiles);
} catch (error: any) {
} catch (error: unknown) {
console.error(`❌ Step ${i + 1} failed:`, error);
onStepError?.(i, error.message);
throw error;
const message = formatErrorMessage(error);
onStepError?.(i, message);
throw error instanceof Error ? error : new Error(message);
}
}

View File

@ -18,6 +18,8 @@ export interface AutomationProcessingResult {
errors: string[];
}
export type AutomationFormParameters = Record<string, unknown>;
export class AutomationFileProcessor {
/**
* Check if a blob is a ZIP file by examining its header
@ -121,11 +123,11 @@ export class AutomationFileProcessor {
files: [resultFile],
errors: []
};
} catch (error: any) {
} catch (error: unknown) {
return {
success: false,
files: [],
errors: [`Automation step failed: ${error.response?.data || error.message}`]
errors: [`Automation step failed: ${AutomationFileProcessor.formatError(error)}`]
};
}
}
@ -154,11 +156,11 @@ export class AutomationFileProcessor {
// Multi-file responses are typically ZIP files
return await this.extractAutomationZipFiles(response.data);
} catch (error: any) {
} catch (error: unknown) {
return {
success: false,
files: [],
errors: [`Automation step failed: ${error.response?.data || error.message}`]
errors: [`Automation step failed: ${AutomationFileProcessor.formatError(error)}`]
};
}
}
@ -167,7 +169,7 @@ export class AutomationFileProcessor {
* Build form data for automation tool operations
*/
static buildAutomationFormData(
parameters: Record<string, any>,
parameters: AutomationFormParameters,
files: File | File[],
fileFieldName: string = 'fileInput'
): FormData {
@ -183,12 +185,51 @@ export class AutomationFileProcessor {
// Add parameters
Object.entries(parameters).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(item => formData.append(key, item));
value.forEach(item => {
if (item !== undefined && item !== null) {
formData.append(key, AutomationFileProcessor.normalizeFormValue(item));
}
});
} else if (value !== undefined && value !== null) {
formData.append(key, value);
formData.append(key, AutomationFileProcessor.normalizeFormValue(value));
}
});
return formData;
}
private static normalizeFormValue(value: unknown): string | Blob {
if (value instanceof Blob || value instanceof File) {
return value;
}
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
private static formatError(error: unknown): string {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
if (typeof responseData === 'string') return responseData;
if (responseData && typeof responseData === 'object' && 'message' in responseData) {
const message = (responseData as { message?: unknown }).message;
if (typeof message === 'string') {
return message;
}
}
return error.message;
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
}

View File

@ -143,7 +143,7 @@ export function downloadTextAsFile(
* @param data - Data to serialize and download
* @param filename - Filename for the download
*/
export function downloadJsonAsFile(data: any, filename: string): void {
export function downloadJsonAsFile(data: unknown, filename: string): void {
const content = JSON.stringify(data, null, 2);
downloadTextAsFile(content, filename, 'application/json');
}

View File

@ -11,9 +11,11 @@
* }
*/
export interface SettingsWithPending<T = any> {
type PlainObject = Record<string, unknown>;
export interface SettingsWithPending<T extends PlainObject = PlainObject> {
_pending?: Partial<T>;
[key: string]: any;
[key: string]: unknown;
}
/**
@ -23,15 +25,21 @@ export interface SettingsWithPending<T = any> {
* @param settings Settings object from backend (may contain _pending block)
* @returns Merged settings with pending values applied
*/
export function mergePendingSettings<T extends SettingsWithPending>(settings: T): Omit<T, '_pending'> {
if (!settings || !settings._pending) {
export function mergePendingSettings<T extends SettingsWithPending>(
settings: T | null | undefined
): Omit<T, '_pending'> {
if (!settings) {
return {} as Omit<T, '_pending'>;
}
if (!settings._pending) {
// No pending changes, return as-is (without _pending property)
const { _pending, ...rest } = settings || {};
const { _pending, ...rest } = settings;
return rest as Omit<T, '_pending'>;
}
// Deep merge pending changes
const merged = deepMerge(settings, settings._pending);
const merged = deepMerge(settings, settings._pending as PlainObject | undefined);
// Remove _pending from result
const { _pending, ...result } = merged;
@ -54,7 +62,7 @@ export function isFieldPending<T extends SettingsWithPending>(
}
// Navigate the pending object using dot notation
const value = getNestedValue(settings._pending, fieldPath);
const value = getNestedValue(settings._pending as PlainObject | undefined, fieldPath);
return value !== undefined;
}
@ -80,12 +88,12 @@ export function hasPendingChanges<T extends SettingsWithPending>(
export function getPendingValue<T extends SettingsWithPending>(
settings: T | null | undefined,
fieldPath: string
): any {
): unknown {
if (!settings?._pending) {
return undefined;
}
return getNestedValue(settings._pending, fieldPath);
return getNestedValue(settings._pending as PlainObject | undefined, fieldPath);
}
/**
@ -98,14 +106,14 @@ export function getPendingValue<T extends SettingsWithPending>(
export function getCurrentValue<T extends SettingsWithPending>(
settings: T | null | undefined,
fieldPath: string
): any {
): unknown {
if (!settings) {
return undefined;
}
// Get from settings, ignoring _pending
const { _pending, ...activeSettings } = settings;
return getNestedValue(activeSettings, fieldPath);
return getNestedValue(activeSettings as PlainObject | undefined, fieldPath);
}
// ========== Helper Functions ==========
@ -113,11 +121,11 @@ export function getCurrentValue<T extends SettingsWithPending>(
/**
* Deep merge two objects. Second object takes priority.
*/
function deepMerge(target: any, source: any): any {
if (!source) return target;
function deepMerge(target: PlainObject | undefined, source: PlainObject | undefined): PlainObject {
if (!source) return target ?? {};
if (!target) return source;
const result = { ...target };
const result: PlainObject = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
@ -138,17 +146,20 @@ function deepMerge(target: any, source: any): any {
/**
* Get nested value using dot notation.
*/
function getNestedValue(obj: any, path: string): any {
function getNestedValue(obj: PlainObject | undefined, path: string): unknown {
if (!obj || !path) return undefined;
const parts = path.split('.');
let current = obj;
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
current = current[part];
if (typeof current !== 'object' || !(part in current)) {
return undefined;
}
current = (current as PlainObject)[part];
}
return current;
@ -157,7 +168,7 @@ function getNestedValue(obj: any, path: string): any {
/**
* Check if value is a plain object (not array, not null, not Date, etc.)
*/
function isPlainObject(value: any): boolean {
function isPlainObject(value: unknown): value is PlainObject {
return (
value !== null &&
typeof value === 'object' &&

View File

@ -2,32 +2,40 @@
* Standardized error handling utilities for tool operations
*/
import axios from 'axios';
/**
* Default error extractor that follows the standard pattern
*/
export const extractErrorMessage = (error: any): string => {
if (error.response?.data && typeof error.response.data === 'string') {
return error.response.data;
const DEFAULT_MESSAGE = 'There was an error processing your request.';
const resolveErrorMessage = (error: unknown, fallbackMessage: string): string => {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
if (typeof responseData === 'string') {
return responseData;
}
if (responseData && typeof responseData === 'object' && 'message' in responseData) {
const message = (responseData as { message?: unknown }).message;
if (typeof message === 'string') {
return message;
}
}
return error.message || fallbackMessage;
}
if (error.message) {
if (error instanceof Error) {
return error.message;
}
return 'There was an error processing your request.';
return fallbackMessage;
};
export const extractErrorMessage = (error: unknown): string => resolveErrorMessage(error, DEFAULT_MESSAGE);
/**
* Creates a standardized error handler for tool operations
* @param fallbackMessage - Message to show when no specific error can be extracted
* @returns Error handler function that follows the standard pattern
*/
export const createStandardErrorHandler = (fallbackMessage: string) => {
return (error: any): string => {
if (error.response?.data && typeof error.response.data === 'string') {
return error.response.data;
}
if (error.message) {
return error.message;
}
return fallbackMessage;
};
};
return (error: unknown): string => resolveErrorMessage(error, fallbackMessage);
};

View File

@ -15,7 +15,7 @@ export async function processResponse(
originalFiles: File[],
filePrefix?: string,
responseHandler?: ResponseHandler,
responseHeaders?: Record<string, any>
responseHeaders?: Record<string, unknown>
): Promise<File[]> {
if (responseHandler) {
const out = await responseHandler(blob, originalFiles);
@ -25,10 +25,14 @@ export async function processResponse(
// Check if we should use the backend-provided filename from headers
// Only when responseHeaders are explicitly provided (indicating the operation requested this)
if (responseHeaders) {
const contentDisposition = responseHeaders['content-disposition'];
const contentDisposition = responseHeaders['content-disposition'] as string | undefined;
const backendFilename = getFilenameFromHeaders(contentDisposition);
if (backendFilename) {
const type = blob.type || responseHeaders['content-type'] || 'application/octet-stream';
const contentType = responseHeaders['content-type'];
const type =
blob.type ||
(typeof contentType === 'string' ? contentType : undefined) ||
'application/octet-stream';
return [new File([blob], backendFilename, { type })];
}
// If preserveBackendFilename was requested but no Content-Disposition header found,

View File

@ -3,9 +3,15 @@ declare module '*.module.css';
// Auto-generated icon set JSON import
declare module 'assets/material-symbols-icons.json' {
type IconDefinition = {
body: string;
width?: number;
height?: number;
[key: string]: unknown;
};
const value: {
prefix: string;
icons: Record<string, any>;
icons: Record<string, IconDefinition>;
width?: number;
height?: number;
};