Merge branch 'V2' of github.com:Stirling-Tools/Stirling-PDF into UIUX/V2/StylingSnags

This commit is contained in:
EthanHealy01 2025-08-26 11:55:21 +01:00
commit 6d72878b4b
21 changed files with 619 additions and 259 deletions

View File

@ -48,7 +48,11 @@
"filesSelected": "{{count}} files selected", "filesSelected": "{{count}} files selected",
"files": { "files": {
"title": "Files", "title": "Files",
"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",
"addFiles": "Add files",
"noFiles": "No files uploaded. ",
"selectFromWorkbench": "Select files from the workbench or "
}, },
"noFavourites": "No favourites added", "noFavourites": "No favourites added",
"downloadComplete": "Download Complete", "downloadComplete": "Download Complete",
@ -2275,6 +2279,20 @@
"description": "Configure the settings for this tool. These settings will be applied when the automation runs.", "description": "Configure the settings for this tool. These settings will be applied when the automation runs.",
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save Configuration" "save": "Save Configuration"
} },
"copyToSaved": "Copy to Saved"
} }
},
"automation": {
"suggested": {
"securePdfIngestion": "Secure PDF Ingestion",
"securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitises documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimises file size.",
"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.",
"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.",
"processImages": "Process Images",
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}
}
} }

View File

@ -2106,5 +2106,20 @@
"results": { "results": {
"title": "Decrypted PDFs" "title": "Decrypted PDFs"
} }
},
"automation": {
"suggested": {
"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.",
"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.",
"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.",
"processImages": "Process Images",
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}
},
"automate": {
"copyToSaved": "Copy to Saved"
} }
} }

View File

