mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Enhance automate to have import and export buttons (#6326)
This commit is contained in:
@@ -23,7 +23,10 @@ import {
|
||||
AutomationTool,
|
||||
} from "@app/types/automation";
|
||||
import { useAutomationForm } from "@app/hooks/tools/automate/useAutomationForm";
|
||||
import { downloadFolderScanningConfig } from "@app/utils/automationConverter";
|
||||
import {
|
||||
downloadAutomationConfig,
|
||||
downloadFolderScanningConfig,
|
||||
} from "@app/utils/automationConverter";
|
||||
|
||||
interface AutomationCreationProps {
|
||||
mode: AutomationMode;
|
||||
@@ -154,6 +157,24 @@ export default function AutomationCreation({
|
||||
const currentConfigTool =
|
||||
configuraingToolIndex >= 0 ? selectedTools[configuraingToolIndex] : null;
|
||||
|
||||
/**
|
||||
* Build a temporary AutomationConfig from in-progress form state so the
|
||||
* export buttons can run without requiring the user to save first. The
|
||||
* `id` and timestamps are placeholders — the export utilities strip them.
|
||||
*/
|
||||
const buildExportableAutomation = (): AutomationConfig => ({
|
||||
id: existingAutomation?.id || "temp",
|
||||
name: automationName.trim(),
|
||||
description: automationDescription.trim(),
|
||||
icon: automationIcon,
|
||||
operations: selectedTools.map((tool) => ({
|
||||
operation: tool.operation,
|
||||
parameters: tool.parameters || {},
|
||||
})),
|
||||
createdAt: existingAutomation?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text
|
||||
@@ -237,34 +258,34 @@ export default function AutomationCreation({
|
||||
{t("automate.creation.save", "Save Automation")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
leftSection={<DownloadIcon />}
|
||||
onClick={() => {
|
||||
// Create a temporary automation config from current state
|
||||
const tempAutomation: AutomationConfig = {
|
||||
id: existingAutomation?.id || "temp",
|
||||
name: automationName.trim(),
|
||||
description: automationDescription.trim(),
|
||||
icon: automationIcon,
|
||||
operations: selectedTools.map((tool) => ({
|
||||
operation: tool.operation,
|
||||
parameters: tool.parameters || {},
|
||||
})),
|
||||
createdAt:
|
||||
existingAutomation?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
downloadFolderScanningConfig(tempAutomation, toolRegistry);
|
||||
}}
|
||||
disabled={!canSaveAutomation()}
|
||||
variant="light"
|
||||
fullWidth
|
||||
>
|
||||
{t(
|
||||
"automate.creation.exportForFolderScanning",
|
||||
"Export for Folder Scanning",
|
||||
)}
|
||||
</Button>
|
||||
<Group gap="sm" grow>
|
||||
<Button
|
||||
leftSection={<DownloadIcon />}
|
||||
onClick={() => {
|
||||
downloadAutomationConfig(buildExportableAutomation());
|
||||
}}
|
||||
disabled={!canSaveAutomation()}
|
||||
variant="light"
|
||||
>
|
||||
{t("automate.creation.export", "Export")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<DownloadIcon />}
|
||||
onClick={() => {
|
||||
downloadFolderScanningConfig(
|
||||
buildExportableAutomation(),
|
||||
toolRegistry,
|
||||
);
|
||||
}}
|
||||
disabled={!canSaveAutomation()}
|
||||
variant="light"
|
||||
>
|
||||
{t(
|
||||
"automate.creation.exportForFolderScanning",
|
||||
"Export for Folder Scanning",
|
||||
)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import EditIcon from "@mui/icons-material/Edit";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { ToolIcon } from "@app/components/shared/ToolIcon";
|
||||
import { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
@@ -32,8 +33,12 @@ interface AutomationEntryProps {
|
||||
onDelete?: () => void;
|
||||
/** Copy handler (for suggested automations) */
|
||||
onCopy?: () => void;
|
||||
/** Export handler (for folder scanning) */
|
||||
onExport?: () => void;
|
||||
/** Export handler — folder scanning JSON */
|
||||
onExportFolderScan?: () => void;
|
||||
/** Export handler — native Automate JSON (renders as just "Export") */
|
||||
onExportAutomation?: () => void;
|
||||
/** Import handler — opens the import modal (used on "Create New" entry) */
|
||||
onImport?: () => void;
|
||||
/** Tool registry to resolve operation names */
|
||||
toolRegistry?: Partial<ToolRegistry>;
|
||||
}
|
||||
@@ -49,7 +54,9 @@ export default function AutomationEntry({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCopy,
|
||||
onExport,
|
||||
onExportFolderScan,
|
||||
onExportAutomation,
|
||||
onImport,
|
||||
toolRegistry,
|
||||
}: AutomationEntryProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -211,6 +218,11 @@ export default function AutomationEntry({
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
size="md"
|
||||
aria-label={t(
|
||||
"automate.entryMenu.label",
|
||||
"Open menu for {{title}}",
|
||||
{ title: title || operations.join(" → ") },
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -228,6 +240,17 @@ export default function AutomationEntry({
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{onImport && (
|
||||
<Menu.Item
|
||||
leftSection={<UploadFileIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImport();
|
||||
}}
|
||||
>
|
||||
{t("automate.import", "Import")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onCopy && (
|
||||
<Menu.Item
|
||||
leftSection={<ContentCopyIcon style={{ fontSize: 16 }} />}
|
||||
@@ -250,12 +273,23 @@ export default function AutomationEntry({
|
||||
{t("edit", "Edit")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onExport && (
|
||||
{onExportAutomation && (
|
||||
<Menu.Item
|
||||
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExport();
|
||||
onExportAutomation();
|
||||
}}
|
||||
>
|
||||
{t("automate.export", "Export")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onExportFolderScan && (
|
||||
<Menu.Item
|
||||
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExportFolderScan();
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import { Z_INDEX_AUTOMATE_MODAL } from "@app/styles/zIndex";
|
||||
import {
|
||||
ParsedAutomationImport,
|
||||
parseAutomationFile,
|
||||
} from "@app/utils/automationConverter";
|
||||
import { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { ImportableAutomation } from "@app/hooks/tools/automate/useSavedAutomations";
|
||||
|
||||
interface AutomationImportModalProps {
|
||||
opened: boolean;
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
onCancel: () => void;
|
||||
onImport: (
|
||||
automation: ImportableAutomation,
|
||||
meta: { format: ParsedAutomationImport["format"]; unresolved: string[] },
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single import surface for both supported automation JSON shapes.
|
||||
*
|
||||
* Accepts a file drop or pasted text, auto-detects whether the JSON is the
|
||||
* native Automate config or the backend folder-scanning config, and shows
|
||||
* the resolved name + format before the user commits the import.
|
||||
*/
|
||||
export default function AutomationImportModal({
|
||||
opened,
|
||||
toolRegistry,
|
||||
onCancel,
|
||||
onImport,
|
||||
}: AutomationImportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [pastedText, setPastedText] = useState("");
|
||||
const [parsed, setParsed] = useState<ParsedAutomationImport | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Reset modal state every time it's reopened so a previous import doesn't
|
||||
// leak into the next one.
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setPastedText("");
|
||||
setParsed(null);
|
||||
setParseError(null);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Re-parse whenever the textarea changes — gives the user immediate feedback
|
||||
// without requiring a click.
|
||||
useEffect(() => {
|
||||
const trimmed = pastedText.trim();
|
||||
if (!trimmed) {
|
||||
setParsed(null);
|
||||
setParseError(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = parseAutomationFile(trimmed, toolRegistry);
|
||||
setParsed(result);
|
||||
setParseError(null);
|
||||
} catch (err) {
|
||||
setParsed(null);
|
||||
setParseError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [pastedText, toolRegistry]);
|
||||
|
||||
const handleFileDrop = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
setPastedText(text);
|
||||
} catch (err) {
|
||||
setParseError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!parsed) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onImport(parsed.automation, {
|
||||
format: parsed.format,
|
||||
unresolved: parsed.unresolvedOperations,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatLabel =
|
||||
parsed?.format === "automate"
|
||||
? t("automate.importModal.detectedAutomation", "Automate JSON")
|
||||
: parsed?.format === "folderScanning"
|
||||
? t("automate.importModal.detectedFolderScan", "Folder Scanning JSON")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
title={t("automate.importModal.title", "Import automation")}
|
||||
centered
|
||||
size="lg"
|
||||
zIndex={Z_INDEX_AUTOMATE_MODAL}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"automate.importModal.intro",
|
||||
"Drop a JSON file or paste its contents below. The format (Automate or Folder Scanning) is detected automatically.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Dropzone
|
||||
onDrop={(files) => void handleFileDrop(files)}
|
||||
accept={["application/json", "text/plain"]}
|
||||
multiple={false}
|
||||
maxSize={10 * 1024 * 1024}
|
||||
aria-label={t(
|
||||
"automate.importModal.dropzoneAriaLabel",
|
||||
"Drop an automation JSON file here",
|
||||
)}
|
||||
>
|
||||
<Group
|
||||
gap="md"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
mih={80}
|
||||
justify="center"
|
||||
>
|
||||
<UploadFileIcon style={{ fontSize: 32, opacity: 0.6 }} />
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{t(
|
||||
"automate.importModal.dropHint",
|
||||
"Drop JSON here or click to choose a file",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"automate.importModal.dropSubhint",
|
||||
"Both Automate and Folder Scanning configs are accepted",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
<Textarea
|
||||
label={t("automate.importModal.pasteLabel", "Or paste JSON")}
|
||||
placeholder={t(
|
||||
"automate.importModal.pastePlaceholder",
|
||||
"Paste your automation JSON here…",
|
||||
)}
|
||||
value={pastedText}
|
||||
onChange={(e) => setPastedText(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={6}
|
||||
maxRows={12}
|
||||
spellCheck={false}
|
||||
styles={{ input: { fontFamily: "monospace", fontSize: 12 } }}
|
||||
/>
|
||||
|
||||
{parseError && (
|
||||
<Alert color="red" variant="light">
|
||||
{t(
|
||||
"automate.importModal.parseError",
|
||||
"Could not parse: {{message}}",
|
||||
{
|
||||
message: parseError,
|
||||
},
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{parsed && (
|
||||
<Alert color="green" variant="light">
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="center">
|
||||
<Badge color="green" variant="light">
|
||||
{formatLabel}
|
||||
</Badge>
|
||||
<Text size="sm" fw={500}>
|
||||
{parsed.automation.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("automate.importModal.opCount", "{{count}} operation(s)", {
|
||||
count: parsed.automation.operations.length,
|
||||
})}
|
||||
</Text>
|
||||
{parsed.unresolvedOperations.length > 0 && (
|
||||
<Text size="xs" c="orange">
|
||||
{t("automate.importModal.unresolved", "Unmapped: {{ops}}", {
|
||||
ops: parsed.unresolvedOperations.join(", "),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Button variant="subtle" onClick={onCancel} disabled={submitting}>
|
||||
{t("automate.importModal.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={!parsed || submitting}
|
||||
loading={submitting}
|
||||
>
|
||||
{t("automate.importModal.confirm", "Import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Title, Stack, Divider } from "@mantine/core";
|
||||
import AddCircleOutline from "@mui/icons-material/AddCircleOutlined";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import AutomationEntry from "@app/components/tools/automate/AutomationEntry";
|
||||
import AutomationImportModal from "@app/components/tools/automate/AutomationImportModal";
|
||||
import { useSuggestedAutomations } from "@app/hooks/tools/automate/useSuggestedAutomations";
|
||||
import { AutomationConfig, SuggestedAutomation } from "@app/types/automation";
|
||||
import { iconMap } from "@app/components/tools/automate/iconMap";
|
||||
import { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { downloadFolderScanningConfig } from "@app/utils/automationConverter";
|
||||
import {
|
||||
downloadAutomationConfig,
|
||||
downloadFolderScanningConfig,
|
||||
} from "@app/utils/automationConverter";
|
||||
import type { ImportableAutomation } from "@app/hooks/tools/automate/useSavedAutomations";
|
||||
|
||||
interface AutomationSelectionProps {
|
||||
savedAutomations: AutomationConfig[];
|
||||
@@ -16,6 +22,11 @@ interface AutomationSelectionProps {
|
||||
onEdit: (automation: AutomationConfig) => void;
|
||||
onDelete: (automation: AutomationConfig) => void;
|
||||
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
|
||||
onImportAutomation: (
|
||||
automation: ImportableAutomation,
|
||||
) => Promise<AutomationConfig>;
|
||||
onImportError?: (message: string) => void;
|
||||
onImportSuccess?: (message: string) => void;
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
}
|
||||
|
||||
@@ -26,11 +37,49 @@ export default function AutomationSelection({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCopyFromSuggested,
|
||||
onImportAutomation,
|
||||
onImportError,
|
||||
onImportSuccess,
|
||||
toolRegistry,
|
||||
}: AutomationSelectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const suggestedAutomations = useSuggestedAutomations();
|
||||
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
|
||||
const handleImportSubmit = async (
|
||||
automation: ImportableAutomation,
|
||||
meta: { format: "automate" | "folderScanning"; unresolved: string[] },
|
||||
) => {
|
||||
try {
|
||||
await onImportAutomation(automation);
|
||||
setImportModalOpen(false);
|
||||
if (meta.unresolved.length > 0) {
|
||||
onImportSuccess?.(
|
||||
t(
|
||||
"automate.importPartialSuccess",
|
||||
"Imported with {{count}} unmapped operation(s): {{ops}}",
|
||||
{
|
||||
count: meta.unresolved.length,
|
||||
ops: meta.unresolved.join(", "),
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
onImportSuccess?.(
|
||||
t("automate.importSuccess", "Imported automation: {{name}}", {
|
||||
name: automation.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to import automation";
|
||||
console.error("Failed to import automation:", err);
|
||||
onImportError?.(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title
|
||||
@@ -53,6 +102,8 @@ export default function AutomationSelection({
|
||||
operations={[]}
|
||||
onClick={onCreateNew}
|
||||
keepIconColor={true}
|
||||
showMenu={true}
|
||||
onImport={() => setImportModalOpen(true)}
|
||||
toolRegistry={toolRegistry}
|
||||
/>
|
||||
{/* Saved Automations */}
|
||||
@@ -72,7 +123,8 @@ export default function AutomationSelection({
|
||||
onClick={() => onRun(automation)}
|
||||
showMenu={true}
|
||||
onEdit={() => onEdit(automation)}
|
||||
onExport={() =>
|
||||
onExportAutomation={() => downloadAutomationConfig(automation)}
|
||||
onExportFolderScan={() =>
|
||||
downloadFolderScanningConfig(automation, toolRegistry)
|
||||
}
|
||||
onDelete={() => onDelete(automation)}
|
||||
@@ -110,6 +162,13 @@ export default function AutomationSelection({
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<AutomationImportModal
|
||||
opened={importModalOpen}
|
||||
toolRegistry={toolRegistry}
|
||||
onCancel={() => setImportModalOpen(false)}
|
||||
onImport={handleImportSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user