diff --git a/AGENTS.md b/AGENTS.md index 34ab958..5182884 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,13 +138,14 @@ Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$finger ```yaml scripts: - - script: plugin/my-plugin + - name: Notify to channel + script: plugin/my-plugin config: url: http://10.8.0.6:3030/notes token: $SECRET_joplin_api_token ``` -Canonical ref is `plugin/` (`.js` suffix is optional). +Optional `name` is the display title in the editor and graph (falls back to the script filename). Canonical ref is `plugin/` (`.js` suffix is optional). ## Do not diff --git a/packages/server/fs-store.js b/packages/server/fs-store.js index bf96205..2e3e6d5 100644 --- a/packages/server/fs-store.js +++ b/packages/server/fs-store.js @@ -50,7 +50,7 @@ export function readScript(name) { } /** - * Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`. + * Icon next to the script: `fetch-html.js` → `fetch-html.png`, `.jpg`, or `.jpeg`. * @returns {{ filePath: string, contentType: string } | null} */ export function resolveScriptIcon(name) { @@ -59,6 +59,7 @@ export function resolveScriptIcon(name) { for (const { ext, contentType } of [ { ext: "png", contentType: "image/png" }, { ext: "jpg", contentType: "image/jpeg" }, + { ext: "jpeg", contentType: "image/jpeg" }, ]) { const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`); if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { diff --git a/packages/server/scripts/fetch-binary.png b/packages/server/scripts/fetch-binary.png new file mode 100644 index 0000000..3ab9b42 Binary files /dev/null and b/packages/server/scripts/fetch-binary.png differ diff --git a/packages/server/scripts/fetch-html.png b/packages/server/scripts/fetch-html.png new file mode 100644 index 0000000..3ab9b42 Binary files /dev/null and b/packages/server/scripts/fetch-html.png differ diff --git a/packages/server/scripts/fetch-http.png b/packages/server/scripts/fetch-http.png new file mode 100644 index 0000000..3ab9b42 Binary files /dev/null and b/packages/server/scripts/fetch-http.png differ diff --git a/packages/server/scripts/fetch-rss-feed.png b/packages/server/scripts/fetch-rss-feed.png new file mode 100644 index 0000000..b5a4d40 Binary files /dev/null and b/packages/server/scripts/fetch-rss-feed.png differ diff --git a/packages/server/scripts/jsonata.png b/packages/server/scripts/jsonata.png new file mode 100644 index 0000000..9715ca3 Binary files /dev/null and b/packages/server/scripts/jsonata.png differ diff --git a/packages/server/scripts/ntfy.png b/packages/server/scripts/ntfy.png new file mode 100644 index 0000000..c1c653e Binary files /dev/null and b/packages/server/scripts/ntfy.png differ diff --git a/packages/server/workflow-parse.js b/packages/server/workflow-parse.js index dd26e4a..b3c722b 100644 --- a/packages/server/workflow-parse.js +++ b/packages/server/workflow-parse.js @@ -9,9 +9,10 @@ export const SET_STEP_SCRIPT = "set"; * kind: "script", * script: string, * profile: string | null, - * config: unknown | null, - * expression?: undefined, - * id: string | null, + * config: unknown | null, + * expression?: undefined, + * name: string | null, + * id: string | null, * needsKind: "none" | "list" | "map", * needs: NeedEdge[], * when: string | null, @@ -21,8 +22,9 @@ export const SET_STEP_SCRIPT = "set"; * script: typeof SET_STEP_SCRIPT, * profile: null, * config: { expression: string }, - * expression: string, - * id: string | null, + * expression: string, + * name: string | null, + * id: string | null, * needsKind: "none" | "list" | "map", * needs: NeedEdge[], * when: string | null, @@ -80,6 +82,7 @@ export function parseScriptStep(step) { script: step, profile: null, config: null, + name: null, id: null, needsKind: "none", needs: [], @@ -119,6 +122,7 @@ export function parseScriptStep(step) { script: hasScript ? step.script : "", profile: hasProfile ? step.profile : null, config: step.config ?? null, + name: parseOptionalName(step.name), id: parseOptionalId(step.id), needsKind, needs, @@ -271,6 +275,7 @@ function parseSetStep(step) { profile: null, config: { expression }, expression, + name: parseOptionalName(step.name), id: parseOptionalId(step.id), needsKind, needs, @@ -291,6 +296,19 @@ function parseWhen(when) { return when; } +/** + * @param {unknown} name + * @returns {string | null} + */ +function parseOptionalName(name) { + if (name == null || name === "") return null; + if (typeof name !== "string") { + throw new Error(`Invalid step name: ${JSON.stringify(name)}`); + } + const trimmed = name.trim(); + return trimmed || null; +} + /** * @param {unknown} id * @returns {string | null} diff --git a/packages/web/package.json b/packages/web/package.json index 6a8e645..59f5b91 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,7 +8,7 @@ "build": "vite build", "preview": "vite preview", "lint": "eslint src", - "test": "node --test src/lib/workflow-graph.test.js src/lib/try-session.test.js" + "test": "node --test src/lib/workflow-graph.test.js src/lib/try-session.test.js src/lib/workflow-doc.test.js" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/packages/web/src/components/ScriptIcon.jsx b/packages/web/src/components/ScriptIcon.jsx index e543f39..cb1bd0a 100644 --- a/packages/web/src/components/ScriptIcon.jsx +++ b/packages/web/src/components/ScriptIcon.jsx @@ -5,7 +5,7 @@ export function scriptIconSrc(name) { return `/api/scripts/${encodeURIComponent(name)}/icon`; } -/** Same-name PNG/JPG next to the script, or a JS document icon. */ +/** Same-name PNG/JPG/JPEG next to the script, or a JS document icon. */ export function ScriptIcon({ name, hasIcon, className = "size-10" }) { const [failed, setFailed] = useState(false); diff --git a/packages/web/src/components/workflow/GraphTab.jsx b/packages/web/src/components/workflow/GraphTab.jsx index 1062a38..f9a6450 100644 --- a/packages/web/src/components/workflow/GraphTab.jsx +++ b/packages/web/src/components/workflow/GraphTab.jsx @@ -42,6 +42,20 @@ import "@xyflow/react/dist/style.css"; const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode }; +function listedHasIcon(listed) { + if (listed == null || typeof listed === "string") return undefined; + return listed.hasIcon; +} + +function stepIconData(node, scriptsByName, profilesByName) { + if (node.type !== "step" || node.data?.kind === "set") { + return { iconScript: "", hasIcon: false }; + } + const profile = node.data?.profile ? profilesByName.get(node.data.profile) : null; + const iconScript = profile?.script || node.data?.script || ""; + return { iconScript, hasIcon: listedHasIcon(scriptsByName.get(iconScript)) }; +} + /** Two Control zoom-out steps from React Flow's default zoom (1). */ const INITIAL_ZOOM = 1 / 1.2 ** 2; const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 }; @@ -87,28 +101,6 @@ function GraphTabInner({ const steps = doc?.scripts ?? []; const triggers = doc?.triggers ?? []; - const { edges } = useMemo(() => buildGraphElements(doc, positions), [doc, positions]); - - useEffect(() => { - const built = buildGraphElements(doc, positions); - setNodes((prev) => { - const selected = new Set(prev.filter((n) => n.selected).map((n) => n.id)); - return built.nodes.map((n) => ({ - ...n, - selected: selected.has(n.id), - deletable: !disabled, - draggable: !disabled, - })); - }); - }, [doc, disabled, positions]); - - useEffect(() => { - const t = requestAnimationFrame(() => fitView(FIT_VIEW)); - return () => cancelAnimationFrame(t); - }, [owner, file, fitView]); - - const selected = parseGraphNodeId(selectedId); - const scriptsByName = useMemo(() => { const map = new Map(); for (const s of scripts) { @@ -126,6 +118,29 @@ function GraphTabInner({ return map; }, [profiles]); + const { edges } = useMemo(() => buildGraphElements(doc, positions), [doc, positions]); + + useEffect(() => { + const built = buildGraphElements(doc, positions); + setNodes((prev) => { + const selected = new Set(prev.filter((n) => n.selected).map((n) => n.id)); + return built.nodes.map((n) => ({ + ...n, + data: { ...n.data, ...stepIconData(n, scriptsByName, profilesByName) }, + selected: selected.has(n.id), + deletable: !disabled, + draggable: !disabled, + })); + }); + }, [doc, disabled, positions, scriptsByName, profilesByName]); + + useEffect(() => { + const t = requestAnimationFrame(() => fitView(FIT_VIEW)); + return () => cancelAnimationFrame(t); + }, [owner, file, fitView]); + + const selected = parseGraphNodeId(selectedId); + const displayEdges = useMemo( () => edges.map((e) => ({ diff --git a/packages/web/src/components/workflow/ScriptCard.jsx b/packages/web/src/components/workflow/ScriptCard.jsx index c57ae54..69b4caf 100644 --- a/packages/web/src/components/workflow/ScriptCard.jsx +++ b/packages/web/src/components/workflow/ScriptCard.jsx @@ -18,7 +18,7 @@ import { LogViewer } from "../LogViewer.jsx"; import { StatusBadge } from "../../lib/format"; import { prettyJson } from "../../lib/script.js"; import { seedTryDialog, stepTryLabel } from "../../lib/try-session.js"; -import { needsMode } from "../../lib/workflow-doc.js"; +import { needsMode, stepCustomName, stepDisplayName } from "../../lib/workflow-doc.js"; import { stepPredecessors, stepSuccessors, @@ -117,7 +117,9 @@ function ScriptCardView({ step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId); const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey); const previewFull = configValueText(mergedConfig, meta?.previewConfigKey); - const baseName = step.kind === "set" ? "set" : scriptName || "untitled"; + const scriptFallback = step.kind === "set" ? "set" : scriptName || "untitled"; + const customName = stepCustomName(step); + const baseName = stepDisplayName(step, scriptFallback); const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName; const setConfig = step.kind === "set" ? { expression: step.expression ?? "" } : null; @@ -180,10 +182,18 @@ function ScriptCardView({ >

