Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First pass at generating mermaid diagrams for all workflows #620

Merged
merged 13 commits into from
Dec 11, 2024
Merged
4 changes: 1 addition & 3 deletions .github/workflows/gh-page-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ jobs:
steps:
- name: Checkout PR Branch
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}

- name: Set up Python
uses: actions/setup-python@v5
Expand Down Expand Up @@ -72,4 +70,4 @@ jobs:
run: |
git rm -rf __preview/pr-${{ github.event.pull_request.number }}
git commit -m "Remove preview for PR #${{ github.event.pull_request.number }}"
git push origin gh-pages
git push origin gh-pages
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ tool_test_output.html
tool_test_output.json
.DS_Store
workflow_manifest.json
.vscode
110 changes: 110 additions & 0 deletions scripts/create_mermaid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import argparse
import json
import os
from typing import Literal

from gxformat2.converter import python_to_workflow
from gxformat2.yaml import ordered_load

STEP_TYPE_TO_SHAPE = {
"data_input": "@{ shape: doc }",
"data_collection_input": "@{ shape: docs }",
"parameter_input": "@{ shape: lean-l }",
"tool": "@{ shape: process }",
"subworkflow": "@{ shape: subprocess }",
}
STEP_TYPE_TO_SYMBOL = {
"data_input": "ℹ️ ",
"data_collection_input": "ℹ️ ",
"parameter_input": "ℹ️ ",
"subworkflow": "🛠️ ",
"tool": "",
}


def step_to_mermaid_item(
step_type: Literal[
"parameter_input", "data_input", "data_collection_input", "tool", "subworkflow"
],
step_label: str,
):
prefix = STEP_TYPE_TO_SYMBOL[step_type]
step_label_anchor = f'["{prefix}{step_label}"]'
shape = STEP_TYPE_TO_SHAPE.get(step_type, "")
return f"{step_label_anchor}{shape}"


def workflow_to_mermaid_diagrams(workflow, workflows = None):
"""
Converts a Galaxy workflow JSON to a Mermaid flowchart diagram.

Args:
workflow_json: The JSON representation of the Galaxy workflow.

Returns:
A string representing the Mermaid flowchart diagram.
"""
if workflows is None:
workflows = {}

mermaid_diagram = ["graph LR"]

# Create a mapping of step IDs to their labels
id_step_labels = {
int(step["id"]): step.get("label") or step["name"] or step["content_id"] or step["id"]
for step in workflow["steps"].values()
}

# Iterate through each step and its connections
for step_id, step in workflow["steps"].items():
step_label = id_step_labels.get(int(step_id))
mermaid_diagram.append(
f'{step_id}{step_to_mermaid_item(step["type"], step_label)}'
)
for input_connection in step.get("input_connections", {}).values():
if not isinstance(input_connection, list):
input_connection = [input_connection]
for ic in input_connection:
mermaid_diagram.append(f"{ic['id']} --> {step_id}")

if step["type"] == "subworkflow":
workflow_to_mermaid_diagrams(step["subworkflow"], workflows=workflows)

workflows[workflow["name"]] = "\n".join(mermaid_diagram)

return workflows


def walk_directory(directory):
"""
Walk directory and call workflow_to_mermaid on each discovered .ga file.
"""
for root, _, paths in os.walk(directory):
for path in paths:
if path.endswith((".ga", ".gxwf.yml")):
file_path = os.path.join(root, path)
with open(file_path, "r") as f:
workflow_data = ordered_load(f)
if workflow_data.get("class") == "GalaxyWorkflow":
workflow_data = python_to_workflow(workflow_data, galaxy_interface=None, workflow_directory=os.path.dirname(file_path))
mermaid_diagrams = workflow_to_mermaid_diagrams(workflow_data)

markdown_items = ["# Workflow diagrams\n"]
for workflow_name, diagram in reversed(mermaid_diagrams.items()):
markdown_items.append(f"## {workflow_name}\n")
markdown_items.append(f"```mermaid\n{diagram}\n```\n")

mmd_path = f"{os.path.splitext(file_path)[0]}_diagrams.md"
with open(mmd_path, "w") as f:
f.write("\n".join(markdown_items))


def parse_args():
parser = argparse.ArgumentParser(description="Process files in a directory")
parser.add_argument("directory", type=str, help="Path to the input directory")
return parser.parse_args()


if __name__ == "__main__":
args = parse_args()
walk_directory(args.directory)
40 changes: 18 additions & 22 deletions scripts/workflow_manifest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import os
import json
import yaml
from create_mermaid import walk_directory


def read_contents(path: str):
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
print(f"No {os.path.basename(path)} at {path}")
except Exception as e:
print(
f"Error reading file {path}: {e}"
)


def find_and_load_compliant_workflows(directory):
Expand Down Expand Up @@ -57,28 +70,10 @@ def find_and_load_compliant_workflows(directory):
f"No workflow file: {os.path.join(root, workflow['primaryDescriptorPath'])}: {e}"
)

