From cf4b342e044ebe26f154f6f9e3aa794ae766b052 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Sat, 15 Aug 2026 23:00:16 +0700 Subject: [PATCH] feat(workflows): implement workflow duplication feature - Added functionality to duplicate workflows via a new API endpoint, allowing users to create copies of existing workflows with modified names and owners. - Introduced utility functions for managing workflow filenames and ensuring unique names for duplicates. - Created a user interface component for duplicating workflows, including validation for existing files and owner changes. - Enhanced existing workflows to support the new duplication feature, ensuring seamless integration with the current workflow management system. This update significantly improves user experience by enabling efficient workflow management and organization. --- packages/server/src/api/workflows.js | 100 +++++++++++++++ packages/server/workflow-duplicate.js | 111 +++++++++++++++++ .../default/comic-monkeyuser-to-ntfy.yaml | 15 ++- .../server/workflows/default/registers.yaml | 1 + .../workflows/default/rss-devto-to-ntfy.yaml | 27 ++++ .../default/rss-selfhst-to-ntfy.yaml | 5 +- packages/web/package.json | 1 + packages/web/src/api/hooks.js | 21 ++++ .../components/DuplicateWorkflowDialog.jsx | 117 ++++++++++++++++++ packages/web/src/components/JsonViewBlock.jsx | 41 ++++++ packages/web/src/lib/workflow-doc.js | 29 +++++ packages/web/src/pages/EventDetailPage.jsx | 28 +---- packages/web/src/pages/WorkflowEditPage.jsx | 103 ++++++++++----- packages/web/src/pages/WorkflowsPage.jsx | 26 +++- pnpm-lock.yaml | 21 ++++ 15 files changed, 579 insertions(+), 67 deletions(-) create mode 100644 packages/server/workflow-duplicate.js create mode 100644 packages/server/workflows/default/rss-devto-to-ntfy.yaml create mode 100644 packages/web/src/components/DuplicateWorkflowDialog.jsx create mode 100644 packages/web/src/components/JsonViewBlock.jsx diff --git a/packages/server/src/api/workflows.js b/packages/server/src/api/workflows.js index 1be33bf..abc4bea 100644 --- a/packages/server/src/api/workflows.js +++ b/packages/server/src/api/workflows.js @@ -10,6 +10,11 @@ import { authLabel, validateWorkflowHttpTriggers, } from "../../workflow-http-validate.js"; +import { + duplicateWorkflowYaml, + ensureWorkflowFilename, + suggestCopyFilename, +} from "../../workflow-duplicate.js"; function triggerSummary(owner, workflow) { if (!workflow || typeof workflow !== "object") return []; @@ -259,6 +264,101 @@ export default function workflowsPluginFactory(registry) { return { ok: true }; }); + fastify.post("/workflows/:owner/:file/duplicate", async (req, reply) => { + const { owner, file } = /** @type {{ owner: string, file: string }} */ ( + req.params + ); + try { + fsStore.assertOwner(owner); + fsStore.assertWorkflowFile(file); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + const source = fsStore.readWorkflowYaml(owner, file); + if (source == null) { + return reply.code(404).send({ error: "workflow not found" }); + } + + const body = /** @type {{ file?: unknown, owner?: unknown }} */ (req.body ?? {}); + let destOwner = owner; + if (body.owner != null && body.owner !== "") { + if (typeof body.owner !== "string") { + return reply.code(400).send({ error: "owner must be a string" }); + } + try { + destOwner = fsStore.assertOwner(body.owner); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + } + + let destFile; + try { + if (body.file == null || body.file === "") { + destFile = suggestCopyFilename(file, fsStore.listOwnerYamlFiles(destOwner)); + } else if (typeof body.file !== "string") { + return reply.code(400).send({ error: "file must be a string" }); + } else { + destFile = ensureWorkflowFilename(body.file); + } + fsStore.assertWorkflowFile(destFile); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + + if (destOwner === owner && destFile === file) { + return reply.code(400).send({ error: "cannot duplicate onto itself" }); + } + if (fsStore.readWorkflowYaml(destOwner, destFile) != null) { + return reply.code(409).send({ error: "workflow already exists" }); + } + + let content; + try { + content = duplicateWorkflowYaml(source, { + sourceFile: file, + destFile, + rewriteHttpPaths: destOwner === owner, + }); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ + error: err instanceof Error ? err.message : String(err), + }); + } + + let parsed; + try { + parsed = yaml.parse(content); + } catch (err) { + return reply.code(400).send({ + error: `invalid yaml: ${err instanceof Error ? err.message : String(err)}`, + }); + } + try { + compileWorkflowScripts(parsed?.scripts); + } catch (err) { + return reply.code(400).send({ + error: err instanceof Error ? err.message : String(err), + }); + } + try { + await validateWorkflowHttpTriggers(parsed); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ + error: err instanceof Error ? err.message : String(err), + }); + } + + fsStore.writeWorkflowYaml(destOwner, destFile, content); + const registered = fsStore.readRegisters(destOwner); + if (!registered.includes(destFile)) { + registered.push(destFile); + fsStore.writeRegisters(destOwner, registered); + } + registry.reregister(); + return reply.code(201).send({ owner: destOwner, file: destFile }); + }); + fastify.post("/workflows/:owner/:file/run", async (req, reply) => { const { owner, file } = /** @type {{ owner: string, file: string }} */ ( req.params diff --git a/packages/server/workflow-duplicate.js b/packages/server/workflow-duplicate.js new file mode 100644 index 0000000..6e41506 --- /dev/null +++ b/packages/server/workflow-duplicate.js @@ -0,0 +1,111 @@ +import yaml from "yaml"; + +export function ensureWorkflowFilename(file) { + const trimmed = String(file ?? "").trim(); + if (!trimmed) return ""; + return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`; +} + +export function workflowFileStem(file) { + return String(file).replace(/\.ya?ml$/i, ""); +} + +/** + * Next unused copy filename: `track.yaml` → `track-copy.yaml`, + * `track-copy.yaml` → `track-copy-2.yaml`. + * @param {string} file + * @param {string[]} existingFiles + */ +export function suggestCopyFilename(file, existingFiles = []) { + const name = ensureWorkflowFilename(file) || "workflow.yaml"; + const match = name.match(/^(.*?)(\.ya?ml)$/i); + const base = match ? match[1] : name; + const ext = match ? match[2] : ".yaml"; + const existing = new Set(existingFiles); + + const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/); + const root = copyMatch ? copyMatch[1] : base; + const candidate = (i) => + i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`; + + let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1; + while (existing.has(candidate(n))) n += 1; + return candidate(n); +} + +export function nextCopyName(name) { + const trimmed = String(name ?? "").trim(); + if (!trimmed) return "copy"; + const match = trimmed.match(/^(.*) \(copy(?: (\d+))?\)$/); + if (!match) return `${trimmed} (copy)`; + const n = match[2] ? Number(match[2]) + 1 : 2; + return `${match[1]} (copy ${n})`; +} + +export function httpPathCopySuffix(sourceFile, destFile) { + const src = workflowFileStem(sourceFile); + const dest = workflowFileStem(destFile); + if (dest.startsWith(`${src}-`) && dest.length > src.length + 1) { + return dest.slice(src.length + 1); + } + if (dest === src) return "copy"; + return dest || "copy"; +} + +function suffixHttpPath(path, suffix) { + const trimmed = String(path).replace(/\/+$/, ""); + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + const safe = String(suffix).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "copy"; + return `${withSlash}-${safe}`; +} + +function rewriteHttpTriggerPaths(doc, suffix) { + const triggers = doc.get("triggers"); + if (!yaml.isSeq(triggers)) return; + for (const item of triggers.items) { + if (!yaml.isMap(item)) continue; + if (String(item.get("type") ?? "").toLowerCase() !== "http") continue; + const path = item.get("path"); + if (typeof path !== "string" || !path.trim()) continue; + item.set("path", suffixHttpPath(path, suffix)); + } +} + +/** + * Copy workflow YAML: append " (copy)" to name, disable, optionally rewrite HTTP paths. + * Preserves comments via YAML CST. + * @param {string} content + * @param {{ sourceFile: string, destFile: string, rewriteHttpPaths?: boolean }} opts + */ +export function duplicateWorkflowYaml(content, opts) { + const sourceFile = opts?.sourceFile ?? ""; + const destFile = opts?.destFile ?? ""; + const rewriteHttpPaths = opts?.rewriteHttpPaths !== false; + + const doc = yaml.parseDocument(content); + if (doc.errors?.length) { + const err = new Error(doc.errors[0]?.message ?? "invalid yaml"); + err.statusCode = 400; + throw err; + } + const parsed = doc.toJSON(); + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + const err = new Error("workflow yaml must be an object"); + err.statusCode = 400; + throw err; + } + + const currentName = doc.get("name"); + if (typeof currentName === "string" && currentName.trim()) { + doc.set("name", nextCopyName(currentName)); + } else { + doc.set("name", workflowFileStem(destFile) || "copy"); + } + doc.set("enabled", false); + + if (rewriteHttpPaths) { + rewriteHttpTriggerPaths(doc, httpPathCopySuffix(sourceFile, destFile)); + } + + return String(doc); +} diff --git a/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml b/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml index dbc554e..8e8690b 100644 --- a/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml +++ b/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml @@ -2,24 +2,23 @@ name: Comic - monkeyuser to ntfy scripts: - script: fetch-html.js config: - url: "https://www.monkeyuser.com/" - outputVar: "httpResponse" - selector: ".comic img" + url: https://www.monkeyuser.com/ + outputVar: comic + selector: .comic img jsonata: | {"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]} - script: jsonata.js config: expression: | { - "title": data.httpResponse.title, - "message": data.httpResponse.title, - "attach": data.httpResponse.url + "title": data.comic.title, + "message": data.comic.title, + "attach": data.comic.url } - - fetch-binary.js + - script: fetch-binary.js - script: ntfy.js config: url: https://ntfy.sh/jerapah-flow - triggers: - type: HTTP method: POST diff --git a/packages/server/workflows/default/registers.yaml b/packages/server/workflows/default/registers.yaml index 075139f..7273056 100644 --- a/packages/server/workflows/default/registers.yaml +++ b/packages/server/workflows/default/registers.yaml @@ -9,3 +9,4 @@ scripts: - send-gmail.yaml - test-send-gmail.yaml - track.yaml + - rss-devto-to-ntfy.yaml diff --git a/packages/server/workflows/default/rss-devto-to-ntfy.yaml b/packages/server/workflows/default/rss-devto-to-ntfy.yaml new file mode 100644 index 0000000..16b7cb3 --- /dev/null +++ b/packages/server/workflows/default/rss-devto-to-ntfy.yaml @@ -0,0 +1,27 @@ +name: RSS - selfh.st first item to ntfy (copy) +scripts: + - script: fetch-rss-feed.js + config: + url: https://selfh.st/rss/ + outputVar: item + jsonata: items[0] + - script: fingerprint.js + config: + key: selfhst-latest + jsonata: "data.item.guid ? data.item.guid : data.item.link" + - script: jsonata.js + config: + expression: | + { + "title": data.item.title, + "message": data.item.contentSnippet & "\n" & data.item.link, + "attach": data.item.mediaContent.url ? data.item.mediaContent.url : (data.item.mediaContent.$ ? data.item.mediaContent.$.url : undefined) + } + - script: ntfy.js + config: + url: https://n.0dev.web.id/system +triggers: + - type: HTTP + method: POST + path: /selfhst-rss-rss-devto-to-ntfy +enabled: false diff --git a/packages/server/workflows/default/rss-selfhst-to-ntfy.yaml b/packages/server/workflows/default/rss-selfhst-to-ntfy.yaml index b305130..900bfe2 100644 --- a/packages/server/workflows/default/rss-selfhst-to-ntfy.yaml +++ b/packages/server/workflows/default/rss-selfhst-to-ntfy.yaml @@ -2,7 +2,7 @@ name: RSS - selfh.st first item to ntfy scripts: - script: fetch-rss-feed.js config: - url: "https://selfh.st/rss/" + url: https://selfh.st/rss/ outputVar: item jsonata: items[0] - script: fingerprint.js @@ -19,8 +19,7 @@ scripts: } - script: ntfy.js config: - url: https://ntfy.sh/jerapah-flow - + url: https://n.0dev.web.id/system triggers: - type: HTTP method: POST diff --git a/packages/web/package.json b/packages/web/package.json index 9bdd243..0c484f9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,6 +14,7 @@ "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.84.0", + "@uiw/react-json-view": "2.0.0-alpha.43", "axios": "^1.11.0", "cronstrue": "^3.24.0", "mermaid": "^11.9.0", diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index 72f052c..0da8036 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -199,6 +199,27 @@ export function useDeleteWorkflow() { }); } +export function useDuplicateWorkflow() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ owner, file, destOwner, destFile }) => + ( + await api.post( + `/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`, + { + ...(destOwner ? { owner: destOwner } : {}), + ...(destFile ? { file: destFile } : {}), + }, + ) + ).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workflows"] }); + qc.invalidateQueries({ queryKey: ["owners"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + }, + }); +} + export function useRunWorkflow() { const qc = useQueryClient(); return useMutation({ diff --git a/packages/web/src/components/DuplicateWorkflowDialog.jsx b/packages/web/src/components/DuplicateWorkflowDialog.jsx new file mode 100644 index 0000000..63ac181 --- /dev/null +++ b/packages/web/src/components/DuplicateWorkflowDialog.jsx @@ -0,0 +1,117 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { errorMessage } from "../api/client.js"; +import { useDuplicateWorkflow, useOwners, useWorkflows } from "../api/hooks.js"; +import { ensureWorkflowFilename, suggestCopyFilename } from "../lib/workflow-doc.js"; + +const EMPTY_WORKFLOWS = []; + +export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) { + const { data: owners = [] } = useOwners(); + const { data: workflows = EMPTY_WORKFLOWS } = useWorkflows(); + const duplicate = useDuplicateWorkflow(); + const [destOwner, setDestOwner] = useState(source.owner); + const [destFile, setDestFile] = useState(() => suggestCopyFilename(source.file)); + const fileTouched = useRef(false); + + const existingFiles = useMemo( + () => workflows.filter((w) => w.owner === destOwner).map((w) => w.file), + [workflows, destOwner], + ); + + useEffect(() => { + if (fileTouched.current) return; + setDestFile(suggestCopyFilename(source.file, existingFiles)); + }, [source.file, existingFiles]); + + const yamlFile = ensureWorkflowFilename(destFile); + const sameAsSource = destOwner === source.owner && yamlFile === source.file; + const exists = existingFiles.includes(yamlFile); + const canSubmit = Boolean(destOwner && yamlFile) && !sameAsSource && !exists && !duplicate.isPending; + + function onSubmit(e) { + e.preventDefault(); + if (!canSubmit) return; + duplicate.mutate( + { + owner: source.owner, + file: source.file, + destOwner, + destFile: yamlFile, + }, + { + onSuccess: (data) => { + onDuplicated?.(data); + }, + }, + ); + } + + return ( + +
+

Duplicate {source.key}?

+

+ The copy starts disabled. HTTP paths are rewritten when staying under the same owner so + triggers do not collide. +

+ {warnUnsaved ? ( +

The copy uses the last saved YAML, not unsaved edits.

+ ) : null} +
+ + + {sameAsSource ? ( +

Choose a different owner or filename.

+ ) : exists ? ( +

{destOwner}/{yamlFile} already exists.

+ ) : null} + {duplicate.isError ? ( +

{errorMessage(duplicate.error)}

+ ) : null} +
+ + +
+
+
+
+ +
+
+ ); +} diff --git a/packages/web/src/components/JsonViewBlock.jsx b/packages/web/src/components/JsonViewBlock.jsx new file mode 100644 index 0000000..8860e04 --- /dev/null +++ b/packages/web/src/components/JsonViewBlock.jsx @@ -0,0 +1,41 @@ +import JsonView from "@uiw/react-json-view"; +import { darkTheme } from "@uiw/react-json-view/dark"; +import { lightTheme } from "@uiw/react-json-view/light"; +import { useTheme } from "../theme.jsx"; + +/** + * Collapsible JSON tree using @uiw/react-json-view. + * Docs: value (JSON), keyName (root label), collapsed (depth), + * enableClipboard, displayObjectSize, displayDataTypes, style theme vars. + */ +export function JsonViewBlock({ title, value }) { + const { theme } = useTheme() ?? { theme: "light" }; + if (value === undefined) return null; + + const isTree = value != null && typeof value === "object"; + + return ( +
+ {title} +
+
+ +
+
+
+ ); +} diff --git a/packages/web/src/lib/workflow-doc.js b/packages/web/src/lib/workflow-doc.js index b280318..42a1b62 100644 --- a/packages/web/src/lib/workflow-doc.js +++ b/packages/web/src/lib/workflow-doc.js @@ -12,6 +12,35 @@ triggers: path: /new `; +export function ensureWorkflowFilename(file) { + const trimmed = String(file ?? "").trim(); + if (!trimmed) return ""; + return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`; +} + +/** + * Next unused copy filename: `track.yaml` → `track-copy.yaml`, + * `track-copy.yaml` → `track-copy-2.yaml`. + * @param {string} file + * @param {string[]} existingFiles + */ +export function suggestCopyFilename(file, existingFiles = []) { + const name = ensureWorkflowFilename(file) || "workflow.yaml"; + const match = name.match(/^(.*?)(\.ya?ml)$/i); + const base = match ? match[1] : name; + const ext = match ? match[2] : ".yaml"; + const existing = new Set(existingFiles); + + const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/); + const root = copyMatch ? copyMatch[1] : base; + const candidate = (i) => + i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`; + + let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1; + while (existing.has(candidate(n))) n += 1; + return candidate(n); +} + let uidSeq = 0; export function nextUiId(prefix = "ui") { diff --git a/packages/web/src/pages/EventDetailPage.jsx b/packages/web/src/pages/EventDetailPage.jsx index 372005a..0a90357 100644 --- a/packages/web/src/pages/EventDetailPage.jsx +++ b/packages/web/src/pages/EventDetailPage.jsx @@ -2,9 +2,9 @@ import { useMemo, useState, Fragment } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { LuArrowLeft, LuFilter } from "react-icons/lu"; import { useRun } from "../api/hooks.js"; +import { JsonViewBlock } from "../components/JsonViewBlock.jsx"; import { LogViewer } from "../components/LogViewer.jsx"; import { formatTime, StatusBadge } from "../lib/format.jsx"; -import { prettyJson } from "../lib/script.js"; function stepLabel(s) { if (s.script === "set") return "set"; @@ -19,22 +19,6 @@ function isEditableScript(s) { return Boolean(s.script) && s.script !== "set"; } -function JsonBlock({ title, value }) { - if (value == null || value === "") return null; - const text = prettyJson(value); - if (!text) return null; - return ( -
- {title} -
-
-          {text}
-        
-
-
- ); -} - function envelopeParts(raw) { if (raw != null && typeof raw === "object" && !Array.isArray(raw) && ("output" in raw || "context" in raw)) { return { @@ -128,9 +112,9 @@ export function EventDetailPage() { ) : null}
- - - + + +
{runParts.skipRemaining ? (

This run stopped early (skipRemaining).

@@ -201,8 +185,8 @@ export function EventDetailPage() {
- - + +
diff --git a/packages/web/src/pages/WorkflowEditPage.jsx b/packages/web/src/pages/WorkflowEditPage.jsx index 5855002..03c6d1e 100644 --- a/packages/web/src/pages/WorkflowEditPage.jsx +++ b/packages/web/src/pages/WorkflowEditPage.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; -import { LuArrowLeft, LuPause, LuPlay, LuSave } from "react-icons/lu"; +import { LuArrowLeft, LuCopy, LuPause, LuPlay, LuSave } from "react-icons/lu"; import { errorMessage } from "../api/client.js"; import { useOwners, @@ -8,6 +8,7 @@ import { useSetWorkflowEnabled, useWorkflow, } from "../api/hooks.js"; +import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx"; import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx"; import { WorkflowVisualEditor } from "../components/workflow/WorkflowVisualEditor.jsx"; import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js"; @@ -21,6 +22,7 @@ function WorkflowEditorLayout({ saveSuccess, onSave, onTest, + onDuplicate, onToggleEnabled, enabled, enablePending, @@ -60,6 +62,12 @@ function WorkflowEditorLayout({ Test ) : null} + {onDuplicate ? ( + + ) : null}