Enhance automate to have import and export buttons (#6326)

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