diff --git a/ADDING_TOOLS.md b/ADDING_TOOLS.md new file mode 100644 index 000000000..8b2aa1d99 --- /dev/null +++ b/ADDING_TOOLS.md @@ -0,0 +1,308 @@ +# Adding New React Tools to Stirling PDF + +This guide covers how to add new PDF tools to the React frontend, either by migrating existing Thymeleaf templates or creating entirely new tools. + +## Overview + +When adding tools, follow this systematic approach using the established patterns and architecture. + +## 1. Create Tool Structure + +Create these files in the correct directories: +``` +frontend/src/hooks/tools/[toolName]/ + ├── use[ToolName]Parameters.ts # Parameter definitions and validation + └── use[ToolName]Operation.ts # Tool operation logic using useToolOperation + +frontend/src/components/tools/[toolName]/ + └── [ToolName]Settings.tsx # Settings UI component (if needed) + +frontend/src/tools/ + └── [ToolName].tsx # Main tool component +``` + +## 2. Implementation Pattern + +Use `useBaseTool` for simplified hook management. This is the recommended approach for all new tools: + +**Parameters Hook** (`use[ToolName]Parameters.ts`): +```typescript +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface [ToolName]Parameters extends BaseParameters { + // Define your tool-specific parameters here + someOption: boolean; +} + +export const defaultParameters: [ToolName]Parameters = { + someOption: false, +}; + +export const use[ToolName]Parameters = (): BaseParametersHook<[ToolName]Parameters> => { + return useBaseParameters({ + defaultParameters, + endpointName: 'your-endpoint-name', + validateFn: (params) => true, // Add validation logic + }); +}; +``` + +**Operation Hook** (`use[ToolName]Operation.ts`): +```typescript +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; + +export const build[ToolName]FormData = (parameters: [ToolName]Parameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + // Add parameters to formData + return formData; +}; + +export const [toolName]OperationConfig = { + toolType: ToolType.singleFile, // or ToolType.multiFile + buildFormData: build[ToolName]FormData, + operationType: '[toolName]', + endpoint: '/api/v1/category/endpoint-name', + filePrefix: 'processed_', // Will be overridden with translation + defaultParameters, +} as const; + +export const use[ToolName]Operation = () => { + const { t } = useTranslation(); + return useToolOperation({ + ...[toolName]OperationConfig, + filePrefix: t('[toolName].filenamePrefix', 'processed') + '_', + getErrorMessage: createStandardErrorHandler(t('[toolName].error.failed', 'Operation failed')) + }); +}; +``` + +**Main Component** (`[ToolName].tsx`): +```typescript +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import { use[ToolName]Parameters } from "../hooks/tools/[toolName]/use[ToolName]Parameters"; +import { use[ToolName]Operation } from "../hooks/tools/[toolName]/use[ToolName]Operation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; + +const [ToolName] = (props: BaseToolProps) => { + const { t } = useTranslation(); + const base = useBaseTool('[toolName]', use[ToolName]Parameters, use[ToolName]Operation, props); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + placeholder: t("[toolName].files.placeholder", "Select files to get started"), + }, + steps: [ + // Add settings steps if needed + ], + executeButton: { + text: t("[toolName].submit", "Process"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("[toolName].results.title", "Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +[ToolName].tool = () => use[ToolName]Operation; +export default [ToolName] as ToolComponent; +``` + +**Note**: Some existing tools (like AddPassword, Compress) use a legacy pattern with manual hook management. **Always use the Modern Pattern above for new tools** - it's cleaner, more maintainable, and includes automation support. + +## 3. Register Tool in System +Update these files to register your new tool: + +**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`): +1. Add imports at the top: +```typescript +import [ToolName] from "../tools/[ToolName]"; +import { [toolName]OperationConfig } from "../hooks/tools/[toolName]/use[ToolName]Operation"; +import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Settings"; +``` + +2. Add tool entry in the `allTools` object: +```typescript +[toolName]: { + icon: , + name: t("home.[toolName].title", "Tool Name"), + component: [ToolName], + description: t("home.[toolName].desc", "Tool description"), + categoryId: ToolCategoryId.STANDARD_TOOLS, // or appropriate category + subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY, + maxFiles: -1, // or specific number + endpoints: ["endpoint-name"], + operationConfig: [toolName]OperationConfig, + settingsComponent: [ToolName]Settings, // if settings exist +}, +``` + +## 4. Add Tooltips (Optional but Recommended) +Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:** + +**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`): +```typescript +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const use[ToolName]Tips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("[toolName].tooltip.header.title", "Tool Overview") + }, + tips: [ + { + title: t("[toolName].tooltip.description.title", "What does this tool do?"), + description: t("[toolName].tooltip.description.text", "Simple explanation in everyday language that non-technical users can understand."), + bullets: [ + t("[toolName].tooltip.description.bullet1", "Easy-to-understand benefit 1"), + t("[toolName].tooltip.description.bullet2", "Easy-to-understand benefit 2") + ] + } + // Add more tip sections as needed + ] + }; +}; +``` + +**Add tooltip to your main component:** +```typescript +import { use[ToolName]Tips } from "../components/tooltips/use[ToolName]Tips"; + +const [ToolName] = (props: BaseToolProps) => { + const tips = use[ToolName]Tips(); + + // In your steps array: + steps: [ + { + title: t("[toolName].steps.settings", "Settings"), + tooltip: tips, // Add this line + content: <[ToolName]Settings ... /> + } + ] +``` + +## 5. Add Translations +Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately. + +**File to update:** `frontend/public/locales/en-GB/translation.json` + +**Required Translation Keys**: +```json +{ + "home": { + "[toolName]": { + "title": "Tool Name", + "desc": "Tool description" + } + }, + "[toolName]": { + "title": "Tool Name", + "submit": "Process", + "filenamePrefix": "processed", + "files": { + "placeholder": "Select files to get started" + }, + "steps": { + "settings": "Settings" + }, + "options": { + "title": "Tool Options", + "someOption": "Option Label", + "someOption.desc": "Option description", + "note": "General information about the tool." + }, + "results": { + "title": "Results" + }, + "error": { + "failed": "Operation failed" + }, + "tooltip": { + "header": { + "title": "Tool Overview" + }, + "description": { + "title": "What does this tool do?", + "text": "Simple explanation in everyday language", + "bullet1": "Easy-to-understand benefit 1", + "bullet2": "Easy-to-understand benefit 2" + } + } + } +} +``` + +**Translation Notes:** +- **Only update `en-GB/translation.json`** - other locale files are managed separately +- Use descriptive keys that match your component's `t()` calls +- Include tooltip translations if you created tooltip hooks +- Add `options.*` keys if your tool has settings with descriptions + +**Tooltip Writing Guidelines:** +- **Use simple, everyday language** - avoid technical terms like "converts interactive elements" +- **Focus on benefits** - explain what the user gains, not how it works internally +- **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened" +- **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?" +- **Keep descriptions concise** - 1-2 sentences maximum per section +- **Use bullet points** for multiple benefits or features + +## 6. Migration from Thymeleaf +When migrating existing Thymeleaf templates: + +1. **Identify Form Parameters**: Look at the original `
` inputs to determine parameter structure +2. **Extract Translation Keys**: Find `#{key.name}` references and add them to JSON translations +3. **Map API Endpoint**: Note the `th:action` URL for the operation hook +4. **Preserve Functionality**: Ensure all original form behavior is replicated + +## 7. Testing Your Tool +- Verify tool appears in UI with correct icon and description +- Test with various file sizes and types +- Confirm translations work +- Check error handling +- Test undo functionality +- Verify results display correctly + +## Tool Development Patterns + +### Three Tool Patterns: + +**Pattern 1: Single-File Tools** (Individual processing) +- Backend processes one file per API call +- Set `multiFileEndpoint: false` +- Examples: Compress, Rotate + +**Pattern 2: Multi-File Tools** (Batch processing) +- Backend accepts `MultipartFile[]` arrays in single API call +- Set `multiFileEndpoint: true` +- Examples: Split, Merge, Overlay + +**Pattern 3: Complex Tools** (Custom processing) +- Tools with complex routing logic or non-standard processing +- Provide `customProcessor` for full control +- Examples: Convert, OCR + +## Benefits of This Architecture: +- **No Timeouts**: Operations run until completion (supports 100GB+ files) +- **Consistent**: All tools follow same pattern and interface +- **Maintainable**: Single responsibility hooks, easy to test and modify +- **i18n Ready**: Built-in internationalization support +- **Type Safe**: Full TypeScript support with generic interfaces +- **Memory Safe**: Automatic resource cleanup and blob URL management \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index be4e92201..a806d0098 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,6 +208,7 @@ return useToolOperation({ - **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`) - **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes - **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation) +- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools ## Communication Style - Be direct and to the point diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 637ab59e1..5f61f7544 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1305,7 +1305,48 @@ "title": "Flatten", "header": "Flatten PDF", "flattenOnlyForms": "Flatten only forms", - "submit": "Flatten" + "submit": "Flatten", + "filenamePrefix": "flattened", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "steps": { + "settings": "Settings" + }, + "options": { + "stepTitle": "Flatten Options", + "title": "Flatten Options", + "flattenOnlyForms": "Flatten only forms", + "flattenOnlyForms.desc": "Only flatten form fields, leaving other interactive elements intact", + "note": "Flattening removes interactive elements from the PDF, making them non-editable." + }, + "results": { + "title": "Flatten Results" + }, + "error": { + "failed": "An error occurred while flattening the PDF." + }, + "tooltip": { + "header": { + "title": "About Flattening PDFs" + }, + "description": { + "title": "What does flattening do?", + "text": "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere.", + "bullet1": "Text boxes become regular text (can't be edited)", + "bullet2": "Checkboxes and buttons become pictures", + "bullet3": "Great for final versions you don't want changed", + "bullet4": "Ensures consistent appearance across all devices" + }, + "formsOnly": { + "title": "What does 'Flatten only forms' mean?", + "text": "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments.", + "bullet1": "Forms become non-editable", + "bullet2": "Links still work when clicked", + "bullet3": "Comments and notes remain visible", + "bullet4": "Bookmarks still help you navigate" + } + } }, "repair": { "tags": "fix,restore,correction,recover", diff --git a/frontend/src/components/tools/flatten/FlattenSettings.tsx b/frontend/src/components/tools/flatten/FlattenSettings.tsx new file mode 100644 index 000000000..a8ec9af07 --- /dev/null +++ b/frontend/src/components/tools/flatten/FlattenSettings.tsx @@ -0,0 +1,35 @@ +import { Stack, Text, Checkbox } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { FlattenParameters } from "../../../hooks/tools/flatten/useFlattenParameters"; + +interface FlattenSettingsProps { + parameters: FlattenParameters; + onParameterChange: (key: keyof FlattenParameters, value: boolean) => void; + disabled?: boolean; +} + +const FlattenSettings = ({ parameters, onParameterChange, disabled = false }: FlattenSettingsProps) => { + const { t } = useTranslation(); + + return ( + + + onParameterChange('flattenOnlyForms', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('flatten.options.flattenOnlyForms', 'Flatten only forms')} + + {t('flatten.options.flattenOnlyForms.desc', 'Only flatten form fields, leaving other interactive elements intact')} + +
+ } + /> +
+
+ ); +}; + +export default FlattenSettings; \ No newline at end of file diff --git a/frontend/src/components/tooltips/useFlattenTips.ts b/frontend/src/components/tooltips/useFlattenTips.ts new file mode 100644 index 000000000..a60e5a4bc --- /dev/null +++ b/frontend/src/components/tooltips/useFlattenTips.ts @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useFlattenTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("flatten.tooltip.header.title", "About Flattening PDFs") + }, + tips: [ + { + title: t("flatten.tooltip.description.title", "What does flattening do?"), + description: t("flatten.tooltip.description.text", "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere."), + bullets: [ + t("flatten.tooltip.description.bullet1", "Text boxes become regular text (can't be edited)"), + t("flatten.tooltip.description.bullet2", "Checkboxes and buttons become pictures"), + t("flatten.tooltip.description.bullet3", "Great for final versions you don't want changed"), + t("flatten.tooltip.description.bullet4", "Ensures consistent appearance across all devices") + ] + }, + { + title: t("flatten.tooltip.formsOnly.title", "What does 'Flatten only forms' mean?"), + description: t("flatten.tooltip.formsOnly.text", "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments."), + bullets: [ + t("flatten.tooltip.formsOnly.bullet1", "Forms become non-editable"), + t("flatten.tooltip.formsOnly.bullet2", "Links still work when clicked"), + t("flatten.tooltip.formsOnly.bullet3", "Comments and notes remain visible"), + t("flatten.tooltip.formsOnly.bullet4", "Bookmarks still help you navigate") + ] + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 68883fe92..f4d0f444c 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -15,6 +15,7 @@ import Repair from "../tools/Repair"; import SingleLargePage from "../tools/SingleLargePage"; import UnlockPdfForms from "../tools/UnlockPdfForms"; import RemoveCertificateSign from "../tools/RemoveCertificateSign"; +import Flatten from "../tools/Flatten"; import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation"; import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation"; import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation"; @@ -28,6 +29,7 @@ import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation"; import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation"; import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation"; import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation"; +import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation"; import CompressSettings from "../components/tools/compress/CompressSettings"; import SplitSettings from "../components/tools/split/SplitSettings"; import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings"; @@ -39,6 +41,7 @@ import AddWatermarkSingleStepSettings from "../components/tools/addWatermark/Add import OCRSettings from "../components/tools/ocr/OCRSettings"; import ConvertSettings from "../components/tools/convert/ConvertSettings"; import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings"; +import FlattenSettings from "../components/tools/flatten/FlattenSettings"; import { ToolId } from "../types/toolId"; const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI @@ -198,10 +201,14 @@ export function useFlatToolRegistry(): ToolRegistry { flatten: { icon: , name: t("home.flatten.title", "Flatten"), - component: null, + component: Flatten, description: t("home.flatten.desc", "Remove all interactive elements and forms from a PDF"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.DOCUMENT_SECURITY, + maxFiles: -1, + endpoints: ["flatten"], + operationConfig: flattenOperationConfig, + settingsComponent: FlattenSettings, }, "unlock-pdf-forms": { icon: , diff --git a/frontend/src/hooks/tools/flatten/useFlattenOperation.ts b/frontend/src/hooks/tools/flatten/useFlattenOperation.ts new file mode 100644 index 000000000..82eaba258 --- /dev/null +++ b/frontend/src/hooks/tools/flatten/useFlattenOperation.ts @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { FlattenParameters, defaultParameters } from './useFlattenParameters'; + +// Static function that can be used by both the hook and automation executor +export const buildFlattenFormData = (parameters: FlattenParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + formData.append('flattenOnlyForms', parameters.flattenOnlyForms.toString()); + return formData; +}; + +// Static configuration object +export const flattenOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildFlattenFormData, + operationType: 'flatten', + endpoint: '/api/v1/misc/flatten', + filePrefix: 'flattened_', // Will be overridden in hook with translation + multiFileEndpoint: false, + defaultParameters, +} as const; + +export const useFlattenOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...flattenOperationConfig, + filePrefix: t('flatten.filenamePrefix', 'flattened') + '_', + getErrorMessage: createStandardErrorHandler(t('flatten.error.failed', 'An error occurred while flattening the PDF.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/flatten/useFlattenParameters.ts b/frontend/src/hooks/tools/flatten/useFlattenParameters.ts new file mode 100644 index 000000000..c945fb769 --- /dev/null +++ b/frontend/src/hooks/tools/flatten/useFlattenParameters.ts @@ -0,0 +1,20 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface FlattenParameters extends BaseParameters { + flattenOnlyForms: boolean; +} + +export const defaultParameters: FlattenParameters = { + flattenOnlyForms: false, +}; + +export type FlattenParametersHook = BaseParametersHook; + +export const useFlattenParameters = (): FlattenParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'flatten', + validateFn: () => true, // Always valid - no required parameters + }); +}; \ No newline at end of file diff --git a/frontend/src/tools/Flatten.tsx b/frontend/src/tools/Flatten.tsx new file mode 100644 index 000000000..691a733f9 --- /dev/null +++ b/frontend/src/tools/Flatten.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import FlattenSettings from "../components/tools/flatten/FlattenSettings"; +import { useFlattenParameters } from "../hooks/tools/flatten/useFlattenParameters"; +import { useFlattenOperation } from "../hooks/tools/flatten/useFlattenOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { useFlattenTips } from "../components/tooltips/useFlattenTips"; +import { BaseToolProps, ToolComponent } from "../types/tool"; + +const Flatten = (props: BaseToolProps) => { + const { t } = useTranslation(); + const flattenTips = useFlattenTips(); + + const base = useBaseTool( + 'flatten', + useFlattenParameters, + useFlattenOperation, + props + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + placeholder: t("flatten.files.placeholder", "Select a PDF file in the main view to get started"), + }, + steps: [ + { + title: t("flatten.options.stepTitle", "Flatten Options"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: flattenTips, + content: ( + + ), + }, + ], + executeButton: { + text: t("flatten.submit", "Flatten PDF"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("flatten.results.title", "Flatten Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +// Static method to get the operation hook for automation +Flatten.tool = () => useFlattenOperation; + +export default Flatten as ToolComponent; \ No newline at end of file