diff --git a/client/src/components/Markdown/Markdown.vue b/client/src/components/Markdown/Markdown.vue index edcd3abf4a01..4d15b58b7e42 100644 --- a/client/src/components/Markdown/Markdown.vue +++ b/client/src/components/Markdown/Markdown.vue @@ -74,15 +74,12 @@ import Vue from "vue"; import { useWorkflowStore } from "@/stores/workflowStore"; +import { splitMarkdown as splitMarkdownUnrendered } from "./parse"; + import MarkdownContainer from "./MarkdownContainer.vue"; import LoadingSpan from "components/LoadingSpan.vue"; import StsDownloadButton from "components/StsDownloadButton.vue"; -const FUNCTION_VALUE_REGEX = `\\s*(?:[\\w_\\-]+|\\"[^\\"]+\\"|\\'[^\\']+\\')\\s*`; -const FUNCTION_CALL = `\\s*[\\w\\|]+\\s*=` + FUNCTION_VALUE_REGEX; -const FUNCTION_CALL_LINE = `\\s*(\\w+)\\s*\\(\\s*(?:(${FUNCTION_CALL})(,${FUNCTION_CALL})*)?\\s*\\)\\s*`; -const FUNCTION_CALL_LINE_TEMPLATE = new RegExp(FUNCTION_CALL_LINE, "m"); - const mdNewline = markdownItRegexp(/
/, () => { return "

"; }); @@ -195,70 +192,15 @@ export default { } }, splitMarkdown(markdown) { - const sections = []; - let digest = markdown; - while (digest.length > 0) { - const galaxyStart = digest.indexOf("```galaxy"); - if (galaxyStart != -1) { - const galaxyEnd = digest.substr(galaxyStart + 1).indexOf("```"); - if (galaxyEnd != -1) { - if (galaxyStart > 0) { - const defaultContent = digest.substr(0, galaxyStart).trim(); - if (defaultContent) { - sections.push({ - name: "default", - content: md.render(defaultContent), - }); - } - } - const galaxyEndIndex = galaxyEnd + 4; - const galaxySection = digest.substr(galaxyStart, galaxyEndIndex); - let args = null; - try { - args = this.getArgs(galaxySection); - sections.push(args); - } catch (e) { - this.markdownErrors.push({ - error: "Found an unresolved tag.", - line: galaxySection, - }); - } - digest = digest.substr(galaxyStart + galaxyEndIndex); - } else { - digest = digest.substr(galaxyStart + 1); - } - } else { - sections.push({ - name: "default", - content: md.render(digest), - }); - break; + const { sections, markdownErrors } = splitMarkdownUnrendered(markdown); + markdownErrors.forEach((error) => markdownErrors.push(error)); + sections.forEach((section) => { + if (section.name == "default") { + section.content = md.render(section.content); } - } + }); return sections; }, - getArgs(content) { - const galaxy_function = FUNCTION_CALL_LINE_TEMPLATE.exec(content); - const args = {}; - const function_name = galaxy_function[1]; - // we need [... ] to return empty string, if regex doesn't match - const function_arguments = [...content.matchAll(new RegExp(FUNCTION_CALL, "g"))]; - for (let i = 0; i < function_arguments.length; i++) { - if (function_arguments[i] === undefined) { - continue; - } - const arguments_str = function_arguments[i].toString().replace(/,/g, "").trim(); - if (arguments_str) { - const [key, val] = arguments_str.split("="); - args[key.trim()] = val.replace(/['"]+/g, "").trim(); - } - } - return { - name: function_name, - args: args, - content: content, - }; - }, }, }; diff --git a/client/src/components/Markdown/parse.test.js b/client/src/components/Markdown/parse.test.js new file mode 100644 index 000000000000..50b1ae99397d --- /dev/null +++ b/client/src/components/Markdown/parse.test.js @@ -0,0 +1,175 @@ +import { getArgs, replaceLabel, splitMarkdown } from "./parse"; + +describe("parse.ts", () => { + describe("getArgs", () => { + it("parses simple directive expression", () => { + const args = getArgs("job_metrics(job_id=THISFAKEID)"); + expect(args.name).toBe("job_metrics"); + }); + + it("parses labels spaces at the end with single quotes", () => { + const args = getArgs("job_metrics(step=' fakestepname ')"); + expect(args.name).toBe("job_metrics"); + expect(args.args.step).toBe(" fakestepname "); + }); + + it("parses labels spaces at the end with double quotes", () => { + const args = getArgs('job_metrics(step=" fakestepname ")'); + expect(args.name).toBe("job_metrics"); + expect(args.args.step).toBe(" fakestepname "); + }); + }); + + describe("splitMarkdown", () => { + it("strip leading whitespace by default", () => { + const { sections } = splitMarkdown("\n```galaxy\njob_metrics(job_id=THISFAKEID)\n```"); + expect(sections.length).toBe(1); + }); + + it("should not strip leading whitespace if disabled", () => { + const { sections } = splitMarkdown("\n```galaxy\njob_metrics(job_id=THISFAKEID)\n```", true); + expect(sections.length).toBe(2); + expect(sections[0].content).toBe("\n"); + }); + + it("should parse labels with leading spaces", () => { + const { sections } = splitMarkdown("\n```galaxy\njob_metrics(step='THISFAKEID ')\n```", true); + expect(sections.length).toBe(2); + expect(sections[0].content).toBe("\n"); + }); + }); + + describe("replaceLabel", () => { + it("should leave unaffected markdown alone", () => { + const input = "some random\n`markdown content`\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(result); + }); + + it("should leave unaffected galaxy directives alone", () => { + const input = "some random\n`markdown content`\n```galaxy\ncurrent_time()\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(result); + }); + + it("should leave galaxy directives of same type with other labels alone", () => { + const input = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output=moo)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(result); + }); + + it("should leave galaxy directives of other types with same labels alone", () => { + const input = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(input=from)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(result); + }); + + it("should swap simple directives of specified type", () => { + const input = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output=from)\n```\n"; + const output = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output=to)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + it("should swap single quoted directives of specified type", () => { + const input = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output='from')\n```\n"; + const output = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output=to)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + it("should swap single quoted directives of specified type with extra args", () => { + const input = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output='from', title=dog)\n```\n"; + const output = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output=to, title=dog)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + it("should swap double quoted directives of specified type", () => { + const input = 'some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output="from")\n```\n'; + const output = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(output=to)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + it("should swap double quoted directives of specified type with extra args", () => { + const input = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output=\"from\", title=dog)\n```\n"; + const output = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output=to, title=dog)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + it("should work with double quotes for labels and spaces in the quotes", () => { + const input = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output=\"from this\", title=dog)\n```\n"; + const output = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output=\"to that\", title=dog)\n```\n"; + const result = replaceLabel(input, "output", "from this", "to that"); + expect(result).toBe(output); + }); + + it("should work with single quotes for labels and spaces in the quotes", () => { + const input = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output='from this', title=dog)\n```\n"; + const output = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output='to that', title=dog)\n```\n"; + const result = replaceLabel(input, "output", "from this", "to that"); + expect(result).toBe(output); + }); + + it("should work with single quotes for labels and spaces in the quotes including end", () => { + const input = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output='from this ', title=dog)\n```\n"; + const output = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output='to thatx ', title=dog)\n```\n"; + const result = replaceLabel(input, "output", "from this ", "to thatx "); + expect(result).toBe(output); + }); + + it("should add quotes when refactoring to labels with spaces", () => { + const input = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output=fromthis, title=dog)\n```\n"; + const output = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output='to that', title=dog)\n```\n"; + const result = replaceLabel(input, "output", "fromthis", "to that"); + expect(result).toBe(output); + }); + + it("should add quotes when refactoring to labels with spaces including end space", () => { + const input = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output=fromthis, title=dog)\n```\n"; + const output = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(footer='cow', output='to that ', title=dog)\n```\n"; + const result = replaceLabel(input, "output", "fromthis", "to that "); + expect(result).toBe(output); + }); + + it("should leave non-arguments alone", () => { + const input = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(title='cow from farm', output=from)\n```\n"; + const output = + "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(title='cow from farm', output=to)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + + // not a valid workflow label per se but make sure we're escaping the regex to be safe + it("should not be messed up by labels containing regexp content", () => { + const input = "```galaxy\nhistory_dataset_embedded(output='from(')\n```\n"; + const output = "```galaxy\nhistory_dataset_embedded(output=to$1)\n```\n"; + const result = replaceLabel(input, "output", "from(", "to$1"); + expect(result).toBe(output); + }); + + it("should not swallow leading newlines", () => { + const input = "\n```galaxy\nhistory_dataset_embedded(output='from')\n```\n"; + const output = "\n```galaxy\nhistory_dataset_embedded(output=to)\n```\n"; + const result = replaceLabel(input, "output", "from", "to"); + expect(result).toBe(output); + }); + }); +}); diff --git a/client/src/components/Markdown/parse.ts b/client/src/components/Markdown/parse.ts new file mode 100644 index 000000000000..f7d5b17cd562 --- /dev/null +++ b/client/src/components/Markdown/parse.ts @@ -0,0 +1,170 @@ +const FUNCTION_ARGUMENT_VALUE_REGEX = `\\s*(?:[\\w_\\-]+|\\"[^\\"]+\\"|\\'[^\\']+\\')\\s*`; +const FUNCTION_ARGUMENT_VALUE_TO_VALUE_REGEX = `\\s*(?:\\"(?[^\\"]+)\\"|\\'(?[^\\']+)\\'|(?[\\w_\\-]+))\\s*`; +const FUNCTION_ARGUMENT_REGEX = `\\s*[\\w\\|]+\\s*=` + FUNCTION_ARGUMENT_VALUE_REGEX; +const FUNCTION_CALL_LINE = `\\s*(\\w+)\\s*\\(\\s*(?:(${FUNCTION_ARGUMENT_REGEX})(,${FUNCTION_ARGUMENT_REGEX})*)?\\s*\\)\\s*`; +const FUNCTION_CALL_LINE_TEMPLATE = new RegExp(FUNCTION_CALL_LINE, "m"); + +type DefaultSection = { name: "default"; content: string }; +type GalaxyDirectiveSection = { name: string; content: string; args: { [key: string]: string } }; +type Section = DefaultSection | GalaxyDirectiveSection; + +type WorkflowLabelKind = "input" | "output" | "step"; + +const SINGLE_QUOTE = "'"; +const DOUBLE_QUOTE = '"'; + +export function splitMarkdown(markdown: string, preserveWhitespace: boolean = false) { + const sections: Section[] = []; + const markdownErrors = []; + let digest = markdown; + while (digest.length > 0) { + const galaxyStart = digest.indexOf("```galaxy"); + if (galaxyStart != -1) { + const galaxyEnd = digest.substr(galaxyStart + 1).indexOf("```"); + if (galaxyEnd != -1) { + if (galaxyStart > 0) { + const rawContent = digest.substr(0, galaxyStart); + const defaultContent = rawContent.trim(); + if (preserveWhitespace || defaultContent) { + sections.push({ + name: "default", + content: preserveWhitespace ? rawContent : defaultContent, + }); + } + } + const galaxyEndIndex = galaxyEnd + 4; + const galaxySection = digest.substr(galaxyStart, galaxyEndIndex); + let args = null; + try { + args = getArgs(galaxySection); + sections.push(args); + } catch (e) { + markdownErrors.push({ + error: "Found an unresolved tag.", + line: galaxySection, + }); + } + digest = digest.substr(galaxyStart + galaxyEndIndex); + } else { + digest = digest.substr(galaxyStart + 1); + } + } else { + sections.push({ + name: "default", + content: digest, + }); + break; + } + } + return { sections, markdownErrors }; +} + +export function replaceLabel( + markdown: string, + labelType: WorkflowLabelKind, + fromLabel: string, + toLabel: string +): string { + const { sections } = splitMarkdown(markdown, true); + + function rewriteSection(section: Section) { + if ("args" in section) { + const directiveSection = section as GalaxyDirectiveSection; + const args = directiveSection.args; + if (!(labelType in args)) { + return section; + } + const labelValue = args[labelType]; + if (labelValue != fromLabel) { + return section; + } + // we've got a section with a matching label and type... + const newArgs = { ...args }; + newArgs[labelType] = toLabel; + const argRexExp = namedArgumentRegex(labelType); + let escapedToLabel = escapeRegExpReplacement(toLabel); + const incomingContent = directiveSection.content; + let content: string; + const match = incomingContent.match(argRexExp); + if (match) { + const firstMatch = match[0]; + // TODO: handle whitespace more broadly here... + if (escapedToLabel.indexOf(" ") >= 0) { + const quoteChar = getQuoteChar(firstMatch); + escapedToLabel = `${quoteChar}${escapedToLabel}${quoteChar}`; + } + content = incomingContent.replace(argRexExp, `$1${escapedToLabel}`); + } else { + content = incomingContent; + } + return { + name: directiveSection.name, + args: newArgs, + content: content, + }; + } else { + return section; + } + } + + const rewrittenSections = sections.map(rewriteSection); + const rewrittenMarkdown = rewrittenSections.map((section) => section.content).join(""); + return rewrittenMarkdown; +} + +function getQuoteChar(argMatch: string): string { + // this could be a lot stronger, handling escaping and such... + let quoteChar = SINGLE_QUOTE; + if (argMatch.indexOf(DOUBLE_QUOTE) >= 0) { + quoteChar = DOUBLE_QUOTE; + } + return quoteChar; +} + +export function getArgs(content: string): GalaxyDirectiveSection { + const galaxy_function = FUNCTION_CALL_LINE_TEMPLATE.exec(content); + if (galaxy_function == null) { + throw Error("Failed to parse galaxy directive"); + } + type ArgsType = { [key: string]: string }; + const args: ArgsType = {}; + const function_name = galaxy_function[1] as string; + // we need [... ] to return empty string, if regex doesn't match + const function_arguments = [...content.matchAll(new RegExp(FUNCTION_ARGUMENT_REGEX, "g"))]; + for (let i = 0; i < function_arguments.length; i++) { + if (function_arguments[i] === undefined) { + continue; + } + const arguments_str = function_arguments[i]?.toString().replace(/,/g, "").trim(); + if (arguments_str) { + const [keyStr, valStr] = arguments_str.split("="); + if (keyStr == undefined || valStr == undefined) { + throw Error("Failed to parse galaxy directive"); + } + const key = keyStr.trim(); + let val: string = valStr?.trim() ?? ""; + if (val) { + const strippedValueMatch = val.match(FUNCTION_ARGUMENT_VALUE_TO_VALUE_REGEX); + const groups = strippedValueMatch?.groups; + if (groups) { + val = groups.unquoted ?? groups.squoted ?? groups.dquoted ?? val; + } + } + args[key] = val; + } + } + return { + name: function_name, + args: args, + content: content, + }; +} + +function namedArgumentRegex(argument: string): RegExp { + return new RegExp(`(\\s*${argument}\\s*=)` + FUNCTION_ARGUMENT_VALUE_REGEX); +} + +// https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex +function escapeRegExpReplacement(value: string): string { + return value.replace(/\$/g, "$$$$"); +} diff --git a/client/src/components/Workflow/Editor/Forms/FormDefault.vue b/client/src/components/Workflow/Editor/Forms/FormDefault.vue index 8ec5d71f984f..72e786d3b6d3 100644 --- a/client/src/components/Workflow/Editor/Forms/FormDefault.vue +++ b/client/src/components/Workflow/Editor/Forms/FormDefault.vue @@ -51,7 +51,8 @@ :key="index" :name="output.name" :step="step" - :show-details="true" /> + :show-details="true" + @onOutputLabel="onOutputLabel" />
@@ -78,7 +79,14 @@ const props = defineProps<{ step: Step; datatypes: DatatypesMapperModel["datatypes"]; }>(); -const emit = defineEmits(["onAnnotation", "onLabel", "onAttemptRefactor", "onEditSubworkflow", "onSetData"]); +const emit = defineEmits([ + "onAnnotation", + "onLabel", + "onAttemptRefactor", + "onEditSubworkflow", + "onSetData", + "onOutputLabel", +]); const stepRef = toRef(props, "step"); const { stepId, contentId, annotation, label, name, type, configForm } = useStepProps(stepRef); const { stepStore } = useWorkflowStores(); @@ -117,4 +125,7 @@ function onChange(values: any) { inputs: values, }); } +function onOutputLabel(oldValue: string | null, newValue: string | null) { + emit("onOutputLabel", oldValue, newValue); +} diff --git a/client/src/components/Workflow/Editor/Forms/FormOutput.vue b/client/src/components/Workflow/Editor/Forms/FormOutput.vue index a0e41c37eb40..554b218df6ef 100644 --- a/client/src/components/Workflow/Editor/Forms/FormOutput.vue +++ b/client/src/components/Workflow/Editor/Forms/FormOutput.vue @@ -1,7 +1,7 @@ @@ -88,7 +89,7 @@ export default { required: true, }, }, - emits: ["onSetData", "onUpdateStep", "onChangePostJobActions", "onAnnotation", "onLabel"], + emits: ["onSetData", "onUpdateStep", "onChangePostJobActions", "onAnnotation", "onLabel", "onOutputLabel"], setup(props, { emit }) { const { stepId, annotation, label, stepInputs, stepOutputs, configForm, postJobActions } = useStepProps( toRef(props, "step") @@ -163,6 +164,9 @@ export default { onLabel(newLabel) { this.$emit("onLabel", this.stepId, newLabel); }, + onOutputLabel(oldValue, newValue) { + this.$emit("onOutputLabel", oldValue, newValue); + }, /** * Change event is triggered on component creation and input changes. * @param { Object } values contains flat key-value pairs `prefixed-name=value` diff --git a/client/src/components/Workflow/Editor/Index.vue b/client/src/components/Workflow/Editor/Index.vue index a283e5b863b2..b045017acc9c 100644 --- a/client/src/components/Workflow/Editor/Index.vue +++ b/client/src/components/Workflow/Editor/Index.vue @@ -95,6 +95,7 @@ @onChangePostJobActions="onChangePostJobActions" @onAnnotation="onAnnotation" @onLabel="onLabel" + @onOutputLabel="onOutputLabel" @onUpdateStep="onUpdateStep" @onSetData="onSetData" /> = 0; + const labelType = isInput ? "input" : "step"; + const labelTypeTitle = isInput ? "Input" : "Step"; + const newMarkdown = replaceLabel(this.markdownText, labelType, oldLabel, newLabel); + if (newMarkdown !== this.markdownText) { + this.debouncedToast(`${labelTypeTitle} label updated in workflow report.`, 1500); + } + this.onReportUpdate(newMarkdown); + }, + debouncedToast(message, delay) { + clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => Toast.success(message), delay); }, onScrollTo(stepId) { this.scrollToId = stepId;