From 94e7a78afe26b9da2d7840e7a97f20bbae3f89f7 Mon Sep 17 00:00:00 2001 From: Danny McVey Date: Thu, 5 Feb 2026 19:55:07 -0800 Subject: [PATCH] Add Mermaid project structure diagram export --- ProExporter/ContextCollector.cs | 5 + ProExporter/Models.cs | 10 ++ ProExporter/Serializer.cs | 168 ++++++++++++++++++++++++- README.md | 5 +- cli/README.md | 5 + cli/arcgispro_cli/cli.py | 4 +- cli/arcgispro_cli/commands/diagram.py | 93 ++++++++++++++ cli/arcgispro_cli/commands/snapshot.py | 14 +++ 8 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 cli/arcgispro_cli/commands/diagram.py diff --git a/ProExporter/ContextCollector.cs b/ProExporter/ContextCollector.cs index 1c5de9b..bd7c280 100644 --- a/ProExporter/ContextCollector.cs +++ b/ProExporter/ContextCollector.cs @@ -457,6 +457,11 @@ private static LayoutInfo CollectLayoutInfo(ArcGIS.Desktop.Layouts.Layout layout { if (element is ArcGIS.Desktop.Layouts.MapFrame mapFrame) { + info.MapFrames.Add(new MapFrameInfo + { + Name = mapFrame.Name, + MapName = mapFrame.Map?.Name + }); info.MapFrameNames.Add(mapFrame.Name); } } diff --git a/ProExporter/Models.cs b/ProExporter/Models.cs index 05aa045..f1f7f04 100644 --- a/ProExporter/Models.cs +++ b/ProExporter/Models.cs @@ -149,6 +149,16 @@ public class LayoutInfo public double PageHeight { get; set; } public string PageUnits { get; set; } public List MapFrameNames { get; set; } = new List(); + public List MapFrames { get; set; } = new List(); + } + + /// + /// Map frame information inside a layout + /// + public class MapFrameInfo + { + public string Name { get; set; } + public string MapName { get; set; } } /// diff --git a/ProExporter/Serializer.cs b/ProExporter/Serializer.cs index 8c7e638..36e5c38 100644 --- a/ProExporter/Serializer.cs +++ b/ProExporter/Serializer.cs @@ -73,6 +73,13 @@ public static async Task> WriteContextAsync(ExportContext context, await WriteContextMarkdownAsync(contextMdPath, context); files.Add(contextMdPath); + // Write Mermaid diagram files + var mermaidPath = Path.Combine(snapshotFolder, "project-structure.mmd"); + var mermaidMdPath = Path.Combine(snapshotFolder, "project-structure.md"); + await WriteMermaidDiagramAsync(mermaidPath, mermaidMdPath, context); + files.Add(mermaidPath); + files.Add(mermaidMdPath); + // Write AGENTS.md to project root for immediate discoverability var projectRoot = Directory.GetParent(outputFolder)?.FullName; if (projectRoot != null) @@ -204,8 +211,18 @@ private static async Task WriteContextMarkdownAsync(string path, ExportContext c sb.AppendLine($"### {layout.Name}"); sb.AppendLine(); sb.AppendLine($"- **Size:** {layout.PageWidth} x {layout.PageHeight} {layout.PageUnits}"); - if (layout.MapFrameNames.Any()) + if (layout.MapFrames.Any()) + { + var mapFrames = layout.MapFrames.Select(mapFrame => + string.IsNullOrWhiteSpace(mapFrame.MapName) + ? mapFrame.Name + : $"{mapFrame.Name} ({mapFrame.MapName})"); + sb.AppendLine($"- **Map Frames:** {string.Join(", ", mapFrames)}"); + } + else if (layout.MapFrameNames.Any()) + { sb.AppendLine($"- **Map Frames:** {string.Join(", ", layout.MapFrameNames)}"); + } sb.AppendLine(); } } @@ -254,6 +271,150 @@ private static async Task WriteContextMarkdownAsync(string path, ExportContext c await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8); } + /// + /// Write Mermaid diagram files describing project structure + /// + private static async Task WriteMermaidDiagramAsync(string mermaidPath, string markdownPath, ExportContext context) + { + var mermaid = BuildMermaidDiagram(context); + + var md = new StringBuilder(); + md.AppendLine("# ArcGIS Pro Project Structure"); + md.AppendLine(); + md.AppendLine("```mermaid"); + md.AppendLine(mermaid); + md.AppendLine("```"); + md.AppendLine(); + md.AppendLine("Rendered with Mermaid-compatible tools (ex: beautiful-mermaid) for visual export."); + + await File.WriteAllTextAsync(mermaidPath, mermaid, Encoding.UTF8); + await File.WriteAllTextAsync(markdownPath, md.ToString(), Encoding.UTF8); + } + + private static string BuildMermaidDiagram(ExportContext context) + { + var sb = new StringBuilder(); + sb.AppendLine("flowchart LR"); + sb.AppendLine("%% ArcGIS Pro project structure"); + + var projectName = context.Project?.Name ?? "ArcGIS Pro Project"; + sb.AppendLine($"project[\"Project: {EscapeLabel(projectName)}\"]"); + + var layerNameCounts = context.Layers + .GroupBy(layer => layer.Name) + .ToDictionary(group => group.Key, group => group.Count()); + + var mapNodes = new Dictionary(); + var layoutNodes = new Dictionary(); + var layerNodes = new Dictionary(); + var sharedLayerNodes = new List(); + + var mapIndex = 0; + foreach (var map in context.Maps) + { + mapIndex++; + var mapNode = $"map_{mapIndex}"; + mapNodes[map.Name] = mapNode; + sb.AppendLine($"{mapNode}[\"Map: {EscapeLabel(map.Name)}\"]"); + sb.AppendLine($"project --> {mapNode}"); + } + + var layoutIndex = 0; + foreach (var layout in context.Layouts) + { + layoutIndex++; + var layoutNode = $"layout_{layoutIndex}"; + layoutNodes[layout.Name] = layoutNode; + sb.AppendLine($"{layoutNode}[\"Layout: {EscapeLabel(layout.Name)}\"]"); + sb.AppendLine($"project --> {layoutNode}"); + } + + var layerIndex = 0; + foreach (var map in context.Maps) + { + if (!mapNodes.TryGetValue(map.Name, out var mapNode)) + continue; + + var mapLayers = context.Layers.Where(layer => layer.MapName == map.Name).ToList(); + var groupNodesByName = new Dictionary(); + + foreach (var layer in mapLayers) + { + layerIndex++; + var layerNode = $"layer_{layerIndex}"; + layerNodes[layer] = layerNode; + + var layerLabel = layer.LayerType == "GroupLayer" + ? $"Group: {layer.Name}" + : $"Layer: {layer.Name}"; + + sb.AppendLine($"{layerNode}[\"{EscapeLabel(layerLabel)}\"]"); + + if (layer.LayerType == "GroupLayer" && !groupNodesByName.ContainsKey(layer.Name)) + { + groupNodesByName[layer.Name] = layerNode; + } + + if (layerNameCounts.TryGetValue(layer.Name, out var count) && count > 1) + { + sharedLayerNodes.Add(layerNode); + } + } + + foreach (var layer in mapLayers) + { + var layerNode = layerNodes[layer]; + if (!string.IsNullOrEmpty(layer.ParentGroupLayer) && + groupNodesByName.TryGetValue(layer.ParentGroupLayer, out var parentNode)) + { + sb.AppendLine($"{parentNode} --> {layerNode}"); + } + else + { + sb.AppendLine($"{mapNode} --> {layerNode}"); + } + } + } + + foreach (var layout in context.Layouts) + { + if (!layoutNodes.TryGetValue(layout.Name, out var layoutNode)) + continue; + + foreach (var mapFrame in layout.MapFrames) + { + if (string.IsNullOrWhiteSpace(mapFrame.MapName)) + continue; + + if (mapNodes.TryGetValue(mapFrame.MapName, out var mapNode)) + { + sb.AppendLine($"{layoutNode} --> {mapNode}"); + } + } + } + + if (sharedLayerNodes.Any()) + { + sb.AppendLine("classDef sharedLayer fill:#fff4cc,stroke:#d39e00,stroke-width:2px;"); + sb.AppendLine($"class {string.Join(",", sharedLayerNodes.Distinct())} sharedLayer;"); + } + + return sb.ToString().TrimEnd(); + } + + private static string EscapeLabel(string label) + { + if (string.IsNullOrEmpty(label)) + return string.Empty; + + return label + .Replace("\"", "'") + .Replace("[", "(") + .Replace("]", ")") + .Replace("\r", " ") + .Replace("\n", " "); + } + /// /// Write the AGENTS.md file - a skill file for AI agents /// @@ -306,6 +467,7 @@ arcgispro context # Full markdown summary | `arcgispro connections` | Database/folder connections | | `arcgispro notebooks` | Jupyter notebooks in project | | `arcgispro context` | Full markdown dump (good for pasting) | +| `arcgispro diagram` | Render Mermaid diagram of project structure | | `arcgispro status` | Validate export files | ## File Structure @@ -330,7 +492,9 @@ arcgispro context # Full markdown summary │ ├── map_*.png # Screenshots of each map view │ └── layout_*.png # Screenshots of each layout └── snapshot/ - └── context.md # Human-readable summary + ├── context.md # Human-readable summary + ├── project-structure.mmd # Mermaid diagram source + └── project-structure.md # Mermaid diagram markdown ``` ## Configuration diff --git a/README.md b/README.md index d4b55e0..fc8e880 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ ProExporter (Pro add-in) creates detailed flat files that explain the state of y | `arcgispro connections` | Data connections | | `arcgispro notebooks` | Jupyter notebooks in project | | `arcgispro context` | Full markdown dump | +| `arcgispro diagram` | Render Mermaid diagram of project structure | Add `--json` to any query command for machine-readable output. @@ -118,7 +119,9 @@ project_root/ │ ├── map_*.png # Screenshots of each map view │ └── layout_*.png # Screenshots of each layout └── snapshot/ - └── context.md # Human-readable summary + ├── context.md # Human-readable summary + ├── project-structure.mmd # Mermaid diagram source + └── project-structure.md # Mermaid diagram markdown ``` The `AGENTS.md` file teaches AI agents how to use the CLI and interpret the exported data; no user explanation needed. diff --git a/cli/README.md b/cli/README.md index 79742ad..c22bdfa 100644 --- a/cli/README.md +++ b/cli/README.md @@ -27,6 +27,9 @@ arcgispro images # Assemble snapshot arcgispro snapshot +# Render project diagram +arcgispro diagram + # Clean up exports arcgispro clean --all @@ -86,6 +89,8 @@ The CLI reads from `.arcgispro/` folder created by the add-in: │ └── layouts.json ├── snapshot/ │ ├── context.md +│ ├── project-structure.mmd +│ ├── project-structure.md │ ├── CONTEXT_SKILL.md │ └── AGENT_TOOL_SKILL.md └── images/ diff --git a/cli/arcgispro_cli/cli.py b/cli/arcgispro_cli/cli.py index e983c7b..465c7b9 100644 --- a/cli/arcgispro_cli/cli.py +++ b/cli/arcgispro_cli/cli.py @@ -21,13 +21,14 @@ arcgispro connections - List data connections arcgispro notebooks - List Jupyter notebooks arcgispro context - Print full markdown summary + arcgispro diagram - Render project structure diagram """ import click from rich.console import Console from . import __version__ -from .commands import clean, open_project, install, query, launch, notebooks, tui +from .commands import clean, open_project, install, query, launch, notebooks, tui, diagram console = Console() @@ -73,6 +74,7 @@ def main(ctx): main.add_command(query.connections_cmd, name="connections") main.add_command(notebooks.notebooks_cmd, name="notebooks") main.add_command(query.context_cmd, name="context") +main.add_command(diagram.diagram_cmd, name="diagram") main.add_command(tui.tui) diff --git a/cli/arcgispro_cli/commands/diagram.py b/cli/arcgispro_cli/commands/diagram.py new file mode 100644 index 0000000..449febb --- /dev/null +++ b/cli/arcgispro_cli/commands/diagram.py @@ -0,0 +1,93 @@ +"""diagram command - Render project structure diagrams.""" + +import shutil +import subprocess +from pathlib import Path + +import click +from rich.console import Console + +from ..paths import find_arcgispro_folder, get_snapshot_folder + +console = Console() + + +@click.command("diagram") +@click.option("--path", "-p", type=click.Path(exists=True), help="Path to search for .arcgispro folder") +@click.option( + "--render/--no-render", + default=True, + help="Render images with beautiful-mermaid if available", +) +@click.option( + "--format", + "format_", + type=click.Choice(["svg", "png", "both"], case_sensitive=False), + default="svg", + show_default=True, + help="Image format to render", +) +@click.option( + "--renderer", + type=click.Path(), + help="Path to beautiful-mermaid executable (defaults to PATH lookup)", +) +def diagram_cmd(path, render, format_, renderer): + """Render Mermaid diagrams for the exported ArcGIS Pro project structure.""" + start_path = Path(path) if path else None + arcgispro_path = find_arcgispro_folder(start_path) + + if not arcgispro_path: + console.print("[red]✗[/red] No .arcgispro folder found") + console.print(" Run the Snapshot export from ArcGIS Pro first.") + raise SystemExit(1) + + snapshot_folder = get_snapshot_folder(arcgispro_path) + mermaid_path = snapshot_folder / "project-structure.mmd" + + if not mermaid_path.exists(): + console.print("[red]✗[/red] Mermaid diagram source not found") + console.print(" Re-run Snapshot export from ArcGIS Pro to generate it.") + raise SystemExit(1) + + console.print(f"[green]✓[/green] Mermaid source: {mermaid_path}") + + if not render: + return + + renderer_path = Path(renderer) if renderer else None + if renderer_path: + executable = renderer_path + else: + executable = shutil.which("beautiful-mermaid") + executable = Path(executable) if executable else None + + if not executable: + console.print("[yellow]⚠[/yellow] beautiful-mermaid not found in PATH") + console.print(" Install it to render images from Mermaid code.") + return + + formats = [format_.lower()] if format_.lower() != "both" else ["svg", "png"] + outputs = [] + + for fmt in formats: + output_path = snapshot_folder / f"project-structure.{fmt}" + cmd = [ + str(executable), + "--input", + str(mermaid_path), + "--output", + str(output_path), + "--format", + fmt, + ] + + try: + subprocess.run(cmd, check=True) + outputs.append(output_path) + except subprocess.CalledProcessError: + console.print(f"[red]✗[/red] Failed to render {fmt} using beautiful-mermaid") + raise SystemExit(1) + + for output_path in outputs: + console.print(f"[green]✓[/green] Rendered {output_path}") diff --git a/cli/arcgispro_cli/commands/snapshot.py b/cli/arcgispro_cli/commands/snapshot.py index f16ed74..7b61c73 100644 --- a/cli/arcgispro_cli/commands/snapshot.py +++ b/cli/arcgispro_cli/commands/snapshot.py @@ -70,6 +70,8 @@ def snapshot_cmd(path, force): context_md = snapshot_folder / "context.md" context_skill = snapshot_folder / "CONTEXT_SKILL.md" agent_skill = snapshot_folder / "AGENT_TOOL_SKILL.md" + diagram_source = snapshot_folder / "project-structure.mmd" + diagram_md = snapshot_folder / "project-structure.md" files_created = 0 @@ -90,6 +92,16 @@ def snapshot_cmd(path, force): files_created += 1 else: console.print(f"[yellow]⚠[/yellow] AGENT_TOOL_SKILL.md missing") + + if diagram_source.exists(): + console.print(f"[green]✓[/green] project-structure.mmd exists") + else: + console.print(f"[yellow]⚠[/yellow] project-structure.mmd missing") + + if diagram_md.exists(): + console.print(f"[green]✓[/green] project-structure.md exists") + else: + console.print(f"[yellow]⚠[/yellow] project-structure.md missing") # Copy images to snapshot folder snapshot_images_folder = snapshot_folder / "images" @@ -115,6 +127,8 @@ def snapshot_cmd(path, force): console.print("[bold]Contents:[/bold]") console.print(f" {snapshot_folder}/") console.print(f" context.md - Human-readable summary") + console.print(f" project-structure.mmd - Mermaid diagram source") + console.print(f" project-structure.md - Mermaid diagram markdown") console.print(f" CONTEXT_SKILL.md - How to use exports") console.print(f" AGENT_TOOL_SKILL.md - CLI usage") console.print(f" images/ - {len(images)} PNG files")