Restructure frontend code to allow for extensions (#4721)

# Description of Changes
Move frontend code into `core` folder and add infrastructure for
`proprietary` folder to include premium, non-OSS features
This commit is contained in:
James Brunton
2025-10-28 10:29:36 +00:00
committed by GitHub
parent 960d48f80c
commit d2b38ef4b8
725 changed files with 2485 additions and 2226 deletions
@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationConfig, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { AddAttachmentsParameters } from '@app/hooks/tools/addAttachments/useAddAttachmentsParameters';
const buildFormData = (parameters: AddAttachmentsParameters, file: File): FormData => {
const formData = new FormData();
// Add the main PDF file (single file per request in singleFile mode)
if (file) {
formData.append("fileInput", file);
}
// Add attachment files
(parameters.attachments || []).forEach((attachment) => {
if (attachment) formData.append("attachments", attachment);
});
return formData;
};
// Operation configuration for automation
export const addAttachmentsOperationConfig: ToolOperationConfig<AddAttachmentsParameters> = {
toolType: ToolType.singleFile,
buildFormData,
operationType: 'addAttachments',
endpoint: '/api/v1/misc/add-attachments',
};
export const useAddAttachmentsOperation = () => {
const { t } = useTranslation();
return useToolOperation<AddAttachmentsParameters>({
...addAttachmentsOperationConfig,
getErrorMessage: createStandardErrorHandler(t('addAttachments.error.failed', 'An error occurred while adding attachments to the PDF.'))
});
};
@@ -0,0 +1,35 @@
import { useState } from 'react';
export interface AddAttachmentsParameters {
attachments: File[];
}
const defaultParameters: AddAttachmentsParameters = {
attachments: []
};
export const useAddAttachmentsParameters = () => {
const [parameters, setParameters] = useState<AddAttachmentsParameters>(defaultParameters);
const updateParameter = <K extends keyof AddAttachmentsParameters>(
key: K,
value: AddAttachmentsParameters[K]
) => {
setParameters(prev => ({ ...prev, [key]: value }));
};
const resetParameters = () => {
setParameters(defaultParameters);
};
const validateParameters = (): boolean => {
return parameters.attachments.length > 0;
};
return {
parameters,
updateParameter,
resetParameters,
validateParameters
};
};
@@ -0,0 +1,129 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useAddPasswordOperation } from '@app/hooks/tools/addPassword/useAddPasswordOperation';
import type { AddPasswordFullParameters } from '@app/hooks/tools/addPassword/useAddPasswordParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Mock the error handler
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
}));
// Import the mocked function
import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
describe('useAddPasswordOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<AddPasswordFullParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: '',
isLoading: false,
errorMessage: null,
status: '',
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test.each([
{
description: 'with all parameters filled',
password: 'user-password',
ownerPassword: 'owner-password',
keyLength: 256
},
{
description: 'with empty passwords',
password: '',
ownerPassword: '',
keyLength: 128
},
{
description: 'with 40-bit key length',
password: 'test',
ownerPassword: '',
keyLength: 40
}
])('should create form data correctly $description', ({ password, ownerPassword, keyLength }) => {
renderHook(() => useAddPasswordOperation());
const callArgs = getToolConfig();
const buildFormData = callArgs.buildFormData;
const testParameters: AddPasswordFullParameters = {
password,
ownerPassword,
keyLength,
permissions: {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false
}
};
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const formData = buildFormData(testParameters, testFile);
// Verify the form data contains the file
expect(formData.get('fileInput')).toBe(testFile);
// Verify password parameters
expect(formData.get('password')).toBe(password);
expect(formData.get('ownerPassword')).toBe(ownerPassword);
expect(formData.get('keyLength')).toBe(keyLength.toString());
});
test('should use correct translation for error messages', () => {
renderHook(() => useAddPasswordOperation());
expect(mockT).toHaveBeenCalledWith(
'addPassword.error.failed',
'An error occurred while encrypting the PDF.'
);
});
test.each([
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
{ property: 'operationType' as const, expectedValue: 'addPassword' }
])('should configure $property correctly', ({ property, expectedValue }) => {
renderHook(() => useAddPasswordOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { AddPasswordFullParameters, defaultParameters } from '@app/hooks/tools/addPassword/useAddPasswordParameters';
import { defaultParameters as permissionsDefaults } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
import { getFormData } from '@app/hooks/tools/changePermissions/useChangePermissionsOperation';
// Static function that can be used by both the hook and automation executor
export const buildAddPasswordFormData = (parameters: AddPasswordFullParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
formData.append("ownerPassword", parameters.ownerPassword);
formData.append("keyLength", parameters.keyLength.toString());
getFormData(parameters.permissions).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
// Full default parameters including permissions for automation
const fullDefaultParameters: AddPasswordFullParameters = {
...defaultParameters,
permissions: permissionsDefaults,
};
// Static configuration object
export const addPasswordOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddPasswordFormData,
operationType: 'addPassword',
endpoint: '/api/v1/security/add-password',
defaultParameters: fullDefaultParameters,
} as const;
export const useAddPasswordOperation = () => {
const { t } = useTranslation();
return useToolOperation<AddPasswordFullParameters>({
...addPasswordOperationConfig,
getErrorMessage: createStandardErrorHandler(t('addPassword.error.failed', 'An error occurred while encrypting the PDF.'))
});
};
@@ -0,0 +1,152 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAddPasswordParameters, defaultParameters, AddPasswordParametersHook } from '@app/hooks/tools/addPassword/useAddPasswordParameters';
import { defaultParameters as defaultChangePermissionsParameters, ChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
describe('useAddPasswordParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useAddPasswordParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: 'password' as const, value: 'test-password' },
{ paramName: 'ownerPassword' as const, value: 'owner-password' },
{ paramName: 'keyLength' as const, value: 256 }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test.each([
{ paramName: 'preventAssembly' as const },
{ paramName: 'preventPrinting' as const }
])('should update boolean permission parameter $paramName', ({ paramName }) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.permissions.updateParameter(paramName, true);
});
expect(result.current.permissions.parameters[paramName]).toBe(true);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useAddPasswordParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('password', 'test');
result.current.updateParameter('keyLength', 256);
result.current.permissions.updateParameter('preventAssembly', true);
});
expect(result.current.parameters.password).toBe('test');
expect(result.current.parameters.keyLength).toBe(256);
expect(result.current.permissions.parameters.preventAssembly).toBe(true);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useAddPasswordParameters());
expect(result.current.getEndpointName()).toBe('add-password');
});
test.each([
{
description: 'with user password only',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', 'user-password');
}
},
{
description: 'with owner password only',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('ownerPassword', 'owner-password');
}
},
{
description: 'with both passwords',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', 'user-password');
hook.updateParameter('ownerPassword', 'owner-password');
}
},
{
description: 'with whitespace only password',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', ' \t ');
}
},
{
description: 'with whitespace only owner password',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('ownerPassword', ' \t ');
}
},
{
description: 'with restrictions only',
setup: (hook: AddPasswordParametersHook) => {
hook.permissions.updateParameter('preventAssembly', true);
hook.permissions.updateParameter('preventPrinting', true);
}
},
{
description: 'with passwords and restrictions',
setup: (hook: AddPasswordParametersHook) => {
hook.updateParameter('password', 'test-password');
hook.permissions.updateParameter('preventAssembly', true);
}
}
])('should validate parameters correctly $description', ({ setup }) => {
const { result } = renderHook(() => useAddPasswordParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
// Apply the test scenario setup
act(() => {
setup(result.current);
});
expect(result.current.validateParameters()).toBe(true);
});
test.each(Object.keys(defaultChangePermissionsParameters) as Array<keyof ChangePermissionsParameters>)('should handle boolean restriction parameter %s', (param) => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.resetParameters();
result.current.permissions.updateParameter(param, true);
});
expect(result.current.validateParameters()).toBe(true);
});
test('should handle mixed parameter types in updateParameter', () => {
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.updateParameter('password', 'test-string');
result.current.updateParameter('keyLength', 40);
result.current.permissions.updateParameter('preventAssembly', true);
});
expect(result.current.parameters.password).toBe('test-string');
expect(result.current.parameters.keyLength).toBe(40);
expect(result.current.permissions.parameters.preventAssembly).toBe(true);
});
});
@@ -0,0 +1,54 @@
import { ChangePermissionsParameters, ChangePermissionsParametersHook, useChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface AddPasswordParameters extends BaseParameters {
password: string;
ownerPassword: string;
keyLength: number;
}
export interface AddPasswordFullParameters extends AddPasswordParameters {
permissions: ChangePermissionsParameters;
}
export interface AddPasswordParametersHook extends BaseParametersHook<AddPasswordParameters> {
fullParameters: AddPasswordFullParameters;
permissions: ChangePermissionsParametersHook;
}
export const defaultParameters: AddPasswordParameters = {
password: '',
ownerPassword: '',
keyLength: 128,
};
export const useAddPasswordParameters = (): AddPasswordParametersHook => {
const permissions = useChangePermissionsParameters();
const baseHook = useBaseParameters({
defaultParameters,
endpointName: 'add-password',
validateFn: () => {
// No required parameters for Add Password. Defer to permissions validation.
return permissions.validateParameters();
},
});
const fullParameters: AddPasswordFullParameters = {
...baseHook.parameters,
permissions: permissions.parameters,
};
const resetParameters = () => {
baseHook.resetParameters();
permissions.resetParameters();
};
return {
...baseHook,
fullParameters,
permissions,
resetParameters,
};
};
@@ -0,0 +1,52 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { AddWatermarkParameters, defaultParameters } from '@app/hooks/tools/addWatermark/useAddWatermarkParameters';
// Static function that can be used by both the hook and automation executor
export const buildAddWatermarkFormData = (parameters: AddWatermarkParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Required: watermarkType as string
formData.append("watermarkType", parameters.watermarkType || "text");
// Add watermark content based on type
if (parameters.watermarkType === 'text') {
formData.append("watermarkText", parameters.watermarkText);
} else if (parameters.watermarkType === 'image' && parameters.watermarkImage) {
formData.append("watermarkImage", parameters.watermarkImage);
}
// Required parameters with correct formatting
formData.append("fontSize", parameters.fontSize.toString());
formData.append("rotation", parameters.rotation.toString());
formData.append("opacity", (parameters.opacity / 100).toString()); // Convert percentage to decimal
formData.append("widthSpacer", parameters.widthSpacer.toString());
formData.append("heightSpacer", parameters.heightSpacer.toString());
// Backend-expected parameters from user input
formData.append("alphabet", parameters.alphabet);
formData.append("customColor", parameters.customColor);
formData.append("convertPDFToImage", parameters.convertPDFToImage.toString());
return formData;
};
// Static configuration object
export const addWatermarkOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddWatermarkFormData,
operationType: 'watermark',
endpoint: '/api/v1/security/add-watermark',
defaultParameters,
} as const;
export const useAddWatermarkOperation = () => {
const { t } = useTranslation();
return useToolOperation<AddWatermarkParameters>({
...addWatermarkOperationConfig,
getErrorMessage: createStandardErrorHandler(t('watermark.error.failed', 'An error occurred while adding watermark to the PDF.'))
});
};
@@ -0,0 +1,49 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface AddWatermarkParameters extends BaseParameters {
watermarkType?: 'text' | 'image';
watermarkText: string;
watermarkImage?: File;
fontSize: number; // Used for both text size and image size
rotation: number;
opacity: number;
widthSpacer: number;
heightSpacer: number;
alphabet: string;
customColor: string;
convertPDFToImage: boolean;
}
export const defaultParameters: AddWatermarkParameters = {
watermarkType: undefined,
watermarkText: '',
fontSize: 12,
rotation: 0,
opacity: 50,
widthSpacer: 50,
heightSpacer: 50,
alphabet: 'roman',
customColor: '#d3d3d3',
convertPDFToImage: false
};
export type AddWatermarkParametersHook = BaseParametersHook<AddWatermarkParameters>;
export const useAddWatermarkParameters = (): AddWatermarkParametersHook => {
return useBaseParameters({
defaultParameters: defaultParameters,
endpointName: 'add-watermark',
validateFn: (params): boolean => {
if (!params.watermarkType) {
return false;
}
if (params.watermarkType === 'text') {
return params.watermarkText.trim().length > 0;
} else {
return params.watermarkImage !== undefined;
}
},
});
};
@@ -0,0 +1,94 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { AdjustContrastParameters, defaultParameters } from '@app/hooks/tools/adjustContrast/useAdjustContrastParameters';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { applyAdjustmentsToCanvas } from '@app/components/tools/adjustContrast/utils';
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number): Promise<HTMLCanvasElement> {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context unavailable');
await page.render({ canvasContext: ctx, viewport }).promise;
return canvas;
}
// adjustment logic moved to shared util
// Render, adjust, and assemble all pages of a single PDF into a new PDF
async function buildAdjustedPdfForFile(file: File, params: AdjustContrastParameters): Promise<File> {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {});
const pageCount = pdf.numPages;
const newDoc = await PDFLibDocument.create();
for (let p = 1; p <= pageCount; p++) {
const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2);
const adjusted = applyAdjustmentsToCanvas(srcCanvas, params);
const pngUrl = adjusted.toDataURL('image/png');
const res = await fetch(pngUrl);
const pngBytes = new Uint8Array(await res.arrayBuffer());
const embedded = await newDoc.embedPng(pngBytes);
const { width, height } = embedded.scale(1);
const page = newDoc.addPage([width, height]);
page.drawImage(embedded, { x: 0, y: 0, width, height });
}
const pdfBytes = await newDoc.save();
const out = createFileFromApiResponse(pdfBytes, { 'content-type': 'application/pdf' }, file.name);
pdfWorkerManager.destroyDocument(pdf);
return out;
}
async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise<File[]> {
// Limit concurrency to avoid exhausting memory/CPU while still getting speedups
// Heuristic: use up to 4 workers on capable machines, otherwise 2-3
let CONCURRENCY_LIMIT = 2;
if (typeof navigator !== 'undefined' && typeof navigator.hardwareConcurrency === 'number') {
if (navigator.hardwareConcurrency >= 8) CONCURRENCY_LIMIT = 4;
else if (navigator.hardwareConcurrency >= 4) CONCURRENCY_LIMIT = 3;
}
CONCURRENCY_LIMIT = Math.min(CONCURRENCY_LIMIT, files.length);
const mapWithConcurrency = async <T, R>(items: T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]> => {
const results: R[] = new Array(items.length);
let nextIndex = 0;
const workers = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
let current = nextIndex++;
while (current < items.length) {
results[current] = await worker(items[current], current);
current = nextIndex++;
}
});
await Promise.all(workers);
return results;
};
return mapWithConcurrency(files, CONCURRENCY_LIMIT, (file) => buildAdjustedPdfForFile(file, params));
}
export const adjustContrastOperationConfig = {
toolType: ToolType.custom,
customProcessor: processPdfClientSide,
operationType: 'adjustContrast',
defaultParameters,
// Single-step settings component for Automate
settingsComponentPath: 'components/tools/adjustContrast/AdjustContrastSingleStepSettings',
} as const;
export const useAdjustContrastOperation = () => {
const { t } = useTranslation();
return useToolOperation<AdjustContrastParameters>({
...adjustContrastOperationConfig,
getErrorMessage: () => t('adjustContrast.error.failed', 'Failed to adjust colors/contrast')
});
};
@@ -0,0 +1,30 @@
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface AdjustContrastParameters {
contrast: number; // 0-200 (%), 100 = neutral
brightness: number; // 0-200 (%), 100 = neutral
saturation: number; // 0-200 (%), 100 = neutral
red: number; // 0-200 (%), 100 = neutral
green: number; // 0-200 (%), 100 = neutral
blue: number; // 0-200 (%), 100 = neutral
}
export const defaultParameters: AdjustContrastParameters = {
contrast: 100,
brightness: 100,
saturation: 100,
red: 100,
green: 100,
blue: 100,
};
export type AdjustContrastParametersHook = BaseParametersHook<AdjustContrastParameters>;
export const useAdjustContrastParameters = (): AdjustContrastParametersHook => {
return useBaseParameters<AdjustContrastParameters>({
defaultParameters,
endpointName: '',
});
};
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { AdjustPageScaleParameters, defaultParameters } from '@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters';
export const buildAdjustPageScaleFormData = (parameters: AdjustPageScaleParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("scaleFactor", parameters.scaleFactor.toString());
formData.append("pageSize", parameters.pageSize);
return formData;
};
export const adjustPageScaleOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAdjustPageScaleFormData,
operationType: 'scalePages',
endpoint: '/api/v1/general/scale-pages',
defaultParameters,
} as const;
export const useAdjustPageScaleOperation = () => {
const { t } = useTranslation();
return useToolOperation<AdjustPageScaleParameters>({
...adjustPageScaleOperationConfig,
getErrorMessage: createStandardErrorHandler(t('adjustPageScale.error.failed', 'An error occurred while adjusting the page scale.'))
});
};
@@ -0,0 +1,142 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAdjustPageScaleParameters, defaultParameters, PageSize, AdjustPageScaleParametersHook } from '@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters';
describe('useAdjustPageScaleParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
expect(result.current.parameters.scaleFactor).toBe(1.0);
expect(result.current.parameters.pageSize).toBe(PageSize.KEEP);
});
test.each([
{ paramName: 'scaleFactor' as const, value: 0.5 },
{ paramName: 'scaleFactor' as const, value: 2.0 },
{ paramName: 'scaleFactor' as const, value: 10.0 },
{ paramName: 'pageSize' as const, value: PageSize.A4 },
{ paramName: 'pageSize' as const, value: PageSize.LETTER },
{ paramName: 'pageSize' as const, value: PageSize.LEGAL },
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('scaleFactor', 2.5);
result.current.updateParameter('pageSize', PageSize.A3);
});
expect(result.current.parameters.scaleFactor).toBe(2.5);
expect(result.current.parameters.pageSize).toBe(PageSize.A3);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
expect(result.current.getEndpointName()).toBe('scale-pages');
});
test.each([
{
description: 'with default parameters',
setup: () => {},
expected: true
},
{
description: 'with valid scale factor 0.1',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', 0.1);
},
expected: true
},
{
description: 'with valid scale factor 10.0',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', 10.0);
},
expected: true
},
{
description: 'with A4 page size',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('pageSize', PageSize.A4);
},
expected: true
},
{
description: 'with invalid scale factor 0',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', 0);
},
expected: false
},
{
description: 'with negative scale factor',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', -0.5);
},
expected: false
}
])('should validate parameters correctly $description', ({ setup, expected }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
act(() => {
setup(result.current);
});
expect(result.current.validateParameters()).toBe(expected);
});
test('should handle all PageSize enum values', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
Object.values(PageSize).forEach(pageSize => {
act(() => {
result.current.updateParameter('pageSize', pageSize);
});
expect(result.current.parameters.pageSize).toBe(pageSize);
expect(result.current.validateParameters()).toBe(true);
});
});
test('should handle scale factor edge cases', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
// Test very small valid scale factor
act(() => {
result.current.updateParameter('scaleFactor', 0.01);
});
expect(result.current.validateParameters()).toBe(true);
// Test scale factor just above zero
act(() => {
result.current.updateParameter('scaleFactor', 0.001);
});
expect(result.current.validateParameters()).toBe(true);
// Test exactly zero (invalid)
act(() => {
result.current.updateParameter('scaleFactor', 0);
});
expect(result.current.validateParameters()).toBe(false);
});
});
@@ -0,0 +1,37 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export enum PageSize {
KEEP = 'KEEP',
A0 = 'A0',
A1 = 'A1',
A2 = 'A2',
A3 = 'A3',
A4 = 'A4',
A5 = 'A5',
A6 = 'A6',
LETTER = 'LETTER',
LEGAL = 'LEGAL'
}
export interface AdjustPageScaleParameters extends BaseParameters {
scaleFactor: number;
pageSize: PageSize;
}
export const defaultParameters: AdjustPageScaleParameters = {
scaleFactor: 1.0,
pageSize: PageSize.KEEP,
};
export type AdjustPageScaleParametersHook = BaseParametersHook<AdjustPageScaleParameters>;
export const useAdjustPageScaleParameters = (): AdjustPageScaleParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'scale-pages',
validateFn: (params) => {
return params.scaleFactor > 0;
},
});
};
@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { AutoRenameParameters, defaultParameters } from '@app/hooks/tools/autoRename/useAutoRenameParameters';
export const getFormData = ((parameters: AutoRenameParameters) =>
Object.entries(parameters).map(([key, value]) =>
[key, value.toString()]
) as string[][]
);
// Static function that can be used by both the hook and automation executor
export const buildAutoRenameFormData = (parameters: AutoRenameParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add all permission parameters
getFormData(parameters).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
// Static configuration object
export const autoRenameOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAutoRenameFormData,
operationType: 'autoRename',
endpoint: '/api/v1/misc/auto-rename',
preserveBackendFilename: true, // Use filename from backend response headers
defaultParameters,
} as const;
export const useAutoRenameOperation = () => {
const { t } = useTranslation();
return useToolOperation({
...autoRenameOperationConfig,
getErrorMessage: createStandardErrorHandler(t('auto-rename.error.failed', 'An error occurred while auto-renaming the PDF.'))
});
};
@@ -0,0 +1,19 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface AutoRenameParameters extends BaseParameters {
useFirstTextAsFallback: boolean;
}
export const defaultParameters: AutoRenameParameters = {
useFirstTextAsFallback: false,
};
export type AutoRenameParametersHook = BaseParametersHook<AutoRenameParameters>;
export const useAutoRenameParameters = (): AutoRenameParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'auto-rename',
});
};
@@ -0,0 +1,47 @@
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { useCallback } from 'react';
import { executeAutomationSequence } from '@app/utils/automationExecutor';
import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
import { AutomateParameters } from '@app/types/automation';
export function useAutomateOperation() {
const { allTools } = useToolRegistry();
const toolRegistry = allTools;
const customProcessor = useCallback(async (params: AutomateParameters, files: File[]) => {
console.log('🚀 Starting automation execution via customProcessor', { params, files });
if (!params.automationConfig) {
throw new Error('No automation configuration provided');
}
// Execute the automation sequence and return the final results
const finalResults = await executeAutomationSequence(
params.automationConfig!,
files,
toolRegistry,
(stepIndex: number, operationName: string) => {
console.log(`Step ${stepIndex + 1} started: ${operationName}`);
params.onStepStart?.(stepIndex, operationName);
},
(stepIndex: number, resultFiles: File[]) => {
console.log(`Step ${stepIndex + 1} completed with ${resultFiles.length} files`);
params.onStepComplete?.(stepIndex, resultFiles);
},
(stepIndex: number, error: string) => {
console.error(`Step ${stepIndex + 1} failed:`, error);
params.onStepError?.(stepIndex, error);
throw new Error(`Automation step ${stepIndex + 1} failed: ${error}`);
}
);
console.log(`✅ Automation completed, returning ${finalResults.length} files`);
return finalResults;
}, [toolRegistry]);
return useToolOperation<AutomateParameters>({
toolType: ToolType.custom,
operationType: 'automate',
customProcessor,
});
}
@@ -0,0 +1,132 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { AutomationTool, AutomationConfig, AutomationMode } from '@app/types/automation';
import { AUTOMATION_CONSTANTS } from '@app/constants/automation';
import { ToolRegistry } from '@app/data/toolsTaxonomy';
import { ToolId } from "@app/types/toolId";
interface UseAutomationFormProps {
mode: AutomationMode;
existingAutomation?: AutomationConfig;
toolRegistry: Partial<ToolRegistry>;
}
export function useAutomationForm({ mode, existingAutomation, toolRegistry }: UseAutomationFormProps) {
const { t } = useTranslation();
const [automationName, setAutomationName] = useState('');
const [automationDescription, setAutomationDescription] = useState('');
const [automationIcon, setAutomationIcon] = useState<string>('');
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
const getToolName = useCallback((operation: string) => {
const tool = toolRegistry?.[operation as ToolId] as any;
return tool?.name || t(`tools.${operation}.name`, operation);
}, [toolRegistry, t]);
const getToolDefaultParameters = useCallback((operation: string): Record<string, any> => {
const config = toolRegistry[operation as ToolId]?.operationConfig;
if (config?.defaultParameters) {
return { ...config.defaultParameters };
}
return {};
}, [toolRegistry]);
// Initialize based on mode and existing automation
useEffect(() => {
if ((mode === AutomationMode.SUGGESTED || mode === AutomationMode.EDIT) && existingAutomation) {
setAutomationName(existingAutomation.name || '');
setAutomationDescription(existingAutomation.description || '');
setAutomationIcon(existingAutomation.icon || '');
const operations = existingAutomation.operations || [];
const tools = operations.map((op, index) => {
const operation = typeof op === 'string' ? op : op.operation;
const toolEntry = toolRegistry[operation as ToolId];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = mode === AutomationMode.EDIT ? true : !toolEntry?.automationSettings;
return {
id: `${operation}-${Date.now()}-${index}`,
operation: operation,
name: getToolName(operation),
configured: isConfigured,
parameters: typeof op === 'object' ? op.parameters || {} : {}
};
});
setSelectedTools(tools);
} else if (mode === AutomationMode.CREATE && selectedTools.length === 0) {
// Initialize with default empty tools for new automation
const defaultTools = Array.from({ length: AUTOMATION_CONSTANTS.DEFAULT_TOOL_COUNT }, (_, index) => ({
id: `tool-${index + 1}-${Date.now()}`,
operation: '',
name: t('automate.creation.tools.selectTool', 'Select a tool...'),
configured: false,
parameters: {}
}));
setSelectedTools(defaultTools);
}
}, [mode, existingAutomation, t, getToolName]);
const addTool = (operation: string) => {
const toolEntry = toolRegistry[operation as ToolId];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = !toolEntry?.automationSettings;
const newTool: AutomationTool = {
id: `${operation}-${Date.now()}`,
operation,
name: getToolName(operation),
configured: isConfigured,
parameters: getToolDefaultParameters(operation)
};
setSelectedTools([...selectedTools, newTool]);
};
const removeTool = (index: number) => {
if (selectedTools.length <= AUTOMATION_CONSTANTS.MIN_TOOL_COUNT) return;
setSelectedTools(selectedTools.filter((_, i) => i !== index));
};
const updateTool = (index: number, updates: Partial<AutomationTool>) => {
const updatedTools = [...selectedTools];
updatedTools[index] = { ...updatedTools[index], ...updates };
setSelectedTools(updatedTools);
};
const hasUnsavedChanges = () => {
return (
automationName.trim() !== '' ||
selectedTools.some(tool => tool.operation !== '' || tool.configured)
);
};
const canSaveAutomation = () => {
return (
automationName.trim() !== '' &&
selectedTools.length > 0 &&
selectedTools.every(tool => tool.configured && tool.operation !== '')
);
};
return {
automationName,
setAutomationName,
automationDescription,
setAutomationDescription,
automationIcon,
setAutomationIcon,
selectedTools,
setSelectedTools,
addTool,
removeTool,
updateTool,
hasUnsavedChanges,
canSaveAutomation,
getToolName,
getToolDefaultParameters
};
}
@@ -0,0 +1,94 @@
import { useState, useEffect, useCallback } from 'react';
import { AutomationConfig } from '@app/services/automationStorage';
import { SuggestedAutomation } from '@app/types/automation';
export interface SavedAutomation extends AutomationConfig {}
export function useSavedAutomations() {
const [savedAutomations, setSavedAutomations] = useState<SavedAutomation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const loadSavedAutomations = useCallback(async () => {
try {
setLoading(true);
setError(null);
const { automationStorage } = await import('@app/services/automationStorage');
const automations = await automationStorage.getAllAutomations();
setSavedAutomations(automations);
} catch (err) {
console.error('Error loading saved automations:', err);
setError(err as Error);
setSavedAutomations([]);
} finally {
setLoading(false);
}
}, []);
const refreshAutomations = useCallback(() => {
loadSavedAutomations();
}, [loadSavedAutomations]);
const deleteAutomation = useCallback(async (id: string) => {
try {
const { automationStorage } = await import('@app/services/automationStorage');
await automationStorage.deleteAutomation(id);
// Refresh the list after deletion
refreshAutomations();
} catch (err) {
console.error('Error deleting automation:', err);
throw err;
}
}, [refreshAutomations]);
const copyFromSuggested = useCallback(async (suggestedAutomation: SuggestedAutomation) => {
try {
const { automationStorage } = await import('@app/services/automationStorage');
// Map suggested automation icons to MUI icon keys
const getIconKey = (_suggestedIcon: {id: string}): string => {
// Check the automation ID or name to determine the appropriate icon
switch (suggestedAutomation.id) {
case 'secure-pdf-ingestion':
case 'secure-workflow':
return 'SecurityIcon'; // Security icon for security workflows
case 'email-preparation':
return 'CompressIcon'; // Compression icon
case 'process-images':
return 'StarIcon'; // Star icon for process images
default:
return 'SettingsIcon'; // Default fallback
}
};
// Convert suggested automation to saved automation format
const savedAutomation = {
name: suggestedAutomation.name,
description: suggestedAutomation.description,
icon: getIconKey(suggestedAutomation.icon),
operations: suggestedAutomation.operations
};
await automationStorage.saveAutomation(savedAutomation);
// Refresh the list after saving
refreshAutomations();
} catch (err) {
console.error('Error copying suggested automation:', err);
throw err;
}
}, [refreshAutomations]);
// Load automations on mount
useEffect(() => {
loadSavedAutomations();
}, [loadSavedAutomations]);
return {
savedAutomations,
loading,
error,
refreshAutomations,
deleteAutomation,
copyFromSuggested
};
}
@@ -0,0 +1,194 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import React from 'react';
import LocalIcon from '@app/components/shared/LocalIcon';
import { SuggestedAutomation } from '@app/types/automation';
import { SPLIT_METHODS } from '@app/constants/splitConstants';
// Create icon components
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' });
const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' });
export function useSuggestedAutomations(): SuggestedAutomation[] {
const { t } = useTranslation();
const suggestedAutomations = useMemo<SuggestedAutomation[]>(() => {
const now = new Date().toISOString();
return [
{
id: "secure-pdf-ingestion",
name: t("automation.suggested.securePdfIngestion", "Secure PDF Ingestion"),
description: t("automation.suggested.securePdfIngestionDesc", "Comprehensive PDF processing workflow that sanitizes documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimizes file size."),
operations: [
{
operation: "sanitize",
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: true,
removeMetadata: true,
removeLinks: false,
removeFonts: false,
}
},
{
operation: "ocr",
parameters: {
languages: ['eng'],
ocrType: 'skip-text',
ocrRenderType: 'hocr',
additionalOptions: ['clean', 'cleanFinal'],
}
},
{
operation: "convert",
parameters: {
fromExtension: 'pdf',
toExtension: 'pdfa',
pdfaOptions: {
outputFormat: 'pdfa-1',
}
}
},
{
operation: "compress",
parameters: {
compressionLevel: 5,
grayscale: false,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
fileSizeUnit: 'MB',
}
}
],
createdAt: now,
updatedAt: now,
icon: SecurityIcon,
},
{
id: "email-preparation",
name: t("automation.suggested.emailPreparation", "Email Preparation"),
description: t("automation.suggested.emailPreparationDesc", "Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy."),
operations: [
{
operation: "compress",
parameters: {
compressionLevel: 5,
grayscale: false,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
fileSizeUnit: 'MB',
}
},
{
operation: "split",
parameters: {
method: SPLIT_METHODS.BY_SIZE,
pages: '',
hDiv: '1',
vDiv: '1',
merge: false,
splitValue: '20MB',
bookmarkLevel: '1',
includeMetadata: false,
allowDuplicates: false,
duplexMode: false,
}
},
{
operation: "sanitize",
parameters: {
removeJavaScript: false,
removeEmbeddedFiles: false,
removeXMPMetadata: true,
removeMetadata: true,
removeLinks: false,
removeFonts: false,
}
}
],
createdAt: now,
updatedAt: now,
icon: CompressIcon,
},
{
id: "secure-workflow",
name: t("automation.suggested.secureWorkflow", "Security Workflow"),
description: t("automation.suggested.secureWorkflowDesc", "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access. Password is set to 'password' by default."),
operations: [
{
operation: "sanitize",
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
}
},
{
operation: "addPassword",
parameters: {
password: 'password',
ownerPassword: '',
keyLength: 128,
permissions: {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
}
}
}
],
createdAt: now,
updatedAt: now,
icon: SecurityIcon,
},
{
id: "process-images",
name: t("automation.suggested.processImages", "Process Images"),
description: t("automation.suggested.processImagesDesc", "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."),
operations: [
{
operation: "convert",
parameters: {
fromExtension: 'image',
toExtension: 'pdf',
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true,
}
}
},
{
operation: "ocr",
parameters: {
languages: ['eng'],
ocrType: 'skip-text',
ocrRenderType: 'hocr',
additionalOptions: [],
}
}
],
createdAt: now,
updatedAt: now,
icon: StarIcon,
},
];
}, [t]);
return suggestedAutomations;
}
@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { BookletImpositionParameters, defaultParameters } from '@app/hooks/tools/bookletImposition/useBookletImpositionParameters';
// Static configuration that can be used by both the hook and automation executor
export const buildBookletImpositionFormData = (parameters: BookletImpositionParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("pagesPerSheet", parameters.pagesPerSheet.toString());
formData.append("addBorder", parameters.addBorder.toString());
formData.append("spineLocation", parameters.spineLocation);
formData.append("addGutter", parameters.addGutter.toString());
formData.append("gutterSize", parameters.gutterSize.toString());
formData.append("doubleSided", parameters.doubleSided.toString());
formData.append("duplexPass", parameters.duplexPass);
formData.append("flipOnShortEdge", parameters.flipOnShortEdge.toString());
return formData;
};
// Static configuration object
export const bookletImpositionOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildBookletImpositionFormData,
operationType: 'bookletImposition',
endpoint: '/api/v1/general/booklet-imposition',
defaultParameters,
} as const;
export const useBookletImpositionOperation = () => {
const { t } = useTranslation();
return useToolOperation<BookletImpositionParameters>({
...bookletImpositionOperationConfig,
getErrorMessage: createStandardErrorHandler(t('bookletImposition.error.failed', 'An error occurred while creating the booklet imposition.'))
});
};
@@ -0,0 +1,36 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface BookletImpositionParameters extends BaseParameters {
pagesPerSheet: 2;
addBorder: boolean;
spineLocation: 'LEFT' | 'RIGHT';
addGutter: boolean;
gutterSize: number;
doubleSided: boolean;
duplexPass: 'BOTH' | 'FIRST' | 'SECOND';
flipOnShortEdge: boolean;
}
export const defaultParameters: BookletImpositionParameters = {
pagesPerSheet: 2,
addBorder: false,
spineLocation: 'LEFT',
addGutter: false,
gutterSize: 12,
doubleSided: true,
duplexPass: 'BOTH',
flipOnShortEdge: false,
};
export type BookletImpositionParametersHook = BaseParametersHook<BookletImpositionParameters>;
export const useBookletImpositionParameters = (): BookletImpositionParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'booklet-imposition',
validateFn: (params) => {
return params.pagesPerSheet === 2;
},
});
};
@@ -0,0 +1,71 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { CertSignParameters, defaultParameters } from '@app/hooks/tools/certSign/useCertSignParameters';
// Build form data for signing
export const buildCertSignFormData = (parameters: CertSignParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Handle sign mode
if (parameters.signMode === 'AUTO') {
formData.append('certType', 'SERVER');
} else {
formData.append('certType', parameters.certType);
formData.append('password', parameters.password);
// Add certificate files based on type (only for manual mode)
switch (parameters.certType) {
case 'PEM':
if (parameters.privateKeyFile) {
formData.append('privateKeyFile', parameters.privateKeyFile);
}
if (parameters.certFile) {
formData.append('certFile', parameters.certFile);
}
break;
case 'PKCS12':
if (parameters.p12File) {
formData.append('p12File', parameters.p12File);
}
break;
case 'JKS':
if (parameters.jksFile) {
formData.append('jksFile', parameters.jksFile);
}
break;
}
}
// Add signature appearance options if enabled
if (parameters.showSignature) {
formData.append('showSignature', 'true');
formData.append('reason', parameters.reason);
formData.append('location', parameters.location);
formData.append('name', parameters.name);
formData.append('pageNumber', parameters.pageNumber.toString());
formData.append('showLogo', parameters.showLogo.toString());
}
return formData;
};
// Static configuration object
export const certSignOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCertSignFormData,
operationType: 'certSign',
endpoint: '/api/v1/security/cert-sign',
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useCertSignOperation = () => {
const { t } = useTranslation();
return useToolOperation<CertSignParameters>({
...certSignOperationConfig,
getErrorMessage: createStandardErrorHandler(t('certSign.error.failed', 'An error occurred while processing signatures.'))
});
};
@@ -0,0 +1,67 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface CertSignParameters extends BaseParameters {
// Sign mode selection
signMode: 'MANUAL' | 'AUTO';
// Certificate signing options (only for manual mode)
certType: '' | 'PEM' | 'PKCS12' | 'PFX' | 'JKS';
privateKeyFile?: File;
certFile?: File;
p12File?: File;
jksFile?: File;
password: string;
// Signature appearance options
showSignature: boolean;
reason: string;
location: string;
name: string;
pageNumber: number;
showLogo: boolean;
}
export const defaultParameters: CertSignParameters = {
signMode: 'MANUAL',
certType: '',
password: '',
showSignature: false,
reason: '',
location: '',
name: '',
pageNumber: 1,
showLogo: true,
};
export type CertSignParametersHook = BaseParametersHook<CertSignParameters>;
export const useCertSignParameters = (): CertSignParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'cert-sign',
validateFn: (params) => {
// Auto mode (server certificate) - no additional validation needed
if (params.signMode === 'AUTO') {
return true;
}
// Manual mode - requires certificate type and files
if (!params.certType) {
return false;
}
// Check for required files based on cert type
switch (params.certType) {
case 'PEM':
return !!(params.privateKeyFile && params.certFile);
case 'PKCS12':
case 'PFX':
return !!params.p12File;
case 'JKS':
return !!params.jksFile;
default:
return false;
}
},
});
};
@@ -0,0 +1,144 @@
import { buildChangeMetadataFormData } from '@app/hooks/tools/changeMetadata/useChangeMetadataOperation';
import { ChangeMetadataParameters } from '@app/hooks/tools/changeMetadata/useChangeMetadataParameters';
import { TrappedStatus } from '@app/types/metadata';
import { describe, expect, test } from 'vitest';
describe('buildChangeMetadataFormData', () => {
const mockFile = new File(['test'], 'test.pdf', { type: 'application/pdf' });
const defaultParams: ChangeMetadataParameters = {
title: '',
author: '',
subject: '',
keywords: '',
creator: '',
producer: '',
creationDate: null,
modificationDate: null,
trapped: TrappedStatus.UNKNOWN,
customMetadata: [],
deleteAll: false,
};
test.each([
{
name: 'should build FormData with basic parameters',
params: {
...defaultParams,
title: 'Test Document',
author: 'John Doe',
deleteAll: true,
},
expectedFormData: {
fileInput: mockFile,
title: 'Test Document',
author: 'John Doe',
deleteAll: 'true',
},
},
{
name: 'should handle empty string values',
params: defaultParams,
expectedFormData: {
title: '',
author: '',
subject: '',
keywords: '',
creator: '',
producer: '',
creationDate: '',
modificationDate: '',
trapped: TrappedStatus.UNKNOWN,
deleteAll: 'false',
},
},
{
name: 'should include all standard metadata fields',
params: {
...defaultParams,
title: 'Test Title',
author: 'Test Author',
subject: 'Test Subject',
keywords: 'test, keywords',
creator: 'Test Creator',
producer: 'Test Producer',
creationDate: new Date('2025/01/17 14:30:00'),
modificationDate: new Date('2025/01/17 15:30:00'),
trapped: TrappedStatus.TRUE,
},
expectedFormData: {
title: 'Test Title',
author: 'Test Author',
subject: 'Test Subject',
keywords: 'test, keywords',
creator: 'Test Creator',
producer: 'Test Producer',
creationDate: '2025/01/17 14:30:00',
modificationDate: '2025/01/17 15:30:00',
trapped: TrappedStatus.TRUE,
},
},
])('$name', ({ params, expectedFormData }) => {
const formData = buildChangeMetadataFormData(params, mockFile);
Object.entries(expectedFormData).forEach(([key, value]) => {
expect(formData.get(key)).toBe(value);
});
});
test('should handle custom metadata with proper indexing', () => {
const params = {
...defaultParams,
customMetadata: [
{ key: 'Department', value: 'Engineering', id: 'custom1' },
{ key: 'Project', value: 'Test Project', id: 'custom2' },
{ key: 'Status', value: 'Draft', id: 'custom3' },
],
};
const formData = buildChangeMetadataFormData(params, mockFile);
expect(formData.get('allRequestParams[customKey1]')).toBe('Department');
expect(formData.get('allRequestParams[customValue1]')).toBe('Engineering');
expect(formData.get('allRequestParams[customKey2]')).toBe('Project');
expect(formData.get('allRequestParams[customValue2]')).toBe('Test Project');
expect(formData.get('allRequestParams[customKey3]')).toBe('Status');
expect(formData.get('allRequestParams[customValue3]')).toBe('Draft');
});
test('should skip custom metadata with empty keys or values', () => {
const params = {
...defaultParams,
customMetadata: [
{ key: 'Department', value: 'Engineering', id: 'custom1' },
{ key: '', value: 'No Key', id: 'custom2' },
{ key: 'No Value', value: '', id: 'custom3' },
{ key: ' ', value: 'Whitespace Key', id: 'custom4' },
{ key: 'Valid', value: 'Valid Value', id: 'custom5' },
],
};
const formData = buildChangeMetadataFormData(params, mockFile);
expect(formData.get('allRequestParams[customKey1]')).toBe('Department');
expect(formData.get('allRequestParams[customValue1]')).toBe('Engineering');
expect(formData.get('allRequestParams[customKey2]')).toBe('Valid');
expect(formData.get('allRequestParams[customValue2]')).toBe('Valid Value');
expect(formData.get('allRequestParams[customKey3]')).toBeNull();
expect(formData.get('allRequestParams[customKey4]')).toBeNull();
});
test('should trim whitespace from custom metadata', () => {
const params = {
...defaultParams,
customMetadata: [
{ key: ' Department ', value: ' Engineering ', id: 'custom1' },
],
};
const formData = buildChangeMetadataFormData(params, mockFile);
expect(formData.get('allRequestParams[customKey1]')).toBe('Department');
expect(formData.get('allRequestParams[customValue1]')).toBe('Engineering');
});
});
@@ -0,0 +1,71 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { ChangeMetadataParameters, defaultParameters } from '@app/hooks/tools/changeMetadata/useChangeMetadataParameters';
// Helper function to format Date object to string
const formatDateForBackend = (date: Date | null): string => {
if (!date) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
};
// Static function that can be used by both the hook and automation executor
export const buildChangeMetadataFormData = (parameters: ChangeMetadataParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Standard metadata fields
formData.append("title", parameters.title || "");
formData.append("author", parameters.author || "");
formData.append("subject", parameters.subject || "");
formData.append("keywords", parameters.keywords || "");
formData.append("creator", parameters.creator || "");
formData.append("producer", parameters.producer || "");
// Date fields - convert Date objects to strings
formData.append("creationDate", formatDateForBackend(parameters.creationDate));
formData.append("modificationDate", formatDateForBackend(parameters.modificationDate));
// Trapped status
formData.append("trapped", parameters.trapped || "");
// Delete all metadata flag
formData.append("deleteAll", parameters.deleteAll.toString());
// Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]'
let keyNumber = 0;
parameters.customMetadata.forEach((entry) => {
if (entry.key.trim() && entry.value.trim()) {
keyNumber += 1;
formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim());
formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim());
}
});
return formData;
};
// Static configuration object
export const changeMetadataOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildChangeMetadataFormData,
operationType: 'changeMetadata',
endpoint: '/api/v1/misc/update-metadata',
defaultParameters,
} as const;
export const useChangeMetadataOperation = () => {
const { t } = useTranslation();
return useToolOperation<ChangeMetadataParameters>({
...changeMetadataOperationConfig,
filePrefix: t('changeMetadata.filenamePrefix', 'metadata') + '_',
getErrorMessage: createStandardErrorHandler(t('changeMetadata.error.failed', 'An error occurred while changing the PDF metadata.'))
});
};
@@ -0,0 +1,168 @@
import { renderHook, act } from '@testing-library/react';
import { defaultParameters, useChangeMetadataParameters } from '@app/hooks/tools/changeMetadata/useChangeMetadataParameters';
import { TrappedStatus } from '@app/types/metadata';
import { describe, expect, test } from 'vitest';
describe('useChangeMetadataParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
describe('parameter updates', () => {
test.each([
{ paramName: 'title', value: 'Test Document' },
{ paramName: 'author', value: 'John Doe' },
{ paramName: 'subject', value: 'Test Subject' },
{ paramName: 'keywords', value: 'test, metadata' },
{ paramName: 'creator', value: 'Test Creator' },
{ paramName: 'producer', value: 'Test Producer' },
{ paramName: 'creationDate', value: new Date('2025/01/17 14:30:00') },
{ paramName: 'modificationDate', value: new Date('2025/01/17 15:30:00') },
{ paramName: 'trapped', value: TrappedStatus.TRUE },
{ paramName: 'deleteAll', value: true },
] as const)('should update $paramName parameter', ({ paramName, value }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
});
describe('validation', () => {
test.each([
{ description: 'deleteAll is true', updates: { deleteAll: true }, expected: true },
{ description: 'has title', updates: { title: 'Test Document' }, expected: true },
{ description: 'has author', updates: { author: 'John Doe' }, expected: true },
{ description: 'has subject', updates: { subject: 'Test Subject' }, expected: true },
{ description: 'has keywords', updates: { keywords: 'test' }, expected: true },
{ description: 'has creator', updates: { creator: 'Test Creator' }, expected: true },
{ description: 'has producer', updates: { producer: 'Test Producer' }, expected: true },
{ description: 'has creation date', updates: { creationDate: new Date('2025/01/17 14:30:00') }, expected: true },
{ description: 'has modification date', updates: { modificationDate: new Date('2025/01/17 14:30:00') }, expected: true },
{ description: 'has trapped status', updates: { trapped: TrappedStatus.TRUE }, expected: true },
{ description: 'no meaningful content', updates: {}, expected: false },
{ description: 'whitespace only', updates: { title: ' ', author: ' ' }, expected: false },
])('should validate correctly when $description', ({ updates, expected }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
Object.entries(updates).forEach(([key, value]) => {
result.current.updateParameter(key as keyof typeof updates, value);
});
});
expect(result.current.validateParameters()).toBe(expected);
});
test.each([
{ description: 'valid creation date', updates: { title: 'Test', creationDate: new Date('2025/01/17 14:30:00') }, expected: true },
{ description: 'valid modification date', updates: { title: 'Test', modificationDate: new Date('2025/01/17 14:30:00') }, expected: true },
{ description: 'empty dates are valid', updates: { title: 'Test', creationDate: null, modificationDate: null }, expected: true },
])('should validate dates correctly with $description', ({ updates, expected }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
Object.entries(updates).forEach(([key, value]) => {
result.current.updateParameter(key as keyof typeof updates, value);
});
});
expect(result.current.validateParameters()).toBe(expected);
});
});
describe('custom metadata', () => {
test('should add custom metadata with sequential IDs', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
expect(result.current.parameters.customMetadata).toHaveLength(1);
expect(result.current.parameters.customMetadata[0]).toEqual({
key: '',
value: '',
id: expect.stringMatching(/^custom\d+$/)
});
});
test('should remove custom metadata by ID', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
const customId = result.current.parameters.customMetadata[0].id;
act(() => {
result.current.removeCustomMetadata(customId);
});
expect(result.current.parameters.customMetadata).toHaveLength(0);
});
test('should update custom metadata by ID', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
const customId = result.current.parameters.customMetadata[0].id;
act(() => {
result.current.updateCustomMetadata(customId, 'Department', 'Engineering');
});
expect(result.current.parameters.customMetadata[0]).toEqual({
key: 'Department',
value: 'Engineering',
id: customId
});
});
test('should validate with custom metadata', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
});
const customId = result.current.parameters.customMetadata[0].id;
act(() => {
result.current.updateCustomMetadata(customId, 'Department', 'Engineering');
});
expect(result.current.validateParameters()).toBe(true);
});
test('should generate unique IDs for multiple custom entries', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
for (let i = 0; i < 3; i++) {
act(() => {
result.current.addCustomMetadata();
});
}
const ids = result.current.parameters.customMetadata.map(entry => entry.id);
expect(ids).toHaveLength(3);
expect(new Set(ids).size).toBe(3); // All unique
expect(ids.every(id => id.startsWith('custom'))).toBe(true);
});
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
expect(result.current.getEndpointName()).toBe('update-metadata');
});
});
@@ -0,0 +1,136 @@
import { BaseParameters } from '@app/types/parameters';
import { TrappedStatus, CustomMetadataEntry } from '@app/types/metadata';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface ChangeMetadataParameters extends BaseParameters {
// Standard PDF metadata fields
title: string;
author: string;
subject: string;
keywords: string;
creator: string;
producer: string;
// Date fields
creationDate: Date | null;
modificationDate: Date | null;
// Trapped status
trapped: TrappedStatus;
// Custom metadata entries
customMetadata: CustomMetadataEntry[];
// Delete all metadata option
deleteAll: boolean;
}
export const defaultParameters: ChangeMetadataParameters = {
title: '',
author: '',
subject: '',
keywords: '',
creator: '',
producer: '',
creationDate: null,
modificationDate: null,
trapped: TrappedStatus.UNKNOWN,
customMetadata: [],
deleteAll: false,
};
// Global counter for custom metadata IDs
let customMetadataIdCounter = 1;
// Utility functions that can work with external parameters
export const createCustomMetadataFunctions = (
parameters: ChangeMetadataParameters,
onParameterChange: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void
) => {
const addCustomMetadata = (key: string = '', value: string = '') => {
const newEntry: CustomMetadataEntry = {
key,
value,
id: `custom${customMetadataIdCounter++}`,
};
onParameterChange('customMetadata', [
...parameters.customMetadata,
newEntry,
]);
};
const removeCustomMetadata = (id: string) => {
onParameterChange('customMetadata',
parameters.customMetadata.filter(entry => entry.id !== id)
);
};
const updateCustomMetadata = (id: string, key: string, value: string) => {
onParameterChange('customMetadata',
parameters.customMetadata.map(entry =>
entry.id === id ? { ...entry, key, value } : entry
)
);
};
return {
addCustomMetadata,
removeCustomMetadata,
updateCustomMetadata
};
};
// Validation function
const validateParameters = (params: ChangeMetadataParameters): boolean => {
// If deleteAll is true, no other validation needed
if (params.deleteAll) {
return true;
}
// At least one field should have content for the operation to be meaningful
const hasStandardMetadata = !!(
params.title.trim()
|| params.author.trim()
|| params.subject.trim()
|| params.keywords.trim()
|| params.creator.trim()
|| params.producer.trim()
|| params.creationDate
|| params.modificationDate
|| params.trapped !== TrappedStatus.UNKNOWN
);
const hasCustomMetadata = params.customMetadata.some(
entry => entry.key.trim() && entry.value.trim()
);
return hasStandardMetadata || hasCustomMetadata;
};
export type ChangeMetadataParametersHook = BaseParametersHook<ChangeMetadataParameters> & {
addCustomMetadata: (key?: string, value?: string) => void;
removeCustomMetadata: (id: string) => void;
updateCustomMetadata: (id: string, key: string, value: string) => void;
};
export const useChangeMetadataParameters = (): ChangeMetadataParametersHook => {
const base = useBaseParameters({
defaultParameters,
endpointName: 'update-metadata',
validateFn: validateParameters,
});
// Use the utility functions with the hook's parameters and updateParameter
const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } = createCustomMetadataFunctions(
base.parameters,
base.updateParameter,
);
return {
...base,
addCustomMetadata,
removeCustomMetadata,
updateCustomMetadata
};
};
@@ -0,0 +1,70 @@
import { useState, useEffect, useRef } from "react";
import { extractPDFMetadata } from "@app/services/pdfMetadataService";
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
interface MetadataExtractionParams {
updateParameter: <K extends keyof ChangeMetadataParameters>(key: K, value: ChangeMetadataParameters[K]) => void;
}
export const useMetadataExtraction = (params: MetadataExtractionParams) => {
const { selectedFiles } = useSelectedFiles();
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
const [hasExtractedMetadata, setHasExtractedMetadata] = useState(false);
const previousFileCountRef = useRef(0);
// Reset extraction state only when files are cleared (length goes to 0)
useEffect(() => {
if (previousFileCountRef.current > 0 && selectedFiles.length === 0) {
setHasExtractedMetadata(false);
}
previousFileCountRef.current = selectedFiles.length;
}, [selectedFiles]);
// Extract metadata from first file when files change
useEffect(() => {
const extractMetadata = async () => {
if (selectedFiles.length === 0) {
return;
}
const firstFile = selectedFiles[0];
if (hasExtractedMetadata) {
return;
}
setIsExtractingMetadata(true);
const result = await extractPDFMetadata(firstFile);
if (result.success) {
const metadata = result.metadata;
// Pre-populate all fields with extracted metadata
params.updateParameter('title', metadata.title);
params.updateParameter('author', metadata.author);
params.updateParameter('subject', metadata.subject);
params.updateParameter('keywords', metadata.keywords);
params.updateParameter('creator', metadata.creator);
params.updateParameter('producer', metadata.producer);
params.updateParameter('creationDate', metadata.creationDate ? new Date(metadata.creationDate) : null);
params.updateParameter('modificationDate', metadata.modificationDate ? new Date(metadata.modificationDate) : null);
params.updateParameter('trapped', metadata.trapped);
params.updateParameter('customMetadata', metadata.customMetadata);
setHasExtractedMetadata(true);
} else {
console.warn('Failed to extract metadata:', result.error);
}
setIsExtractingMetadata(false);
};
extractMetadata();
}, [selectedFiles, hasExtractedMetadata, params]);
return {
isExtractingMetadata,
hasExtractedMetadata,
};
};
@@ -0,0 +1,123 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useChangePermissionsOperation } from '@app/hooks/tools/changePermissions/useChangePermissionsOperation';
import type { ChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Mock the error handler
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
}));
// Import the mocked function
import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
describe('useChangePermissionsOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<ChangePermissionsParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: '',
isLoading: false,
errorMessage: null,
status: '',
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test.each([
{
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
},
{
preventAssembly: true,
preventExtractContent: false,
preventExtractForAccessibility: true,
preventFillInForm: false,
preventModify: true,
preventModifyAnnotations: false,
preventPrinting: true,
preventPrintingFaithful: false,
},
{
preventAssembly: true,
preventExtractContent: true,
preventExtractForAccessibility: true,
preventFillInForm: true,
preventModify: true,
preventModifyAnnotations: true,
preventPrinting: true,
preventPrintingFaithful: true,
},
])('should create form data correctly', (testParameters: ChangePermissionsParameters) => {
renderHook(() => useChangePermissionsOperation());
const callArgs = getToolConfig();
const buildFormData = callArgs.buildFormData;
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const formData = buildFormData(testParameters, testFile);
// Verify the form data contains the file
expect(formData.get('fileInput')).toBe(testFile);
(Object.keys(testParameters) as Array<keyof ChangePermissionsParameters>).forEach(key => {
expect(formData.get(key), `Parameter ${key} should be set correctly`).toBe(testParameters[key].toString());
});
});
test('should use correct translation for error messages', () => {
renderHook(() => useChangePermissionsOperation());
expect(mockT).toHaveBeenCalledWith(
'changePermissions.error.failed',
'An error occurred while changing PDF permissions.'
);
});
test.each([
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
{ property: 'operationType' as const, expectedValue: 'changePermissions' }
])('should configure $property correctly', ({ property, expectedValue }) => {
renderHook(() => useChangePermissionsOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { ChangePermissionsParameters, defaultParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
export const getFormData = ((parameters: ChangePermissionsParameters) =>
Object.entries(parameters).map(([key, value]) =>
[key, value.toString()]
) as string[][]
);
// Static function that can be used by both the hook and automation executor
export const buildChangePermissionsFormData = (parameters: ChangePermissionsParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add all permission parameters
getFormData(parameters).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
// Static configuration object
export const changePermissionsOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildChangePermissionsFormData,
operationType: 'changePermissions',
endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool
defaultParameters,
} as const;
export const useChangePermissionsOperation = () => {
const { t } = useTranslation();
return useToolOperation({
...changePermissionsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('changePermissions.error.failed', 'An error occurred while changing PDF permissions.')
),
});
};
@@ -0,0 +1,110 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useChangePermissionsParameters, defaultParameters, ChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
describe('useChangePermissionsParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update individual boolean parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
act(() => {
result.current.updateParameter('preventAssembly', true);
});
expect(result.current.parameters.preventAssembly).toBe(true);
expect(result.current.parameters.preventPrinting).toBe(false); // Other parameters should remain unchanged
act(() => {
result.current.updateParameter('preventPrinting', true);
});
expect(result.current.parameters.preventPrinting).toBe(true);
expect(result.current.parameters.preventAssembly).toBe(true);
});
test('should update all permission parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
// Set all to true
act(() => {
permissionKeys.forEach(key => {
result.current.updateParameter(key, true);
});
});
permissionKeys.forEach(key => {
expect(result.current.parameters[key]).toBe(true);
});
// Set all to false
act(() => {
permissionKeys.forEach(key => {
result.current.updateParameter(key, false);
});
});
permissionKeys.forEach(key => {
expect(result.current.parameters[key]).toBe(false);
});
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('preventAssembly', true);
result.current.updateParameter('preventPrinting', true);
result.current.updateParameter('preventModify', true);
});
expect(result.current.parameters.preventAssembly).toBe(true);
expect(result.current.parameters.preventPrinting).toBe(true);
expect(result.current.parameters.preventModify).toBe(true);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
expect(result.current.getEndpointName()).toBe('add-password');
});
test('should always validate as true', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
// Set some restrictions - should still be valid
act(() => {
result.current.updateParameter('preventAssembly', true);
result.current.updateParameter('preventPrinting', true);
});
expect(result.current.validateParameters()).toBe(true);
// Set all restrictions - should still be valid
act(() => {
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
permissionKeys.forEach(key => {
result.current.updateParameter(key, true);
});
});
expect(result.current.validateParameters()).toBe(true);
});
});
@@ -0,0 +1,33 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface ChangePermissionsParameters extends BaseParameters {
preventAssembly: boolean;
preventExtractContent: boolean;
preventExtractForAccessibility: boolean;
preventFillInForm: boolean;
preventModify: boolean;
preventModifyAnnotations: boolean;
preventPrinting: boolean;
preventPrintingFaithful: boolean;
}
export const defaultParameters: ChangePermissionsParameters = {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
};
export type ChangePermissionsParametersHook = BaseParametersHook<ChangePermissionsParameters>;
export const useChangePermissionsParameters = (): ChangePermissionsParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'add-password', // Change Permissions is a fake endpoint for the Add Password tool
});
};
@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { CompressParameters, defaultParameters } from '@app/hooks/tools/compress/useCompressParameters';
// Static configuration that can be used by both the hook and automation executor
export const buildCompressFormData = (parameters: CompressParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
if (parameters.compressionMethod === 'quality') {
formData.append("optimizeLevel", parameters.compressionLevel.toString());
} else {
// File size method
const fileSize = parameters.fileSizeValue ? `${parameters.fileSizeValue}${parameters.fileSizeUnit}` : '';
if (fileSize) {
formData.append("expectedOutputSize", fileSize);
}
}
formData.append("grayscale", parameters.grayscale.toString());
return formData;
};
// Static configuration object
export const compressOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCompressFormData,
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
defaultParameters,
} as const;
export const useCompressOperation = () => {
const { t } = useTranslation();
return useToolOperation<CompressParameters>({
...compressOperationConfig,
getErrorMessage: createStandardErrorHandler(t('compress.error.failed', 'An error occurred while compressing the PDF.'))
});
};
@@ -0,0 +1,33 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface CompressParameters extends BaseParameters {
compressionLevel: number;
grayscale: boolean;
expectedSize: string;
compressionMethod: 'quality' | 'filesize';
fileSizeValue: string;
fileSizeUnit: 'KB' | 'MB';
}
export const defaultParameters: CompressParameters = {
compressionLevel: 5,
grayscale: false,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
fileSizeUnit: 'MB',
};
export type CompressParametersHook = BaseParametersHook<CompressParameters>;
export const useCompressParameters = (): CompressParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'compress-pdf',
validateFn: (params) => {
// For compression, we only need to validate that compression level is within range
return params.compressionLevel >= 1 && params.compressionLevel <= 9;
},
});
};
@@ -0,0 +1,167 @@
import { useCallback } from 'react';
import apiClient from '@app/services/apiClient';
import { useTranslation } from 'react-i18next';
import { ConvertParameters, defaultParameters } from '@app/hooks/tools/convert/useConvertParameters';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { getEndpointUrl, isImageFormat, isWebFormat } from '@app/utils/convertUtils';
// Static function that can be used by both the hook and automation executor
export const shouldProcessFilesSeparately = (
selectedFiles: File[],
parameters: ConvertParameters
): boolean => {
return selectedFiles.length > 1 && (
// Image to PDF with combineImages = false
((isImageFormat(parameters.fromExtension) || parameters.fromExtension === 'image') &&
parameters.toExtension === 'pdf' && !parameters.imageOptions.combineImages) ||
// PDF to image conversions (each PDF should generate its own image file)
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
// PDF to PDF/A conversions (each PDF should be processed separately)
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') ||
// PDF to text-like formats should be one output per input
(parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv'].includes(parameters.toExtension)) ||
// Web files to PDF conversions (each web file should generate its own PDF)
((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') &&
parameters.toExtension === 'pdf') ||
// Web files smart detection
(parameters.isSmartDetection && parameters.smartDetectionType === 'web') ||
// Mixed file types (smart detection)
(parameters.isSmartDetection && parameters.smartDetectionType === 'mixed')
);
};
// Static function that can be used by both the hook and automation executor
export const buildConvertFormData = (parameters: ConvertParameters, selectedFiles: File[]): FormData => {
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append("fileInput", file);
});
const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions } = parameters;
if (isImageFormat(toExtension)) {
formData.append("imageFormat", toExtension);
formData.append("colorType", imageOptions.colorType);
formData.append("dpi", imageOptions.dpi.toString());
formData.append("singleOrMultiple", imageOptions.singleOrMultiple);
} else if (fromExtension === 'pdf' && ['docx', 'odt'].includes(toExtension)) {
formData.append("outputFormat", toExtension);
} else if (fromExtension === 'pdf' && ['pptx', 'odp'].includes(toExtension)) {
formData.append("outputFormat", toExtension);
} else if (fromExtension === 'pdf' && ['txt', 'rtf'].includes(toExtension)) {
formData.append("outputFormat", toExtension);
} else if ((isImageFormat(fromExtension) || fromExtension === 'image') && toExtension === 'pdf') {
formData.append("fitOption", imageOptions.fitOption);
formData.append("colorType", imageOptions.colorType);
formData.append("autoRotate", imageOptions.autoRotate.toString());
} else if ((fromExtension === 'html' || fromExtension === 'zip') && toExtension === 'pdf') {
formData.append("zoom", htmlOptions.zoomLevel.toString());
} else if (fromExtension === 'eml' && toExtension === 'pdf') {
formData.append("includeAttachments", emailOptions.includeAttachments.toString());
formData.append("maxAttachmentSizeMB", emailOptions.maxAttachmentSizeMB.toString());
formData.append("downloadHtml", emailOptions.downloadHtml.toString());
formData.append("includeAllRecipients", emailOptions.includeAllRecipients.toString());
} else if (fromExtension === 'pdf' && toExtension === 'pdfa') {
formData.append("outputFormat", pdfaOptions.outputFormat);
} else if (fromExtension === 'pdf' && toExtension === 'csv') {
formData.append("pageNumbers", "all");
}
return formData;
};
// Static function that can be used by both the hook and automation executor
export const createFileFromResponse = (
responseData: any,
headers: any,
originalFileName: string,
targetExtension: string
): File => {
const originalName = originalFileName.split('.')[0];
if (targetExtension == 'pdfa') {
targetExtension = 'pdf';
}
const fallbackFilename = `${originalName}.${targetExtension}`;
return createFileFromApiResponse(responseData, headers, fallbackFilename);
};
// Static processor that can be used by both the hook and automation executor
export const convertProcessor = async (
parameters: ConvertParameters,
selectedFiles: File[]
): Promise<File[]> => {
const processedFiles: File[] = [];
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
if (!endpoint) {
throw new Error('Unsupported conversion format');
}
// Convert-specific routing logic: decide batch vs individual processing
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
// Individual processing for complex cases (PDF→image, smart detection, etc.)
for (const file of selectedFiles) {
try {
const formData = buildConvertFormData(parameters, [file]);
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension);
processedFiles.push(convertedFile);
} catch (error) {
console.warn(`Failed to convert file ${file.name}:`, error);
}
}
} else {
// Batch processing for simple cases (image→PDF combine)
const formData = buildConvertFormData(parameters, selectedFiles);
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
const baseFilename = selectedFiles.length === 1
? selectedFiles[0].name
: 'converted_files';
const convertedFile = createFileFromResponse(response.data, response.headers, baseFilename, parameters.toExtension);
processedFiles.push(convertedFile);
}
return processedFiles;
};
// Static configuration object
export const convertOperationConfig = {
toolType: ToolType.custom,
customProcessor: convertProcessor, // Can't use callback version here
operationType: 'convert',
defaultParameters,
} as const;
export const useConvertOperation = () => {
const { t } = useTranslation();
const customConvertProcessor = useCallback(async (
parameters: ConvertParameters,
selectedFiles: File[]
): Promise<File[]> => {
return convertProcessor(parameters, selectedFiles);
}, []);
return useToolOperation<ConvertParameters>({
...convertOperationConfig,
customProcessor: customConvertProcessor, // Use instance-specific processor for translation support
getErrorMessage: (error) => {
if (error.response?.data && typeof error.response.data === 'string') {
return error.response.data;
}
if (error.message) {
return error.message;
}
return t("convert.errorConversion", "An error occurred while converting the file.");
},
});
};
@@ -0,0 +1,230 @@
/**
* Unit tests for useConvertParameters hook
*/
import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
import { FIT_OPTIONS } from '@app/constants/convertConstants';
describe('useConvertParameters', () => {
describe('Parameter Management', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useConvertParameters());
expect(result.current.parameters.fromExtension).toBe('');
expect(result.current.parameters.toExtension).toBe('');
expect(result.current.parameters.imageOptions.colorType).toBe('color');
expect(result.current.parameters.imageOptions.dpi).toBe(300);
expect(result.current.parameters.imageOptions.singleOrMultiple).toBe('multiple');
expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.0);
expect(result.current.parameters.emailOptions.includeAttachments).toBe(true);
expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(10);
expect(result.current.parameters.emailOptions.downloadHtml).toBe(false);
expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(false);
expect(result.current.parameters.pdfaOptions.outputFormat).toBe('pdfa-1');
});
test('should update individual parameters', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
});
expect(result.current.parameters.fromExtension).toBe('pdf');
expect(result.current.parameters.toExtension).toBe(''); // Should not affect other params
});
test('should update nested image options', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('imageOptions', {
colorType: 'grayscale',
dpi: 150,
singleOrMultiple: 'single',
fitOption: FIT_OPTIONS.FILL_PAGE,
autoRotate: false,
combineImages: false,
});
});
expect(result.current.parameters.imageOptions.colorType).toBe('grayscale');
expect(result.current.parameters.imageOptions.dpi).toBe(150);
expect(result.current.parameters.imageOptions.singleOrMultiple).toBe('single');
expect(result.current.parameters.imageOptions.fitOption).toBe(FIT_OPTIONS.FILL_PAGE);
expect(result.current.parameters.imageOptions.autoRotate).toBe(false);
expect(result.current.parameters.imageOptions.combineImages).toBe(false);
});
test('should update nested HTML options', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('htmlOptions', {
zoomLevel: 1.5
});
});
expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.5);
});
test('should update nested email options', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('emailOptions', {
includeAttachments: false,
maxAttachmentSizeMB: 20,
downloadHtml: true,
includeAllRecipients: true
});
});
expect(result.current.parameters.emailOptions.includeAttachments).toBe(false);
expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(20);
expect(result.current.parameters.emailOptions.downloadHtml).toBe(true);
expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(true);
});
test('should update nested PDF/A options', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('pdfaOptions', {
outputFormat: 'pdfa'
});
});
expect(result.current.parameters.pdfaOptions.outputFormat).toBe('pdfa');
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
result.current.updateParameter('toExtension', 'png');
});
expect(result.current.parameters.fromExtension).toBe('pdf');
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters.fromExtension).toBe('');
expect(result.current.parameters.toExtension).toBe('');
});
});
describe('Parameter Validation', () => {
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useConvertParameters());
// No parameters - should be invalid
expect(result.current.validateParameters()).toBe(false);
// Only fromExtension - should be invalid
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
});
expect(result.current.validateParameters()).toBe(false);
// Both extensions with supported conversion - should be valid
act(() => {
result.current.updateParameter('toExtension', 'png');
});
expect(result.current.validateParameters()).toBe(true);
});
test('should validate unsupported conversions', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
result.current.updateParameter('toExtension', 'unsupported');
});
expect(result.current.validateParameters()).toBe(false);
});
});
describe('Endpoint Generation', () => {
test('should generate correct endpoint names', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
result.current.updateParameter('toExtension', 'png');
});
const endpointName = result.current.getEndpointName();
expect(endpointName).toBe('pdf-to-img');
});
test('should generate correct endpoint URLs', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
result.current.updateParameter('toExtension', 'png');
});
const endpoint = result.current.getEndpoint();
expect(endpoint).toBe('/api/v1/convert/pdf/img');
});
test('should return empty strings for invalid conversions', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'invalid');
result.current.updateParameter('toExtension', 'invalid');
});
expect(result.current.getEndpointName()).toBe('');
expect(result.current.getEndpoint()).toBe('');
});
});
describe('Available Extensions', () => {
test('should return available extensions for valid source format', () => {
const { result } = renderHook(() => useConvertParameters());
const availableExtensions = result.current.getAvailableToExtensions('pdf');
expect(availableExtensions.length).toBeGreaterThan(0);
expect(availableExtensions.some(ext => ext.value === 'png')).toBe(true);
expect(availableExtensions.some(ext => ext.value === 'jpg')).toBe(true);
});
test('should return empty array for invalid source format', () => {
const { result } = renderHook(() => useConvertParameters());
const availableExtensions = result.current.getAvailableToExtensions('invalid');
expect(availableExtensions).toEqual([{
"group": "Document",
"label": "PDF",
"value": "pdf",
}]);
});
test('should return empty array for empty source format', () => {
const { result } = renderHook(() => useConvertParameters());
const availableExtensions = result.current.getAvailableToExtensions('');
expect(availableExtensions).toEqual([]);
});
});
});
@@ -0,0 +1,355 @@
import {
COLOR_TYPES,
OUTPUT_OPTIONS,
FIT_OPTIONS,
CONVERSION_MATRIX,
type ColorType,
type OutputOption,
type FitOption
} from '@app/constants/convertConstants';
import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat, isWebFormat, getAvailableToExtensions as getAvailableToExtensionsUtil } from '@app/utils/convertUtils';
import { detectFileExtension as detectFileExtensionUtil } from '@app/utils/fileUtils';
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { useCallback, useMemo } from 'react';
export interface ConvertParameters extends BaseParameters {
fromExtension: string;
toExtension: string;
imageOptions: {
colorType: ColorType;
dpi: number;
singleOrMultiple: OutputOption;
fitOption: FitOption;
autoRotate: boolean;
combineImages: boolean;
};
htmlOptions: {
zoomLevel: number;
};
emailOptions: {
includeAttachments: boolean;
maxAttachmentSizeMB: number;
downloadHtml: boolean;
includeAllRecipients: boolean;
};
pdfaOptions: {
outputFormat: string;
};
isSmartDetection: boolean;
smartDetectionType: 'mixed' | 'images' | 'web' | 'none';
}
export interface ConvertParametersHook extends BaseParametersHook<ConvertParameters> {
getEndpoint: () => string;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
analyzeFileTypes: (files: Array<{name: string}>) => void;
}
export const defaultParameters: ConvertParameters = {
fromExtension: '',
toExtension: '',
imageOptions: {
colorType: COLOR_TYPES.COLOR,
dpi: 300,
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
fitOption: FIT_OPTIONS.MAINTAIN_ASPECT,
autoRotate: true,
combineImages: true,
},
htmlOptions: {
zoomLevel: 1.0,
},
emailOptions: {
includeAttachments: true,
maxAttachmentSizeMB: 10,
downloadHtml: false,
includeAllRecipients: false,
},
pdfaOptions: {
outputFormat: 'pdfa-1',
},
isSmartDetection: false,
smartDetectionType: 'none',
};
const validateParameters = (params: ConvertParameters): boolean => {
const { fromExtension, toExtension } = params;
if (!fromExtension || !toExtension) return false;
// Handle dynamic format identifiers (file-<extension>)
let supportedToExtensions: string[] = [];
if (fromExtension.startsWith('file-')) {
// Dynamic format - use 'any' conversion options
supportedToExtensions = CONVERSION_MATRIX['any'] || [];
} else {
// Regular format - check conversion matrix
supportedToExtensions = CONVERSION_MATRIX[fromExtension] || [];
}
if (!supportedToExtensions.includes(toExtension)) {
return false;
}
return true;
};
const getEndpointName = (params: ConvertParameters): string => {
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = params;
if (isSmartDetection) {
if (smartDetectionType === 'mixed') {
// Mixed file types -> PDF using file-to-pdf endpoint
return 'file-to-pdf';
} else if (smartDetectionType === 'images') {
// All images -> PDF using img-to-pdf endpoint
return 'img-to-pdf';
} else if (smartDetectionType === 'web') {
// All web files -> PDF using html-to-pdf endpoint
return 'html-to-pdf';
}
}
// Handle dynamic format identifiers (file-<extension>)
if (fromExtension.startsWith('file-')) {
// Dynamic format - use file-to-pdf endpoint
return 'file-to-pdf';
}
return getEndpointNameUtil(fromExtension, toExtension);
};
export const useConvertParameters = (): ConvertParametersHook => {
const config = useMemo(() => ({
defaultParameters,
endpointName: getEndpointName,
validateFn: validateParameters,
}), []);
const baseHook = useBaseParameters(config);
const getEndpoint = () => {
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = baseHook.parameters;
if (isSmartDetection) {
if (smartDetectionType === 'mixed') {
// Mixed file types -> PDF using file-to-pdf endpoint
return '/api/v1/convert/file/pdf';
} else if (smartDetectionType === 'images') {
// All images -> PDF using img-to-pdf endpoint
return '/api/v1/convert/img/pdf';
} else if (smartDetectionType === 'web') {
// All web files -> PDF using html-to-pdf endpoint
return '/api/v1/convert/html/pdf';
}
}
// Handle dynamic format identifiers (file-<extension>)
if (fromExtension.startsWith('file-')) {
// Dynamic format - use file-to-pdf endpoint
return '/api/v1/convert/file/pdf';
}
return getEndpointUrl(fromExtension, toExtension);
};
const getAvailableToExtensions = getAvailableToExtensionsUtil;
const analyzeFileTypes = useCallback((files: Array<{name: string}>) => {
if (files.length === 0) {
// No files - only reset smart detection, keep user's format choices
baseHook.setParameters(prev => {
// Only update if something actually changed
if (prev.isSmartDetection === false && prev.smartDetectionType === 'none') {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: false,
smartDetectionType: 'none'
// Don't reset fromExtension and toExtension - let user keep their choices
};
});
return;
}
if (files.length === 1) {
// Single file - use regular detection with smart target selection
const detectedExt = detectFileExtensionUtil(files[0].name);
let fromExt = detectedExt;
let availableTargets = detectedExt ? CONVERSION_MATRIX[detectedExt] || [] : [];
// If no explicit conversion exists for this file type, create a dynamic format entry
// and fall back to 'any' conversion logic for the actual endpoint
if (availableTargets.length === 0 && detectedExt) {
fromExt = `file-${detectedExt}`; // Create dynamic format identifier
availableTargets = CONVERSION_MATRIX['any'] || [];
} else if (availableTargets.length === 0) {
// No extension detected - fall back to 'any'
fromExt = 'any';
availableTargets = CONVERSION_MATRIX['any'] || [];
}
baseHook.setParameters(prev => {
// Check if current toExtension is still valid for the new fromExtension
const currentToExt = prev.toExtension;
const isCurrentToExtValid = availableTargets.includes(currentToExt);
// Auto-select target only if:
// 1. No current target is set, OR
// 2. Current target is invalid for new source type, OR
// 3. There's only one possible target (forced conversion)
let newToExtension = currentToExt;
if (!currentToExt || !isCurrentToExtValid) {
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
}
const newState = {
...prev,
isSmartDetection: false,
smartDetectionType: 'none' as const,
fromExtension: fromExt,
toExtension: newToExtension
};
// Only update if something actually changed
if (
prev.isSmartDetection === newState.isSmartDetection &&
prev.smartDetectionType === newState.smartDetectionType &&
prev.fromExtension === newState.fromExtension &&
prev.toExtension === newState.toExtension
) {
return prev; // Return the same object to prevent re-render
}
return newState;
});
return;
}
// Multiple files - analyze file types
const extensions = files.map(file => detectFileExtensionUtil(file.name));
const uniqueExtensions = [...new Set(extensions)];
if (uniqueExtensions.length === 1) {
// All files are the same type - use regular detection with smart target selection
const detectedExt = uniqueExtensions[0];
let fromExt = detectedExt;
let availableTargets = CONVERSION_MATRIX[detectedExt] || [];
// If no explicit conversion exists for this file type, fall back to 'any'
if (availableTargets.length === 0) {
fromExt = 'any';
availableTargets = CONVERSION_MATRIX['any'] || [];
}
baseHook.setParameters(prev => {
// Check if current toExtension is still valid for the new fromExtension
const currentToExt = prev.toExtension;
const isCurrentToExtValid = availableTargets.includes(currentToExt);
// Auto-select target only if:
// 1. No current target is set, OR
// 2. Current target is invalid for new source type, OR
// 3. There's only one possible target (forced conversion)
let newToExtension = currentToExt;
if (!currentToExt || !isCurrentToExtValid) {
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
}
const newState = {
...prev,
isSmartDetection: false,
smartDetectionType: 'none' as const,
fromExtension: fromExt,
toExtension: newToExtension
};
// Only update if something actually changed
if (
prev.isSmartDetection === newState.isSmartDetection &&
prev.smartDetectionType === newState.smartDetectionType &&
prev.fromExtension === newState.fromExtension &&
prev.toExtension === newState.toExtension
) {
return prev; // Return the same object to prevent re-render
}
return newState;
});
} else {
// Mixed file types
const allImages = uniqueExtensions.every(ext => isImageFormat(ext));
const allWeb = uniqueExtensions.every(ext => isWebFormat(ext));
if (allImages) {
// All files are images - use image-to-pdf conversion
baseHook.setParameters(prev => {
// Only update if something actually changed
if (prev.isSmartDetection === true &&
prev.smartDetectionType === 'images' &&
prev.fromExtension === 'image' &&
prev.toExtension === 'pdf') {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: true,
smartDetectionType: 'images',
fromExtension: 'image',
toExtension: 'pdf'
};
});
} else if (allWeb) {
// All files are web files - use html-to-pdf conversion
baseHook.setParameters(prev => {
// Only update if something actually changed
if (prev.isSmartDetection === true &&
prev.smartDetectionType === 'web' &&
prev.fromExtension === 'html' &&
prev.toExtension === 'pdf') {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: true,
smartDetectionType: 'web',
fromExtension: 'html',
toExtension: 'pdf'
};
});
} else {
// Mixed non-image types - use file-to-pdf conversion
baseHook.setParameters(prev => {
// Only update if something actually changed
if (prev.isSmartDetection === true &&
prev.smartDetectionType === 'mixed' &&
prev.fromExtension === 'any' &&
prev.toExtension === 'pdf') {
return prev; // No change needed
}
return {
...prev,
isSmartDetection: true,
smartDetectionType: 'mixed',
fromExtension: 'any',
toExtension: 'pdf'
};
});
}
}
}, [baseHook.setParameters]);
return {
...baseHook,
getEndpoint,
getAvailableToExtensions,
analyzeFileTypes,
};
};
@@ -0,0 +1,365 @@
/**
* Tests for auto-detection and smart conversion features in useConvertParameters
* This covers the analyzeFileTypes function and related smart detection logic
*/
import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Single File Detection', () => {
test('should detect single file extension and set auto-target', () => {
const { result } = renderHook(() => useConvertParameters());
const pdfFile = [{ name: 'document.pdf' }];
act(() => {
result.current.analyzeFileTypes(pdfFile);
});
expect(result.current.parameters.fromExtension).toBe('pdf');
expect(result.current.parameters.toExtension).toBe(''); // No auto-selection for multiple targets
expect(result.current.parameters.isSmartDetection).toBe(false);
expect(result.current.parameters.smartDetectionType).toBe('none');
});
test('should handle unknown file types with file-to-pdf fallback', () => {
const { result } = renderHook(() => useConvertParameters());
const unknownFile = [{ name: 'document.xyz' }, { name: 'image.jpggg' }];
act(() => {
result.current.analyzeFileTypes(unknownFile);
});
expect(result.current.parameters.fromExtension).toBe('any');
expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf
expect(result.current.parameters.isSmartDetection).toBe(true);
});
test('should handle files without extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const noExtFile = [{ name: 'document' }];
act(() => {
result.current.analyzeFileTypes(noExtFile);
});
expect(result.current.parameters.fromExtension).toBe('any');
expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf
});
});
describe('Multiple Identical Files', () => {
test('should detect multiple PDF files and set auto-target', () => {
const { result } = renderHook(() => useConvertParameters());
const pdfFiles = [
{ name: 'doc1.pdf' },
{ name: 'doc2.pdf' },
{ name: 'doc3.pdf' }
];
act(() => {
result.current.analyzeFileTypes(pdfFiles);
});
expect(result.current.parameters.fromExtension).toBe('pdf');
expect(result.current.parameters.toExtension).toBe(''); // Auto-selected
expect(result.current.parameters.isSmartDetection).toBe(false);
expect(result.current.parameters.smartDetectionType).toBe('none');
});
test('should handle multiple unknown file types with fallback', () => {
const { result } = renderHook(() => useConvertParameters());
const unknownFiles = [
{ name: 'file1.xyz' },
{ name: 'file2.xyz' }
];
act(() => {
result.current.analyzeFileTypes(unknownFiles);
});
expect(result.current.parameters.fromExtension).toBe('any');
expect(result.current.parameters.toExtension).toBe('pdf');
expect(result.current.parameters.isSmartDetection).toBe(false);
});
});
describe('Smart Detection - All Images', () => {
test('should detect all image files and enable smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [
{ name: 'photo1.jpg' },
{ name: 'photo2.png' },
{ name: 'photo3.gif' }
];
act(() => {
result.current.analyzeFileTypes(imageFiles);
});
expect(result.current.parameters.fromExtension).toBe('image');
expect(result.current.parameters.toExtension).toBe('pdf');
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('images');
});
test('should handle mixed case image extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [
{ name: 'photo1.JPG' },
{ name: 'photo2.PNG' }
];
act(() => {
result.current.analyzeFileTypes(imageFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('images');
});
});
describe('Smart Detection - All Web Files', () => {
test('should detect all web files and enable web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const webFiles = [
{ name: 'page1.html' },
{ name: 'archive.zip' }
];
act(() => {
result.current.analyzeFileTypes(webFiles);
});
expect(result.current.parameters.fromExtension).toBe('html');
expect(result.current.parameters.toExtension).toBe('pdf');
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('web');
});
test('should handle mixed case web extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const webFiles = [
{ name: 'page1.HTML' },
{ name: 'archive.ZIP' }
];
act(() => {
result.current.analyzeFileTypes(webFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('web');
});
test('should detect multiple web files and enable web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const zipFiles = [
{ name: 'site1.zip' },
{ name: 'site2.html' }
];
act(() => {
result.current.analyzeFileTypes(zipFiles);
});
expect(result.current.parameters.fromExtension).toBe('html');
expect(result.current.parameters.toExtension).toBe('pdf');
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('web');
});
});
describe('Smart Detection - Mixed File Types', () => {
test('should detect mixed file types and enable smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'document.pdf' },
{ name: 'spreadsheet.xlsx' },
{ name: 'presentation.pptx' }
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.parameters.fromExtension).toBe('any');
expect(result.current.parameters.toExtension).toBe('pdf');
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('mixed');
});
test('should detect mixed images and documents as mixed type', () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'photo.jpg' },
{ name: 'document.pdf' },
{ name: 'text.txt' }
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('mixed');
});
test('should handle mixed with unknown file types', () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'document.pdf' },
{ name: 'unknown.xyz' },
{ name: 'noextension' }
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('mixed');
});
});
describe('Smart Detection Endpoint Resolution', () => {
test('should return correct endpoint for image smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [
{ name: 'photo1.jpg' },
{ name: 'photo2.png' }
];
act(() => {
result.current.analyzeFileTypes(imageFiles);
});
expect(result.current.getEndpointName()).toBe('img-to-pdf');
expect(result.current.getEndpoint()).toBe('/api/v1/convert/img/pdf');
});
test('should return correct endpoint for web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const webFiles = [
{ name: 'page1.html' },
{ name: 'archive.zip' }
];
act(() => {
result.current.analyzeFileTypes(webFiles);
});
expect(result.current.getEndpointName()).toBe('html-to-pdf');
expect(result.current.getEndpoint()).toBe('/api/v1/convert/html/pdf');
});
test('should return correct endpoint for mixed smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'document.pdf' },
{ name: 'spreadsheet.xlsx' }
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.getEndpointName()).toBe('file-to-pdf');
expect(result.current.getEndpoint()).toBe('/api/v1/convert/file/pdf');
});
});
describe('Auto-Target Selection Logic', () => {
test('should select single available target automatically', () => {
const { result } = renderHook(() => useConvertParameters());
// Markdown has only one conversion target (PDF)
const mdFile = [{ name: 'readme.md' }];
act(() => {
result.current.analyzeFileTypes(mdFile);
});
expect(result.current.parameters.fromExtension).toBe('md');
expect(result.current.parameters.toExtension).toBe('pdf'); // Only available target
});
test('should not auto-select when multiple targets available', () => {
const { result } = renderHook(() => useConvertParameters());
// PDF has multiple conversion targets, so no auto-selection
const pdfFile = [{ name: 'document.pdf' }];
act(() => {
result.current.analyzeFileTypes(pdfFile);
});
expect(result.current.parameters.fromExtension).toBe('pdf');
// Should NOT auto-select when multiple targets available
expect(result.current.parameters.toExtension).toBe('');
});
});
describe('Edge Cases', () => {
test('should handle empty file names', () => {
const { result } = renderHook(() => useConvertParameters());
const emptyFiles = [{ name: '' }];
act(() => {
result.current.analyzeFileTypes(emptyFiles);
});
expect(result.current.parameters.fromExtension).toBe('any');
expect(result.current.parameters.toExtension).toBe('pdf');
});
test('should handle malformed file objects', () => {
const { result } = renderHook(() => useConvertParameters());
const malformedFiles: Array<{name: string}> = [
{ name: 'valid.pdf' },
// @ts-expect-error - Testing runtime resilience
{ name: null },
// @ts-expect-error - Testing runtime resilience
{ name: undefined }
];
act(() => {
result.current.analyzeFileTypes(malformedFiles);
});
// Should still process the valid file and handle gracefully
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('mixed');
});
});
});
@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { CropParameters, defaultParameters } from '@app/hooks/tools/crop/useCropParameters';
// Static configuration that can be used by both the hook and automation executor
export const buildCropFormData = (parameters: CropParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
const cropArea = parameters.cropArea;
// Backend expects precise float values for PDF coordinates
formData.append("x", cropArea.x.toString());
formData.append("y", cropArea.y.toString());
formData.append("width", cropArea.width.toString());
formData.append("height", cropArea.height.toString());
return formData;
};
// Static configuration object
export const cropOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCropFormData,
operationType: 'crop',
endpoint: '/api/v1/general/crop',
defaultParameters,
} as const;
export const useCropOperation = () => {
const { t } = useTranslation();
return useToolOperation<CropParameters>({
...cropOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('crop.error.failed', 'An error occurred while cropping the PDF.')
)
});
};
@@ -0,0 +1,141 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { useCallback } from 'react';
import { Rectangle, PDFBounds, constrainCropAreaToPDF, createFullPDFCropArea, roundCropArea, isRectangle } from '@app/utils/cropCoordinates';
import { DEFAULT_CROP_AREA } from '@app/constants/cropConstants';
export interface CropParameters extends BaseParameters {
cropArea: Rectangle;
}
export const defaultParameters: CropParameters = {
cropArea: DEFAULT_CROP_AREA,
};
export type CropParametersHook = BaseParametersHook<CropParameters> & {
/** Set crop area with PDF bounds validation */
setCropArea: (cropArea: Rectangle, pdfBounds?: PDFBounds) => void;
/** Get current crop area as CropArea object */
getCropArea: () => Rectangle;
/** Reset to full PDF dimensions */
resetToFullPDF: (pdfBounds: PDFBounds) => void;
/** Check if current crop area is valid for the PDF */
isCropAreaValid: (pdfBounds?: PDFBounds) => boolean;
/** Check if crop area covers the entire PDF */
isFullPDFCrop: (pdfBounds?: PDFBounds) => boolean;
/** Update crop area with constraints applied */
updateCropAreaConstrained: (cropArea: Partial<Rectangle>, pdfBounds?: PDFBounds) => void;
};
export const useCropParameters = (): CropParametersHook => {
const baseHook = useBaseParameters({
defaultParameters,
endpointName: 'crop',
validateFn: (params) => {
const rect = params.cropArea;
// Basic validation - coordinates and dimensions must be positive
return rect.x >= 0 &&
rect.y >= 0 &&
rect.width > 0 &&
rect.height > 0;
},
});
// Get current crop area as CropArea object
const getCropArea = useCallback((): Rectangle => {
return baseHook.parameters.cropArea;
}, [baseHook.parameters]);
// Set crop area with optional PDF bounds validation
const setCropArea = useCallback((cropArea: Rectangle, pdfBounds?: PDFBounds) => {
let finalCropArea = roundCropArea(cropArea);
// Apply PDF bounds constraints if provided
if (pdfBounds) {
finalCropArea = constrainCropAreaToPDF(finalCropArea, pdfBounds);
}
baseHook.updateParameter('cropArea', finalCropArea);
}, [baseHook]);
// Reset to cover entire PDF
const resetToFullPDF = useCallback((pdfBounds: PDFBounds) => {
const fullCropArea = createFullPDFCropArea(pdfBounds);
setCropArea(fullCropArea);
}, [setCropArea]);
// Check if current crop area is valid for the given PDF bounds
const isCropAreaValid = useCallback((pdfBounds?: PDFBounds): boolean => {
const cropArea = getCropArea();
// Basic validation
if (cropArea.x < 0 || cropArea.y < 0 || cropArea.width <= 0 || cropArea.height <= 0) {
return false;
}
// PDF bounds validation if provided
if (pdfBounds) {
const tolerance = 0.01; // Small tolerance for floating point precision
return cropArea.x + cropArea.width <= pdfBounds.actualWidth + tolerance &&
cropArea.y + cropArea.height <= pdfBounds.actualHeight + tolerance;
}
return true;
}, [getCropArea]);
// Check if crop area covers the entire PDF
const isFullPDFCrop = useCallback((pdfBounds?: PDFBounds): boolean => {
if (!pdfBounds) return false;
const cropArea = getCropArea();
const tolerance = 0.5; // Allow 0.5 point tolerance for floating point precision
return Math.abs(cropArea.x) < tolerance &&
Math.abs(cropArea.y) < tolerance &&
Math.abs(cropArea.width - pdfBounds.actualWidth) < tolerance &&
Math.abs(cropArea.height - pdfBounds.actualHeight) < tolerance;
}, [getCropArea]);
// Update crop area with constraints applied
const updateCropAreaConstrained = useCallback((
partialCropArea: Partial<Rectangle>,
pdfBounds?: PDFBounds
) => {
const currentCropArea = getCropArea();
const newCropArea = { ...currentCropArea, ...partialCropArea };
setCropArea(newCropArea, pdfBounds);
}, [getCropArea, setCropArea]);
// Enhanced validation that considers PDF bounds
const validateParameters = useCallback((pdfBounds?: PDFBounds): boolean => {
return baseHook.validateParameters() && isCropAreaValid(pdfBounds);
}, [baseHook, isCropAreaValid]);
// Override updateParameter to ensure positive values
const updateParameter = useCallback(<K extends keyof CropParameters>(
parameter: K,
value: CropParameters[K]
) => {
if(isRectangle(value)) {
value.x = Math.max(0.1, value.x); // Minimum 0.1 point
value.x = Math.max(0.1, value.y); // Minimum 0.1 point
value.width = Math.max(0, value.width); // Minimum 0 point
value.height = Math.max(0, value.height); // Minimum 0 point
}
baseHook.updateParameter(parameter, value);
}, [baseHook]);
return {
...baseHook,
updateParameter,
validateParameters: () => validateParameters(),
setCropArea,
getCropArea,
resetToFullPDF,
isCropAreaValid,
isFullPDFCrop,
updateCropAreaConstrained,
};
};
@@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { ExtractImagesParameters, defaultParameters } from '@app/hooks/tools/extractImages/useExtractImagesParameters';
import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
// Static configuration that can be used by both the hook and automation executor
export const buildExtractImagesFormData = (parameters: ExtractImagesParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("format", parameters.format);
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
return formData;
};
// Static configuration object (without response handler - will be added in hook)
export const extractImagesOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildExtractImagesFormData,
operationType: 'extractImages',
endpoint: '/api/v1/misc/extract-images',
defaultParameters,
} as const;
export const useExtractImagesOperation = () => {
const { t } = useTranslation();
const { extractZipFiles } = useToolResources();
// Response handler that respects auto-unzip preferences
const responseHandler = useCallback(async (blob: Blob, _originalFiles: File[]): Promise<File[]> => {
// Extract images returns a ZIP file - use preference-aware extraction
return await extractZipFiles(blob);
}, [extractZipFiles]);
return useToolOperation<ExtractImagesParameters>({
...extractImagesOperationConfig,
responseHandler,
getErrorMessage: createStandardErrorHandler(t('extractImages.error.failed', 'An error occurred while extracting images from the PDF.'))
});
};
@@ -0,0 +1,19 @@
import { useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters';
export interface ExtractImagesParameters {
format: 'png' | 'jpg' | 'gif';
allowDuplicates: boolean;
}
export const defaultParameters: ExtractImagesParameters = {
format: 'png',
allowDuplicates: false,
};
export const useExtractImagesParameters = () => {
return useBaseParameters<ExtractImagesParameters>({
defaultParameters,
endpointName: 'extract-images',
validateFn: () => true, // All parameters have valid defaults
});
};
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { FlattenParameters, defaultParameters } from '@app/hooks/tools/flatten/useFlattenParameters';
// Static function that can be used by both the hook and automation executor
export const buildFlattenFormData = (parameters: FlattenParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('flattenOnlyForms', parameters.flattenOnlyForms.toString());
return formData;
};
// Static configuration object
export const flattenOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildFlattenFormData,
operationType: 'flatten',
endpoint: '/api/v1/misc/flatten',
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useFlattenOperation = () => {
const { t } = useTranslation();
return useToolOperation<FlattenParameters>({
...flattenOperationConfig,
getErrorMessage: createStandardErrorHandler(t('flatten.error.failed', 'An error occurred while flattening the PDF.'))
});
};
@@ -0,0 +1,19 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface FlattenParameters extends BaseParameters {
flattenOnlyForms: boolean;
}
export const defaultParameters: FlattenParameters = {
flattenOnlyForms: false,
};
export type FlattenParametersHook = BaseParametersHook<FlattenParameters>;
export const useFlattenParameters = (): FlattenParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'flatten',
});
};
@@ -0,0 +1,120 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useMergeOperation } from '@app/hooks/tools/merge/useMergeOperation';
import type { MergeParameters } from '@app/hooks/tools/merge/useMergeParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation');
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Mock the error handler
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
}));
// Import the mocked function
import { MultiFileToolOperationConfig, ToolOperationHook, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
describe('useMergeOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation<MergeParameters>);
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as MultiFileToolOperationConfig<MergeParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: '',
isLoading: false,
errorMessage: null,
status: '',
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: function (): Promise<void> {
throw new Error('Function not implemented.');
}
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test('should build FormData correctly', () => {
renderHook(() => useMergeOperation());
const config = getToolConfig();
const mockFiles = [
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
];
const parameters: MergeParameters = {
removeDigitalSignature: true,
generateTableOfContents: false
};
const formData = config.buildFormData(parameters, mockFiles);
// Verify files are appended
expect(formData.getAll('fileInput')).toHaveLength(2);
expect(formData.getAll('fileInput')[0]).toBe(mockFiles[0]);
expect(formData.getAll('fileInput')[1]).toBe(mockFiles[1]);
// Verify parameters are appended correctly
expect(formData.get('sortType')).toBe('orderProvided');
expect(formData.get('removeCertSign')).toBe('true');
expect(formData.get('generateToc')).toBe('false');
});
test('should return the hook result from useToolOperation', () => {
const { result } = renderHook(() => useMergeOperation());
expect(result.current).toBe(mockToolOperationReturn);
});
test('should use correct translation keys for error handling', () => {
renderHook(() => useMergeOperation());
expect(mockT).toHaveBeenCalledWith('merge.error.failed', 'An error occurred while merging the PDFs.');
});
test('should build FormData with different parameter combinations', () => {
renderHook(() => useMergeOperation());
const config = getToolConfig();
const mockFiles = [new File(['test'], 'test.pdf', { type: 'application/pdf' })];
// Test case 1: All options disabled
const params1: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false
};
const formData1 = config.buildFormData(params1, mockFiles);
expect(formData1.get('removeCertSign')).toBe('false');
expect(formData1.get('generateToc')).toBe('false');
// Test case 2: All options enabled
const params2: MergeParameters = {
removeDigitalSignature: true,
generateTableOfContents: true
};
const formData2 = config.buildFormData(params2, mockFiles);
expect(formData2.get('removeCertSign')).toBe('true');
expect(formData2.get('generateToc')).toBe('true');
});
});
@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationConfig, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { MergeParameters } from '@app/hooks/tools/merge/useMergeParameters';
const buildFormData = (parameters: MergeParameters, files: File[]): FormData => {
const formData = new FormData();
files.forEach((file) => {
formData.append("fileInput", file);
});
// Provide stable client file IDs (align with files order)
const clientIds: string[] = files.map((f: any) => String((f as any).fileId || f.name));
formData.append('clientFileIds', JSON.stringify(clientIds));
formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
formData.append("removeCertSign", parameters.removeDigitalSignature.toString());
formData.append("generateToc", parameters.generateTableOfContents.toString());
return formData;
};
// Operation configuration for automation
export const mergeOperationConfig: ToolOperationConfig<MergeParameters> = {
toolType: ToolType.multiFile,
buildFormData,
operationType: 'merge',
endpoint: '/api/v1/general/merge-pdfs',
filePrefix: 'merged_',
};
export const useMergeOperation = () => {
const { t } = useTranslation();
return useToolOperation<MergeParameters>({
...mergeOperationConfig,
getErrorMessage: createStandardErrorHandler(t('merge.error.failed', 'An error occurred while merging the PDFs.'))
});
};
@@ -0,0 +1,68 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMergeParameters, defaultParameters } from '@app/hooks/tools/merge/useMergeParameters';
describe('useMergeParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useMergeParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: 'removeDigitalSignature' as const, value: true },
{ paramName: 'removeDigitalSignature' as const, value: false },
{ paramName: 'generateTableOfContents' as const, value: true },
{ paramName: 'generateTableOfContents' as const, value: false }
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderHook(() => useMergeParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useMergeParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('removeDigitalSignature', true);
result.current.updateParameter('generateTableOfContents', true);
});
expect(result.current.parameters.removeDigitalSignature).toBe(true);
expect(result.current.parameters.generateTableOfContents).toBe(true);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should validate parameters correctly - always returns true', () => {
const { result } = renderHook(() => useMergeParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
// Change parameters and validate again
act(() => {
result.current.updateParameter('removeDigitalSignature', true);
result.current.updateParameter('generateTableOfContents', true);
});
expect(result.current.validateParameters()).toBe(true);
// Reset and validate again
act(() => {
result.current.resetParameters();
});
expect(result.current.validateParameters()).toBe(true);
});
});
@@ -0,0 +1,21 @@
import { BaseParameters } from '@app/types/parameters';
import { BaseParametersHook, useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters';
export interface MergeParameters extends BaseParameters {
removeDigitalSignature: boolean;
generateTableOfContents: boolean;
};
export const defaultParameters: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
};
export type MergeParametersHook = BaseParametersHook<MergeParameters>;
export const useMergeParameters = (): MergeParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "merge-pdfs",
});
};
@@ -0,0 +1,125 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { OCRParameters, defaultParameters } from '@app/hooks/tools/ocr/useOCRParameters';
import { useToolOperation, ToolOperationConfig, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
// Helper: get MIME type based on file extension
function getMimeType(filename: string): string {
const ext = filename.toLowerCase().split('.').pop();
switch (ext) {
case 'pdf': return 'application/pdf';
case 'txt': return 'text/plain';
case 'zip': return 'application/zip';
default: return 'application/octet-stream';
}
}
// Lightweight ZIP extractor (keep or replace with a shared util if you have one)
async function extractZipFile(zipBlob: Blob): Promise<File[]> {
const JSZip = await import('jszip');
const zip = new JSZip.default();
const zipContent = await zip.loadAsync(await zipBlob.arrayBuffer());
const out: File[] = [];
for (const [filename, file] of Object.entries(zipContent.files)) {
if (!file.dir) {
const content = await file.async('blob');
out.push(new File([content], filename, { type: getMimeType(filename) }));
}
}
return out;
}
// Helper: strip extension
function stripExt(name: string): string {
const i = name.lastIndexOf('.');
return i > 0 ? name.slice(0, i) : name;
}
// Static function that can be used by both the hook and automation executor
export const buildOCRFormData = (parameters: OCRParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
parameters.languages.forEach((lang) => formData.append('languages', lang));
formData.append('ocrType', parameters.ocrType);
formData.append('ocrRenderType', parameters.ocrRenderType);
formData.append('sidecar', parameters.additionalOptions.includes('sidecar').toString());
formData.append('deskew', parameters.additionalOptions.includes('deskew').toString());
formData.append('clean', parameters.additionalOptions.includes('clean').toString());
formData.append('cleanFinal', parameters.additionalOptions.includes('cleanFinal').toString());
formData.append('removeImagesAfter', parameters.additionalOptions.includes('removeImagesAfter').toString());
return formData;
};
// Static response handler for OCR - can be used by automation executor
export const ocrResponseHandler = async (blob: Blob, originalFiles: File[], extractZipFiles: (blob: Blob) => Promise<File[]>): Promise<File[]> => {
const headBuf = await blob.slice(0, 8).arrayBuffer();
const head = new TextDecoder().decode(new Uint8Array(headBuf));
// ZIP: sidecar or multi-asset output
if (head.startsWith('PK')) {
const base = stripExt(originalFiles[0].name);
try {
const extractedFiles = await extractZipFiles(blob);
if (extractedFiles.length > 0) return extractedFiles;
} catch { /* ignore and try local extractor */ }
try {
const local = await extractZipFile(blob); // local fallback
if (local.length > 0) return local;
} catch { /* fall through */ }
return [new File([blob], `ocr_${base}.zip`, { type: 'application/zip' })];
}
// Not a PDF: surface error details if present
if (!head.startsWith('%PDF')) {
const textBuf = await blob.slice(0, 1024).arrayBuffer();
const text = new TextDecoder().decode(new Uint8Array(textBuf));
if (/error|exception|html/i.test(text)) {
if (text.includes('OCR tools') && text.includes('not installed')) {
throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.');
}
const title =
text.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1] ||
text.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1] ||
'Unknown error';
throw new Error(`OCR service error: ${title}`);
}
throw new Error(`Response is not a valid PDF. Header: "${head}"`);
}
const originalName = originalFiles[0].name;
return [new File([blob], originalName, { type: 'application/pdf' })];
};
// Static configuration object (without t function dependencies)
export const ocrOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildOCRFormData,
operationType: 'ocr',
endpoint: '/api/v1/misc/ocr-pdf',
defaultParameters,
} as const;
export const useOCROperation = () => {
const { t } = useTranslation();
const { extractZipFiles } = useToolResources();
// OCR-specific parsing: ZIP (sidecar) vs PDF vs HTML error
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
// extractZipFiles from useToolResources already returns File[] directly
const simpleExtractZipFiles = extractZipFiles;
return ocrResponseHandler(blob, originalFiles, simpleExtractZipFiles);
}, [extractZipFiles]);
const ocrConfig: ToolOperationConfig<OCRParameters> = {
...ocrOperationConfig,
responseHandler,
getErrorMessage: (error) =>
error.message?.includes('OCR tools') && error.message?.includes('not installed')
? 'OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.'
: createStandardErrorHandler(t('ocr.error.failed', 'OCR operation failed'))(error),
};
return useToolOperation(ocrConfig);
};
@@ -0,0 +1,29 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface OCRParameters extends BaseParameters {
languages: string[];
ocrType: string;
ocrRenderType: string;
additionalOptions: string[];
}
export type OCRParametersHook = BaseParametersHook<OCRParameters>;
export const defaultParameters: OCRParameters = {
languages: [],
ocrType: 'skip-text',
ocrRenderType: 'hocr',
additionalOptions: [],
};
export const useOCRParameters = (): OCRParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'ocr-pdf',
validateFn: (params) => {
// At minimum, we need at least one language selected
return params.languages.length > 0;
},
});
};
@@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType, type ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { type OverlayPdfsParameters } from '@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters';
const buildFormData = (parameters: OverlayPdfsParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Overlay files
for (const overlay of parameters.overlayFiles || []) {
formData.append('overlayFiles', overlay);
}
// Mode and position
formData.append('overlayMode', parameters.overlayMode);
formData.append('overlayPosition', String(parameters.overlayPosition));
// Counts (only relevant for FixedRepeatOverlay, server accepts repeated 'counts' fields)
if (parameters.overlayMode === 'FixedRepeatOverlay') {
for (const count of parameters.counts || []) {
formData.append('counts', String(count));
}
}
return formData;
};
export const overlayPdfsOperationConfig: ToolOperationConfig<OverlayPdfsParameters> = {
toolType: ToolType.singleFile,
buildFormData,
operationType: 'overlayPdfs',
endpoint: '/api/v1/general/overlay-pdfs'
};
export const useOverlayPdfsOperation = () => {
const { t } = useTranslation();
return useToolOperation<OverlayPdfsParameters>({
...overlayPdfsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('overlay-pdfs.error.failed', 'An error occurred while overlaying PDFs.')
),
});
};
@@ -0,0 +1,37 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export type OverlayMode = 'SequentialOverlay' | 'InterleavedOverlay' | 'FixedRepeatOverlay';
export interface OverlayPdfsParameters extends BaseParameters {
overlayFiles: File[];
overlayMode: OverlayMode;
overlayPosition: 0 | 1;
counts: number[];
}
export const defaultParameters: OverlayPdfsParameters = {
overlayFiles: [],
overlayMode: 'SequentialOverlay',
overlayPosition: 0,
counts: []
};
export type OverlayPdfsParametersHook = BaseParametersHook<OverlayPdfsParameters>;
export const useOverlayPdfsParameters = (): OverlayPdfsParametersHook => {
return useBaseParameters<OverlayPdfsParameters>({
defaultParameters,
endpointName: 'overlay-pdfs',
validateFn: (params) => {
if (!params.overlayFiles || params.overlayFiles.length === 0) return false;
if (params.overlayMode === 'FixedRepeatOverlay') {
if (!params.counts || params.counts.length !== params.overlayFiles.length) return false;
if (params.counts.some((c) => !Number.isFinite(c) || c <= 0)) return false;
}
return true;
},
});
};
@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { PageLayoutParameters, defaultParameters } from '@app/hooks/tools/pageLayout/usePageLayoutParameters';
export const buildPageLayoutFormData = (parameters: PageLayoutParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('pagesPerSheet', String(parameters.pagesPerSheet));
formData.append('addBorder', String(parameters.addBorder));
return formData;
};
export const pageLayoutOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildPageLayoutFormData,
operationType: 'pageLayout',
endpoint: '/api/v1/general/multi-page-layout',
defaultParameters,
} as const;
export const usePageLayoutOperation = () => {
const { t } = useTranslation();
return useToolOperation<PageLayoutParameters>({
...pageLayoutOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('pageLayout.error.failed', 'An error occurred while creating the multi-page layout.')
)
});
};
@@ -0,0 +1,23 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface PageLayoutParameters extends BaseParameters {
pagesPerSheet: number;
addBorder: boolean;
}
export const defaultParameters: PageLayoutParameters = {
pagesPerSheet: 4,
addBorder: false,
};
export type PageLayoutParametersHook = BaseParametersHook<PageLayoutParameters>;
export const usePageLayoutParameters = (): PageLayoutParametersHook => {
return useBaseParameters<PageLayoutParameters>({
defaultParameters,
endpointName: 'multi-page-layout',
});
};
@@ -0,0 +1,142 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { buildRedactFormData, redactOperationConfig, useRedactOperation } from '@app/hooks/tools/redact/useRedactOperation';
import { defaultParameters, RedactParameters } from '@app/hooks/tools/redact/useRedactParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) })
}));
// Mock the error handler utility
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => vi.fn())
}));
describe('buildRedactFormData', () => {
const mockFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
test('should build form data for automatic mode', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: ['Confidential', 'Secret'],
useRegex: true,
wholeWordSearch: true,
redactColor: '#FF0000',
customPadding: 0.5,
convertPDFToImage: false,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('fileInput')).toBe(mockFile);
expect(formData.get('listOfText')).toBe('Confidential\nSecret');
expect(formData.get('useRegex')).toBe('true');
expect(formData.get('wholeWordSearch')).toBe('true');
expect(formData.get('redactColor')).toBe('FF0000'); // Hash should be removed
expect(formData.get('customPadding')).toBe('0.5');
expect(formData.get('convertPDFToImage')).toBe('false');
});
test('should handle empty words array', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: [],
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('listOfText')).toBe('');
});
test('should join multiple words with newlines', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: ['Word1', 'Word2', 'Word3'],
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('listOfText')).toBe('Word1\nWord2\nWord3');
});
test.each([
{ description: 'remove hash from redact color', redactColor: '#123456', expected: '123456' },
{ description: 'handle redact color without hash', redactColor: 'ABCDEF', expected: 'ABCDEF' },
])('should $description', ({ redactColor, expected }) => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
redactColor,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('redactColor')).toBe(expected);
});
test('should convert boolean parameters to strings', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
useRegex: false,
wholeWordSearch: true,
convertPDFToImage: false,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('useRegex')).toBe('false');
expect(formData.get('wholeWordSearch')).toBe('true');
expect(formData.get('convertPDFToImage')).toBe('false');
});
test('should throw error for manual mode (not implemented)', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'manual',
};
expect(() => buildRedactFormData(parameters, mockFile)).toThrow('Manual redaction not yet implemented');
});
});
describe('useRedactOperation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('should call useToolOperation with correct configuration', async () => {
const { useToolOperation } = await import('@app/hooks/tools/shared/useToolOperation');
const mockUseToolOperation = vi.mocked(useToolOperation);
renderHook(() => useRedactOperation());
expect(mockUseToolOperation).toHaveBeenCalledWith({
...redactOperationConfig,
getErrorMessage: expect.any(Function),
});
});
test('should provide error handler to useToolOperation', async () => {
const { useToolOperation } = await import('@app/hooks/tools/shared/useToolOperation');
const mockUseToolOperation = vi.mocked(useToolOperation);
renderHook(() => useRedactOperation());
const callArgs = mockUseToolOperation.mock.calls[0][0];
expect(typeof callArgs.getErrorMessage).toBe('function');
});
});
@@ -0,0 +1,50 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RedactParameters, defaultParameters } from '@app/hooks/tools/redact/useRedactParameters';
// Static configuration that can be used by both the hook and automation executor
export const buildRedactFormData = (parameters: RedactParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
if (parameters.mode === 'automatic') {
// Convert array to newline-separated string as expected by backend
formData.append("listOfText", parameters.wordsToRedact.join('\n'));
formData.append("useRegex", parameters.useRegex.toString());
formData.append("wholeWordSearch", parameters.wholeWordSearch.toString());
formData.append("redactColor", parameters.redactColor.replace('#', ''));
formData.append("customPadding", parameters.customPadding.toString());
formData.append("convertPDFToImage", parameters.convertPDFToImage.toString());
} else {
// Manual mode parameters would go here when implemented
throw new Error('Manual redaction not yet implemented');
}
return formData;
};
// Static configuration object
export const redactOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRedactFormData,
operationType: 'redact',
endpoint: (parameters: RedactParameters) => {
if (parameters.mode === 'automatic') {
return '/api/v1/security/auto-redact';
} else {
// Manual redaction endpoint would go here when implemented
throw new Error('Manual redaction not yet implemented');
}
},
defaultParameters,
} as const;
export const useRedactOperation = () => {
const { t } = useTranslation();
return useToolOperation<RedactParameters>({
...redactOperationConfig,
getErrorMessage: createStandardErrorHandler(t('redact.error.failed', 'An error occurred while redacting the PDF.'))
});
};
@@ -0,0 +1,134 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRedactParameters, defaultParameters } from '@app/hooks/tools/redact/useRedactParameters';
describe('useRedactParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRedactParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: 'mode' as const, value: 'manual' as const },
{ paramName: 'wordsToRedact' as const, value: ['word1', 'word2'] },
{ paramName: 'useRegex' as const, value: true },
{ paramName: 'wholeWordSearch' as const, value: true },
{ paramName: 'redactColor' as const, value: '#FF0000' },
{ paramName: 'customPadding' as const, value: 0.5 },
{ paramName: 'convertPDFToImage' as const, value: false }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toStrictEqual(value);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useRedactParameters());
// Modify some parameters
act(() => {
result.current.updateParameter('mode', 'manual');
result.current.updateParameter('wordsToRedact', ['test']);
result.current.updateParameter('useRegex', true);
});
// Reset parameters
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
describe('validation', () => {
test.each([
{ description: 'validate when wordsToRedact has non-empty words in automatic mode', wordsToRedact: ['word1', 'word2'], expected: true },
{ description: 'not validate when wordsToRedact is empty in automatic mode', wordsToRedact: [], expected: false },
{ description: 'not validate when wordsToRedact contains only empty strings in automatic mode', wordsToRedact: ['', ' ', ''], expected: false },
{ description: 'validate when wordsToRedact contains at least one non-empty word in automatic mode', wordsToRedact: ['', 'valid', ' '], expected: true },
])('should $description', ({ wordsToRedact, expected }) => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'automatic');
result.current.updateParameter('wordsToRedact', wordsToRedact);
});
expect(result.current.validateParameters()).toBe(expected);
});
test('should not validate in manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'manual');
});
expect(result.current.validateParameters()).toBe(false);
});
});
describe('endpoint handling', () => {
test('should return correct endpoint for automatic mode', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'automatic');
});
expect(result.current.getEndpointName()).toBe('/api/v1/security/auto-redact');
});
test('should throw error for manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'manual');
});
expect(() => result.current.getEndpointName()).toThrow('Manual redaction not yet implemented');
});
});
test('should maintain parameter state across updates', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('redactColor', '#FF0000');
result.current.updateParameter('customPadding', 0.5);
result.current.updateParameter('wordsToRedact', ['word1']);
});
// All parameters should be updated
expect(result.current.parameters.redactColor).toBe('#FF0000');
expect(result.current.parameters.customPadding).toBe(0.5);
expect(result.current.parameters.wordsToRedact).toEqual(['word1']);
// Other parameters should remain at defaults
expect(result.current.parameters.mode).toBe('automatic');
expect(result.current.parameters.useRegex).toBe(false);
expect(result.current.parameters.wholeWordSearch).toBe(false);
expect(result.current.parameters.convertPDFToImage).toBe(true);
});
test('should handle array parameter updates correctly', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('wordsToRedact', ['initial']);
});
expect(result.current.parameters.wordsToRedact).toEqual(['initial']);
act(() => {
result.current.updateParameter('wordsToRedact', ['updated', 'multiple']);
});
expect(result.current.parameters.wordsToRedact).toEqual(['updated', 'multiple']);
});
});
@@ -0,0 +1,48 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export type RedactMode = 'automatic' | 'manual';
export interface RedactParameters extends BaseParameters {
mode: RedactMode;
// Automatic redaction parameters
wordsToRedact: string[];
useRegex: boolean;
wholeWordSearch: boolean;
redactColor: string;
customPadding: number;
convertPDFToImage: boolean;
}
export const defaultParameters: RedactParameters = {
mode: 'automatic',
wordsToRedact: [],
useRegex: false,
wholeWordSearch: false,
redactColor: '#000000',
customPadding: 0.1,
convertPDFToImage: true,
};
export type RedactParametersHook = BaseParametersHook<RedactParameters>;
export const useRedactParameters = (): RedactParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: (params) => {
if (params.mode === 'automatic') {
return '/api/v1/security/auto-redact';
}
// Manual redaction endpoint would go here when implemented
throw new Error('Manual redaction not yet implemented');
},
validateFn: (params) => {
if (params.mode === 'automatic') {
return params.wordsToRedact.length > 0 && params.wordsToRedact.some(word => word.trim().length > 0);
}
// Manual mode validation would go here when implemented
return false;
}
});
};
@@ -0,0 +1,96 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RemoveAnnotationsParameters, defaultParameters } from '@app/hooks/tools/removeAnnotations/useRemoveAnnotationsParameters';
import { PDFDocument, PDFName, PDFRef, PDFDict } from 'pdf-lib';
// Client-side PDF processing using PDF-lib
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<File[]> => {
const processedFiles: File[] = [];
for (const file of files) {
try {
// Load the PDF
const fileArrayBuffer = await file.arrayBuffer();
const pdfBytesIn = new Uint8Array(fileArrayBuffer);
const pdfDoc = await PDFDocument.load(pdfBytesIn, { ignoreEncryption: true });
const ctx = pdfDoc.context;
const pages = pdfDoc.getPages();
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
// Annots() returns PDFArray | undefined
const annots = page.node.Annots();
if (!annots || annots.size() === 0) continue;
// Delete each annotation object (they are usually PDFRef)
for (let j = annots.size() - 1; j >= 0; j--) {
try {
const entry = annots.get(j);
if (entry instanceof PDFRef) {
ctx.delete(entry);
} else if (entry instanceof PDFDict) {
// In practice, Annots array should contain refs; if not, just remove the array linkage.
// (We avoid poking internal maps to find a ref for the dict.)
}
} catch (err) {
console.warn(`Failed to remove annotation ${j} on page ${i + 1}:`, err);
}
}
// Remove the Annots key entirely
try {
if (page.node.has(PDFName.of('Annots'))) {
page.node.delete(PDFName.of('Annots'));
}
} catch (err) {
console.warn(`Failed to delete /Annots on page ${i + 1}:`, err);
}
}
// Optional: if removing ALL annotations across the doc, strip AcroForm to avoid dangling widget refs
try {
const catalog = pdfDoc.context.lookup(pdfDoc.context.trailerInfo.Root);
if (catalog && 'has' in catalog && 'delete' in catalog) {
const catalogDict = catalog as any;
if (catalogDict.has(PDFName.of('AcroForm'))) {
catalogDict.delete(PDFName.of('AcroForm'));
}
}
} catch (err) {
console.warn('Failed to remove /AcroForm:', err);
}
// Save returns Uint8Array — safe for Blob
const outBytes = await pdfDoc.save();
const outBlob = new Blob([new Uint8Array(outBytes)], { type: 'application/pdf' });
// Create new file with original name
const processedFile = new File([outBlob], file.name, { type: 'application/pdf' });
processedFiles.push(processedFile);
} catch (error) {
console.error('Error processing file:', file.name, error);
throw new Error(`Failed to process ${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
return processedFiles;
};
// Static configuration object
export const removeAnnotationsOperationConfig = {
toolType: ToolType.custom,
operationType: 'removeAnnotations',
customProcessor: removeAnnotationsProcessor,
defaultParameters,
} as const;
export const useRemoveAnnotationsOperation = () => {
const { t } = useTranslation();
return useToolOperation<RemoveAnnotationsParameters>({
...removeAnnotationsOperationConfig,
getErrorMessage: createStandardErrorHandler(t('removeAnnotations.error.failed', 'An error occurred while removing annotations from the PDF.'))
});
};
@@ -0,0 +1,14 @@
import { useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters';
export type RemoveAnnotationsParameters = Record<string, never>
export const defaultParameters: RemoveAnnotationsParameters = {
};
export const useRemoveAnnotationsParameters = () => {
return useBaseParameters<RemoveAnnotationsParameters>({
defaultParameters,
endpointName: 'remove-annotations', // Not used for client-side processing, but required by base hook
validateFn: () => true, // No parameters to validate
});
};
@@ -0,0 +1,43 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RemoveBlanksParameters, defaultParameters } from '@app/hooks/tools/removeBlanks/useRemoveBlanksParameters';
import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
export const buildRemoveBlanksFormData = (parameters: RemoveBlanksParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('threshold', String(parameters.threshold));
formData.append('whitePercent', String(parameters.whitePercent));
// Note: includeBlankPages is not sent to backend as it always returns both files in a ZIP
return formData;
};
export const removeBlanksOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveBlanksFormData,
operationType: 'removeBlanks',
endpoint: '/api/v1/misc/remove-blanks',
defaultParameters,
} as const satisfies ToolOperationConfig<RemoveBlanksParameters>;
export const useRemoveBlanksOperation = () => {
const { t } = useTranslation();
const { extractZipFiles } = useToolResources();
const responseHandler = useCallback(async (blob: Blob): Promise<File[]> => {
// Backend always returns a ZIP file containing the processed PDFs
return await extractZipFiles(blob);
}, [extractZipFiles]);
return useToolOperation<RemoveBlanksParameters>({
...removeBlanksOperationConfig,
responseHandler,
getErrorMessage: createStandardErrorHandler(
t('removeBlanks.error.failed', 'Failed to remove blank pages')
)
});
};
@@ -0,0 +1,26 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface RemoveBlanksParameters extends BaseParameters {
threshold: number; // 0-255
whitePercent: number; // 0.1-100
includeBlankPages: boolean; // whether to include detected blank pages in output
}
export const defaultParameters: RemoveBlanksParameters = {
threshold: 10,
whitePercent: 99.9,
includeBlankPages: false,
};
export type RemoveBlanksParametersHook = BaseParametersHook<RemoveBlanksParameters>;
export const useRemoveBlanksParameters = (): RemoveBlanksParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-blanks',
validateFn: (p) => p.threshold >= 0 && p.threshold <= 255 && p.whitePercent > 0 && p.whitePercent <= 100,
});
};
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RemoveCertificateSignParameters, defaultParameters } from '@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters';
// Static function that can be used by both the hook and automation executor
export const buildRemoveCertificateSignFormData = (_parameters: RemoveCertificateSignParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
// Static configuration object
export const removeCertificateSignOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveCertificateSignFormData,
operationType: 'removeCertSign',
endpoint: '/api/v1/security/remove-cert-sign',
defaultParameters,
} as const;
export const useRemoveCertificateSignOperation = () => {
const { t } = useTranslation();
return useToolOperation<RemoveCertificateSignParameters>({
...removeCertificateSignOperationConfig,
getErrorMessage: createStandardErrorHandler(t('removeCertSign.error.failed', 'An error occurred while removing certificate signatures.'))
});
};
@@ -0,0 +1,19 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface RemoveCertificateSignParameters extends BaseParameters {
// Extends BaseParameters - ready for future parameter additions if needed
}
export const defaultParameters: RemoveCertificateSignParameters = {
// No parameters needed
};
export type RemoveCertificateSignParametersHook = BaseParametersHook<RemoveCertificateSignParameters>;
export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-certificate-sign',
});
};
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationConfig, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import type { RemoveImageParameters } from '@app/hooks/tools/removeImage/useRemoveImageParameters';
export const buildRemoveImageFormData = (_params: RemoveImageParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
return formData;
};
export const removeImageOperationConfig: ToolOperationConfig<RemoveImageParameters> = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveImageFormData,
operationType: 'removeImage',
endpoint: '/api/v1/general/remove-image-pdf',
};
export const useRemoveImageOperation = () => {
const { t } = useTranslation();
return useToolOperation<RemoveImageParameters>({
...removeImageOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('removeImage.error.failed', 'Failed to remove images from the PDF.')
),
});
};
@@ -0,0 +1,17 @@
import { useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters';
import type { BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export type RemoveImageParameters = Record<string, never>;
export const defaultParameters: RemoveImageParameters = {};
export type RemoveImageParametersHook = BaseParametersHook<RemoveImageParameters>;
export const useRemoveImageParameters = (): RemoveImageParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-image-pdf',
});
};
@@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RemovePagesParameters, defaultParameters } from '@app/hooks/tools/removePages/useRemovePagesParameters';
// import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
export const buildRemovePagesFormData = (parameters: RemovePagesParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
const cleaned = parameters.pageNumbers.replace(/\s+/g, '');
formData.append('pageNumbers', cleaned);
return formData;
};
export const removePagesOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemovePagesFormData,
operationType: 'removePages',
endpoint: '/api/v1/general/remove-pages',
defaultParameters,
} as const satisfies ToolOperationConfig<RemovePagesParameters>;
export const useRemovePagesOperation = () => {
const { t } = useTranslation();
return useToolOperation<RemovePagesParameters>({
...removePagesOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('removePages.error.failed', 'Failed to remove pages')
)
});
};
@@ -0,0 +1,21 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { validatePageNumbers } from '@app/utils/pageSelection';
export interface RemovePagesParameters extends BaseParameters {
pageNumbers: string; // comma-separated page numbers or ranges (e.g., "1,3,5-8")
}
export const defaultParameters: RemovePagesParameters = {
pageNumbers: '',
};
export type RemovePagesParametersHook = BaseParametersHook<RemovePagesParameters>;
export const useRemovePagesParameters = (): RemovePagesParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-pages',
validateFn: (p) => validatePageNumbers(p.pageNumbers),
});
};
@@ -0,0 +1,107 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useRemovePasswordOperation } from '@app/hooks/tools/removePassword/useRemovePasswordOperation';
import type { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Mock the error handler
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
}));
// Import the mocked function
import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
describe('useRemovePasswordOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<RemovePasswordParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: '',
isLoading: false,
errorMessage: null,
status: '',
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test.each([
{
description: 'with valid password',
password: 'test-password'
},
{
description: 'with complex password',
password: 'C0mpl3x@P@ssw0rd!'
},
{
description: 'with single character password',
password: 'a'
}
])('should create form data correctly $description', ({ password }) => {
renderHook(() => useRemovePasswordOperation());
const callArgs = getToolConfig();
const buildFormData = callArgs.buildFormData;
const testParameters: RemovePasswordParameters = {
password
};
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const formData = buildFormData(testParameters, testFile as any);
// Verify the form data contains the file
expect(formData.get('fileInput')).toBe(testFile);
// Verify password parameter
expect(formData.get('password')).toBe(password);
});
test('should use correct translation for error messages', () => {
renderHook(() => useRemovePasswordOperation());
expect(mockT).toHaveBeenCalledWith(
'removePassword.error.failed',
'An error occurred while removing the password from the PDF.'
);
});
test.each([
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/remove-password' },
{ property: 'operationType' as const, expectedValue: 'removePassword' }
])('should configure $property correctly', ({ property, expectedValue }) => {
renderHook(() => useRemovePasswordOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
// Static function that can be used by both the hook and automation executor
export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
return formData;
};
// Static configuration object
export const removePasswordOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemovePasswordFormData,
operationType: 'removePassword',
endpoint: '/api/v1/security/remove-password',
defaultParameters,
} as const;
export const useRemovePasswordOperation = () => {
const { t } = useTranslation();
return useToolOperation<RemovePasswordParameters>({
...removePasswordOperationConfig,
getErrorMessage: createStandardErrorHandler(t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.'))
});
};
@@ -0,0 +1,81 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
describe('useRemovePasswordParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update password parameter', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
act(() => {
result.current.updateParameter('password', 'test-password');
});
expect(result.current.parameters.password).toBe('test-password');
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
// First, change the password
act(() => {
result.current.updateParameter('password', 'test-password');
});
expect(result.current.parameters.password).toBe('test-password');
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
expect(result.current.getEndpointName()).toBe('remove-password');
});
test.each([
{
description: 'with valid password',
password: 'valid-password',
expectedValid: true
},
{
description: 'with empty password',
password: '',
expectedValid: false
},
{
description: 'with whitespace only password',
password: ' \t ',
expectedValid: true
},
{
description: 'with password containing special characters',
password: 'p@ssw0rd!',
expectedValid: true
},
{
description: 'with single character password',
password: 'a',
expectedValid: true
}
])('should validate parameters correctly $description', ({ password, expectedValid }) => {
const { result } = renderHook(() => useRemovePasswordParameters());
act(() => {
result.current.updateParameter('password', password);
});
expect(result.current.validateParameters()).toBe(expectedValid);
});
});
@@ -0,0 +1,22 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface RemovePasswordParameters extends BaseParameters {
password: string;
}
export type RemovePasswordParametersHook = BaseParametersHook<RemovePasswordParameters>;
export const defaultParameters: RemovePasswordParameters = {
password: '',
};
export const useRemovePasswordParameters = (): RemovePasswordParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-password',
validateFn: (params) => {
return params.password !== '';
},
});
};
@@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next';
import { ToolOperationConfig, ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { ReorganizePagesParameters } from '@app/hooks/tools/reorganizePages/useReorganizePagesParameters';
const buildFormData = (parameters: ReorganizePagesParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
if (parameters.customMode) {
formData.append('customMode', parameters.customMode);
}
if (parameters.pageNumbers) {
const cleaned = parameters.pageNumbers.replace(/\s+/g, '');
formData.append('pageNumbers', cleaned);
}
return formData;
};
export const reorganizePagesOperationConfig: ToolOperationConfig<ReorganizePagesParameters> = {
toolType: ToolType.singleFile,
buildFormData,
operationType: 'reorganizePages',
endpoint: '/api/v1/general/rearrange-pages',
};
export const useReorganizePagesOperation = () => {
const { t } = useTranslation();
return useToolOperation<ReorganizePagesParameters>({
...reorganizePagesOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('reorganizePages.error.failed', 'Failed to reorganize pages')
)
});
};
@@ -0,0 +1,39 @@
import { useState } from 'react';
export interface ReorganizePagesParameters {
customMode: string; // empty string means custom order using pageNumbers
pageNumbers: string; // e.g. "1,3,2,4-6"
}
export const defaultReorganizePagesParameters: ReorganizePagesParameters = {
customMode: '',
pageNumbers: '',
};
export const useReorganizePagesParameters = () => {
const [parameters, setParameters] = useState<ReorganizePagesParameters>(defaultReorganizePagesParameters);
const updateParameter = <K extends keyof ReorganizePagesParameters>(
key: K,
value: ReorganizePagesParameters[K]
) => {
setParameters(prev => ({ ...prev, [key]: value }));
};
const resetParameters = () => setParameters(defaultReorganizePagesParameters);
// If customMode is '' (custom) or 'DUPLICATE', a page order is required; otherwise it's optional/ignored
const validateParameters = (): boolean => {
const requiresOrder = parameters.customMode === '' || parameters.customMode === 'DUPLICATE';
return requiresOrder ? parameters.pageNumbers.trim().length > 0 : true;
};
return {
parameters,
updateParameter,
resetParameters,
validateParameters,
};
};
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RepairParameters, defaultParameters } from '@app/hooks/tools/repair/useRepairParameters';
// Static function that can be used by both the hook and automation executor
export const buildRepairFormData = (_parameters: RepairParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
// Static configuration object
export const repairOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRepairFormData,
operationType: 'repair',
endpoint: '/api/v1/misc/repair',
defaultParameters,
} as const;
export const useRepairOperation = () => {
const { t } = useTranslation();
return useToolOperation<RepairParameters>({
...repairOperationConfig,
getErrorMessage: createStandardErrorHandler(t('repair.error.failed', 'An error occurred while repairing the PDF.'))
});
};
@@ -0,0 +1,20 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface RepairParameters extends BaseParameters {
// Extends BaseParameters - ready for future parameter additions if needed
}
export const defaultParameters: RepairParameters = {
// No parameters needed
};
export type RepairParametersHook = BaseParametersHook<RepairParameters>;
export const useRepairParameters = (): RepairParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'repair',
// validateFn: optional custom validation if needed in future
});
};
@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { ReplaceColorParameters, defaultParameters } from '@app/hooks/tools/replaceColor/useReplaceColorParameters';
export const buildReplaceColorFormData = (parameters: ReplaceColorParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('replaceAndInvertOption', parameters.replaceAndInvertOption);
if (parameters.replaceAndInvertOption === 'HIGH_CONTRAST_COLOR') {
formData.append('highContrastColorCombination', parameters.highContrastColorCombination);
} else if (parameters.replaceAndInvertOption === 'CUSTOM_COLOR') {
formData.append('textColor', parameters.textColor);
formData.append('backGroundColor', parameters.backGroundColor);
}
return formData;
};
export const replaceColorOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildReplaceColorFormData,
operationType: 'replaceColor',
endpoint: '/api/v1/misc/replace-invert-pdf',
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useReplaceColorOperation = () => {
const { t } = useTranslation();
return useToolOperation<ReplaceColorParameters>({
...replaceColorOperationConfig,
getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the colour replacement.'))
});
};
@@ -0,0 +1,29 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface ReplaceColorParameters extends BaseParameters {
replaceAndInvertOption: 'HIGH_CONTRAST_COLOR' | 'CUSTOM_COLOR' | 'FULL_INVERSION';
highContrastColorCombination: 'WHITE_TEXT_ON_BLACK' | 'BLACK_TEXT_ON_WHITE' | 'YELLOW_TEXT_ON_BLACK' | 'GREEN_TEXT_ON_BLACK';
textColor: string;
backGroundColor: string;
}
export const defaultParameters: ReplaceColorParameters = {
replaceAndInvertOption: 'HIGH_CONTRAST_COLOR',
highContrastColorCombination: 'WHITE_TEXT_ON_BLACK',
textColor: '#000000',
backGroundColor: '#ffffff',
};
export type ReplaceColorParametersHook = BaseParametersHook<ReplaceColorParameters>;
export const useReplaceColorParameters = (): ReplaceColorParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'replace-invert-pdf',
validateFn: () => {
// All parameters are always valid as they have defaults
return true;
},
});
};
@@ -0,0 +1,101 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useRotateOperation } from '@app/hooks/tools/rotate/useRotateOperation';
import type { RotateParameters } from '@app/hooks/tools/rotate/useRotateParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation');
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Mock the error handler
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
}));
// Import the mocked function
import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
describe('useRotateOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<RotateParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: '',
isLoading: false,
errorMessage: null,
status: '',
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test.each([
{ angle: 0, expectedNormalized: 0 },
{ angle: 90, expectedNormalized: 90 },
{ angle: 180, expectedNormalized: 180 },
{ angle: 270, expectedNormalized: 270 },
{ angle: 360, expectedNormalized: 0 },
{ angle: -90, expectedNormalized: 270 },
{ angle: -180, expectedNormalized: 180 },
{ angle: -270, expectedNormalized: 90 },
{ angle: 450, expectedNormalized: 90 },
])('should create form data correctly with angle $angle (normalized to $expectedNormalized)', ({ angle, expectedNormalized }) => {
renderHook(() => useRotateOperation());
const callArgs = getToolConfig();
const testParameters: RotateParameters = { angle };
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const formData = callArgs.buildFormData(testParameters, testFile);
// Verify the form data contains the file
expect(formData.get('fileInput')).toBe(testFile);
// Verify angle parameter is normalized for backend
expect(formData.get('angle')).toBe(expectedNormalized.toString());
});
test('should use correct translation for error messages', () => {
renderHook(() => useRotateOperation());
expect(mockT).toHaveBeenCalledWith(
'rotate.error.failed',
'An error occurred while rotating the PDF.'
);
});
test.each([
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
{ property: 'endpoint' as const, expectedValue: '/api/v1/general/rotate-pdf' },
{ property: 'operationType' as const, expectedValue: 'rotate' }
])('should configure $property correctly', ({ property, expectedValue }) => {
renderHook(() => useRotateOperation());
const callArgs = getToolConfig();
expect(callArgs[property]).toBe(expectedValue);
});
});
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RotateParameters, defaultParameters, normalizeAngle } from '@app/hooks/tools/rotate/useRotateParameters';
// Static configuration that can be used by both the hook and automation executor
export const buildRotateFormData = (parameters: RotateParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Normalize angle for backend (0, 90, 180, 270)
formData.append("angle", normalizeAngle(parameters.angle).toString());
return formData;
};
// Static configuration object
export const rotateOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRotateFormData,
operationType: 'rotate',
endpoint: '/api/v1/general/rotate-pdf',
defaultParameters,
} as const;
export const useRotateOperation = () => {
const { t } = useTranslation();
return useToolOperation<RotateParameters>({
...rotateOperationConfig,
getErrorMessage: createStandardErrorHandler(t('rotate.error.failed', 'An error occurred while rotating the PDF.'))
});
};
@@ -0,0 +1,160 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRotateParameters, defaultParameters, normalizeAngle } from '@app/hooks/tools/rotate/useRotateParameters';
describe('useRotateParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRotateParameters());
expect(result.current.parameters).toEqual(defaultParameters);
expect(result.current.parameters.angle).toBe(0);
expect(result.current.hasRotation).toBe(false);
});
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useRotateParameters());
// Default should be valid
expect(result.current.validateParameters()).toBe(true);
// Set invalid angle
act(() => {
result.current.updateParameter('angle', 45);
});
expect(result.current.validateParameters()).toBe(false);
// Set valid angle
act(() => {
result.current.updateParameter('angle', 90);
});
expect(result.current.validateParameters()).toBe(true);
});
test('should rotate clockwise correctly', () => {
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(90);
expect(result.current.hasRotation).toBe(true);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(180);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(270);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(360);
expect(normalizeAngle(result.current.parameters.angle)).toBe(0);
expect(result.current.hasRotation).toBe(false);
});
test('should rotate anticlockwise correctly', () => {
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-90);
expect(result.current.hasRotation).toBe(true);
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-180);
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-270);
act(() => {
result.current.rotateAnticlockwise();
});
expect(result.current.parameters.angle).toBe(-360);
expect(normalizeAngle(result.current.parameters.angle)).toBe(0);
expect(result.current.hasRotation).toBe(false);
});
test('should normalize angles correctly', () => {
const { result } = renderHook(() => useRotateParameters());
expect(result.current.normalizeAngle(360)).toBe(0);
expect(result.current.normalizeAngle(450)).toBe(90);
expect(result.current.normalizeAngle(-90)).toBe(270);
expect(result.current.normalizeAngle(-180)).toBe(180);
});
test('should reset parameters correctly', () => {
const { result } = renderHook(() => useRotateParameters());
// Set some rotation
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(90);
act(() => {
result.current.rotateClockwise();
});
expect(result.current.parameters.angle).toBe(180);
// Reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toEqual(defaultParameters);
expect(result.current.hasRotation).toBe(false);
});
test('should update parameters', () => {
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.updateParameter('angle', 450);
});
expect(result.current.parameters.angle).toBe(450);
expect(normalizeAngle(result.current.parameters.angle)).toBe(90);
act(() => {
result.current.updateParameter('angle', -90);
});
expect(result.current.parameters.angle).toBe(-90);
expect(normalizeAngle(result.current.parameters.angle)).toBe(270);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useRotateParameters());
expect(result.current.getEndpointName()).toBe('rotate-pdf');
});
test('should detect rotation state correctly', () => {
const { result } = renderHook(() => useRotateParameters());
// Initially no rotation
expect(result.current.hasRotation).toBe(false);
// After rotation
act(() => {
result.current.rotateClockwise();
});
expect(result.current.hasRotation).toBe(true);
// After full rotation (360 degrees) - 3 more clicks to complete 360°
for (let i = 0; i < 3; i++) {
act(() => {
result.current.rotateClockwise();
});
}
expect(result.current.hasRotation).toBe(false);
});
});
@@ -0,0 +1,67 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { useMemo, useCallback } from 'react';
// Normalize angle to number between 0 and 359
export const normalizeAngle = (angle: number): number => {
return ((angle % 360) + 360) % 360;
};
export interface RotateParameters extends BaseParameters {
angle: number; // Current rotation angle (0, 90, 180, 270)
}
export const defaultParameters: RotateParameters = {
angle: 0,
};
export type RotateParametersHook = BaseParametersHook<RotateParameters> & {
rotateClockwise: () => void;
rotateAnticlockwise: () => void;
hasRotation: boolean;
normalizeAngle: (angle: number) => number;
};
export const useRotateParameters = (): RotateParametersHook => {
const baseHook = useBaseParameters({
defaultParameters,
endpointName: 'rotate-pdf',
validateFn: (params) => {
// Angle must be a multiple of 90
return params.angle % 90 === 0;
},
});
// Rotate clockwise by 90 degrees
const rotateClockwise = useCallback(() => {
baseHook.updateParameter('angle', baseHook.parameters.angle + 90);
}, [baseHook]);
// Rotate anticlockwise by 90 degrees
const rotateAnticlockwise = useCallback(() => {
baseHook.updateParameter('angle', baseHook.parameters.angle - 90);
}, [baseHook]);
// Check if rotation will actually change the document
const hasRotation = useMemo(() => {
const normalized = normalizeAngle(baseHook.parameters.angle);
return normalized !== 0;
}, [baseHook.parameters.angle, normalizeAngle]);
// Override updateParameter - no longer normalize angles here
const updateParameter = useCallback(<K extends keyof RotateParameters>(
parameter: K,
value: RotateParameters[K]
) => {
baseHook.updateParameter(parameter, value);
}, [baseHook]);
return {
...baseHook,
updateParameter,
rotateClockwise,
rotateAnticlockwise,
hasRotation,
normalizeAngle,
};
};
@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { SanitizeParameters, defaultParameters } from '@app/hooks/tools/sanitize/useSanitizeParameters';
// Static function that can be used by both the hook and automation executor
export const buildSanitizeFormData = (parameters: SanitizeParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Add parameters
formData.append('removeJavaScript', parameters.removeJavaScript.toString());
formData.append('removeEmbeddedFiles', parameters.removeEmbeddedFiles.toString());
formData.append('removeXMPMetadata', parameters.removeXMPMetadata.toString());
formData.append('removeMetadata', parameters.removeMetadata.toString());
formData.append('removeLinks', parameters.removeLinks.toString());
formData.append('removeFonts', parameters.removeFonts.toString());
return formData;
};
// Static configuration object
export const sanitizeOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSanitizeFormData,
operationType: 'sanitize',
endpoint: '/api/v1/security/sanitize-pdf',
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useSanitizeOperation = () => {
const { t } = useTranslation();
return useToolOperation<SanitizeParameters>({
...sanitizeOperationConfig,
getErrorMessage: createStandardErrorHandler(t('sanitize.error.failed', 'An error occurred while sanitising the PDF.'))
});
};
@@ -0,0 +1,90 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { defaultParameters, useSanitizeParameters } from '@app/hooks/tools/sanitize/useSanitizeParameters';
describe('useSanitizeParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useSanitizeParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update individual parameters', () => {
const { result } = renderHook(() => useSanitizeParameters());
act(() => {
result.current.updateParameter('removeXMPMetadata', true);
});
expect(result.current.parameters).toStrictEqual({
...defaultParameters, // Other params unchanged
removeXMPMetadata: true,
});
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useSanitizeParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('removeXMPMetadata', true);
result.current.updateParameter('removeJavaScript', false);
});
expect(result.current.parameters.removeXMPMetadata).toBe(true);
expect(result.current.parameters.removeJavaScript).toBe(false);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useSanitizeParameters());
expect(result.current.getEndpointName()).toBe('sanitize-pdf');
});
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useSanitizeParameters());
// Default state should be valid (has removeJavaScript and removeEmbeddedFiles enabled)
expect(result.current.validateParameters()).toBe(true);
// Turn off all parameters - should be invalid
act(() => {
result.current.updateParameter('removeJavaScript', false);
result.current.updateParameter('removeEmbeddedFiles', false);
});
expect(result.current.validateParameters()).toBe(false);
// Turn on one parameter - should be valid again
act(() => {
result.current.updateParameter('removeLinks', true);
});
expect(result.current.validateParameters()).toBe(true);
});
test('should handle all parameter types correctly', () => {
const { result } = renderHook(() => useSanitizeParameters());
const allParameters = Object.keys(defaultParameters) as (keyof typeof defaultParameters)[];
allParameters.forEach(param => {
act(() => {
result.current.updateParameter(param, true);
});
expect(result.current.parameters[param]).toBe(true);
act(() => {
result.current.updateParameter(param, false);
});
expect(result.current.parameters[param]).toBe(false);
});
});
});
@@ -0,0 +1,32 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface SanitizeParameters extends BaseParameters {
removeJavaScript: boolean;
removeEmbeddedFiles: boolean;
removeXMPMetadata: boolean;
removeMetadata: boolean;
removeLinks: boolean;
removeFonts: boolean;
}
export const defaultParameters: SanitizeParameters = {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
};
export type SanitizeParametersHook = BaseParametersHook<SanitizeParameters>;
export const useSanitizeParameters = (): SanitizeParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'sanitize-pdf',
validateFn: (params) => {
return Object.values(params).some(value => value === true);
},
});
};
@@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { ScannerImageSplitParameters, defaultParameters } from '@app/hooks/tools/scannerImageSplit/useScannerImageSplitParameters';
import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('angle_threshold', parameters.angle_threshold.toString());
formData.append('tolerance', parameters.tolerance.toString());
formData.append('min_area', parameters.min_area.toString());
formData.append('min_contour_area', parameters.min_contour_area.toString());
formData.append('border_size', parameters.border_size.toString());
return formData;
};
// Static configuration object
export const scannerImageSplitOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildScannerImageSplitFormData,
operationType: 'scannerImageSplit',
endpoint: '/api/v1/misc/extract-image-scans',
defaultParameters,
} as const;
export const useScannerImageSplitOperation = () => {
const { t } = useTranslation();
const { extractZipFiles } = useToolResources();
// Custom response handler that extracts ZIP files containing images
// Can't add to exported config because it requires access to the hook so must be part of the hook
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
try {
// Scanner image split returns ZIP files with multiple images
const extractedFiles = await extractZipFiles(blob);
// If extraction succeeded and returned files, use them
if (extractedFiles.length > 0) {
return extractedFiles;
}
} catch (error) {
console.warn('Failed to extract as ZIP, treating as single file:', error);
}
// Fallback: treat as single file (PNG image)
const inputFileName = originalFiles[0]?.name || 'document';
const baseFileName = inputFileName.replace(/\.[^.]+$/, '');
const singleFile = new File([blob], `${baseFileName}.png`, { type: 'image/png' });
return [singleFile];
}, [extractZipFiles]);
const config: ToolOperationConfig<ScannerImageSplitParameters> = {
...scannerImageSplitOperationConfig,
responseHandler,
getErrorMessage: createStandardErrorHandler(t('scannerImageSplit.error.failed', 'An error occurred while extracting image scans.'))
};
return useToolOperation(config);
};
@@ -0,0 +1,31 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface ScannerImageSplitParameters extends BaseParameters {
angle_threshold: number;
tolerance: number;
min_area: number;
min_contour_area: number;
border_size: number;
}
export const defaultParameters: ScannerImageSplitParameters = {
angle_threshold: 10,
tolerance: 30,
min_area: 10000,
min_contour_area: 500,
border_size: 1,
};
export type ScannerImageSplitParametersHook = BaseParametersHook<ScannerImageSplitParameters>;
export const useScannerImageSplitParameters = (): ScannerImageSplitParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'extract-image-scans',
validateFn: () => {
// All parameters are numeric with defaults, validation handled by form
return true;
},
});
};
@@ -0,0 +1,123 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
/**
* State conditions that affect accordion behavior
*/
export interface AccordionStateConditions {
/** Whether files are present (steps collapse when false) */
hasFiles?: boolean;
/** Whether results are available (steps collapse when true) */
hasResults?: boolean;
/** Whether the accordion is disabled (steps collapse when true) */
disabled?: boolean;
}
/**
* Configuration for the useAccordionSteps hook
*/
export interface UseAccordionStepsConfig<T extends string | number | symbol> {
/** Special step that represents "no step open" state */
noneValue: T;
/** Initial step to open */
initialStep: T;
/** Current state conditions that affect accordion behavior */
stateConditions?: AccordionStateConditions;
/** Callback to run when interacting with a step when we have results (usually used for resetting params) */
afterResults?: () => void;
}
/**
* Return type for the useAccordionSteps hook
*/
export interface AccordionStepsAPI<T extends string | number | symbol> {
/** Currently active/open step (noneValue if no step is open) */
currentStep: T;
/** Get whether a specific step should be collapsed */
getCollapsedState: (step: T) => boolean;
/** Toggle a step open/closed (accordion behavior - only one open at a time) */
handleStepToggle: (step: T) => void;
/** Set the currently open step */
setOpenStep: (step: T) => void;
/** Close all steps */
closeAllSteps: () => void;
}
/**
* Accordion-style step management hook.
*
* Provides sophisticated accordion behavior where only one step can be open at a time,
* with configurable collapse conditions.
*/
export function useAccordionSteps<T extends string | number | symbol>(
config: UseAccordionStepsConfig<T>
): AccordionStepsAPI<T> {
const { initialStep, stateConditions, noneValue } = config;
const [openStep, setOpenStep] = useState<T>(initialStep);
// Determine if all steps should be collapsed based on conditions
const shouldCollapseAll = useMemo(() => {
if (!stateConditions) {
return false;
}
return (
(stateConditions.hasFiles === false) ||
(stateConditions.hasResults === true) ||
(stateConditions.disabled === true)
);
}, [stateConditions]);
// Get collapsed state for a specific step
const getCollapsedState = useCallback((step: T): boolean => {
if (shouldCollapseAll) {
return true;
} else {
return openStep !== step;
}
}, [openStep, shouldCollapseAll]);
// Handle step toggle with accordion behavior
const handleStepToggle = useCallback((step: T) => {
if (stateConditions?.hasResults) {
config.afterResults?.();
}
// If all steps should be collapsed, don't allow opening
if (shouldCollapseAll) {
return;
}
// Accordion behavior: if clicking the open step, close it; otherwise open the clicked step
setOpenStep(currentStep => {
if (currentStep === step) {
// Clicking the open step - close it
return noneValue;
} else {
// Open the clicked step
return step;
}
});
}, [shouldCollapseAll, noneValue, stateConditions?.hasResults, config.afterResults]);
// Close all steps
const closeAllSteps = useCallback(() => {
setOpenStep(noneValue);
}, [noneValue]);
// Automatically reset to first step if we have results
// Note that everything is still collapsed when this happens, it's just preparing for re-running the tool
useEffect(() => {
if (stateConditions?.hasResults) {
setOpenStep(initialStep);
}
}, [stateConditions?.hasResults, initialStep]);
return {
currentStep: shouldCollapseAll ? noneValue : openStep,
getCollapsedState,
handleStepToggle,
setOpenStep,
closeAllSteps
};
}
@@ -0,0 +1,56 @@
import { useState, useCallback, Dispatch, SetStateAction } from 'react';
export interface BaseParametersHook<T> {
parameters: T;
setParameters: Dispatch<SetStateAction<T>>;
updateParameter: <K extends keyof T>(parameter: K, value: T[K]) => void;
resetParameters: () => void;
validateParameters: () => boolean;
getEndpointName: () => string;
}
export interface BaseParametersConfig<T> {
defaultParameters: T;
endpointName: string | ((params: T) => string);
validateFn?: (params: T) => boolean;
}
export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParametersHook<T> {
const [parameters, setParameters] = useState<T>(config.defaultParameters);
const updateParameter = useCallback(<K extends keyof T>(parameter: K, value: T[K]) => {
setParameters(prev => ({
...prev,
[parameter]: value,
}));
}, []);
const resetParameters = useCallback(() => {
setParameters(config.defaultParameters);
}, [config.defaultParameters]);
const validateParameters = useCallback(() => {
return config.validateFn ? config.validateFn(parameters) : true;
}, [parameters, config.validateFn]);
const endpointName = config.endpointName;
let getEndpointName: () => string;
if (typeof endpointName === "string") {
getEndpointName = useCallback(() => {
return endpointName;
}, []);
} else {
getEndpointName = useCallback(() => {
return endpointName(parameters);
}, [parameters]);
}
return {
parameters,
setParameters,
updateParameter,
resetParameters,
validateParameters,
getEndpointName,
};
}
@@ -0,0 +1,141 @@
import { useEffect, useCallback, useRef } from 'react';
import { useFileSelection } from '@app/contexts/FileContext';
import { useEndpointEnabled } from '@app/hooks/useEndpointConfig';
import { BaseToolProps } from '@app/types/tool';
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
import { BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { StirlingFile } from '@app/types/fileContext';
interface BaseToolReturn<TParams, TParamsHook extends BaseParametersHook<TParams>> {
// File management
selectedFiles: StirlingFile[];
// Tool-specific hooks
params: TParamsHook;
operation: ToolOperationHook<TParams>;
// Endpoint validation
endpointEnabled: boolean | null;
endpointLoading: boolean;
// Standard handlers
handleExecute: () => Promise<void>;
handleThumbnailClick: (file: File) => void;
handleSettingsReset: () => void;
handleUndo: () => Promise<void>;
// Standard computed state
hasFiles: boolean;
hasResults: boolean;
settingsCollapsed: boolean;
}
/**
* Base tool hook for tool components. Manages standard behaviour for tools.
*/
export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TParams>>(
toolName: string,
useParams: () => TParamsHook,
useOperation: () => ToolOperationHook<TParams>,
props: BaseToolProps,
options?: { minFiles?: number }
): BaseToolReturn<TParams, TParamsHook> {
const minFiles = options?.minFiles ?? 1;
const { onPreviewFile, onComplete, onError } = props;
// File selection
const { selectedFiles } = useFileSelection();
const previousFileCount = useRef(selectedFiles.length);
// Tool-specific hooks
const params = useParams();
const operation = useOperation();
// Endpoint validation using parameters hook
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(params.getEndpointName());
// Reset results when parameters change
useEffect(() => {
operation.resetResults();
onPreviewFile?.(null);
}, [params.parameters]);
// Reset results when selected files change
useEffect(() => {
if (selectedFiles.length > 0) {
operation.resetResults();
onPreviewFile?.(null);
}
}, [selectedFiles.length]);
// Reset parameters when transitioning from 0 files to at least 1 file
useEffect(() => {
const currentFileCount = selectedFiles.length;
const prevFileCount = previousFileCount.current;
if (prevFileCount === 0 && currentFileCount > 0) {
params.resetParameters();
}
previousFileCount.current = currentFileCount;
}, [selectedFiles.length]);
// Standard handlers
const handleExecute = useCallback(async () => {
try {
await operation.executeOperation(params.parameters, selectedFiles);
if (operation.files && onComplete) {
onComplete(operation.files);
}
} catch (error) {
if (onError) {
const message = error instanceof Error ? error.message : `${toolName} operation failed`;
onError(message);
}
}
}, [operation, params.parameters, selectedFiles, onComplete, onError, toolName]);
const handleThumbnailClick = useCallback((file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', toolName);
}, [onPreviewFile, toolName]);
const handleSettingsReset = useCallback(() => {
operation.resetResults();
onPreviewFile?.(null);
}, [operation, onPreviewFile]);
const handleUndo = useCallback(async () => {
await operation.undoOperation();
onPreviewFile?.(null);
}, [operation, onPreviewFile]);
// Standard computed state
const hasFiles = selectedFiles.length >= minFiles;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;
return {
// File management
selectedFiles,
// Tool-specific hooks
params,
operation,
// Endpoint validation
endpointEnabled,
endpointLoading,
// Handlers
handleExecute,
handleThumbnailClick,
handleSettingsReset,
handleUndo,
// State
hasFiles,
hasResults,
settingsCollapsed
};
}
@@ -0,0 +1,67 @@
import { useState, useCallback } from 'react';
export interface OperationResult {
files: File[];
thumbnails: string[];
isGeneratingThumbnails: boolean;
}
export interface OperationResultsHook {
results: OperationResult;
downloadUrl: string | null;
status: string;
errorMessage: string | null;
isLoading: boolean;
setResults: (results: OperationResult) => void;
setDownloadUrl: (url: string | null) => void;
setStatus: (status: string) => void;
setErrorMessage: (error: string | null) => void;
setIsLoading: (loading: boolean) => void;
resetResults: () => void;
clearError: () => void;
}
const initialResults: OperationResult = {
files: [],
thumbnails: [],
isGeneratingThumbnails: false,
};
export const useOperationResults = (): OperationResultsHook => {
const [results, setResults] = useState<OperationResult>(initialResults);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [status, setStatus] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const resetResults = useCallback(() => {
setResults(initialResults);
setDownloadUrl(null);
setStatus('');
setErrorMessage(null);
setIsLoading(false);
}, []);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
return {
results,
downloadUrl,
status,
errorMessage,
isLoading,
setResults,
setDownloadUrl,
setStatus,
setErrorMessage,
setIsLoading,
resetResults,
clearError,
};
};
@@ -0,0 +1,117 @@
import { useCallback, useRef } from 'react';
import axios, {type CancelTokenSource} from 'axios'; // Real axios for static methods (CancelToken, isCancel)
import apiClient from '@app/services/apiClient'; // Our configured instance
import { processResponse, ResponseHandler } from '@app/utils/toolResponseProcessor';
import { isEmptyOutput } from '@app/services/errorUtils';
import type { ProcessingProgress } from '@app/hooks/tools/shared/useToolState';
export interface ApiCallsConfig<TParams = void> {
endpoint: string | ((params: TParams) => string);
buildFormData: (params: TParams, file: File) => FormData;
filePrefix?: string;
responseHandler?: ResponseHandler;
preserveBackendFilename?: boolean;
}
export const useToolApiCalls = <TParams = void>() => {
const cancelTokenRef = useRef<CancelTokenSource | null>(null);
const processFiles = useCallback(async (
params: TParams,
validFiles: File[],
config: ApiCallsConfig<TParams>,
onProgress: (progress: ProcessingProgress) => void,
onStatus: (status: string) => void,
markFileError?: (fileId: string) => void,
): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => {
const processedFiles: File[] = [];
const successSourceIds: string[] = [];
const failedFiles: string[] = [];
const total = validFiles.length;
// Create cancel token for this operation
cancelTokenRef.current = axios.CancelToken.source();
for (let i = 0; i < validFiles.length; i++) {
const file = validFiles[i];
console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: (file as any).fileId });
onProgress({ current: i + 1, total, currentFileName: file.name });
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
try {
const formData = config.buildFormData(params, file);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
console.debug('[processFiles] POST', { endpoint, name: file.name });
const response = await apiClient.post(endpoint, formData, {
responseType: 'blob',
cancelToken: cancelTokenRef.current?.token,
});
console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status });
// Forward to shared response processor (uses tool-specific responseHandler if provided)
const responseFiles = await processResponse(
response.data,
[file],
config.filePrefix,
config.responseHandler,
config.preserveBackendFilename ? response.headers : undefined
);
// Guard: some endpoints may return an empty/0-byte file with 200
const empty = isEmptyOutput(responseFiles);
if (empty) {
console.warn('[processFiles] Empty output treated as failure', { name: file.name });
failedFiles.push(file.name);
try {
(markFileError as any)?.((file as any).fileId);
} catch (e) {
console.debug('markFileError', e);
}
continue;
}
processedFiles.push(...responseFiles);
// record source id as successful
successSourceIds.push((file as any).fileId);
console.debug('[processFiles] Success', { name: file.name, produced: responseFiles.length });
} catch (error) {
if (axios.isCancel(error)) {
throw new Error('Operation was cancelled');
}
console.error('[processFiles] Failed', { name: file.name, error });
failedFiles.push(file.name);
// mark errored file so UI can highlight
try {
(markFileError as any)?.((file as any).fileId);
} catch (e) {
console.debug('markFileError', e);
}
}
}
if (failedFiles.length > 0 && processedFiles.length === 0) {
throw new Error(`Failed to process all files: ${failedFiles.join(', ')}`);
}
if (failedFiles.length > 0) {
onStatus(`Processed ${processedFiles.length}/${total} files. Failed: ${failedFiles.join(', ')}`);
} else {
onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`);
}
console.debug('[processFiles] Completed batch', { total, successes: successSourceIds.length, outputs: processedFiles.length, failed: failedFiles.length });
return { outputFiles: processedFiles, successSourceIds };
}, []);
const cancelOperation = useCallback(() => {
if (cancelTokenRef.current) {
cancelTokenRef.current.cancel('Operation cancelled by user');
cancelTokenRef.current = null;
}
}, []);
return {
processFiles,
cancelOperation,
};
};
@@ -0,0 +1,535 @@
import { useCallback, useRef, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
import { useTranslation } from 'react-i18next';
import { useFileContext } from '@app/contexts/FileContext';
import { useToolState, type ProcessingProgress } from '@app/hooks/tools/shared/useToolState';
import { useToolApiCalls, type ApiCallsConfig } from '@app/hooks/tools/shared/useToolApiCalls';
import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
import { extractErrorMessage } from '@app/utils/toolErrorHandler';
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '@app/types/fileContext';
import { FILE_EVENTS } from '@app/services/errorUtils';
import { ResponseHandler } from '@app/utils/toolResponseProcessor';
import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
import { ToolOperation } from '@app/types/file';
import { ToolId } from '@app/types/toolId';
// Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler };
export enum ToolType {
singleFile,
multiFile,
custom,
}
/**
* Configuration for tool operations defining processing behavior and API integration.
*
* Supports three patterns:
* 1. Single-file tools: multiFileEndpoint: false, processes files individually
* 2. Multi-file tools: multiFileEndpoint: true, single API call with all files
* 3. Complex tools: customProcessor handles all processing logic
*/
interface BaseToolOperationConfig<TParams> {
/** Operation identifier for tracking and logging */
operationType: ToolId;
/**
* Prefix added to processed filenames (e.g., 'compressed_', 'split_').
* Only generally useful for multiFile interfaces.
*/
filePrefix?: string;
/**
* Whether to preserve the filename provided by the backend in response headers.
* When true, ignores filePrefix and uses the filename from Content-Disposition header.
* Useful for tools like auto-rename where the backend determines the final filename.
*/
preserveBackendFilename?: boolean;
/** How to handle API responses (e.g., ZIP extraction, single file response) */
responseHandler?: ResponseHandler;
/** Extract user-friendly error messages from API errors */
getErrorMessage?: (error: any) => string;
/** Default parameter values for automation */
defaultParameters?: TParams;
}
export interface SingleFileToolOperationConfig<TParams> extends BaseToolOperationConfig<TParams> {
/** This tool processes one file at a time. */
toolType: ToolType.singleFile;
/** Builds FormData for API request. */
buildFormData: ((params: TParams, file: File) => FormData);
/** API endpoint for the operation. Can be static string or function for dynamic routing. */
endpoint: string | ((params: TParams) => string);
customProcessor?: undefined;
}
export interface MultiFileToolOperationConfig<TParams> extends BaseToolOperationConfig<TParams> {
/** This tool processes multiple files at once. */
toolType: ToolType.multiFile;
/** Prefix added to processed filename (e.g., 'merged_', 'split_') */
filePrefix: string;
/** Builds FormData for API request. */
buildFormData: ((params: TParams, files: File[]) => FormData);
/** API endpoint for the operation. Can be static string or function for dynamic routing. */
endpoint: string | ((params: TParams) => string);
customProcessor?: undefined;
}
export interface CustomToolOperationConfig<TParams> extends BaseToolOperationConfig<TParams> {
/** This tool has custom behaviour. */
toolType: ToolType.custom;
buildFormData?: undefined;
endpoint?: undefined;
/**
* Custom processing logic that completely bypasses standard file processing.
* This tool handles all API calls, response processing, and file creation.
* Use for tools with complex routing logic or non-standard processing requirements.
*/
customProcessor: (params: TParams, files: File[]) => Promise<File[]>;
}
export type ToolOperationConfig<TParams = void> = SingleFileToolOperationConfig<TParams> | MultiFileToolOperationConfig<TParams> | CustomToolOperationConfig<TParams>;
/**
* Complete tool operation interface with execution capability
*/
export interface ToolOperationHook<TParams = void> {
// State
files: File[];
thumbnails: string[];
isGeneratingThumbnails: boolean;
downloadUrl: string | null;
downloadFilename: string;
isLoading: boolean;
status: string;
errorMessage: string | null;
progress: ProcessingProgress | null;
// Actions
executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise<void>;
resetResults: () => void;
clearError: () => void;
cancelOperation: () => void;
undoOperation: () => Promise<void>;
}
// Re-export for backwards compatibility
export { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
/**
* Shared hook for tool operations providing consistent error handling, progress tracking,
* and FileContext integration. Eliminates boilerplate while maintaining flexibility.
*
* Supports three tool patterns:
* 1. Single-file tools: Set multiFileEndpoint: false, processes files individually
* 2. Multi-file tools: Set multiFileEndpoint: true, single API call with all files
* 3. Complex tools: Provide customProcessor for full control over processing logic
*
* @param config - Tool operation configuration
* @returns Hook interface with state and execution methods
*/
export const useToolOperation = <TParams>(
config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => {
const { t } = useTranslation();
const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
// Composed hooks
const { state, actions } = useToolState();
const { actions: fileActions } = useFileContext();
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles } = useToolResources();
// Track last operation for undo functionality
const lastOperationRef = useRef<{
inputFiles: File[];
inputStirlingFileStubs: StirlingFileStub[];
outputFileIds: FileId[];
} | null>(null);
const executeOperation = useCallback(async (
params: TParams,
selectedFiles: StirlingFile[]
): Promise<void> => {
// Validation
if (selectedFiles.length === 0) {
actions.setError(t('noFileSelected', 'No files selected'));
return;
}
// Handle zero-byte inputs explicitly: mark as error and continue with others
const zeroByteFiles = selectedFiles.filter(file => (file as any)?.size === 0);
if (zeroByteFiles.length > 0) {
try {
for (const f of zeroByteFiles) {
(fileActions.markFileError as any)((f as any).fileId);
}
} catch (e) {
console.log('markFileError', e);
}
}
const validFiles = selectedFiles.filter(file => (file as any)?.size > 0);
if (validFiles.length === 0) {
actions.setError(t('noValidFiles', 'No valid files to process'));
return;
}
// Reset state
actions.setLoading(true);
actions.setError(null);
actions.resetResults();
cleanupBlobUrls();
// Prepare files with history metadata injection (for PDFs)
actions.setStatus('Processing files...');
// Listen for global error file id events from HTTP interceptor during this run
let externalErrorFileIds: string[] = [];
const errorListener = (e: Event) => {
const detail = (e as CustomEvent)?.detail as any;
if (detail?.fileIds) {
externalErrorFileIds = Array.isArray(detail.fileIds) ? detail.fileIds : [];
}
};
window.addEventListener(FILE_EVENTS.markError, errorListener as EventListener);
try {
let processedFiles: File[];
let successSourceIds: string[] = [];
// Use original files directly (no PDF metadata injection - history stored in IndexedDB)
const filesForAPI = extractFiles(validFiles);
switch (config.toolType) {
case ToolType.singleFile: {
// Individual file processing - separate API call per file
const apiCallsConfig: ApiCallsConfig<TParams> = {
endpoint: config.endpoint,
buildFormData: config.buildFormData,
filePrefix: config.filePrefix,
responseHandler: config.responseHandler,
preserveBackendFilename: config.preserveBackendFilename
};
console.debug('[useToolOperation] Multi-file start', { count: filesForAPI.length });
const result = await processFiles(
params,
filesForAPI,
apiCallsConfig,
actions.setProgress,
actions.setStatus,
fileActions.markFileError as any
);
processedFiles = result.outputFiles;
successSourceIds = result.successSourceIds as any;
console.debug('[useToolOperation] Multi-file results', { outputFiles: processedFiles.length, successSources: result.successSourceIds.length });
break;
}
case ToolType.multiFile: {
// Multi-file processing - single API call with all files
actions.setStatus('Processing files...');
const formData = config.buildFormData(params, filesForAPI);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, filesForAPI);
} else if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - add prefix to first original filename
const filename = `${config.filePrefix}${filesForAPI[0]?.name || 'document.pdf'}`;
const singleFile = new File([response.data], filename, { type: 'application/pdf' });
processedFiles = [singleFile];
} else {
// Default: assume ZIP response for multi-file endpoints
// Note: extractZipFiles will check preferences.autoUnzip setting
processedFiles = await extractZipFiles(response.data);
}
// Assume all inputs succeeded together unless server provided an error earlier
successSourceIds = validFiles.map(f => (f as any).fileId) as any;
break;
}
case ToolType.custom: {
actions.setStatus('Processing files...');
processedFiles = await config.customProcessor(params, filesForAPI);
// Try to map outputs back to inputs by filename (before extension)
const inputBaseNames = new Map<string, string>();
for (const f of validFiles) {
const base = (f.name || '').replace(/\.[^.]+$/, '').toLowerCase();
inputBaseNames.set(base, (f as any).fileId);
}
const mappedSuccess: string[] = [];
for (const out of processedFiles) {
const base = (out.name || '').replace(/\.[^.]+$/, '').toLowerCase();
const id = inputBaseNames.get(base);
if (id) mappedSuccess.push(id);
}
// Fallback to naive alignment if names don't match
if (mappedSuccess.length === 0) {
successSourceIds = validFiles.slice(0, processedFiles.length).map(f => (f as any).fileId) as any;
} else {
successSourceIds = mappedSuccess as any;
}
break;
}
}
// Normalize error flags across tool types: mark failures, clear successes
try {
const allInputIds = validFiles.map(f => (f as any).fileId) as unknown as string[];
const okSet = new Set((successSourceIds as unknown as string[]) || []);
// Clear errors on successes
for (const okId of okSet) {
try { (fileActions.clearFileError as any)(okId); } catch (_e) { void _e; }
}
// Mark errors on inputs that didn't succeed
for (const id of allInputIds) {
if (!okSet.has(id)) {
try { (fileActions.markFileError as any)(id); } catch (_e) { void _e; }
}
}
} catch (_e) { void _e; }
if (externalErrorFileIds.length > 0) {
// If backend told us which sources failed, prefer that mapping
successSourceIds = validFiles
.map(f => (f as any).fileId)
.filter(id => !externalErrorFileIds.includes(id)) as any;
// Also mark failed IDs immediately
try {
for (const badId of externalErrorFileIds) {
(fileActions.markFileError as any)(badId);
}
} catch (_e) { void _e; }
}
if (processedFiles.length > 0) {
actions.setFiles(processedFiles);
// Generate thumbnails and download URL concurrently
actions.setGeneratingThumbnails(true);
const [thumbnails, downloadInfo] = await Promise.all([
generateThumbnails(processedFiles),
createDownloadInfo(processedFiles, config.operationType)
]);
actions.setGeneratingThumbnails(false);
actions.setThumbnails(thumbnails);
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
// Replace input files with processed files (consumeFiles handles pinning)
const inputFileIds: FileId[] = [];
const inputStirlingFileStubs: StirlingFileStub[] = [];
// Build parallel arrays of IDs and records for undo tracking
for (const file of validFiles) {
const fileId = file.fileId;
const record = selectors.getStirlingFileStub(fileId);
if (record) {
inputFileIds.push(fileId);
inputStirlingFileStubs.push(record);
} else {
console.warn(`No file stub found for file: ${file.name}`);
}
}
// Create new tool operation
const newToolOperation: ToolOperation = {
toolId: config.operationType,
timestamp: Date.now()
};
// Generate fresh processedFileMetadata for all processed files to ensure accuracy
actions.setStatus('Generating metadata for processed files...');
const processedFileMetadataArray = await Promise.all(
processedFiles.map(file => generateProcessedFileMetadata(file))
);
// Always create child stubs linking back to the successful source inputs
const successInputStubs = successSourceIds
.map((id) => selectors.getStirlingFileStub(id as any))
.filter(Boolean) as StirlingFileStub[];
if (successInputStubs.length !== processedFiles.length) {
console.warn('[useToolOperation] Mismatch successInputStubs vs outputs', {
successInputStubs: successInputStubs.length,
outputs: processedFiles.length,
});
}
const outputStirlingFileStubs = processedFiles.map((resultingFile, index) =>
createChildStub(
successInputStubs[index] || inputStirlingFileStubs[index] || inputStirlingFileStubs[0],
newToolOperation,
resultingFile,
thumbnails[index],
processedFileMetadataArray[index]
)
);
// Create StirlingFile objects from processed files and child stubs
const outputStirlingFiles = processedFiles.map((file, index) => {
const childStub = outputStirlingFileStubs[index];
return createStirlingFile(file, childStub.id);
});
// Build consumption arrays aligned to the successful source IDs
const toConsumeInputIds = successSourceIds.filter((id: string) => inputFileIds.includes(id as any)) as unknown as FileId[];
// Outputs and stubs are already ordered by success sequence
console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs);
// Store operation data for undo (only store what we need to avoid memory bloat)
lastOperationRef.current = {
inputFiles: extractFiles(validFiles), // Convert to File objects for undo
inputStirlingFileStubs: inputStirlingFileStubs.map(record => ({ ...record })), // Deep copy to avoid reference issues
outputFileIds
};
}
} catch (error: any) {
// Centralized 422 handler: mark provided IDs in errorFileIds
try {
const status = (error?.response?.status as number | undefined);
if (status === 422) {
const payload = error?.response?.data;
let parsed: any = payload;
if (typeof payload === 'string') {
try { parsed = JSON.parse(payload); } catch { parsed = payload; }
} else if (payload && typeof (payload as any).text === 'function') {
// Blob or Response-like object from axios when responseType='blob'
const text = await (payload as Blob).text();
try { parsed = JSON.parse(text); } catch { parsed = text; }
}
let ids: string[] | undefined = Array.isArray(parsed?.errorFileIds) ? parsed.errorFileIds : undefined;
if (!ids && typeof parsed === 'string') {
const match = parsed.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);
if (match && match.length > 0) ids = Array.from(new Set(match));
}
if (ids && ids.length > 0) {
for (const badId of ids) {
try { (fileActions.markFileError as any)(badId); } catch (_e) { void _e; }
}
actions.setStatus('Process failed due to invalid/corrupted file(s)');
// Avoid duplicating toast messaging here
return;
}
}
} catch (_e) { void _e; }
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
actions.setError(errorMessage);
actions.setStatus('');
} finally {
window.removeEventListener(FILE_EVENTS.markError, errorListener as EventListener);
actions.setLoading(false);
actions.setProgress(null);
}
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles]);
const cancelOperation = useCallback(() => {
cancelApiCalls();
actions.setLoading(false);
actions.setProgress(null);
actions.setStatus('Operation cancelled');
}, [cancelApiCalls, actions]);
const resetResults = useCallback(() => {
cleanupBlobUrls();
actions.resetResults();
// Clear undo data when results are reset to prevent memory leaks
lastOperationRef.current = null;
}, [cleanupBlobUrls, actions]);
// Cleanup on unmount to prevent memory leaks
useEffect(() => {
return () => {
lastOperationRef.current = null;
};
}, []);
const undoOperation = useCallback(async () => {
if (!lastOperationRef.current) {
actions.setError(t('noOperationToUndo', 'No operation to undo'));
return;
}
const { inputFiles, inputStirlingFileStubs, outputFileIds } = lastOperationRef.current;
// Validate that we have data to undo
if (inputFiles.length === 0 || inputStirlingFileStubs.length === 0) {
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
return;
}
if (outputFileIds.length === 0) {
actions.setError(t('noFilesToUndo', 'Cannot undo: no files were processed in the last operation'));
return;
}
try {
// Undo the consume operation
await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
// Clear results and operation tracking
resetResults();
lastOperationRef.current = null;
// Show success message
actions.setStatus(t('undoSuccess', 'Operation undone successfully'));
} catch (error: any) {
let errorMessage = extractErrorMessage(error);
// Provide more specific error messages based on error type
if (error.message?.includes('Mismatch between input files')) {
errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted');
} else if (error.message?.includes('IndexedDB')) {
errorMessage = t('undoStorageError', 'Undo completed but some files could not be saved to storage');
} else if (error.name === 'QuotaExceededError') {
errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space');
}
actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`);
// Don't clear the operation data if undo failed - user might want to try again
}
}, [undoConsumeFiles, resetResults, actions, t]);
return {
// State
files: state.files,
thumbnails: state.thumbnails,
isGeneratingThumbnails: state.isGeneratingThumbnails,
downloadUrl: state.downloadUrl,
downloadFilename: state.downloadFilename,
isLoading: state.isLoading,
status: state.status,
errorMessage: state.errorMessage,
progress: state.progress,
// Actions
executeOperation,
resetResults,
clearError: actions.clearError,
cancelOperation,
undoOperation
};
};
@@ -0,0 +1,124 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { generateThumbnailForFile, generateThumbnailWithMetadata, ThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
import { zipFileService } from '@app/services/zipFileService';
import { usePreferences } from '@app/contexts/PreferencesContext';
export const useToolResources = () => {
const { preferences } = usePreferences();
const [blobUrls, setBlobUrls] = useState<string[]>([]);
const addBlobUrl = useCallback((url: string) => {
setBlobUrls(prev => [...prev, url]);
}, []);
const cleanupBlobUrls = useCallback(() => {
setBlobUrls(prev => {
prev.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
console.warn('Failed to revoke blob URL:', error);
}
});
return [];
});
}, []); // No dependencies - use functional update pattern
// Cleanup on unmount - use ref to avoid dependency on blobUrls state
const blobUrlsRef = useRef<string[]>([]);
useEffect(() => {
blobUrlsRef.current = blobUrls;
}, [blobUrls]);
useEffect(() => {
return () => {
blobUrlsRef.current.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
console.warn('Failed to revoke blob URL during cleanup:', error);
}
});
};
}, []); // No dependencies - use ref to access current URLs
const generateThumbnails = useCallback(async (files: File[]): Promise<string[]> => {
console.log(`🖼️ useToolResources.generateThumbnails: Starting for ${files.length} files`);
const thumbnails: string[] = [];
for (const file of files) {
try {
console.log(`🖼️ Generating thumbnail for: ${file.name} (${file.type}, ${file.size} bytes)`);
const thumbnail = await generateThumbnailForFile(file);
console.log(`🖼️ Generated thumbnail for ${file.name}: SUCCESS`);
thumbnails.push(thumbnail);
} catch (error) {
console.warn(`🖼️ Failed to generate thumbnail for ${file.name}:`, error);
thumbnails.push('');
}
}
return thumbnails;
}, []);
const generateThumbnailsWithMetadata = useCallback(async (files: File[]): Promise<ThumbnailWithMetadata[]> => {
console.log(`🖼️ useToolResources.generateThumbnailsWithMetadata: Starting for ${files.length} files`);
const results: ThumbnailWithMetadata[] = [];
for (const file of files) {
try {
console.log(`🖼️ Generating thumbnail with metadata for: ${file.name} (${file.type}, ${file.size} bytes)`);
const result = await generateThumbnailWithMetadata(file);
console.log(`🖼️ Generated thumbnail with metadata for ${file.name}: SUCCESS, ${result.pageCount} pages`);
results.push(result);
} catch (error) {
console.warn(`🖼️ Failed to generate thumbnail with metadata for ${file.name}:`, error);
results.push({ thumbnail: '', pageCount: 1 });
}
}
console.log(`🖼️ useToolResources.generateThumbnailsWithMetadata: Complete. Generated ${results.length}/${files.length} thumbnails with metadata`);
return results;
}, []);
const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise<File[]> => {
try {
return await zipFileService.extractWithPreferences(zipBlob, {
autoUnzip: preferences.autoUnzip,
autoUnzipFileLimit: preferences.autoUnzipFileLimit,
skipAutoUnzip
});
} catch (error) {
console.error('useToolResources.extractZipFiles - Error:', error);
return [];
}
}, [preferences.autoUnzip, preferences.autoUnzipFileLimit]);
const createDownloadInfo = useCallback(async (
files: File[],
operationType: string
): Promise<{ url: string; filename: string }> => {
if (files.length === 1) {
const url = URL.createObjectURL(files[0]);
addBlobUrl(url);
return { url, filename: files[0].name };
}
// Multiple files - create zip using shared service
const { zipFile } = await zipFileService.createZipFromFiles(files, `${operationType}_results.zip`);
const url = URL.createObjectURL(zipFile);
addBlobUrl(url);
return { url, filename: zipFile.name };
}, [addBlobUrl]);
return {
generateThumbnails,
generateThumbnailsWithMetadata,
createDownloadInfo,
extractZipFiles,
cleanupBlobUrls,
};
};
@@ -0,0 +1,139 @@
import { useReducer, useCallback } from 'react';
export interface ProcessingProgress {
current: number;
total: number;
currentFileName?: string;
}
export interface OperationState {
files: File[];
thumbnails: string[];
isGeneratingThumbnails: boolean;
downloadUrl: string | null;
downloadFilename: string;
isLoading: boolean;
status: string;
errorMessage: string | null;
progress: ProcessingProgress | null;
}
type OperationAction =
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_FILES'; payload: File[] }
| { type: 'SET_THUMBNAILS'; payload: string[] }
| { type: 'SET_GENERATING_THUMBNAILS'; payload: boolean }
| { type: 'SET_DOWNLOAD_INFO'; payload: { url: string | null; filename: string } }
| { type: 'SET_STATUS'; payload: string }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_PROGRESS'; payload: ProcessingProgress | null }
| { type: 'RESET_RESULTS' }
| { type: 'CLEAR_ERROR' };
const initialState: OperationState = {
files: [],
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl: null,
downloadFilename: '',
isLoading: false,
status: '',
errorMessage: null,
progress: null,
};
const operationReducer = (state: OperationState, action: OperationAction): OperationState => {
switch (action.type) {
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
case 'SET_FILES':
return { ...state, files: action.payload };
case 'SET_THUMBNAILS':
return { ...state, thumbnails: action.payload };
case 'SET_GENERATING_THUMBNAILS':
return { ...state, isGeneratingThumbnails: action.payload };
case 'SET_DOWNLOAD_INFO':
return {
...state,
downloadUrl: action.payload.url,
downloadFilename: action.payload.filename
};
case 'SET_STATUS':
return { ...state, status: action.payload };
case 'SET_ERROR':
return { ...state, errorMessage: action.payload };
case 'SET_PROGRESS':
return { ...state, progress: action.payload };
case 'RESET_RESULTS':
return {
...initialState,
isLoading: state.isLoading, // Preserve loading state during reset
};
case 'CLEAR_ERROR':
return { ...state, errorMessage: null };
default:
return state;
}
};
export const useToolState = () => {
const [state, dispatch] = useReducer(operationReducer, initialState);
const setLoading = useCallback((loading: boolean) => {
dispatch({ type: 'SET_LOADING', payload: loading });
}, []);
const setFiles = useCallback((files: File[]) => {
dispatch({ type: 'SET_FILES', payload: files });
}, []);
const setThumbnails = useCallback((thumbnails: string[]) => {
console.log(`🔧 useToolState.setThumbnails: Setting ${thumbnails.length} thumbnails:`,
thumbnails.map((t, i) => `[${i}]: ${t ? 'PRESENT' : 'MISSING'}`));
dispatch({ type: 'SET_THUMBNAILS', payload: thumbnails });
}, []);
const setGeneratingThumbnails = useCallback((generating: boolean) => {
dispatch({ type: 'SET_GENERATING_THUMBNAILS', payload: generating });
}, []);
const setDownloadInfo = useCallback((url: string | null, filename: string) => {
dispatch({ type: 'SET_DOWNLOAD_INFO', payload: { url, filename } });
}, []);
const setStatus = useCallback((status: string) => {
dispatch({ type: 'SET_STATUS', payload: status });
}, []);
const setError = useCallback((error: string | null) => {
dispatch({ type: 'SET_ERROR', payload: error });
}, []);
const setProgress = useCallback((progress: ProcessingProgress | null) => {
dispatch({ type: 'SET_PROGRESS', payload: progress });
}, []);
const resetResults = useCallback(() => {
dispatch({ type: 'RESET_RESULTS' });
}, []);
const clearError = useCallback(() => {
dispatch({ type: 'CLEAR_ERROR' });
}, []);
return {
state,
actions: {
setLoading,
setFiles,
setThumbnails,
setGeneratingThumbnails,
setDownloadInfo,
setStatus,
setError,
setProgress,
resetResults,
clearError,
},
};
};
@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationHook, ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { SignParameters, DEFAULT_PARAMETERS } from '@app/hooks/tools/sign/useSignParameters';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
// Static configuration that can be used by both the hook and automation executor
export const buildSignFormData = (params: SignParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Add signature data if available
if (params.signatureData) {
formData.append('signatureData', params.signatureData);
}
// Add signature position and size
if (params.signaturePosition) {
formData.append('x', params.signaturePosition.x.toString());
formData.append('y', params.signaturePosition.y.toString());
formData.append('width', params.signaturePosition.width.toString());
formData.append('height', params.signaturePosition.height.toString());
formData.append('page', params.signaturePosition.page.toString());
}
// Add signature type
formData.append('signatureType', params.signatureType || 'draw');
// Add other parameters
if (params.reason) {
formData.append('reason', params.reason);
}
if (params.location) {
formData.append('location', params.location);
}
if (params.signerName) {
formData.append('signerName', params.signerName);
}
return formData;
};
// Static configuration object
export const signOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSignFormData,
operationType: 'sign',
endpoint: '/api/v1/security/add-signature',
filePrefix: 'signed_',
defaultParameters: DEFAULT_PARAMETERS,
} as const;
export const useSignOperation = (): ToolOperationHook<SignParameters> => {
const { t } = useTranslation();
return useToolOperation<SignParameters>({
...signOperationConfig,
getErrorMessage: createStandardErrorHandler(t('sign.error.failed', 'An error occurred while signing the PDF.'))
});
};
@@ -0,0 +1,63 @@
import { useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters';
export interface SignaturePosition {
x: number;
y: number;
width: number;
height: number;
page: number;
}
export interface SignParameters {
signatureType: 'image' | 'text' | 'draw' | 'canvas';
signatureData?: string; // Base64 encoded image or text content
signaturePosition?: SignaturePosition;
reason?: string;
location?: string;
signerName?: string;
fontFamily?: string;
fontSize?: number;
textColor?: string;
}
export const DEFAULT_PARAMETERS: SignParameters = {
signatureType: 'canvas',
reason: 'Document signing',
location: 'Digital',
signerName: '',
fontFamily: 'Helvetica',
fontSize: 16,
textColor: '#000000',
};
const validateSignParameters = (parameters: SignParameters): boolean => {
// Basic validation
if (!parameters.signatureType) return false;
// If signature position is set, validate it
if (parameters.signaturePosition) {
const pos = parameters.signaturePosition;
if (pos.x < 0 || pos.y < 0 || pos.width <= 0 || pos.height <= 0 || pos.page < 0) {
return false;
}
}
// For image and canvas signatures, require signature data
if ((parameters.signatureType === 'image' || parameters.signatureType === 'canvas') && !parameters.signatureData) {
return false;
}
// For text signatures, require signer name
if (parameters.signatureType === 'text' && !parameters.signerName) {
return false;
}
return true;
};
export const useSignParameters = () => {
return useBaseParameters<SignParameters>({
defaultParameters: DEFAULT_PARAMETERS,
endpointName: 'add-signature',
validateFn: validateSignParameters,
});
};
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { SingleLargePageParameters, defaultParameters } from '@app/hooks/tools/singleLargePage/useSingleLargePageParameters';
// Static function that can be used by both the hook and automation executor
export const buildSingleLargePageFormData = (_parameters: SingleLargePageParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
// Static configuration object
export const singleLargePageOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSingleLargePageFormData,
operationType: 'pdfToSinglePage',
endpoint: '/api/v1/general/pdf-to-single-page',
defaultParameters,
} as const;
export const useSingleLargePageOperation = () => {
const { t } = useTranslation();
return useToolOperation<SingleLargePageParameters>({
...singleLargePageOperationConfig,
getErrorMessage: createStandardErrorHandler(t('pdfToSinglePage.error.failed', 'An error occurred while converting to single page.'))
});
};
@@ -0,0 +1,19 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface SingleLargePageParameters extends BaseParameters {
// Extends BaseParameters - ready for future parameter additions if needed
}
export const defaultParameters: SingleLargePageParameters = {
// No parameters needed
};
export type SingleLargePageParametersHook = BaseParametersHook<SingleLargePageParameters>;
export const useSingleLargePageParameters = (): SingleLargePageParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'pdf-to-single-page',
});
};
@@ -0,0 +1,97 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation, ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { SplitParameters, defaultParameters } from '@app/hooks/tools/split/useSplitParameters';
import { SPLIT_METHODS } from '@app/constants/splitConstants';
import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
// Static functions that can be used by both the hook and automation executor
export const buildSplitFormData = (parameters: SplitParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
switch (parameters.method) {
case SPLIT_METHODS.BY_PAGES:
formData.append("pageNumbers", parameters.pages);
break;
case SPLIT_METHODS.BY_SECTIONS:
formData.append("horizontalDivisions", parameters.hDiv);
formData.append("verticalDivisions", parameters.vDiv);
formData.append("merge", parameters.merge.toString());
break;
case SPLIT_METHODS.BY_SIZE:
formData.append("splitType", "0");
formData.append("splitValue", parameters.splitValue);
break;
case SPLIT_METHODS.BY_PAGE_COUNT:
formData.append("splitType", "1");
formData.append("splitValue", parameters.splitValue);
break;
case SPLIT_METHODS.BY_DOC_COUNT:
formData.append("splitType", "2");
formData.append("splitValue", parameters.splitValue);
break;
case SPLIT_METHODS.BY_CHAPTERS:
formData.append("bookmarkLevel", parameters.bookmarkLevel);
formData.append("includeMetadata", parameters.includeMetadata.toString());
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
break;
case SPLIT_METHODS.BY_PAGE_DIVIDER:
formData.append("duplexMode", parameters.duplexMode.toString());
break;
default:
throw new Error(`Unknown split method: ${parameters.method}`);
}
return formData;
};
export const getSplitEndpoint = (parameters: SplitParameters): string => {
switch (parameters.method) {
case SPLIT_METHODS.BY_PAGES:
return "/api/v1/general/split-pages";
case SPLIT_METHODS.BY_SECTIONS:
return "/api/v1/general/split-pdf-by-sections";
case SPLIT_METHODS.BY_SIZE:
case SPLIT_METHODS.BY_PAGE_COUNT:
case SPLIT_METHODS.BY_DOC_COUNT:
return "/api/v1/general/split-by-size-or-count";
case SPLIT_METHODS.BY_CHAPTERS:
return "/api/v1/general/split-pdf-by-chapters";
case SPLIT_METHODS.BY_PAGE_DIVIDER:
return "/api/v1/misc/auto-split-pdf";
default:
throw new Error(`Unknown split method: ${parameters.method}`);
}
};
// Static configuration object
export const splitOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSplitFormData,
operationType: 'split',
endpoint: getSplitEndpoint,
defaultParameters,
} as const;
export const useSplitOperation = () => {
const { t } = useTranslation();
const { extractZipFiles } = useToolResources();
// Custom response handler that extracts ZIP files
// Can't add to exported config because it requires access to the zip code so must be part of the hook
const responseHandler = useCallback(async (blob: Blob, _originalFiles: File[]): Promise<File[]> => {
// Split operations return ZIP files with multiple PDF pages
return await extractZipFiles(blob);
}, [extractZipFiles]);
const splitConfig: ToolOperationConfig<SplitParameters> = {
...splitOperationConfig,
responseHandler,
getErrorMessage: createStandardErrorHandler(t('split.error.failed', 'An error occurred while splitting the PDF.'))
};
return useToolOperation(splitConfig);
};

Some files were not shown because too many files have changed in this diff Show More