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}