{baseName} + {customName && scriptFallback && scriptFallback !== customName ? ( + + {scriptFallback} + + ) : null} {!expanded && preview ? ( + {step.kind === "set" ? (
s.id && s.uiId !== step.uiId) - .map((s) => s.id); + const others = otherSteps.filter((s) => s.id && s.uiId !== step.uiId); + const ids = others.map((s) => s.id); + const nameById = new Map( + others.map((s) => [s.id, stepCustomName(s) || null]), + ); function setMode(next) { if (next === "none") onChange({ ...step, needs: null }); @@ -479,6 +500,11 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) { else onChange({ ...step, needs: {}, when: "" }); } + function idLabel(id) { + const named = nameById.get(id); + return named ? `${id} · ${named}` : id; + } + return (
needs @@ -520,7 +546,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) { }); }} /> - {id} + {idLabel(id)} ); }) @@ -531,6 +557,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) { onChange({ ...step, needs })} /> @@ -539,7 +566,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) { ); } -function NeedsMap({ needs, ids, disabled, onChange }) { +function NeedsMap({ needs, ids, idLabel, disabled, onChange }) { const entries = Object.entries(needs ?? {}); return (
@@ -564,11 +591,11 @@ function NeedsMap({ needs, ids, disabled, onChange }) { onChange={(e) => onChange({ ...needs, [alias]: e.target.value })} > {from && !ids.includes(from) ? ( - + ) : null} {ids.map((id) => ( ))} @@ -874,8 +901,20 @@ function ScriptTryDialog({ } >
+ {!isSet && script ? ( + + ) : null}
-

{isSet ? "set" : script}

+

+ {stepDisplayName(step, isSet ? "set" : script)} +

+ {stepCustomName(step) && script ? ( +

{isSet ? "set" : script}

+ ) : null}

{isSet ? "Dry-run this set with editable data, context, and expression. Does not create an event. Use Apply to card to write the expression back to the step." diff --git a/packages/web/src/components/workflow/graph/GraphNodes.jsx b/packages/web/src/components/workflow/graph/GraphNodes.jsx index e0aa990..9b0df5e 100644 --- a/packages/web/src/components/workflow/graph/GraphNodes.jsx +++ b/packages/web/src/components/workflow/graph/GraphNodes.jsx @@ -1,4 +1,5 @@ import { Handle, Position } from "@xyflow/react"; +import { ScriptIcon } from "../../ScriptIcon.jsx"; export function TriggerGraphNode({ data, selected }) { return ( @@ -19,6 +20,7 @@ export function TriggerGraphNode({ data, selected }) { export function StepGraphNode({ data, selected }) { const kindLabel = data.kind === "set" ? "set" : data.profile ? "profile" : "script"; + const iconScript = data.iconScript || ""; return (

-
-

{kindLabel}

- {data.stepId ? ( - {data.stepId} - ) : null} - {data.when ? ( - - when - - ) : null} - {data.needsMode === "map" ? ( - - map - +
+ {data.kind !== "set" && iconScript ? ( + ) : null} +
+
+

{kindLabel}

+ {data.stepId ? ( + {data.stepId} + ) : null} + {data.when ? ( + + when + + ) : null} + {data.needsMode === "map" ? ( + + map + + ) : null} +
+

+ {data.label} +

+ {data.missingNeeds?.length ? ( +

Unknown needs: {data.missingNeeds.join(", ")}

+ ) : null} +
-

- {data.label} -

- {data.missingNeeds?.length ? ( -

Unknown needs: {data.missingNeeds.join(", ")}

- ) : null} { assert.equal(pruned.byStep.b, undefined); assert.equal(pruned.lastTriedUiId, null); }); + +test("stepTryLabel uses custom name", () => { + assert.equal( + stepTryLabel({ kind: "script", script: "ntfy.js", name: "Notify to channel" }, 0), + "step 1 / Notify to channel", + ); + assert.equal( + stepTryLabel( + { kind: "script", script: "ntfy.js", id: "notify", name: "Notify to channel" }, + 2, + ), + "step 3 / notify (Notify to channel)", + ); +}); diff --git a/packages/web/src/lib/workflow-doc.js b/packages/web/src/lib/workflow-doc.js index 652065e..7dba978 100644 --- a/packages/web/src/lib/workflow-doc.js +++ b/packages/web/src/lib/workflow-doc.js @@ -105,6 +105,19 @@ export function isDagDoc(doc) { return (doc?.scripts ?? []).some((s) => !isEmptyNeeds(s.needs)); } +export function stepCustomName(step) { + return typeof step?.name === "string" ? step.name.trim() : ""; +} + +export function stepDisplayName(step, fallback) { + const custom = stepCustomName(step); + if (custom) return custom; + if (fallback) return fallback; + if (step?.kind === "set") return "set"; + if (step?.profile) return `profile ${step.profile}`; + return step?.script || "untitled"; +} + export function newScriptStep(script, config = {}) { return { uiId: nextUiId("step"), @@ -112,6 +125,7 @@ export function newScriptStep(script, config = {}) { script, profile: "", config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {}, + name: "", id: "", when: "", needs: null, @@ -125,6 +139,7 @@ export function newProfileStep(profileName, script = "") { script, profile: profileName, config: {}, + name: "", id: "", when: "", needs: null, @@ -137,6 +152,7 @@ export function newSetStep() { kind: "set", script: "set", expression: "", + name: "", id: "", when: "", needs: null, @@ -200,6 +216,10 @@ function readOnFailureWorkflow(raw) { return typeof raw?.onFailureWorkflow === "string" ? raw.onFailureWorkflow : ""; } +function readStepName(raw) { + return typeof raw === "string" ? raw : ""; +} + function normalizeStep(step) { const uiId = nextUiId("step"); if (typeof step === "string") { @@ -209,6 +229,7 @@ function normalizeStep(step) { script: step, profile: "", config: {}, + name: "", id: "", when: "", needs: null, @@ -221,6 +242,7 @@ function normalizeStep(step) { script: "", profile: "", config: {}, + name: "", id: "", when: "", needs: null, @@ -233,12 +255,13 @@ function normalizeStep(step) { kind: "set", script: "set", expression: typeof spec.expression === "string" ? spec.expression : "", + name: readStepName(step.name), id: typeof step.id === "string" ? step.id : "", when: typeof step.when === "string" ? step.when : "", needs: step.needs ?? null, }; } - const known = new Set(["script", "profile", "config", "id", "when", "needs", "set"]); + const known = new Set(["script", "profile", "config", "name", "id", "when", "needs", "set"]); /** @type {Record} */ const extra = {}; for (const [key, value] of Object.entries(step)) { @@ -254,6 +277,7 @@ function normalizeStep(step) { script: typeof step.script === "string" ? step.script : "", profile: typeof step.profile === "string" ? step.profile : "", config, + name: readStepName(step.name), id: typeof step.id === "string" ? step.id : "", when: typeof step.when === "string" ? step.when : "", needs: step.needs ?? null, @@ -306,10 +330,16 @@ function normalizeTrigger(raw) { }; } +function dumpStepName(step, out) { + const name = stepCustomName(step); + if (name) out.name = name; +} + function dumpStep(step) { if (step.kind === "set") { /** @type {Record} */ const out = {}; + dumpStepName(step, out); if (step.id) out.id = step.id; out.set = { expression: step.expression ?? "", @@ -320,6 +350,7 @@ function dumpStep(step) { } /** @type {Record} */ const out = {}; + dumpStepName(step, out); if (step.id) out.id = step.id; if (step.profile) { out.profile = step.profile; diff --git a/packages/web/src/lib/workflow-doc.test.js b/packages/web/src/lib/workflow-doc.test.js new file mode 100644 index 0000000..e8c213c --- /dev/null +++ b/packages/web/src/lib/workflow-doc.test.js @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + parseWorkflowYaml, + stringifyWorkflowDoc, + stepCustomName, + stepDisplayName, +} from "./workflow-doc.js"; + +test("step name round-trips in yaml", () => { + const { doc, parseError } = parseWorkflowYaml(` +name: demo +scripts: + - name: Notify to channel + script: ntfy.js + config: + url: https://ntfy.sh/demo + - name: Transform + set: + expression: data +`); + assert.equal(parseError, null); + assert.equal(doc.scripts[0].name, "Notify to channel"); + assert.equal(doc.scripts[0].script, "ntfy.js"); + assert.equal(doc.scripts[0].extra, undefined); + assert.equal(doc.scripts[1].name, "Transform"); + assert.equal(doc.scripts[1].kind, "set"); + + const yaml = stringifyWorkflowDoc(doc); + assert.match(yaml, /name: Notify to channel/); + assert.match(yaml, /script: ntfy.js/); + assert.match(yaml, /name: Transform/); +}); + +test("empty step name is omitted from yaml", () => { + const { doc } = parseWorkflowYaml(` +scripts: + - name: " " + script: ntfy.js +`); + assert.equal(doc.scripts[0].name, " "); + assert.equal(stepCustomName(doc.scripts[0]), ""); + const yaml = stringifyWorkflowDoc(doc); + assert.doesNotMatch(yaml, /^\s*name:/m); + assert.match(yaml, /script: ntfy.js/); +}); + +test("stepDisplayName prefers custom name", () => { + assert.equal( + stepDisplayName({ kind: "script", script: "ntfy.js", name: "Notify to channel" }), + "Notify to channel", + ); + assert.equal(stepDisplayName({ kind: "script", script: "ntfy.js" }), "ntfy.js"); + assert.equal(stepDisplayName({ kind: "set" }), "set"); + assert.equal( + stepDisplayName({ kind: "script", script: "ntfy.js" }, "fallback.js"), + "fallback.js", + ); +}); diff --git a/packages/web/src/lib/workflow-graph.js b/packages/web/src/lib/workflow-graph.js index d8542ff..7627375 100644 --- a/packages/web/src/lib/workflow-graph.js +++ b/packages/web/src/lib/workflow-graph.js @@ -1,4 +1,4 @@ -import { isDagDoc, isEmptyNeeds, needsMode, nextStepId } from "./workflow-doc.js"; +import { isDagDoc, isEmptyNeeds, needsMode, nextStepId, stepCustomName } from "./workflow-doc.js"; export const STEP_NODE_PREFIX = "step:"; export const TRIGGER_NODE_PREFIX = "trig:"; @@ -44,6 +44,8 @@ export function triggerSummary(trigger, owner) { } export function stepLabel(step) { + const custom = stepCustomName(step); + if (custom) return custom; if (step?.kind === "set") return step.id ? `${step.id}: set` : "set"; const target = step?.profile ? `profile ${step.profile}` : step?.script || "untitled"; return step?.id ? `${step.id}: ${target}` : target; @@ -254,6 +256,7 @@ export function buildGraphElements(doc, positions = {}) { data: { layoutKey, label: stepLabel(s), + named: Boolean(stepCustomName(s)), kind: s.kind, stepId: s.id || "", when: s.when || "", diff --git a/packages/web/src/lib/workflow-graph.test.js b/packages/web/src/lib/workflow-graph.test.js index dd78ba4..411fd81 100644 --- a/packages/web/src/lib/workflow-graph.test.js +++ b/packages/web/src/lib/workflow-graph.test.js @@ -6,6 +6,7 @@ import { canConnectSteps, enteringDagWouldStripWhen, removeStepEdge, + stepLabel, stepPredecessors, stepSuccessors, wouldCreateCycle, @@ -113,3 +114,15 @@ test("stepSuccessors / stepPredecessors DAG multi", () => { assert.deepEqual(stepPredecessors(scripts, "d"), ["a"]); assert.deepEqual(stepPredecessors(scripts, "a"), []); }); + +test("stepLabel uses custom name", () => { + assert.equal( + stepLabel({ kind: "script", script: "ntfy.js", name: "Notify to channel" }), + "Notify to channel", + ); + assert.equal(stepLabel({ kind: "script", script: "ntfy.js", id: "n" }), "n: ntfy.js"); + assert.equal( + stepLabel({ kind: "set", id: "xform", name: "Shape payload" }), + "Shape payload", + ); +});