mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
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:
@@ -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,
|
||||
};
|
||||
};
|
||||
+123
@@ -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.')
|
||||
),
|
||||
});
|
||||
};
|
||||
+110
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+29
@@ -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.'))
|
||||
});
|
||||
};
|
||||
+19
@@ -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
Reference in New Issue
Block a user