improvement/v2/automate/tweaks (#4293)

- [x] Cleanup Automation output name garbage			
- [x] Remove Cross button on first two tools			
- [x] Automation creation name title to make clearer to the user
- [x] Colours for dark mode on automation tool settings are bad 	
- [x] Fix tool names not using correct translated ones 
- [x] suggested Automation Password needs adding to description 
- [x] Allow different filetypes in automation
- [x] Custom Icons for automation
- [x] split Tool wasn't working with merge to single pdf

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
ConnorYoh 2025-08-26 16:59:03 +01:00 committed by GitHub
parent 3d26b054f1
commit 47ccb6a6ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 582 additions and 134 deletions

View File

@ -51,7 +51,6 @@
"placeholder": "Select a PDF file in the main view to get started", "placeholder": "Select a PDF file in the main view to get started",
"upload": "Upload", "upload": "Upload",
"addFiles": "Add files", "addFiles": "Add files",
"noFiles": "No files uploaded. ",
"selectFromWorkbench": "Select files from the workbench or " "selectFromWorkbench": "Select files from the workbench or "
}, },
"noFavourites": "No favourites added", "noFavourites": "No favourites added",
@ -2320,9 +2319,14 @@
"creation": { "creation": {
"createTitle": "Create Automation", "createTitle": "Create Automation",
"editTitle": "Edit Automation", "editTitle": "Edit Automation",
"description": "Automations run tools sequentially. To get started, add tools in the order you want them to run.", "intro": "Automations run tools sequentially. To get started, add tools in the order you want them to run.",
"name": { "name": {
"placeholder": "Automation name" "label": "Automation Name",
"placeholder": "My Automation"
},
"description": {
"label": "Description (optional)",
"placeholder": "Describe what this automation does..."
}, },
"tools": { "tools": {
"selectTool": "Select a tool...", "selectTool": "Select a tool...",
@ -2339,6 +2343,9 @@
"message": "You have unsaved changes. Are you sure you want to go back? All changes will be lost.", "message": "You have unsaved changes. Are you sure you want to go back? All changes will be lost.",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Go Back" "confirm": "Go Back"
},
"icon": {
"label": "Icon"
} }
}, },
"run": { "run": {
@ -2369,7 +2376,7 @@
"emailPreparation": "Email Preparation", "emailPreparation": "Email Preparation",
"emailPreparationDesc": "Optimises PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.", "emailPreparationDesc": "Optimises PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.",
"secureWorkflow": "Security Workflow", "secureWorkflow": "Security Workflow",
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access.", "secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default.",
"processImages": "Process Images", "processImages": "Process Images",
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
} }

View File

@ -2111,10 +2111,10 @@
"suggested": { "suggested": {
"securePdfIngestion": "Secure PDF Ingestion", "securePdfIngestion": "Secure PDF Ingestion",
"securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitizes documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimizes file size.", "securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitizes documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimizes file size.",
"emailPreparation": "Email Preparation", "emailPreparation": "Email Preparation",
"emailPreparationDesc": "Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.", "emailPreparationDesc": "Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.",
"secureWorkflow": "Security Workflow", "secureWorkflow": "Security Workflow",
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access.", "secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access. Password is set to 'password' by default.",
"processImages": "Process Images", "processImages": "Process Images",
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
} }

View File

