mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Enhance automate to have import and export buttons (#6326)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <[email protected]>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.10.0
|
||||
pkgver=2.10.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <[email protected]>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.10.0
|
||||
pkgver=2.10.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.10.0'
|
||||
version = '2.10.1'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
|
||||
|
||||
@@ -1791,26 +1791,14 @@ bullet3 = "Keeps the original name if no suitable title is found"
|
||||
text = "Automatically finds the title from your PDF content and uses it as the filename."
|
||||
title = "Smart Renaming"
|
||||
|
||||
[pdfCommentAgent]
|
||||
submit = "Generate comments"
|
||||
prompt.label = "What should the AI comment on?"
|
||||
prompt.placeholder = "e.g. Flag any ambiguous dates and suggest clarifications"
|
||||
|
||||
[pdfCommentAgent.settings]
|
||||
title = "Comment instructions"
|
||||
|
||||
[pdfCommentAgent.results]
|
||||
title = "Commented PDF"
|
||||
|
||||
[pdfCommentAgent.error]
|
||||
failed = "Failed to generate comments"
|
||||
emptyPrompt = "Please describe what the AI should comment on"
|
||||
tooLong = "Prompt is too long (maximum {{max}} characters)"
|
||||
|
||||
[automate]
|
||||
copyToSaved = "Copy to Saved"
|
||||
desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||
export = "Export"
|
||||
exportForFolderScanning = "Export for Folder Scanning"
|
||||
import = "Import"
|
||||
importPartialSuccess = "Imported with {{count}} unmapped operation(s): {{ops}}"
|
||||
importSuccess = "Imported automation: {{name}}"
|
||||
invalidStep = "Invalid step"
|
||||
reviewTitle = "Automation Results"
|
||||
title = "Automate"
|
||||
@@ -1826,6 +1814,7 @@ title = "Configure {{toolName}}"
|
||||
[automate.creation]
|
||||
createTitle = "Create Automation"
|
||||
editTitle = "Edit Automation"
|
||||
export = "Export"
|
||||
exportForFolderScanning = "Export for Folder Scanning"
|
||||
intro = "Automations run tools sequentially. To get started, add tools in the order you want them to run."
|
||||
save = "Save Automation"
|
||||
@@ -1856,9 +1845,28 @@ confirm = "Go Back"
|
||||
message = "You have unsaved changes. Are you sure you want to go back? All changes will be lost."
|
||||
title = "Unsaved Changes"
|
||||
|
||||
[automate.entryMenu]
|
||||
label = "Open menu for {{title}}"
|
||||
|
||||
[automate.files]
|
||||
placeholder = "Select files to process with this automation"
|
||||
|
||||
[automate.importModal]
|
||||
cancel = "Cancel"
|
||||
confirm = "Import"
|
||||
detectedAutomation = "Automate JSON"
|
||||
detectedFolderScan = "Folder Scanning JSON"
|
||||
dropHint = "Drop JSON here or click to choose a file"
|
||||
dropSubhint = "Both Automate and Folder Scanning configs are accepted"
|
||||
dropzoneAriaLabel = "Drop an automation JSON file here"
|
||||
intro = "Drop a JSON file or paste its contents below. The format (Automate or Folder Scanning) is detected automatically."
|
||||
opCount = "{{count}} operation(s)"
|
||||
parseError = "Could not parse: {{message}}"
|
||||
pasteLabel = "Or paste JSON"
|
||||
pastePlaceholder = "Paste your automation JSON here…"
|
||||
title = "Import automation"
|
||||
unresolved = "Unmapped: {{ops}}"
|
||||
|
||||
[automate.run]
|
||||
title = "Run Automation"
|
||||
|
||||
@@ -4068,11 +4076,6 @@ desc = "Auto renames a PDF file based on its detected header"
|
||||
tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title"
|
||||
title = "Auto Rename PDF File"
|
||||
|
||||
[home.pdfCommentAgent]
|
||||
desc = "Ask AI to annotate a PDF with sticky-note comments based on your prompt"
|
||||
tags = "AI,agent,comment,annotate,sticky note,review,feedback,notes"
|
||||
title = "Add AI comments"
|
||||
|
||||
[home.autoSizeSplitPDF]
|
||||
desc = "Automatically split PDFs by file size or page count"
|
||||
tags = "auto,split,size"
|
||||
@@ -4212,6 +4215,11 @@ desc = "Merge multiple pages of a PDF document into a single page"
|
||||
tags = "layout,arrange,combine,N-up,2-up,4-up,multiple per page,pages per sheet,layout pages,tile,grid layout,multi-page layout,combine on page,handout"
|
||||
title = "Multi-Page Layout"
|
||||
|
||||
[home.pdfCommentAgent]
|
||||
desc = "Ask AI to annotate a PDF with sticky-note comments based on your prompt"
|
||||
tags = "AI,agent,comment,annotate,sticky note,review,feedback,notes"
|
||||
title = "Add AI comments"
|
||||
|
||||
[home.pdfOrganiser]
|
||||
desc = "Remove/Rearrange pages in any order"
|
||||
tags = "organize,rearrange,reorder,organise,arrange pages,sort,move pages,delete pages,remove pages,page management,page organizer,page organiser,resequence"
|
||||
@@ -5288,6 +5296,24 @@ selectYearly = "Select Yearly"
|
||||
title = "Choose Your Billing Period"
|
||||
totalForSeats = "Total ({{count}} seats)"
|
||||
|
||||
[pdfCommentAgent]
|
||||
submit = "Generate comments"
|
||||
|
||||
[pdfCommentAgent.error]
|
||||
emptyPrompt = "Please describe what the AI should comment on"
|
||||
failed = "Failed to generate comments"
|
||||
tooLong = "Prompt is too long (maximum {{max}} characters)"
|
||||
|
||||
[pdfCommentAgent.prompt]
|
||||
label = "What should the AI comment on?"
|
||||
placeholder = "e.g. Flag any ambiguous dates and suggest clarifications"
|
||||
|
||||
[pdfCommentAgent.results]
|
||||
title = "Commented PDF"
|
||||
|
||||
[pdfCommentAgent.settings]
|
||||
title = "Comment instructions"
|
||||
|
||||
[pdfOrganiser]
|
||||
header = "PDF Page Organiser"
|
||||
placeholder = "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.10.0",
|
||||
"version": "2.10.1",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -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>
|
||||
|
||||
<Group gap="sm" grow>
|
||||
<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);
|
||||
downloadAutomationConfig(buildExportableAutomation());
|
||||
}}
|
||||
disabled={!canSaveAutomation()}
|
||||
variant="light"
|
||||
>
|
||||
{t("automate.creation.export", "Export")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<DownloadIcon />}
|
||||
onClick={() => {
|
||||
downloadFolderScanningConfig(
|
||||
buildExportableAutomation(),
|
||||
toolRegistry,
|
||||
);
|
||||
}}
|
||||
disabled={!canSaveAutomation()}
|
||||
variant="light"
|
||||
fullWidth
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,11 @@ export function useAutomationForm({
|
||||
|
||||
const [automationName, setAutomationName] = useState("");
|
||||
const [automationDescription, setAutomationDescription] = useState("");
|
||||
const [automationIcon, setAutomationIcon] = useState<string>("");
|
||||
// Default to "SettingsIcon" so the saved entry always has a valid icon key
|
||||
// matching what the IconSelector renders visually. Saving an empty string
|
||||
// here would round-trip into a fallback cog, making it look like the
|
||||
// picker silently lost the user's choice.
|
||||
const [automationIcon, setAutomationIcon] = useState<string>("SettingsIcon");
|
||||
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
|
||||
|
||||
const getToolName = useCallback(
|
||||
@@ -54,7 +58,7 @@ export function useAutomationForm({
|
||||
) {
|
||||
setAutomationName(existingAutomation.name || "");
|
||||
setAutomationDescription(existingAutomation.description || "");
|
||||
setAutomationIcon(existingAutomation.icon || "");
|
||||
setAutomationIcon(existingAutomation.icon || "SettingsIcon");
|
||||
|
||||
const operations = existingAutomation.operations || [];
|
||||
const tools = operations.map((op, index) => {
|
||||
|
||||
@@ -4,6 +4,11 @@ import { SuggestedAutomation } from "@app/types/automation";
|
||||
|
||||
export interface SavedAutomation extends AutomationConfig {}
|
||||
|
||||
export type ImportableAutomation = Omit<
|
||||
AutomationConfig,
|
||||
"id" | "createdAt" | "updatedAt"
|
||||
>;
|
||||
|
||||
export function useSavedAutomations() {
|
||||
const [savedAutomations, setSavedAutomations] = useState<SavedAutomation[]>(
|
||||
[],
|
||||
@@ -89,6 +94,22 @@ export function useSavedAutomations() {
|
||||
[refreshAutomations],
|
||||
);
|
||||
|
||||
const importAutomation = useCallback(
|
||||
async (automation: ImportableAutomation): Promise<SavedAutomation> => {
|
||||
try {
|
||||
const { automationStorage } =
|
||||
await import("@app/services/automationStorage");
|
||||
const saved = await automationStorage.saveAutomation(automation);
|
||||
refreshAutomations();
|
||||
return saved;
|
||||
} catch (err) {
|
||||
console.error("Error importing automation:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[refreshAutomations],
|
||||
);
|
||||
|
||||
// Load automations on mount
|
||||
useEffect(() => {
|
||||
loadSavedAutomations();
|
||||
@@ -101,5 +122,6 @@ export function useSavedAutomations() {
|
||||
refreshAutomations,
|
||||
deleteAutomation,
|
||||
copyFromSuggested,
|
||||
importAutomation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: "2.10.0",
|
||||
appVersion: "2.10.1",
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Stubbed coverage for the Automate tool's import / export flows.
|
||||
*
|
||||
* Imports go through a single "Import" entry on the Create New kebab that
|
||||
* opens a modal. The modal accepts either a dropped JSON file or pasted
|
||||
* text, auto-detects the format (Automate vs Folder Scanning), and only
|
||||
* enables the Import button once the input parses cleanly.
|
||||
*
|
||||
* Exports come in two flavours:
|
||||
* - Per-automation kebab: "Export" (native Automate) + "Export for
|
||||
* Folder Scanning" (backend pipeline shape).
|
||||
* - Builder buttons: same two options, side by side.
|
||||
*/
|
||||
|
||||
async function clearAutomationStorage(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
indexedDB.deleteDatabase("StirlingPDF_Automations");
|
||||
} catch {
|
||||
// First-page-load case where IDB hasn't been opened yet — safe to ignore.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function gotoAutomate(page: Page): Promise<void> {
|
||||
await clearAutomationStorage(page);
|
||||
await page.goto("/automate");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
}
|
||||
|
||||
async function ensureOnSelection(page: Page): Promise<void> {
|
||||
const savedHeader = page.getByText("Saved").first();
|
||||
if (!(await savedHeader.isVisible({ timeout: 2_000 }).catch(() => false))) {
|
||||
await page
|
||||
.getByText(/Automation Selection/i)
|
||||
.first()
|
||||
.click();
|
||||
await expect(savedHeader).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover the entry button matching `entryTitle` to reveal its kebab, then
|
||||
* click the kebab. Returns once the menu is open.
|
||||
*/
|
||||
async function openEntryMenu(
|
||||
page: Page,
|
||||
entryTitle: RegExp | string,
|
||||
): Promise<void> {
|
||||
const entryButton = page
|
||||
.getByRole("button", { name: entryTitle as RegExp })
|
||||
.first();
|
||||
await entryButton.hover();
|
||||
const titleSource =
|
||||
typeof entryTitle === "string" ? entryTitle : entryTitle.source;
|
||||
const escaped = titleSource.replace(/^\^|\$$/g, "");
|
||||
const kebab = page
|
||||
.getByRole("button", {
|
||||
name: new RegExp(`Open menu for .*${escaped}`, "i"),
|
||||
})
|
||||
.first();
|
||||
await kebab.click();
|
||||
}
|
||||
|
||||
function makeAutomateJson(name: string): string {
|
||||
return JSON.stringify({
|
||||
name,
|
||||
description: "Imported via Playwright",
|
||||
icon: "CompressIcon",
|
||||
operations: [
|
||||
{ operation: "merge", parameters: { generateToc: true } },
|
||||
{ operation: "compress", parameters: { compressionLevel: 3 } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function makeFolderScanJson(name: string): string {
|
||||
return JSON.stringify({
|
||||
name,
|
||||
pipeline: [
|
||||
{
|
||||
operation: "/api/v1/general/merge-pdfs",
|
||||
parameters: {
|
||||
generateToc: true,
|
||||
fileInput: "automated",
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: "/api/v1/misc/compress-pdf",
|
||||
parameters: {
|
||||
compressionLevel: 3,
|
||||
fileInput: "automated",
|
||||
},
|
||||
},
|
||||
],
|
||||
_examples: {
|
||||
outputDir: "{outputFolder}/{folderName}",
|
||||
outputFileName: "{filename}-{pipelineName}-{date}-{time}",
|
||||
},
|
||||
outputDir: "{outputFolder}",
|
||||
outputFileName: "{filename}",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the import modal from the "Create New Automation" kebab.
|
||||
*/
|
||||
async function openImportModal(page: Page): Promise<void> {
|
||||
await openEntryMenu(page, /Create New Automation/i);
|
||||
await page.getByRole("menuitem", { name: /^Import$/ }).click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /Import automation/i }),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
test.describe("12. Automation Page — Import / Export", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await gotoAutomate(page);
|
||||
await ensureOnSelection(page);
|
||||
});
|
||||
|
||||
test.describe("12.1 Create New kebab — single Import option", () => {
|
||||
test("kebab on Create New entry exposes a single Import option", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openEntryMenu(page, /Create New Automation/i);
|
||||
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Import$/ }),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Saved-only options should not appear on Create New.
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Edit$/ }),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Delete$/ }),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Export$/ }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("12.2 Import modal — paste flow", () => {
|
||||
test("pasting valid Automate JSON enables Import and saves the entry", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openImportModal(page);
|
||||
|
||||
// Import button starts disabled.
|
||||
const importBtn = page.getByRole("button", { name: /^Import$/ }).last();
|
||||
await expect(importBtn).toBeDisabled();
|
||||
|
||||
const textarea = page.getByLabel(/Or paste JSON/i);
|
||||
await textarea.fill(makeAutomateJson("Pasted Automate"));
|
||||
|
||||
// Detected-format badge should appear.
|
||||
await expect(page.getByText(/Automate JSON/).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(importBtn).toBeEnabled();
|
||||
|
||||
await importBtn.click();
|
||||
|
||||
// Modal closes and the new entry appears in Saved.
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /Import automation/i }),
|
||||
).not.toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Pasted Automate/i }).first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Icon must round-trip into IndexedDB. If it doesn't, the saved-entry
|
||||
// render falls back to SettingsIcon and looks like a silent picker bug.
|
||||
const persistedIcon = await page.evaluate(
|
||||
() =>
|
||||
new Promise<string | undefined>((resolve) => {
|
||||
const req = indexedDB.open("StirlingPDF_Automations", 1);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction("automations", "readonly");
|
||||
const store = tx.objectStore("automations");
|
||||
const all = store.getAll();
|
||||
all.onsuccess = () => {
|
||||
const match = (
|
||||
all.result as Array<{
|
||||
name: string;
|
||||
icon?: string;
|
||||
}>
|
||||
).find((a) => a.name === "Pasted Automate");
|
||||
resolve(match?.icon);
|
||||
};
|
||||
all.onerror = () => resolve(undefined);
|
||||
};
|
||||
req.onerror = () => resolve(undefined);
|
||||
}),
|
||||
);
|
||||
expect(persistedIcon).toBe("CompressIcon");
|
||||
});
|
||||
|
||||
test("pasting valid Folder Scanning JSON shows the right format and imports", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openImportModal(page);
|
||||
|
||||
const textarea = page.getByLabel(/Or paste JSON/i);
|
||||
await textarea.fill(makeFolderScanJson("Pasted Folder Scan"));
|
||||
|
||||
await expect(page.getByText(/Folder Scanning JSON/).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: /^Import$/ })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Pasted Folder Scan/i }).first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("pasting invalid JSON shows an error and keeps Import disabled", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openImportModal(page);
|
||||
|
||||
const textarea = page.getByLabel(/Or paste JSON/i);
|
||||
await textarea.fill("{ this is not valid json");
|
||||
|
||||
await expect(page.getByText(/Could not parse/i).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const importBtn = page.getByRole("button", { name: /^Import$/ }).last();
|
||||
await expect(importBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("12.3 Import modal — file drop flow", () => {
|
||||
test("dropping an Automate JSON file fills the textarea and imports", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openImportModal(page);
|
||||
|
||||
// The Mantine Dropzone exposes a hidden file input we can target via
|
||||
// `setInputFiles`. There's only one file input inside the modal.
|
||||
const fileInput = page
|
||||
.getByRole("dialog", { name: /Import automation/i })
|
||||
.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: "dropped.automate.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(makeAutomateJson("Dropped Automate")),
|
||||
});
|
||||
|
||||
// The textarea should reflect the dropped content.
|
||||
await expect(page.getByLabel(/Or paste JSON/i)).toHaveValue(
|
||||
/Dropped Automate/,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: /^Import$/ })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Dropped Automate/i }).first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("dropping a Folder Scanning JSON file imports it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openImportModal(page);
|
||||
|
||||
const fileInput = page
|
||||
.getByRole("dialog", { name: /Import automation/i })
|
||||
.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: "dropped.folder-scan.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(makeFolderScanJson("Dropped Folder Scan")),
|
||||
});
|
||||
|
||||
await expect(page.getByText(/Folder Scanning JSON/).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: /^Import$/ })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Dropped Folder Scan/i }).first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("12.4 Export — per-automation kebab menu", () => {
|
||||
test("saved entry kebab exposes Export and Export for Folder Scanning", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Seed a saved automation by importing one first.
|
||||
await openImportModal(page);
|
||||
await page
|
||||
.getByLabel(/Or paste JSON/i)
|
||||
.fill(makeAutomateJson("Export Menu Seed"));
|
||||
await page
|
||||
.getByRole("button", { name: /^Import$/ })
|
||||
.last()
|
||||
.click();
|
||||
const seededEntry = page
|
||||
.getByRole("button", { name: /Export Menu Seed/i })
|
||||
.first();
|
||||
await expect(seededEntry).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await openEntryMenu(page, /Export Menu Seed/i);
|
||||
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Export$/ }),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /Export for Folder Scanning/i }),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Edit$/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Delete$/ }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking 'Export' triggers a .automate.json download", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openImportModal(page);
|
||||
await page
|
||||
.getByLabel(/Or paste JSON/i)
|
||||
.fill(makeAutomateJson("Download Test"));
|
||||
await page
|
||||
.getByRole("button", { name: /^Import$/ })
|
||||
.last()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Download Test/i }).first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await openEntryMenu(page, /Download Test/i);
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("menuitem", { name: /^Export$/ }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toMatch(/\.automate\.json$/);
|
||||
});
|
||||
|
||||
test("clicking 'Export for Folder Scanning' triggers a .folder-scan.json download", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openImportModal(page);
|
||||
await page
|
||||
.getByLabel(/Or paste JSON/i)
|
||||
.fill(makeAutomateJson("Folder Download Test"));
|
||||
await page
|
||||
.getByRole("button", { name: /^Import$/ })
|
||||
.last()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Folder Download Test/i }).first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await openEntryMenu(page, /Folder Download Test/i);
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("menuitem", { name: /Export for Folder Scanning/i })
|
||||
.click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toMatch(/\.folder-scan\.json$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +1,98 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Stubbed coverage for the `/automate` super-tool. The Automate UI is a small
|
||||
* three-step state machine: Selection ↔ Creation ↔ Run. These specs exercise
|
||||
* the surface area that doesn't require a live backend run — the catalogue,
|
||||
* the builder save flow, and the per-automation menu.
|
||||
*
|
||||
* Live runs (actually executing a pipeline against a real backend) live in
|
||||
* `src/core/tests/live/automate-chain.spec.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* IndexedDB persists across tests sharing a worker. Each test wipes the
|
||||
* automation store before navigating so the "Saved" list starts empty.
|
||||
*/
|
||||
async function clearAutomationStorage(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
indexedDB.deleteDatabase("StirlingPDF_Automations");
|
||||
} catch {
|
||||
// First-page-load case where IDB hasn't been opened yet — safe to ignore.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function gotoAutomate(page: Page): Promise<void> {
|
||||
await clearAutomationStorage(page);
|
||||
await page.goto("/automate");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
}
|
||||
|
||||
/**
|
||||
* Land on the Selection step. The auto-goto fixture sometimes parks us on the
|
||||
* builder if a previous test left state behind; click the section header to
|
||||
* snap back to a known state.
|
||||
*/
|
||||
async function ensureOnSelection(page: Page): Promise<void> {
|
||||
const savedHeader = page.getByText("Saved").first();
|
||||
if (!(await savedHeader.isVisible({ timeout: 2_000 }).catch(() => false))) {
|
||||
await page
|
||||
.getByText(/Automation Selection/i)
|
||||
.first()
|
||||
.click();
|
||||
await expect(savedHeader).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover the entry whose title matches `entryTitle` and click its kebab.
|
||||
* Returns once the kebab menu is open and the dropdown is visible.
|
||||
*/
|
||||
async function openEntryMenu(page: Page, entryTitle: RegExp): Promise<void> {
|
||||
const entry = page.getByRole("button", { name: entryTitle }).first();
|
||||
await entry.hover();
|
||||
const kebab = page
|
||||
.getByRole("button", { name: /^Open menu for / })
|
||||
.filter({ hasText: "" }) // ActionIcon has no text content
|
||||
.first();
|
||||
// Match by accessible-name fragment — the title is interpolated.
|
||||
const titleHint = (entryTitle.source || "")
|
||||
.replace(/^\^|\$$/g, "")
|
||||
.replace(/\\/g, "");
|
||||
const scopedKebab = page
|
||||
.getByRole("button", {
|
||||
name: new RegExp(`Open menu for .*${titleHint}`, "i"),
|
||||
})
|
||||
.first();
|
||||
if (await scopedKebab.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||||
await scopedKebab.click();
|
||||
} else {
|
||||
await kebab.click();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("11. Automation Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/automate");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await gotoAutomate(page);
|
||||
});
|
||||
|
||||
test.describe("11.1 Automation - Suggested Workflows", () => {
|
||||
test("should display saved and suggested workflows", async ({ page }) => {
|
||||
// Step 1: Verify the Automate link in the navigation is visible
|
||||
const automateLink = page.locator('a[href="/automate"]').first();
|
||||
await expect(automateLink).toBeVisible();
|
||||
|
||||
// Step 2: Verify the Automation Selection header is present
|
||||
await expect(
|
||||
page.getByText(/Automation Selection/i).first(),
|
||||
).toBeVisible();
|
||||
await ensureOnSelection(page);
|
||||
|
||||
// If we accidentally landed on the creation step, click back to selection
|
||||
const selectionHeader = page.getByText(/Automation Selection/i).first();
|
||||
const savedText = page.getByText(/Saved/i).first();
|
||||
if (!(await savedText.isVisible().catch(() => false))) {
|
||||
await selectionHeader.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Step 3: Verify the Saved section is visible
|
||||
await expect(page.getByText("Saved").first()).toBeVisible();
|
||||
|
||||
// Step 4: Verify the Create New Automation entry is present
|
||||
await expect(
|
||||
page.getByText(/Create New Automation/i).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Step 5: Verify suggested preset workflows are listed
|
||||
await expect(page.getByText("Suggested").first()).toBeVisible();
|
||||
|
||||
const suggestedWorkflows = [
|
||||
@@ -46,7 +105,7 @@ test.describe("11. Automation Page", () => {
|
||||
|
||||
for (const workflow of suggestedWorkflows) {
|
||||
await expect(page.getByText(workflow).first()).toBeVisible({
|
||||
timeout: 5000,
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -56,31 +115,85 @@ test.describe("11. Automation Page", () => {
|
||||
test("should open automation builder when clicking create button", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Ensure we're on the selection step first
|
||||
const savedText = page.getByText("Saved").first();
|
||||
if (!(await savedText.isVisible().catch(() => false))) {
|
||||
const selectionHeader = page.getByText(/Automation Selection/i).first();
|
||||
await selectionHeader.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
await ensureOnSelection(page);
|
||||
|
||||
// Step 1: Click the Create New Automation entry
|
||||
const createEntry = page.getByText(/Create New Automation/i).first();
|
||||
await createEntry.click();
|
||||
await page
|
||||
.getByText(/Create New Automation/i)
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Step 2: Verify the automation builder/editor opens with form fields
|
||||
await expect(page.getByText(/Create Automation/i).first()).toBeVisible({
|
||||
timeout: 5000,
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Step 3: Verify the user can see automation configuration fields
|
||||
await expect(page.getByText(/Automation Name/i).first()).toBeVisible({
|
||||
timeout: 5000,
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText(/Add Tool/i).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Save Automation/i }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("save button stays disabled until a name is filled", async ({
|
||||
page,
|
||||
}) => {
|
||||
await ensureOnSelection(page);
|
||||
await page
|
||||
.getByText(/Create New Automation/i)
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const saveBtn = page
|
||||
.getByRole("button", { name: /Save Automation/i })
|
||||
.first();
|
||||
await expect(saveBtn).toBeVisible({ timeout: 5_000 });
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
|
||||
// Naming alone shouldn't enable save — a tool must be added too.
|
||||
await page.getByLabel(/Automation Name/i).fill("E2E Builder Smoke");
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
test("both export buttons render in the builder and are disabled until valid", async ({
|
||||
page,
|
||||
}) => {
|
||||
await ensureOnSelection(page);
|
||||
await page
|
||||
.getByText(/Create New Automation/i)
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const exportAutomate = page
|
||||
.getByRole("button", { name: /^Export$/ })
|
||||
.first();
|
||||
const exportFolderScan = page
|
||||
.getByRole("button", { name: /Export for Folder Scanning/i })
|
||||
.first();
|
||||
|
||||
await expect(exportAutomate).toBeVisible({ timeout: 5_000 });
|
||||
await expect(exportFolderScan).toBeVisible({ timeout: 5_000 });
|
||||
// Same disabled-when-invalid contract as Save — no name + no tool.
|
||||
await expect(exportAutomate).toBeDisabled();
|
||||
await expect(exportFolderScan).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("11.3 Automation - Suggested copy-to-saved", () => {
|
||||
test("copies a suggested automation into the Saved list", async ({
|
||||
page,
|
||||
}) => {
|
||||
await ensureOnSelection(page);
|
||||
|
||||
await openEntryMenu(page, /Secure PDF Ingestion/i);
|
||||
await page.getByRole("menuitem", { name: /Copy to Saved/i }).click();
|
||||
|
||||
// The suggested entry stays in the list and a duplicate appears under
|
||||
// Saved, so the count of matching texts goes from 1 to 2.
|
||||
await expect
|
||||
.poll(async () => page.getByText(/Secure PDF Ingestion/i).count(), {
|
||||
timeout: 5_000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
deleteAutomation,
|
||||
refreshAutomations,
|
||||
copyFromSuggested,
|
||||
importAutomation,
|
||||
} = useSavedAutomations();
|
||||
|
||||
// Use ref to store the latest reset function to avoid closure issues
|
||||
@@ -153,6 +154,8 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
);
|
||||
}
|
||||
}}
|
||||
onImportAutomation={importAutomation}
|
||||
onImportError={(message) => onError?.(message)}
|
||||
toolRegistry={toolRegistry}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Unit tests for automationConverter import/export round-tripping.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
convertToAutomationConfig,
|
||||
convertToFolderScanningConfig,
|
||||
detectAutomationFormat,
|
||||
parseAutomationConfigJson,
|
||||
parseAutomationFile,
|
||||
parseFolderScanningConfig,
|
||||
} from "@app/utils/automationConverter";
|
||||
import { AutomationConfig } from "@app/types/automation";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
|
||||
// Minimal stub registry — only the fields automationConverter actually reads.
|
||||
const registry = {
|
||||
merge: {
|
||||
operationConfig: {
|
||||
endpoint: "/api/v1/general/merge-pdfs",
|
||||
},
|
||||
},
|
||||
compress: {
|
||||
operationConfig: {
|
||||
endpoint: "/api/v1/misc/compress-pdf",
|
||||
},
|
||||
},
|
||||
ocr: {
|
||||
operationConfig: {
|
||||
endpoint: "/api/v1/misc/ocr-pdf",
|
||||
},
|
||||
},
|
||||
// Dynamic endpoint — endpoint depends on parameters.
|
||||
convert: {
|
||||
operationConfig: {
|
||||
endpoint: (params: Record<string, any>) =>
|
||||
`/api/v1/convert/${params.fromExtension}-to-${params.toExtension}`,
|
||||
},
|
||||
},
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
const sampleAutomation: AutomationConfig = {
|
||||
id: "auto-123",
|
||||
name: "Sample",
|
||||
description: "Test automation",
|
||||
icon: "CompressIcon",
|
||||
operations: [
|
||||
{ operation: "merge", parameters: { generateToc: true } },
|
||||
{ operation: "compress", parameters: { compressionLevel: 3 } },
|
||||
],
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("automationConverter", () => {
|
||||
describe("convertToAutomationConfig", () => {
|
||||
test("strips id/createdAt/updatedAt and clones parameters", () => {
|
||||
const result = convertToAutomationConfig(sampleAutomation);
|
||||
expect(result).toEqual({
|
||||
name: "Sample",
|
||||
description: "Test automation",
|
||||
icon: "CompressIcon",
|
||||
operations: [
|
||||
{ operation: "merge", parameters: { generateToc: true } },
|
||||
{ operation: "compress", parameters: { compressionLevel: 3 } },
|
||||
],
|
||||
});
|
||||
expect(result).not.toHaveProperty("id");
|
||||
expect(result).not.toHaveProperty("createdAt");
|
||||
expect(result).not.toHaveProperty("updatedAt");
|
||||
// Parameters should be cloned, not the same reference.
|
||||
expect(result.operations[0].parameters).not.toBe(
|
||||
sampleAutomation.operations[0].parameters,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToFolderScanningConfig", () => {
|
||||
test("rewrites operation keys to backend endpoints and adds fileInput", () => {
|
||||
const config = convertToFolderScanningConfig(sampleAutomation, registry);
|
||||
expect(config.pipeline).toEqual([
|
||||
{
|
||||
operation: "/api/v1/general/merge-pdfs",
|
||||
parameters: { generateToc: true, fileInput: "automated" },
|
||||
},
|
||||
{
|
||||
operation: "/api/v1/misc/compress-pdf",
|
||||
parameters: { compressionLevel: 3, fileInput: "automated" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves icon and description so round-tripping keeps UI metadata", () => {
|
||||
const config = convertToFolderScanningConfig(sampleAutomation, registry);
|
||||
expect(config.icon).toBe("CompressIcon");
|
||||
expect(config.description).toBe("Test automation");
|
||||
});
|
||||
|
||||
test("omits icon and description when not set on the source automation", () => {
|
||||
const minimal: AutomationConfig = {
|
||||
id: "min",
|
||||
name: "Minimal",
|
||||
operations: [{ operation: "merge", parameters: {} }],
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
};
|
||||
const config = convertToFolderScanningConfig(minimal, registry);
|
||||
expect(config).not.toHaveProperty("icon");
|
||||
expect(config).not.toHaveProperty("description");
|
||||
});
|
||||
|
||||
test("falls back to operation key when no endpoint is registered", () => {
|
||||
const automation: AutomationConfig = {
|
||||
...sampleAutomation,
|
||||
operations: [{ operation: "unknownTool", parameters: {} }],
|
||||
};
|
||||
const config = convertToFolderScanningConfig(automation, registry);
|
||||
expect(config.pipeline[0].operation).toBe("unknownTool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectAutomationFormat", () => {
|
||||
test("detects native Automate JSON", () => {
|
||||
expect(detectAutomationFormat({ operations: [] })).toBe("automate");
|
||||
});
|
||||
test("detects folder-scanning JSON", () => {
|
||||
expect(detectAutomationFormat({ pipeline: [] })).toBe("folderScanning");
|
||||
});
|
||||
test("returns unknown for ambiguous or invalid input", () => {
|
||||
expect(detectAutomationFormat({ pipeline: [], operations: [] })).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(detectAutomationFormat(null)).toBe("unknown");
|
||||
expect(detectAutomationFormat("string")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAutomationConfigJson", () => {
|
||||
test("accepts a previously exported native Automate JSON", () => {
|
||||
const exported = convertToAutomationConfig(sampleAutomation);
|
||||
const parsed = parseAutomationConfigJson(exported, registry);
|
||||
expect(parsed.unresolvedOperations).toEqual([]);
|
||||
expect(parsed.automation.name).toBe("Sample");
|
||||
expect(parsed.automation.operations).toHaveLength(2);
|
||||
expect(parsed.automation.operations[0].operation).toBe("merge");
|
||||
// Icon must round-trip — losing it makes saved entries fall back to the
|
||||
// settings cog, which historically looked like a silent picker bug.
|
||||
expect(parsed.automation.icon).toBe("CompressIcon");
|
||||
});
|
||||
|
||||
test("flags operations not present in the registry", () => {
|
||||
const result = parseAutomationConfigJson(
|
||||
{
|
||||
name: "x",
|
||||
operations: [{ operation: "notARealTool", parameters: {} }],
|
||||
},
|
||||
registry,
|
||||
);
|
||||
expect(result.unresolvedOperations).toEqual(["notARealTool"]);
|
||||
});
|
||||
|
||||
test("throws on missing operations array", () => {
|
||||
expect(() => parseAutomationConfigJson({ name: "x" }, registry)).toThrow(
|
||||
/operations/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFolderScanningConfig", () => {
|
||||
test("round-trips a folder-scan export back to tool IDs", () => {
|
||||
const exported = convertToFolderScanningConfig(
|
||||
sampleAutomation,
|
||||
registry,
|
||||
);
|
||||
const parsed = parseFolderScanningConfig(exported, registry);
|
||||
expect(parsed.unresolvedOperations).toEqual([]);
|
||||
expect(parsed.automation.operations.map((o) => o.operation)).toEqual([
|
||||
"merge",
|
||||
"compress",
|
||||
]);
|
||||
// Icon survives the round trip even through the folder-scan format.
|
||||
expect(parsed.automation.icon).toBe("CompressIcon");
|
||||
// The export-time `fileInput: "automated"` marker must not survive import.
|
||||
for (const op of parsed.automation.operations) {
|
||||
expect(op.parameters).not.toHaveProperty("fileInput");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves a dynamic endpoint by replaying it with the imported parameters", () => {
|
||||
const config = {
|
||||
name: "Convert",
|
||||
pipeline: [
|
||||
{
|
||||
operation: "/api/v1/convert/pdf-to-docx",
|
||||
parameters: {
|
||||
fromExtension: "pdf",
|
||||
toExtension: "docx",
|
||||
fileInput: "automated",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const parsed = parseFolderScanningConfig(config, registry);
|
||||
expect(parsed.unresolvedOperations).toEqual([]);
|
||||
expect(parsed.automation.operations[0].operation).toBe("convert");
|
||||
expect(parsed.automation.operations[0].parameters).toEqual({
|
||||
fromExtension: "pdf",
|
||||
toExtension: "docx",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps unmappable endpoints verbatim and reports them", () => {
|
||||
const config = {
|
||||
name: "Mystery",
|
||||
pipeline: [{ operation: "/api/v1/unknown/op", parameters: {} }],
|
||||
};
|
||||
const parsed = parseFolderScanningConfig(config, registry);
|
||||
expect(parsed.unresolvedOperations).toEqual(["/api/v1/unknown/op"]);
|
||||
expect(parsed.automation.operations[0].operation).toBe(
|
||||
"/api/v1/unknown/op",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws on missing pipeline array", () => {
|
||||
expect(() => parseFolderScanningConfig({ name: "x" }, registry)).toThrow(
|
||||
/pipeline/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAutomationFile", () => {
|
||||
test("auto-detects Automate JSON", () => {
|
||||
const text = JSON.stringify({
|
||||
name: "x",
|
||||
operations: [{ operation: "merge", parameters: {} }],
|
||||
});
|
||||
const result = parseAutomationFile(text, registry);
|
||||
expect(result.format).toBe("automate");
|
||||
});
|
||||
|
||||
test("auto-detects Folder Scanning JSON", () => {
|
||||
const text = JSON.stringify({
|
||||
name: "x",
|
||||
pipeline: [{ operation: "/api/v1/general/merge-pdfs", parameters: {} }],
|
||||
});
|
||||
const result = parseAutomationFile(text, registry);
|
||||
expect(result.format).toBe("folderScanning");
|
||||
});
|
||||
|
||||
test("rejects mismatched explicit format", () => {
|
||||
const text = JSON.stringify({
|
||||
name: "x",
|
||||
operations: [{ operation: "merge", parameters: {} }],
|
||||
});
|
||||
expect(() =>
|
||||
parseAutomationFile(text, registry, "folderScanning"),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("rejects malformed JSON", () => {
|
||||
expect(() => parseAutomationFile("{ not json", registry)).toThrow(
|
||||
/not valid JSON/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unrecognized shape", () => {
|
||||
expect(() =>
|
||||
parseAutomationFile(JSON.stringify({ foo: "bar" }), registry),
|
||||
).toThrow(/Unrecognized JSON/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,33 @@
|
||||
/**
|
||||
* Utility functions for converting between automation formats
|
||||
* Utility functions for converting between automation formats.
|
||||
*
|
||||
* Two on-disk formats are supported:
|
||||
*
|
||||
* 1. **Automate JSON** (native) — mirrors {@link AutomationConfig}; the format
|
||||
* used internally by the Automate tool and persisted in IndexedDB. Operation
|
||||
* names are frontend tool IDs (e.g. "merge", "compress").
|
||||
*
|
||||
* 2. **Folder Scanning JSON** — the format consumed by the backend
|
||||
* PipelineDirectoryProcessor. Operation names are full backend endpoint
|
||||
* paths (e.g. "/api/v1/general/merge-pdfs").
|
||||
*/
|
||||
|
||||
import { AutomationConfig } from "@app/types/automation";
|
||||
import { AutomationConfig, AutomationOperation } from "@app/types/automation";
|
||||
import { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
|
||||
/**
|
||||
* Pipeline configuration format used by folder scanning
|
||||
* Pipeline configuration format used by folder scanning.
|
||||
*
|
||||
* `description` and `icon` are unused by the backend pipeline runner but are
|
||||
* preserved so a folder-scan export can round-trip cleanly back into the
|
||||
* Automate UI without losing display metadata.
|
||||
*/
|
||||
interface FolderScanningPipeline {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
pipeline: Array<{
|
||||
operation: string;
|
||||
parameters: Record<string, any>;
|
||||
@@ -25,10 +41,28 @@ interface FolderScanningPipeline {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an AutomationConfig to a folder scanning pipeline configuration
|
||||
* @param automation The automation configuration to convert
|
||||
* @param toolRegistry The tool registry to map operation types to endpoints
|
||||
* @returns Folder scanning pipeline configuration
|
||||
* Discriminated result returned by {@link parseAutomationFile}.
|
||||
*/
|
||||
export type ParsedAutomationImport =
|
||||
| {
|
||||
format: "automate";
|
||||
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
|
||||
unresolvedOperations: string[];
|
||||
}
|
||||
| {
|
||||
format: "folderScanning";
|
||||
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
|
||||
unresolvedOperations: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize a filename so it works on Windows / macOS / Linux.
|
||||
*/
|
||||
const sanitizeFilename = (name: string): string =>
|
||||
(name || "automation").replace(/[\\/:*?"<>|]+/g, "_").trim() || "automation";
|
||||
|
||||
/**
|
||||
* Converts an AutomationConfig to a folder scanning pipeline configuration.
|
||||
*/
|
||||
export function convertToFolderScanningConfig(
|
||||
automation: AutomationConfig,
|
||||
@@ -36,8 +70,9 @@ export function convertToFolderScanningConfig(
|
||||
): FolderScanningPipeline {
|
||||
return {
|
||||
name: automation.name,
|
||||
...(automation.description ? { description: automation.description } : {}),
|
||||
...(automation.icon ? { icon: automation.icon } : {}),
|
||||
pipeline: automation.operations.map((op) => {
|
||||
// Map operationType to full API endpoint path
|
||||
const toolId = op.operation as ToolId;
|
||||
const toolEntry = toolRegistry[toolId];
|
||||
const endpointConfig = toolEntry?.operationConfig?.endpoint;
|
||||
@@ -47,7 +82,6 @@ export function convertToFolderScanningConfig(
|
||||
if (typeof endpointConfig === "string") {
|
||||
endpoint = endpointConfig;
|
||||
} else if (typeof endpointConfig === "function") {
|
||||
// For dynamic endpoints, call with the saved parameters
|
||||
try {
|
||||
endpoint = endpointConfig(op.parameters);
|
||||
} catch (error) {
|
||||
@@ -85,9 +119,7 @@ export function convertToFolderScanningConfig(
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a folder scanning configuration as a JSON file
|
||||
* @param automation The automation configuration to export
|
||||
* @param toolRegistry The tool registry to map operation types to endpoints
|
||||
* Downloads a folder scanning configuration as a JSON file.
|
||||
*/
|
||||
export function downloadFolderScanningConfig(
|
||||
automation: AutomationConfig,
|
||||
@@ -96,5 +128,299 @@ export function downloadFolderScanningConfig(
|
||||
const config = convertToFolderScanningConfig(automation, toolRegistry);
|
||||
const json = JSON.stringify(config, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
void downloadFile({ data: blob, filename: `${automation.name}.json` });
|
||||
void downloadFile({
|
||||
data: blob,
|
||||
filename: `${sanitizeFilename(automation.name)}.folder-scan.json`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an exportable native Automate JSON object. Strips the IndexedDB
|
||||
* primary key and timestamps so an imported copy looks fresh.
|
||||
*/
|
||||
export function convertToAutomationConfig(
|
||||
automation: AutomationConfig,
|
||||
): Omit<AutomationConfig, "id" | "createdAt" | "updatedAt"> {
|
||||
return {
|
||||
name: automation.name,
|
||||
description: automation.description,
|
||||
icon: automation.icon,
|
||||
operations: automation.operations.map((op) => ({
|
||||
operation: op.operation,
|
||||
parameters: { ...(op.parameters || {}) },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an automation in the native Automate JSON format. The file can be
|
||||
* re-imported via {@link parseAutomationFile} to restore the automation on a
|
||||
* different machine or browser profile.
|
||||
*/
|
||||
export function downloadAutomationConfig(automation: AutomationConfig): void {
|
||||
const config = convertToAutomationConfig(automation);
|
||||
const json = JSON.stringify(config, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
void downloadFile({
|
||||
data: blob,
|
||||
filename: `${sanitizeFilename(automation.name)}.automate.json`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an inverse map of `endpoint string` → `frontend tool ID` from the
|
||||
* tool registry. Only static string endpoints are added — dynamic
|
||||
* (function) endpoints are matched at parse time by replaying them.
|
||||
*/
|
||||
function buildEndpointToToolIdMap(
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): Map<string, ToolId> {
|
||||
const map = new Map<string, ToolId>();
|
||||
for (const [toolId, entry] of Object.entries(toolRegistry)) {
|
||||
const endpoint = entry?.operationConfig?.endpoint;
|
||||
if (typeof endpoint === "string" && !map.has(endpoint)) {
|
||||
map.set(endpoint, toolId as ToolId);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try every dynamic endpoint in the registry with the supplied parameters,
|
||||
* returning the first tool ID whose endpoint function produces `targetEndpoint`.
|
||||
*/
|
||||
function findDynamicEndpointMatch(
|
||||
targetEndpoint: string,
|
||||
parameters: Record<string, any>,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): ToolId | undefined {
|
||||
for (const [toolId, entry] of Object.entries(toolRegistry)) {
|
||||
const endpoint = entry?.operationConfig?.endpoint;
|
||||
if (typeof endpoint === "function") {
|
||||
try {
|
||||
if (endpoint(parameters) === targetEndpoint) {
|
||||
return toolId as ToolId;
|
||||
}
|
||||
} catch {
|
||||
// Endpoint function expected different parameters — ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isToolIdInRegistry = (
|
||||
candidate: string,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): boolean => Object.prototype.hasOwnProperty.call(toolRegistry, candidate);
|
||||
|
||||
/**
|
||||
* Parse a folder-scanning pipeline JSON into the native AutomationConfig
|
||||
* shape. Endpoint paths are reverse-mapped to frontend tool IDs via the
|
||||
* supplied registry; unmappable operations are kept verbatim and reported in
|
||||
* `unresolvedOperations`.
|
||||
*/
|
||||
export function parseFolderScanningConfig(
|
||||
raw: unknown,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): {
|
||||
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
|
||||
unresolvedOperations: string[];
|
||||
} {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new Error("Invalid folder scanning config: expected JSON object");
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const pipeline = obj.pipeline;
|
||||
if (!Array.isArray(pipeline)) {
|
||||
throw new Error("Invalid folder scanning config: missing 'pipeline' array");
|
||||
}
|
||||
|
||||
const endpointMap = buildEndpointToToolIdMap(toolRegistry);
|
||||
const unresolved: string[] = [];
|
||||
|
||||
const operations: AutomationOperation[] = pipeline.map(
|
||||
(step: unknown, index: number) => {
|
||||
if (!step || typeof step !== "object") {
|
||||
throw new Error(
|
||||
`Invalid folder scanning config: pipeline[${index}] is not an object`,
|
||||
);
|
||||
}
|
||||
const stepObj = step as Record<string, unknown>;
|
||||
const rawOperation = stepObj.operation;
|
||||
if (typeof rawOperation !== "string" || rawOperation.length === 0) {
|
||||
throw new Error(
|
||||
`Invalid folder scanning config: pipeline[${index}].operation must be a non-empty string`,
|
||||
);
|
||||
}
|
||||
const rawParameters = (stepObj.parameters as Record<string, any>) || {};
|
||||
// Strip the export-time marker so the imported automation runs cleanly.
|
||||
const { fileInput: _fileInput, ...parameters } = rawParameters;
|
||||
|
||||
// 1) Direct frontend-tool-id match (handles the converter's fallback when
|
||||
// no endpoint could be resolved at export time).
|
||||
if (isToolIdInRegistry(rawOperation, toolRegistry)) {
|
||||
return { operation: rawOperation, parameters };
|
||||
}
|
||||
|
||||
// 2) Static endpoint string match.
|
||||
const staticMatch = endpointMap.get(rawOperation);
|
||||
if (staticMatch) {
|
||||
return { operation: staticMatch, parameters };
|
||||
}
|
||||
|
||||
// 3) Dynamic endpoint match — replay each function endpoint with the
|
||||
// parameters we have and look for a match.
|
||||
const dynamicMatch = findDynamicEndpointMatch(
|
||||
rawOperation,
|
||||
parameters,
|
||||
toolRegistry,
|
||||
);
|
||||
if (dynamicMatch) {
|
||||
return { operation: dynamicMatch, parameters };
|
||||
}
|
||||
|
||||
unresolved.push(rawOperation);
|
||||
return { operation: rawOperation, parameters };
|
||||
},
|
||||
);
|
||||
|
||||
const name =
|
||||
typeof obj.name === "string" && obj.name.length > 0
|
||||
? obj.name
|
||||
: "Imported Automation";
|
||||
|
||||
return {
|
||||
automation: {
|
||||
name,
|
||||
description: typeof obj.description === "string" ? obj.description : "",
|
||||
icon: typeof obj.icon === "string" ? obj.icon : undefined,
|
||||
operations,
|
||||
},
|
||||
unresolvedOperations: unresolved,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a native Automate JSON file (a previously-exported AutomationConfig).
|
||||
* The id / createdAt / updatedAt fields are dropped — the storage layer
|
||||
* regenerates them on save.
|
||||
*/
|
||||
export function parseAutomationConfigJson(
|
||||
raw: unknown,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
): {
|
||||
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
|
||||
unresolvedOperations: string[];
|
||||
} {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new Error("Invalid automation config: expected JSON object");
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const operations = obj.operations;
|
||||
if (!Array.isArray(operations)) {
|
||||
throw new Error("Invalid automation config: missing 'operations' array");
|
||||
}
|
||||
|
||||
const unresolved: string[] = [];
|
||||
const parsedOperations: AutomationOperation[] = operations.map(
|
||||
(op: unknown, index: number) => {
|
||||
if (!op || typeof op !== "object") {
|
||||
throw new Error(
|
||||
`Invalid automation config: operations[${index}] is not an object`,
|
||||
);
|
||||
}
|
||||
const opObj = op as Record<string, unknown>;
|
||||
const operation = opObj.operation;
|
||||
if (typeof operation !== "string" || operation.length === 0) {
|
||||
throw new Error(
|
||||
`Invalid automation config: operations[${index}].operation must be a non-empty string`,
|
||||
);
|
||||
}
|
||||
const parameters = (opObj.parameters as Record<string, any>) || {};
|
||||
if (!isToolIdInRegistry(operation, toolRegistry)) {
|
||||
unresolved.push(operation);
|
||||
}
|
||||
return { operation, parameters };
|
||||
},
|
||||
);
|
||||
|
||||
const name =
|
||||
typeof obj.name === "string" && obj.name.length > 0
|
||||
? obj.name
|
||||
: "Imported Automation";
|
||||
|
||||
return {
|
||||
automation: {
|
||||
name,
|
||||
description: typeof obj.description === "string" ? obj.description : "",
|
||||
icon: typeof obj.icon === "string" ? obj.icon : undefined,
|
||||
operations: parsedOperations,
|
||||
},
|
||||
unresolvedOperations: unresolved,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic format detector. Folder-scanning JSON uses a `pipeline` array;
|
||||
* native Automate JSON uses an `operations` array. Both is invalid.
|
||||
*/
|
||||
export function detectAutomationFormat(
|
||||
raw: unknown,
|
||||
): "automate" | "folderScanning" | "unknown" {
|
||||
if (!raw || typeof raw !== "object") return "unknown";
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const hasPipeline = Array.isArray(obj.pipeline);
|
||||
const hasOperations = Array.isArray(obj.operations);
|
||||
if (hasPipeline && !hasOperations) return "folderScanning";
|
||||
if (hasOperations && !hasPipeline) return "automate";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON file's text content into a normalized AutomationConfig.
|
||||
* Auto-detects the format unless `expectedFormat` is supplied; throws with a
|
||||
* user-readable message on any structural problem.
|
||||
*/
|
||||
export function parseAutomationFile(
|
||||
fileText: string,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
expectedFormat?: "automate" | "folderScanning",
|
||||
): ParsedAutomationImport {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(fileText);
|
||||
} catch (err) {
|
||||
throw new Error(`File is not valid JSON: ${(err as Error).message}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
const detected = detectAutomationFormat(raw);
|
||||
const format = expectedFormat ?? detected;
|
||||
|
||||
if (expectedFormat && detected !== "unknown" && detected !== expectedFormat) {
|
||||
throw new Error(
|
||||
`Expected ${
|
||||
expectedFormat === "automate"
|
||||
? "Automate JSON (operations array)"
|
||||
: "Folder Scanning JSON (pipeline array)"
|
||||
} but file looks like ${
|
||||
detected === "automate" ? "Automate JSON" : "Folder Scanning JSON"
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "automate") {
|
||||
const result = parseAutomationConfigJson(raw, toolRegistry);
|
||||
return { format: "automate", ...result };
|
||||
}
|
||||
if (format === "folderScanning") {
|
||||
const result = parseFolderScanningConfig(raw, toolRegistry);
|
||||
return { format: "folderScanning", ...result };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unrecognized JSON shape. Expected an Automate config (operations[]) or a Folder Scanning config (pipeline[]).",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: "2.10.0",
|
||||
appVersion: "2.10.1",
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
enableDesktopInstallSlide: true,
|
||||
|
||||
Reference in New Issue
Block a user