mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +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]>
|
# Maintainer: Stirling PDF Inc <[email protected]>
|
||||||
pkgname=stirling-pdf-desktop
|
pkgname=stirling-pdf-desktop
|
||||||
pkgver=2.10.0
|
pkgver=2.10.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Stirling PDF Inc <[email protected]>
|
# Maintainer: Stirling PDF Inc <[email protected]>
|
||||||
pkgname=stirling-pdf-server-bin
|
pkgname=stirling-pdf-server-bin
|
||||||
pkgver=2.10.0
|
pkgver=2.10.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ springBoot {
|
|||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
group = 'stirling.software'
|
group = 'stirling.software'
|
||||||
version = '2.10.0'
|
version = '2.10.1'
|
||||||
|
|
||||||
configurations.configureEach {
|
configurations.configureEach {
|
||||||
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
|
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."
|
text = "Automatically finds the title from your PDF content and uses it as the filename."
|
||||||
title = "Smart Renaming"
|
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]
|
[automate]
|
||||||
copyToSaved = "Copy to Saved"
|
copyToSaved = "Copy to Saved"
|
||||||
desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||||
|
export = "Export"
|
||||||
exportForFolderScanning = "Export for Folder Scanning"
|
exportForFolderScanning = "Export for Folder Scanning"
|
||||||
|
import = "Import"
|
||||||
|
importPartialSuccess = "Imported with {{count}} unmapped operation(s): {{ops}}"
|
||||||
|
importSuccess = "Imported automation: {{name}}"
|
||||||
invalidStep = "Invalid step"
|
invalidStep = "Invalid step"
|
||||||
reviewTitle = "Automation Results"
|
reviewTitle = "Automation Results"
|
||||||
title = "Automate"
|
title = "Automate"
|
||||||
@@ -1826,6 +1814,7 @@ title = "Configure {{toolName}}"
|
|||||||
[automate.creation]
|
[automate.creation]
|
||||||
createTitle = "Create Automation"
|
createTitle = "Create Automation"
|
||||||
editTitle = "Edit Automation"
|
editTitle = "Edit Automation"
|
||||||
|
export = "Export"
|
||||||
exportForFolderScanning = "Export for Folder Scanning"
|
exportForFolderScanning = "Export for Folder Scanning"
|
||||||
intro = "Automations run tools sequentially. To get started, add tools in the order you want them to run."
|
intro = "Automations run tools sequentially. To get started, add tools in the order you want them to run."
|
||||||
save = "Save Automation"
|
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."
|
message = "You have unsaved changes. Are you sure you want to go back? All changes will be lost."
|
||||||
title = "Unsaved Changes"
|
title = "Unsaved Changes"
|
||||||
|
|
||||||
|
[automate.entryMenu]
|
||||||
|
label = "Open menu for {{title}}"
|
||||||
|
|
||||||
[automate.files]
|
[automate.files]
|
||||||
placeholder = "Select files to process with this automation"
|
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]
|
[automate.run]
|
||||||
title = "Run Automation"
|
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"
|
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"
|
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]
|
[home.autoSizeSplitPDF]
|
||||||
desc = "Automatically split PDFs by file size or page count"
|
desc = "Automatically split PDFs by file size or page count"
|
||||||
tags = "auto,split,size"
|
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"
|
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"
|
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]
|
[home.pdfOrganiser]
|
||||||
desc = "Remove/Rearrange pages in any order"
|
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"
|
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"
|
title = "Choose Your Billing Period"
|
||||||
totalForSeats = "Total ({{count}} seats)"
|
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]
|
[pdfOrganiser]
|
||||||
header = "PDF Page Organiser"
|
header = "PDF Page Organiser"
|
||||||
placeholder = "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
|
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",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "Stirling-PDF",
|
"productName": "Stirling-PDF",
|
||||||
"version": "2.10.0",
|
"version": "2.10.1",
|
||||||
"identifier": "stirling.pdf.dev",
|
"identifier": "stirling.pdf.dev",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import {
|
|||||||
AutomationTool,
|
AutomationTool,
|
||||||
} from "@app/types/automation";
|
} from "@app/types/automation";
|
||||||
import { useAutomationForm } from "@app/hooks/tools/automate/useAutomationForm";
|
import { useAutomationForm } from "@app/hooks/tools/automate/useAutomationForm";
|
||||||
import { downloadFolderScanningConfig } from "@app/utils/automationConverter";
|
import {
|
||||||
|
downloadAutomationConfig,
|
||||||
|
downloadFolderScanningConfig,
|
||||||
|
} from "@app/utils/automationConverter";
|
||||||
|
|
||||||
interface AutomationCreationProps {
|
interface AutomationCreationProps {
|
||||||
mode: AutomationMode;
|
mode: AutomationMode;
|
||||||
@@ -154,6 +157,24 @@ export default function AutomationCreation({
|
|||||||
const currentConfigTool =
|
const currentConfigTool =
|
||||||
configuraingToolIndex >= 0 ? selectedTools[configuraingToolIndex] : null;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Text
|
<Text
|
||||||
@@ -237,34 +258,34 @@ export default function AutomationCreation({
|
|||||||
{t("automate.creation.save", "Save Automation")}
|
{t("automate.creation.save", "Save Automation")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Group gap="sm" grow>
|
||||||
leftSection={<DownloadIcon />}
|
<Button
|
||||||
onClick={() => {
|
leftSection={<DownloadIcon />}
|
||||||
// Create a temporary automation config from current state
|
onClick={() => {
|
||||||
const tempAutomation: AutomationConfig = {
|
downloadAutomationConfig(buildExportableAutomation());
|
||||||
id: existingAutomation?.id || "temp",
|
}}
|
||||||
name: automationName.trim(),
|
disabled={!canSaveAutomation()}
|
||||||
description: automationDescription.trim(),
|
variant="light"
|
||||||
icon: automationIcon,
|
>
|
||||||
operations: selectedTools.map((tool) => ({
|
{t("automate.creation.export", "Export")}
|
||||||
operation: tool.operation,
|
</Button>
|
||||||
parameters: tool.parameters || {},
|
<Button
|
||||||
})),
|
leftSection={<DownloadIcon />}
|
||||||
createdAt:
|
onClick={() => {
|
||||||
existingAutomation?.createdAt || new Date().toISOString(),
|
downloadFolderScanningConfig(
|
||||||
updatedAt: new Date().toISOString(),
|
buildExportableAutomation(),
|
||||||
};
|
toolRegistry,
|
||||||
downloadFolderScanningConfig(tempAutomation, toolRegistry);
|
);
|
||||||
}}
|
}}
|
||||||
disabled={!canSaveAutomation()}
|
disabled={!canSaveAutomation()}
|
||||||
variant="light"
|
variant="light"
|
||||||
fullWidth
|
>
|
||||||
>
|
{t(
|
||||||
{t(
|
"automate.creation.exportForFolderScanning",
|
||||||
"automate.creation.exportForFolderScanning",
|
"Export for Folder Scanning",
|
||||||
"Export for Folder Scanning",
|
)}
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import EditIcon from "@mui/icons-material/Edit";
|
|||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||||
import { ToolIcon } from "@app/components/shared/ToolIcon";
|
import { ToolIcon } from "@app/components/shared/ToolIcon";
|
||||||
import { ToolRegistry } from "@app/data/toolsTaxonomy";
|
import { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||||
@@ -32,8 +33,12 @@ interface AutomationEntryProps {
|
|||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
/** Copy handler (for suggested automations) */
|
/** Copy handler (for suggested automations) */
|
||||||
onCopy?: () => void;
|
onCopy?: () => void;
|
||||||
/** Export handler (for folder scanning) */
|
/** Export handler — folder scanning JSON */
|
||||||
onExport?: () => void;
|
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 */
|
/** Tool registry to resolve operation names */
|
||||||
toolRegistry?: Partial<ToolRegistry>;
|
toolRegistry?: Partial<ToolRegistry>;
|
||||||
}
|
}
|
||||||
@@ -49,7 +54,9 @@ export default function AutomationEntry({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCopy,
|
onCopy,
|
||||||
onExport,
|
onExportFolderScan,
|
||||||
|
onExportAutomation,
|
||||||
|
onImport,
|
||||||
toolRegistry,
|
toolRegistry,
|
||||||
}: AutomationEntryProps) {
|
}: AutomationEntryProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -211,6 +218,11 @@ export default function AutomationEntry({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
size="md"
|
size="md"
|
||||||
|
aria-label={t(
|
||||||
|
"automate.entryMenu.label",
|
||||||
|
"Open menu for {{title}}",
|
||||||
|
{ title: title || operations.join(" → ") },
|
||||||
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -228,6 +240,17 @@ export default function AutomationEntry({
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
{onImport && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<UploadFileIcon style={{ fontSize: 16 }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onImport();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("automate.import", "Import")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
{onCopy && (
|
{onCopy && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<ContentCopyIcon style={{ fontSize: 16 }} />}
|
leftSection={<ContentCopyIcon style={{ fontSize: 16 }} />}
|
||||||
@@ -250,12 +273,23 @@ export default function AutomationEntry({
|
|||||||
{t("edit", "Edit")}
|
{t("edit", "Edit")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
{onExport && (
|
{onExportAutomation && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onExport();
|
onExportAutomation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("automate.export", "Export")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{onExportFolderScan && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onExportFolderScan();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(
|
{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 { useTranslation } from "react-i18next";
|
||||||
import { Title, Stack, Divider } from "@mantine/core";
|
import { Title, Stack, Divider } from "@mantine/core";
|
||||||
import AddCircleOutline from "@mui/icons-material/AddCircleOutlined";
|
import AddCircleOutline from "@mui/icons-material/AddCircleOutlined";
|
||||||
import SettingsIcon from "@mui/icons-material/Settings";
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
import AutomationEntry from "@app/components/tools/automate/AutomationEntry";
|
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 { useSuggestedAutomations } from "@app/hooks/tools/automate/useSuggestedAutomations";
|
||||||
import { AutomationConfig, SuggestedAutomation } from "@app/types/automation";
|
import { AutomationConfig, SuggestedAutomation } from "@app/types/automation";
|
||||||
import { iconMap } from "@app/components/tools/automate/iconMap";
|
import { iconMap } from "@app/components/tools/automate/iconMap";
|
||||||
import { ToolRegistry } from "@app/data/toolsTaxonomy";
|
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 {
|
interface AutomationSelectionProps {
|
||||||
savedAutomations: AutomationConfig[];
|
savedAutomations: AutomationConfig[];
|
||||||
@@ -16,6 +22,11 @@ interface AutomationSelectionProps {
|
|||||||
onEdit: (automation: AutomationConfig) => void;
|
onEdit: (automation: AutomationConfig) => void;
|
||||||
onDelete: (automation: AutomationConfig) => void;
|
onDelete: (automation: AutomationConfig) => void;
|
||||||
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
|
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
|
||||||
|
onImportAutomation: (
|
||||||
|
automation: ImportableAutomation,
|
||||||
|
) => Promise<AutomationConfig>;
|
||||||
|
onImportError?: (message: string) => void;
|
||||||
|
onImportSuccess?: (message: string) => void;
|
||||||
toolRegistry: Partial<ToolRegistry>;
|
toolRegistry: Partial<ToolRegistry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,11 +37,49 @@ export default function AutomationSelection({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCopyFromSuggested,
|
onCopyFromSuggested,
|
||||||
|
onImportAutomation,
|
||||||
|
onImportError,
|
||||||
|
onImportSuccess,
|
||||||
toolRegistry,
|
toolRegistry,
|
||||||
}: AutomationSelectionProps) {
|
}: AutomationSelectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const suggestedAutomations = useSuggestedAutomations();
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Title
|
<Title
|
||||||
@@ -53,6 +102,8 @@ export default function AutomationSelection({
|
|||||||
operations={[]}
|
operations={[]}
|
||||||
onClick={onCreateNew}
|
onClick={onCreateNew}
|
||||||
keepIconColor={true}
|
keepIconColor={true}
|
||||||
|
showMenu={true}
|
||||||
|
onImport={() => setImportModalOpen(true)}
|
||||||
toolRegistry={toolRegistry}
|
toolRegistry={toolRegistry}
|
||||||
/>
|
/>
|
||||||
{/* Saved Automations */}
|
{/* Saved Automations */}
|
||||||
@@ -72,7 +123,8 @@ export default function AutomationSelection({
|
|||||||
onClick={() => onRun(automation)}
|
onClick={() => onRun(automation)}
|
||||||
showMenu={true}
|
showMenu={true}
|
||||||
onEdit={() => onEdit(automation)}
|
onEdit={() => onEdit(automation)}
|
||||||
onExport={() =>
|
onExportAutomation={() => downloadAutomationConfig(automation)}
|
||||||
|
onExportFolderScan={() =>
|
||||||
downloadFolderScanningConfig(automation, toolRegistry)
|
downloadFolderScanningConfig(automation, toolRegistry)
|
||||||
}
|
}
|
||||||
onDelete={() => onDelete(automation)}
|
onDelete={() => onDelete(automation)}
|
||||||
@@ -110,6 +162,13 @@ export default function AutomationSelection({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<AutomationImportModal
|
||||||
|
opened={importModalOpen}
|
||||||
|
toolRegistry={toolRegistry}
|
||||||
|
onCancel={() => setImportModalOpen(false)}
|
||||||
|
onImport={handleImportSubmit}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ export function useAutomationForm({
|
|||||||
|
|
||||||
const [automationName, setAutomationName] = useState("");
|
const [automationName, setAutomationName] = useState("");
|
||||||
const [automationDescription, setAutomationDescription] = 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 [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
|
||||||
|
|
||||||
const getToolName = useCallback(
|
const getToolName = useCallback(
|
||||||
@@ -54,7 +58,7 @@ export function useAutomationForm({
|
|||||||
) {
|
) {
|
||||||
setAutomationName(existingAutomation.name || "");
|
setAutomationName(existingAutomation.name || "");
|
||||||
setAutomationDescription(existingAutomation.description || "");
|
setAutomationDescription(existingAutomation.description || "");
|
||||||
setAutomationIcon(existingAutomation.icon || "");
|
setAutomationIcon(existingAutomation.icon || "SettingsIcon");
|
||||||
|
|
||||||
const operations = existingAutomation.operations || [];
|
const operations = existingAutomation.operations || [];
|
||||||
const tools = operations.map((op, index) => {
|
const tools = operations.map((op, index) => {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { SuggestedAutomation } from "@app/types/automation";
|
|||||||
|
|
||||||
export interface SavedAutomation extends AutomationConfig {}
|
export interface SavedAutomation extends AutomationConfig {}
|
||||||
|
|
||||||
|
export type ImportableAutomation = Omit<
|
||||||
|
AutomationConfig,
|
||||||
|
"id" | "createdAt" | "updatedAt"
|
||||||
|
>;
|
||||||
|
|
||||||
export function useSavedAutomations() {
|
export function useSavedAutomations() {
|
||||||
const [savedAutomations, setSavedAutomations] = useState<SavedAutomation[]>(
|
const [savedAutomations, setSavedAutomations] = useState<SavedAutomation[]>(
|
||||||
[],
|
[],
|
||||||
@@ -89,6 +94,22 @@ export function useSavedAutomations() {
|
|||||||
[refreshAutomations],
|
[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
|
// Load automations on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSavedAutomations();
|
loadSavedAutomations();
|
||||||
@@ -101,5 +122,6 @@ export function useSavedAutomations() {
|
|||||||
refreshAutomations,
|
refreshAutomations,
|
||||||
deleteAutomation,
|
deleteAutomation,
|
||||||
copyFromSuggested,
|
copyFromSuggested,
|
||||||
|
importAutomation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
|||||||
|
|
||||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||||
enableAnalytics: true,
|
enableAnalytics: true,
|
||||||
appVersion: "2.10.0",
|
appVersion: "2.10.1",
|
||||||
serverCertificateEnabled: false,
|
serverCertificateEnabled: false,
|
||||||
enableAlphaFunctionality: false,
|
enableAlphaFunctionality: false,
|
||||||
serverPort: 8080,
|
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 { 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.describe("11. Automation Page", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/automate");
|
await gotoAutomate(page);
|
||||||
await page.waitForLoadState("domcontentloaded");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("11.1 Automation - Suggested Workflows", () => {
|
test.describe("11.1 Automation - Suggested Workflows", () => {
|
||||||
test("should display saved and suggested workflows", async ({ page }) => {
|
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();
|
const automateLink = page.locator('a[href="/automate"]').first();
|
||||||
await expect(automateLink).toBeVisible();
|
await expect(automateLink).toBeVisible();
|
||||||
|
|
||||||
// Step 2: Verify the Automation Selection header is present
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(/Automation Selection/i).first(),
|
page.getByText(/Automation Selection/i).first(),
|
||||||
).toBeVisible();
|
).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();
|
await expect(page.getByText("Saved").first()).toBeVisible();
|
||||||
|
|
||||||
// Step 4: Verify the Create New Automation entry is present
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(/Create New Automation/i).first(),
|
page.getByText(/Create New Automation/i).first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// Step 5: Verify suggested preset workflows are listed
|
|
||||||
await expect(page.getByText("Suggested").first()).toBeVisible();
|
await expect(page.getByText("Suggested").first()).toBeVisible();
|
||||||
|
|
||||||
const suggestedWorkflows = [
|
const suggestedWorkflows = [
|
||||||
@@ -46,7 +105,7 @@ test.describe("11. Automation Page", () => {
|
|||||||
|
|
||||||
for (const workflow of suggestedWorkflows) {
|
for (const workflow of suggestedWorkflows) {
|
||||||
await expect(page.getByText(workflow).first()).toBeVisible({
|
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 ({
|
test("should open automation builder when clicking create button", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Ensure we're on the selection step first
|
await ensureOnSelection(page);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Click the Create New Automation entry
|
await page
|
||||||
const createEntry = page.getByText(/Create New Automation/i).first();
|
.getByText(/Create New Automation/i)
|
||||||
await createEntry.click();
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Step 2: Verify the automation builder/editor opens with form fields
|
|
||||||
await expect(page.getByText(/Create Automation/i).first()).toBeVisible({
|
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({
|
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.getByText(/Add Tool/i).first()).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: /Save Automation/i }).first(),
|
page.getByRole("button", { name: /Save Automation/i }).first(),
|
||||||
).toBeVisible();
|
).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,
|
deleteAutomation,
|
||||||
refreshAutomations,
|
refreshAutomations,
|
||||||
copyFromSuggested,
|
copyFromSuggested,
|
||||||
|
importAutomation,
|
||||||
} = useSavedAutomations();
|
} = useSavedAutomations();
|
||||||
|
|
||||||
// Use ref to store the latest reset function to avoid closure issues
|
// 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}
|
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 { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||||
import { downloadFile } from "@app/services/downloadService";
|
import { downloadFile } from "@app/services/downloadService";
|
||||||
import { ToolId } from "@app/types/toolId";
|
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 {
|
interface FolderScanningPipeline {
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
pipeline: Array<{
|
pipeline: Array<{
|
||||||
operation: string;
|
operation: string;
|
||||||
parameters: Record<string, any>;
|
parameters: Record<string, any>;
|
||||||
@@ -25,10 +41,28 @@ interface FolderScanningPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts an AutomationConfig to a folder scanning pipeline configuration
|
* Discriminated result returned by {@link parseAutomationFile}.
|
||||||
* @param automation The automation configuration to convert
|
*/
|
||||||
* @param toolRegistry The tool registry to map operation types to endpoints
|
export type ParsedAutomationImport =
|
||||||
* @returns Folder scanning pipeline configuration
|
| {
|
||||||
|
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(
|
export function convertToFolderScanningConfig(
|
||||||
automation: AutomationConfig,
|
automation: AutomationConfig,
|
||||||
@@ -36,8 +70,9 @@ export function convertToFolderScanningConfig(
|
|||||||
): FolderScanningPipeline {
|
): FolderScanningPipeline {
|
||||||
return {
|
return {
|
||||||
name: automation.name,
|
name: automation.name,
|
||||||
|
...(automation.description ? { description: automation.description } : {}),
|
||||||
|
...(automation.icon ? { icon: automation.icon } : {}),
|
||||||
pipeline: automation.operations.map((op) => {
|
pipeline: automation.operations.map((op) => {
|
||||||
// Map operationType to full API endpoint path
|
|
||||||
const toolId = op.operation as ToolId;
|
const toolId = op.operation as ToolId;
|
||||||
const toolEntry = toolRegistry[toolId];
|
const toolEntry = toolRegistry[toolId];
|
||||||
const endpointConfig = toolEntry?.operationConfig?.endpoint;
|
const endpointConfig = toolEntry?.operationConfig?.endpoint;
|
||||||
@@ -47,7 +82,6 @@ export function convertToFolderScanningConfig(
|
|||||||
if (typeof endpointConfig === "string") {
|
if (typeof endpointConfig === "string") {
|
||||||
endpoint = endpointConfig;
|
endpoint = endpointConfig;
|
||||||
} else if (typeof endpointConfig === "function") {
|
} else if (typeof endpointConfig === "function") {
|
||||||
// For dynamic endpoints, call with the saved parameters
|
|
||||||
try {
|
try {
|
||||||
endpoint = endpointConfig(op.parameters);
|
endpoint = endpointConfig(op.parameters);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -85,9 +119,7 @@ export function convertToFolderScanningConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a folder scanning configuration as a JSON file
|
* 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
|
|
||||||
*/
|
*/
|
||||||
export function downloadFolderScanningConfig(
|
export function downloadFolderScanningConfig(
|
||||||
automation: AutomationConfig,
|
automation: AutomationConfig,
|
||||||
@@ -96,5 +128,299 @@ export function downloadFolderScanningConfig(
|
|||||||
const config = convertToFolderScanningConfig(automation, toolRegistry);
|
const config = convertToFolderScanningConfig(automation, toolRegistry);
|
||||||
const json = JSON.stringify(config, null, 2);
|
const json = JSON.stringify(config, null, 2);
|
||||||
const blob = new Blob([json], { type: "application/json" });
|
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 = {
|
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||||
enableAnalytics: true,
|
enableAnalytics: true,
|
||||||
appVersion: "2.10.0",
|
appVersion: "2.10.1",
|
||||||
serverCertificateEnabled: false,
|
serverCertificateEnabled: false,
|
||||||
enableAlphaFunctionality: false,
|
enableAlphaFunctionality: false,
|
||||||
enableDesktopInstallSlide: true,
|
enableDesktopInstallSlide: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user