@ -32,9 +32,10 @@ const FileUploadButton = ({
onChange={onChange} onChange={onChange}
accept={accept} accept={accept}
disabled={disabled} disabled={disabled}
> >
{(props) => ( {(props) => (
<Button {...props} variant={variant} fullWidth={fullWidth}> <Button {...props} variant={variant} fullWidth={fullWidth} color="blue">
{file ? file.name : (placeholder || defaultPlaceholder)} {file ? file.name : (placeholder || defaultPlaceholder)}
</Button> </Button>
)} )}

View File

@ -22,7 +22,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
const { t } = useTranslation(); const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode } = useToolWorkflow(); const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const [configModalOpen, setConfigModalOpen] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools'); const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null); const scrollableRef = useRef<HTMLDivElement>(null);
@ -74,7 +74,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
type: 'navigation', type: 'navigation',
onClick: () => { onClick: () => {
setActiveButton('automate'); setActiveButton('automate');
handleToolSelect('automate'); // If already on automate tool, reset it directly
if (selectedToolKey === 'automate') {
resetTool('automate');
} else {
handleToolSelect('automate');
}
} }
}, },
{ {

View File

@ -16,7 +16,7 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
<div style={{ display: 'flex', gap: '4px' }}> <div style={{ display: 'flex', gap: '4px' }}>
<Button <Button
variant={watermarkType === 'text' ? 'filled' : 'outline'} variant={watermarkType === 'text' ? 'filled' : 'outline'}
color={watermarkType === 'text' ? 'blue' : 'gray'} color={watermarkType === 'text' ? 'blue' : 'var(--text-muted)'}
onClick={() => onWatermarkTypeChange('text')} onClick={() => onWatermarkTypeChange('text')}
disabled={disabled} disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
@ -27,7 +27,7 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
</Button> </Button>
<Button <Button
variant={watermarkType === 'image' ? 'filled' : 'outline'} variant={watermarkType === 'image' ? 'filled' : 'outline'}
color={watermarkType === 'image' ? 'blue' : 'gray'} color={watermarkType === 'image' ? 'blue' : 'var(--text-muted)'}
onClick={() => onWatermarkTypeChange('image')} onClick={() => onWatermarkTypeChange('image')}
disabled={disabled} disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}

View File

@ -6,6 +6,7 @@ import {
Stack, Stack,
Group, Group,
TextInput, TextInput,
Textarea,
Divider, Divider,
Modal Modal
} from '@mantine/core'; } from '@mantine/core';
@ -13,6 +14,7 @@ import CheckIcon from '@mui/icons-material/Check';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import ToolConfigurationModal from './ToolConfigurationModal'; import ToolConfigurationModal from './ToolConfigurationModal';
import ToolList from './ToolList'; import ToolList from './ToolList';
import IconSelector from './IconSelector';
import { AutomationConfig, AutomationMode, AutomationTool } from '../../../types/automation'; import { AutomationConfig, AutomationMode, AutomationTool } from '../../../types/automation';
import { useAutomationForm } from '../../../hooks/tools/automate/useAutomationForm'; import { useAutomationForm } from '../../../hooks/tools/automate/useAutomationForm';
@ -31,6 +33,10 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
const { const {
automationName, automationName,
setAutomationName, setAutomationName,
automationDescription,
setAutomationDescription,
automationIcon,
setAutomationIcon,
selectedTools, selectedTools,
addTool, addTool,
removeTool, removeTool,
@ -100,7 +106,8 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
const automationData = { const automationData = {
name: automationName.trim(), name: automationName.trim(),
description: '', description: automationDescription.trim(),
icon: automationIcon,
operations: selectedTools.map(tool => ({ operations: selectedTools.map(tool => ({
operation: tool.operation, operation: tool.operation,
parameters: tool.parameters || {} parameters: tool.parameters || {}
@ -114,7 +121,7 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
if (mode === AutomationMode.EDIT && existingAutomation) { if (mode === AutomationMode.EDIT && existingAutomation) {
// For edit mode, check if name has changed // For edit mode, check if name has changed
const nameChanged = automationName.trim() !== existingAutomation.name; const nameChanged = automationName.trim() !== existingAutomation.name;
if (nameChanged) { if (nameChanged) {
// Name changed - create new automation // Name changed - create new automation
savedAutomation = await automationStorage.saveAutomation(automationData); savedAutomation = await automationStorage.saveAutomation(automationData);
@ -144,17 +151,39 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
return ( return (
<div> <div>
<Text size="sm" mb="md" p="md" style={{borderRadius:'var(--mantine-radius-md)', background: 'var(--color-gray-200)', color: 'var(--mantine-color-text)' }}> <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.")} {t("automate.creation.intro", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
</Text> </Text>
<Divider mb="md" /> <Divider mb="md" />
<Stack gap="md"> <Stack gap="md">
{/* Automation Name */} {/* Automation Name and Icon */}
<TextInput <Group gap="xs" align="flex-end">
placeholder={t('automate.creation.name.placeholder', 'Automation name')} <Stack gap="xs" style={{ flex: 1 }}>
value={automationName} <TextInput
onChange={(e) => setAutomationName(e.currentTarget.value)} placeholder={t('automate.creation.name.placeholder', 'My Automation')}
value={automationName}
withAsterisk
label={t('automate.creation.name.label', 'Automation Name')}
onChange={(e) => setAutomationName(e.currentTarget.value)}
size="sm"
/>
</Stack>
<IconSelector
value={automationIcon || 'SettingsIcon'}
onChange={setAutomationIcon}
size="sm"
/>
</Group>
{/* Automation Description */}
<Textarea
placeholder={t('automate.creation.description.placeholder', 'Describe what this automation does...')}
value={automationDescription}
label={t('automate.creation.description.label', 'Description')}
onChange={(e) => setAutomationDescription(e.currentTarget.value)}
size="sm" size="sm"
rows={3}
/> />

View File

@ -6,6 +6,7 @@ import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Tooltip } from '../../shared/Tooltip'; import { Tooltip } from '../../shared/Tooltip';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
interface AutomationEntryProps { interface AutomationEntryProps {
/** Optional title for the automation (usually for custom ones) */ /** Optional title for the automation (usually for custom ones) */
@ -28,6 +29,8 @@ interface AutomationEntryProps {
onDelete?: () => void; onDelete?: () => void;
/** Copy handler (for suggested automations) */ /** Copy handler (for suggested automations) */
onCopy?: () => void; onCopy?: () => void;
/** Tool registry to resolve operation names */
toolRegistry?: Record<string, ToolRegistryEntry>;
} }
export default function AutomationEntry({ export default function AutomationEntry({
@ -40,7 +43,8 @@ export default function AutomationEntry({
showMenu = false, showMenu = false,
onEdit, onEdit,
onDelete, onDelete,
onCopy onCopy,
toolRegistry
}: AutomationEntryProps) { }: AutomationEntryProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@ -49,9 +53,19 @@ export default function AutomationEntry({
// Keep item in hovered state if menu is open // Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen; const shouldShowHovered = isHovered || isMenuOpen;
// Helper function to resolve tool display names
const getToolDisplayName = (operation: string): string => {
if (toolRegistry?.[operation]?.name) {
return toolRegistry[operation].name;
}
// Fallback to translation or operation key
return t(`${operation}.title`, operation);
};
// Create tooltip content with description and tool chain // Create tooltip content with description and tool chain
const createTooltipContent = () => { const createTooltipContent = () => {
if (!description) return null; // Show tooltip if there's a description OR if there are operations to show in the chain
if (!description && operations.length === 0) return null;
const toolChain = operations.map((op, index) => ( const toolChain = operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}> <React.Fragment key={`${op}-${index}`}>
@ -68,7 +82,7 @@ export default function AutomationEntry({
whiteSpace: 'nowrap' whiteSpace: 'nowrap'
}} }}
> >
{t(`${op}.title`, op)} {getToolDisplayName(op)}
</Text> </Text>
{index < operations.length - 1 && ( {index < operations.length - 1 && (
<Text component="span" size="sm" mx={4}> <Text component="span" size="sm" mx={4}>
@ -80,12 +94,16 @@ export default function AutomationEntry({
return ( return (
<div style={{ minWidth: '400px', width: 'auto' }}> <div style={{ minWidth: '400px', width: 'auto' }}>
<Text size="sm" mb={8} style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}> {description && (
{description} <Text size="sm" mb={8} style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}>
</Text> {description}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', whiteSpace: 'nowrap' }}> </Text>
{toolChain} )}
</div> {operations.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', whiteSpace: 'nowrap' }}>
{toolChain}
</div>
)}
</div> </div>
); );
}; };
@ -122,7 +140,7 @@ export default function AutomationEntry({
{operations.map((op, index) => ( {operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}> <React.Fragment key={`${op}-${index}`}>
<Text size="xs" style={{ color: 'var(--mantine-color-text)' }}> <Text size="xs" style={{ color: 'var(--mantine-color-text)' }}>
{t(`${op}.title`, op)} {getToolDisplayName(op)}
</Text> </Text>
{index < operations.length - 1 && ( {index < operations.length - 1 && (
@ -221,8 +239,10 @@ export default function AutomationEntry({
</Box> </Box>
); );
// Only show tooltip if description exists, otherwise return plain content // Show tooltip if there's a description OR operations to display
return description ? ( const shouldShowTooltip = description || operations.length > 0;
return shouldShowTooltip ? (
<Tooltip <Tooltip
content={createTooltipContent()} content={createTooltipContent()}
position="right" position="right"

View File

@ -6,6 +6,8 @@ import SettingsIcon from "@mui/icons-material/Settings";
import AutomationEntry from "./AutomationEntry"; import AutomationEntry from "./AutomationEntry";
import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations"; import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations";
import { AutomationConfig, SuggestedAutomation } from "../../../types/automation"; import { AutomationConfig, SuggestedAutomation } from "../../../types/automation";
import { iconMap } from './iconMap';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
interface AutomationSelectionProps { interface AutomationSelectionProps {
savedAutomations: AutomationConfig[]; savedAutomations: AutomationConfig[];
@ -14,6 +16,7 @@ interface AutomationSelectionProps {
onEdit: (automation: AutomationConfig) => void; onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void; onDelete: (automation: AutomationConfig) => void;
onCopyFromSuggested: (automation: SuggestedAutomation) => void; onCopyFromSuggested: (automation: SuggestedAutomation) => void;
toolRegistry: Record<string, ToolRegistryEntry>;
} }
export default function AutomationSelection({ export default function AutomationSelection({
@ -22,7 +25,8 @@ export default function AutomationSelection({
onRun, onRun,
onEdit, onEdit,
onDelete, onDelete,
onCopyFromSuggested onCopyFromSuggested,
toolRegistry
}: AutomationSelectionProps) { }: AutomationSelectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const suggestedAutomations = useSuggestedAutomations(); const suggestedAutomations = useSuggestedAutomations();
@ -40,20 +44,26 @@ export default function AutomationSelection({
operations={[]} operations={[]}
onClick={onCreateNew} onClick={onCreateNew}
keepIconColor={true} keepIconColor={true}
toolRegistry={toolRegistry}
/> />
{/* Saved Automations */} {/* Saved Automations */}
{savedAutomations.map((automation) => ( {savedAutomations.map((automation) => {
<AutomationEntry const IconComponent = automation.icon ? iconMap[automation.icon as keyof typeof iconMap] : SettingsIcon;
key={automation.id} return (
title={automation.name} <AutomationEntry
badgeIcon={SettingsIcon} key={automation.id}
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)} title={automation.name}
onClick={() => onRun(automation)} description={automation.description}
showMenu={true} badgeIcon={IconComponent || SettingsIcon}
onEdit={() => onEdit(automation)} operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
onDelete={() => onDelete(automation)} onClick={() => onRun(automation)}
/> showMenu={true}
))} onEdit={() => onEdit(automation)}
onDelete={() => onDelete(automation)}
toolRegistry={toolRegistry}
/>
);
})}
<Divider pb='sm' /> <Divider pb='sm' />
{/* Suggested Automations */} {/* Suggested Automations */}
@ -72,6 +82,7 @@ export default function AutomationSelection({
onClick={() => onRun(automation)} onClick={() => onRun(automation)}
showMenu={true} showMenu={true}
onCopy={() => onCopyFromSuggested(automation)} onCopy={() => onCopyFromSuggested(automation)}
toolRegistry={toolRegistry}
/> />
))} ))}
</Stack> </Stack>

View File

@ -0,0 +1,116 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, Text, Stack, Button, SimpleGrid, Tooltip, Popover } from "@mantine/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { iconMap, iconOptions } from './iconMap';
interface IconSelectorProps {
value?: string;
onChange?: (iconKey: string) => void;
size?: "sm" | "md" | "lg";
}
export default function IconSelector({ value = "SettingsIcon", onChange, size = "sm" }: IconSelectorProps) {
const { t } = useTranslation();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const selectedIconComponent = iconMap[value as keyof typeof iconMap] || iconMap.SettingsIcon;
const handleIconSelect = (iconKey: string) => {
onChange?.(iconKey);
setIsDropdownOpen(false);
};
const iconSize = size === "sm" ? 16 : size === "md" ? 18 : 20;
return (
<Stack gap="1px">
<Text size="sm" fw={600} style={{ color: "var(--mantine-color-primary)" }}>
{t("automate.creation.icon.label", "Icon")}
</Text>
<Popover
opened={isDropdownOpen}
onClose={() => setIsDropdownOpen(false)}
onDismiss={() => setIsDropdownOpen(false)}
position="bottom-start"
withArrow
trapFocus
>
<Popover.Target>
<Button
variant="outline"
size={size}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
style={{
width: size === "sm" ? "3.75rem" : "4.375rem",
position: "relative",
display: "flex",
justifyContent: "flex-start",
paddingLeft: "0.5rem",
borderColor: "var(--mantine-color-gray-3)",
color: "var(--mantine-color-text)",
}}
>
{React.createElement(selectedIconComponent, { style: { fontSize: iconSize } })}
<KeyboardArrowDownIcon
style={{
fontSize: iconSize * 0.8,
position: "absolute",
right: "0.25rem",
top: "50%",
transform: "translateY(-50%)",
}}
/>
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<SimpleGrid cols={4} spacing="xs">
{iconOptions.map((option) => {
const IconComponent = iconMap[option.value as keyof typeof iconMap];
const isSelected = value === option.value;
return (
<Tooltip key={option.value} label={option.label}>
<Box
onClick={() => handleIconSelect(option.value)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.5rem",
borderRadius: "0.25rem",
cursor: "pointer",
backgroundColor: isSelected ? "var(--mantine-color-gray-1)" : "transparent",
}}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = "var(--mantine-color-gray-0)";
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = "transparent";
}
}}
>
<IconComponent
style={{
fontSize: iconSize,
color: isSelected ? "var(--mantine-color-gray-9)" : "var(--mantine-color-gray-7)",
}}
/>
</Box>
</Tooltip>
);
})}
</SimpleGrid>
</Stack>
</Popover.Dropdown>
</Popover>
</Stack>
);
}

View File

@ -63,22 +63,24 @@ export default function ToolList({
borderBottomWidth: tool.operation && !tool.configured ? "0" : "1px", borderBottomWidth: tool.operation && !tool.configured ? "0" : "1px",
}} }}
> >
{/* Delete X in top right */} {/* Delete X in top right - only show for tools after the first 2 */}
<ActionIcon {index > 1 && (
variant="subtle" <ActionIcon
size="xs" variant="subtle"
onClick={() => onToolRemove(index)} size="xs"
title={t("automate.creation.tools.remove", "Remove tool")} onClick={() => onToolRemove(index)}
style={{ title={t("automate.creation.tools.remove", "Remove tool")}
position: "absolute", style={{
top: "4px", position: "absolute",
right: "4px", top: "4px",
zIndex: 1, right: "4px",
color: "var(--mantine-color-gray-6)", zIndex: 1,
}} color: "var(--mantine-color-gray-6)",
> }}
<CloseIcon style={{ fontSize: 16 }} /> >
</ActionIcon> <CloseIcon style={{ fontSize: 16 }} />
</ActionIcon>
)}
<div style={{ paddingRight: "1.25rem" }}> <div style={{ paddingRight: "1.25rem" }}>
{/* Tool Selection Dropdown with inline settings cog */} {/* Tool Selection Dropdown with inline settings cog */}

View File

@ -0,0 +1,92 @@
import SettingsIcon from '@mui/icons-material/Settings';
import CompressIcon from '@mui/icons-material/Compress';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
import CropIcon from '@mui/icons-material/Crop';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import FolderIcon from '@mui/icons-material/Folder';
import CloudIcon from '@mui/icons-material/Cloud';
import StorageIcon from '@mui/icons-material/Storage';
import SearchIcon from '@mui/icons-material/Search';
import DownloadIcon from '@mui/icons-material/Download';
import UploadIcon from '@mui/icons-material/Upload';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import VisibilityIcon from '@mui/icons-material/Visibility';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import WorkIcon from '@mui/icons-material/Work';
import BuildIcon from '@mui/icons-material/Build';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import CheckIcon from '@mui/icons-material/Check';
import SecurityIcon from '@mui/icons-material/Security';
import StarIcon from '@mui/icons-material/Star';
export const iconMap = {
SettingsIcon,
CompressIcon,
SwapHorizIcon,
CleaningServicesIcon,
CropIcon,
TextFieldsIcon,
PictureAsPdfIcon,
EditIcon,
DeleteIcon,
FolderIcon,
CloudIcon,
StorageIcon,
SearchIcon,
DownloadIcon,
UploadIcon,
PlayArrowIcon,
RotateLeftIcon,
RotateRightIcon,
VisibilityIcon,
ContentCutIcon,
ContentCopyIcon,
WorkIcon,
BuildIcon,
AutoAwesomeIcon,
SmartToyIcon,
CheckIcon,
SecurityIcon,
StarIcon
};
export const iconOptions = [
{ value: 'SettingsIcon', label: 'Settings' },
{ value: 'CompressIcon', label: 'Compress' },
{ value: 'SwapHorizIcon', label: 'Convert' },
{ value: 'CleaningServicesIcon', label: 'Clean' },
{ value: 'CropIcon', label: 'Crop' },
{ value: 'TextFieldsIcon', label: 'Text' },
{ value: 'PictureAsPdfIcon', label: 'PDF' },
{ value: 'EditIcon', label: 'Edit' },
{ value: 'DeleteIcon', label: 'Delete' },
{ value: 'FolderIcon', label: 'Folder' },
{ value: 'CloudIcon', label: 'Cloud' },
{ value: 'StorageIcon', label: 'Storage' },
{ value: 'SearchIcon', label: 'Search' },
{ value: 'DownloadIcon', label: 'Download' },
{ value: 'UploadIcon', label: 'Upload' },
{ value: 'PlayArrowIcon', label: 'Play' },
{ value: 'RotateLeftIcon', label: 'Rotate Left' },
{ value: 'RotateRightIcon', label: 'Rotate Right' },
{ value: 'VisibilityIcon', label: 'View' },
{ value: 'ContentCutIcon', label: 'Cut' },
{ value: 'ContentCopyIcon', label: 'Copy' },
{ value: 'WorkIcon', label: 'Work' },
{ value: 'BuildIcon', label: 'Build' },
{ value: 'AutoAwesomeIcon', label: 'Magic' },
{ value: 'SmartToyIcon', label: 'Robot' },
{ value: 'CheckIcon', label: 'Check' },
{ value: 'SecurityIcon', label: 'Security' },
{ value: 'StarIcon', label: 'Star' }
];
export type IconKey = keyof typeof iconMap;

View File

@ -23,7 +23,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
<div style={{ display: 'flex', gap: '4px' }}> <div style={{ display: 'flex', gap: '4px' }}>
<Button <Button
variant={parameters.compressionMethod === 'quality' ? 'filled' : 'outline'} variant={parameters.compressionMethod === 'quality' ? 'filled' : 'outline'}
color={parameters.compressionMethod === 'quality' ? 'blue' : 'gray'} color={parameters.compressionMethod === 'quality' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('compressionMethod', 'quality')} onClick={() => onParameterChange('compressionMethod', 'quality')}
disabled={disabled} disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
@ -34,7 +34,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
</Button> </Button>
<Button <Button
variant={parameters.compressionMethod === 'filesize' ? 'filled' : 'outline'} variant={parameters.compressionMethod === 'filesize' ? 'filled' : 'outline'}
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'gray'} color={parameters.compressionMethod === 'filesize' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('compressionMethod', 'filesize')} onClick={() => onParameterChange('compressionMethod', 'filesize')}
disabled={disabled} disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}

View File

@ -86,6 +86,10 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
selectTool: (toolId: string) => void; selectTool: (toolId: string) => void;
clearToolSelection: () => void; clearToolSelection: () => void;
// Tool Reset Actions
registerToolReset: (toolId: string, resetFunction: () => void) => void;
resetTool: (toolId: string) => void;
// Workflow Actions (compound actions) // Workflow Actions (compound actions)
handleToolSelect: (toolId: string) => void; handleToolSelect: (toolId: string) => void;
handleBackToTools: () => void; handleBackToTools: () => void;
@ -106,16 +110,19 @@ interface ToolWorkflowProviderProps {
export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState); const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
// Store reset functions for tools
const [toolResetFunctions, setToolResetFunctions] = React.useState<Record<string, () => void>>({});
// Navigation actions and state are available since we're inside NavigationProvider // Navigation actions and state are available since we're inside NavigationProvider
const { actions } = useNavigationActions(); const { actions } = useNavigationActions();
const navigationState = useNavigationState(); const navigationState = useNavigationState();
// Tool management hook // Tool management hook
const { const {
toolRegistry, toolRegistry,
getSelectedTool, getSelectedTool,
} = useToolManagement(); } = useToolManagement();
// Get selected tool from navigation context // Get selected tool from navigation context
const selectedTool = getSelectedTool(navigationState.selectedToolKey); const selectedTool = getSelectedTool(navigationState.selectedToolKey);
@ -147,13 +154,29 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
dispatch({ type: 'SET_SEARCH_QUERY', payload: query }); dispatch({ type: 'SET_SEARCH_QUERY', payload: query });
}, []); }, []);
// Tool reset methods
const registerToolReset = useCallback((toolId: string, resetFunction: () => void) => {
setToolResetFunctions(prev => ({ ...prev, [toolId]: resetFunction }));
}, []);
const resetTool = useCallback((toolId: string) => {
// Use the current state directly instead of depending on the state in the closure
setToolResetFunctions(current => {
const resetFunction = current[toolId];
if (resetFunction) {
resetFunction();
}
return current; // Return the same state to avoid unnecessary updates
});
}, []); // Empty dependency array makes this stable
// Workflow actions (compound actions that coordinate multiple state changes) // Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: string) => { const handleToolSelect = useCallback((toolId: string) => {
actions.handleToolSelect(toolId); actions.handleToolSelect(toolId);
// Clear search query when selecting a tool // Clear search query when selecting a tool
setSearchQuery(''); setSearchQuery('');
// Handle view switching logic // Handle view switching logic
if (toolId === 'allTools' || toolId === 'read' || toolId === 'view-pdf') { if (toolId === 'allTools' || toolId === 'read' || toolId === 'view-pdf') {
setLeftPanelView('toolPicker'); setLeftPanelView('toolPicker');
@ -212,6 +235,10 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
selectTool: actions.selectTool, selectTool: actions.selectTool,
clearToolSelection: actions.clearToolSelection, clearToolSelection: actions.clearToolSelection,
// Tool Reset Actions
registerToolReset,
resetTool,
// Workflow Actions // Workflow Actions
handleToolSelect, handleToolSelect,
handleBackToTools, handleBackToTools,
@ -233,6 +260,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSearchQuery, setSearchQuery,
actions.selectTool, actions.selectTool,
actions.clearToolSelection, actions.clearToolSelection,
registerToolReset,
resetTool,
handleToolSelect, handleToolSelect,
handleBackToTools, handleBackToTools,
handleReaderToggle, handleReaderToggle,
@ -251,36 +280,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
export function useToolWorkflow(): ToolWorkflowContextValue { export function useToolWorkflow(): ToolWorkflowContextValue {
const context = useContext(ToolWorkflowContext); const context = useContext(ToolWorkflowContext);
if (!context) { 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); console.error('ToolWorkflowContext not found. Current stack:', new Error().stack);
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider'); throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');

View File

@ -42,6 +42,26 @@ import ChangePermissionsSettings from '../components/tools/changePermissions/Cha
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
// Convert tool supported file formats
export const CONVERT_SUPPORTED_FORMATS = [
// Microsoft Office
"doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx",
// OpenDocument
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg",
// Text formats
"txt", "text", "xml", "rtf", "html", "lwp", "md",
// Images
"bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp",
// StarOffice
"sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw",
// Email formats
"eml",
// Archive formats
"zip",
// Other
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
];
// Hook to get the translated tool registry // Hook to get the translated tool registry
export function useFlatToolRegistry(): ToolRegistry { export function useFlatToolRegistry(): ToolRegistry {
const { t } = useTranslation(); const { t } = useTranslation();
@ -394,6 +414,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS, categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION, subcategoryId: SubcategoryId.AUTOMATION,
maxFiles: -1, maxFiles: -1,
supportedFormats: CONVERT_SUPPORTED_FORMATS,
endpoints: ["handleData"] endpoints: ["handleData"]
}, },
"auto-rename-pdf-file": { "auto-rename-pdf-file": {
@ -589,6 +610,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS, categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL, subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1, maxFiles: -1,
supportedFormats: CONVERT_SUPPORTED_FORMATS,
endpoints: [ endpoints: [
"pdf-to-img", "pdf-to-img",
"img-to-pdf", "img-to-pdf",
@ -605,24 +627,7 @@ export function useFlatToolRegistry(): ToolRegistry {
"pdf-to-pdfa", "pdf-to-pdfa",
"eml-to-pdf" "eml-to-pdf"
], ],
supportedFormats: [
// Microsoft Office
"doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx",
// OpenDocument
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg",
// Text formats
"txt", "text", "xml", "rtf", "html", "lwp", "md",
// Images
"bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp",
// StarOffice
"sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw",
// Email formats
"eml",
// Archive formats
"zip",
// Other
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
],
operationConfig: convertOperationConfig, operationConfig: convertOperationConfig,
settingsComponent: ConvertSettings settingsComponent: ConvertSettings
}, },

View File

@ -44,6 +44,6 @@ export function useAutomateOperation() {
endpoint: '/api/v1/pipeline/handleData', // Not used with customProcessor endpoint: '/api/v1/pipeline/handleData', // Not used with customProcessor
buildFormData: () => new FormData(), // Not used with customProcessor buildFormData: () => new FormData(), // Not used with customProcessor
customProcessor, customProcessor,
filePrefix: AUTOMATION_CONSTANTS.FILE_PREFIX filePrefix: '' // No prefix needed since automation handles naming internally
}); });
} }

View File

@ -14,6 +14,8 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
const { t } = useTranslation(); const { t } = useTranslation();
const [automationName, setAutomationName] = useState(''); const [automationName, setAutomationName] = useState('');
const [automationDescription, setAutomationDescription] = useState('');
const [automationIcon, setAutomationIcon] = useState<string>('');
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]); const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
const getToolName = useCallback((operation: string) => { const getToolName = useCallback((operation: string) => {
@ -33,6 +35,8 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
useEffect(() => { useEffect(() => {
if ((mode === AutomationMode.SUGGESTED || mode === AutomationMode.EDIT) && existingAutomation) { if ((mode === AutomationMode.SUGGESTED || mode === AutomationMode.EDIT) && existingAutomation) {
setAutomationName(existingAutomation.name || ''); setAutomationName(existingAutomation.name || '');
setAutomationDescription(existingAutomation.description || '');
setAutomationIcon(existingAutomation.icon || '');
const operations = existingAutomation.operations || []; const operations = existingAutomation.operations || [];
const tools = operations.map((op, index) => { const tools = operations.map((op, index) => {
@ -101,6 +105,10 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
return { return {
automationName, automationName,
setAutomationName, setAutomationName,
automationDescription,
setAutomationDescription,
automationIcon,
setAutomationIcon,
selectedTools, selectedTools,
setSelectedTools, setSelectedTools,
addTool, addTool,

View File

@ -45,10 +45,27 @@ export function useSavedAutomations() {
try { try {
const { automationStorage } = await import('../../../services/automationStorage'); const { automationStorage } = await import('../../../services/automationStorage');
// Map suggested automation icons to MUI icon keys
const getIconKey = (suggestedIcon: {id: string}): string => {
// Check the automation ID or name to determine the appropriate icon
switch (suggestedAutomation.id) {
case 'secure-pdf-ingestion':
case 'secure-workflow':
return 'SecurityIcon'; // Security icon for security workflows
case 'email-preparation':
return 'CompressIcon'; // Compression icon
case 'process-images':
return 'StarIcon'; // Star icon for process images
default:
return 'SettingsIcon'; // Default fallback
}
};
// Convert suggested automation to saved automation format // Convert suggested automation to saved automation format
const savedAutomation = { const savedAutomation = {
name: suggestedAutomation.name, name: suggestedAutomation.name,
description: suggestedAutomation.description, description: suggestedAutomation.description,
icon: getIconKey(suggestedAutomation.icon),
operations: suggestedAutomation.operations operations: suggestedAutomation.operations
}; };

View File

@ -117,7 +117,7 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
{ {
id: "secure-workflow", id: "secure-workflow",
name: t("automation.suggested.secureWorkflow", "Security Workflow"), name: t("automation.suggested.secureWorkflow", "Security Workflow"),
description: t("automation.suggested.secureWorkflowDesc", "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access."), description: t("automation.suggested.secureWorkflowDesc", "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access. Password is set to 'password' by default."),
operations: [ operations: [
{ {
operation: "sanitize", operation: "sanitize",

View File

@ -156,10 +156,16 @@ export const useToolOperation = <TParams = void>(
const response = await axios.post(endpoint, formData, { responseType: 'blob' }); const response = await axios.post(endpoint, formData, { responseType: 'blob' });
// Multi-file responses are typically ZIP files that need extraction // Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
if (config.responseHandler) { if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction) // Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, validFiles); processedFiles = await config.responseHandler(response.data, validFiles);
} else if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - use original filename
const originalFileName = validFiles[0]?.name || 'document.pdf';
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
processedFiles = [singleFile];
} else { } else {
// Default: assume ZIP response for multi-file endpoints // Default: assume ZIP response for multi-file endpoints
processedFiles = await extractZipFiles(response.data); processedFiles = await extractZipFiles(response.data);

View File

@ -107,9 +107,8 @@ export const mantineTheme = createTheme({
}, },
}, },
}, },
Textarea: {
TextInput: { styles: (theme: any) => ({
styles: {
input: { input: {
backgroundColor: 'var(--bg-surface)', backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)', borderColor: 'var(--border-default)',
@ -123,7 +122,43 @@ export const mantineTheme = createTheme({
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
fontWeight: 'var(--font-weight-medium)', fontWeight: 'var(--font-weight-medium)',
}, },
}, }),
},
TextInput: {
styles: (theme: any) => ({
input: {
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
color: 'var(--text-primary)',
'&:focus': {
borderColor: 'var(--color-primary-500)',
boxShadow: '0 0 0 1px var(--color-primary-500)',
},
},
label: {
color: 'var(--text-secondary)',
fontWeight: 'var(--font-weight-medium)',
},
}),
},
PasswordInput: {
styles: (theme: any) => ({
input: {
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
color: 'var(--text-primary)',
'&:focus': {
borderColor: 'var(--color-primary-500)',
boxShadow: '0 0 0 1px var(--color-primary-500)',
},
},
label: {
color: 'var(--text-secondary)',
fontWeight: 'var(--font-weight-medium)',
},
}),
}, },
Select: { Select: {

View File

@ -1,8 +1,9 @@
import React, { useState } from "react"; import React, { useState, useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useFileSelection } from "../contexts/FileContext"; import { useFileSelection } from "../contexts/FileContext";
import { useNavigation } from "../contexts/NavigationContext"; import { useNavigation } from "../contexts/NavigationContext";
import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { createFilesToolStep } from "../components/tools/shared/FilesToolStep"; import { createFilesToolStep } from "../components/tools/shared/FilesToolStep";
@ -21,6 +22,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { selectedFiles } = useFileSelection(); const { selectedFiles } = useFileSelection();
const { setMode } = useNavigation(); const { setMode } = useNavigation();
const { registerToolReset } = useToolWorkflow();
const [currentStep, setCurrentStep] = useState<AutomationStep>(AUTOMATION_STEPS.SELECTION); const [currentStep, setCurrentStep] = useState<AutomationStep>(AUTOMATION_STEPS.SELECTION);
const [stepData, setStepData] = useState<AutomationStepData>({ step: AUTOMATION_STEPS.SELECTION }); const [stepData, setStepData] = useState<AutomationStepData>({ step: AUTOMATION_STEPS.SELECTION });
@ -30,6 +32,28 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null; const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null;
const { savedAutomations, deleteAutomation, refreshAutomations, copyFromSuggested } = useSavedAutomations(); const { savedAutomations, deleteAutomation, refreshAutomations, copyFromSuggested } = useSavedAutomations();
// Use ref to store the latest reset function to avoid closure issues
const resetFunctionRef = React.useRef<() => void>(null);
// Update ref with latest reset function
resetFunctionRef.current = () => {
automateOperation.resetResults();
automateOperation.clearError();
setCurrentStep(AUTOMATION_STEPS.SELECTION);
setStepData({ step: AUTOMATION_STEPS.SELECTION });
};
// Register reset function with the tool workflow context - only once on mount
React.useEffect(() => {
const stableResetFunction = () => {
if (resetFunctionRef.current) {
resetFunctionRef.current();
}
};
registerToolReset('automate', stableResetFunction);
}, [registerToolReset]); // Only depend on registerToolReset which should be stable
const handleStepChange = (data: AutomationStepData) => { const handleStepChange = (data: AutomationStepData) => {
// If navigating away from run step, reset automation results // If navigating away from run step, reset automation results
if (currentStep === AUTOMATION_STEPS.RUN && data.step !== AUTOMATION_STEPS.RUN) { if (currentStep === AUTOMATION_STEPS.RUN && data.step !== AUTOMATION_STEPS.RUN) {
@ -87,6 +111,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
onError?.(`Failed to copy automation: ${suggestedAutomation.name}`); onError?.(`Failed to copy automation: ${suggestedAutomation.name}`);
} }
}} }}
toolRegistry={toolRegistry}
/> />
); );
@ -132,11 +157,25 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
content content
}); });
// Dynamic file placeholder based on supported types
const filesPlaceholder = useMemo(() => {
if (currentStep === AUTOMATION_STEPS.RUN && stepData.automation?.operations?.length) {
const firstOperation = stepData.automation.operations[0];
const toolConfig = toolRegistry[firstOperation.operation];
// Check if the tool has supportedFormats that include non-PDF formats
if (toolConfig?.supportedFormats && toolConfig.supportedFormats.length > 1) {
return t('automate.files.placeholder.multiFormat', 'Select files to process (supports various formats)');
}
}
return t('automate.files.placeholder', 'Select PDF files to process with this automation');
}, [currentStep, stepData.automation, toolRegistry, t]);
// Always create files step to avoid conditional hook calls // Always create files step to avoid conditional hook calls
const filesStep = createFilesToolStep(createStep, { const filesStep = createFilesToolStep(createStep, {
selectedFiles, selectedFiles,
isCollapsed: hasResults, isCollapsed: hasResults,
placeholder: t('automate.files.placeholder', 'Select files to process with this automation') placeholder: filesPlaceholder
}); });
const automationSteps = [ const automationSteps = [

View File

@ -23,9 +23,18 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(splitParams.getEndpointName()); const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(splitParams.getEndpointName());
useEffect(() => { useEffect(() => {
// Only reset results when parameters change, not when files change
splitOperation.resetResults(); splitOperation.resetResults();
onPreviewFile?.(null); onPreviewFile?.(null);
}, [splitParams.parameters, selectedFiles]); }, [splitParams.parameters]);
useEffect(() => {
// Reset results when selected files change (user selected different files)
if (selectedFiles.length > 0) {
splitOperation.resetResults();
onPreviewFile?.(null);
}
}, [selectedFiles]);
const handleSplit = async () => { const handleSplit = async () => {
try { try {
await splitOperation.executeOperation(splitParams.parameters, selectedFiles); await splitOperation.executeOperation(splitParams.parameters, selectedFiles);
@ -51,7 +60,7 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
}; };
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;
const hasResults = splitOperation.downloadUrl !== null; const hasResults = splitOperation.files.length > 0 || splitOperation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults; const settingsCollapsed = !hasFiles || hasResults;
return createToolFlow({ return createToolFlow({

View File

@ -11,6 +11,7 @@ export interface AutomationConfig {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
icon?: string;
operations: AutomationOperation[]; operations: AutomationOperation[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@ -14,6 +14,19 @@ export const executeToolOperation = async (
parameters: any, parameters: any,
files: File[], files: File[],
toolRegistry: ToolRegistry toolRegistry: ToolRegistry
): Promise<File[]> => {
return executeToolOperationWithPrefix(operationName, parameters, files, toolRegistry, AUTOMATION_CONSTANTS.FILE_PREFIX);
};
/**
* Execute a tool operation with custom prefix
*/
export const executeToolOperationWithPrefix = async (
operationName: string,
parameters: any,
files: File[],
toolRegistry: ToolRegistry,
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX
): Promise<File[]> => { ): Promise<File[]> => {
console.log(`🔧 Executing tool: ${operationName}`, { parameters, fileCount: files.length }); console.log(`🔧 Executing tool: ${operationName}`, { parameters, fileCount: files.length });
@ -51,15 +64,37 @@ export const executeToolOperation = async (
console.log(`📥 Response status: ${response.status}, size: ${response.data.size} bytes`); console.log(`📥 Response status: ${response.status}, size: ${response.data.size} bytes`);
// Multi-file responses are typically ZIP files, but may be single files // Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
const result = await AutomationFileProcessor.extractAutomationZipFiles(response.data); let result;
if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - use original filename
const originalFileName = files[0]?.name || 'document.pdf';
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
result = {
success: true,
files: [singleFile],
errors: []
};
} else {
// ZIP response
result = await AutomationFileProcessor.extractAutomationZipFiles(response.data);
}
if (result.errors.length > 0) { if (result.errors.length > 0) {
console.warn(`⚠️ File processing warnings:`, result.errors); console.warn(`⚠️ File processing warnings:`, result.errors);
} }
console.log(`📁 Processed ${result.files.length} files from response`); // Apply prefix to files, replacing any existing prefix
return result.files; const processedFiles = filePrefix
? result.files.map(file => {
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, '');
return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type });
})
: result.files;
console.log(`📁 Processed ${processedFiles.length} files from response`);
return processedFiles;
} else { } else {
// Single-file processing - separate API call per file // Single-file processing - separate API call per file
@ -83,11 +118,12 @@ export const executeToolOperation = async (
console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`); console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`);
// Create result file // Create result file with automation prefix
const resultFile = ResourceManager.createResultFile( const resultFile = ResourceManager.createResultFile(
response.data, response.data,
file.name, file.name,
AUTOMATION_CONSTANTS.FILE_PREFIX filePrefix
); );
resultFiles.push(resultFile); resultFiles.push(resultFile);
console.log(`✅ Created result file: ${resultFile.name}`); console.log(`✅ Created result file: ${resultFile.name}`);
@ -123,6 +159,7 @@ export const executeAutomationSequence = async (
} }
let currentFiles = [...initialFiles]; let currentFiles = [...initialFiles];
const automationPrefix = automation.name ? `${automation.name}_` : 'automated_';
for (let i = 0; i < automation.operations.length; i++) { for (let i = 0; i < automation.operations.length; i++) {
const operation = automation.operations[i]; const operation = automation.operations[i];
@ -134,11 +171,12 @@ export const executeAutomationSequence = async (
try { try {
onStepStart?.(i, operation.operation); onStepStart?.(i, operation.operation);
const resultFiles = await executeToolOperation( const resultFiles = await executeToolOperationWithPrefix(
operation.operation, operation.operation,
operation.parameters || {}, operation.parameters || {},
currentFiles, currentFiles,
toolRegistry toolRegistry,
i === automation.operations.length - 1 ? automationPrefix : '' // Only add prefix to final step
); );
console.log(`✅ Step ${i + 1} completed: ${resultFiles.length} result files`); console.log(`✅ Step ${i + 1} completed: ${resultFiles.length} result files`);

View File

@ -10,7 +10,15 @@
export const getFilenameFromHeaders = (contentDisposition: string = ''): string | null => { export const getFilenameFromHeaders = (contentDisposition: string = ''): string | null => {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match && match[1]) { if (match && match[1]) {
return match[1].replace(/['"]/g, ''); const filename = match[1].replace(/['"]/g, '');
// Decode URL-encoded characters (e.g., %20 -> space)
try {
return decodeURIComponent(filename);
} catch (error) {
// If decoding fails, return the original filename
return filename;
}
} }
return null; return null;
}; };