# also try to load a README.md file for each workflow
try:
with open(os.path.join(root, "README.md")) as f:
workflow["readme"] = f.read()
# catch FileNotFound
except FileNotFoundError:
print(f"No README.md at {os.path.join(root, 'README.md')}")
except Exception as e:
print(
f"Error reading file {os.path.join(root, 'README.md')}: {e}"
)

# also try to load a CHANGELOG.md file for each workflow
try:
with open(os.path.join(root, "CHANGELOG.md")) as f:
workflow["changelog"] = f.read()
except FileNotFoundError:
print(f"No CHANGELOG.md at {os.path.join(root, 'CHANGELOG.md')}")
except Exception as e:
print(
f"Error reading file {os.path.join(root, 'CHANGELOG.md')}: {e}"
)
# load readme, changelog and diagrams
workflow["readme"] = read_contents(os.path.join(root, "README.md"))
workflow["changelog"] = read_contents(os.path.join(root, "CHANGELOG.md"))
workflow["diagrams"] = read_contents(f"{os.path.splitext(workflow_path)[0]}_diagrams.md")
dirname = os.path.dirname(workflow_path).split("/")[-1]
workflow["trsID"] = f"#workflow/github.com/iwc-workflows/{dirname}/{workflow['name'] or 'main'}"

Expand Down Expand Up @@ -108,5 +103,6 @@ def write_to_json(data, filename):


if __name__ == "__main__":
walk_directory("./workflows")
workflow_data = find_and_load_compliant_workflows("./workflows")
write_to_json(workflow_data, "workflow_manifest.json")
60 changes: 60 additions & 0 deletions website/components/MarkdownRenderer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ref, nextTick, watch } from "vue";
import { marked } from "marked";
import mermaid from "mermaid";

// Props
const props = defineProps({
markdownContent: {
type: String,
required: true,
},
});

// Refs
const renderedHtml = ref<string>("");

// Initialize Mermaid
mermaid.initialize({
startOnLoad: false,
theme: "neutral",
});

// Watch Markdown Content for Changes
watch(
() => props.markdownContent,
(newContent) => {
renderMarkdown(newContent);
},
{ immediate: true },
);

// Render Markdown Function
async function renderMarkdown(content: string) {
renderedHtml.value = await marked(content);
await nextTick();
renderMermaidDiagrams();
}

// Render Mermaid Diagrams
function renderMermaidDiagrams() {
const mermaidElements = document.querySelectorAll(".language-mermaid");
mermaidElements.forEach((element) => {
const parent = element.parentElement;
const code = element.textContent || "";
if (parent) {
try {
const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`;
mermaid.render(id, code).then((value) => (parent.innerHTML = value.svg));
parent.classList.add("not-prose");
} catch (e) {
console.error("Mermaid rendering error:", e);
}
}
});
}
</script>

<template>
<div v-html="renderedHtml"></div>
</template>
1 change: 1 addition & 0 deletions website/models/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface Workflow {
definition: WorkflowDefinition;
readme: string;
changelog: string;
diagrams: string;
trsID: string;
}

Expand Down
1 change: 1 addition & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@nuxt/ui": "^2.19.2",
"@pinia/nuxt": "^0.5.5",
"marked": "^14.1.1",
"mermaid": "^11.4.1",
"nuxt": "^3.11.2",
"nuxt-icon": "^1.0.0-beta.7",
"pinia": "^2.2.4",
Expand Down
22 changes: 9 additions & 13 deletions website/pages/workflow/[id].vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onBeforeMount } from "vue";
import { useRoute } from "vue-router";
import { marked } from "marked";
import MarkdownRenderer from "~/components/MarkdownRenderer.vue";
import Author from "~/components/Author.vue";
import { useWorkflowStore } from "~/stores/workflows";

Expand All @@ -17,10 +17,6 @@ const formatDate = (date: string) => {
});
};

const parseMarkdown = (content: string) => {
return marked(content);
};

// TODO: Add a component for authors. For now, just have a computed that grabs names and joins them
const authors = computed(() => {
let authorLine = "";
Expand Down Expand Up @@ -91,14 +87,14 @@ const tabs = computed(() => [
label: "Version History",
content: workflow.value?.changelog || "No CHANGELOG available.",
},
{
label: "Diagram",
content: workflow.value?.diagrams || "No diagram available",
},
{
label: "Tools",
tools: tools || "This tab will show a nice listing of all the tools used in this workflow.",
},
// {
// label: "Preview",
// preview: true,
// },
]);

/* Instance Selector -- factor out to a component */
Expand Down Expand Up @@ -187,13 +183,13 @@ const onInstanceChange = (value: string) => {
</template>
<template #item="{ item }">
<div v-if="item.content" class="mt-6">
<div
class="prose dark:prose-invert !max-w-none"
v-html="parseMarkdown(item.content)"></div>
<div class="prose dark:prose-invert !max-w-none">
<MarkdownRenderer :markdownContent="item.content" />
</div>
</div>
<div v-else-if="item.tools" class="mt-6">
<div class="prose dark:prose-invert !max-w-none">
<h3>The following tools are required to run this workflow.</h3>
<h3>The following tools are used by this workflow.</h3>
<p>
This will eventually be a pretty page with links to each tool in the (new)
toolshed, etc.
Expand Down
Loading