From b21249394333b14f508e9f6fde75ad6ae80a10d3 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Wed, 20 Dec 2023 12:10:43 -0500 Subject: [PATCH 1/9] Refactor Markdown parsing out into a seperate file for reuse. --- client/src/components/Markdown/Markdown.vue | 74 ++---------------- client/src/components/Markdown/parse.test.js | 10 +++ client/src/components/Markdown/parse.ts | 79 ++++++++++++++++++++ 3 files changed, 97 insertions(+), 66 deletions(-) create mode 100644 client/src/components/Markdown/parse.test.js create mode 100644 client/src/components/Markdown/parse.ts 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..782170804fe9 --- /dev/null +++ b/client/src/components/Markdown/parse.test.js @@ -0,0 +1,10 @@ +import { getArgs } 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"); + }); + }); +}); diff --git a/client/src/components/Markdown/parse.ts b/client/src/components/Markdown/parse.ts new file mode 100644 index 000000000000..900bbd3135db --- /dev/null +++ b/client/src/components/Markdown/parse.ts @@ -0,0 +1,79 @@ +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"); + +export function splitMarkdown(markdown: string) { + const sections = []; + 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 defaultContent = digest.substr(0, galaxyStart).trim(); + if (defaultContent) { + sections.push({ + name: "default", + content: 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 getArgs(content: string) { + 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]; + // 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("="); + if (key == undefined || val == undefined) { + throw Error("Failed to parse galaxy directive"); + } + args[key.trim()] = val.replace(/['"]+/g, "").trim(); + } + } + return { + name: function_name, + args: args, + content: content, + }; +} From d5263db5cc8735627e227d3ab84eeedd6569af0c Mon Sep 17 00:00:00 2001 From: John Chilton Date: Thu, 11 Jan 2024 09:31:46 -0500 Subject: [PATCH 2/9] Rename variables for clarity. --- client/src/components/Markdown/parse.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/Markdown/parse.ts b/client/src/components/Markdown/parse.ts index 900bbd3135db..0b00cdf11052 100644 --- a/client/src/components/Markdown/parse.ts +++ b/client/src/components/Markdown/parse.ts @@ -1,6 +1,6 @@ -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_ARGUMENT_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"); export function splitMarkdown(markdown: string) { @@ -57,7 +57,7 @@ export function getArgs(content: string) { const args: ArgsType = {}; 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"))]; + 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; From f137f7b6c8b695038f369768c877e8c01619685a Mon Sep 17 00:00:00 2001 From: John Chilton Date: Wed, 10 Jan 2024 12:20:57 -0500 Subject: [PATCH 3/9] TS library function for replacing gx markdown labels. --- client/src/components/Markdown/parse.test.js | 104 ++++++++++++++++++- client/src/components/Markdown/parse.ts | 70 +++++++++++-- 2 files changed, 166 insertions(+), 8 deletions(-) diff --git a/client/src/components/Markdown/parse.test.js b/client/src/components/Markdown/parse.test.js index 782170804fe9..d8b11134410e 100644 --- a/client/src/components/Markdown/parse.test.js +++ b/client/src/components/Markdown/parse.test.js @@ -1,4 +1,4 @@ -import { getArgs } from "./parse"; +import { getArgs, replaceLabel, splitMarkdown } from "./parse"; describe("parse.ts", () => { describe("getArgs", () => { @@ -7,4 +7,106 @@ describe("parse.ts", () => { expect(args.name).toBe("job_metrics"); }); }); + + 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"); + }); + }); + + 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 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 index 0b00cdf11052..7a2ee3d1856a 100644 --- a/client/src/components/Markdown/parse.ts +++ b/client/src/components/Markdown/parse.ts @@ -3,8 +3,14 @@ const FUNCTION_ARGUMENT_REGEX = `\\s*[\\w\\|]+\\s*=` + FUNCTION_ARGUMENT_VALUE_R 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"); -export function splitMarkdown(markdown: string) { - const sections = []; +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"; + +export function splitMarkdown(markdown: string, preserveWhitespace: boolean = false) { + const sections: Section[] = []; const markdownErrors = []; let digest = markdown; while (digest.length > 0) { @@ -13,11 +19,12 @@ export function splitMarkdown(markdown: string) { const galaxyEnd = digest.substr(galaxyStart + 1).indexOf("```"); if (galaxyEnd != -1) { if (galaxyStart > 0) { - const defaultContent = digest.substr(0, galaxyStart).trim(); - if (defaultContent) { + const rawContent = digest.substr(0, galaxyStart); + const defaultContent = rawContent.trim(); + if (preserveWhitespace || defaultContent) { sections.push({ name: "default", - content: defaultContent, + content: preserveWhitespace ? rawContent : defaultContent, }); } } @@ -48,14 +55,54 @@ export function splitMarkdown(markdown: string) { return { sections, markdownErrors }; } -export function getArgs(content: string) { +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); + const escapedToLabel = escapeRegExpReplacement(toLabel); + const content = directiveSection.content.replace(argRexExp, `$1${escapedToLabel}`); + 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; +} + +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]; + 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++) { @@ -77,3 +124,12 @@ export function getArgs(content: string) { 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, "$$$$"); +} From 00466e3df6548823b58e4fa33f035cfb85b6d045 Mon Sep 17 00:00:00 2001 From: Assunta DeSanto Date: Tue, 16 Jan 2024 22:51:18 -0500 Subject: [PATCH 4/9] Update the workflow markdown report when editing the step label --- client/src/components/Workflow/Editor/Index.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/src/components/Workflow/Editor/Index.vue b/client/src/components/Workflow/Editor/Index.vue index a283e5b863b2..06db435cedd9 100644 --- a/client/src/components/Workflow/Editor/Index.vue +++ b/client/src/components/Workflow/Editor/Index.vue @@ -169,6 +169,7 @@ import { Toast } from "composables/toast"; import { storeToRefs } from "pinia"; import Vue, { computed, onUnmounted, ref, unref } from "vue"; +import { replaceLabel } from "@/components/Markdown/parse"; import { getUntypedWorkflowParameters } from "@/components/Workflow/Editor/modules/parameters"; import { ConfirmDialog } from "@/composables/confirmDialog"; import { useDatatypesMapper } from "@/composables/datatypesMapper"; @@ -642,7 +643,10 @@ export default { }, onLabel(nodeId, newLabel) { const step = { ...this.steps[nodeId], label: newLabel }; + const oldLabel = this.steps[nodeId].label; this.onUpdateStep(step); + const newMarkdown = replaceLabel(this.markdownText, "step", oldLabel, newLabel); + this.onReportUpdate(newMarkdown); }, onScrollTo(stepId) { this.scrollToId = stepId; From acdf5c342891b85996a77614776f2a8032514b50 Mon Sep 17 00:00:00 2001 From: Assunta DeSanto Date: Tue, 30 Jan 2024 17:43:48 -0500 Subject: [PATCH 5/9] adding a debounced Toast and step/input differentiation --- client/src/components/Workflow/Editor/Index.vue | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/client/src/components/Workflow/Editor/Index.vue b/client/src/components/Workflow/Editor/Index.vue index 06db435cedd9..34ebf3ff1bce 100644 --- a/client/src/components/Workflow/Editor/Index.vue +++ b/client/src/components/Workflow/Editor/Index.vue @@ -338,6 +338,7 @@ export default { showSaveAsModal: false, transform: { x: 0, y: 0, k: 1 }, graphOffset: { left: 0, top: 0, width: 0, height: 0 }, + debounceTimer: null, }; }, computed: { @@ -645,9 +646,19 @@ export default { const step = { ...this.steps[nodeId], label: newLabel }; const oldLabel = this.steps[nodeId].label; this.onUpdateStep(step); - const newMarkdown = replaceLabel(this.markdownText, "step", oldLabel, newLabel); + const stepType = this.steps[nodeId].type; + const isInput = ["data_input", "data_collection_input", "parameter_input"].indexOf(stepType) >= 0; + const labelType = isInput ? "input" : "step"; + const newMarkdown = replaceLabel(this.markdownText, labelType, oldLabel, newLabel); + if (newMarkdown !== this.markdownText) { + this.debouncedToast("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; this.onHighlight(stepId); From e1e2c964432535c926a3228fcd71ed8f6b359a00 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Thu, 28 Mar 2024 14:06:40 -0400 Subject: [PATCH 6/9] Handle spaces better in Markdown parse.ts. --- client/src/components/Markdown/parse.test.js | 27 +++++++++++++++++++ client/src/components/Markdown/parse.ts | 28 ++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/client/src/components/Markdown/parse.test.js b/client/src/components/Markdown/parse.test.js index d8b11134410e..ae3f929cb052 100644 --- a/client/src/components/Markdown/parse.test.js +++ b/client/src/components/Markdown/parse.test.js @@ -85,6 +85,33 @@ describe("parse.ts", () => { 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 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 leave non-arguments alone", () => { const input = "some random\n`markdown content`\n```galaxy\nhistory_dataset_embedded(title='cow from farm', output=from)\n```\n"; diff --git a/client/src/components/Markdown/parse.ts b/client/src/components/Markdown/parse.ts index 7a2ee3d1856a..80616faa3c81 100644 --- a/client/src/components/Markdown/parse.ts +++ b/client/src/components/Markdown/parse.ts @@ -9,6 +9,9 @@ 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 = []; @@ -78,8 +81,20 @@ export function replaceLabel( const newArgs = { ...args }; newArgs[labelType] = toLabel; const argRexExp = namedArgumentRegex(labelType); - const escapedToLabel = escapeRegExpReplacement(toLabel); - const content = directiveSection.content.replace(argRexExp, `$1${escapedToLabel}`); + let escapedToLabel = escapeRegExpReplacement(toLabel); + const incomingContent = directiveSection.content; + let content: string; + const match = incomingContent.match(argRexExp); + if (match) { + const firstMatch = match[0]; + 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, @@ -95,6 +110,15 @@ export function replaceLabel( 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) { From 963dc0d05851afa248e071a455004c3264b741c1 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Thu, 28 Mar 2024 15:28:27 -0400 Subject: [PATCH 7/9] Include output labels when updating report markdown for label changes. --- .../Workflow/Editor/Forms/FormDefault.vue | 15 +++++++++++++-- .../Workflow/Editor/Forms/FormOutput.vue | 5 ++++- .../Workflow/Editor/Forms/FormOutputLabel.vue | 8 ++++++++ .../Workflow/Editor/Forms/FormSection.vue | 4 ++++ .../components/Workflow/Editor/Forms/FormTool.vue | 6 +++++- client/src/components/Workflow/Editor/Index.vue | 12 +++++++++++- 6 files changed, 45 insertions(+), 5 deletions(-) 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 34ebf3ff1bce..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("Label updated in workflow report.", 1500); + this.debouncedToast(`${labelTypeTitle} label updated in workflow report.`, 1500); } this.onReportUpdate(newMarkdown); }, From beeaa277c9b3cabbe6e85bef4c6d99d6e284701a Mon Sep 17 00:00:00 2001 From: John Chilton Date: Mon, 1 Apr 2024 13:03:38 -0400 Subject: [PATCH 8/9] Fixup spaces in parse.ts even more. --- client/src/components/Markdown/parse.test.js | 36 ++++++++++++++++++++ client/src/components/Markdown/parse.ts | 17 +++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/client/src/components/Markdown/parse.test.js b/client/src/components/Markdown/parse.test.js index ae3f929cb052..50b1ae99397d 100644 --- a/client/src/components/Markdown/parse.test.js +++ b/client/src/components/Markdown/parse.test.js @@ -6,6 +6,18 @@ describe("parse.ts", () => { 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", () => { @@ -19,6 +31,12 @@ describe("parse.ts", () => { 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", () => { @@ -103,6 +121,15 @@ describe("parse.ts", () => { 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"; @@ -112,6 +139,15 @@ describe("parse.ts", () => { 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"; diff --git a/client/src/components/Markdown/parse.ts b/client/src/components/Markdown/parse.ts index 80616faa3c81..2991846bede4 100644 --- a/client/src/components/Markdown/parse.ts +++ b/client/src/components/Markdown/parse.ts @@ -1,4 +1,5 @@ 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"); @@ -87,6 +88,7 @@ export function replaceLabel( 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}`; @@ -135,11 +137,20 @@ export function getArgs(content: string): GalaxyDirectiveSection { } const arguments_str = function_arguments[i]?.toString().replace(/,/g, "").trim(); if (arguments_str) { - const [key, val] = arguments_str.split("="); - if (key == undefined || val == undefined) { + const [keyStr, valStr] = arguments_str.split("="); + if (keyStr == undefined || keyStr == undefined) { throw Error("Failed to parse galaxy directive"); } - args[key.trim()] = val.replace(/['"]+/g, "").trim(); + 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 { From 1b2f8b8eb727ff4a27f123c80377d9a041e798aa Mon Sep 17 00:00:00 2001 From: John Chilton Date: Tue, 2 Apr 2024 11:06:24 -0400 Subject: [PATCH 9/9] Update client/src/components/Markdown/parse.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: David López <46503462+davelopez@users.noreply.github.com> --- client/src/components/Markdown/parse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/Markdown/parse.ts b/client/src/components/Markdown/parse.ts index 2991846bede4..f7d5b17cd562 100644 --- a/client/src/components/Markdown/parse.ts +++ b/client/src/components/Markdown/parse.ts @@ -138,7 +138,7 @@ export function getArgs(content: string): GalaxyDirectiveSection { const arguments_str = function_arguments[i]?.toString().replace(/,/g, "").trim(); if (arguments_str) { const [keyStr, valStr] = arguments_str.split("="); - if (keyStr == undefined || keyStr == undefined) { + if (keyStr == undefined || valStr == undefined) { throw Error("Failed to parse galaxy directive"); } const key = keyStr.trim();