From 18b67479a7955e85485471558b16a45132e9af43 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Mon, 15 Sep 2025 13:24:35 +0100 Subject: [PATCH] Addition of the Remove Pages tool, note: this is different to the Remove Blank Pages tool --- .../tools/removePages/RemovePagesSettings.tsx | 39 +++++++++++ .../src/data/useTranslatedToolRegistry.tsx | 5 +- .../removePages/useRemovePagesOperation.ts | 65 +++++++++++++++++++ .../removePages/useRemovePagesParameters.ts | 20 ++++++ frontend/src/tools/RemovePages.tsx | 61 +++++++++++++++++ 5 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/tools/removePages/RemovePagesSettings.tsx create mode 100644 frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts create mode 100644 frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts create mode 100644 frontend/src/tools/RemovePages.tsx diff --git a/frontend/src/components/tools/removePages/RemovePagesSettings.tsx b/frontend/src/components/tools/removePages/RemovePagesSettings.tsx new file mode 100644 index 000000000..2135fb49e --- /dev/null +++ b/frontend/src/components/tools/removePages/RemovePagesSettings.tsx @@ -0,0 +1,39 @@ +import { Stack, Text, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { RemovePagesParameters } from "../../../hooks/tools/removePages/useRemovePagesParameters"; + +interface RemovePagesSettingsProps { + parameters: RemovePagesParameters; + onParameterChange: (key: K, value: RemovePagesParameters[K]) => void; + disabled?: boolean; +} + +const RemovePagesSettings = ({ parameters, onParameterChange, disabled = false }: RemovePagesSettingsProps) => { + const { t } = useTranslation(); + + const handlePageNumbersChange = (value: string) => { + // Remove spaces and normalize input + const normalized = value.replace(/\s+/g, ''); + onParameterChange('pageNumbers', normalized); + }; + + return ( + + + handlePageNumbersChange(event.currentTarget.value)} + placeholder={t('removePages.pageNumbers.placeholder', 'e.g., 1,3,5-8,10')} + disabled={disabled} + required + /> + + {t('removePages.pageNumbers.desc', 'Enter page numbers or ranges separated by commas. Examples: 1,3,5 or 1-5,10-15')} + + + + ); +}; + +export default RemovePagesSettings; diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 62a7dcd96..3e5612eb0 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -9,6 +9,7 @@ import Sanitize from "../tools/Sanitize"; import AddPassword from "../tools/AddPassword"; import ChangePermissions from "../tools/ChangePermissions"; import RemoveBlanks from "../tools/RemoveBlanks"; +import RemovePages from "../tools/RemovePages"; import RemovePassword from "../tools/RemovePassword"; import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy"; import AddWatermark from "../tools/AddWatermark"; @@ -415,10 +416,12 @@ export function useFlatToolRegistry(): ToolRegistry { removePages: { icon: , name: t("home.removePages.title", "Remove Pages"), - component: null, + component: RemovePages, description: t("home.removePages.desc", "Remove specific pages from a PDF document"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.REMOVAL, + maxFiles: 1, + endpoints: ["remove-pages"], }, "remove-blank-pages": { icon: , diff --git a/frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts b/frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts new file mode 100644 index 000000000..5df067f31 --- /dev/null +++ b/frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts @@ -0,0 +1,65 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { RemovePagesParameters, defaultParameters } from './useRemovePagesParameters'; + +export const buildRemovePagesFormData = (parameters: RemovePagesParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + formData.append('pageNumbers', parameters.pageNumbers); + return formData; +}; + +export const removePagesOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildRemovePagesFormData, + operationType: 'remove-pages', + endpoint: '/api/v1/general/remove-pages', + filePrefix: 'removed_pages_', + defaultParameters, +} as const satisfies ToolOperationConfig; + +export const useRemovePagesOperation = () => { + const { t } = useTranslation(); + + const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise => { + // Try to detect zip vs pdf + const headBuf = await blob.slice(0, 4).arrayBuffer(); + const head = new TextDecoder().decode(new Uint8Array(headBuf)); + + // PDF response: return as single file + if (head.startsWith('%PDF')) { + const base = originalFiles[0]?.name?.replace(/\.[^.]+$/, '') || 'document'; + return [new File([blob], `removed_pages_${base}.pdf`, { type: 'application/pdf' })]; + } + + // ZIP: extract PDFs inside + if (head.startsWith('PK')) { + const { extractZipFiles } = await import('../shared/useToolResources'); + const files = await extractZipFiles(blob); + if (files.length > 0) return files; + } + + // Unknown blob type + const textBuf = await blob.slice(0, 1024).arrayBuffer(); + const text = new TextDecoder().decode(new Uint8Array(textBuf)); + if (/error|exception|html/i.test(text)) { + const title = + text.match(/]*>([^<]+)<\/title>/i)?.[1] || + text.match(/]*>([^<]+)<\/h1>/i)?.[1] || + 'Unknown error'; + throw new Error(`Remove pages service error: ${title}`); + } + throw new Error('Unexpected response format from remove pages service'); + }, []); + + return useToolOperation({ + ...removePagesOperationConfig, + responseHandler, + filePrefix: t('removePages.filenamePrefix', 'removed_pages') + '_', + getErrorMessage: createStandardErrorHandler( + t('removePages.error.failed', 'Failed to remove pages') + ) + }); +}; diff --git a/frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts b/frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts new file mode 100644 index 000000000..6a2830f2e --- /dev/null +++ b/frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts @@ -0,0 +1,20 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +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; + +export const useRemovePagesParameters = (): RemovePagesParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'remove-pages', + validateFn: (p) => p.pageNumbers.trim().length > 0, + }); +}; diff --git a/frontend/src/tools/RemovePages.tsx b/frontend/src/tools/RemovePages.tsx new file mode 100644 index 000000000..413d1b54f --- /dev/null +++ b/frontend/src/tools/RemovePages.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import { BaseToolProps, ToolComponent } from "../types/tool"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { useRemovePagesParameters } from "../hooks/tools/removePages/useRemovePagesParameters"; +import { useRemovePagesOperation } from "../hooks/tools/removePages/useRemovePagesOperation"; +import RemovePagesSettings from "../components/tools/removePages/RemovePagesSettings"; + +const RemovePages = (props: BaseToolProps) => { + const { t } = useTranslation(); + + const base = useBaseTool( + 'remove-pages', + useRemovePagesParameters, + useRemovePagesOperation, + props + ); + + + const settingsContent = ( + + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: t("removePages.settings.title", "Settings"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + content: settingsContent, + }, + ], + executeButton: { + text: t("removePages.submit", "Remove Pages"), + loadingText: t("loading"), + onClick: base.handleExecute, + isVisible: !base.hasResults, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("removePages.results.title", "Pages Removed"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +RemovePages.tool = () => useRemovePagesOperation; + +export default RemovePages as ToolComponent;