Files
jerapah-flow/packages/server/workflow-duplicate.js
T
nsrbandCursor 368a2ca365 feat(shared): extract client/server pure helpers into @jerapah-flow/shared
Share profile merge, workflow filename/path helpers, HTTP_METHODS (with OPTIONS), and isPlainObject so web and server stop drifting. Core script sandboxes keep local isPlainObject copies.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 06:01:42 +07:00

105 lines
3.1 KiB
JavaScript

import yaml from "yaml";
import {
ensureWorkflowFilename,
suggestCopyFilename,
} from "@jerapah-flow/shared";
import {
newWorkflowFilename,
workflowFileStem,
} from "./workflow-normalize.js";
export { ensureWorkflowFilename, suggestCopyFilename };
/**
* UUID-based duplicate filename (default for new duplicates).
* @param {string[]} existingFiles
*/
export function suggestDuplicateFilename(existingFiles = []) {
const existing = new Set(existingFiles);
let file = newWorkflowFilename();
while (existing.has(file)) {
file = newWorkflowFilename();
}
return file;
}
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);
}