Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ProExporter/ContextCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
10 changes: 10 additions & 0 deletions ProExporter/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,16 @@ public class LayoutInfo
public double PageHeight { get; set; }
public string PageUnits { get; set; }
public List<string> MapFrameNames { get; set; } = new List<string>();
public List<MapFrameInfo> MapFrames { get; set; } = new List<MapFrameInfo>();
}

/// <summary>
/// Map frame information inside a layout
/// </summary>
public class MapFrameInfo
{
public string Name { get; set; }
public string MapName { get; set; }
}

/// <summary>
Expand Down
168 changes: 166 additions & 2 deletions ProExporter/Serializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ public static async Task<List<string>> 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)
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -254,6 +271,150 @@ private static async Task WriteContextMarkdownAsync(string path, ExportContext c
await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8);
}

/// <summary>
/// Write Mermaid diagram files describing project structure
/// </summary>
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<string, string>();
var layoutNodes = new Dictionary<string, string>();
var layerNodes = new Dictionary<LayerInfo, string>();
var sharedLayerNodes = new List<string>();

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<string, string>();

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", " ");
}

/// <summary>
/// Write the AGENTS.md file - a skill file for AI agents
/// </summary>
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ arcgispro images
# Assemble snapshot
arcgispro snapshot

# Render project diagram
arcgispro diagram

# Clean up exports
arcgispro clean --all

Expand Down Expand Up @@ -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/
Expand Down
4 changes: 3 additions & 1 deletion cli/arcgispro_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)


Expand Down
93 changes: 93 additions & 0 deletions cli/arcgispro_cli/commands/diagram.py
Original file line number Diff line number Diff line change
@@ -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}")
Loading