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 (
+
+ );
+}
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}
+ {title}
-
- {text}
-
-
This run stopped early (skipRemaining).
@@ -201,8 +185,8 @@ export function EventDetailPage() {