mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-23 12:49:24 +00:00
Feature/v2/automate (#4248)
* automate feature * Moved all providers to app level to simplify homepage * Circular dependency fixes * You will see that now toolRegistry gets a tool config and a tool settings object. These enable automate to run the tools using as much static code as possible. --------- Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
parent
7d9c0b0298
commit
23d86deae7
1431
frontend/package-lock.json
generated
1431
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -74,6 +74,7 @@
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"jsdom": "^23.0.0",
|
||||
"license-checker": "^25.0.1",
|
||||
"madge": "^8.0.0",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
|
@ -85,6 +85,7 @@
|
||||
"warning": {
|
||||
"tooltipTitle": "Warning"
|
||||
},
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
@ -538,10 +539,6 @@
|
||||
"title": "Edit Table of Contents",
|
||||
"desc": "Add or edit bookmarks and table of contents in PDF documents"
|
||||
},
|
||||
"automate": {
|
||||
"title": "Automate",
|
||||
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||
},
|
||||
"manageCertificates": {
|
||||
"title": "Manage Certificates",
|
||||
"desc": "Import, export, or delete digital certificate files used for signing PDFs."
|
||||
@ -601,6 +598,10 @@
|
||||
"changePermissions": {
|
||||
"title": "Change Permissions",
|
||||
"desc": "Change document restrictions and permissions"
|
||||
},
|
||||
"automate": {
|
||||
"title": "Automate",
|
||||
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||
}
|
||||
},
|
||||
"viewPdf": {
|
||||
@ -731,7 +732,8 @@
|
||||
"officeDocs": "Office Documents (Word, Excel, PowerPoint)",
|
||||
"imagesExt": "Images (JPG, PNG, etc.)",
|
||||
"markdown": "Markdown",
|
||||
"textRtf": "Text/RTF"
|
||||
"textRtf": "Text/RTF",
|
||||
"grayscale": "Greyscale"
|
||||
},
|
||||
"imageToPdf": {
|
||||
"tags": "conversion,img,jpg,picture,photo"
|
||||
@ -2021,7 +2023,8 @@
|
||||
"downloadSelected": "Download Selected",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"download": "Download",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"unsupported":"Unsupported"
|
||||
},
|
||||
"storage": {
|
||||
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
|
||||
@ -2191,5 +2194,68 @@
|
||||
"results": {
|
||||
"title": "Decrypted PDFs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automate": {
|
||||
"title": "Automate",
|
||||
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks.",
|
||||
"invalidStep": "Invalid step",
|
||||
"files": {
|
||||
"placeholder": "Select files to process with this automation"
|
||||
},
|
||||
"selection": {
|
||||
"title": "Automation Selection",
|
||||
"saved": {
|
||||
"title": "Saved"
|
||||
},
|
||||
"createNew": {
|
||||
"title": "Create New Automation"
|
||||
},
|
||||
"suggested": {
|
||||
"title": "Suggested"
|
||||
}
|
||||
},
|
||||
"creation": {
|
||||
"createTitle": "Create Automation",
|
||||
"editTitle": "Edit Automation",
|
||||
"description": "Automations run tools sequentially. To get started, add tools in the order you want them to run.",
|
||||
"name": {
|
||||
"placeholder": "Automation name"
|
||||
},
|
||||
"tools": {
|
||||
"selectTool": "Select a tool...",
|
||||
"selected": "Selected Tools",
|
||||
"remove": "Remove tool",
|
||||
"configure": "Configure tool",
|
||||
"notConfigured": "! Not Configured",
|
||||
"addTool": "Add Tool",
|
||||
"add": "Add a tool..."
|
||||
},
|
||||
"save": "Save Automation",
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
"message": "You have unsaved changes. Are you sure you want to go back? All changes will be lost.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Go Back"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"title": "Run Automation"
|
||||
},
|
||||
"sequence": {
|
||||
"unnamed": "Unnamed Automation",
|
||||
"steps": "{{count}} steps",
|
||||
"running": "Running Automation...",
|
||||
"run": "Run Automation",
|
||||
"finish": "Finish"
|
||||
},
|
||||
"reviewTitle": "Automation Results",
|
||||
"config": {
|
||||
"loading": "Loading tool configuration...",
|
||||
"noSettings": "This tool does not have configurable settings.",
|
||||
"title": "Configure {{toolName}}",
|
||||
"description": "Configure the settings for this tool. These settings will be applied when the automation runs.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Configuration"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,24 +1,29 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
|
||||
import { FileContextProvider } from './contexts/FileContext';
|
||||
import { NavigationProvider } from './contexts/NavigationContext';
|
||||
import { FilesModalProvider } from './contexts/FilesModalContext';
|
||||
import HomePage from './pages/HomePage';
|
||||
import React, { Suspense } from "react";
|
||||
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
|
||||
import { FileContextProvider } from "./contexts/FileContext";
|
||||
import { NavigationProvider } from "./contexts/NavigationContext";
|
||||
import { FilesModalProvider } from "./contexts/FilesModalContext";
|
||||
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
||||
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import HomePage from "./pages/HomePage";
|
||||
|
||||
// Import global styles
|
||||
import './styles/tailwind.css';
|
||||
import './index.css';
|
||||
import "./styles/tailwind.css";
|
||||
import "./index.css";
|
||||
|
||||
// Loading component for i18next suspense
|
||||
const LoadingFallback = () => (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
fontSize: "18px",
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
@ -27,13 +32,19 @@ export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RainbowThemeProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<HomePage />
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
<ErrorBoundary>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<SidebarProvider>
|
||||
<HomePage />
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</Suspense>
|
||||
);
|
||||
|
@ -111,7 +111,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onClose={closeFilesModal}
|
||||
size={isMobile ? "100%" : "auto"}
|
||||
centered
|
||||
radius={30}
|
||||
radius="md"
|
||||
className="overflow-hidden p-0"
|
||||
withCloseButton={false}
|
||||
styles={{
|
||||
@ -144,7 +144,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
borderRadius: '30px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-file-manager)'
|
||||
}}
|
||||
styles={{
|
||||
|
56
frontend/src/components/shared/ErrorBoundary.tsx
Normal file
56
frontend/src/components/shared/ErrorBoundary.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { Text, Button, Stack } from '@mantine/core';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ComponentType<{error?: Error; retry: () => void}>;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
retry = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
const Fallback = this.props.fallback;
|
||||
return <Fallback error={this.state.error} retry={this.retry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack align="center" justify="center" style={{ minHeight: '200px', padding: '2rem' }}>
|
||||
<Text size="lg" fw={500} c="red">Something went wrong</Text>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<Text size="sm" c="dimmed" style={{ textAlign: 'center', fontFamily: 'monospace' }}>
|
||||
{this.state.error.message}
|
||||
</Text>
|
||||
)}
|
||||
<Button onClick={this.retry} variant="light">
|
||||
Try Again
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
@ -1,13 +1,11 @@
|
||||
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
|
||||
import { Box, Text, Stack } from "@mantine/core";
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSubcategoryLabel, ToolRegistryEntry } from "../../data/toolsTaxonomy";
|
||||
import ToolButton from "./toolPicker/ToolButton";
|
||||
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
|
||||
import "./toolPicker/ToolPicker.css";
|
||||
import { SubcategoryGroup, useToolSections } from "../../hooks/useToolSections";
|
||||
import SubcategoryHeader from "./shared/SubcategoryHeader";
|
||||
import { useToolSections } from "../../hooks/useToolSections";
|
||||
import NoToolsFound from "./shared/NoToolsFound";
|
||||
import { TFunction } from "i18next";
|
||||
import { renderToolButtons } from "./shared/renderToolButtons";
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
@ -16,32 +14,6 @@ interface ToolPickerProps {
|
||||
isSearching?: boolean;
|
||||
}
|
||||
|
||||
// Helper function to render tool buttons for a subcategory
|
||||
const renderToolButtons = (
|
||||
t: TFunction,
|
||||
subcategory: SubcategoryGroup,
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
showSubcategoryHeader: boolean = true
|
||||
) => (
|
||||
<Box key={subcategory.subcategoryId} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
|
||||
)}
|
||||
<Stack gap="xs">
|
||||
{subcategory.tools.map(({ id, tool }: { id: string; tool: any }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
|
||||
|
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* AddWatermarkSingleStepSettings - Used for automation only
|
||||
*
|
||||
* This component combines all watermark settings into a single step interface
|
||||
* for use in the automation system. It includes type selection and all relevant
|
||||
* settings in one unified component.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||
import WatermarkTypeSettings from "./WatermarkTypeSettings";
|
||||
import WatermarkWording from "./WatermarkWording";
|
||||
import WatermarkTextStyle from "./WatermarkTextStyle";
|
||||
import WatermarkImageFile from "./WatermarkImageFile";
|
||||
import WatermarkFormatting from "./WatermarkFormatting";
|
||||
|
||||
interface AddWatermarkSingleStepSettingsProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddWatermarkSingleStepSettings = ({ parameters, onParameterChange, disabled = false }: AddWatermarkSingleStepSettingsProps) => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Watermark Type Selection */}
|
||||
<WatermarkTypeSettings
|
||||
watermarkType={parameters.watermarkType}
|
||||
onWatermarkTypeChange={(type) => onParameterChange("watermarkType", type)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Conditional settings based on watermark type */}
|
||||
{parameters.watermarkType === "text" && (
|
||||
<>
|
||||
<WatermarkWording
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<WatermarkTextStyle
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{parameters.watermarkType === "image" && (
|
||||
<WatermarkImageFile
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Formatting settings for both text and image */}
|
||||
{parameters.watermarkType && (
|
||||
<WatermarkFormatting
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddWatermarkSingleStepSettings;
|
@ -6,7 +6,7 @@ import NumberInputWithUnit from "../shared/NumberInputWithUnit";
|
||||
|
||||
interface WatermarkFormattingProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@ import FileUploadButton from "../../shared/FileUploadButton";
|
||||
|
||||
interface WatermarkImageFileProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ const WatermarkImageFile = ({ parameters, onParameterChange, disabled = false }:
|
||||
<Stack gap="sm">
|
||||
<FileUploadButton
|
||||
file={parameters.watermarkImage}
|
||||
onChange={(file) => onParameterChange('watermarkImage', file)}
|
||||
onChange={(file) => onParameterChange('watermarkImage', file || undefined)}
|
||||
accept="image/*"
|
||||
disabled={disabled}
|
||||
placeholder={t('watermark.settings.image.choose', 'Choose Image')}
|
||||
|
@ -5,7 +5,7 @@ import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAdd
|
||||
|
||||
interface WatermarkStyleSettingsProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
|
||||
<Text size="sm" fw={500}>{t('watermark.settings.rotation', 'Rotation (degrees)')}</Text>
|
||||
<NumberInput
|
||||
value={parameters.rotation}
|
||||
onChange={(value) => onParameterChange('rotation', value || 0)}
|
||||
onChange={(value) => onParameterChange('rotation', typeof value === 'number' ? value : (parseInt(value as string, 10) || 0))}
|
||||
min={-360}
|
||||
max={360}
|
||||
disabled={disabled}
|
||||
@ -28,7 +28,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
|
||||
<Text size="sm" fw={500}>{t('watermark.settings.opacity', 'Opacity (%)')}</Text>
|
||||
<NumberInput
|
||||
value={parameters.opacity}
|
||||
onChange={(value) => onParameterChange('opacity', value || 50)}
|
||||
onChange={(value) => onParameterChange('opacity', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
|
||||
min={0}
|
||||
max={100}
|
||||
disabled={disabled}
|
||||
@ -40,7 +40,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
|
||||
<Text size="sm" fw={500}>{t('watermark.settings.spacing.width', 'Width Spacing')}</Text>
|
||||
<NumberInput
|
||||
value={parameters.widthSpacer}
|
||||
onChange={(value) => onParameterChange('widthSpacer', value || 50)}
|
||||
onChange={(value) => onParameterChange('widthSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
|
||||
min={0}
|
||||
max={200}
|
||||
disabled={disabled}
|
||||
@ -49,7 +49,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
|
||||
<Text size="sm" fw={500}>{t('watermark.settings.spacing.height', 'Height Spacing')}</Text>
|
||||
<NumberInput
|
||||
value={parameters.heightSpacer}
|
||||
onChange={(value) => onParameterChange('heightSpacer', value || 50)}
|
||||
onChange={(value) => onParameterChange('heightSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
|
||||
min={0}
|
||||
max={200}
|
||||
disabled={disabled}
|
||||
|
@ -6,7 +6,7 @@ import { alphabetOptions } from "../../../constants/addWatermarkConstants";
|
||||
|
||||
interface WatermarkTextStyleProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { removeEmojis } from "../../../utils/textUtils";
|
||||
|
||||
interface WatermarkWordingProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
199
frontend/src/components/tools/automate/AutomationCreation.tsx
Normal file
199
frontend/src/components/tools/automate/AutomationCreation.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Text,
|
||||
Stack,
|
||||
Group,
|
||||
TextInput,
|
||||
Divider,
|
||||
Modal
|
||||
} from '@mantine/core';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
import ToolConfigurationModal from './ToolConfigurationModal';
|
||||
import ToolList from './ToolList';
|
||||
import { AutomationConfig, AutomationMode, AutomationTool } from '../../../types/automation';
|
||||
import { useAutomationForm } from '../../../hooks/tools/automate/useAutomationForm';
|
||||
|
||||
|
||||
interface AutomationCreationProps {
|
||||
mode: AutomationMode;
|
||||
existingAutomation?: AutomationConfig;
|
||||
onBack: () => void;
|
||||
onComplete: (automation: AutomationConfig) => void;
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
}
|
||||
|
||||
export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
automationName,
|
||||
setAutomationName,
|
||||
selectedTools,
|
||||
addTool,
|
||||
removeTool,
|
||||
updateTool,
|
||||
hasUnsavedChanges,
|
||||
canSaveAutomation,
|
||||
getToolName,
|
||||
getToolDefaultParameters
|
||||
} = useAutomationForm({ mode, existingAutomation, toolRegistry });
|
||||
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [configuraingToolIndex, setConfiguringToolIndex] = useState(-1);
|
||||
const [unsavedWarningOpen, setUnsavedWarningOpen] = useState(false);
|
||||
|
||||
|
||||
const configureTool = (index: number) => {
|
||||
setConfiguringToolIndex(index);
|
||||
setConfigModalOpen(true);
|
||||
};
|
||||
|
||||
const handleToolConfigSave = (parameters: Record<string, any>) => {
|
||||
if (configuraingToolIndex >= 0) {
|
||||
updateTool(configuraingToolIndex, {
|
||||
configured: true,
|
||||
parameters
|
||||
});
|
||||
}
|
||||
setConfigModalOpen(false);
|
||||
setConfiguringToolIndex(-1);
|
||||
};
|
||||
|
||||
const handleToolConfigCancel = () => {
|
||||
setConfigModalOpen(false);
|
||||
setConfiguringToolIndex(-1);
|
||||
};
|
||||
|
||||
const handleToolAdd = () => {
|
||||
const newTool: AutomationTool = {
|
||||
id: `tool-${Date.now()}`,
|
||||
operation: '',
|
||||
name: t('automate.creation.tools.selectTool', 'Select a tool...'),
|
||||
configured: false,
|
||||
parameters: {}
|
||||
};
|
||||
updateTool(selectedTools.length, newTool);
|
||||
};
|
||||
|
||||
const handleBackClick = () => {
|
||||
if (hasUnsavedChanges()) {
|
||||
setUnsavedWarningOpen(true);
|
||||
} else {
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmBack = () => {
|
||||
setUnsavedWarningOpen(false);
|
||||
onBack();
|
||||
};
|
||||
|
||||
const handleCancelBack = () => {
|
||||
setUnsavedWarningOpen(false);
|
||||
};
|
||||
|
||||
const saveAutomation = async () => {
|
||||
if (!canSaveAutomation()) return;
|
||||
|
||||
const automation = {
|
||||
name: automationName.trim(),
|
||||
description: '',
|
||||
operations: selectedTools.map(tool => ({
|
||||
operation: tool.operation,
|
||||
parameters: tool.parameters || {}
|
||||
}))
|
||||
};
|
||||
|
||||
try {
|
||||
const { automationStorage } = await import('../../../services/automationStorage');
|
||||
const savedAutomation = await automationStorage.saveAutomation(automation);
|
||||
onComplete(savedAutomation);
|
||||
} catch (error) {
|
||||
console.error('Error saving automation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const currentConfigTool = configuraingToolIndex >= 0 ? selectedTools[configuraingToolIndex] : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text size="sm" mb="md" p="md" style={{borderRadius:'var(--mantine-radius-md)', background: 'var(--color-gray-200)', color: 'var(--mantine-color-text)' }}>
|
||||
{t("automate.creation.description", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
|
||||
</Text>
|
||||
<Divider mb="md" />
|
||||
|
||||
<Stack gap="md">
|
||||
{/* Automation Name */}
|
||||
<TextInput
|
||||
placeholder={t('automate.creation.name.placeholder', 'Automation name')}
|
||||
value={automationName}
|
||||
onChange={(e) => setAutomationName(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
|
||||
{/* Selected Tools List */}
|
||||
{selectedTools.length > 0 && (
|
||||
<ToolList
|
||||
tools={selectedTools}
|
||||
toolRegistry={toolRegistry}
|
||||
onToolUpdate={updateTool}
|
||||
onToolRemove={removeTool}
|
||||
onToolConfigure={configureTool}
|
||||
onToolAdd={handleToolAdd}
|
||||
getToolName={getToolName}
|
||||
getToolDefaultParameters={getToolDefaultParameters}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Save Button */}
|
||||
<Button
|
||||
leftSection={<CheckIcon />}
|
||||
onClick={saveAutomation}
|
||||
disabled={!canSaveAutomation()}
|
||||
fullWidth
|
||||
>
|
||||
{t('automate.creation.save', 'Save Automation')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Tool Configuration Modal */}
|
||||
{currentConfigTool && (
|
||||
<ToolConfigurationModal
|
||||
opened={configModalOpen}
|
||||
tool={currentConfigTool}
|
||||
onSave={handleToolConfigSave}
|
||||
onCancel={handleToolConfigCancel}
|
||||
toolRegistry={toolRegistry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unsaved Changes Warning Modal */}
|
||||
<Modal
|
||||
opened={unsavedWarningOpen}
|
||||
onClose={handleCancelBack}
|
||||
title={t('automate.creation.unsavedChanges.title', 'Unsaved Changes')}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t('automate.creation.unsavedChanges.message', 'You have unsaved changes. Are you sure you want to go back? All changes will be lost.')}
|
||||
</Text>
|
||||
<Group gap="md" justify="flex-end">
|
||||
<Button variant="outline" onClick={handleCancelBack}>
|
||||
{t('automate.creation.unsavedChanges.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleConfirmBack}>
|
||||
{t('automate.creation.unsavedChanges.confirm', 'Go Back')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
163
frontend/src/components/tools/automate/AutomationEntry.tsx
Normal file
163
frontend/src/components/tools/automate/AutomationEntry.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
|
||||
interface AutomationEntryProps {
|
||||
/** Optional title for the automation (usually for custom ones) */
|
||||
title?: string;
|
||||
/** MUI Icon component for the badge */
|
||||
badgeIcon?: React.ComponentType<any>;
|
||||
/** Array of tool operation names in the workflow */
|
||||
operations: string[];
|
||||
/** Click handler */
|
||||
onClick: () => void;
|
||||
/** Whether to keep the icon at normal color (for special cases like "Add New") */
|
||||
keepIconColor?: boolean;
|
||||
/** Show menu for saved/suggested automations */
|
||||
showMenu?: boolean;
|
||||
/** Edit handler */
|
||||
onEdit?: () => void;
|
||||
/** Delete handler */
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export default function AutomationEntry({
|
||||
title,
|
||||
badgeIcon: BadgeIcon,
|
||||
operations,
|
||||
onClick,
|
||||
keepIconColor = false,
|
||||
showMenu = false,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: AutomationEntryProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// Keep item in hovered state if menu is open
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
|
||||
const renderContent = () => {
|
||||
if (title) {
|
||||
// Custom automation with title
|
||||
return (
|
||||
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
|
||||
{BadgeIcon && (
|
||||
<BadgeIcon
|
||||
style={{
|
||||
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" style={{ flex: 1, textAlign: 'left', color: 'var(--mantine-color-text)' }}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
} else {
|
||||
// Suggested automation showing tool chain
|
||||
return (
|
||||
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
|
||||
{BadgeIcon && (
|
||||
<BadgeIcon
|
||||
style={{
|
||||
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Group gap="xs" justify="flex-start" style={{ flex: 1 }}>
|
||||
{operations.map((op, index) => (
|
||||
<React.Fragment key={`${op}-${index}`}>
|
||||
<Text size="xs" style={{ color: 'var(--mantine-color-text)' }}>
|
||||
{t(`${op}.title`, op)}
|
||||
</Text>
|
||||
|
||||
{index < operations.length - 1 && (
|
||||
<Text size="xs" c="dimmed" style={{ color: 'var(--mantine-color-text)' }}>
|
||||
→
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
transition: 'background-color 0.15s ease',
|
||||
padding: '0.75rem 1rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Group gap="md" align="center" justify="space-between" style={{ width: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start' }}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{showMenu && (
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
size="md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
opacity: shouldShowHovered ? 1 : 0,
|
||||
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
|
||||
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
||||
pointerEvents: shouldShowHovered ? 'auto' : 'none'
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{onEdit && (
|
||||
<Menu.Item
|
||||
leftSection={<EditIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
{t('edit', 'Edit')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
{t('delete', 'Delete')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
223
frontend/src/components/tools/automate/AutomationRun.tsx
Normal file
223
frontend/src/components/tools/automate/AutomationRun.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import { useFileSelection } from "../../../contexts/FileContext";
|
||||
import { useFlatToolRegistry } from "../../../data/useTranslatedToolRegistry";
|
||||
import { AutomationConfig, ExecutionStep } from "../../../types/automation";
|
||||
import { AUTOMATION_CONSTANTS, EXECUTION_STATUS } from "../../../constants/automation";
|
||||
import { useResourceCleanup } from "../../../utils/resourceManager";
|
||||
|
||||
interface AutomationRunProps {
|
||||
automation: AutomationConfig;
|
||||
onComplete: () => void;
|
||||
automateOperation?: any; // TODO: Type this properly when available
|
||||
}
|
||||
|
||||
export default function AutomationRun({ automation, onComplete, automateOperation }: AutomationRunProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const toolRegistry = useFlatToolRegistry();
|
||||
const cleanup = useResourceCleanup();
|
||||
|
||||
// Progress tracking state
|
||||
const [executionSteps, setExecutionSteps] = useState<ExecutionStep[]>([]);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
||||
|
||||
// Use the operation hook's loading state
|
||||
const isExecuting = automateOperation?.isLoading || false;
|
||||
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
|
||||
|
||||
// Initialize execution steps from automation
|
||||
React.useEffect(() => {
|
||||
if (automation?.operations) {
|
||||
const steps = automation.operations.map((op: any, index: number) => {
|
||||
const tool = toolRegistry[op.operation];
|
||||
return {
|
||||
id: `${op.operation}-${index}`,
|
||||
operation: op.operation,
|
||||
name: tool?.name || op.operation,
|
||||
status: EXECUTION_STATUS.PENDING
|
||||
};
|
||||
});
|
||||
setExecutionSteps(steps);
|
||||
setCurrentStepIndex(-1);
|
||||
}
|
||||
}, [automation, toolRegistry]);
|
||||
|
||||
// Cleanup when component unmounts
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
// Reset progress state when component unmounts
|
||||
setExecutionSteps([]);
|
||||
setCurrentStepIndex(-1);
|
||||
// Clean up any blob URLs
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
const executeAutomation = async () => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!automateOperation) {
|
||||
console.error('No automateOperation provided');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset progress tracking
|
||||
setCurrentStepIndex(0);
|
||||
setExecutionSteps(prev => prev.map(step => ({ ...step, status: EXECUTION_STATUS.PENDING, error: undefined })));
|
||||
|
||||
try {
|
||||
// Use the automateOperation.executeOperation to handle file consumption properly
|
||||
await automateOperation.executeOperation(
|
||||
{
|
||||
automationConfig: automation,
|
||||
onStepStart: (stepIndex: number, operationName: string) => {
|
||||
setCurrentStepIndex(stepIndex);
|
||||
setExecutionSteps(prev => prev.map((step, idx) =>
|
||||
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step
|
||||
));
|
||||
},
|
||||
onStepComplete: (stepIndex: number, resultFiles: File[]) => {
|
||||
setExecutionSteps(prev => prev.map((step, idx) =>
|
||||
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step
|
||||
));
|
||||
},
|
||||
onStepError: (stepIndex: number, error: string) => {
|
||||
setExecutionSteps(prev => prev.map((step, idx) =>
|
||||
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.ERROR, error } : step
|
||||
));
|
||||
}
|
||||
},
|
||||
selectedFiles
|
||||
);
|
||||
|
||||
// Mark all as completed and reset current step
|
||||
setCurrentStepIndex(-1);
|
||||
console.log(`✅ Automation completed successfully`);
|
||||
} catch (error: any) {
|
||||
console.error("Automation execution failed:", error);
|
||||
setCurrentStepIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const getProgress = () => {
|
||||
if (executionSteps.length === 0) return 0;
|
||||
const completedSteps = executionSteps.filter(step => step.status === EXECUTION_STATUS.COMPLETED).length;
|
||||
return (completedSteps / executionSteps.length) * 100;
|
||||
};
|
||||
|
||||
const getStepIcon = (step: ExecutionStep) => {
|
||||
switch (step.status) {
|
||||
case EXECUTION_STATUS.COMPLETED:
|
||||
return <CheckIcon style={{ fontSize: 16, color: 'green' }} />;
|
||||
case EXECUTION_STATUS.ERROR:
|
||||
return <span style={{ fontSize: 16, color: 'red' }}>✕</span>;
|
||||
case EXECUTION_STATUS.RUNNING:
|
||||
return <div style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid #ccc',
|
||||
borderTop: '2px solid #007bff',
|
||||
borderRadius: '50%',
|
||||
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
|
||||
}} />;
|
||||
default:
|
||||
return <div style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid #ccc',
|
||||
borderRadius: '50%'
|
||||
}} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Stack gap="md">
|
||||
{/* Automation Info */}
|
||||
<Card padding="md" withBorder>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{automation?.name || t("automate.sequence.unnamed", "Unnamed Automation")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("automate.sequence.steps", "{{count}} steps", { count: executionSteps.length })}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{isExecuting && (
|
||||
<div>
|
||||
<Text size="sm" mb="xs">
|
||||
Progress: {currentStepIndex + 1}/{executionSteps.length}
|
||||
</Text>
|
||||
<Progress value={getProgress()} size="lg" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution Steps */}
|
||||
<Stack gap="xs">
|
||||
{executionSteps.map((step, index) => (
|
||||
<Group key={step.id} gap="sm" align="center">
|
||||
<Text size="xs" c="dimmed" style={{ minWidth: "1rem", textAlign: "center" }}>
|
||||
{index + 1}
|
||||
</Text>
|
||||
|
||||
{getStepIcon(step)}
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
color: step.status === EXECUTION_STATUS.RUNNING ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)',
|
||||
fontWeight: step.status === EXECUTION_STATUS.RUNNING ? 500 : 400
|
||||
}}
|
||||
>
|
||||
{step.name}
|
||||
</Text>
|
||||
{step.error && (
|
||||
<Text size="xs" c="red" mt="xs">
|
||||
{step.error}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button
|
||||
leftSection={<PlayArrowIcon />}
|
||||
onClick={executeAutomation}
|
||||
disabled={isExecuting || !selectedFiles || selectedFiles.length === 0}
|
||||
loading={isExecuting}
|
||||
>
|
||||
{isExecuting
|
||||
? t("automate.sequence.running", "Running Automation...")
|
||||
: t("automate.sequence.run", "Run Automation")}
|
||||
</Button>
|
||||
|
||||
{hasResults && (
|
||||
<Button variant="light" onClick={onComplete}>
|
||||
{t("automate.sequence.finish", "Finish")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Title, Stack, Divider } from "@mantine/core";
|
||||
import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import AutomationEntry from "./AutomationEntry";
|
||||
import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations";
|
||||
import { AutomationConfig } from "../../../types/automation";
|
||||
|
||||
interface AutomationSelectionProps {
|
||||
savedAutomations: AutomationConfig[];
|
||||
onCreateNew: () => void;
|
||||
onRun: (automation: AutomationConfig) => void;
|
||||
onEdit: (automation: AutomationConfig) => void;
|
||||
onDelete: (automation: AutomationConfig) => void;
|
||||
}
|
||||
|
||||
export default function AutomationSelection({
|
||||
savedAutomations,
|
||||
onCreateNew,
|
||||
onRun,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: AutomationSelectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const suggestedAutomations = useSuggestedAutomations();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title order={3} size="h4" fw={600} mb="md" style={{color: 'var(--mantine-color-dimmed)'}}>
|
||||
{t("automate.selection.saved.title", "Saved")}
|
||||
</Title>
|
||||
|
||||
<Stack gap="xs">
|
||||
<AutomationEntry
|
||||
title={t("automate.selection.createNew.title", "Create New Automation")}
|
||||
badgeIcon={AddCircleOutline}
|
||||
operations={[]}
|
||||
onClick={onCreateNew}
|
||||
keepIconColor={true}
|
||||
/>
|
||||
{/* Saved Automations */}
|
||||
{savedAutomations.map((automation) => (
|
||||
<AutomationEntry
|
||||
key={automation.id}
|
||||
title={automation.name}
|
||||
badgeIcon={SettingsIcon}
|
||||
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
|
||||
onClick={() => onRun(automation)}
|
||||
showMenu={true}
|
||||
onEdit={() => onEdit(automation)}
|
||||
onDelete={() => onDelete(automation)}
|
||||
/>
|
||||
))}
|
||||
<Divider pb='sm' />
|
||||
|
||||
{/* Suggested Automations */}
|
||||
<div>
|
||||
<Title order={3} size="h4" fw={600} mb="md"style={ {color: 'var(--mantine-color-dimmed)'}}>
|
||||
{t("automate.selection.suggested.title", "Suggested")}
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{suggestedAutomations.map((automation) => (
|
||||
<AutomationEntry
|
||||
key={automation.id}
|
||||
badgeIcon={automation.icon}
|
||||
operations={automation.operations.map(op => op.operation)}
|
||||
onClick={() => onRun(automation)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,138 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
Title,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Alert
|
||||
} from '@mantine/core';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import { ToolRegistry } from '../../../data/toolsTaxonomy';
|
||||
import { getAvailableToExtensions } from '../../../utils/convertUtils';
|
||||
interface ToolConfigurationModalProps {
|
||||
opened: boolean;
|
||||
tool: {
|
||||
id: string;
|
||||
operation: string;
|
||||
name: string;
|
||||
parameters?: any;
|
||||
};
|
||||
onSave: (parameters: any) => void;
|
||||
onCancel: () => void;
|
||||
toolRegistry: ToolRegistry;
|
||||
}
|
||||
|
||||
export default function ToolConfigurationModal({ opened, tool, onSave, onCancel, toolRegistry }: ToolConfigurationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [parameters, setParameters] = useState<any>({});
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
|
||||
// Get tool info from registry
|
||||
const toolInfo = toolRegistry[tool.operation];
|
||||
const SettingsComponent = toolInfo?.settingsComponent;
|
||||
|
||||
// Initialize parameters from tool (which should contain defaults from registry)
|
||||
useEffect(() => {
|
||||
if (tool.parameters) {
|
||||
setParameters(tool.parameters);
|
||||
} else {
|
||||
// Fallback to empty parameters if none provided
|
||||
setParameters({});
|
||||
}
|
||||
}, [tool.parameters, tool.operation]);
|
||||
|
||||
// Render the settings component
|
||||
const renderToolSettings = () => {
|
||||
if (!SettingsComponent) {
|
||||
return (
|
||||
<Alert icon={<WarningIcon />} color="orange">
|
||||
<Text size="sm">
|
||||
{t('automate.config.noSettings', 'This tool does not have configurable settings.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Special handling for ConvertSettings which needs additional props
|
||||
if (tool.operation === 'convert') {
|
||||
return (
|
||||
<SettingsComponent
|
||||
parameters={parameters}
|
||||
onParameterChange={(key: string, value: any) => {
|
||||
setParameters((prev: any) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
getAvailableToExtensions={getAvailableToExtensions}
|
||||
selectedFiles={[]}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsComponent
|
||||
parameters={parameters}
|
||||
onParameterChange={(key: string, value: any) => {
|
||||
setParameters((prev: any) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (isValid) {
|
||||
onSave(parameters);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<SettingsIcon />
|
||||
<Title order={3}>
|
||||
{t('automate.config.title', 'Configure {{toolName}}', { toolName: tool.name })}
|
||||
</Title>
|
||||
</Group>
|
||||
}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('automate.config.description', 'Configure the settings for this tool. These settings will be applied when the automation runs.')}
|
||||
</Text>
|
||||
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{renderToolSettings()}
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<CloseIcon />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('automate.config.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<CheckIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={!isValid}
|
||||
>
|
||||
{t('automate.config.save', 'Save Configuration')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
149
frontend/src/components/tools/automate/ToolList.tsx
Normal file
149
frontend/src/components/tools/automate/ToolList.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, Stack, Group, ActionIcon } from '@mantine/core';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import AddCircleOutline from '@mui/icons-material/AddCircleOutline';
|
||||
import { AutomationTool } from '../../../types/automation';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
import ToolSelector from './ToolSelector';
|
||||
import AutomationEntry from './AutomationEntry';
|
||||
|
||||
interface ToolListProps {
|
||||
tools: AutomationTool[];
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
onToolUpdate: (index: number, updates: Partial<AutomationTool>) => void;
|
||||
onToolRemove: (index: number) => void;
|
||||
onToolConfigure: (index: number) => void;
|
||||
onToolAdd: () => void;
|
||||
getToolName: (operation: string) => string;
|
||||
getToolDefaultParameters: (operation: string) => Record<string, any>;
|
||||
}
|
||||
|
||||
export default function ToolList({
|
||||
tools,
|
||||
toolRegistry,
|
||||
onToolUpdate,
|
||||
onToolRemove,
|
||||
onToolConfigure,
|
||||
onToolAdd,
|
||||
getToolName,
|
||||
getToolDefaultParameters
|
||||
}: ToolListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleToolSelect = (index: number, newOperation: string) => {
|
||||
const defaultParams = getToolDefaultParameters(newOperation);
|
||||
|
||||
onToolUpdate(index, {
|
||||
operation: newOperation,
|
||||
name: getToolName(newOperation),
|
||||
configured: false,
|
||||
parameters: defaultParams
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs" style={{ color: 'var(--mantine-color-text)' }}>
|
||||
{t('automate.creation.tools.selected', 'Selected Tools')} ({tools.length})
|
||||
</Text>
|
||||
<Stack gap="0">
|
||||
{tools.map((tool, index) => (
|
||||
<React.Fragment key={tool.id}>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
position: 'relative',
|
||||
padding: 'var(--mantine-spacing-xs)'
|
||||
}}
|
||||
>
|
||||
{/* Delete X in top right */}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => onToolRemove(index)}
|
||||
title={t('automate.creation.tools.remove', 'Remove tool')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
zIndex: 1,
|
||||
color: 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
>
|
||||
<CloseIcon style={{ fontSize: 12 }} />
|
||||
</ActionIcon>
|
||||
|
||||
<div style={{ paddingRight: '1.25rem' }}>
|
||||
{/* Tool Selection Dropdown with inline settings cog */}
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<ToolSelector
|
||||
key={`tool-selector-${tool.id}`}
|
||||
onSelect={(newOperation) => handleToolSelect(index, newOperation)}
|
||||
excludeTools={['automate']}
|
||||
toolRegistry={toolRegistry}
|
||||
selectedValue={tool.operation}
|
||||
placeholder={tool.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings cog - only show if tool is selected, aligned right */}
|
||||
{tool.operation && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => onToolConfigure(index)}
|
||||
title={t('automate.creation.tools.configure', 'Configure tool')}
|
||||
style={{ color: 'var(--mantine-color-gray-6)' }}
|
||||
>
|
||||
<SettingsIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Configuration status underneath */}
|
||||
{tool.operation && !tool.configured && (
|
||||
<Text pl="md" size="xs" c="dimmed" mt="xs">
|
||||
{t('automate.creation.tools.notConfigured', "! Not Configured")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{index < tools.length - 1 && (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Text size="xs" c="dimmed">↓</Text>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Arrow before Add Tool Button */}
|
||||
{tools.length > 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Text size="xs" c="dimmed">↓</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Tool Button */}
|
||||
<div style={{
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<AutomationEntry
|
||||
title={t('automate.creation.tools.addTool', 'Add Tool')}
|
||||
badgeIcon={AddCircleOutline}
|
||||
operations={[]}
|
||||
onClick={onToolAdd}
|
||||
keepIconColor={true}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
182
frontend/src/components/tools/automate/ToolSelector.tsx
Normal file
182
frontend/src/components/tools/automate/ToolSelector.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Menu, Stack, Text, ScrollArea } from '@mantine/core';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
import { useToolSections } from '../../../hooks/useToolSections';
|
||||
import { renderToolButtons } from '../shared/renderToolButtons';
|
||||
import ToolSearch from '../toolPicker/ToolSearch';
|
||||
|
||||
interface ToolSelectorProps {
|
||||
onSelect: (toolKey: string) => void;
|
||||
excludeTools?: string[];
|
||||
toolRegistry: Record<string, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
|
||||
selectedValue?: string; // For showing current selection when editing existing tool
|
||||
placeholder?: string; // Custom placeholder text
|
||||
}
|
||||
|
||||
export default function ToolSelector({
|
||||
onSelect,
|
||||
excludeTools = [],
|
||||
toolRegistry,
|
||||
selectedValue,
|
||||
placeholder
|
||||
}: ToolSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Filter out excluded tools (like 'automate' itself)
|
||||
const baseFilteredTools = useMemo(() => {
|
||||
return Object.entries(toolRegistry).filter(([key]) => !excludeTools.includes(key));
|
||||
}, [toolRegistry, excludeTools]);
|
||||
|
||||
// Apply search filter
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return baseFilteredTools;
|
||||
}
|
||||
|
||||
const lowercaseSearch = searchTerm.toLowerCase();
|
||||
return baseFilteredTools.filter(([key, tool]) => {
|
||||
return (
|
||||
tool.name.toLowerCase().includes(lowercaseSearch) ||
|
||||
tool.description?.toLowerCase().includes(lowercaseSearch) ||
|
||||
key.toLowerCase().includes(lowercaseSearch)
|
||||
);
|
||||
});
|
||||
}, [baseFilteredTools, searchTerm]);
|
||||
|
||||
// Create filtered tool registry for ToolSearch
|
||||
const filteredToolRegistry = useMemo(() => {
|
||||
const registry: Record<string, ToolRegistryEntry> = {};
|
||||
baseFilteredTools.forEach(([key, tool]) => {
|
||||
registry[key] = tool;
|
||||
});
|
||||
return registry;
|
||||
}, [baseFilteredTools]);
|
||||
|
||||
// Use the same tool sections logic as the main ToolPicker
|
||||
const { sections, searchGroups } = useToolSections(filteredTools);
|
||||
|
||||
// Determine what to display: search results or organized sections
|
||||
const isSearching = searchTerm.trim().length > 0;
|
||||
const displayGroups = useMemo(() => {
|
||||
if (isSearching) {
|
||||
return searchGroups || [];
|
||||
}
|
||||
|
||||
if (!sections || sections.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find the "all" section which contains all tools without duplicates
|
||||
const allSection = sections.find(s => (s as any).key === 'all');
|
||||
return allSection?.subcategories || [];
|
||||
}, [isSearching, searchGroups, sections]);
|
||||
|
||||
const handleToolSelect = useCallback((toolKey: string) => {
|
||||
onSelect(toolKey);
|
||||
setOpened(false);
|
||||
setSearchTerm(''); // Clear search to show the selected tool display
|
||||
}, [onSelect]);
|
||||
|
||||
const renderedTools = useMemo(() =>
|
||||
displayGroups.map((subcategory) =>
|
||||
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching)
|
||||
), [displayGroups, handleToolSelect, isSearching, t]
|
||||
);
|
||||
|
||||
const handleSearchFocus = () => {
|
||||
setOpened(true);
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
if (!opened) {
|
||||
setOpened(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Get display value for selected tool
|
||||
const getDisplayValue = () => {
|
||||
if (selectedValue && toolRegistry[selectedValue]) {
|
||||
return toolRegistry[selectedValue].name;
|
||||
}
|
||||
return placeholder || t('automate.creation.tools.add', 'Add a tool...');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={(isOpen) => {
|
||||
setOpened(isOpen);
|
||||
// Clear search term when menu closes to show proper display
|
||||
if (!isOpen) {
|
||||
setSearchTerm('');
|
||||
}
|
||||
}}
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
position="bottom-start"
|
||||
offset={4}
|
||||
withinPortal={false}
|
||||
trapFocus={false}
|
||||
shadow="sm"
|
||||
transitionProps={{ duration: 0 }}
|
||||
>
|
||||
<Menu.Target>
|
||||
<div style={{ width: '100%' }}>
|
||||
{selectedValue && toolRegistry[selectedValue] && !opened ? (
|
||||
// Show selected tool in AutomationEntry style when tool is selected and not searching
|
||||
<div onClick={handleSearchFocus} style={{ cursor: 'pointer' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--mantine-spacing-sm)',
|
||||
padding: '0 0.5rem',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
}}>
|
||||
<div style={{ color: 'var(--mantine-color-text)', fontSize: '1.2rem' }}>
|
||||
{toolRegistry[selectedValue].icon}
|
||||
</div>
|
||||
<Text size="sm" style={{ flex: 1, color: 'var(--mantine-color-text)' }}>
|
||||
{toolRegistry[selectedValue].name}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Show search input when no tool selected or actively searching
|
||||
<ToolSearch
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
toolRegistry={filteredToolRegistry}
|
||||
mode="filter"
|
||||
placeholder={getDisplayValue()}
|
||||
hideIcon={true}
|
||||
onFocus={handleSearchFocus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown p={0} style={{ minWidth: '16rem' }}>
|
||||
<ScrollArea h={350}>
|
||||
<Stack gap="sm" p="sm">
|
||||
{displayGroups.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
||||
{isSearching
|
||||
? t('tools.noSearchResults', 'No tools found')
|
||||
: t('tools.noTools', 'No tools available')
|
||||
}
|
||||
</Text>
|
||||
) : (
|
||||
renderedTools
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
|
||||
|
||||
export interface SuggestedToolsSectionProps {}
|
||||
|
||||
export function SuggestedToolsSection(): React.ReactElement {
|
||||
|
@ -9,6 +9,7 @@ export interface FilesStepConfig {
|
||||
isCollapsed?: boolean;
|
||||
placeholder?: string;
|
||||
onCollapsedClick?: () => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
export interface MiddleStepConfig {
|
||||
@ -63,7 +64,7 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}>
|
||||
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
|
||||
{/* Files Step */}
|
||||
{steps.createFilesStep({
|
||||
{config.files.isVisible !== false && steps.createFilesStep({
|
||||
selectedFiles: config.files.selectedFiles,
|
||||
isCollapsed: config.files.isCollapsed,
|
||||
placeholder: config.files.placeholder,
|
||||
|
34
frontend/src/components/tools/shared/renderToolButtons.tsx
Normal file
34
frontend/src/components/tools/shared/renderToolButtons.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack } from '@mantine/core';
|
||||
import ToolButton from '../toolPicker/ToolButton';
|
||||
import SubcategoryHeader from './SubcategoryHeader';
|
||||
|
||||
import { getSubcategoryLabel } from "../../../data/toolsTaxonomy";
|
||||
import { TFunction } from 'i18next';
|
||||
import { SubcategoryGroup } from '../../../hooks/useToolSections';
|
||||
|
||||
// Helper function to render tool buttons for a subcategory
|
||||
export const renderToolButtons = (
|
||||
t: TFunction,
|
||||
subcategory: SubcategoryGroup,
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
showSubcategoryHeader: boolean = true
|
||||
) => (
|
||||
<Box key={subcategory.subcategoryId} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
|
||||
)}
|
||||
<Stack gap="xs">
|
||||
{subcategory.tools.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
@ -12,19 +12,26 @@ interface ToolSearchProps {
|
||||
onToolSelect?: (toolId: string) => void;
|
||||
mode: 'filter' | 'dropdown';
|
||||
selectedToolKey?: string | null;
|
||||
placeholder?: string;
|
||||
hideIcon?: boolean;
|
||||
onFocus?: () => void;
|
||||
}
|
||||
|
||||
const ToolSearch = ({
|
||||
value,
|
||||
onChange,
|
||||
toolRegistry,
|
||||
onToolSelect,
|
||||
const ToolSearch = ({
|
||||
value,
|
||||
onChange,
|
||||
toolRegistry,
|
||||
onToolSelect,
|
||||
mode = 'filter',
|
||||
selectedToolKey
|
||||
selectedToolKey,
|
||||
placeholder,
|
||||
hideIcon = false,
|
||||
onFocus
|
||||
}: ToolSearchProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!value.trim()) return [];
|
||||
@ -47,7 +54,12 @@ const ToolSearch = ({
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
searchRef.current &&
|
||||
dropdownRef.current &&
|
||||
!searchRef.current.contains(event.target as Node) &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
@ -61,9 +73,10 @@ const ToolSearch = ({
|
||||
ref={searchRef}
|
||||
value={value}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||
icon={<span className="material-symbols-rounded">search</span>}
|
||||
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||
icon={hideIcon ? undefined : <span className="material-symbols-rounded">search</span>}
|
||||
autoComplete="off"
|
||||
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -77,19 +90,19 @@ const ToolSearch = ({
|
||||
{searchInput}
|
||||
{dropdownOpen && filteredTools.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
border: '1px solid var(--border-default)',
|
||||
borderRadius: '8px',
|
||||
marginTop: '4px',
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" style={{ padding: '8px' }}>
|
||||
@ -97,7 +110,10 @@ const ToolSearch = ({
|
||||
<Button
|
||||
key={id}
|
||||
variant="subtle"
|
||||
onClick={() => onToolSelect && onToolSelect(id)}
|
||||
onClick={() => {
|
||||
onToolSelect && onToolSelect(id);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
leftSection={
|
||||
<div style={{ color: 'var(--tools-text-and-icon-color)' }}>
|
||||
{tool.icon}
|
||||
@ -126,4 +142,4 @@ const ToolSearch = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolSearch;
|
||||
export default ToolSearch;
|
||||
|
42
frontend/src/constants/automation.ts
Normal file
42
frontend/src/constants/automation.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Constants for automation functionality
|
||||
*/
|
||||
|
||||
export const AUTOMATION_CONSTANTS = {
|
||||
// Timeouts
|
||||
OPERATION_TIMEOUT: 300000, // 5 minutes in milliseconds
|
||||
|
||||
// Default values
|
||||
DEFAULT_TOOL_COUNT: 2,
|
||||
MIN_TOOL_COUNT: 2,
|
||||
|
||||
// File prefixes
|
||||
FILE_PREFIX: 'automated_',
|
||||
RESPONSE_ZIP_PREFIX: 'response_',
|
||||
RESULT_FILE_PREFIX: 'result_',
|
||||
PROCESSED_FILE_PREFIX: 'processed_',
|
||||
|
||||
// Operation types
|
||||
CONVERT_OPERATION_TYPE: 'convert',
|
||||
|
||||
// Storage keys
|
||||
DB_NAME: 'StirlingPDF_Automations',
|
||||
DB_VERSION: 1,
|
||||
STORE_NAME: 'automations',
|
||||
|
||||
// UI delays
|
||||
SPINNER_ANIMATION_DURATION: '1s'
|
||||
} as const;
|
||||
|
||||
export const AUTOMATION_STEPS = {
|
||||
SELECTION: 'selection',
|
||||
CREATION: 'creation',
|
||||
RUN: 'run'
|
||||
} as const;
|
||||
|
||||
export const EXECUTION_STATUS = {
|
||||
PENDING: 'pending',
|
||||
RUNNING: 'running',
|
||||
COMPLETED: 'completed',
|
||||
ERROR: 'error'
|
||||
} as const;
|
@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useReducer, useCallback } from 'react';
|
||||
import { useNavigationUrlSync } from '../hooks/useUrlSync';
|
||||
import { ModeType, isValidMode, getDefaultMode } from '../types/navigation';
|
||||
|
||||
/**
|
||||
* NavigationContext - Complete navigation management system
|
||||
@ -9,32 +10,13 @@ import { useNavigationUrlSync } from '../hooks/useUrlSync';
|
||||
* maintain clear separation of concerns.
|
||||
*/
|
||||
|
||||
// Navigation mode types - complete list to match fileContext.ts
|
||||
export type ModeType =
|
||||
| 'viewer'
|
||||
| 'pageEditor'
|
||||
| 'fileEditor'
|
||||
| 'merge'
|
||||
| 'split'
|
||||
| 'compress'
|
||||
| 'ocr'
|
||||
| 'convert'
|
||||
| 'sanitize'
|
||||
| 'addPassword'
|
||||
| 'changePermissions'
|
||||
| 'addWatermark'
|
||||
| 'removePassword'
|
||||
| 'single-large-page'
|
||||
| 'repair'
|
||||
| 'unlockPdfForms'
|
||||
| 'removeCertificateSign';
|
||||
|
||||
// Navigation state
|
||||
interface NavigationState {
|
||||
currentMode: ModeType;
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
selectedToolKey: string | null; // Add tool selection to navigation state
|
||||
}
|
||||
|
||||
// Navigation actions
|
||||
@ -42,7 +24,8 @@ type NavigationAction =
|
||||
| { type: 'SET_MODE'; payload: { mode: ModeType } }
|
||||
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
||||
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
|
||||
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
|
||||
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } }
|
||||
| { type: 'SET_SELECTED_TOOL'; payload: { toolKey: string | null } };
|
||||
|
||||
// Navigation reducer
|
||||
const navigationReducer = (state: NavigationState, action: NavigationAction): NavigationState => {
|
||||
@ -59,6 +42,9 @@ const navigationReducer = (state: NavigationState, action: NavigationAction): Na
|
||||
case 'SHOW_NAVIGATION_WARNING':
|
||||
return { ...state, showNavigationWarning: action.payload.show };
|
||||
|
||||
case 'SET_SELECTED_TOOL':
|
||||
return { ...state, selectedToolKey: action.payload.toolKey };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@ -66,10 +52,11 @@ const navigationReducer = (state: NavigationState, action: NavigationAction): Na
|
||||
|
||||
// Initial state
|
||||
const initialState: NavigationState = {
|
||||
currentMode: 'pageEditor',
|
||||
currentMode: getDefaultMode(),
|
||||
hasUnsavedChanges: false,
|
||||
pendingNavigation: null,
|
||||
showNavigationWarning: false
|
||||
showNavigationWarning: false,
|
||||
selectedToolKey: null
|
||||
};
|
||||
|
||||
// Navigation context actions interface
|
||||
@ -80,6 +67,9 @@ export interface NavigationContextActions {
|
||||
requestNavigation: (navigationFn: () => void) => void;
|
||||
confirmNavigation: () => void;
|
||||
cancelNavigation: () => void;
|
||||
selectTool: (toolKey: string) => void;
|
||||
clearToolSelection: () => void;
|
||||
handleToolSelect: (toolId: string) => void;
|
||||
}
|
||||
|
||||
// Split context values
|
||||
@ -88,6 +78,7 @@ export interface NavigationContextStateValue {
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
selectedToolKey: string | null;
|
||||
}
|
||||
|
||||
export interface NavigationContextActionsValue {
|
||||
@ -145,6 +136,31 @@ export const NavigationProvider: React.FC<{
|
||||
// Clear navigation without executing
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||
}, []),
|
||||
|
||||
selectTool: useCallback((toolKey: string) => {
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey } });
|
||||
}, []),
|
||||
|
||||
clearToolSelection: useCallback(() => {
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
|
||||
}, []),
|
||||
|
||||
handleToolSelect: useCallback((toolId: string) => {
|
||||
// Handle special cases
|
||||
if (toolId === 'allTools') {
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Special-case: if tool is a dedicated reader tool, enter reader mode
|
||||
if (toolId === 'read' || toolId === 'view-pdf') {
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: toolId } });
|
||||
dispatch({ type: 'SET_MODE', payload: { mode: 'fileEditor' as ModeType } });
|
||||
}, [])
|
||||
};
|
||||
|
||||
@ -152,7 +168,8 @@ export const NavigationProvider: React.FC<{
|
||||
currentMode: state.currentMode,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges,
|
||||
pendingNavigation: state.pendingNavigation,
|
||||
showNavigationWarning: state.showNavigationWarning
|
||||
showNavigationWarning: state.showNavigationWarning,
|
||||
selectedToolKey: state.selectedToolKey
|
||||
};
|
||||
|
||||
const actionsValue: NavigationContextActionsValue = {
|
||||
@ -212,16 +229,8 @@ export const useNavigationGuard = () => {
|
||||
};
|
||||
};
|
||||
|
||||
// Utility functions for mode handling
|
||||
export const isValidMode = (mode: string): mode is ModeType => {
|
||||
const validModes: ModeType[] = [
|
||||
'viewer', 'pageEditor', 'fileEditor', 'merge', 'split',
|
||||
'compress', 'ocr', 'convert', 'addPassword', 'changePermissions', 'sanitize'
|
||||
];
|
||||
return validModes.includes(mode as ModeType);
|
||||
};
|
||||
|
||||
export const getDefaultMode = (): ModeType => 'pageEditor';
|
||||
// Re-export utility functions from types for backward compatibility
|
||||
export { isValidMode, getDefaultMode, type ModeType } from '../types/navigation';
|
||||
|
||||
// TODO: This will be expanded for URL-based routing system
|
||||
// - URL parsing utilities
|
||||
|
@ -8,6 +8,7 @@ import { useToolManagement } from '../hooks/useToolManagement';
|
||||
import { PageEditorFunctions } from '../types/pageEditor';
|
||||
import { ToolRegistryEntry } from '../data/toolsTaxonomy';
|
||||
import { useToolWorkflowUrlSync } from '../hooks/useUrlSync';
|
||||
import { useNavigationActions, useNavigationState } from './NavigationContext';
|
||||
|
||||
// State interface
|
||||
interface ToolWorkflowState {
|
||||
@ -100,23 +101,23 @@ const ToolWorkflowContext = createContext<ToolWorkflowContextValue | undefined>(
|
||||
// Provider component
|
||||
interface ToolWorkflowProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** Handler for view changes (passed from parent) */
|
||||
onViewChange?: (view: string) => void;
|
||||
/** Enable URL synchronization for tool selection */
|
||||
enableUrlSync?: boolean;
|
||||
}
|
||||
|
||||
export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = true }: ToolWorkflowProviderProps) {
|
||||
export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
|
||||
|
||||
// Navigation actions and state are available since we're inside NavigationProvider
|
||||
const { actions } = useNavigationActions();
|
||||
const navigationState = useNavigationState();
|
||||
|
||||
// Tool management hook
|
||||
const {
|
||||
selectedToolKey,
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
selectTool,
|
||||
clearToolSelection,
|
||||
getSelectedTool,
|
||||
} = useToolManagement();
|
||||
|
||||
// Get selected tool from navigation context
|
||||
const selectedTool = getSelectedTool(navigationState.selectedToolKey);
|
||||
|
||||
// UI Action creators
|
||||
const setSidebarsVisible = useCallback((visible: boolean) => {
|
||||
@ -145,28 +146,30 @@ export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = t
|
||||
|
||||
// Workflow actions (compound actions that coordinate multiple state changes)
|
||||
const handleToolSelect = useCallback((toolId: string) => {
|
||||
// Special-case: if tool is a dedicated reader tool, enter reader mode and do not go to toolContent
|
||||
if (toolId === 'read' || toolId === 'view-pdf') {
|
||||
setReaderMode(true);
|
||||
setLeftPanelView('toolPicker');
|
||||
clearToolSelection();
|
||||
setSearchQuery('');
|
||||
return;
|
||||
}
|
||||
|
||||
selectTool(toolId);
|
||||
onViewChange?.('fileEditor');
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false);
|
||||
// Clear search so the tool content becomes visible immediately
|
||||
actions.handleToolSelect(toolId);
|
||||
|
||||
// Clear search query when selecting a tool
|
||||
setSearchQuery('');
|
||||
}, [selectTool, onViewChange, setLeftPanelView, setReaderMode, setSearchQuery, clearToolSelection]);
|
||||
|
||||
// Handle view switching logic
|
||||
if (toolId === 'allTools' || toolId === 'read' || toolId === 'view-pdf') {
|
||||
setLeftPanelView('toolPicker');
|
||||
if (toolId === 'read' || toolId === 'view-pdf') {
|
||||
setReaderMode(true);
|
||||
} else {
|
||||
setReaderMode(false);
|
||||
}
|
||||
} else {
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false); // Disable read mode when selecting tools
|
||||
}
|
||||
}, [actions, setLeftPanelView, setReaderMode, setSearchQuery]);
|
||||
|
||||
const handleBackToTools = useCallback(() => {
|
||||
setLeftPanelView('toolPicker');
|
||||
setReaderMode(false);
|
||||
clearToolSelection();
|
||||
}, [setLeftPanelView, setReaderMode, clearToolSelection]);
|
||||
actions.clearToolSelection();
|
||||
}, [setLeftPanelView, setReaderMode, actions]);
|
||||
|
||||
const handleReaderToggle = useCallback(() => {
|
||||
setReaderMode(true);
|
||||
@ -186,13 +189,13 @@ export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = t
|
||||
);
|
||||
|
||||
// Enable URL synchronization for tool selection
|
||||
useToolWorkflowUrlSync(selectedToolKey, selectTool, clearToolSelection, enableUrlSync);
|
||||
useToolWorkflowUrlSync(navigationState.selectedToolKey, actions.selectTool, actions.clearToolSelection, true);
|
||||
|
||||
// Simple context value with basic memoization
|
||||
// Properly memoized context value
|
||||
const contextValue = useMemo((): ToolWorkflowContextValue => ({
|
||||
// State
|
||||
...state,
|
||||
selectedToolKey,
|
||||
selectedToolKey: navigationState.selectedToolKey,
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
|
||||
@ -203,8 +206,8 @@ export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = t
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSearchQuery,
|
||||
selectTool,
|
||||
clearToolSelection,
|
||||
selectTool: actions.selectTool,
|
||||
clearToolSelection: actions.clearToolSelection,
|
||||
|
||||
// Workflow Actions
|
||||
handleToolSelect,
|
||||
@ -214,7 +217,25 @@ export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = t
|
||||
// Computed
|
||||
filteredTools,
|
||||
isPanelVisible,
|
||||
}), [state, selectedToolKey, selectedTool, toolRegistry, filteredTools, isPanelVisible]);
|
||||
}), [
|
||||
state,
|
||||
navigationState.selectedToolKey,
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSearchQuery,
|
||||
actions.selectTool,
|
||||
actions.clearToolSelection,
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
handleReaderToggle,
|
||||
filteredTools,
|
||||
isPanelVisible,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ToolWorkflowContext.Provider value={contextValue}>
|
||||
@ -227,6 +248,38 @@ export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = t
|
||||
export function useToolWorkflow(): ToolWorkflowContextValue {
|
||||
const context = useContext(ToolWorkflowContext);
|
||||
if (!context) {
|
||||
// During development hot reload, temporarily return a safe fallback
|
||||
if (false && process.env.NODE_ENV === 'development') {
|
||||
console.warn('ToolWorkflowContext temporarily unavailable during hot reload, using fallback');
|
||||
|
||||
// Return minimal safe fallback to prevent crashes
|
||||
return {
|
||||
sidebarsVisible: true,
|
||||
leftPanelView: 'toolPicker',
|
||||
readerMode: false,
|
||||
previewFile: null,
|
||||
pageEditorFunctions: null,
|
||||
searchQuery: '',
|
||||
selectedToolKey: null,
|
||||
selectedTool: null,
|
||||
toolRegistry: {},
|
||||
filteredTools: [],
|
||||
isPanelVisible: true,
|
||||
setSidebarsVisible: () => {},
|
||||
setLeftPanelView: () => {},
|
||||
setReaderMode: () => {},
|
||||
setPreviewFile: () => {},
|
||||
setPageEditorFunctions: () => {},
|
||||
setSearchQuery: () => {},
|
||||
selectTool: () => {},
|
||||
clearToolSelection: () => {},
|
||||
handleToolSelect: () => {},
|
||||
handleBackToTools: () => {},
|
||||
handleReaderToggle: () => {}
|
||||
} as ToolWorkflowContextValue;
|
||||
}
|
||||
|
||||
console.error('ToolWorkflowContext not found. Current stack:', new Error().stack);
|
||||
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
|
||||
}
|
||||
return context;
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { type TFunction } from 'i18next';
|
||||
import React from 'react';
|
||||
import { ToolOperationHook, ToolOperationConfig } from '../hooks/tools/shared/useToolOperation';
|
||||
import { BaseToolProps } from '../types/tool';
|
||||
import { BaseParameters } from '../types/parameters';
|
||||
|
||||
export enum SubcategoryId {
|
||||
SIGNING = 'signing',
|
||||
@ -23,18 +25,22 @@ export enum ToolCategoryId {
|
||||
}
|
||||
|
||||
export type ToolRegistryEntry = {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
component: React.ComponentType<BaseToolProps> | null;
|
||||
view: 'sign' | 'security' | 'format' | 'extract' | 'view' | 'merge' | 'pageEditor' | 'convert' | 'redact' | 'split' | 'convert' | 'remove' | 'compress' | 'external';
|
||||
description: string;
|
||||
categoryId: ToolCategoryId;
|
||||
subcategoryId: SubcategoryId;
|
||||
maxFiles?: number;
|
||||
supportedFormats?: string[];
|
||||
endpoints?: string[];
|
||||
link?: string;
|
||||
type?: string;
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
component: React.ComponentType<BaseToolProps> | null;
|
||||
view: 'sign' | 'security' | 'format' | 'extract' | 'view' | 'merge' | 'pageEditor' | 'convert' | 'redact' | 'split' | 'convert' | 'remove' | 'compress' | 'external';
|
||||
description: string;
|
||||
categoryId: ToolCategoryId;
|
||||
subcategoryId: SubcategoryId;
|
||||
maxFiles?: number;
|
||||
supportedFormats?: string[];
|
||||
endpoints?: string[];
|
||||
link?: string;
|
||||
type?: string;
|
||||
// Operation configuration for automation
|
||||
operationConfig?: ToolOperationConfig<any>;
|
||||
// Settings component for automation configuration
|
||||
settingsComponent?: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
export type ToolRegistry = Record<string /* FIX ME: Should be ToolId */, ToolRegistryEntry>;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SplitPdfPanel from "../tools/Split";
|
||||
import CompressPdfPanel from "../tools/Compress";
|
||||
@ -13,6 +14,30 @@ import Repair from '../tools/Repair';
|
||||
import SingleLargePage from '../tools/SingleLargePage';
|
||||
import UnlockPdfForms from '../tools/UnlockPdfForms';
|
||||
import RemoveCertificateSign from '../tools/RemoveCertificateSign';
|
||||
import { compressOperationConfig } from '../hooks/tools/compress/useCompressOperation';
|
||||
import { splitOperationConfig } from '../hooks/tools/split/useSplitOperation';
|
||||
import { addPasswordOperationConfig } from '../hooks/tools/addPassword/useAddPasswordOperation';
|
||||
import { removePasswordOperationConfig } from '../hooks/tools/removePassword/useRemovePasswordOperation';
|
||||
import { sanitizeOperationConfig } from '../hooks/tools/sanitize/useSanitizeOperation';
|
||||
import { repairOperationConfig } from '../hooks/tools/repair/useRepairOperation';
|
||||
import { addWatermarkOperationConfig } from '../hooks/tools/addWatermark/useAddWatermarkOperation';
|
||||
import { unlockPdfFormsOperationConfig } from '../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation';
|
||||
import { singleLargePageOperationConfig } from '../hooks/tools/singleLargePage/useSingleLargePageOperation';
|
||||
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 CompressSettings from '../components/tools/compress/CompressSettings';
|
||||
import SplitSettings from '../components/tools/split/SplitSettings';
|
||||
import AddPasswordSettings from '../components/tools/addPassword/AddPasswordSettings';
|
||||
import RemovePasswordSettings from '../components/tools/removePassword/RemovePasswordSettings';
|
||||
import SanitizeSettings from '../components/tools/sanitize/SanitizeSettings';
|
||||
import RepairSettings from '../components/tools/repair/RepairSettings';
|
||||
import UnlockPdfFormsSettings from '../components/tools/unlockPdfForms/UnlockPdfFormsSettings';
|
||||
import AddWatermarkSingleStepSettings from '../components/tools/addWatermark/AddWatermarkSingleStepSettings';
|
||||
import OCRSettings from '../components/tools/ocr/OCRSettings';
|
||||
import ConvertSettings from '../components/tools/convert/ConvertSettings';
|
||||
import ChangePermissionsSettings from '../components/tools/changePermissions/ChangePermissionsSettings';
|
||||
|
||||
const showPlaceholderTools = false; // For development purposes. Allows seeing the full list of tools, even if they're unimplemented
|
||||
|
||||
@ -20,7 +45,8 @@ const showPlaceholderTools = false; // For development purposes. Allows seeing t
|
||||
export function useFlatToolRegistry(): ToolRegistry {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const allTools: ToolRegistry = {
|
||||
return useMemo(() => {
|
||||
const allTools: ToolRegistry = {
|
||||
// Signing
|
||||
|
||||
"certSign": {
|
||||
@ -54,7 +80,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-password"]
|
||||
endpoints: ["add-password"],
|
||||
operationConfig: addPasswordOperationConfig,
|
||||
settingsComponent: AddPasswordSettings
|
||||
},
|
||||
"watermark": {
|
||||
icon: <span className="material-symbols-rounded">branding_watermark</span>,
|
||||
@ -65,7 +93,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.watermark.desc", "Add a custom watermark to your PDF document."),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
endpoints: ["add-watermark"]
|
||||
endpoints: ["add-watermark"],
|
||||
operationConfig: addWatermarkOperationConfig,
|
||||
settingsComponent: AddWatermarkSingleStepSettings
|
||||
},
|
||||
"add-stamp": {
|
||||
icon: <span className="material-symbols-rounded">approval</span>,
|
||||
@ -85,7 +115,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
description: t("home.sanitize.desc", "Remove potentially harmful elements from PDF files"),
|
||||
endpoints: ["sanitize-pdf"]
|
||||
endpoints: ["sanitize-pdf"],
|
||||
operationConfig: sanitizeOperationConfig,
|
||||
settingsComponent: SanitizeSettings
|
||||
},
|
||||
"flatten": {
|
||||
icon: <span className="material-symbols-rounded">layers_clear</span>,
|
||||
@ -105,7 +137,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["unlock-pdf-forms"]
|
||||
endpoints: ["unlock-pdf-forms"],
|
||||
operationConfig: unlockPdfFormsOperationConfig,
|
||||
settingsComponent: UnlockPdfFormsSettings
|
||||
},
|
||||
"manage-certificates": {
|
||||
icon: <span className="material-symbols-rounded">license</span>,
|
||||
@ -125,7 +159,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-password"]
|
||||
endpoints: ["add-password"],
|
||||
operationConfig: changePermissionsOperationConfig,
|
||||
settingsComponent: ChangePermissionsSettings
|
||||
},
|
||||
// Verification
|
||||
|
||||
@ -196,7 +232,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
view: "split",
|
||||
description: t("home.split.desc", "Split PDFs into multiple documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
operationConfig: splitOperationConfig,
|
||||
settingsComponent: SplitSettings
|
||||
},
|
||||
"reorganize-pages": {
|
||||
icon: <span className="material-symbols-rounded">move_down</span>,
|
||||
@ -243,7 +281,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["pdf-to-single-page"]
|
||||
endpoints: ["pdf-to-single-page"],
|
||||
operationConfig: singleLargePageOperationConfig
|
||||
},
|
||||
"add-attachments": {
|
||||
icon: <span className="material-symbols-rounded">attachment</span>,
|
||||
@ -326,7 +365,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
endpoints: ["remove-password"],
|
||||
maxFiles: -1,
|
||||
|
||||
operationConfig: removePasswordOperationConfig,
|
||||
settingsComponent: RemovePasswordSettings
|
||||
},
|
||||
"remove-certificate-sign": {
|
||||
icon: <span className="material-symbols-rounded">remove_moderator</span>,
|
||||
@ -337,7 +377,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"]
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
operationConfig: removeCertificateSignOperationConfig
|
||||
},
|
||||
|
||||
|
||||
@ -346,11 +387,13 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
"automate": {
|
||||
icon: <span className="material-symbols-rounded">automation</span>,
|
||||
name: t("home.automate.title", "Automate"),
|
||||
component: null,
|
||||
component: React.lazy(() => import('../tools/Automate')),
|
||||
view: "format",
|
||||
description: t("home.automate.desc", "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION
|
||||
subcategoryId: SubcategoryId.AUTOMATION,
|
||||
maxFiles: -1,
|
||||
endpoints: ["handleData"]
|
||||
},
|
||||
"auto-rename-pdf-file": {
|
||||
icon: <span className="material-symbols-rounded">match_word</span>,
|
||||
@ -401,7 +444,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["repair"]
|
||||
endpoints: ["repair"],
|
||||
operationConfig: repairOperationConfig,
|
||||
settingsComponent: RepairSettings
|
||||
},
|
||||
"detect-split-scanned-photos": {
|
||||
icon: <span className="material-symbols-rounded">scanner</span>,
|
||||
@ -530,7 +575,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.compress.desc", "Compress PDFs to reduce their file size."),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1
|
||||
maxFiles: -1,
|
||||
operationConfig: compressOperationConfig,
|
||||
settingsComponent: CompressSettings
|
||||
},
|
||||
"convert": {
|
||||
icon: <span className="material-symbols-rounded">sync_alt</span>,
|
||||
@ -574,7 +621,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
"zip",
|
||||
// Other
|
||||
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
|
||||
]
|
||||
],
|
||||
operationConfig: convertOperationConfig,
|
||||
settingsComponent: ConvertSettings
|
||||
},
|
||||
"mergePdfs": {
|
||||
icon: <span className="material-symbols-rounded">library_add</span>,
|
||||
@ -604,7 +653,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.ocr.desc", "Extract text from scanned PDFs using Optical Character Recognition"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1
|
||||
maxFiles: -1,
|
||||
operationConfig: ocrOperationConfig,
|
||||
settingsComponent: OCRSettings
|
||||
},
|
||||
"redact": {
|
||||
icon: <span className="material-symbols-rounded">visibility_off</span>,
|
||||
@ -617,15 +668,16 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
},
|
||||
};
|
||||
|
||||
if (showPlaceholderTools) {
|
||||
return allTools;
|
||||
} else {
|
||||
const filteredTools = Object.keys(allTools)
|
||||
.filter(key => allTools[key].component !== null || allTools[key].link)
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = allTools[key];
|
||||
return obj;
|
||||
}, {} as ToolRegistry);
|
||||
return filteredTools;
|
||||
}
|
||||
if (showPlaceholderTools) {
|
||||
return allTools;
|
||||
} else {
|
||||
const filteredTools = Object.keys(allTools)
|
||||
.filter(key => allTools[key].component !== null || allTools[key].link)
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = allTools[key];
|
||||
return obj;
|
||||
}, {} as ToolRegistry);
|
||||
return filteredTools;
|
||||
}
|
||||
}, [t]); // Only re-compute when translations change
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../sha
|
||||
describe('useAddPasswordOperation', () => {
|
||||
const mockUseToolOperation = vi.mocked(useToolOperation);
|
||||
|
||||
const getToolConfig = (): ToolOperationConfig<AddPasswordFullParameters> => mockUseToolOperation.mock.calls[0][0];
|
||||
const getToolConfig = (): ToolOperationConfig<AddPasswordFullParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<AddPasswordFullParameters>;
|
||||
|
||||
const mockToolOperationReturn: ToolOperationHook<unknown> = {
|
||||
files: [],
|
||||
|
@ -1,30 +1,45 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { AddPasswordFullParameters } from './useAddPasswordParameters';
|
||||
import { AddPasswordFullParameters, defaultParameters } from './useAddPasswordParameters';
|
||||
import { defaultParameters as permissionsDefaults } from '../changePermissions/useChangePermissionsParameters';
|
||||
import { getFormData } from '../changePermissions/useChangePermissionsOperation';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildAddPasswordFormData = (parameters: AddPasswordFullParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("password", parameters.password);
|
||||
formData.append("ownerPassword", parameters.ownerPassword);
|
||||
formData.append("keyLength", parameters.keyLength.toString());
|
||||
getFormData(parameters.permissions).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Full default parameters including permissions for automation
|
||||
const fullDefaultParameters: AddPasswordFullParameters = {
|
||||
...defaultParameters,
|
||||
permissions: permissionsDefaults,
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const addPasswordOperationConfig = {
|
||||
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;
|
||||
|
||||
export const useAddPasswordOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buildFormData = (parameters: AddPasswordFullParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("password", parameters.password);
|
||||
formData.append("ownerPassword", parameters.ownerPassword);
|
||||
formData.append("keyLength", parameters.keyLength.toString());
|
||||
getFormData(parameters.permissions).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
return formData;
|
||||
};
|
||||
|
||||
return useToolOperation<AddPasswordFullParameters>({
|
||||
operationType: 'addPassword',
|
||||
endpoint: '/api/v1/security/add-password',
|
||||
buildFormData,
|
||||
...addPasswordOperationConfig,
|
||||
filePrefix: t('addPassword.filenamePrefix', 'encrypted') + '_',
|
||||
multiFileEndpoint: false,
|
||||
getErrorMessage: createStandardErrorHandler(t('addPassword.error.failed', 'An error occurred while encrypting the PDF.'))
|
||||
});
|
||||
};
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { AddWatermarkParameters } from './useAddWatermarkParameters';
|
||||
import { AddWatermarkParameters, defaultParameters } from './useAddWatermarkParameters';
|
||||
|
||||
const buildFormData = (parameters: AddWatermarkParameters, file: File): FormData => {
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildAddWatermarkFormData = (parameters: AddWatermarkParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
|
||||
@ -32,15 +33,22 @@ const buildFormData = (parameters: AddWatermarkParameters, file: File): FormData
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const addWatermarkOperationConfig = {
|
||||
operationType: 'watermark',
|
||||
endpoint: '/api/v1/security/add-watermark',
|
||||
buildFormData: buildAddWatermarkFormData,
|
||||
filePrefix: 'watermarked_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useAddWatermarkOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<AddWatermarkParameters>({
|
||||
operationType: 'watermark',
|
||||
endpoint: '/api/v1/security/add-watermark',
|
||||
buildFormData,
|
||||
...addWatermarkOperationConfig,
|
||||
filePrefix: t('watermark.filenamePrefix', 'watermarked') + '_',
|
||||
multiFileEndpoint: false, // Individual API calls per file
|
||||
getErrorMessage: createStandardErrorHandler(t('watermark.error.failed', 'An error occurred while adding watermark to the PDF.'))
|
||||
});
|
||||
};
|
@ -46,3 +46,4 @@ export const useAddWatermarkParameters = (): AddWatermarkParametersHook => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
49
frontend/src/hooks/tools/automate/useAutomateOperation.ts
Normal file
49
frontend/src/hooks/tools/automate/useAutomateOperation.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { useCallback } from 'react';
|
||||
import { executeAutomationSequence } from '../../../utils/automationExecutor';
|
||||
import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry';
|
||||
import { AutomateParameters } from '../../../types/automation';
|
||||
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
|
||||
|
||||
export function useAutomateOperation() {
|
||||
const toolRegistry = useFlatToolRegistry();
|
||||
|
||||
const customProcessor = useCallback(async (params: AutomateParameters, files: File[]) => {
|
||||
console.log('🚀 Starting automation execution via customProcessor', { params, files });
|
||||
|
||||
if (!params.automationConfig) {
|
||||
throw new Error('No automation configuration provided');
|
||||
}
|
||||
|
||||
// Execute the automation sequence and return the final results
|
||||
const finalResults = await executeAutomationSequence(
|
||||
params.automationConfig!,
|
||||
files,
|
||||
toolRegistry,
|
||||
(stepIndex: number, operationName: string) => {
|
||||
console.log(`Step ${stepIndex + 1} started: ${operationName}`);
|
||||
params.onStepStart?.(stepIndex, operationName);
|
||||
},
|
||||
(stepIndex: number, resultFiles: File[]) => {
|
||||
console.log(`Step ${stepIndex + 1} completed with ${resultFiles.length} files`);
|
||||
params.onStepComplete?.(stepIndex, resultFiles);
|
||||
},
|
||||
(stepIndex: number, error: string) => {
|
||||
console.error(`Step ${stepIndex + 1} failed:`, error);
|
||||
params.onStepError?.(stepIndex, error);
|
||||
throw new Error(`Automation step ${stepIndex + 1} failed: ${error}`);
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`✅ Automation completed, returning ${finalResults.length} files`);
|
||||
return finalResults;
|
||||
}, [toolRegistry]);
|
||||
|
||||
return useToolOperation<AutomateParameters>({
|
||||
operationType: 'automate',
|
||||
endpoint: '/api/v1/pipeline/handleData', // Not used with customProcessor
|
||||
buildFormData: () => new FormData(), // Not used with customProcessor
|
||||
customProcessor,
|
||||
filePrefix: AUTOMATION_CONSTANTS.FILE_PREFIX
|
||||
});
|
||||
}
|
114
frontend/src/hooks/tools/automate/useAutomationForm.ts
Normal file
114
frontend/src/hooks/tools/automate/useAutomationForm.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation';
|
||||
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
|
||||
interface UseAutomationFormProps {
|
||||
mode: AutomationMode;
|
||||
existingAutomation?: AutomationConfig;
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
}
|
||||
|
||||
export function useAutomationForm({ mode, existingAutomation, toolRegistry }: UseAutomationFormProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [automationName, setAutomationName] = useState('');
|
||||
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
|
||||
|
||||
const getToolName = (operation: string) => {
|
||||
const tool = toolRegistry?.[operation] as any;
|
||||
return tool?.name || t(`tools.${operation}.name`, operation);
|
||||
};
|
||||
|
||||
const getToolDefaultParameters = (operation: string): Record<string, any> => {
|
||||
const config = toolRegistry[operation]?.operationConfig;
|
||||
if (config?.defaultParameters) {
|
||||
return { ...config.defaultParameters };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
// Initialize based on mode and existing automation
|
||||
useEffect(() => {
|
||||
if ((mode === AutomationMode.SUGGESTED || mode === AutomationMode.EDIT) && existingAutomation) {
|
||||
setAutomationName(existingAutomation.name || '');
|
||||
|
||||
const operations = existingAutomation.operations || [];
|
||||
const tools = operations.map((op, index) => {
|
||||
const operation = typeof op === 'string' ? op : op.operation;
|
||||
return {
|
||||
id: `${operation}-${Date.now()}-${index}`,
|
||||
operation: operation,
|
||||
name: getToolName(operation),
|
||||
configured: mode === AutomationMode.EDIT ? true : false,
|
||||
parameters: typeof op === 'object' ? op.parameters || {} : {}
|
||||
};
|
||||
});
|
||||
|
||||
setSelectedTools(tools);
|
||||
} else if (mode === AutomationMode.CREATE && selectedTools.length === 0) {
|
||||
// Initialize with default empty tools for new automation
|
||||
const defaultTools = Array.from({ length: AUTOMATION_CONSTANTS.DEFAULT_TOOL_COUNT }, (_, index) => ({
|
||||
id: `tool-${index + 1}-${Date.now()}`,
|
||||
operation: '',
|
||||
name: t('automate.creation.tools.selectTool', 'Select a tool...'),
|
||||
configured: false,
|
||||
parameters: {}
|
||||
}));
|
||||
setSelectedTools(defaultTools);
|
||||
}
|
||||
}, [mode, existingAutomation, selectedTools.length, t, getToolName]);
|
||||
|
||||
const addTool = (operation: string) => {
|
||||
const newTool: AutomationTool = {
|
||||
id: `${operation}-${Date.now()}`,
|
||||
operation,
|
||||
name: getToolName(operation),
|
||||
configured: false,
|
||||
parameters: getToolDefaultParameters(operation)
|
||||
};
|
||||
|
||||
setSelectedTools([...selectedTools, newTool]);
|
||||
};
|
||||
|
||||
const removeTool = (index: number) => {
|
||||
if (selectedTools.length <= AUTOMATION_CONSTANTS.MIN_TOOL_COUNT) return;
|
||||
setSelectedTools(selectedTools.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateTool = (index: number, updates: Partial<AutomationTool>) => {
|
||||
const updatedTools = [...selectedTools];
|
||||
updatedTools[index] = { ...updatedTools[index], ...updates };
|
||||
setSelectedTools(updatedTools);
|
||||
};
|
||||
|
||||
const hasUnsavedChanges = () => {
|
||||
return (
|
||||
automationName.trim() !== '' ||
|
||||
selectedTools.some(tool => tool.operation !== '' || tool.configured)
|
||||
);
|
||||
};
|
||||
|
||||
const canSaveAutomation = () => {
|
||||
return (
|
||||
automationName.trim() !== '' &&
|
||||
selectedTools.length > 0 &&
|
||||
selectedTools.every(tool => tool.configured && tool.operation !== '')
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
automationName,
|
||||
setAutomationName,
|
||||
selectedTools,
|
||||
setSelectedTools,
|
||||
addTool,
|
||||
removeTool,
|
||||
updateTool,
|
||||
hasUnsavedChanges,
|
||||
canSaveAutomation,
|
||||
getToolName,
|
||||
getToolDefaultParameters
|
||||
};
|
||||
}
|
55
frontend/src/hooks/tools/automate/useSavedAutomations.ts
Normal file
55
frontend/src/hooks/tools/automate/useSavedAutomations.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { AutomationConfig } from '../../../services/automationStorage';
|
||||
|
||||
export interface SavedAutomation extends AutomationConfig {}
|
||||
|
||||
export function useSavedAutomations() {
|
||||
const [savedAutomations, setSavedAutomations] = useState<SavedAutomation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const loadSavedAutomations = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { automationStorage } = await import('../../../services/automationStorage');
|
||||
const automations = await automationStorage.getAllAutomations();
|
||||
setSavedAutomations(automations);
|
||||
} catch (err) {
|
||||
console.error('Error loading saved automations:', err);
|
||||
setError(err as Error);
|
||||
setSavedAutomations([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshAutomations = useCallback(() => {
|
||||
loadSavedAutomations();
|
||||
}, [loadSavedAutomations]);
|
||||
|
||||
const deleteAutomation = useCallback(async (id: string) => {
|
||||
try {
|
||||
const { automationStorage } = await import('../../../services/automationStorage');
|
||||
await automationStorage.deleteAutomation(id);
|
||||
// Refresh the list after deletion
|
||||
refreshAutomations();
|
||||
} catch (err) {
|
||||
console.error('Error deleting automation:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [refreshAutomations]);
|
||||
|
||||
// Load automations on mount
|
||||
useEffect(() => {
|
||||
loadSavedAutomations();
|
||||
}, [loadSavedAutomations]);
|
||||
|
||||
return {
|
||||
savedAutomations,
|
||||
loading,
|
||||
error,
|
||||
refreshAutomations,
|
||||
deleteAutomation
|
||||
};
|
||||
}
|
53
frontend/src/hooks/tools/automate/useSuggestedAutomations.ts
Normal file
53
frontend/src/hooks/tools/automate/useSuggestedAutomations.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import { SuggestedAutomation } from '../../../types/automation';
|
||||
|
||||
export function useSuggestedAutomations(): SuggestedAutomation[] {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const suggestedAutomations = useMemo<SuggestedAutomation[]>(() => {
|
||||
const now = new Date().toISOString();
|
||||
return [
|
||||
{
|
||||
id: "compress-and-merge",
|
||||
name: t("automation.suggested.compressAndMerge", "Compress & Merge"),
|
||||
description: t("automation.suggested.compressAndMergeDesc", "Compress PDFs and merge them into one file"),
|
||||
operations: [
|
||||
{ operation: "compress", parameters: {} },
|
||||
{ operation: "merge", parameters: {} }
|
||||
],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
icon: StarIcon,
|
||||
},
|
||||
{
|
||||
id: "ocr-and-convert",
|
||||
name: t("automation.suggested.ocrAndConvert", "OCR & Convert"),
|
||||
description: t("automation.suggested.ocrAndConvertDesc", "Extract text via OCR and convert to different format"),
|
||||
operations: [
|
||||
{ operation: "ocr", parameters: {} },
|
||||
{ operation: "convert", parameters: {} }
|
||||
],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
icon: StarIcon,
|
||||
},
|
||||
{
|
||||
id: "secure-workflow",
|
||||
name: t("automation.suggested.secureWorkflow", "Secure Workflow"),
|
||||
description: t("automation.suggested.secureWorkflowDesc", "Sanitize, add password, and set permissions"),
|
||||
operations: [
|
||||
{ operation: "sanitize", parameters: {} },
|
||||
{ operation: "addPassword", parameters: {} },
|
||||
{ operation: "changePermissions", parameters: {} }
|
||||
],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
icon: StarIcon,
|
||||
},
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
return suggestedAutomations;
|
||||
}
|
@ -25,7 +25,7 @@ import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../sha
|
||||
describe('useChangePermissionsOperation', () => {
|
||||
const mockUseToolOperation = vi.mocked(useToolOperation);
|
||||
|
||||
const getToolConfig = (): ToolOperationConfig<ChangePermissionsParameters> => mockUseToolOperation.mock.calls[0][0];
|
||||
const getToolConfig = (): ToolOperationConfig<ChangePermissionsParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<ChangePermissionsParameters>;
|
||||
|
||||
const mockToolOperationReturn: ToolOperationHook<unknown> = {
|
||||
files: [],
|
||||
@ -109,7 +109,7 @@ describe('useChangePermissionsOperation', () => {
|
||||
{ property: 'multiFileEndpoint' as const, expectedValue: false },
|
||||
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
|
||||
{ property: 'filePrefix' as const, expectedValue: 'permissions_' },
|
||||
{ property: 'operationType' as const, expectedValue: 'changePermissions' }
|
||||
{ property: 'operationType' as const, expectedValue: 'change-permissions' }
|
||||
])('should configure $property correctly', ({ property, expectedValue }) => {
|
||||
renderHook(() => useChangePermissionsOperation());
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import type { ChangePermissionsParameters } from './useChangePermissionsParameters';
|
||||
import { ChangePermissionsParameters, defaultParameters } from './useChangePermissionsParameters';
|
||||
|
||||
export const getFormData = ((parameters: ChangePermissionsParameters) =>
|
||||
Object.entries(parameters).map(([key, value]) =>
|
||||
@ -9,27 +9,34 @@ export const getFormData = ((parameters: ChangePermissionsParameters) =>
|
||||
) as string[][]
|
||||
);
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildChangePermissionsFormData = (parameters: ChangePermissionsParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
|
||||
// Add all permission parameters
|
||||
getFormData(parameters).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const changePermissionsOperationConfig = {
|
||||
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;
|
||||
|
||||
export const useChangePermissionsOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buildFormData = (parameters: ChangePermissionsParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
|
||||
// Add all permission parameters
|
||||
getFormData(parameters).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
return useToolOperation({
|
||||
operationType: 'changePermissions',
|
||||
endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool
|
||||
buildFormData,
|
||||
filePrefix: 'permissions_',
|
||||
multiFileEndpoint: false,
|
||||
...changePermissionsOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(
|
||||
t('changePermissions.error.failed', 'An error occurred while changing PDF permissions.')
|
||||
)
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { CompressParameters } from './useCompressParameters';
|
||||
import { CompressParameters, defaultParameters } from './useCompressParameters';
|
||||
|
||||
const buildFormData = (parameters: CompressParameters, file: File): FormData => {
|
||||
// Static configuration that can be used by both the hook and automation executor
|
||||
export const buildCompressFormData = (parameters: CompressParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
|
||||
@ -21,15 +22,21 @@ const buildFormData = (parameters: CompressParameters, file: File): FormData =>
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const compressOperationConfig = {
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData: buildCompressFormData,
|
||||
filePrefix: 'compressed_',
|
||||
multiFileEndpoint: false, // Individual API calls per file
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useCompressOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<CompressParameters>({
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData,
|
||||
filePrefix: 'compressed_',
|
||||
multiFileEndpoint: false, // Individual API calls per file
|
||||
...compressOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('compress.error.failed', 'An error occurred while compressing the PDF.'))
|
||||
});
|
||||
};
|
||||
|
@ -10,7 +10,7 @@ export interface CompressParameters extends BaseParameters {
|
||||
fileSizeUnit: 'KB' | 'MB';
|
||||
}
|
||||
|
||||
const defaultParameters: CompressParameters = {
|
||||
export const defaultParameters: CompressParameters = {
|
||||
compressionLevel: 5,
|
||||
grayscale: false,
|
||||
expectedSize: '',
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters } from './useConvertParameters';
|
||||
import { ConvertParameters, defaultParameters } from './useConvertParameters';
|
||||
import { detectFileExtension } from '../../../utils/fileUtils';
|
||||
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
||||
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||
|
||||
const shouldProcessFilesSeparately = (
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const shouldProcessFilesSeparately = (
|
||||
selectedFiles: File[],
|
||||
parameters: ConvertParameters
|
||||
): boolean => {
|
||||
@ -29,7 +30,8 @@ const shouldProcessFilesSeparately = (
|
||||
);
|
||||
};
|
||||
|
||||
const buildFormData = (parameters: ConvertParameters, selectedFiles: File[]): FormData => {
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildConvertFormData = (parameters: ConvertParameters, selectedFiles: File[]): FormData => {
|
||||
const formData = new FormData();
|
||||
|
||||
selectedFiles.forEach(file => {
|
||||
@ -69,7 +71,8 @@ const buildFormData = (parameters: ConvertParameters, selectedFiles: File[]): Fo
|
||||
return formData;
|
||||
};
|
||||
|
||||
const createFileFromResponse = (
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const createFileFromResponse = (
|
||||
responseData: any,
|
||||
headers: any,
|
||||
originalFileName: string,
|
||||
@ -81,6 +84,59 @@ const createFileFromResponse = (
|
||||
return createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||
};
|
||||
|
||||
// Static processor that can be used by both the hook and automation executor
|
||||
export const convertProcessor = async (
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
): Promise<File[]> => {
|
||||
const processedFiles: File[] = [];
|
||||
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error('Unsupported conversion format');
|
||||
}
|
||||
|
||||
// Convert-specific routing logic: decide batch vs individual processing
|
||||
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
|
||||
// Individual processing for complex cases (PDF→image, smart detection, etc.)
|
||||
for (const file of selectedFiles) {
|
||||
try {
|
||||
const formData = buildConvertFormData(parameters, [file]);
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension);
|
||||
|
||||
processedFiles.push(convertedFile);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to convert file ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Batch processing for simple cases (image→PDF combine)
|
||||
const formData = buildConvertFormData(parameters, selectedFiles);
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
const baseFilename = selectedFiles.length === 1
|
||||
? selectedFiles[0].name
|
||||
: 'converted_files';
|
||||
|
||||
const convertedFile = createFileFromResponse(response.data, response.headers, baseFilename, parameters.toExtension);
|
||||
processedFiles.push(convertedFile);
|
||||
}
|
||||
|
||||
return processedFiles;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const convertOperationConfig = {
|
||||
operationType: 'convert',
|
||||
endpoint: '', // Not used with customProcessor but required
|
||||
buildFormData: buildConvertFormData, // Not used with customProcessor but required
|
||||
filePrefix: 'converted_',
|
||||
customProcessor: convertProcessor,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useConvertOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -88,52 +144,12 @@ export const useConvertOperation = () => {
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
): Promise<File[]> => {
|
||||
|
||||
const processedFiles: File[] = [];
|
||||
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error(t('errorNotSupported', 'Unsupported conversion format'));
|
||||
}
|
||||
|
||||
// Convert-specific routing logic: decide batch vs individual processing
|
||||
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
|
||||
// Individual processing for complex cases (PDF→image, smart detection, etc.)
|
||||
for (const file of selectedFiles) {
|
||||
try {
|
||||
const formData = buildFormData(parameters, [file]);
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension);
|
||||
|
||||
processedFiles.push(convertedFile);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to convert file ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Batch processing for simple cases (image→PDF combine)
|
||||
const formData = buildFormData(parameters, selectedFiles);
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
const baseFilename = selectedFiles.length === 1
|
||||
? selectedFiles[0].name
|
||||
: 'converted_files';
|
||||
|
||||
const convertedFile = createFileFromResponse(response.data, response.headers, baseFilename, parameters.toExtension);
|
||||
processedFiles.push(convertedFile);
|
||||
|
||||
}
|
||||
|
||||
return processedFiles;
|
||||
}, [t]);
|
||||
return convertProcessor(parameters, selectedFiles);
|
||||
}, []);
|
||||
|
||||
return useToolOperation<ConvertParameters>({
|
||||
operationType: 'convert',
|
||||
endpoint: '', // Not used with customProcessor but required
|
||||
buildFormData, // Not used with customProcessor but required
|
||||
filePrefix: 'converted_',
|
||||
customProcessor: customConvertProcessor, // Convert handles its own routing
|
||||
...convertOperationConfig,
|
||||
customProcessor: customConvertProcessor, // Use instance-specific processor for translation support
|
||||
getErrorMessage: (error) => {
|
||||
if (error.response?.data && typeof error.response.data === 'string') {
|
||||
return error.response.data;
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
type OutputOption,
|
||||
type FitOption
|
||||
} from '../../../constants/convertConstants';
|
||||
import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||
import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat, isWebFormat, getAvailableToExtensions as getAvailableToExtensionsUtil } from '../../../utils/convertUtils';
|
||||
import { detectFileExtension as detectFileExtensionUtil } from '../../../utils/fileUtils';
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
@ -47,7 +47,7 @@ export interface ConvertParametersHook extends BaseParametersHook<ConvertParamet
|
||||
analyzeFileTypes: (files: Array<{name: string}>) => void;
|
||||
}
|
||||
|
||||
const defaultParameters: ConvertParameters = {
|
||||
export const defaultParameters: ConvertParameters = {
|
||||
fromExtension: '',
|
||||
toExtension: '',
|
||||
imageOptions: {
|
||||
@ -155,30 +155,7 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
||||
return getEndpointUrl(fromExtension, toExtension);
|
||||
};
|
||||
|
||||
const getAvailableToExtensions = (fromExtension: string) => {
|
||||
if (!fromExtension) return [];
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use 'any' conversion options (file-to-pdf)
|
||||
const supportedExtensions = CONVERSION_MATRIX['any'] || [];
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
supportedExtensions.includes(option.value)
|
||||
);
|
||||
}
|
||||
|
||||
let supportedExtensions = CONVERSION_MATRIX[fromExtension] || [];
|
||||
|
||||
// If no explicit conversion exists, but file-to-pdf might be available,
|
||||
// fall back to 'any' conversion (which converts unknown files to PDF via file-to-pdf)
|
||||
if (supportedExtensions.length === 0 && fromExtension !== 'any') {
|
||||
supportedExtensions = CONVERSION_MATRIX['any'] || [];
|
||||
}
|
||||
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
supportedExtensions.includes(option.value)
|
||||
);
|
||||
};
|
||||
const getAvailableToExtensions = getAvailableToExtensionsUtil;
|
||||
|
||||
|
||||
const analyzeFileTypes = useCallback((files: Array<{name: string}>) => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OCRParameters } from './useOCRParameters';
|
||||
import { OCRParameters, defaultParameters } from './useOCRParameters';
|
||||
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { useToolResources } from '../shared/useToolResources';
|
||||
@ -37,7 +37,8 @@ function stripExt(name: string): string {
|
||||
return i > 0 ? name.slice(0, i) : name;
|
||||
}
|
||||
|
||||
const buildFormData = (parameters: OCRParameters, file: File): FormData => {
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildOCRFormData = (parameters: OCRParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
parameters.languages.forEach((lang) => formData.append('languages', lang));
|
||||
@ -51,57 +52,70 @@ const buildFormData = (parameters: OCRParameters, file: File): FormData => {
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static response handler for OCR - can be used by automation executor
|
||||
export const ocrResponseHandler = async (blob: Blob, originalFiles: File[], extractZipFiles: (blob: Blob) => Promise<File[]>): Promise<File[]> => {
|
||||
const headBuf = await blob.slice(0, 8).arrayBuffer();
|
||||
const head = new TextDecoder().decode(new Uint8Array(headBuf));
|
||||
|
||||
// ZIP: sidecar or multi-asset output
|
||||
if (head.startsWith('PK')) {
|
||||
const base = stripExt(originalFiles[0].name);
|
||||
try {
|
||||
const extractedFiles = await extractZipFiles(blob);
|
||||
if (extractedFiles.length > 0) return extractedFiles;
|
||||
} catch { /* ignore and try local extractor */ }
|
||||
try {
|
||||
const local = await extractZipFile(blob); // local fallback
|
||||
if (local.length > 0) return local;
|
||||
} catch { /* fall through */ }
|
||||
return [new File([blob], `ocr_${base}.zip`, { type: 'application/zip' })];
|
||||
}
|
||||
|
||||
// Not a PDF: surface error details if present
|
||||
if (!head.startsWith('%PDF')) {
|
||||
const textBuf = await blob.slice(0, 1024).arrayBuffer();
|
||||
const text = new TextDecoder().decode(new Uint8Array(textBuf));
|
||||
if (/error|exception|html/i.test(text)) {
|
||||
if (text.includes('OCR tools') && text.includes('not installed')) {
|
||||
throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.');
|
||||
}
|
||||
const title =
|
||||
text.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1] ||
|
||||
text.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1] ||
|
||||
'Unknown error';
|
||||
throw new Error(`OCR service error: ${title}`);
|
||||
}
|
||||
throw new Error(`Response is not a valid PDF. Header: "${head}"`);
|
||||
}
|
||||
|
||||
const base = stripExt(originalFiles[0].name);
|
||||
return [new File([blob], `ocr_${base}.pdf`, { type: 'application/pdf' })];
|
||||
};
|
||||
|
||||
// Static configuration object (without t function dependencies)
|
||||
export const ocrOperationConfig = {
|
||||
operationType: 'ocr',
|
||||
endpoint: '/api/v1/misc/ocr-pdf',
|
||||
buildFormData: buildOCRFormData,
|
||||
filePrefix: 'ocr_',
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useOCROperation = () => {
|
||||
const { t } = useTranslation();
|
||||
const { extractZipFiles } = useToolResources();
|
||||
|
||||
// OCR-specific parsing: ZIP (sidecar) vs PDF vs HTML error
|
||||
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
|
||||
const headBuf = await blob.slice(0, 8).arrayBuffer();
|
||||
const head = new TextDecoder().decode(new Uint8Array(headBuf));
|
||||
|
||||
// ZIP: sidecar or multi-asset output
|
||||
if (head.startsWith('PK')) {
|
||||
const base = stripExt(originalFiles[0].name);
|
||||
try {
|
||||
const extracted = await extractZipFiles(blob);
|
||||
if (extracted.length > 0) return extracted;
|
||||
} catch { /* ignore and try local extractor */ }
|
||||
try {
|
||||
const local = await extractZipFile(blob); // local fallback
|
||||
if (local.length > 0) return local;
|
||||
} catch { /* fall through */ }
|
||||
return [new File([blob], `ocr_${base}.zip`, { type: 'application/zip' })];
|
||||
}
|
||||
|
||||
// Not a PDF: surface error details if present
|
||||
if (!head.startsWith('%PDF')) {
|
||||
const textBuf = await blob.slice(0, 1024).arrayBuffer();
|
||||
const text = new TextDecoder().decode(new Uint8Array(textBuf));
|
||||
if (/error|exception|html/i.test(text)) {
|
||||
if (text.includes('OCR tools') && text.includes('not installed')) {
|
||||
throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.');
|
||||
}
|
||||
const title =
|
||||
text.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1] ||
|
||||
text.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1] ||
|
||||
t('ocr.error.unknown', 'Unknown error');
|
||||
throw new Error(`OCR service error: ${title}`);
|
||||
}
|
||||
throw new Error(`Response is not a valid PDF. Header: "${head}"`);
|
||||
}
|
||||
|
||||
const base = stripExt(originalFiles[0].name);
|
||||
return [new File([blob], `ocr_${base}.pdf`, { type: 'application/pdf' })];
|
||||
}, [t, extractZipFiles]);
|
||||
// extractZipFiles from useToolResources already returns File[] directly
|
||||
const simpleExtractZipFiles = extractZipFiles;
|
||||
return ocrResponseHandler(blob, originalFiles, simpleExtractZipFiles);
|
||||
}, [extractZipFiles]);
|
||||
|
||||
const ocrConfig: ToolOperationConfig<OCRParameters> = {
|
||||
operationType: 'ocr',
|
||||
endpoint: '/api/v1/misc/ocr-pdf',
|
||||
buildFormData,
|
||||
filePrefix: 'ocr_',
|
||||
multiFileEndpoint: false, // Process files individually
|
||||
responseHandler, // use shared flow
|
||||
...ocrOperationConfig,
|
||||
responseHandler,
|
||||
getErrorMessage: (error) =>
|
||||
error.message?.includes('OCR tools') && error.message?.includes('not installed')
|
||||
? 'OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.'
|
||||
|
@ -10,7 +10,7 @@ export interface OCRParameters extends BaseParameters {
|
||||
|
||||
export type OCRParametersHook = BaseParametersHook<OCRParameters>;
|
||||
|
||||
const defaultParameters: OCRParameters = {
|
||||
export const defaultParameters: OCRParameters = {
|
||||
languages: [],
|
||||
ocrType: 'skip-text',
|
||||
ocrRenderType: 'hocr',
|
||||
|
@ -1,23 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RemoveCertificateSignParameters } from './useRemoveCertificateSignParameters';
|
||||
import { RemoveCertificateSignParameters, defaultParameters } from './useRemoveCertificateSignParameters';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildRemoveCertificateSignFormData = (parameters: RemoveCertificateSignParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const removeCertificateSignOperationConfig = {
|
||||
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;
|
||||
|
||||
export const useRemoveCertificateSignOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buildFormData = (parameters: RemoveCertificateSignParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
return useToolOperation<RemoveCertificateSignParameters>({
|
||||
operationType: 'removeCertificateSign',
|
||||
endpoint: '/api/v1/security/remove-cert-sign',
|
||||
buildFormData,
|
||||
...removeCertificateSignOperationConfig,
|
||||
filePrefix: t('removeCertSign.filenamePrefix', 'unsigned') + '_',
|
||||
multiFileEndpoint: false,
|
||||
getErrorMessage: createStandardErrorHandler(t('removeCertSign.error.failed', 'An error occurred while removing certificate signatures.'))
|
||||
});
|
||||
};
|
@ -25,7 +25,7 @@ import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../sha
|
||||
describe('useRemovePasswordOperation', () => {
|
||||
const mockUseToolOperation = vi.mocked(useToolOperation);
|
||||
|
||||
const getToolConfig = (): ToolOperationConfig<RemovePasswordParameters> => mockUseToolOperation.mock.calls[0][0];
|
||||
const getToolConfig = (): ToolOperationConfig<RemovePasswordParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<RemovePasswordParameters>;
|
||||
|
||||
const mockToolOperationReturn: ToolOperationHook<unknown> = {
|
||||
files: [],
|
||||
|
@ -1,24 +1,32 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RemovePasswordParameters } from './useRemovePasswordParameters';
|
||||
import { RemovePasswordParameters, defaultParameters } from './useRemovePasswordParameters';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("password", parameters.password);
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const removePasswordOperationConfig = {
|
||||
operationType: 'removePassword',
|
||||
endpoint: '/api/v1/security/remove-password',
|
||||
buildFormData: buildRemovePasswordFormData,
|
||||
filePrefix: 'decrypted_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useRemovePasswordOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buildFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("password", parameters.password);
|
||||
return formData;
|
||||
};
|
||||
|
||||
return useToolOperation<RemovePasswordParameters>({
|
||||
operationType: 'removePassword',
|
||||
endpoint: '/api/v1/security/remove-password',
|
||||
buildFormData,
|
||||
...removePasswordOperationConfig,
|
||||
filePrefix: t('removePassword.filenamePrefix', 'decrypted') + '_',
|
||||
multiFileEndpoint: false,
|
||||
getErrorMessage: createStandardErrorHandler(t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.'))
|
||||
});
|
||||
};
|
||||
|
@ -1,23 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RepairParameters } from './useRepairParameters';
|
||||
import { RepairParameters, defaultParameters } from './useRepairParameters';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildRepairFormData = (parameters: RepairParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const repairOperationConfig = {
|
||||
operationType: 'repair',
|
||||
endpoint: '/api/v1/misc/repair',
|
||||
buildFormData: buildRepairFormData,
|
||||
filePrefix: 'repaired_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useRepairOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buildFormData = (parameters: RepairParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
return useToolOperation<RepairParameters>({
|
||||
operationType: 'repair',
|
||||
endpoint: '/api/v1/misc/repair',
|
||||
buildFormData,
|
||||
...repairOperationConfig,
|
||||
filePrefix: t('repair.filenamePrefix', 'repaired') + '_',
|
||||
multiFileEndpoint: false,
|
||||
getErrorMessage: createStandardErrorHandler(t('repair.error.failed', 'An error occurred while repairing the PDF.'))
|
||||
});
|
||||
};
|
@ -1,9 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { SanitizeParameters } from './useSanitizeParameters';
|
||||
import { SanitizeParameters, defaultParameters } from './useSanitizeParameters';
|
||||
|
||||
const buildFormData = (parameters: SanitizeParameters, file: File): FormData => {
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildSanitizeFormData = (parameters: SanitizeParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
|
||||
@ -18,15 +19,22 @@ const buildFormData = (parameters: SanitizeParameters, file: File): FormData =>
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const sanitizeOperationConfig = {
|
||||
operationType: 'sanitize',
|
||||
endpoint: '/api/v1/security/sanitize-pdf',
|
||||
buildFormData: buildSanitizeFormData,
|
||||
filePrefix: 'sanitized_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useSanitizeOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<SanitizeParameters>({
|
||||
operationType: 'sanitize',
|
||||
endpoint: '/api/v1/security/sanitize-pdf',
|
||||
buildFormData,
|
||||
...sanitizeOperationConfig,
|
||||
filePrefix: t('sanitize.filenamePrefix', 'sanitized') + '_',
|
||||
multiFileEndpoint: false, // Individual API calls per file
|
||||
getErrorMessage: createStandardErrorHandler(t('sanitize.error.failed', 'An error occurred while sanitising the PDF.'))
|
||||
});
|
||||
};
|
||||
|
@ -61,6 +61,9 @@ export interface ToolOperationConfig<TParams = void> {
|
||||
|
||||
/** Extract user-friendly error messages from API errors */
|
||||
getErrorMessage?: (error: any) => string;
|
||||
|
||||
/** Default parameter values for automation */
|
||||
defaultParameters?: TParams;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,23 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { SingleLargePageParameters } from './useSingleLargePageParameters';
|
||||
import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildSingleLargePageFormData = (parameters: SingleLargePageParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const singleLargePageOperationConfig = {
|
||||
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;
|
||||
|
||||
export const useSingleLargePageOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buildFormData = (parameters: SingleLargePageParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
return useToolOperation<SingleLargePageParameters>({
|
||||
operationType: 'singleLargePage',
|
||||
endpoint: '/api/v1/general/pdf-to-single-page',
|
||||
buildFormData,
|
||||
...singleLargePageOperationConfig,
|
||||
filePrefix: t('pdfToSinglePage.filenamePrefix', 'single_page') + '_',
|
||||
multiFileEndpoint: false,
|
||||
getErrorMessage: createStandardErrorHandler(t('pdfToSinglePage.error.failed', 'An error occurred while converting to single page.'))
|
||||
});
|
||||
};
|
@ -1,11 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { SplitParameters } from './useSplitParameters';
|
||||
import { SplitParameters, defaultParameters } from './useSplitParameters';
|
||||
import { SPLIT_MODES } from '../../../constants/splitConstants';
|
||||
|
||||
|
||||
const buildFormData = (parameters: SplitParameters, selectedFiles: File[]): FormData => {
|
||||
// Static functions that can be used by both the hook and automation executor
|
||||
export const buildSplitFormData = (parameters: SplitParameters, selectedFiles: File[]): FormData => {
|
||||
const formData = new FormData();
|
||||
|
||||
selectedFiles.forEach(file => {
|
||||
@ -40,7 +40,7 @@ const buildFormData = (parameters: SplitParameters, selectedFiles: File[]): Form
|
||||
return formData;
|
||||
};
|
||||
|
||||
const getEndpoint = (parameters: SplitParameters): string => {
|
||||
export const getSplitEndpoint = (parameters: SplitParameters): string => {
|
||||
switch (parameters.mode) {
|
||||
case SPLIT_MODES.BY_PAGES:
|
||||
return "/api/v1/general/split-pages";
|
||||
@ -55,15 +55,21 @@ const getEndpoint = (parameters: SplitParameters): string => {
|
||||
}
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const splitOperationConfig = {
|
||||
operationType: 'splitPdf',
|
||||
endpoint: getSplitEndpoint,
|
||||
buildFormData: buildSplitFormData,
|
||||
filePrefix: 'split_',
|
||||
multiFileEndpoint: true, // Single API call with all files
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useSplitOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<SplitParameters>({
|
||||
operationType: 'split',
|
||||
endpoint: (params) => getEndpoint(params),
|
||||
buildFormData: buildFormData, // Multi-file signature: (params, selectedFiles) => FormData
|
||||
filePrefix: 'split_',
|
||||
multiFileEndpoint: true, // Single API call with all files
|
||||
...splitOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('split.error.failed', 'An error occurred while splitting the PDF.'))
|
||||
});
|
||||
};
|
||||
|
@ -17,7 +17,7 @@ export interface SplitParameters extends BaseParameters {
|
||||
|
||||
export type SplitParametersHook = BaseParametersHook<SplitParameters>;
|
||||
|
||||
const defaultParameters: SplitParameters = {
|
||||
export const defaultParameters: SplitParameters = {
|
||||
mode: '',
|
||||
pages: '',
|
||||
hDiv: '2',
|
||||
|
@ -1,23 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { UnlockPdfFormsParameters } from './useUnlockPdfFormsParameters';
|
||||
import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildUnlockPdfFormsFormData = (parameters: UnlockPdfFormsParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const unlockPdfFormsOperationConfig = {
|
||||
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;
|
||||
|
||||
export const useUnlockPdfFormsOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buildFormData = (parameters: UnlockPdfFormsParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
return useToolOperation<UnlockPdfFormsParameters>({
|
||||
operationType: 'unlockPdfForms',
|
||||
endpoint: '/api/v1/misc/unlock-pdf-forms',
|
||||
buildFormData,
|
||||
...unlockPdfFormsOperationConfig,
|
||||
filePrefix: t('unlockPDFForms.filenamePrefix', 'unlocked_forms') + '_',
|
||||
multiFileEndpoint: false,
|
||||
getErrorMessage: createStandardErrorHandler(t('unlockPDFForms.error.failed', 'An error occurred while unlocking PDF forms.'))
|
||||
});
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
|
||||
import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext';
|
||||
|
||||
// Material UI Icons
|
||||
import CompressIcon from '@mui/icons-material/Compress';
|
||||
@ -44,7 +44,8 @@ const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
|
||||
];
|
||||
|
||||
export function useSuggestedTools(): SuggestedTool[] {
|
||||
const { handleToolSelect, selectedToolKey } = useToolWorkflow();
|
||||
const { actions } = useNavigationActions();
|
||||
const { selectedToolKey } = useNavigationState();
|
||||
|
||||
return useMemo(() => {
|
||||
// Filter out the current tool
|
||||
@ -53,7 +54,7 @@ export function useSuggestedTools(): SuggestedTool[] {
|
||||
// Add navigation function to each tool
|
||||
return filteredTools.map(tool => ({
|
||||
...tool,
|
||||
navigate: () => handleToolSelect(tool.id)
|
||||
navigate: () => actions.handleToolSelect(tool.id)
|
||||
}));
|
||||
}, [selectedToolKey, handleToolSelect]);
|
||||
}, [selectedToolKey, actions]);
|
||||
}
|
||||
|
@ -5,19 +5,16 @@ import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
|
||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||
|
||||
interface ToolManagementResult {
|
||||
selectedToolKey: string | null;
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolSelectedFileIds: string[];
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
selectTool: (toolKey: string) => void;
|
||||
clearToolSelection: () => void;
|
||||
setToolSelectedFileIds: (fileIds: string[]) => void;
|
||||
getSelectedTool: (toolKey: string | null) => ToolRegistryEntry | null;
|
||||
}
|
||||
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedToolKey, setSelectedToolKey] = useState<string /* FIX ME: Should be ToolId */ | null>(null);
|
||||
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
// Build endpoints list from registry entries with fallback to legacy mapping
|
||||
@ -56,35 +53,15 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
return availableToolRegistry;
|
||||
}, [isToolAvailable, t, baseRegistry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpointsLoading && selectedToolKey && !toolRegistry[selectedToolKey]) {
|
||||
const firstAvailableTool = Object.keys(toolRegistry)[0];
|
||||
if (firstAvailableTool) {
|
||||
setSelectedToolKey(firstAvailableTool);
|
||||
} else {
|
||||
setSelectedToolKey(null);
|
||||
}
|
||||
}
|
||||
}, [endpointsLoading, selectedToolKey, toolRegistry]);
|
||||
|
||||
const selectTool = useCallback((toolKey: string) => {
|
||||
setSelectedToolKey(toolKey);
|
||||
}, []);
|
||||
|
||||
const clearToolSelection = useCallback(() => {
|
||||
setSelectedToolKey(null);
|
||||
}, []);
|
||||
|
||||
const selectedTool = selectedToolKey ? toolRegistry[selectedToolKey] : null;
|
||||
const getSelectedTool = useCallback((toolKey: string | null): ToolRegistryEntry | null => {
|
||||
return toolKey ? toolRegistry[toolKey] || null : null;
|
||||
}, [toolRegistry]);
|
||||
|
||||
return {
|
||||
selectedToolKey,
|
||||
selectedTool,
|
||||
selectedTool: getSelectedTool(null), // This will be unused, kept for compatibility
|
||||
toolSelectedFileIds,
|
||||
toolRegistry,
|
||||
selectTool,
|
||||
clearToolSelection,
|
||||
setToolSelectedFileIds,
|
||||
|
||||
getSelectedTool,
|
||||
};
|
||||
};
|
||||
|
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { ModeType } from '../contexts/NavigationContext';
|
||||
import { ModeType } from '../types/navigation';
|
||||
import { parseToolRoute, updateToolRoute, clearToolRoute } from '../utils/urlRouting';
|
||||
|
||||
/**
|
||||
|
@ -1,10 +1,8 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileActions, useFileSelection } from "../contexts/FileContext";
|
||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||
import { ToolWorkflowProvider, useToolWorkflow } from "../contexts/ToolWorkflowContext";
|
||||
import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
|
||||
import { Group } from "@mantine/core";
|
||||
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
|
||||
import { useSidebarContext } from "../contexts/SidebarContext";
|
||||
import { useDocumentMeta } from "../hooks/useDocumentMeta";
|
||||
import { getBaseUrl } from "../constants/app";
|
||||
|
||||
@ -14,7 +12,7 @@ import QuickAccessBar from "../components/shared/QuickAccessBar";
|
||||
import FileManager from "../components/FileManager";
|
||||
|
||||
|
||||
function HomePageContent() {
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
sidebarRefs,
|
||||
@ -22,8 +20,6 @@ function HomePageContent() {
|
||||
|
||||
const { quickAccessRef } = sidebarRefs;
|
||||
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
const { selectedTool, selectedToolKey } = useToolWorkflow();
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
@ -54,24 +50,3 @@ function HomePageContent() {
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function HomePageWithProviders() {
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
// Wrapper to convert string to ModeType
|
||||
const handleViewChange = (view: string) => {
|
||||
actions.setMode(view as any); // ToolWorkflowContext should validate this
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolWorkflowProvider onViewChange={handleViewChange}>
|
||||
<SidebarProvider>
|
||||
<HomePageContent />
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
return <HomePageWithProviders />;
|
||||
}
|
||||
|
183
frontend/src/services/automationStorage.ts
Normal file
183
frontend/src/services/automationStorage.ts
Normal file
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Service for managing automation configurations in IndexedDB
|
||||
*/
|
||||
|
||||
export interface AutomationConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
operations: Array<{
|
||||
operation: string;
|
||||
parameters: any;
|
||||
}>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
class AutomationStorage {
|
||||
private dbName = 'StirlingPDF_Automations';
|
||||
private dbVersion = 1;
|
||||
private storeName = 'automations';
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to open automation storage database'));
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
|
||||
store.createIndex('name', 'name', { unique: false });
|
||||
store.createIndex('createdAt', 'createdAt', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async ensureDB(): Promise<IDBDatabase> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
return this.db;
|
||||
}
|
||||
|
||||
async saveAutomation(automation: Omit<AutomationConfig, 'id' | 'createdAt' | 'updatedAt'>): Promise<AutomationConfig> {
|
||||
const db = await this.ensureDB();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const automationWithMeta: AutomationConfig = {
|
||||
id: `automation-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
...automation,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.add(automationWithMeta);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(automationWithMeta);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to save automation'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async updateAutomation(automation: AutomationConfig): Promise<AutomationConfig> {
|
||||
const db = await this.ensureDB();
|
||||
|
||||
const updatedAutomation: AutomationConfig = {
|
||||
...automation,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.put(updatedAutomation);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(updatedAutomation);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to update automation'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getAutomation(id: string): Promise<AutomationConfig | null> {
|
||||
const db = await this.ensureDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result || null);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to get automation'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getAllAutomations(): Promise<AutomationConfig[]> {
|
||||
const db = await this.ensureDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const automations = request.result || [];
|
||||
// Sort by creation date, newest first
|
||||
automations.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
resolve(automations);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to get automations'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAutomation(id: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to delete automation'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async searchAutomations(query: string): Promise<AutomationConfig[]> {
|
||||
const automations = await this.getAllAutomations();
|
||||
|
||||
if (!query.trim()) {
|
||||
return automations;
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return automations.filter(automation =>
|
||||
automation.name.toLowerCase().includes(lowerQuery) ||
|
||||
(automation.description && automation.description.toLowerCase().includes(lowerQuery)) ||
|
||||
automation.operations.some(op => op.operation.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const automationStorage = new AutomationStorage();
|
@ -409,7 +409,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
// Verify integration: utils validation prevents API call, hook shows error
|
||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||
expect(result.current.errorMessage).toContain('errorNotSupported');
|
||||
expect(result.current.errorMessage).toContain('Unsupported conversion format');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.downloadUrl).toBe(null);
|
||||
});
|
||||
|
@ -9,11 +9,11 @@ import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
||||
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||
|
||||
import { useAddPasswordParameters } from "../hooks/tools/addPassword/useAddPasswordParameters";
|
||||
import { useAddPasswordParameters, defaultParameters } from "../hooks/tools/addPassword/useAddPasswordParameters";
|
||||
import { useAddPasswordOperation } from "../hooks/tools/addPassword/useAddPasswordOperation";
|
||||
import { useAddPasswordTips } from "../components/tooltips/useAddPasswordTips";
|
||||
import { useAddPasswordPermissionsTips } from "../components/tooltips/useAddPasswordPermissionsTips";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@ -114,4 +114,4 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default AddPassword;
|
||||
export default AddPassword as ToolComponent;
|
||||
|
@ -21,7 +21,7 @@ import {
|
||||
useWatermarkFileTips,
|
||||
useWatermarkFormattingTips,
|
||||
} from "../components/tooltips/useWatermarkTips";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@ -208,4 +208,7 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
|
||||
});
|
||||
};
|
||||
|
||||
export default AddWatermark;
|
||||
// Static method to get the operation hook for automation
|
||||
AddWatermark.tool = () => useAddWatermarkOperation;
|
||||
|
||||
export default AddWatermark as ToolComponent;
|
||||
|
168
frontend/src/tools/Automate.tsx
Normal file
168
frontend/src/tools/Automate.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { useFileSelection } from "../contexts/FileContext";
|
||||
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import { createFilesToolStep } from "../components/tools/shared/FilesToolStep";
|
||||
import AutomationSelection from "../components/tools/automate/AutomationSelection";
|
||||
import AutomationCreation from "../components/tools/automate/AutomationCreation";
|
||||
import AutomationRun from "../components/tools/automate/AutomationRun";
|
||||
|
||||
import { useAutomateOperation } from "../hooks/tools/automate/useAutomateOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
|
||||
import { useSavedAutomations } from "../hooks/tools/automate/useSavedAutomations";
|
||||
import { AutomationConfig, AutomationStepData, AutomationMode } from "../types/automation";
|
||||
import { AUTOMATION_STEPS } from "../constants/automation";
|
||||
|
||||
const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<'selection' | 'creation' | 'run'>(AUTOMATION_STEPS.SELECTION);
|
||||
const [stepData, setStepData] = useState<AutomationStepData>({ step: AUTOMATION_STEPS.SELECTION });
|
||||
|
||||
const automateOperation = useAutomateOperation();
|
||||
const toolRegistry = useFlatToolRegistry();
|
||||
const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null;
|
||||
const { savedAutomations, deleteAutomation, refreshAutomations } = useSavedAutomations();
|
||||
|
||||
const handleStepChange = (data: AutomationStepData) => {
|
||||
// If navigating away from run step, reset automation results
|
||||
if (currentStep === AUTOMATION_STEPS.RUN && data.step !== AUTOMATION_STEPS.RUN) {
|
||||
automateOperation.resetResults();
|
||||
}
|
||||
|
||||
// If navigating to run step with a different automation, reset results
|
||||
if (data.step === AUTOMATION_STEPS.RUN && data.automation &&
|
||||
stepData.automation && data.automation.id !== stepData.automation.id) {
|
||||
automateOperation.resetResults();
|
||||
}
|
||||
|
||||
setStepData(data);
|
||||
setCurrentStep(data.step);
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
// Reset automation results when completing
|
||||
automateOperation.resetResults();
|
||||
|
||||
// Reset to selection step
|
||||
setCurrentStep(AUTOMATION_STEPS.SELECTION);
|
||||
setStepData({ step: AUTOMATION_STEPS.SELECTION });
|
||||
onComplete?.([]); // Pass empty array since automation creation doesn't produce files
|
||||
};
|
||||
|
||||
const renderCurrentStep = () => {
|
||||
switch (currentStep) {
|
||||
case 'selection':
|
||||
return (
|
||||
<AutomationSelection
|
||||
savedAutomations={savedAutomations}
|
||||
onCreateNew={() => handleStepChange({ step: AUTOMATION_STEPS.CREATION, mode: AutomationMode.CREATE })}
|
||||
onRun={(automation: AutomationConfig) => handleStepChange({ step: AUTOMATION_STEPS.RUN, automation })}
|
||||
onEdit={(automation: AutomationConfig) => handleStepChange({ step: AUTOMATION_STEPS.CREATION, mode: AutomationMode.EDIT, automation })}
|
||||
onDelete={async (automation: AutomationConfig) => {
|
||||
try {
|
||||
await deleteAutomation(automation.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete automation:', error);
|
||||
onError?.(`Failed to delete automation: ${automation.name}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'creation':
|
||||
if (!stepData.mode) {
|
||||
console.error('Creation mode is undefined');
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AutomationCreation
|
||||
mode={stepData.mode}
|
||||
existingAutomation={stepData.automation}
|
||||
onBack={() => handleStepChange({ step: AUTOMATION_STEPS.SELECTION })}
|
||||
onComplete={() => {
|
||||
refreshAutomations();
|
||||
handleStepChange({ step: AUTOMATION_STEPS.SELECTION });
|
||||
}}
|
||||
toolRegistry={toolRegistry}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'run':
|
||||
if (!stepData.automation) {
|
||||
console.error('Automation config is undefined');
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AutomationRun
|
||||
automation={stepData.automation}
|
||||
onComplete={handleComplete}
|
||||
automateOperation={automateOperation}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>{t('automate.invalidStep', 'Invalid step')}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
const createStep = (title: string, props: any, content?: React.ReactNode) => ({
|
||||
title,
|
||||
...props,
|
||||
content
|
||||
});
|
||||
|
||||
// Always create files step to avoid conditional hook calls
|
||||
const filesStep = createFilesToolStep(createStep, {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
placeholder: t('automate.files.placeholder', 'Select files to process with this automation')
|
||||
});
|
||||
|
||||
const automationSteps = [
|
||||
createStep(t('automate.selection.title', 'Automation Selection'), {
|
||||
isVisible: true,
|
||||
isCollapsed: currentStep !== AUTOMATION_STEPS.SELECTION,
|
||||
onCollapsedClick: () => setCurrentStep(AUTOMATION_STEPS.SELECTION)
|
||||
}, currentStep === AUTOMATION_STEPS.SELECTION ? renderCurrentStep() : null),
|
||||
|
||||
createStep(stepData.mode === AutomationMode.EDIT
|
||||
? t('automate.creation.editTitle', 'Edit Automation')
|
||||
: t('automate.creation.createTitle', 'Create Automation'), {
|
||||
isVisible: currentStep === AUTOMATION_STEPS.CREATION,
|
||||
isCollapsed: false
|
||||
}, currentStep === AUTOMATION_STEPS.CREATION ? renderCurrentStep() : null),
|
||||
|
||||
// Files step - only visible during run mode
|
||||
{
|
||||
...filesStep,
|
||||
isVisible: currentStep === AUTOMATION_STEPS.RUN
|
||||
},
|
||||
|
||||
// Run step
|
||||
createStep(t('automate.run.title', 'Run Automation'), {
|
||||
isVisible: currentStep === AUTOMATION_STEPS.RUN,
|
||||
isCollapsed: hasResults,
|
||||
}, currentStep === AUTOMATION_STEPS.RUN ? renderCurrentStep() : null)
|
||||
];
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: currentStep === AUTOMATION_STEPS.RUN ? selectedFiles : [],
|
||||
isCollapsed: currentStep !== AUTOMATION_STEPS.RUN || hasResults,
|
||||
isVisible: false, // Hide the default files step since we add our own
|
||||
},
|
||||
steps: automationSteps,
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: automateOperation,
|
||||
title: t('automate.reviewTitle', 'Automation Results')
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default Automate;
|
@ -11,7 +11,7 @@ import ChangePermissionsSettings from "../components/tools/changePermissions/Cha
|
||||
import { useChangePermissionsParameters } from "../hooks/tools/changePermissions/useChangePermissionsParameters";
|
||||
import { useChangePermissionsOperation } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||
import { useChangePermissionsTips } from "../components/tooltips/useChangePermissionsTips";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@ -95,4 +95,7 @@ const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps
|
||||
});
|
||||
};
|
||||
|
||||
export default ChangePermissions;
|
||||
// Static method to get the operation hook for automation
|
||||
ChangePermissions.tool = () => useChangePermissionsOperation;
|
||||
|
||||
export default ChangePermissions as ToolComponent;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { use, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileSelection } from "../contexts/FileContext";
|
||||
@ -10,7 +10,7 @@ import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||
|
||||
import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters";
|
||||
import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useCompressTips } from "../components/tooltips/useCompressTips";
|
||||
|
||||
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
@ -94,4 +94,5 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default Compress;
|
||||
|
||||
export default Compress as ToolComponent;
|
||||
|
@ -10,7 +10,7 @@ import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
||||
|
||||
import { useConvertParameters } from "../hooks/tools/convert/useConvertParameters";
|
||||
import { useConvertOperation } from "../hooks/tools/convert/useConvertOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@ -133,4 +133,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default Convert;
|
||||
// Static method to get the operation hook for automation
|
||||
Convert.tool = () => useConvertOperation;
|
||||
|
||||
export default Convert as ToolComponent;
|
||||
|
@ -11,7 +11,7 @@ import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings";
|
||||
|
||||
import { useOCRParameters } from "../hooks/tools/ocr/useOCRParameters";
|
||||
import { useOCROperation } from "../hooks/tools/ocr/useOCROperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useOCRTips } from "../components/tooltips/useOCRTips";
|
||||
|
||||
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
@ -134,4 +134,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default OCR;
|
||||
// Static method to get the operation hook for automation
|
||||
OCR.tool = () => useOCROperation;
|
||||
|
||||
export default OCR as ToolComponent;
|
||||
|
@ -9,7 +9,7 @@ import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
|
||||
import { useRemoveCertificateSignParameters } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters";
|
||||
import { useRemoveCertificateSignOperation } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@ -77,4 +77,7 @@ const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolP
|
||||
});
|
||||
};
|
||||
|
||||
export default RemoveCertificateSign;
|
||||
// Static method to get the operation hook for automation
|
||||
RemoveCertificateSign.tool = () => useRemoveCertificateSignOperation;
|
||||
|
||||
export default RemoveCertificateSign as ToolComponent;
|
@ -11,7 +11,7 @@ import RemovePasswordSettings from "../components/tools/removePassword/RemovePas
|
||||
import { useRemovePasswordParameters } from "../hooks/tools/removePassword/useRemovePasswordParameters";
|
||||
import { useRemovePasswordOperation } from "../hooks/tools/removePassword/useRemovePasswordOperation";
|
||||
import { useRemovePasswordTips } from "../components/tooltips/useRemovePasswordTips";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@ -94,4 +94,7 @@ const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
||||
});
|
||||
};
|
||||
|
||||
export default RemovePassword;
|
||||
// Static method to get the operation hook for automation
|
||||
RemovePassword.tool = () => useRemovePasswordOperation;
|
||||
|
||||
export default RemovePassword as ToolComponent;
|
||||
|
@ -9,7 +9,7 @@ import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
|
||||
import { useRepairParameters } from "../hooks/tools/repair/useRepairParameters";
|
||||
import { useRepairOperation } from "../hooks/tools/repair/useRepairOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@ -77,4 +77,7 @@ const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default Repair;
|
||||
// Static method to get the operation hook for automation
|
||||
Repair.tool = () => useRepairOperation;
|
||||
|
||||
export default Repair as ToolComponent;
|
||||
|
@ -2,20 +2,18 @@ import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileSelection } from "../contexts/FileContext";
|
||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
|
||||
|
||||
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
|
||||
import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
const sanitizeParams = useSanitizeParameters();
|
||||
const sanitizeOperation = useSanitizeOperation();
|
||||
@ -91,4 +89,7 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default Sanitize;
|
||||
// Static method to get the operation hook for automation
|
||||
Sanitize.tool = () => useSanitizeOperation;
|
||||
|
||||
export default Sanitize as ToolComponent;
|
||||
|
@ -9,7 +9,7 @@ import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
|
||||
import { useSingleLargePageParameters } from "../hooks/tools/singleLargePage/useSingleLargePageParameters";
|
||||
import { useSingleLargePageOperation } from "../hooks/tools/singleLargePage/useSingleLargePageOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@ -77,4 +77,7 @@ const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps)
|
||||
});
|
||||
};
|
||||
|
||||
export default SingleLargePage;
|
||||
// Static method to get the operation hook for automation
|
||||
SingleLargePage.tool = () => useSingleLargePageOperation;
|
||||
|
||||
export default SingleLargePage as ToolComponent;
|
@ -9,7 +9,7 @@ import SplitSettings from "../components/tools/split/SplitSettings";
|
||||
|
||||
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
|
||||
import { useSplitOperation } from "../hooks/tools/split/useSplitOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@ -90,4 +90,4 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default Split;
|
||||
export default Split as ToolComponent;
|
||||
|
@ -9,7 +9,7 @@ import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
|
||||
import { useUnlockPdfFormsParameters } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters";
|
||||
import { useUnlockPdfFormsOperation } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@ -77,4 +77,7 @@ const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
||||
});
|
||||
};
|
||||
|
||||
export default UnlockPdfForms;
|
||||
// Static method to get the operation hook for automation
|
||||
UnlockPdfForms.tool = () => useUnlockPdfFormsOperation;
|
||||
|
||||
export default UnlockPdfForms as ToolComponent;
|
70
frontend/src/types/automation.ts
Normal file
70
frontend/src/types/automation.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Types for automation functionality
|
||||
*/
|
||||
|
||||
export interface AutomationOperation {
|
||||
operation: string;
|
||||
parameters: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AutomationConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
operations: AutomationOperation[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AutomationTool {
|
||||
id: string;
|
||||
operation: string;
|
||||
name: string;
|
||||
configured: boolean;
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AutomationStepData {
|
||||
step: 'selection' | 'creation' | 'run';
|
||||
mode?: AutomationMode;
|
||||
automation?: AutomationConfig;
|
||||
}
|
||||
|
||||
export interface ExecutionStep {
|
||||
id: string;
|
||||
operation: string;
|
||||
name: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AutomationExecutionCallbacks {
|
||||
onStepStart?: (stepIndex: number, operationName: string) => void;
|
||||
onStepComplete?: (stepIndex: number, resultFiles: File[]) => void;
|
||||
onStepError?: (stepIndex: number, error: string) => void;
|
||||
}
|
||||
|
||||
export interface AutomateParameters extends AutomationExecutionCallbacks {
|
||||
automationConfig?: AutomationConfig;
|
||||
}
|
||||
|
||||
export enum AutomationMode {
|
||||
CREATE = 'create',
|
||||
EDIT = 'edit',
|
||||
SUGGESTED = 'suggested'
|
||||
}
|
||||
|
||||
export interface SuggestedAutomation {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
operations: AutomationOperation[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
icon: any; // MUI Icon component
|
||||
}
|
||||
|
||||
// Export the AutomateParameters interface that was previously defined inline
|
||||
export interface AutomateParameters extends AutomationExecutionCallbacks {
|
||||
automationConfig?: AutomationConfig;
|
||||
}
|
42
frontend/src/types/navigation.ts
Normal file
42
frontend/src/types/navigation.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Shared navigation types to avoid circular dependencies
|
||||
*/
|
||||
|
||||
// Navigation mode types - complete list to match contexts
|
||||
export type ModeType =
|
||||
| 'viewer'
|
||||
| 'pageEditor'
|
||||
| 'fileEditor'
|
||||
| 'merge'
|
||||
| 'split'
|
||||
| 'compress'
|
||||
| 'ocr'
|
||||
| 'convert'
|
||||
| 'sanitize'
|
||||
| 'addPassword'
|
||||
| 'changePermissions'
|
||||
| 'addWatermark'
|
||||
| 'removePassword'
|
||||
| 'single-large-page'
|
||||
| 'repair'
|
||||
| 'unlockPdfForms'
|
||||
| 'removeCertificateSign';
|
||||
|
||||
// Utility functions for mode handling
|
||||
export const isValidMode = (mode: string): mode is ModeType => {
|
||||
const validModes: ModeType[] = [
|
||||
'viewer', 'pageEditor', 'fileEditor', 'merge', 'split',
|
||||
'compress', 'ocr', 'convert', 'addPassword', 'changePermissions',
|
||||
'sanitize', 'addWatermark', 'removePassword', 'single-large-page',
|
||||
'repair', 'unlockPdfForms', 'removeCertificateSign'
|
||||
];
|
||||
return validModes.includes(mode as ModeType);
|
||||
};
|
||||
|
||||
export const getDefaultMode = (): ModeType => 'pageEditor';
|
||||
|
||||
// Route parsing result
|
||||
export interface ToolRoute {
|
||||
mode: ModeType;
|
||||
toolKey: string | null;
|
||||
}
|
21
frontend/src/types/navigationActions.ts
Normal file
21
frontend/src/types/navigationActions.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Navigation action interfaces to break circular dependencies
|
||||
*/
|
||||
|
||||
import { ModeType } from './navigation';
|
||||
|
||||
export interface NavigationActions {
|
||||
setMode: (mode: ModeType) => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
showNavigationWarning: (show: boolean) => void;
|
||||
requestNavigation: (navigationFn: () => void) => void;
|
||||
confirmNavigation: () => void;
|
||||
cancelNavigation: () => void;
|
||||
}
|
||||
|
||||
export interface NavigationState {
|
||||
currentMode: ModeType;
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { ToolOperationHook } from '../hooks/tools/shared/useToolOperation';
|
||||
|
||||
export type MaxFiles = number; // 1=single, >1=limited, -1=unlimited
|
||||
export type ToolCategory = 'manipulation' | 'conversion' | 'analysis' | 'utility' | 'optimization' | 'security';
|
||||
@ -11,6 +12,29 @@ export interface BaseToolProps {
|
||||
onPreviewFile?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for tool components that support automation.
|
||||
* Tools implementing this interface can be used in automation workflows.
|
||||
*/
|
||||
export interface AutomationCapableTool {
|
||||
/**
|
||||
* Static method that returns the operation hook for this tool.
|
||||
* This enables automation to execute the tool programmatically.
|
||||
*/
|
||||
tool: () => () => ToolOperationHook<any>;
|
||||
|
||||
/**
|
||||
* Static method that returns the default parameters for this tool.
|
||||
* This enables automation creation to initialize tools with proper defaults.
|
||||
*/
|
||||
getDefaultParameters: () => any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for tool components that can be used in automation
|
||||
*/
|
||||
export type ToolComponent = React.ComponentType<BaseToolProps> & AutomationCapableTool;
|
||||
|
||||
export interface ToolStepConfig {
|
||||
type: ToolStepType;
|
||||
title: string;
|
||||
|
157
frontend/src/utils/automationExecutor.ts
Normal file
157
frontend/src/utils/automationExecutor.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import axios from 'axios';
|
||||
import { ToolRegistry } from '../data/toolsTaxonomy';
|
||||
import { AutomationConfig, AutomationExecutionCallbacks } from '../types/automation';
|
||||
import { AUTOMATION_CONSTANTS } from '../constants/automation';
|
||||
import { AutomationFileProcessor } from './automationFileProcessor';
|
||||
import { ResourceManager } from './resourceManager';
|
||||
|
||||
|
||||
/**
|
||||
* Execute a tool operation directly without using React hooks
|
||||
*/
|
||||
export const executeToolOperation = async (
|
||||
operationName: string,
|
||||
parameters: any,
|
||||
files: File[],
|
||||
toolRegistry: ToolRegistry
|
||||
): Promise<File[]> => {
|
||||
console.log(`🔧 Executing tool: ${operationName}`, { parameters, fileCount: files.length });
|
||||
|
||||
const config = toolRegistry[operationName]?.operationConfig;
|
||||
if (!config) {
|
||||
console.error(`❌ Tool operation not supported: ${operationName}`);
|
||||
throw new Error(`Tool operation not supported: ${operationName}`);
|
||||
}
|
||||
|
||||
console.log(`📋 Using config:`, config);
|
||||
|
||||
try {
|
||||
// Check if tool uses custom processor (like Convert tool)
|
||||
if (config.customProcessor) {
|
||||
console.log(`🎯 Using custom processor for ${config.operationType}`);
|
||||
const resultFiles = await config.customProcessor(parameters, files);
|
||||
console.log(`✅ Custom processor returned ${resultFiles.length} files`);
|
||||
return resultFiles;
|
||||
}
|
||||
|
||||
if (config.multiFileEndpoint) {
|
||||
// Multi-file processing - single API call with all files
|
||||
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, {
|
||||
responseType: 'blob',
|
||||
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
|
||||
});
|
||||
|
||||
console.log(`📥 Response status: ${response.status}, size: ${response.data.size} bytes`);
|
||||
|
||||
// Multi-file responses are typically ZIP files, but may be single files
|
||||
const result = await AutomationFileProcessor.extractAutomationZipFiles(response.data);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.warn(`⚠️ File processing warnings:`, result.errors);
|
||||
}
|
||||
|
||||
console.log(`📁 Processed ${result.files.length} files from response`);
|
||||
return result.files;
|
||||
|
||||
} else {
|
||||
// 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)
|
||||
: 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, {
|
||||
responseType: 'blob',
|
||||
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
|
||||
});
|
||||
|
||||
console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`);
|
||||
|
||||
// Create result file
|
||||
const resultFile = ResourceManager.createResultFile(
|
||||
response.data,
|
||||
file.name,
|
||||
AUTOMATION_CONSTANTS.FILE_PREFIX
|
||||
);
|
||||
resultFiles.push(resultFile);
|
||||
console.log(`✅ Created result file: ${resultFile.name}`);
|
||||
}
|
||||
|
||||
console.log(`🎉 Single-file processing complete: ${resultFiles.length} files`);
|
||||
return resultFiles;
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`Tool operation ${operationName} failed:`, error);
|
||||
throw new Error(`${operationName} operation failed: ${error.response?.data || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute an entire automation sequence
|
||||
*/
|
||||
export const executeAutomationSequence = async (
|
||||
automation: any,
|
||||
initialFiles: File[],
|
||||
toolRegistry: ToolRegistry,
|
||||
onStepStart?: (stepIndex: number, operationName: string) => void,
|
||||
onStepComplete?: (stepIndex: number, resultFiles: File[]) => void,
|
||||
onStepError?: (stepIndex: number, error: string) => void
|
||||
): Promise<File[]> => {
|
||||
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');
|
||||
}
|
||||
|
||||
let currentFiles = [...initialFiles];
|
||||
|
||||
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 executeToolOperation(
|
||||
operation.operation,
|
||||
operation.parameters || {},
|
||||
currentFiles,
|
||||
toolRegistry
|
||||
);
|
||||
|
||||
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);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🎉 Automation sequence completed: ${currentFiles.length} final files`);
|
||||
return currentFiles;
|
||||
};
|
186
frontend/src/utils/automationFileProcessor.ts
Normal file
186
frontend/src/utils/automationFileProcessor.ts
Normal file
@ -0,0 +1,186 @@
|
||||
/**
|
||||
* File processing utilities specifically for automation workflows
|
||||
*/
|
||||
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { zipFileService } from '../services/zipFileService';
|
||||
import { ResourceManager } from './resourceManager';
|
||||
import { AUTOMATION_CONSTANTS } from '../constants/automation';
|
||||
|
||||
export interface AutomationProcessingOptions {
|
||||
timeout?: number;
|
||||
responseType?: 'blob' | 'json';
|
||||
}
|
||||
|
||||
export interface AutomationProcessingResult {
|
||||
success: boolean;
|
||||
files: File[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class AutomationFileProcessor {
|
||||
/**
|
||||
* Check if a blob is a ZIP file by examining its header
|
||||
*/
|
||||
static isZipFile(blob: Blob): boolean {
|
||||
// This is a simple check - in a real implementation you might want to read the first few bytes
|
||||
// For now, we'll rely on the extraction attempt and fallback
|
||||
return blob.type === 'application/zip' || blob.type === 'application/x-zip-compressed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract files from a ZIP blob during automation execution, with fallback for non-ZIP files
|
||||
*/
|
||||
static async extractAutomationZipFiles(blob: Blob): Promise<AutomationProcessingResult> {
|
||||
try {
|
||||
const zipFile = ResourceManager.createTimestampedFile(
|
||||
blob,
|
||||
AUTOMATION_CONSTANTS.RESPONSE_ZIP_PREFIX,
|
||||
'.zip',
|
||||
'application/zip'
|
||||
);
|
||||
|
||||
const result = await zipFileService.extractPdfFiles(zipFile);
|
||||
|
||||
if (!result.success || result.extractedFiles.length === 0) {
|
||||
// Fallback: treat as single PDF file
|
||||
const fallbackFile = ResourceManager.createTimestampedFile(
|
||||
blob,
|
||||
AUTOMATION_CONSTANTS.RESULT_FILE_PREFIX,
|
||||
'.pdf'
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
files: [fallbackFile],
|
||||
errors: [`ZIP extraction failed, treated as single file: ${result.errors?.join(', ') || 'Unknown error'}`]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
files: result.extractedFiles,
|
||||
errors: []
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to extract automation ZIP files, falling back to single file:', error);
|
||||
// Fallback: treat as single PDF file
|
||||
const fallbackFile = ResourceManager.createTimestampedFile(
|
||||
blob,
|
||||
AUTOMATION_CONSTANTS.RESULT_FILE_PREFIX,
|
||||
'.pdf'
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
files: [fallbackFile],
|
||||
errors: [`ZIP extraction failed, treated as single file: ${error}`]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single file through an automation step
|
||||
*/
|
||||
static async processAutomationSingleFile(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
originalFileName: string,
|
||||
options: AutomationProcessingOptions = {}
|
||||
): Promise<AutomationProcessingResult> {
|
||||
try {
|
||||
const response = await axios.post(endpoint, formData, {
|
||||
responseType: options.responseType || 'blob',
|
||||
timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
files: [],
|
||||
errors: [`Automation step failed - HTTP ${response.status}: ${response.statusText}`]
|
||||
};
|
||||
}
|
||||
|
||||
const resultFile = ResourceManager.createResultFile(
|
||||
response.data,
|
||||
originalFileName,
|
||||
AUTOMATION_CONSTANTS.FILE_PREFIX
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
files: [resultFile],
|
||||
errors: []
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
files: [],
|
||||
errors: [`Automation step failed: ${error.response?.data || error.message}`]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process multiple files through an automation step
|
||||
*/
|
||||
static async processAutomationMultipleFiles(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
options: AutomationProcessingOptions = {}
|
||||
): Promise<AutomationProcessingResult> {
|
||||
try {
|
||||
const response = await axios.post(endpoint, formData, {
|
||||
responseType: options.responseType || 'blob',
|
||||
timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
files: [],
|
||||
errors: [`Automation step failed - HTTP ${response.status}: ${response.statusText}`]
|
||||
};
|
||||
}
|
||||
|
||||
// Multi-file responses are typically ZIP files
|
||||
return await this.extractAutomationZipFiles(response.data);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
files: [],
|
||||
errors: [`Automation step failed: ${error.response?.data || error.message}`]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build form data for automation tool operations
|
||||
*/
|
||||
static buildAutomationFormData(
|
||||
parameters: Record<string, any>,
|
||||
files: File | File[],
|
||||
fileFieldName: string = 'fileInput'
|
||||
): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add files
|
||||
if (Array.isArray(files)) {
|
||||
files.forEach(file => formData.append(fileFieldName, file));
|
||||
} else {
|
||||
formData.append(fileFieldName, files);
|
||||
}
|
||||
|
||||
// Add parameters
|
||||
Object.entries(parameters).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => formData.append(key, item));
|
||||
} else if (value !== undefined && value !== null) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return formData;
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import {
|
||||
CONVERSION_ENDPOINTS,
|
||||
ENDPOINT_NAMES,
|
||||
EXTENSION_TO_ENDPOINT
|
||||
EXTENSION_TO_ENDPOINT,
|
||||
CONVERSION_MATRIX,
|
||||
TO_FORMAT_OPTIONS
|
||||
} from '../constants/convertConstants';
|
||||
|
||||
/**
|
||||
@ -56,4 +58,33 @@ export const isImageFormat = (extension: string): boolean => {
|
||||
*/
|
||||
export const isWebFormat = (extension: string): boolean => {
|
||||
return ['html', 'zip'].includes(extension.toLowerCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets available target extensions for a given source extension
|
||||
* Extracted from useConvertParameters to be reusable in automation settings
|
||||
*/
|
||||
export const getAvailableToExtensions = (fromExtension: string): Array<{value: string, label: string, group: string}> => {
|
||||
if (!fromExtension) return [];
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use 'any' conversion options (file-to-pdf)
|
||||
const supportedExtensions = CONVERSION_MATRIX['any'] || [];
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
supportedExtensions.includes(option.value)
|
||||
);
|
||||
}
|
||||
|
||||
let supportedExtensions = CONVERSION_MATRIX[fromExtension] || [];
|
||||
|
||||
// If no explicit conversion exists, but file-to-pdf might be available,
|
||||
// fall back to 'any' conversion (which converts unknown files to PDF via file-to-pdf)
|
||||
if (supportedExtensions.length === 0 && fromExtension !== 'any') {
|
||||
supportedExtensions = CONVERSION_MATRIX['any'] || [];
|
||||
}
|
||||
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
supportedExtensions.includes(option.value)
|
||||
);
|
||||
};
|
71
frontend/src/utils/resourceManager.ts
Normal file
71
frontend/src/utils/resourceManager.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Utilities for managing file resources and blob URLs
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { AUTOMATION_CONSTANTS } from '../constants/automation';
|
||||
|
||||
export class ResourceManager {
|
||||
private static blobUrls = new Set<string>();
|
||||
|
||||
/**
|
||||
* Create a blob URL and track it for cleanup
|
||||
*/
|
||||
static createBlobUrl(blob: Blob): string {
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.blobUrls.add(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific blob URL
|
||||
*/
|
||||
static revokeBlobUrl(url: string): void {
|
||||
if (this.blobUrls.has(url)) {
|
||||
URL.revokeObjectURL(url);
|
||||
this.blobUrls.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tracked blob URLs
|
||||
*/
|
||||
static revokeAllBlobUrls(): void {
|
||||
this.blobUrls.forEach(url => URL.revokeObjectURL(url));
|
||||
this.blobUrls.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a File with proper naming convention
|
||||
*/
|
||||
static createResultFile(
|
||||
data: BlobPart,
|
||||
originalName: string,
|
||||
prefix: string = AUTOMATION_CONSTANTS.PROCESSED_FILE_PREFIX,
|
||||
type: string = 'application/pdf'
|
||||
): File {
|
||||
return new File([data], `${prefix}${originalName}`, { type });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timestamped file for responses
|
||||
*/
|
||||
static createTimestampedFile(
|
||||
data: BlobPart,
|
||||
prefix: string,
|
||||
extension: string = '.pdf',
|
||||
type: string = 'application/pdf'
|
||||
): File {
|
||||
const timestamp = Date.now();
|
||||
return new File([data], `${prefix}${timestamp}${extension}`, { type });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for automatic cleanup on component unmount
|
||||
*/
|
||||
export function useResourceCleanup(): () => void {
|
||||
return useCallback(() => {
|
||||
ResourceManager.revokeAllBlobUrls();
|
||||
}, []);
|
||||
}
|
@ -3,12 +3,7 @@
|
||||
* Provides clean URL routing for the V2 tool system
|
||||
*/
|
||||
|
||||
import { ModeType } from '../contexts/NavigationContext';
|
||||
|
||||
export interface ToolRoute {
|
||||
mode: ModeType;
|
||||
toolKey?: string;
|
||||
}
|
||||
import { ModeType, isValidMode as isValidModeType, getDefaultMode, ToolRoute } from '../types/navigation';
|
||||
|
||||
/**
|
||||
* Parse the current URL to extract tool routing information
|
||||
@ -45,7 +40,7 @@ export function parseToolRoute(): ToolRoute {
|
||||
|
||||
// Check for query parameter fallback (e.g., ?tool=split)
|
||||
const toolParam = searchParams.get('tool');
|
||||
if (toolParam && isValidMode(toolParam)) {
|
||||
if (toolParam && isValidModeType(toolParam)) {
|
||||
return {
|
||||
mode: toolParam as ModeType,
|
||||
toolKey: toolParam
|
||||
@ -54,7 +49,8 @@ export function parseToolRoute(): ToolRoute {
|
||||
|
||||
// Default to page editor for home page
|
||||
return {
|
||||
mode: 'pageEditor'
|
||||
mode: getDefaultMode(),
|
||||
toolKey: null
|
||||
};
|
||||
}
|
||||
|
||||
@ -137,16 +133,7 @@ export function getToolDisplayName(toolKey: string): string {
|
||||
return displayNames[toolKey] || toolKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mode is valid
|
||||
*/
|
||||
function isValidMode(mode: string): mode is ModeType {
|
||||
const validModes: ModeType[] = [
|
||||
'viewer', 'pageEditor', 'fileEditor', 'merge', 'split',
|
||||
'compress', 'ocr', 'convert', 'addPassword', 'changePermissions', 'sanitize'
|
||||
];
|
||||
return validModes.includes(mode as ModeType);
|
||||
}
|
||||
// Note: isValidMode is now imported from types/navigation.ts
|
||||
|
||||
/**
|
||||
* Generate shareable URL for current tool state
|
||||
|
Loading…
x
Reference in New Issue
Block a user