@ -31,6 +31,8 @@ export interface TextInputProps {
readOnly?: boolean; readOnly?: boolean;
/** Accessibility label */ /** Accessibility label */
'aria-label'?: string; 'aria-label'?: string;
/** Focus event handler */
onFocus?: () => void;
} }
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
@ -46,6 +48,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
disabled = false, disabled = false,
readOnly = false, readOnly = false,
'aria-label': ariaLabel, 'aria-label': ariaLabel,
onFocus,
...props ...props
}, ref) => { }, ref) => {
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
@ -63,7 +66,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
return ( return (
<div className={`${styles.container} ${className}`} style={style}> <div className={`${styles.container} ${className}`} style={style}>
{icon && ( {icon && (
<span <span
className={styles.icon} className={styles.icon}
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }} style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
> >
@ -81,6 +84,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
disabled={disabled} disabled={disabled}
readOnly={readOnly} readOnly={readOnly}
aria-label={ariaLabel} aria-label={ariaLabel}
onFocus={onFocus}
style={{ style={{
backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF', backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF',
color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382', color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382',

View File

@ -8,6 +8,7 @@ import ToolRenderer from './ToolRenderer';
import ToolSearch from './toolPicker/ToolSearch'; import ToolSearch from './toolPicker/ToolSearch';
import { useSidebarContext } from "../../contexts/SidebarContext"; import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css'; import rainbowStyles from '../../styles/rainbow.module.css';
import { Stack, ScrollArea } from '@mantine/core';
// No props needed - component uses context // No props needed - component uses context
@ -91,15 +92,17 @@ export default function ToolPanel() {
</div> </div>
) : ( ) : (
// Selected Tool Content View // Selected Tool Content View
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col overflow-hidden">
{/* Tool content */} {/* Tool content */}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0 overflow-hidden">
{selectedToolKey && ( <ScrollArea h="100%">
<ToolRenderer {selectedToolKey && (
selectedToolKey={selectedToolKey} <ToolRenderer
onPreviewFile={setPreviewFile} selectedToolKey={selectedToolKey}
/> onPreviewFile={setPreviewFile}
)} />
)}
</ScrollArea>
</div> </div>
</div> </div>
)} )}

View File

@ -98,7 +98,7 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
const saveAutomation = async () => { const saveAutomation = async () => {
if (!canSaveAutomation()) return; if (!canSaveAutomation()) return;
const automation = { const automationData = {
name: automationName.trim(), name: automationName.trim(),
description: '', description: '',
operations: selectedTools.map(tool => ({ operations: selectedTools.map(tool => ({
@ -109,7 +109,30 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
try { try {
const { automationStorage } = await import('../../../services/automationStorage'); const { automationStorage } = await import('../../../services/automationStorage');
const savedAutomation = await automationStorage.saveAutomation(automation); let savedAutomation;
if (mode === AutomationMode.EDIT && existingAutomation) {
// For edit mode, check if name has changed
const nameChanged = automationName.trim() !== existingAutomation.name;
if (nameChanged) {
// Name changed - create new automation
savedAutomation = await automationStorage.saveAutomation(automationData);
} else {
// Name unchanged - update existing automation
const updatedAutomation = {
...existingAutomation,
...automationData,
id: existingAutomation.id,
createdAt: existingAutomation.createdAt
};
savedAutomation = await automationStorage.updateAutomation(updatedAutomation);
}
} else {
// Create mode - always create new automation
savedAutomation = await automationStorage.saveAutomation(automationData);
}
onComplete(savedAutomation); onComplete(savedAutomation);
} catch (error) { } catch (error) {
console.error('Error saving automation:', error); console.error('Error saving automation:', error);

View File

@ -4,10 +4,14 @@ import { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit'; 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 { Tooltip } from '../../shared/Tooltip';
interface AutomationEntryProps { interface AutomationEntryProps {
/** Optional title for the automation (usually for custom ones) */ /** Optional title for the automation (usually for custom ones) */
title?: string; title?: string;
/** Optional description for tooltip */
description?: string;
/** MUI Icon component for the badge */ /** MUI Icon component for the badge */
badgeIcon?: React.ComponentType<any>; badgeIcon?: React.ComponentType<any>;
/** Array of tool operation names in the workflow */ /** Array of tool operation names in the workflow */
@ -22,17 +26,21 @@ interface AutomationEntryProps {
onEdit?: () => void; onEdit?: () => void;
/** Delete handler */ /** Delete handler */
onDelete?: () => void; onDelete?: () => void;
/** Copy handler (for suggested automations) */
onCopy?: () => void;
} }
export default function AutomationEntry({ export default function AutomationEntry({
title, title,
description,
badgeIcon: BadgeIcon, badgeIcon: BadgeIcon,
operations, operations,
onClick, onClick,
keepIconColor = false, keepIconColor = false,
showMenu = false, showMenu = false,
onEdit, onEdit,
onDelete onDelete,
onCopy
}: AutomationEntryProps) { }: AutomationEntryProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@ -41,6 +49,47 @@ 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;
// Create tooltip content with description and tool chain
const createTooltipContent = () => {
if (!description) return null;
const toolChain = operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}>
<Text
component="span"
size="sm"
fw={600}
style={{
color: 'var(--mantine-primary-color-filled)',
background: 'var(--mantine-primary-color-light)',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '0.75rem',
whiteSpace: 'nowrap'
}}
>
{t(`${op}.title`, op)}
</Text>
{index < operations.length - 1 && (
<Text component="span" size="sm" mx={4}>
</Text>
)}
</React.Fragment>
));
return (
<div style={{ minWidth: '400px', width: 'auto' }}>
<Text size="sm" mb={8} style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}>
{description}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', whiteSpace: 'nowrap' }}>
{toolChain}
</div>
</div>
);
};
const renderContent = () => { const renderContent = () => {
if (title) { if (title) {
// Custom automation with title // Custom automation with title
@ -89,7 +138,7 @@ export default function AutomationEntry({
} }
}; };
return ( const boxContent = (
<Box <Box
style={{ style={{
backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent', backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent',
@ -132,6 +181,17 @@ export default function AutomationEntry({
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{onCopy && (
<Menu.Item
leftSection={<ContentCopyIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
>
{t('automate.copyToSaved', 'Copy to Saved')}
</Menu.Item>
)}
{onEdit && ( {onEdit && (
<Menu.Item <Menu.Item
leftSection={<EditIcon style={{ fontSize: 16 }} />} leftSection={<EditIcon style={{ fontSize: 16 }} />}
@ -160,4 +220,18 @@ export default function AutomationEntry({
</Group> </Group>
</Box> </Box>
); );
// Only show tooltip if description exists, otherwise return plain content
return description ? (
<Tooltip
content={createTooltipContent()}
position="right"
arrow={true}
delay={500}
>
{boxContent}
</Tooltip>
) : (
boxContent
);
} }

View File

@ -5,7 +5,7 @@ import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
import SettingsIcon from "@mui/icons-material/Settings"; 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 } from "../../../types/automation"; import { AutomationConfig, SuggestedAutomation } from "../../../types/automation";
interface AutomationSelectionProps { interface AutomationSelectionProps {
savedAutomations: AutomationConfig[]; savedAutomations: AutomationConfig[];
@ -13,6 +13,7 @@ interface AutomationSelectionProps {
onRun: (automation: AutomationConfig) => void; onRun: (automation: AutomationConfig) => void;
onEdit: (automation: AutomationConfig) => void; onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void; onDelete: (automation: AutomationConfig) => void;
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
} }
export default function AutomationSelection({ export default function AutomationSelection({
@ -20,7 +21,8 @@ export default function AutomationSelection({
onCreateNew, onCreateNew,
onRun, onRun,
onEdit, onEdit,
onDelete onDelete,
onCopyFromSuggested
}: AutomationSelectionProps) { }: AutomationSelectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const suggestedAutomations = useSuggestedAutomations(); const suggestedAutomations = useSuggestedAutomations();
@ -63,9 +65,13 @@ export default function AutomationSelection({
{suggestedAutomations.map((automation) => ( {suggestedAutomations.map((automation) => (
<AutomationEntry <AutomationEntry
key={automation.id} key={automation.id}
title={automation.name}
description={automation.description}
badgeIcon={automation.icon} badgeIcon={automation.icon}
operations={automation.operations.map(op => op.operation)} operations={automation.operations.map(op => op.operation)}
onClick={() => onRun(automation)} onClick={() => onRun(automation)}
showMenu={true}
onCopy={() => onCopyFromSuggested(automation)}
/> />
))} ))}
</Stack> </Stack>

View File

@ -1,14 +1,13 @@
import React from 'react'; import React from "react";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
import { Text, Stack, Group, ActionIcon } from '@mantine/core'; import { Text, Stack, Group, ActionIcon } from "@mantine/core";
import DeleteIcon from '@mui/icons-material/Delete'; import SettingsIcon from "@mui/icons-material/Settings";
import SettingsIcon from '@mui/icons-material/Settings'; import CloseIcon from "@mui/icons-material/Close";
import CloseIcon from '@mui/icons-material/Close'; import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
import AddCircleOutline from '@mui/icons-material/AddCircleOutline'; import { AutomationTool } from "../../../types/automation";
import { AutomationTool } from '../../../types/automation'; import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; import ToolSelector from "./ToolSelector";
import ToolSelector from './ToolSelector'; import AutomationEntry from "./AutomationEntry";
import AutomationEntry from './AutomationEntry';
interface ToolListProps { interface ToolListProps {
tools: AutomationTool[]; tools: AutomationTool[];
@ -29,35 +28,39 @@ export default function ToolList({
onToolConfigure, onToolConfigure,
onToolAdd, onToolAdd,
getToolName, getToolName,
getToolDefaultParameters getToolDefaultParameters,
}: ToolListProps) { }: ToolListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const handleToolSelect = (index: number, newOperation: string) => { const handleToolSelect = (index: number, newOperation: string) => {
const defaultParams = getToolDefaultParameters(newOperation); const defaultParams = getToolDefaultParameters(newOperation);
onToolUpdate(index, { onToolUpdate(index, {
operation: newOperation, operation: newOperation,
name: getToolName(newOperation), name: getToolName(newOperation),
configured: false, configured: false,
parameters: defaultParams parameters: defaultParams,
}); });
}; };
return ( return (
<div> <div>
<Text size="sm" fw={500} mb="xs" style={{ color: 'var(--mantine-color-text)' }}> <Text size="sm" fw={500} mb="xs" style={{ color: "var(--mantine-color-text)" }}>
{t('automate.creation.tools.selected', 'Selected Tools')} ({tools.length}) {t("automate.creation.tools.selected", "Selected Tools")} ({tools.length})
</Text> </Text>
<Stack gap="0"> <Stack gap="0">
{tools.map((tool, index) => ( {tools.map((tool, index) => (
<React.Fragment key={tool.id}> <React.Fragment key={tool.id}>
<div <div
style={{ style={{
border: '1px solid var(--mantine-color-gray-2)', border: "1px solid var(--mantine-color-gray-2)",
borderRadius: 'var(--mantine-radius-sm)', borderRadius: tool.operation && !tool.configured
position: 'relative', ? "var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0"
padding: 'var(--mantine-spacing-xs)' : "var(--mantine-radius-lg)",
backgroundColor: "var(--mantine-color-gray-2)",
position: "relative",
padding: "var(--mantine-spacing-xs)",
borderBottomWidth: tool.operation && !tool.configured ? "0" : "1px",
}} }}
> >
{/* Delete X in top right */} {/* Delete X in top right */}
@ -65,26 +68,26 @@ export default function ToolList({
variant="subtle" variant="subtle"
size="xs" size="xs"
onClick={() => onToolRemove(index)} onClick={() => onToolRemove(index)}
title={t('automate.creation.tools.remove', 'Remove tool')} title={t("automate.creation.tools.remove", "Remove tool")}
style={{ style={{
position: 'absolute', position: "absolute",
top: '4px', top: "4px",
right: '4px', right: "4px",
zIndex: 1, zIndex: 1,
color: 'var(--mantine-color-gray-6)' color: "var(--mantine-color-gray-6)",
}} }}
> >
<CloseIcon style={{ fontSize: 12 }} /> <CloseIcon style={{ fontSize: 16 }} />
</ActionIcon> </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 */}
<Group gap="xs" align="center" wrap="nowrap"> <Group gap="xs" align="center" wrap="nowrap">
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<ToolSelector <ToolSelector
key={`tool-selector-${tool.id}`} key={`tool-selector-${tool.id}`}
onSelect={(newOperation) => handleToolSelect(index, newOperation)} onSelect={(newOperation) => handleToolSelect(index, newOperation)}
excludeTools={['automate']} excludeTools={["automate"]}
toolRegistry={toolRegistry} toolRegistry={toolRegistry}
selectedValue={tool.operation} selectedValue={tool.operation}
placeholder={tool.name} placeholder={tool.name}
@ -97,26 +100,37 @@ export default function ToolList({
variant="subtle" variant="subtle"
size="sm" size="sm"
onClick={() => onToolConfigure(index)} onClick={() => onToolConfigure(index)}
title={t('automate.creation.tools.configure', 'Configure tool')} title={t("automate.creation.tools.configure", "Configure tool")}
style={{ color: 'var(--mantine-color-gray-6)' }} style={{ color: "var(--mantine-color-gray-6)" }}
> >
<SettingsIcon style={{ fontSize: 16 }} /> <SettingsIcon style={{ fontSize: 16 }} />
</ActionIcon> </ActionIcon>
)} )}
</Group> </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>
</div> </div>
{/* Configuration status underneath */}
{tool.operation && !tool.configured && (
<div
style={{
width: "100%",
border: "1px solid var(--mantine-color-gray-2)",
borderTop: "none",
borderRadius: "0 0 var(--mantine-radius-lg) var(--mantine-radius-lg)",
backgroundColor: "var(--active-bg)",
padding: "var(--mantine-spacing-xs)",
}}
>
<Text pl="md" size="xs" >
{t("automate.creation.tools.notConfigured", "! Not Configured")}
</Text>
</div>
)}
{index < tools.length - 1 && ( {index < tools.length - 1 && (
<div style={{ textAlign: 'center', padding: '8px 0' }}> <div style={{ textAlign: "center", padding: "8px 0" }}>
<Text size="xs" c="dimmed"></Text> <Text size="xs" c="dimmed">
</Text>
</div> </div>
)} )}
</React.Fragment> </React.Fragment>
@ -124,19 +138,23 @@ export default function ToolList({
{/* Arrow before Add Tool Button */} {/* Arrow before Add Tool Button */}
{tools.length > 0 && ( {tools.length > 0 && (
<div style={{ textAlign: 'center', padding: '8px 0' }}> <div style={{ textAlign: "center", padding: "8px 0" }}>
<Text size="xs" c="dimmed"></Text> <Text size="xs" c="dimmed">
</Text>
</div> </div>
)} )}
{/* Add Tool Button */} {/* Add Tool Button */}
<div style={{ <div
border: '1px solid var(--mantine-color-gray-2)', style={{
borderRadius: 'var(--mantine-radius-sm)', border: "1px solid var(--mantine-color-gray-2)",
overflow: 'hidden' borderRadius: "var(--mantine-radius-sm)",
}}> overflow: "hidden",
}}
>
<AutomationEntry <AutomationEntry
title={t('automate.creation.tools.addTool', 'Add Tool')} title={t("automate.creation.tools.addTool", "Add Tool")}
badgeIcon={AddCircleOutline} badgeIcon={AddCircleOutline}
operations={[]} operations={[]}
onClick={onToolAdd} onClick={onToolAdd}
@ -146,4 +164,4 @@ export default function ToolList({
</Stack> </Stack>
</div> </div>
); );
} }

View File

@ -1,10 +1,11 @@
import React, { useState, useMemo, useCallback } from 'react'; import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Menu, Stack, Text, ScrollArea } from '@mantine/core'; import { Stack, Text, ScrollArea } from '@mantine/core';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { useToolSections } from '../../../hooks/useToolSections'; import { useToolSections } from '../../../hooks/useToolSections';
import { renderToolButtons } from '../shared/renderToolButtons'; import { renderToolButtons } from '../shared/renderToolButtons';
import ToolSearch from '../toolPicker/ToolSearch'; import ToolSearch from '../toolPicker/ToolSearch';
import ToolButton from '../toolPicker/ToolButton';
interface ToolSelectorProps { interface ToolSelectorProps {
onSelect: (toolKey: string) => void; onSelect: (toolKey: string) => void;
@ -24,6 +25,8 @@ export default function ToolSelector({
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Filter out excluded tools (like 'automate' itself) // Filter out excluded tools (like 'automate' itself)
const baseFilteredTools = useMemo(() => { const baseFilteredTools = useMemo(() => {
@ -66,13 +69,21 @@ export default function ToolSelector({
} }
if (!sections || sections.length === 0) { if (!sections || sections.length === 0) {
// If no sections, create a simple group from filtered tools
if (baseFilteredTools.length > 0) {
return [{
name: 'All Tools',
subcategoryId: 'all' as any,
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool }))
}];
}
return []; return [];
} }
// Find the "all" section which contains all tools without duplicates // Find the "all" section which contains all tools without duplicates
const allSection = sections.find(s => (s as any).key === 'all'); const allSection = sections.find(s => (s as any).key === 'all');
return allSection?.subcategories || []; return allSection?.subcategories || [];
}, [isSearching, searchGroups, sections]); }, [isSearching, searchGroups, sections, baseFilteredTools]);
const handleToolSelect = useCallback((toolKey: string) => { const handleToolSelect = useCallback((toolKey: string) => {
onSelect(toolKey); onSelect(toolKey);
@ -88,8 +99,25 @@ export default function ToolSelector({
const handleSearchFocus = () => { const handleSearchFocus = () => {
setOpened(true); setOpened(true);
setShouldAutoFocus(true); // Request auto-focus for the input
}; };
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpened(false);
setSearchTerm('');
}
};
if (opened) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [opened]);
const handleSearchChange = (value: string) => { const handleSearchChange = (value: string) => {
setSearchTerm(value); setSearchTerm(value);
if (!opened) { if (!opened) {
@ -97,6 +125,14 @@ export default function ToolSelector({
} }
}; };
const handleInputFocus = () => {
if (!opened) {
setOpened(true);
}
// Clear auto-focus flag since input is now focused
setShouldAutoFocus(false);
};
// Get display value for selected tool // Get display value for selected tool
const getDisplayValue = () => { const getDisplayValue = () => {
if (selectedValue && toolRegistry[selectedValue]) { if (selectedValue && toolRegistry[selectedValue]) {
@ -106,77 +142,63 @@ export default function ToolSelector({
}; };
return ( return (
<div style={{ position: 'relative', width: '100%' }}> <div ref={containerRef} className='rounded-xl'>
<Menu {/* Always show the target - either selected tool or search input */}
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' }}> {selectedValue && toolRegistry[selectedValue] && !opened ? (
<ScrollArea h={350}> // Show selected tool in AutomationEntry style when tool is selected and dropdown closed
<Stack gap="sm" p="sm"> <div onClick={handleSearchFocus} style={{ cursor: 'pointer',
{displayGroups.length === 0 ? ( borderRadius: "var(--mantine-radius-lg)" }}>
<Text size="sm" c="dimmed" ta="center" p="md"> <ToolButton id='tool' tool={toolRegistry[selectedValue]} isSelected={false}
{isSearching onSelect={()=>{}} rounded={true}></ToolButton>
? t('tools.noSearchResults', 'No tools found') </div>
: t('tools.noTools', 'No tools available') ) : (
} // Show search input when no tool selected OR when dropdown is opened
</Text> <ToolSearch
) : ( value={searchTerm}
renderedTools onChange={handleSearchChange}
)} toolRegistry={filteredToolRegistry}
</Stack> mode="unstyled"
</ScrollArea> placeholder={getDisplayValue()}
</Menu.Dropdown> hideIcon={true}
</Menu> onFocus={handleInputFocus}
autoFocus={shouldAutoFocus}
/>
)}
{/* Custom dropdown */}
{opened && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: 'var(--mantine-radius-sm)',
boxShadow: 'var(--mantine-shadow-sm)',
marginTop: '4px',
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>
</div>
)}
</div> </div>
); );
} }

View File

@ -1,9 +1,11 @@
import React from "react"; import React, { useState, useEffect } from "react";
import { Text, Anchor } from "@mantine/core"; import { Text, Anchor } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FolderIcon from '@mui/icons-material/Folder'; import FolderIcon from '@mui/icons-material/Folder';
import UploadIcon from '@mui/icons-material/Upload';
import { useFilesModalContext } from "../../../contexts/FilesModalContext"; import { useFilesModalContext } from "../../../contexts/FilesModalContext";
import { useAllFiles } from "../../../contexts/FileContext"; import { useAllFiles } from "../../../contexts/FileContext";
import { useFileManager } from "../../../hooks/useFileManager";
export interface FileStatusIndicatorProps { export interface FileStatusIndicatorProps {
selectedFiles?: File[]; selectedFiles?: File[];
@ -14,40 +16,110 @@ const FileStatusIndicator = ({
selectedFiles = [], selectedFiles = [],
}: FileStatusIndicatorProps) => { }: FileStatusIndicatorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext(); const { openFilesModal, onFilesSelect } = useFilesModalContext();
const { files: workbenchFiles } = useAllFiles(); const { files: workbenchFiles } = useAllFiles();
const { loadRecentFiles } = useFileManager();
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
// Check if there are recent files
useEffect(() => {
const checkRecentFiles = async () => {
try {
const recentFiles = await loadRecentFiles();
setHasRecentFiles(recentFiles.length > 0);
} catch (error) {
setHasRecentFiles(false);
}
};
checkRecentFiles();
}, [loadRecentFiles]);
// Handle native file picker
const handleNativeUpload = () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = '.pdf,application/pdf';
input.onchange = (event) => {
const files = Array.from((event.target as HTMLInputElement).files || []);
if (files.length > 0) {
onFilesSelect(files);
}
};
input.click();
};
// Don't render until we know if there are recent files
if (hasRecentFiles === null) {
return null;
}
// Check if there are no files in the workbench // Check if there are no files in the workbench
if (workbenchFiles.length === 0) { if (workbenchFiles.length === 0) {
return ( // If no recent files, show upload button
<Text size="sm" c="dimmed"> if (!hasRecentFiles) {
<Anchor return (
size="sm" <Text size="sm" c="dimmed">
onClick={openFilesModal} <Anchor
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }} size="sm"
> onClick={handleNativeUpload}
<FolderIcon style={{ fontSize: '14px' }} /> style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
{t("files.addFiles", "Add files")} >
</Anchor> <UploadIcon style={{ fontSize: '0.875rem' }} />
</Text> {t("files.upload", "Upload")}
); </Anchor>
</Text>
);
} else {
// If there are recent files, show add files button
return (
<Text size="sm" c="dimmed">
<Anchor
size="sm"
onClick={openFilesModal}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
>
<FolderIcon style={{ fontSize: '0.875rem' }} />
{t("files.addFiles", "Add files")}
</Anchor>
</Text>
);
}
} }
// Show selection status when there are files in workbench // Show selection status when there are files in workbench
if (selectedFiles.length === 0) { if (selectedFiles.length === 0) {
return ( // If no recent files, show upload option
<Text size="sm" c="dimmed"> if (!hasRecentFiles) {
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "} return (
<Anchor <Text size="sm" c="dimmed">
size="sm" {t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
onClick={openFilesModal} <Anchor
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }} size="sm"
> onClick={handleNativeUpload}
<FolderIcon style={{ fontSize: '14px' }} /> style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
{t("files.addFiles", "Add files")} >
</Anchor> <UploadIcon style={{ fontSize: '0.875rem' }} />
</Text> {t("files.upload", "Upload")}
); </Anchor>
</Text>
);
} else {
// If there are recent files, show add files option
return (
<Text size="sm" c="dimmed">
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
<Anchor
size="sm"
onClick={openFilesModal}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
>
<FolderIcon style={{ fontSize: '0.875rem' }} />
{t("files.addFiles", "Add files")}
</Anchor>
</Text>
);
}
} }
return ( return (

View File

@ -65,7 +65,8 @@ export function createToolFlow(config: ToolFlowConfig) {
const steps = createToolSteps(); const steps = createToolSteps();
return ( return (
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}> <Stack gap="sm" p="sm" >
{/* <Stack gap="sm" p="sm" h="100%" w="100%" style={{ overflow: 'auto' }}> */}
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}> <ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
{config.title && <ToolWorkflowTitle {...config.title} />} {config.title && <ToolWorkflowTitle {...config.title} />}

View File

@ -9,6 +9,7 @@ interface ToolButtonProps {
tool: ToolRegistryEntry; tool: ToolRegistryEntry;
isSelected: boolean; isSelected: boolean;
onSelect: (id: string) => void; onSelect: (id: string) => void;
rounded?: boolean;
} }
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => { const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {

View File

@ -76,4 +76,4 @@
.search-input-container { .search-input-container {
margin-top: 0.5rem; margin-top: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }

View File

@ -4,18 +4,19 @@ import { useTranslation } from "react-i18next";
import LocalIcon from '../../shared/LocalIcon'; import LocalIcon from '../../shared/LocalIcon';
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy"; import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { TextInput } from "../../shared/TextInput"; import { TextInput } from "../../shared/TextInput";
import './ToolPicker.css'; import "./ToolPicker.css";
interface ToolSearchProps { interface ToolSearchProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
toolRegistry: Readonly<Record<string, ToolRegistryEntry>>; toolRegistry: Readonly<Record<string, ToolRegistryEntry>>;
onToolSelect?: (toolId: string) => void; onToolSelect?: (toolId: string) => void;
mode: 'filter' | 'dropdown'; mode: "filter" | "dropdown" | "unstyled";
selectedToolKey?: string | null; selectedToolKey?: string | null;
placeholder?: string; placeholder?: string;
hideIcon?: boolean; hideIcon?: boolean;
onFocus?: () => void; onFocus?: () => void;
autoFocus?: boolean;
} }
const ToolSearch = ({ const ToolSearch = ({
@ -23,11 +24,12 @@ const ToolSearch = ({
onChange, onChange,
toolRegistry, toolRegistry,
onToolSelect, onToolSelect,
mode = 'filter', mode = "filter",
selectedToolKey, selectedToolKey,
placeholder, placeholder,
hideIcon = false, hideIcon = false,
onFocus onFocus,
autoFocus = false,
}: ToolSearchProps) => { }: ToolSearchProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
@ -38,9 +40,10 @@ const ToolSearch = ({
if (!value.trim()) return []; if (!value.trim()) return [];
return Object.entries(toolRegistry) return Object.entries(toolRegistry)
.filter(([id, tool]) => { .filter(([id, tool]) => {
if (mode === 'dropdown' && id === selectedToolKey) return false; if (mode === "dropdown" && id === selectedToolKey) return false;
return tool.name.toLowerCase().includes(value.toLowerCase()) || return (
tool.description.toLowerCase().includes(value.toLowerCase()); tool.name.toLowerCase().includes(value.toLowerCase()) || tool.description.toLowerCase().includes(value.toLowerCase())
);
}) })
.slice(0, 6) .slice(0, 6)
.map(([id, tool]) => ({ id, tool })); .map(([id, tool]) => ({ id, tool }));
@ -48,7 +51,7 @@ const ToolSearch = ({
const handleSearchChange = (searchValue: string) => { const handleSearchChange = (searchValue: string) => {
onChange(searchValue); onChange(searchValue);
if (mode === 'dropdown') { if (mode === "dropdown") {
setDropdownOpen(searchValue.trim().length > 0 && filteredTools.length > 0); setDropdownOpen(searchValue.trim().length > 0 && filteredTools.length > 0);
} }
}; };
@ -64,12 +67,20 @@ const ToolSearch = ({
setDropdownOpen(false); setDropdownOpen(false);
} }
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, []); }, []);
// Auto-focus the input when requested
useEffect(() => {
if (autoFocus && searchRef.current) {
setTimeout(() => {
searchRef.current?.focus();
}, 10);
}
}, [autoFocus]);
const searchInput = ( const searchInput = (
<div className="search-input-container">
<TextInput <TextInput
ref={searchRef} ref={searchRef}
value={value} value={value}
@ -77,36 +88,39 @@ const ToolSearch = ({
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")} placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />} icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />}
autoComplete="off" autoComplete="off"
onFocus={onFocus}
/> />
</div>
); );
if (mode === 'filter') { if (mode === "filter") {
return <div className="search-input-container">{searchInput}</div>;
}
if (mode === "unstyled") {
return searchInput; return searchInput;
} }
return ( return (
<div ref={searchRef} style={{ position: 'relative' }}> <div ref={searchRef} style={{ position: "relative" }}>
{searchInput} {searchInput}
{dropdownOpen && filteredTools.length > 0 && ( {dropdownOpen && filteredTools.length > 0 && (
<div <div
ref={dropdownRef} ref={dropdownRef}
style={{ style={{
position: 'absolute', position: "absolute",
top: '100%', top: "100%",
left: 0, left: 0,
right: 0, right: 0,
zIndex: 1000, zIndex: 1000,
backgroundColor: 'var(--mantine-color-body)', backgroundColor: "var(--mantine-color-body)",
border: '1px solid var(--mantine-color-gray-3)', border: "1px solid var(--mantine-color-gray-3)",
borderRadius: '6px', borderRadius: "6px",
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)', boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
maxHeight: '300px', maxHeight: "300px",
overflowY: 'auto' overflowY: "auto",
}} }}
> >
<Stack gap="xs" style={{ padding: '8px' }}> <Stack gap="xs" style={{ padding: "8px" }}>
{filteredTools.map(({ id, tool }) => ( {filteredTools.map(({ id, tool }) => (
<Button <Button
key={id} key={id}
@ -115,22 +129,18 @@ const ToolSearch = ({
onToolSelect && onToolSelect(id); onToolSelect && onToolSelect(id);
setDropdownOpen(false); setDropdownOpen(false);
}} }}
leftSection={ leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
<div style={{ color: 'var(--tools-text-and-icon-color)' }}>
{tool.icon}
</div>
}
fullWidth fullWidth
justify="flex-start" justify="flex-start"
style={{ style={{
borderRadius: '6px', borderRadius: "6px",
color: 'var(--tools-text-and-icon-color)', color: "var(--tools-text-and-icon-color)",
padding: '8px 12px' padding: "8px 12px",
}} }}
> >
<div style={{ textAlign: 'left' }}> <div style={{ textAlign: "left" }}>
<div style={{ fontWeight: 500 }}>{tool.name}</div> <div style={{ fontWeight: 500 }}>{tool.name}</div>
<Text size="xs" c="dimmed" style={{ marginTop: '2px' }}> <Text size="xs" c="dimmed" style={{ marginTop: "2px" }}>
{tool.description} {tool.description}
</Text> </Text>
</div> </div>

View File

@ -134,7 +134,10 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const setPreviewFile = useCallback((file: File | null) => { const setPreviewFile = useCallback((file: File | null) => {
dispatch({ type: 'SET_PREVIEW_FILE', payload: file }); dispatch({ type: 'SET_PREVIEW_FILE', payload: file });
}, []); if (file) {
actions.setMode('viewer');
}
}, [actions]);
const setPageEditorFunctions = useCallback((functions: PageEditorFunctions | null) => { const setPageEditorFunctions = useCallback((functions: PageEditorFunctions | null) => {
dispatch({ type: 'SET_PAGE_EDITOR_FUNCTIONS', payload: functions }); dispatch({ type: 'SET_PAGE_EDITOR_FUNCTIONS', payload: functions });

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation'; import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation';
import { AUTOMATION_CONSTANTS } from '../../../constants/automation'; import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
@ -16,18 +16,18 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
const [automationName, setAutomationName] = useState(''); const [automationName, setAutomationName] = useState('');
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]); const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
const getToolName = (operation: string) => { const getToolName = useCallback((operation: string) => {
const tool = toolRegistry?.[operation] as any; const tool = toolRegistry?.[operation] as any;
return tool?.name || t(`tools.${operation}.name`, operation); return tool?.name || t(`tools.${operation}.name`, operation);
}; }, [toolRegistry, t]);
const getToolDefaultParameters = (operation: string): Record<string, any> => { const getToolDefaultParameters = useCallback((operation: string): Record<string, any> => {
const config = toolRegistry[operation]?.operationConfig; const config = toolRegistry[operation]?.operationConfig;
if (config?.defaultParameters) { if (config?.defaultParameters) {
return { ...config.defaultParameters }; return { ...config.defaultParameters };
} }
return {}; return {};
}; }, [toolRegistry]);
// Initialize based on mode and existing automation // Initialize based on mode and existing automation
useEffect(() => { useEffect(() => {
@ -58,7 +58,7 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
})); }));
setSelectedTools(defaultTools); setSelectedTools(defaultTools);
} }
}, [mode, existingAutomation, selectedTools.length, t, getToolName]); }, [mode, existingAutomation, t, getToolName]);
const addTool = (operation: string) => { const addTool = (operation: string) => {
const newTool: AutomationTool = { const newTool: AutomationTool = {

View File

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { AutomationConfig } from '../../../services/automationStorage'; import { AutomationConfig } from '../../../services/automationStorage';
import { SuggestedAutomation } from '../../../types/automation';
export interface SavedAutomation extends AutomationConfig {} export interface SavedAutomation extends AutomationConfig {}
@ -40,6 +41,26 @@ export function useSavedAutomations() {
} }
}, [refreshAutomations]); }, [refreshAutomations]);
const copyFromSuggested = useCallback(async (suggestedAutomation: SuggestedAutomation) => {
try {
const { automationStorage } = await import('../../../services/automationStorage');
// Convert suggested automation to saved automation format
const savedAutomation = {
name: suggestedAutomation.name,
description: suggestedAutomation.description,
operations: suggestedAutomation.operations
};
await automationStorage.saveAutomation(savedAutomation);
// Refresh the list after saving
refreshAutomations();
} catch (err) {
console.error('Error copying suggested automation:', err);
throw err;
}
}, [refreshAutomations]);
// Load automations on mount // Load automations on mount
useEffect(() => { useEffect(() => {
loadSavedAutomations(); loadSavedAutomations();
@ -50,6 +71,7 @@ export function useSavedAutomations() {
loading, loading,
error, error,
refreshAutomations, refreshAutomations,
deleteAutomation deleteAutomation,
copyFromSuggested
}; };
} }

View File

@ -17,9 +17,60 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
const now = new Date().toISOString(); const now = new Date().toISOString();
return [ return [
{ {
id: "compress-and-split", id: "secure-pdf-ingestion",
name: t("automation.suggested.compressAndSplit", "Compress & Split"), name: t("automation.suggested.securePdfIngestion", "Secure PDF Ingestion"),
description: t("automation.suggested.compressAndSplitDesc", "Compress PDFs and split them by pages"), description: t("automation.suggested.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."),
operations: [
{
operation: "sanitize",
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: true,
removeMetadata: true,
removeLinks: false,
removeFonts: false,
}
},
{
operation: "ocr",
parameters: {
languages: ['eng'],
ocrType: 'skip-text',
ocrRenderType: 'hocr',
additionalOptions: ['clean', 'cleanFinal'],
}
},
{
operation: "convert",
parameters: {
fromExtension: 'pdf',
toExtension: 'pdfa',
pdfaOptions: {
outputFormat: 'pdfa-1',
}
}
},
{
operation: "compress",
parameters: {
compressionLevel: 5,
grayscale: false,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
fileSizeUnit: 'MB',
}
}
],
createdAt: now,
updatedAt: now,
icon: SecurityIcon,
},
{
id: "email-preparation",
name: t("automation.suggested.emailPreparation", "Email Preparation"),
description: t("automation.suggested.emailPreparationDesc", "Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy."),
operations: [ operations: [
{ {
operation: "compress", operation: "compress",
@ -36,45 +87,37 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
operation: "splitPdf", operation: "splitPdf",
parameters: { parameters: {
mode: 'bySizeOrCount', mode: 'bySizeOrCount',
pages: '1', pages: '',
hDiv: '2', hDiv: '1',
vDiv: '2', vDiv: '1',
merge: false, merge: false,
splitType: 'pages', splitType: 'size',
splitValue: '1', splitValue: '20MB',
bookmarkLevel: '1', bookmarkLevel: '1',
includeMetadata: false, includeMetadata: false,
allowDuplicates: false, allowDuplicates: false,
} }
},
{
operation: "sanitize",
parameters: {
removeJavaScript: false,
removeEmbeddedFiles: false,
removeXMPMetadata: true,
removeMetadata: true,
removeLinks: false,
removeFonts: false,
}
} }
], ],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
icon: CompressIcon, icon: CompressIcon,
}, },
{
id: "ocr-workflow",
name: t("automation.suggested.ocrWorkflow", "OCR Processing"),
description: t("automation.suggested.ocrWorkflowDesc", "Extract text from PDFs using OCR technology"),
operations: [
{
operation: "ocr",
parameters: {
languages: ['eng'],
ocrType: 'skip-text',
ocrRenderType: 'hocr',
additionalOptions: [],
}
}
],
createdAt: now,
updatedAt: now,
icon: TextFieldsIcon,
},
{ {
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", "Sanitize PDFs and add password protection"), 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."),
operations: [ operations: [
{ {
operation: "sanitize", operation: "sanitize",
@ -111,23 +154,32 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
icon: SecurityIcon, icon: SecurityIcon,
}, },
{ {
id: "optimization-workflow", id: "process-images",
name: t("automation.suggested.optimizationWorkflow", "Optimization Workflow"), name: t("automation.suggested.processImages", "Process Images"),
description: t("automation.suggested.optimizationWorkflowDesc", "Repair and compress PDFs for better performance"), description: t("automation.suggested.processImagesDesc", "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."),
operations: [ operations: [
{ {
operation: "repair", operation: "convert",
parameters: {} parameters: {
fromExtension: 'image',
toExtension: 'pdf',
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true,
}
}
}, },
{ {
operation: "compress", operation: "ocr",
parameters: { parameters: {
compressionLevel: 7, languages: ['eng'],
grayscale: false, ocrType: 'skip-text',
expectedSize: '', ocrRenderType: 'hocr',
compressionMethod: 'quality', additionalOptions: [],
fileSizeValue: '',
fileSizeUnit: 'MB',
} }
} }
], ],

View File

@ -2,6 +2,7 @@ import React, { useState } 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 { 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";
@ -13,20 +14,21 @@ import { useAutomateOperation } from "../hooks/tools/automate/useAutomateOperati
import { BaseToolProps } from "../types/tool"; import { BaseToolProps } from "../types/tool";
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry"; import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
import { useSavedAutomations } from "../hooks/tools/automate/useSavedAutomations"; import { useSavedAutomations } from "../hooks/tools/automate/useSavedAutomations";
import { AutomationConfig, AutomationStepData, AutomationMode } from "../types/automation"; import { AutomationConfig, AutomationStepData, AutomationMode, AutomationStep } from "../types/automation";
import { AUTOMATION_STEPS } from "../constants/automation"; import { AUTOMATION_STEPS } from "../constants/automation";
const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { selectedFiles } = useFileSelection(); const { selectedFiles } = useFileSelection();
const { setMode } = useNavigation();
const [currentStep, setCurrentStep] = useState<'selection' | 'creation' | 'run'>(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 });
const automateOperation = useAutomateOperation(); const automateOperation = useAutomateOperation();
const toolRegistry = useFlatToolRegistry(); const toolRegistry = useFlatToolRegistry();
const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null; const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null;
const { savedAutomations, deleteAutomation, refreshAutomations } = useSavedAutomations(); const { savedAutomations, deleteAutomation, refreshAutomations, copyFromSuggested } = useSavedAutomations();
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
@ -62,7 +64,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const renderCurrentStep = () => { const renderCurrentStep = () => {
switch (currentStep) { switch (currentStep) {
case 'selection': case AUTOMATION_STEPS.SELECTION:
return ( return (
<AutomationSelection <AutomationSelection
savedAutomations={savedAutomations} savedAutomations={savedAutomations}
@ -77,10 +79,18 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
onError?.(`Failed to delete automation: ${automation.name}`); onError?.(`Failed to delete automation: ${automation.name}`);
} }
}} }}
onCopyFromSuggested={async (suggestedAutomation) => {
try {
await copyFromSuggested(suggestedAutomation);
} catch (error) {
console.error('Failed to copy suggested automation:', error);
onError?.(`Failed to copy automation: ${suggestedAutomation.name}`);
}
}}
/> />
); );
case 'creation': case AUTOMATION_STEPS.CREATION:
if (!stepData.mode) { if (!stepData.mode) {
console.error('Creation mode is undefined'); console.error('Creation mode is undefined');
return null; return null;
@ -98,7 +108,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
/> />
); );
case 'run': case AUTOMATION_STEPS.RUN:
if (!stepData.automation) { if (!stepData.automation) {
console.error('Automation config is undefined'); console.error('Automation config is undefined');
return null; return null;
@ -171,7 +181,11 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
review: { review: {
isVisible: hasResults && currentStep === AUTOMATION_STEPS.RUN, isVisible: hasResults && currentStep === AUTOMATION_STEPS.RUN,
operation: automateOperation, operation: automateOperation,
title: t('automate.reviewTitle', 'Automation Results') title: t('automate.reviewTitle', 'Automation Results'),
onFileClick: (file: File) => {
onPreviewFile?.(file);
setMode('viewer');
}
} }
}); });
}; };

View File

@ -24,8 +24,10 @@ export interface AutomationTool {
parameters?: Record<string, any>; parameters?: Record<string, any>;
} }
export type AutomationStep = typeof import('../constants/automation').AUTOMATION_STEPS[keyof typeof import('../constants/automation').AUTOMATION_STEPS];
export interface AutomationStepData { export interface AutomationStepData {
step: 'selection' | 'creation' | 'run'; step: AutomationStep;
mode?: AutomationMode; mode?: AutomationMode;
automation?: AutomationConfig; automation?: AutomationConfig;
} }

View File

@ -33,7 +33,7 @@ export const isValidMode = (mode: string): mode is ModeType => {
return validModes.includes(mode as ModeType); return validModes.includes(mode as ModeType);
}; };
export const getDefaultMode = (): ModeType => 'pageEditor'; export const getDefaultMode = (): ModeType => 'fileEditor';
// Route parsing result // Route parsing result
export interface ToolRoute { export interface ToolRoute {