From 2cac8e8edff5f0a559a26c12f316632f716a89a2 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 27 Aug 2025 14:51:52 +0100 Subject: [PATCH 01/14] Redesgin ToolOperationConfig so types are known for single/multiple/custom tools (#4221) # Description of Changes Redesigns `ToolOperationConfig` so that the types of the functions are always known depending on whether the tool runs on single files, multiple files, or uses custom behaviour --- .../useAddPasswordOperation.test.ts | 18 ++- .../addPassword/useAddPasswordOperation.ts | 6 +- .../addWatermark/useAddWatermarkOperation.ts | 8 +- .../tools/automate/useAutomateOperation.ts | 9 +- .../useChangePermissionsOperation.test.ts | 18 ++- .../useChangePermissionsOperation.ts | 8 +- .../tools/compress/useCompressOperation.ts | 6 +- .../tools/convert/useConvertOperation.ts | 9 +- .../src/hooks/tools/ocr/useOCROperation.ts | 8 +- .../useRemoveCertificateSignOperation.ts | 8 +- .../useRemovePasswordOperation.test.ts | 17 ++- .../useRemovePasswordOperation.ts | 6 +- .../hooks/tools/repair/useRepairOperation.ts | 8 +- .../tools/sanitize/useSanitizeOperation.ts | 5 +- .../hooks/tools/shared/useToolOperation.ts | 133 ++++++++++-------- .../useSingleLargePageOperation.ts | 8 +- .../hooks/tools/split/useSplitOperation.ts | 6 +- .../useUnlockPdfFormsOperation.ts | 8 +- frontend/src/utils/automationExecutor.ts | 68 ++++----- 19 files changed, 195 insertions(+), 162 deletions(-) diff --git a/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts b/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts index 7e6032214..76910bff6 100644 --- a/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts +++ b/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts @@ -4,9 +4,13 @@ import { useAddPasswordOperation } from './useAddPasswordOperation'; import type { AddPasswordFullParameters, AddPasswordParameters } from './useAddPasswordParameters'; // Mock the useToolOperation hook -vi.mock('../shared/useToolOperation', () => ({ - useToolOperation: vi.fn() -})); +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}`); @@ -20,13 +24,13 @@ vi.mock('../../../utils/toolErrorHandler', () => ({ })); // Import the mocked function -import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation'; +import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation'; describe('useAddPasswordOperation', () => { const mockUseToolOperation = vi.mocked(useToolOperation); - const getToolConfig = (): ToolOperationConfig => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig; + const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig; const mockToolOperationReturn: ToolOperationHook = { files: [], @@ -91,7 +95,7 @@ describe('useAddPasswordOperation', () => { }; const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' }); - const formData = buildFormData(testParameters, testFile as any /* FIX ME */); + const formData = buildFormData(testParameters, testFile); // Verify the form data contains the file expect(formData.get('fileInput')).toBe(testFile); @@ -112,7 +116,7 @@ describe('useAddPasswordOperation', () => { }); test.each([ - { property: 'multiFileEndpoint' as const, expectedValue: false }, + { property: 'toolType' as const, expectedValue: ToolType.singleFile }, { property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' }, { property: 'filePrefix' as const, expectedValue: 'translated-addPassword.filenamePrefix_' }, { property: 'operationType' as const, expectedValue: 'addPassword' } diff --git a/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.ts b/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.ts index bd2f2176c..c9a2bdaad 100644 --- a/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.ts +++ b/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { AddPasswordFullParameters, defaultParameters } from './useAddPasswordParameters'; import { defaultParameters as permissionsDefaults } from '../changePermissions/useChangePermissionsParameters'; @@ -26,11 +26,11 @@ const fullDefaultParameters: AddPasswordFullParameters = { // Static configuration object export const addPasswordOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildAddPasswordFormData, operationType: 'addPassword', endpoint: '/api/v1/security/add-password', - buildFormData: buildAddPasswordFormData, filePrefix: 'encrypted_', // Will be overridden in hook with translation - multiFileEndpoint: false, defaultParameters: fullDefaultParameters, } as const; diff --git a/frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts b/frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts index 5c07ee6e7..9da189ea3 100644 --- a/frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts +++ b/frontend/src/hooks/tools/addWatermark/useAddWatermarkOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { AddWatermarkParameters, defaultParameters } from './useAddWatermarkParameters'; @@ -35,11 +35,11 @@ export const buildAddWatermarkFormData = (parameters: AddWatermarkParameters, fi // Static configuration object export const addWatermarkOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildAddWatermarkFormData, operationType: 'watermark', endpoint: '/api/v1/security/add-watermark', - buildFormData: buildAddWatermarkFormData, filePrefix: 'watermarked_', // Will be overridden in hook with translation - multiFileEndpoint: false, defaultParameters, } as const; @@ -51,4 +51,4 @@ export const useAddWatermarkOperation = () => { filePrefix: t('watermark.filenamePrefix', 'watermarked') + '_', getErrorMessage: createStandardErrorHandler(t('watermark.error.failed', 'An error occurred while adding watermark to the PDF.')) }); -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/tools/automate/useAutomateOperation.ts b/frontend/src/hooks/tools/automate/useAutomateOperation.ts index 55e1b311c..112bafbd2 100644 --- a/frontend/src/hooks/tools/automate/useAutomateOperation.ts +++ b/frontend/src/hooks/tools/automate/useAutomateOperation.ts @@ -1,4 +1,4 @@ -import { useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { useCallback } from 'react'; import { executeAutomationSequence } from '../../../utils/automationExecutor'; import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry'; @@ -10,7 +10,7 @@ export function useAutomateOperation() { 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'); } @@ -40,10 +40,9 @@ export function useAutomateOperation() { }, [toolRegistry]); return useToolOperation({ + toolType: ToolType.custom, operationType: 'automate', - endpoint: '/api/v1/pipeline/handleData', // Not used with customProcessor - buildFormData: () => new FormData(), // Not used with customProcessor customProcessor, filePrefix: '' // No prefix needed since automation handles naming internally }); -} \ No newline at end of file +} diff --git a/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts b/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts index 4ffd9b45c..71a0500f6 100644 --- a/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts +++ b/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts @@ -4,9 +4,13 @@ import { useChangePermissionsOperation } from './useChangePermissionsOperation'; import type { ChangePermissionsParameters } from './useChangePermissionsParameters'; // Mock the useToolOperation hook -vi.mock('../shared/useToolOperation', () => ({ - useToolOperation: vi.fn() -})); +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}`); @@ -20,12 +24,12 @@ vi.mock('../../../utils/toolErrorHandler', () => ({ })); // Import the mocked function -import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation'; +import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation'; describe('useChangePermissionsOperation', () => { const mockUseToolOperation = vi.mocked(useToolOperation); - const getToolConfig = (): ToolOperationConfig => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig; + const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig; const mockToolOperationReturn: ToolOperationHook = { files: [], @@ -86,7 +90,7 @@ describe('useChangePermissionsOperation', () => { const buildFormData = callArgs.buildFormData; const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' }); - const formData = buildFormData(testParameters, testFile as any /* FIX ME */); + const formData = buildFormData(testParameters, testFile); // Verify the form data contains the file expect(formData.get('fileInput')).toBe(testFile); @@ -106,7 +110,7 @@ describe('useChangePermissionsOperation', () => { }); test.each([ - { property: 'multiFileEndpoint' as const, expectedValue: false }, + { property: 'toolType' as const, expectedValue: ToolType.singleFile }, { property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' }, { property: 'filePrefix' as const, expectedValue: 'permissions_' }, { property: 'operationType' as const, expectedValue: 'change-permissions' } diff --git a/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.ts b/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.ts index ee28d5381..89f7e1345 100644 --- a/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.ts +++ b/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { ChangePermissionsParameters, defaultParameters } from './useChangePermissionsParameters'; @@ -24,11 +24,11 @@ export const buildChangePermissionsFormData = (parameters: ChangePermissionsPara // Static configuration object export const changePermissionsOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildChangePermissionsFormData, operationType: 'change-permissions', endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool - buildFormData: buildChangePermissionsFormData, filePrefix: 'permissions_', - multiFileEndpoint: false, defaultParameters, } as const; @@ -39,6 +39,6 @@ export const useChangePermissionsOperation = () => { ...changePermissionsOperationConfig, getErrorMessage: createStandardErrorHandler( t('changePermissions.error.failed', 'An error occurred while changing PDF permissions.') - ) + ), }); }; diff --git a/frontend/src/hooks/tools/compress/useCompressOperation.ts b/frontend/src/hooks/tools/compress/useCompressOperation.ts index 08d73859e..8327dd698 100644 --- a/frontend/src/hooks/tools/compress/useCompressOperation.ts +++ b/frontend/src/hooks/tools/compress/useCompressOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; +import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { CompressParameters, defaultParameters } from './useCompressParameters'; @@ -24,11 +24,11 @@ export const buildCompressFormData = (parameters: CompressParameters, file: File // Static configuration object export const compressOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildCompressFormData, operationType: 'compress', endpoint: '/api/v1/misc/compress-pdf', - buildFormData: buildCompressFormData, filePrefix: 'compressed_', - multiFileEndpoint: false, // Individual API calls per file defaultParameters, } as const; diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts index 28b854d4b..731aee748 100644 --- a/frontend/src/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { ConvertParameters, defaultParameters } from './useConvertParameters'; import { detectFileExtension } from '../../../utils/fileUtils'; import { createFileFromApiResponse } from '../../../utils/fileResponseUtils'; -import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; +import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation'; import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils'; // Static function that can be used by both the hook and automation executor @@ -129,11 +129,10 @@ export const convertProcessor = async ( // Static configuration object export const convertOperationConfig = { + toolType: ToolType.custom, + customProcessor: convertProcessor, // Can't use callback version here operationType: 'convert', - endpoint: '', // Not used with customProcessor but required - buildFormData: buildConvertFormData, // Not used with customProcessor but required filePrefix: 'converted_', - customProcessor: convertProcessor, defaultParameters, } as const; @@ -158,6 +157,6 @@ export const useConvertOperation = () => { return error.message; } return t("convert.errorConversion", "An error occurred while converting the file."); - } + }, }); }; diff --git a/frontend/src/hooks/tools/ocr/useOCROperation.ts b/frontend/src/hooks/tools/ocr/useOCROperation.ts index b816b3773..4d25a172f 100644 --- a/frontend/src/hooks/tools/ocr/useOCROperation.ts +++ b/frontend/src/hooks/tools/ocr/useOCROperation.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { OCRParameters, defaultParameters } from './useOCRParameters'; -import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; +import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { useToolResources } from '../shared/useToolResources'; @@ -52,7 +52,7 @@ export const buildOCRFormData = (parameters: OCRParameters, file: File): FormDat return formData; }; -// Static response handler for OCR - can be used by automation executor +// Static response handler for OCR - can be used by automation executor export const ocrResponseHandler = async (blob: Blob, originalFiles: File[], extractZipFiles: (blob: Blob) => Promise): Promise => { const headBuf = await blob.slice(0, 8).arrayBuffer(); const head = new TextDecoder().decode(new Uint8Array(headBuf)); @@ -94,11 +94,11 @@ export const ocrResponseHandler = async (blob: Blob, originalFiles: File[], extr // Static configuration object (without t function dependencies) export const ocrOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildOCRFormData, operationType: 'ocr', endpoint: '/api/v1/misc/ocr-pdf', - buildFormData: buildOCRFormData, filePrefix: 'ocr_', - multiFileEndpoint: false, defaultParameters, } as const; diff --git a/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts b/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts index c466606bc..106150281 100644 --- a/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts +++ b/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { RemoveCertificateSignParameters, defaultParameters } from './useRemoveCertificateSignParameters'; @@ -12,11 +12,11 @@ export const buildRemoveCertificateSignFormData = (parameters: RemoveCertificate // Static configuration object export const removeCertificateSignOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildRemoveCertificateSignFormData, operationType: 'remove-certificate-sign', endpoint: '/api/v1/security/remove-cert-sign', - buildFormData: buildRemoveCertificateSignFormData, filePrefix: 'unsigned_', // Will be overridden in hook with translation - multiFileEndpoint: false, defaultParameters, } as const; @@ -28,4 +28,4 @@ export const useRemoveCertificateSignOperation = () => { filePrefix: t('removeCertSign.filenamePrefix', 'unsigned') + '_', getErrorMessage: createStandardErrorHandler(t('removeCertSign.error.failed', 'An error occurred while removing certificate signatures.')) }); -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.test.ts b/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.test.ts index 61faef9a4..be73ff1d3 100644 --- a/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.test.ts +++ b/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.test.ts @@ -3,10 +3,13 @@ import { renderHook } from '@testing-library/react'; import { useRemovePasswordOperation } from './useRemovePasswordOperation'; import type { RemovePasswordParameters } from './useRemovePasswordParameters'; -// Mock the useToolOperation hook -vi.mock('../shared/useToolOperation', () => ({ - useToolOperation: vi.fn() -})); +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}`); @@ -20,12 +23,12 @@ vi.mock('../../../utils/toolErrorHandler', () => ({ })); // Import the mocked function -import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation'; +import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation'; describe('useRemovePasswordOperation', () => { const mockUseToolOperation = vi.mocked(useToolOperation); - const getToolConfig = (): ToolOperationConfig => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig; + const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig; const mockToolOperationReturn: ToolOperationHook = { files: [], @@ -91,7 +94,7 @@ describe('useRemovePasswordOperation', () => { }); test.each([ - { property: 'multiFileEndpoint' as const, expectedValue: false }, + { property: 'toolType' as const, expectedValue: ToolType.singleFile }, { property: 'endpoint' as const, expectedValue: '/api/v1/security/remove-password' }, { property: 'filePrefix' as const, expectedValue: 'translated-removePassword.filenamePrefix_' }, { property: 'operationType' as const, expectedValue: 'removePassword' } diff --git a/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.ts b/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.ts index 9d61d3c59..ff644db42 100644 --- a/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.ts +++ b/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { RemovePasswordParameters, defaultParameters } from './useRemovePasswordParameters'; @@ -13,11 +13,11 @@ export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters // Static configuration object export const removePasswordOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildRemovePasswordFormData, operationType: 'removePassword', endpoint: '/api/v1/security/remove-password', - buildFormData: buildRemovePasswordFormData, filePrefix: 'decrypted_', // Will be overridden in hook with translation - multiFileEndpoint: false, defaultParameters, } as const; diff --git a/frontend/src/hooks/tools/repair/useRepairOperation.ts b/frontend/src/hooks/tools/repair/useRepairOperation.ts index 8be4fb13f..44fcc9b70 100644 --- a/frontend/src/hooks/tools/repair/useRepairOperation.ts +++ b/frontend/src/hooks/tools/repair/useRepairOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { RepairParameters, defaultParameters } from './useRepairParameters'; @@ -12,11 +12,11 @@ export const buildRepairFormData = (parameters: RepairParameters, file: File): F // Static configuration object export const repairOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildRepairFormData, operationType: 'repair', endpoint: '/api/v1/misc/repair', - buildFormData: buildRepairFormData, filePrefix: 'repaired_', // Will be overridden in hook with translation - multiFileEndpoint: false, defaultParameters, } as const; @@ -28,4 +28,4 @@ export const useRepairOperation = () => { filePrefix: t('repair.filenamePrefix', 'repaired') + '_', getErrorMessage: createStandardErrorHandler(t('repair.error.failed', 'An error occurred while repairing the PDF.')) }); -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts index 284b715f8..4215011cb 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { SanitizeParameters, defaultParameters } from './useSanitizeParameters'; @@ -21,9 +21,10 @@ export const buildSanitizeFormData = (parameters: SanitizeParameters, file: File // Static configuration object export const sanitizeOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildSanitizeFormData, operationType: 'sanitize', endpoint: '/api/v1/security/sanitize-pdf', - buildFormData: buildSanitizeFormData, filePrefix: 'sanitized_', // Will be overridden in hook with translation multiFileEndpoint: false, defaultParameters, diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index abce8bedf..cf86fa312 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -12,6 +12,12 @@ import { ResponseHandler } from '../../../utils/toolResponseProcessor'; // 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. * @@ -20,45 +26,16 @@ export type { ProcessingProgress, ResponseHandler }; * 2. Multi-file tools: multiFileEndpoint: true, single API call with all files * 3. Complex tools: customProcessor handles all processing logic */ -export interface ToolOperationConfig { +interface BaseToolOperationConfig { /** Operation identifier for tracking and logging */ operationType: string; - /** - * API endpoint for the operation. Can be static string or function for dynamic routing. - * Not used when customProcessor is provided. - */ - endpoint: string | ((params: TParams) => string); - - /** - * Builds FormData for API request. Signature determines processing approach: - * - (params, file: File) => FormData: Single-file processing - * - (params, files: File[]) => FormData: Multi-file processing - * Not used when customProcessor is provided. - */ - buildFormData: ((params: TParams, file: File) => FormData) | ((params: TParams, files: File[]) => FormData); /* FIX ME */ - /** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */ filePrefix: string; - /** - * Whether this tool uses backends that accept MultipartFile[] arrays. - * - true: Single API call with all files (backend uses MultipartFile[]) - * - false/undefined: Individual API calls per file (backend uses single MultipartFile) - * Ignored when customProcessor is provided. - */ - multiFileEndpoint?: boolean; - /** How to handle API responses (e.g., ZIP extraction, single file response) */ responseHandler?: ResponseHandler; - /** - * Custom processing logic that completely bypasses standard file processing. - * When provided, 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; - /** Extract user-friendly error messages from API errors */ getErrorMessage?: (error: any) => string; @@ -66,6 +43,49 @@ export interface ToolOperationConfig { defaultParameters?: TParams; } +export interface SingleFileToolOperationConfig extends BaseToolOperationConfig { + /** 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 extends BaseToolOperationConfig { + /** This tool processes multiple files at once. */ + toolType: ToolType.multiFile; + + /** 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 extends BaseToolOperationConfig { + /** 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; +} + +export type ToolOperationConfig = SingleFileToolOperationConfig | MultiFileToolOperationConfig | CustomToolOperationConfig; + /** * Complete tool operation interface with execution capability */ @@ -103,7 +123,7 @@ export { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; * @param config - Tool operation configuration * @returns Hook interface with state and execution methods */ -export const useToolOperation = ( +export const useToolOperation = ( config: ToolOperationConfig ): ToolOperationHook => { const { t } = useTranslation(); @@ -143,15 +163,28 @@ export const useToolOperation = ( try { let processedFiles: File[]; - if (config.customProcessor) { - actions.setStatus('Processing files...'); - processedFiles = await config.customProcessor(params, validFiles); - } else { - // Use explicit multiFileEndpoint flag to determine processing approach - if (config.multiFileEndpoint) { + switch (config.toolType) { + case ToolType.singleFile: + // Individual file processing - separate API call per file + const apiCallsConfig: ApiCallsConfig = { + endpoint: config.endpoint, + buildFormData: config.buildFormData, + filePrefix: config.filePrefix, + responseHandler: config.responseHandler + }; + processedFiles = await processFiles( + params, + validFiles, + apiCallsConfig, + actions.setProgress, + actions.setStatus + ); + break; + + case ToolType.multiFile: // Multi-file processing - single API call with all files actions.setStatus('Processing files...'); - const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validFiles); + const formData = config.buildFormData(params, validFiles); const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; const response = await axios.post(endpoint, formData, { responseType: 'blob' }); @@ -160,7 +193,7 @@ export const useToolOperation = ( if (config.responseHandler) { // Use custom responseHandler for multi-file (handles ZIP extraction) processedFiles = await config.responseHandler(response.data, validFiles); - } else if (response.data.type === 'application/pdf' || + } else if (response.data.type === 'application/pdf' || (response.headers && response.headers['content-type'] === 'application/pdf')) { // Single PDF response (e.g. split with merge option) - use original filename const originalFileName = validFiles[0]?.name || 'document.pdf'; @@ -175,22 +208,12 @@ export const useToolOperation = ( processedFiles = await extractAllZipFiles(response.data); } } - } else { - // Individual file processing - separate API call per file - const apiCallsConfig: ApiCallsConfig = { - endpoint: config.endpoint, - buildFormData: config.buildFormData as (params: TParams, file: File) => FormData, - filePrefix: config.filePrefix, - responseHandler: config.responseHandler - }; - processedFiles = await processFiles( - params, - validFiles, - apiCallsConfig, - actions.setProgress, - actions.setStatus - ); - } + break; + + case ToolType.custom: + actions.setStatus('Processing files...'); + processedFiles = await config.customProcessor(params, validFiles); + break; } if (processedFiles.length > 0) { diff --git a/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts index 41cbb6dd3..ef304fa09 100644 --- a/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts +++ b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters'; @@ -12,11 +12,11 @@ export const buildSingleLargePageFormData = (parameters: SingleLargePageParamete // Static configuration object export const singleLargePageOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildSingleLargePageFormData, operationType: 'single-large-page', endpoint: '/api/v1/general/pdf-to-single-page', - buildFormData: buildSingleLargePageFormData, filePrefix: 'single_page_', // Will be overridden in hook with translation - multiFileEndpoint: false, defaultParameters, } as const; @@ -28,4 +28,4 @@ export const useSingleLargePageOperation = () => { filePrefix: t('pdfToSinglePage.filenamePrefix', 'single_page') + '_', getErrorMessage: createStandardErrorHandler(t('pdfToSinglePage.error.failed', 'An error occurred while converting to single page.')) }); -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/tools/split/useSplitOperation.ts b/frontend/src/hooks/tools/split/useSplitOperation.ts index cc1c6a5d9..e3d9a1727 100644 --- a/frontend/src/hooks/tools/split/useSplitOperation.ts +++ b/frontend/src/hooks/tools/split/useSplitOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { SplitParameters, defaultParameters } from './useSplitParameters'; import { SPLIT_MODES } from '../../../constants/splitConstants'; @@ -57,11 +57,11 @@ export const getSplitEndpoint = (parameters: SplitParameters): string => { // Static configuration object export const splitOperationConfig = { + toolType: ToolType.multiFile, + buildFormData: buildSplitFormData, operationType: 'splitPdf', endpoint: getSplitEndpoint, - buildFormData: buildSplitFormData, filePrefix: 'split_', - multiFileEndpoint: true, // Single API call with all files defaultParameters, } as const; diff --git a/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts b/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts index d6081452f..d47800b34 100644 --- a/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts +++ b/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation } from '../shared/useToolOperation'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters'; @@ -12,11 +12,11 @@ export const buildUnlockPdfFormsFormData = (parameters: UnlockPdfFormsParameters // Static configuration object export const unlockPdfFormsOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildUnlockPdfFormsFormData, operationType: 'unlock-pdf-forms', endpoint: '/api/v1/misc/unlock-pdf-forms', - buildFormData: buildUnlockPdfFormsFormData, filePrefix: 'unlocked_forms_', // Will be overridden in hook with translation - multiFileEndpoint: false, defaultParameters, } as const; @@ -28,4 +28,4 @@ export const useUnlockPdfFormsOperation = () => { filePrefix: t('unlockPDFForms.filenamePrefix', 'unlocked_forms') + '_', getErrorMessage: createStandardErrorHandler(t('unlockPDFForms.error.failed', 'An error occurred while unlocking PDF forms.')) }); -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/automationExecutor.ts b/frontend/src/utils/automationExecutor.ts index e33eb8aee..0eb052e70 100644 --- a/frontend/src/utils/automationExecutor.ts +++ b/frontend/src/utils/automationExecutor.ts @@ -4,14 +4,15 @@ import { AutomationConfig, AutomationExecutionCallbacks } from '../types/automat import { AUTOMATION_CONSTANTS } from '../constants/automation'; import { AutomationFileProcessor } from './automationFileProcessor'; import { ResourceManager } from './resourceManager'; +import { ToolType } from '../hooks/tools/shared/useToolOperation'; /** * Execute a tool operation directly without using React hooks */ export const executeToolOperation = async ( - operationName: string, - parameters: any, + operationName: string, + parameters: any, files: File[], toolRegistry: ToolRegistry ): Promise => { @@ -22,14 +23,14 @@ export const executeToolOperation = async ( * Execute a tool operation with custom prefix */ export const executeToolOperationWithPrefix = async ( - operationName: string, - parameters: any, + operationName: string, + parameters: any, files: File[], toolRegistry: ToolRegistry, filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX ): Promise => { console.log(`🔧 Executing tool: ${operationName}`, { parameters, fileCount: files.length }); - + const config = toolRegistry[operationName]?.operationConfig; if (!config) { console.error(`❌ Tool operation not supported: ${operationName}`); @@ -47,17 +48,17 @@ export const executeToolOperationWithPrefix = async ( return resultFiles; } - if (config.multiFileEndpoint) { + if (config.toolType === ToolType.multiFile) { // Multi-file processing - single API call with all files - const endpoint = typeof config.endpoint === 'function' - ? config.endpoint(parameters) + const endpoint = typeof config.endpoint === 'function' + ? config.endpoint(parameters) : config.endpoint; - + console.log(`🌐 Making multi-file request to: ${endpoint}`); const formData = (config.buildFormData as (params: any, files: File[]) => FormData)(parameters, files); console.log(`📤 FormData entries:`, Array.from(formData.entries())); - - const response = await axios.post(endpoint, formData, { + + const response = await axios.post(endpoint, formData, { responseType: 'blob', timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT }); @@ -66,7 +67,7 @@ export const executeToolOperationWithPrefix = async ( // Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true) let result; - if (response.data.type === 'application/pdf' || + if (response.data.type === 'application/pdf' || (response.headers && response.headers['content-type'] === 'application/pdf')) { // Single PDF response (e.g. split with merge option) - use original filename const originalFileName = files[0]?.name || 'document.pdf'; @@ -80,19 +81,18 @@ export const executeToolOperationWithPrefix = async ( // ZIP response result = await AutomationFileProcessor.extractAutomationZipFiles(response.data); } - + if (result.errors.length > 0) { console.warn(`⚠️ File processing warnings:`, result.errors); } - // Apply prefix to files, replacing any existing prefix - const processedFiles = filePrefix + const processedFiles = filePrefix ? result.files.map(file => { const nameWithoutPrefix = file.name.replace(/^[^_]*_/, ''); return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type }); }) : result.files; - + console.log(`📁 Processed ${processedFiles.length} files from response`); return processedFiles; @@ -100,18 +100,18 @@ export const executeToolOperationWithPrefix = async ( // Single-file processing - separate API call per file console.log(`🔄 Processing ${files.length} files individually`); const resultFiles: File[] = []; - + for (let i = 0; i < files.length; i++) { const file = files[i]; - const endpoint = typeof config.endpoint === 'function' - ? config.endpoint(parameters) + const endpoint = typeof config.endpoint === 'function' + ? config.endpoint(parameters) : config.endpoint; - + console.log(`🌐 Making single-file request ${i+1}/${files.length} to: ${endpoint} for file: ${file.name}`); const formData = (config.buildFormData as (params: any, file: File) => FormData)(parameters, file); console.log(`📤 FormData entries:`, Array.from(formData.entries())); - - const response = await axios.post(endpoint, formData, { + + const response = await axios.post(endpoint, formData, { responseType: 'blob', timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT }); @@ -119,9 +119,9 @@ export const executeToolOperationWithPrefix = async ( console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`); // Create result file with automation prefix - + const resultFile = ResourceManager.createResultFile( - response.data, + response.data, file.name, filePrefix ); @@ -143,7 +143,7 @@ export const executeToolOperationWithPrefix = async ( * Execute an entire automation sequence */ export const executeAutomationSequence = async ( - automation: any, + automation: any, initialFiles: File[], toolRegistry: ToolRegistry, onStepStart?: (stepIndex: number, operationName: string) => void, @@ -153,7 +153,7 @@ export const executeAutomationSequence = async ( console.log(`🚀 Starting automation sequence: ${automation.name || 'Unnamed'}`); console.log(`📁 Initial files: ${initialFiles.length}`); console.log(`🔧 Operations: ${automation.operations?.length || 0}`); - + if (!automation?.operations || automation.operations.length === 0) { throw new Error('No operations in automation'); } @@ -163,26 +163,26 @@ export const executeAutomationSequence = async ( for (let i = 0; i < automation.operations.length; i++) { const operation = automation.operations[i]; - + console.log(`📋 Step ${i + 1}/${automation.operations.length}: ${operation.operation}`); console.log(`📄 Input files: ${currentFiles.length}`); console.log(`⚙️ Parameters:`, operation.parameters || {}); - + try { onStepStart?.(i, operation.operation); - + const resultFiles = await executeToolOperationWithPrefix( - operation.operation, - operation.parameters || {}, + operation.operation, + operation.parameters || {}, currentFiles, toolRegistry, i === automation.operations.length - 1 ? automationPrefix : '' // Only add prefix to final step ); - + console.log(`✅ Step ${i + 1} completed: ${resultFiles.length} result files`); currentFiles = resultFiles; onStepComplete?.(i, resultFiles); - + } catch (error: any) { console.error(`❌ Step ${i + 1} failed:`, error); onStepError?.(i, error.message); @@ -192,4 +192,4 @@ export const executeAutomationSequence = async ( console.log(`🎉 Automation sequence completed: ${currentFiles.length} final files`); return currentFiles; -}; \ No newline at end of file +}; From 581bafbd37567cc7d4fd16cd457ca1496d6fb432 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:50:49 +0100 Subject: [PATCH 02/14] quick text changes (#4301) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> --- frontend/public/locales/en-GB/translation.json | 5 +++-- frontend/src/components/shared/LandingPage.tsx | 2 +- frontend/src/components/tools/shared/FileStatusIndicator.tsx | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 7fa1036db..70aeefbf4 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -50,6 +50,7 @@ "title": "Files", "placeholder": "Select a PDF file in the main view to get started", "upload": "Upload", + "uploadFiles": "Upload Files", "addFiles": "Add files", "selectFromWorkbench": "Select files from the workbench or " }, @@ -2068,7 +2069,7 @@ "loading": "Loading...", "or": "or", "dropFileHere": "Drop file here or click to upload", - "dropFilesHere": "Drop files here or click to upload", + "dropFilesHere": "Drop files here or click the upload button", "pdfFilesOnly": "PDF files only", "supportedFileTypes": "Supported file types", "upload": "Upload", @@ -2381,4 +2382,4 @@ "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." } } -} +} \ No newline at end of file diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 92078e850..688bc2c89 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -189,7 +189,7 @@ const LandingPage = () => { className="text-[var(--accent-interactive)]" style={{ fontSize: '.8rem' }} > - {t('fileUpload.dropFilesHere', 'Drop files here or click to upload')} + {t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')} diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx index 28b8bf428..9b375fc2f 100644 --- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx @@ -100,7 +100,7 @@ const FileStatusIndicator = ({ style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }} > - {t("files.upload", "Upload")} + {t("files.uploadFiles", "Upload Files")} ); From e142af2863052010f1e309bcfb6450ab3ecc775d Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 28 Aug 2025 10:56:07 +0100 Subject: [PATCH 03/14] V2 Make FileId type opaque and use consistently throughout project (#4307) # Description of Changes The `FileId` type in V2 currently is just defined to be a string. This makes it really easy to accidentally pass strings into things accepting file IDs (such as file names). This PR makes the `FileId` type [an opaque type](https://www.geeksforgeeks.org/typescript/opaque-types-in-typescript/), so it is compatible with things accepting strings (arguably not ideal for this...) but strings are not compatible with it without explicit conversion. The PR also includes changes to use `FileId` consistently throughout the project (everywhere I could find uses of `fileId: string`), so that we have the maximum benefit from the type safety. > [!note] > I've marked quite a few things as `FIX ME` where we're passing names in as IDs. If that is intended behaviour, I'm happy to remove the fix me and insert a cast instead, but they probably need comments explaining why we're using a file name as an ID. --- .../src/components/fileEditor/FileEditor.tsx | 69 ++++++----- .../fileEditor/FileEditorThumbnail.tsx | 23 ++-- .../history/FileOperationHistory.tsx | 9 +- .../components/pageEditor/FileThumbnail.tsx | 17 +-- .../src/components/pageEditor/PageEditor.tsx | 70 ++++++----- .../pageEditor/commands/pageCommands.ts | 103 +++++++-------- .../pageEditor/hooks/usePageDocument.ts | 47 +++---- frontend/src/components/shared/FileGrid.tsx | 9 +- .../src/components/shared/FilePickerModal.tsx | 59 ++++----- .../tools/convert/ConvertSettings.tsx | 51 ++++---- frontend/src/components/viewer/Viewer.tsx | 5 +- frontend/src/contexts/FileContext.tsx | 61 +++++---- frontend/src/contexts/FileManagerContext.tsx | 29 ++--- frontend/src/contexts/FilesModalContext.tsx | 5 +- frontend/src/contexts/IndexedDBContext.tsx | 24 ++-- frontend/src/contexts/file/FileReducer.ts | 68 +++++----- frontend/src/contexts/file/fileActions.ts | 117 +++++++++--------- frontend/src/contexts/file/fileHooks.ts | 39 +++--- frontend/src/contexts/file/fileSelectors.ts | 42 +++---- frontend/src/contexts/file/lifecycle.ts | 29 ++--- .../hooks/tools/shared/useToolOperation.ts | 3 +- frontend/src/hooks/useFileHandler.ts | 9 +- frontend/src/hooks/useFileManager.ts | 35 +++--- frontend/src/hooks/useThumbnailGeneration.ts | 51 ++++---- frontend/src/hooks/useToolManagement.tsx | 7 +- .../src/services/fileProcessingService.ts | 27 ++-- frontend/src/services/fileStorage.ts | 23 ++-- .../services/thumbnailGenerationService.ts | 67 +++++----- frontend/src/types/file.ts | 12 +- frontend/src/types/fileContext.ts | 55 ++++---- frontend/src/types/pageEditor.ts | 4 +- frontend/src/utils/toolOperationTracker.ts | 5 +- 32 files changed, 600 insertions(+), 574 deletions(-) diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index eee1eb1a7..d531db8a3 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -16,6 +16,7 @@ import styles from './FileEditor.module.css'; import FileEditorThumbnail from './FileEditorThumbnail'; import FilePickerModal from '../shared/FilePickerModal'; import SkeletonLoader from '../shared/SkeletonLoader'; +import { FileId } from '../../types/file'; interface FileEditorProps { @@ -46,17 +47,17 @@ const FileEditor = ({ // Use optimized FileContext hooks const { state, selectors } = useFileState(); const { addFiles, removeFiles, reorderFiles } = useFileManagement(); - + // Extract needed values from state (memoized to prevent infinite loops) const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]); const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]); const selectedFileIds = state.ui.selectedFileIds; const isProcessing = state.ui.isProcessing; - + // Get the real context actions const { actions } = useFileActions(); const { actions: navActions } = useNavigationActions(); - + // Get file selection context const { setSelectedFiles } = useFileSelection(); @@ -86,9 +87,9 @@ const FileEditor = ({ }); // Get selected file IDs from context (defensive programming) const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; - + // Create refs for frequently changing values to stabilize callbacks - const contextSelectedIdsRef = useRef([]); + const contextSelectedIdsRef = useRef([]); contextSelectedIdsRef.current = contextSelectedIds; // Use activeFileRecords directly - no conversion needed @@ -98,7 +99,7 @@ const FileEditor = ({ const recordToFileItem = useCallback((record: any) => { const file = selectors.getFile(record.id); if (!file) return null; - + return { id: record.id, name: file.name, @@ -166,7 +167,7 @@ const FileEditor = ({ id: operationId, type: 'convert', timestamp: Date.now(), - fileIds: extractionResult.extractedFiles.map(f => f.name), + fileIds: extractionResult.extractedFiles.map(f => f.name) as FileId[] /* FIX ME: This doesn't seem right */, status: 'pending', metadata: { originalFileName: file.name, @@ -179,7 +180,7 @@ const FileEditor = ({ } } }; - + if (extractionResult.errors.length > 0) { errors.push(...extractionResult.errors); } @@ -219,7 +220,7 @@ const FileEditor = ({ id: operationId, type: 'upload', timestamp: Date.now(), - fileIds: [file.name], + fileIds: [file.name as FileId /* This doesn't seem right */], status: 'pending', metadata: { originalFileName: file.name, @@ -239,7 +240,7 @@ const FileEditor = ({ const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; setError(errorMessage); console.error('File processing error:', err); - + // Reset extraction progress on error setZipExtractionProgress({ isExtracting: false, @@ -263,21 +264,21 @@ const FileEditor = ({ // Remove all files from context but keep in storage const allFileIds = activeFileRecords.map(record => record.id); removeFiles(allFileIds, false); // false = keep in storage - + // Clear selections setSelectedFiles([]); }, [activeFileRecords, removeFiles, setSelectedFiles]); - const toggleFile = useCallback((fileId: string) => { + const toggleFile = useCallback((fileId: FileId) => { const currentSelectedIds = contextSelectedIdsRef.current; - + const targetRecord = activeFileRecords.find(r => r.id === fileId); if (!targetRecord) return; const contextFileId = fileId; // No need to create a new ID const isSelected = currentSelectedIds.includes(contextFileId); - let newSelection: string[]; + let newSelection: FileId[]; if (isSelected) { // Remove file from selection @@ -286,7 +287,7 @@ const FileEditor = ({ // Add file to selection // In tool mode, typically allow multiple files unless specified otherwise const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools - + if (maxAllowed === 1) { newSelection = [contextFileId]; } else { @@ -314,30 +315,30 @@ const FileEditor = ({ }, [setSelectedFiles]); // File reordering handler for drag and drop - const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => { + const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => { const currentIds = activeFileRecords.map(r => r.id); - + // Find indices const sourceIndex = currentIds.findIndex(id => id === sourceFileId); const targetIndex = currentIds.findIndex(id => id === targetFileId); - + if (sourceIndex === -1 || targetIndex === -1) { console.warn('Could not find source or target file for reordering'); return; } // Handle multi-file selection reordering - const filesToMove = selectedFileIds.length > 1 + const filesToMove = selectedFileIds.length > 1 ? selectedFileIds.filter(id => currentIds.includes(id)) : [sourceFileId]; // Create new order const newOrder = [...currentIds]; - + // Remove files to move from their current positions (in reverse order to maintain indices) const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id)) .sort((a, b) => b - a); // Sort descending - + sourceIndices.forEach(index => { newOrder.splice(index, 1); }); @@ -372,7 +373,7 @@ const FileEditor = ({ // File operations using context - const handleDeleteFile = useCallback((fileId: string) => { + const handleDeleteFile = useCallback((fileId: FileId) => { const record = activeFileRecords.find(r => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; @@ -385,7 +386,7 @@ const FileEditor = ({ id: operationId, type: 'remove', timestamp: Date.now(), - fileIds: [fileName], + fileIds: [fileName as FileId /* FIX ME: This doesn't seem right */], status: 'pending', metadata: { originalFileName: fileName, @@ -396,7 +397,7 @@ const FileEditor = ({ } } }; - + // Remove file from context but keep in storage (close, don't delete) removeFiles([contextFileId], false); @@ -406,7 +407,7 @@ const FileEditor = ({ } }, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]); - const handleViewFile = useCallback((fileId: string) => { + const handleViewFile = useCallback((fileId: FileId) => { const record = activeFileRecords.find(r => r.id === fileId); if (record) { // Set the file as selected in context and switch to viewer for preview @@ -415,7 +416,7 @@ const FileEditor = ({ } }, [activeFileRecords, setSelectedFiles, navActions.setMode]); - const handleMergeFromHere = useCallback((fileId: string) => { + const handleMergeFromHere = useCallback((fileId: FileId) => { const startIndex = activeFileRecords.findIndex(r => r.id === fileId); if (startIndex === -1) return; @@ -426,14 +427,14 @@ const FileEditor = ({ } }, [activeFileRecords, selectors, onMergeFiles]); - const handleSplitFile = useCallback((fileId: string) => { + const handleSplitFile = useCallback((fileId: FileId) => { const file = selectors.getFile(fileId); if (file && onOpenPageEditor) { onOpenPageEditor(file); } }, [selectors, onOpenPageEditor]); - const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => { + const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { if (selectedFiles.length === 0) return; try { @@ -513,11 +514,11 @@ const FileEditor = ({ ) : ( -
{ const fileItem = recordToFileItem(record); if (!fileItem) return null; - + return ( void; - onDeleteFile: (fileId: string) => void; - onViewFile: (fileId: string) => void; + onToggleFile: (fileId: FileId) => void; + onDeleteFile: (fileId: FileId) => void; + onViewFile: (fileId: FileId) => void; onSetStatus: (status: string) => void; - onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void; - onDownloadFile?: (fileId: string) => void; + onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void; + onDownloadFile?: (fileId: FileId) => void; toolMode?: boolean; isSupported?: boolean; } @@ -161,8 +162,8 @@ const FileEditorThumbnail = ({ onDrop: ({ source }) => { const sourceData = source.data; if (sourceData.type === 'file' && onReorderFiles) { - const sourceFileId = sourceData.fileId as string; - const selectedFileIds = sourceData.selectedFiles as string[]; + const sourceFileId = sourceData.fileId as FileId; + const selectedFileIds = sourceData.selectedFiles as FileId[]; onReorderFiles(sourceFileId, file.id, selectedFileIds); } } @@ -332,7 +333,7 @@ const FileEditorThumbnail = ({ )} {/* Title + meta line */} -
= ({ maxHeight = 400 }) => { // These were stub functions in the old context - replace with empty stubs - const getFileHistory = (fileId: string) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() }); - const getAppliedOperations = (fileId: string) => []; + const getFileHistory = (fileId: FileId) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() }); + const getAppliedOperations = (fileId: FileId) => []; const history = getFileHistory(fileId); const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || []; diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index a0a7d1795..1eda1f6c8 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -11,9 +11,10 @@ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-d import styles from './PageEditor.module.css'; import { useFileContext } from '../../contexts/FileContext'; +import { FileId } from '../../types/file'; interface FileItem { - id: string; + id: FileId; name: string; pageCount: number; thumbnail: string | null; @@ -27,12 +28,12 @@ interface FileThumbnailProps { totalFiles: number; selectedFiles: string[]; selectionMode: boolean; - onToggleFile: (fileId: string) => void; - onDeleteFile: (fileId: string) => void; - onViewFile: (fileId: string) => void; + onToggleFile: (fileId: FileId) => void; + onDeleteFile: (fileId: FileId) => void; + onViewFile: (fileId: FileId) => void; onSetStatus: (status: string) => void; - onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void; - onDownloadFile?: (fileId: string) => void; + onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void; + onDownloadFile?: (fileId: FileId) => void; toolMode?: boolean; isSupported?: boolean; } @@ -161,8 +162,8 @@ const FileThumbnail = ({ onDrop: ({ source }) => { const sourceData = source.data; if (sourceData.type === 'file' && onReorderFiles) { - const sourceFileId = sourceData.fileId as string; - const selectedFileIds = sourceData.selectedFiles as string[]; + const sourceFileId = sourceData.fileId as FileId; + const selectedFileIds = sourceData.selectedFiles as FileId[]; onReorderFiles(sourceFileId, file.id, selectedFileIds); } } diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 69818d28d..ec007f327 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -17,6 +17,7 @@ import PageThumbnail from './PageThumbnail'; import DragDropGrid from './DragDropGrid'; import SkeletonLoader from '../shared/SkeletonLoader'; import NavigationWarningModal from '../shared/NavigationWarningModal'; +import { FileId } from "../../types/file"; import { DOMCommand, @@ -83,7 +84,7 @@ const PageEditor = ({ // Grid container ref for positioning split indicators const gridContainerRef = useRef(null); - + // State to trigger re-renders when container size changes const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }); @@ -128,7 +129,7 @@ const PageEditor = ({ // Interface functions for parent component const displayDocument = editedDocument || mergedPdfDocument; - + // Utility functions to convert between page IDs and page numbers const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => { if (!displayDocument) return []; @@ -137,7 +138,7 @@ const PageEditor = ({ return page?.pageNumber || 0; }).filter(num => num > 0); }, [displayDocument]); - + const getPageIdsFromNumbers = useCallback((pageNumbers: number[]): string[] => { if (!displayDocument) return []; return pageNumbers.map(num => { @@ -145,10 +146,10 @@ const PageEditor = ({ return page?.id || ''; }).filter(id => id !== ''); }, [displayDocument]); - + // Convert selectedPageIds to numbers for components that still need numbers - const selectedPageNumbers = useMemo(() => - getPageNumbersFromIds(selectedPageIds), + const selectedPageNumbers = useMemo(() => + getPageNumbersFromIds(selectedPageIds), [selectedPageIds, getPageNumbersFromIds] ); @@ -173,7 +174,8 @@ const PageEditor = ({ const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({ execute: () => { const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); - undoManagerRef.current.executeCommand(bulkRotateCommand); + + undoManagerRef.current.executeCommand(bulkRotateCommand); } }), []); @@ -182,7 +184,8 @@ const PageEditor = ({ if (!displayDocument) return; const pagesToDelete = pageIds.map(pageId => { - const page = displayDocument.pages.find(p => p.id === pageId); + + const page = displayDocument.pages.find(p => p.id === pageId); return page?.pageNumber || 0; }).filter(num => num > 0); @@ -213,7 +216,7 @@ const PageEditor = ({ ); undoManagerRef.current.executeCommand(splitCommand); } - }), [splitPositions]); +}), [splitPositions]); // Command executor for PageThumbnail const executeCommand = useCallback((command: any) => { @@ -234,7 +237,7 @@ const PageEditor = ({ const handleRotate = useCallback((direction: 'left' | 'right') => { if (!displayDocument || selectedPageIds.length === 0) return; const rotation = direction === 'left' ? -90 : 90; - + handleRotatePages(selectedPageIds, rotation); }, [displayDocument, selectedPageIds, handleRotatePages]); @@ -296,14 +299,14 @@ const PageEditor = ({ // Smart toggle logic: follow the majority, default to adding splits if equal const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length; const noSplitsCount = selectedPositions.length - existingSplitsCount; - + // Remove splits only if majority already have splits // If equal (50/50), default to adding splits const shouldRemoveSplits = existingSplitsCount > noSplitsCount; - + const newSplitPositions = new Set(splitPositions); - + if (shouldRemoveSplits) { // Remove splits from all selected positions selectedPositions.forEach(pos => newSplitPositions.delete(pos)); @@ -316,8 +319,8 @@ const PageEditor = ({ const smartSplitCommand = { execute: () => setSplitPositions(newSplitPositions), undo: () => setSplitPositions(splitPositions), - description: shouldRemoveSplits - ? `Remove ${selectedPositions.length} split(s)` + description: shouldRemoveSplits + ? `Remove ${selectedPositions.length} split(s)` : `Add ${selectedPositions.length - existingSplitsCount} split(s)` }; @@ -343,13 +346,13 @@ const PageEditor = ({ // Smart toggle logic: follow the majority, default to adding splits if equal const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length; const noSplitsCount = selectedPositions.length - existingSplitsCount; - + // Remove splits only if majority already have splits // If equal (50/50), default to adding splits const shouldRemoveSplits = existingSplitsCount > noSplitsCount; - + const newSplitPositions = new Set(splitPositions); - + if (shouldRemoveSplits) { // Remove splits from all selected positions selectedPositions.forEach(pos => newSplitPositions.delete(pos)); @@ -362,8 +365,8 @@ const PageEditor = ({ const smartSplitCommand = { execute: () => setSplitPositions(newSplitPositions), undo: () => setSplitPositions(splitPositions), - description: shouldRemoveSplits - ? `Remove ${selectedPositions.length} split(s)` + description: shouldRemoveSplits + ? `Remove ${selectedPositions.length} split(s)` : `Add ${selectedPositions.length - existingSplitsCount} split(s)` }; @@ -404,7 +407,7 @@ const PageEditor = ({ try { const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage); if (!targetPage) return; - + await actions.addFiles(files, { insertAfterPageId: targetPage.id }); } catch (error) { console.error('Failed to insert files:', error); @@ -443,8 +446,8 @@ const PageEditor = ({ }, [displayDocument, getPageNumbersFromIds]); // Helper function to collect source files for multi-file export - const getSourceFiles = useCallback((): Map | null => { - const sourceFiles = new Map(); + const getSourceFiles = useCallback((): Map | null => { + const sourceFiles = new Map(); // Always include original files activeFileIds.forEach(fileId => { @@ -457,7 +460,7 @@ const PageEditor = ({ // Use multi-file export if we have multiple original files const hasInsertedFiles = false; const hasMultipleOriginalFiles = activeFileIds.length > 1; - + if (!hasInsertedFiles && !hasMultipleOriginalFiles) { return null; // Use single-file export method } @@ -499,7 +502,7 @@ const PageEditor = ({ // Step 2: Use the already selected page IDs // Filter to only include IDs that exist in the document with DOM state - const validSelectedPageIds = selectedPageIds.filter(pageId => + const validSelectedPageIds = selectedPageIds.filter(pageId => documentWithDOMState.pages.some(p => p.id === pageId) ); @@ -551,11 +554,11 @@ const PageEditor = ({ const sourceFiles = getSourceFiles(); const baseExportFilename = getExportFilename(); const baseName = baseExportFilename.replace(/\.pdf$/i, ''); - + for (let i = 0; i < processedDocuments.length; i++) { const doc = processedDocuments[i]; const partFilename = `${baseName}_part_${i + 1}.pdf`; - + const result = sourceFiles ? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename }) : await pdfExportService.exportPDF(doc, [], { filename: partFilename }); @@ -622,6 +625,7 @@ const PageEditor = ({ const closePdf = useCallback(() => { actions.clearAllFiles(); + undoManagerRef.current.clear(); setSelectedPageIds([]); setSelectionMode(false); @@ -632,7 +636,7 @@ const PageEditor = ({ if (!displayDocument) return; // For now, trigger the actual export directly - // In the original, this would show a preview modal first + // In the original, this would show a preview modal first if (selectedOnly) { onExportSelected(); } else { @@ -723,23 +727,23 @@ const PageEditor = ({ const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; const ITEM_HEIGHT = parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx; const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; - + return Array.from(splitPositions).map((position) => { - + // Calculate items per row using DragDropGrid's logic const availableWidth = containerWidth - ITEM_GAP; // Account for first gap const itemWithGap = ITEM_WIDTH + ITEM_GAP; const itemsPerRow = Math.max(1, Math.floor(availableWidth / itemWithGap)); - + // Calculate position within the grid (same as DragDropGrid) const row = Math.floor(position / itemsPerRow); const col = position % itemsPerRow; - + // Position split line between pages (after the current page) // Calculate grid centering offset (same as DragDropGrid) const gridWidth = itemsPerRow * ITEM_WIDTH + (itemsPerRow - 1) * ITEM_GAP; const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2); - + const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2); const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height) diff --git a/frontend/src/components/pageEditor/commands/pageCommands.ts b/frontend/src/components/pageEditor/commands/pageCommands.ts index 4c033696e..96b93aa63 100644 --- a/frontend/src/components/pageEditor/commands/pageCommands.ts +++ b/frontend/src/components/pageEditor/commands/pageCommands.ts @@ -1,3 +1,4 @@ +import { FileId } from '../../../types/file'; import { PDFDocument, PDFPage } from '../../../types/pageEditor'; // V1-style DOM-first command system (replaces the old React state commands) @@ -83,18 +84,18 @@ export class DeletePagesCommand extends DOMCommand { }; this.originalSplitPositions = new Set(this.getSplitPositions()); this.originalSelectedPages = [...this.getSelectedPages()]; - + // Convert page numbers to page IDs for stable identification this.pageIdsToDelete = this.pagesToDelete.map(pageNum => { const page = currentDoc.pages.find(p => p.pageNumber === pageNum); return page?.id || ''; }).filter(id => id); - + this.hasExecuted = true; } // Filter out deleted pages by ID (stable across undo/redo) - const remainingPages = currentDoc.pages.filter(page => + const remainingPages = currentDoc.pages.filter(page => !this.pageIdsToDelete.includes(page.id) ); @@ -172,20 +173,20 @@ export class ReorderPagesCommand extends DOMCommand { const selectedPageObjects = this.selectedPages .map(pageNum => currentDoc.pages.find(p => p.pageNumber === pageNum)) .filter(page => page !== undefined) as PDFPage[]; - + const remainingPages = newPages.filter(page => !this.selectedPages!.includes(page.pageNumber)); remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects); - + remainingPages.forEach((page, index) => { page.pageNumber = index + 1; }); - + newPages.splice(0, newPages.length, ...remainingPages); } else { // Single page reorder const [movedPage] = newPages.splice(sourceIndex, 1); newPages.splice(this.targetIndex, 0, movedPage); - + newPages.forEach((page, index) => { page.pageNumber = index + 1; }); @@ -237,13 +238,13 @@ export class SplitCommand extends DOMCommand { // Toggle the split position const currentPositions = this.getSplitPositions(); const newPositions = new Set(currentPositions); - + if (newPositions.has(this.position)) { newPositions.delete(this.position); } else { newPositions.add(this.position); } - + this.setSplitPositions(newPositions); } @@ -282,7 +283,7 @@ export class BulkRotateCommand extends DOMCommand { const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; this.originalRotations.set(pageId, currentRotation); } - + // Apply rotation using transform to trigger CSS animation const currentTransform = img.style.transform || ''; const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); @@ -354,7 +355,7 @@ export class BulkSplitCommand extends DOMCommand { export class SplitAllCommand extends DOMCommand { private originalSplitPositions: Set = new Set(); private allPossibleSplits: Set = new Set(); - + constructor( private totalPages: number, private getSplitPositions: () => Set, @@ -366,15 +367,15 @@ export class SplitAllCommand extends DOMCommand { this.allPossibleSplits.add(i); } } - + execute(): void { // Store original state for undo this.originalSplitPositions = new Set(this.getSplitPositions()); - + // Check if all splits are already active const currentSplits = this.getSplitPositions(); const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos)); - + if (hasAllSplits) { // Remove all splits this.setSplitPositions(new Set()); @@ -383,12 +384,12 @@ export class SplitAllCommand extends DOMCommand { this.setSplitPositions(this.allPossibleSplits); } } - + undo(): void { // Restore original split positions this.setSplitPositions(this.originalSplitPositions); } - + get description(): string { const currentSplits = this.getSplitPositions(); const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos)); @@ -453,7 +454,7 @@ export class PageBreakCommand extends DOMCommand { }; this.setDocument(updatedDocument); - + // No need to maintain selection - page IDs remain stable, so selection persists automatically } @@ -529,7 +530,7 @@ export class BulkPageBreakCommand extends DOMCommand { }; this.setDocument(updatedDocument); - + // Maintain existing selection by mapping original selected pages to their new positions const updatedSelection: number[] = []; this.originalSelectedPages.forEach(originalPageNum => { @@ -558,9 +559,9 @@ export class BulkPageBreakCommand extends DOMCommand { export class InsertFilesCommand extends DOMCommand { private insertedPages: PDFPage[] = []; private originalDocument: PDFDocument | null = null; - private fileDataMap = new Map(); // Store file data for thumbnail generation + private fileDataMap = new Map(); // Store file data for thumbnail generation private originalProcessedFile: any = null; // Store original ProcessedFile for undo - private insertedFileMap = new Map(); // Store inserted files for export + private insertedFileMap = new Map(); // Store inserted files for export constructor( private files: File[], @@ -569,7 +570,7 @@ export class InsertFilesCommand extends DOMCommand { private setDocument: (doc: PDFDocument) => void, private setSelectedPages: (pages: number[]) => void, private getSelectedPages: () => number[], - private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map) => void + private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map) => void ) { super(); } @@ -587,19 +588,19 @@ export class InsertFilesCommand extends DOMCommand { try { // Process each file to extract pages and wait for all to complete const allNewPages: PDFPage[] = []; - + // Process all files and wait for their completion const baseTimestamp = Date.now(); const extractionPromises = this.files.map(async (file, index) => { - const fileId = `inserted-${file.name}-${baseTimestamp + index}`; + const fileId = `inserted-${file.name}-${baseTimestamp + index}` as FileId; // Store inserted file for export this.insertedFileMap.set(fileId, file); // Use base timestamp + index to ensure unique but predictable file IDs return await this.extractPagesFromFile(file, baseTimestamp + index); }); - + const extractedPageArrays = await Promise.all(extractionPromises); - + // Flatten all extracted pages for (const pages of extractedPageArrays) { allNewPages.push(...pages); @@ -658,7 +659,7 @@ export class InsertFilesCommand extends DOMCommand { // Maintain existing selection by mapping original selected pages to their new positions const originalSelection = this.getSelectedPages(); const updatedSelection: number[] = []; - + originalSelection.forEach(originalPageNum => { if (originalPageNum <= this.insertAfterPageNumber) { // Pages before insertion point keep same number @@ -668,7 +669,7 @@ export class InsertFilesCommand extends DOMCommand { updatedSelection.push(originalPageNum + allNewPages.length); } }); - + this.setSelectedPages(updatedSelection); } catch (error) { @@ -683,35 +684,35 @@ export class InsertFilesCommand extends DOMCommand { private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise { try { const { thumbnailGenerationService } = await import('../../../services/thumbnailGenerationService'); - + // Group pages by file ID to generate thumbnails efficiently - const pagesByFileId = new Map(); - + const pagesByFileId = new Map(); + for (const page of this.insertedPages) { - const fileId = page.id.substring(0, page.id.lastIndexOf('-page-')); + const fileId = page.id.substring(0, page.id.lastIndexOf('-page-')) as FileId /* FIX ME: This looks wrong - like we've thrown away info too early and need to recreate it */; if (!pagesByFileId.has(fileId)) { pagesByFileId.set(fileId, []); } pagesByFileId.get(fileId)!.push(page); } - + // Generate thumbnails for each file for (const [fileId, pages] of pagesByFileId) { const arrayBuffer = this.fileDataMap.get(fileId); - + console.log('Generating thumbnails for file:', fileId); console.log('Pages:', pages.length); console.log('ArrayBuffer size:', arrayBuffer?.byteLength || 'undefined'); - + if (arrayBuffer && arrayBuffer.byteLength > 0) { // Extract page numbers for all pages from this file const pageNumbers = pages.map(page => { const pageNumMatch = page.id.match(/-page-(\d+)$/); return pageNumMatch ? parseInt(pageNumMatch[1]) : 1; }); - + console.log('Generating thumbnails for page numbers:', pageNumbers); - + // Generate thumbnails for all pages from this file at once const results = await thumbnailGenerationService.generateThumbnails( fileId, @@ -719,14 +720,14 @@ export class InsertFilesCommand extends DOMCommand { pageNumbers, { scale: 0.2, quality: 0.8 } ); - + console.log('Thumbnail generation results:', results.length, 'thumbnails generated'); - + // Update pages with generated thumbnails for (let i = 0; i < results.length && i < pages.length; i++) { const result = results[i]; const page = pages[i]; - + if (result.success) { const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id); if (pageIndex >= 0) { @@ -735,7 +736,7 @@ export class InsertFilesCommand extends DOMCommand { } } } - + // Trigger re-render by updating the document this.setDocument({ ...updatedDocument }); } else { @@ -754,7 +755,7 @@ export class InsertFilesCommand extends DOMCommand { try { const arrayBuffer = event.target?.result as ArrayBuffer; console.log('File reader onload - arrayBuffer size:', arrayBuffer?.byteLength || 'undefined'); - + if (!arrayBuffer) { reject(new Error('Failed to read file')); return; @@ -762,24 +763,24 @@ export class InsertFilesCommand extends DOMCommand { // Clone the ArrayBuffer before passing to PDF.js (it might consume it) const clonedArrayBuffer = arrayBuffer.slice(0); - + // Use PDF.js via the worker manager to extract pages const { pdfWorkerManager } = await import('../../../services/pdfWorkerManager'); const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer); - + const pageCount = pdf.numPages; const pages: PDFPage[] = []; - const fileId = `inserted-${file.name}-${baseTimestamp}`; - + const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId; + console.log('Original ArrayBuffer size:', arrayBuffer.byteLength); console.log('Storing ArrayBuffer for fileId:', fileId, 'size:', arrayBuffer.byteLength); - + // Store the original ArrayBuffer for thumbnail generation this.fileDataMap.set(fileId, arrayBuffer); - + console.log('After storing - fileDataMap size:', this.fileDataMap.size); console.log('Stored value size:', this.fileDataMap.get(fileId)?.byteLength || 'undefined'); - + for (let i = 1; i <= pageCount; i++) { const pageId = `${fileId}-page-${i}`; pages.push({ @@ -793,10 +794,10 @@ export class InsertFilesCommand extends DOMCommand { isBlankPage: false }); } - + // Clean up PDF document pdfWorkerManager.destroyDocument(pdf); - + resolve(pages); } catch (error) { reject(error); @@ -876,4 +877,4 @@ export class UndoManager { this.redoStack = []; this.onStateChange?.(); } -} \ No newline at end of file +} diff --git a/frontend/src/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/components/pageEditor/hooks/usePageDocument.ts index 5a9d13f9f..b620c87b8 100644 --- a/frontend/src/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/components/pageEditor/hooks/usePageDocument.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useFileState } from '../../../contexts/FileContext'; import { PDFDocument, PDFPage } from '../../../types/pageEditor'; +import { FileId } from '../../../types/file'; export interface PageDocumentHook { document: PDFDocument | null; @@ -14,17 +15,17 @@ export interface PageDocumentHook { */ export function usePageDocument(): PageDocumentHook { const { state, selectors } = useFileState(); - + // Prefer IDs + selectors to avoid array identity churn const activeFileIds = state.files.ids; const primaryFileId = activeFileIds[0] ?? null; - + // Stable signature for effects (prevents loops) const filesSignature = selectors.getFilesSignature(); - + // UI state const globalProcessing = state.ui.isProcessing; - + // Get primary file record outside useMemo to track processedFile changes const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; const processedFilePages = primaryFileRecord?.processedFile?.pages; @@ -35,7 +36,7 @@ export function usePageDocument(): PageDocumentHook { if (activeFileIds.length === 0) return null; const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; - + // If we have file IDs but no file record, something is wrong - return null to show loading if (!primaryFileRecord) { console.log('🎬 PageEditor: No primary file record found, showing loading'); @@ -50,9 +51,9 @@ export function usePageDocument(): PageDocumentHook { .join(' + '); // Build page insertion map from files with insertion positions - const insertionMap = new Map(); // insertAfterPageId -> fileIds - const originalFileIds: string[] = []; - + const insertionMap = new Map(); // insertAfterPageId -> fileIds + const originalFileIds: FileId[] = []; + activeFileIds.forEach(fileId => { const record = selectors.getFileRecord(fileId); if (record?.insertAfterPageId !== undefined) { @@ -64,21 +65,21 @@ export function usePageDocument(): PageDocumentHook { originalFileIds.push(fileId); } }); - + // Build pages by interleaving original pages with insertions let pages: PDFPage[] = []; let totalPageCount = 0; - + // Helper function to create pages from a file - const createPagesFromFile = (fileId: string, startPageNumber: number): PDFPage[] => { + const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => { const fileRecord = selectors.getFileRecord(fileId); if (!fileRecord) { return []; } - + const processedFile = fileRecord.processedFile; let filePages: PDFPage[] = []; - + if (processedFile?.pages && processedFile.pages.length > 0) { // Use fully processed pages with thumbnails filePages = processedFile.pages.map((page, pageIndex) => ({ @@ -104,7 +105,7 @@ export function usePageDocument(): PageDocumentHook { splitAfter: false, })); } - + return filePages; }; @@ -114,35 +115,35 @@ export function usePageDocument(): PageDocumentHook { const filePages = createPagesFromFile(fileId, 1); // Temporary numbering originalFilePages.push(...filePages); }); - - // Start with all original pages numbered sequentially + + // Start with all original pages numbered sequentially pages = originalFilePages.map((page, index) => ({ ...page, pageNumber: index + 1 })); - + // Process each insertion by finding the page ID and inserting after it for (const [insertAfterPageId, fileIds] of insertionMap.entries()) { const targetPageIndex = pages.findIndex(p => p.id === insertAfterPageId); - + if (targetPageIndex === -1) continue; - + // Collect all pages to insert const allNewPages: PDFPage[] = []; fileIds.forEach(fileId => { const insertedPages = createPagesFromFile(fileId, 1); allNewPages.push(...insertedPages); }); - + // Insert all new pages after the target page pages.splice(targetPageIndex + 1, 0, ...allNewPages); - + // Renumber all pages after insertion pages.forEach((page, index) => { page.pageNumber = index + 1; }); } - + totalPageCount = pages.length; if (pages.length === 0) { @@ -173,4 +174,4 @@ export function usePageDocument(): PageDocumentHook { isVeryLargeDocument, isLoading }; -} \ No newline at end of file +} diff --git a/frontend/src/components/shared/FileGrid.tsx b/frontend/src/components/shared/FileGrid.tsx index 169e19f88..1a43196d6 100644 --- a/frontend/src/components/shared/FileGrid.tsx +++ b/frontend/src/components/shared/FileGrid.tsx @@ -5,6 +5,7 @@ import SearchIcon from "@mui/icons-material/Search"; import SortIcon from "@mui/icons-material/Sort"; import FileCard from "./FileCard"; import { FileRecord } from "../../types/fileContext"; +import { FileId } from "../../types/file"; interface FileGridProps { files: Array<{ file: File; record?: FileRecord }>; @@ -12,8 +13,8 @@ interface FileGridProps { onDoubleClick?: (item: { file: File; record?: FileRecord }) => void; onView?: (item: { file: File; record?: FileRecord }) => void; onEdit?: (item: { file: File; record?: FileRecord }) => void; - onSelect?: (fileId: string) => void; - selectedFiles?: string[]; + onSelect?: (fileId: FileId) => void; + selectedFiles?: FileId[]; showSearch?: boolean; showSort?: boolean; maxDisplay?: number; // If set, shows only this many files with "Show All" option @@ -119,11 +120,11 @@ const FileGrid = ({ direction="row" wrap="wrap" gap="md" - h="30rem" + h="30rem" style={{ overflowY: "auto", width: "100%" }} > {displayFiles.map((item, idx) => { - const fileId = item.record?.id || item.file.name; + const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */; const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId); const supported = isFileSupported ? isFileSupported(item.file.name) : true; return ( diff --git a/frontend/src/components/shared/FilePickerModal.tsx b/frontend/src/components/shared/FilePickerModal.tsx index 8aa054f25..fde20f014 100644 --- a/frontend/src/components/shared/FilePickerModal.tsx +++ b/frontend/src/components/shared/FilePickerModal.tsx @@ -1,12 +1,12 @@ import React, { useState, useEffect } from 'react'; -import { - Modal, - Text, - Button, - Group, - Stack, - Checkbox, - ScrollArea, +import { + Modal, + Text, + Button, + Group, + Stack, + Checkbox, + ScrollArea, Box, Image, Badge, @@ -15,6 +15,7 @@ import { } from '@mantine/core'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import { useTranslation } from 'react-i18next'; +import { FileId } from '../../types/file'; interface FilePickerModalProps { opened: boolean; @@ -30,7 +31,7 @@ const FilePickerModal = ({ onSelectFiles, }: FilePickerModalProps) => { const { t } = useTranslation(); - const [selectedFileIds, setSelectedFileIds] = useState([]); + const [selectedFileIds, setSelectedFileIds] = useState([]); // Reset selection when modal opens useEffect(() => { @@ -39,9 +40,9 @@ const FilePickerModal = ({ } }, [opened]); - const toggleFileSelection = (fileId: string) => { + const toggleFileSelection = (fileId: FileId) => { setSelectedFileIds(prev => { - return prev.includes(fileId) + return prev.includes(fileId) ? prev.filter(id => id !== fileId) : [...prev, fileId]; }); @@ -56,10 +57,10 @@ const FilePickerModal = ({ }; const handleConfirm = async () => { - const selectedFiles = storedFiles.filter(f => + const selectedFiles = storedFiles.filter(f => selectedFileIds.includes(f.id) ); - + // Convert stored files to File objects const convertedFiles = await Promise.all( selectedFiles.map(async (fileItem) => { @@ -68,12 +69,12 @@ const FilePickerModal = ({ if (fileItem instanceof File) { return fileItem; } - + // If it has a file property, use that if (fileItem.file && fileItem.file instanceof File) { return fileItem.file; } - + // If it's from IndexedDB storage, reconstruct the File if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') { const arrayBuffer = await fileItem.arrayBuffer(); @@ -83,8 +84,8 @@ const FilePickerModal = ({ lastModified: fileItem.lastModified || Date.now() }); } - - // If it has data property, reconstruct the File + + // If it has data property, reconstruct the File if (fileItem.data) { const blob = new Blob([fileItem.data], { type: fileItem.type || 'application/pdf' }); return new File([blob], fileItem.name, { @@ -92,7 +93,7 @@ const FilePickerModal = ({ lastModified: fileItem.lastModified || Date.now() }); } - + console.warn('Could not convert file item:', fileItem); return null; } catch (error) { @@ -101,10 +102,10 @@ const FilePickerModal = ({ } }) ); - + // Filter out any null values and return valid Files const validFiles = convertedFiles.filter((f): f is File => f !== null); - + onSelectFiles(validFiles); onClose(); }; @@ -156,18 +157,18 @@ const FilePickerModal = ({ {storedFiles.map((file) => { const fileId = file.id; const isSelected = selectedFileIds.includes(fileId); - + return ( toggleFileSelection(fileId)} onClick={(e) => e.stopPropagation()} /> - + {/* Thumbnail */} {t("close", "Cancel")} - + )} + +
+ ); +} diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 688bc2c89..04fee9b35 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -36,13 +36,13 @@ const LandingPage = () => { }; return ( - + {/* White PDF Page Background */} = ({ filteredTools, onSelect } } return ( - + {searchGroups.map(group => ( - + - + {group.tools.map(({ id, tool }) => ( 0 ? ( // Searching view (replaces both picker and content) -
-
+
-
) : leftPanelView === 'toolPicker' ? ( // Tool Picker View -
+