using System.ComponentModel; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Msagl.Core.Geometry; using Microsoft.Msagl.Core.Geometry.Curves; using Microsoft.Msagl.Core.Routing; using Microsoft.Msagl.Drawing; using Microsoft.Msagl.Layout.Layered; using Microsoft.Msagl.Layout.MDS; using Microsoft.Msagl.Miscellaneous; using ModelContextProtocol; using ModelContextProtocol.Server; var exitCode = McpServerMode.IsRequested(args) ? await McpServerMode.RunAsync(args) : await ProgramEntry.RunAsync(args); return exitCode; static class McpServerMode { public static bool IsRequested(string[] args) => args.Any(arg => string.Equals(arg, "--mcp", StringComparison.OrdinalIgnoreCase)); public static async Task RunAsync(string[] args) { var filteredArgs = args .Where(arg => !string.Equals(arg, "--mcp", StringComparison.OrdinalIgnoreCase)) .ToArray(); var builder = Host.CreateApplicationBuilder(filteredArgs); builder.Logging.ClearProviders(); builder.Logging.AddConsole(consoleLogOptions => { consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; }); builder.Services .AddMcpServer() .WithStdioServerTransport() .WithTools(); await builder.Build().RunAsync(); return 0; } } static class ProgramEntry { public static async Task RunAsync(string[] args) { CliOptions options; try { options = CliOptions.Parse(args); } catch (ArgumentException ex) { Console.Error.WriteLine(ex.Message); Console.Error.WriteLine(); PrintUsage(); return 1; } if (options.ShowHelp) { PrintUsage(); return 0; } try { var result = await GraphGenerator.GenerateAsync(options.Request!, CancellationToken.None); Console.WriteLine($"Wrote manifest: {result.ManifestPath}"); Console.WriteLine($"Wrote cross-links: {result.CrossLinksPath}"); Console.WriteLine($"Wrote warnings: {result.WarningsPath}"); Console.WriteLine($"Seeds: {string.Join(", ", result.SeedNoteIds)}"); Console.WriteLine($"Graphs: {result.Graphs.Count}, Notes: {result.TotalNoteCount}, Edges: {result.TotalEdgeCount}, Warnings: {result.Warnings.Count}"); foreach (var graph in result.Graphs.OrderBy(graph => graph.Key, StringComparer.OrdinalIgnoreCase)) { Console.WriteLine($"[{graph.Key}] {graph.NodeCount} nodes, {graph.EdgeCount} edges"); Console.WriteLine($" SVG: {graph.SvgPath}"); Console.WriteLine($" JSON: {graph.JsonPath}"); } return 0; } catch (Exception ex) { Console.Error.WriteLine(ex); return 1; } } private static void PrintUsage() { Console.WriteLine(""" Usage: norn --vault-root [--output-dir ] [--entry-note ] [--layout sugiyama|mds] [--renderer msagl|rich] [--include-unreachable] norn --mcp Examples: norn --vault-root "E:\Projects\Aetheria-Economy\Aetheria\Source Tree Map" --entry-note "Source Tree Map" norn --vault-root "E:\Projects\Aetheria-Economy\Aetheria\Source Tree Map" --entry-note "Source Tree Map" --layout mds --renderer rich --output-dir ".\out\mds" norn --mcp Outputs: manifest.json overall run summary plus partition outputs source-tree.svg primary rendered SVG for source-tree notes source-tree.msagl.svg raw MSAGL SVG for source-tree notes source-tree.rich.svg custom SVG renderer output for source-tree notes source-tree.json node and edge positions plus metadata for the source-tree partition control-flow.svg primary rendered SVG for control-flow notes control-flow.msagl.svg raw MSAGL SVG for control-flow notes control-flow.rich.svg custom SVG renderer output for control-flow notes control-flow.json node and edge positions plus metadata for the control-flow partition cross-links.json links that cross between graph families warnings.txt unresolved or ambiguous link warnings Modes: --mcp expose the generator as an MCP stdio server """); } } sealed record CliOptions(GraphGenerationRequest? Request, bool ShowHelp) { public static CliOptions Parse(string[] args) { string? vaultRoot = null; string? outputDirectory = null; string? entryNote = null; var layoutMode = LayoutMode.Sugiyama; var rendererMode = RendererMode.Msagl; var includeUnreachable = false; var showHelp = false; for (var i = 0; i < args.Length; i++) { switch (args[i]) { case "--help": case "-h": showHelp = true; break; case "--vault-root": vaultRoot = ReadValue(args, ref i, "--vault-root"); break; case "--output-dir": outputDirectory = ReadValue(args, ref i, "--output-dir"); break; case "--entry-note": entryNote = ReadValue(args, ref i, "--entry-note"); break; case "--layout": { var raw = ReadValue(args, ref i, "--layout"); layoutMode = LayoutModeExtensions.Parse(raw); break; } case "--renderer": { var raw = ReadValue(args, ref i, "--renderer"); rendererMode = RendererModeExtensions.Parse(raw); break; } case "--include-unreachable": includeUnreachable = true; break; default: throw new ArgumentException($"Unknown argument '{args[i]}'."); } } if (showHelp) { return new CliOptions(null, true); } return new CliOptions( GraphGenerationRequest.Normalize(vaultRoot, outputDirectory, entryNote, layoutMode, rendererMode, includeUnreachable), false); } private static string ReadValue(string[] args, ref int index, string option) { if (index + 1 >= args.Length) { throw new ArgumentException($"Missing value for '{option}'."); } index++; return args[index]; } } sealed record GraphGenerationRequest( string VaultRoot, string OutputDirectory, string? EntryNote, LayoutMode LayoutMode, RendererMode RendererMode, bool IncludeUnreachable) { public string GraphTitle => string.IsNullOrWhiteSpace(EntryNote) ? $"Obsidian Graph - {Path.GetFileName(VaultRoot)}" : $"Obsidian Graph - {EntryNote}"; public static GraphGenerationRequest Normalize( string? vaultRoot, string? outputDirectory, string? entryNote, LayoutMode layoutMode, RendererMode rendererMode, bool includeUnreachable) { if (string.IsNullOrWhiteSpace(vaultRoot)) { throw new ArgumentException("Missing required vault root."); } var normalizedVaultRoot = Path.GetFullPath(vaultRoot); if (!Directory.Exists(normalizedVaultRoot)) { throw new ArgumentException($"Vault root does not exist: {normalizedVaultRoot}"); } var normalizedOutput = string.IsNullOrWhiteSpace(outputDirectory) ? Path.Combine(Environment.CurrentDirectory, "out") : Path.GetFullPath(outputDirectory); var normalizedEntryNote = string.IsNullOrWhiteSpace(entryNote) ? null : entryNote.Trim(); return new GraphGenerationRequest( normalizedVaultRoot, normalizedOutput, normalizedEntryNote, layoutMode, rendererMode, includeUnreachable); } } static class GraphGenerator { public static async Task GenerateAsync( GraphGenerationRequest request, CancellationToken cancellationToken) { var model = VaultGraphModel.Load(request.VaultRoot, request.EntryNote, request.IncludeUnreachable); var outputDirectory = Directory.CreateDirectory(request.OutputDirectory); var manifestPath = Path.Combine(outputDirectory.FullName, "manifest.json"); var crossLinksPath = Path.Combine(outputDirectory.FullName, "cross-links.json"); var warningsPath = Path.Combine(outputDirectory.FullName, "warnings.txt"); var partitionResults = new List(); foreach (var partition in model.Partitions) { var drawingGraph = GraphLayoutBuilder.Build(partition, model, request); var slug = partition.Key; var svgPath = Path.Combine(outputDirectory.FullName, $"{slug}.svg"); var msaglSvgPath = Path.Combine(outputDirectory.FullName, $"{slug}.msagl.svg"); var richSvgPath = Path.Combine(outputDirectory.FullName, $"{slug}.rich.svg"); var jsonPath = Path.Combine(outputDirectory.FullName, $"{slug}.json"); await using (var svgStream = File.Create(msaglSvgPath)) { var writer = new SvgGraphWriter(svgStream, drawingGraph) { Precision = 2, }; writer.Write(); } var report = LayoutReport.From(partition, model, drawingGraph, request); await File.WriteAllTextAsync( jsonPath, JsonSerializer.Serialize(report, JsonOptions), cancellationToken); await File.WriteAllTextAsync( richSvgPath, RichSvgRenderer.Render(report, drawingGraph), cancellationToken); var preferredSvgPath = request.RendererMode switch { RendererMode.Rich => richSvgPath, _ => msaglSvgPath, }; File.Copy(preferredSvgPath, svgPath, overwrite: true); partitionResults.Add(new GraphPartitionResult { Key = partition.Key, Title = partition.Title, SvgPath = svgPath, MsaglSvgPath = msaglSvgPath, RichSvgPath = richSvgPath, JsonPath = jsonPath, RendererMode = request.RendererMode.ToApiValue(), NodeCount = report.NodeCount, EdgeCount = report.EdgeCount, }); } await File.WriteAllTextAsync( crossLinksPath, JsonSerializer.Serialize(CrossFamilyLinkReport.From(model), JsonOptions), cancellationToken); await File.WriteAllLinesAsync(warningsPath, model.Warnings, cancellationToken); var result = new GraphGenerationResult { GeneratedAtUtc = DateTime.UtcNow.ToString("O"), VaultRoot = request.VaultRoot, EntryNoteId = model.EntryNoteId, SeedNoteIds = model.SeedNoteIds, OutputDirectory = outputDirectory.FullName, ManifestPath = manifestPath, CrossLinksPath = crossLinksPath, WarningsPath = warningsPath, LayoutMode = request.LayoutMode.ToApiValue(), RendererMode = request.RendererMode.ToApiValue(), TotalNoteCount = model.IncludedNoteIds.Count, TotalEdgeCount = model.AllEdges.Count, Graphs = partitionResults, Warnings = model.Warnings, }; await File.WriteAllTextAsync( manifestPath, JsonSerializer.Serialize(result, JsonOptions), cancellationToken); return result; } private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, }; } sealed class GraphGenerationResult { public required string GeneratedAtUtc { get; init; } public required string VaultRoot { get; init; } public required string? EntryNoteId { get; init; } public required IReadOnlyList SeedNoteIds { get; init; } public required string OutputDirectory { get; init; } public required string ManifestPath { get; init; } public required string CrossLinksPath { get; init; } public required string WarningsPath { get; init; } public required string LayoutMode { get; init; } public required string RendererMode { get; init; } public required int TotalNoteCount { get; init; } public required int TotalEdgeCount { get; init; } public required IReadOnlyList Graphs { get; init; } public required IReadOnlyList Warnings { get; init; } } static class LayoutModeExtensions { public static LayoutMode Parse(string raw) { return raw.Trim().ToLowerInvariant() switch { "sugiyama" => LayoutMode.Sugiyama, "mds" => LayoutMode.Mds, _ => throw new ArgumentException($"Unsupported layout mode '{raw}'. Use 'sugiyama' or 'mds'."), }; } public static string ToApiValue(this LayoutMode layoutMode) => layoutMode switch { LayoutMode.Sugiyama => "sugiyama", LayoutMode.Mds => "mds", _ => throw new ArgumentOutOfRangeException(nameof(layoutMode), layoutMode, null), }; } static class RendererModeExtensions { public static RendererMode Parse(string raw) { return raw.Trim().ToLowerInvariant() switch { "msagl" => RendererMode.Msagl, "rich" => RendererMode.Rich, _ => throw new ArgumentException($"Unsupported renderer mode '{raw}'. Use 'msagl' or 'rich'."), }; } public static string ToApiValue(this RendererMode rendererMode) => rendererMode switch { RendererMode.Msagl => "msagl", RendererMode.Rich => "rich", _ => throw new ArgumentOutOfRangeException(nameof(rendererMode), rendererMode, null), }; } [McpServerToolType] sealed class NornMcpTools { [McpServerTool( Name = "generate_obsidian_graph_layout", Title = "Generate Obsidian Graph Layout", UseStructuredContent = true, Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = false)] [Description("Scan an Obsidian vault subtree, resolve note links, split the note set into source-tree and control-flow graph families, run MSAGL layout for each partition, and write manifest.json, partition SVG/JSON files, cross-links.json, and warnings.txt.")] public static async Task GenerateObsidianGraphLayout( [Description("Path to the Obsidian vault folder that contains markdown notes.")] string vaultRoot, [Description("Optional entry note name or relative note path. When set, only reachable notes are kept unless includeUnreachable is true.")] string? entryNote = null, [Description("Optional output directory for manifest.json, partition SVG/JSON files, cross-links.json, and warnings.txt. Defaults to ./out relative to the server process working directory.")] string? outputDir = null, [Description("Layout algorithm to use. Choose 'sugiyama' for layered hierarchies or 'mds' for freer spread-out graphs.")] string layout = "sugiyama", [Description("Renderer to use for the primary partition SVG. Choose 'msagl' for the raw stock output or 'rich' for the custom readability-focused SVG.")] string renderer = "msagl", [Description("When true and entryNote is set, keep notes that are not reachable from the entry note.")] bool includeUnreachable = false, CancellationToken cancellationToken = default) { try { var request = GraphGenerationRequest.Normalize( vaultRoot, outputDir, entryNote, LayoutModeExtensions.Parse(layout), RendererModeExtensions.Parse(renderer), includeUnreachable); return await GraphGenerator.GenerateAsync(request, cancellationToken); } catch (ArgumentException ex) { throw new McpException(ex.Message); } catch (InvalidOperationException ex) { throw new McpException(ex.Message); } } } enum LayoutMode { Sugiyama, Mds, } enum RendererMode { Msagl, Rich, } sealed class VaultGraphModel { public required string VaultRoot { get; init; } public required string? EntryNoteId { get; init; } public required IReadOnlyList SeedNoteIds { get; init; } public required IReadOnlyDictionary NotesById { get; init; } public required IReadOnlyList AllEdges { get; init; } public required IReadOnlyDictionary> FamiliesByNoteId { get; init; } public required IReadOnlyDictionary DepthById { get; init; } public required IReadOnlyCollection IncludedNoteIds { get; init; } public required IReadOnlyList Partitions { get; init; } public required IReadOnlyList CrossFamilyEdges { get; init; } public required IReadOnlyList Warnings { get; init; } public static VaultGraphModel Load(string vaultRoot, string? entryNote, bool includeUnreachable) { var warnings = new List(); var noteFiles = Directory .EnumerateFiles(vaultRoot, "*.md", SearchOption.AllDirectories) .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) .ToArray(); if (noteFiles.Length == 0) { throw new InvalidOperationException($"No markdown notes were found under '{vaultRoot}'."); } var notes = noteFiles .Select(path => NoteRecord.FromFile(vaultRoot, path)) .ToDictionary(note => note.Id, note => note, StringComparer.OrdinalIgnoreCase); var familiesByNoteId = notes.Values.ToDictionary( note => note.Id, NoteFamilyClassifier.Classify, StringComparer.OrdinalIgnoreCase); var byRelativeStem = notes.Values.ToDictionary(note => note.RelativeStem, note => note, StringComparer.OrdinalIgnoreCase); var byBaseName = notes.Values .GroupBy(note => note.BaseName, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase); var edgeSet = new HashSet<(string source, string target)>(StringTupleComparer.OrdinalIgnoreCase); foreach (var note in notes.Values) { foreach (var targetId in ExtractTargets(note, notes, byRelativeStem, byBaseName, warnings)) { if (!string.Equals(note.Id, targetId, StringComparison.OrdinalIgnoreCase)) { edgeSet.Add((note.Id, targetId)); } } } var edges = edgeSet .OrderBy(edge => edge.source, StringComparer.OrdinalIgnoreCase) .ThenBy(edge => edge.target, StringComparer.OrdinalIgnoreCase) .Select(edge => new EdgeRecord(edge.source, edge.target)) .ToArray(); var outgoing = BuildAdjacency(edges, source => source.SourceId, edge => edge.TargetId); var incoming = BuildAdjacency(edges, source => source.TargetId, edge => edge.SourceId); string? entryNoteId = null; if (!string.IsNullOrWhiteSpace(entryNote)) { entryNoteId = ResolveEntryNote(entryNote, notes, byRelativeStem, byBaseName); if (entryNoteId is null) { throw new InvalidOperationException($"Could not resolve entry note '{entryNote}'."); } } var seedNoteIds = entryNoteId is null ? Array.Empty() : ResolveSeedNotes(entryNoteId, notes, familiesByNoteId, byBaseName); var included = includeUnreachable || entryNoteId is null ? new HashSet(notes.Keys, StringComparer.OrdinalIgnoreCase) : TraverseFromEntries(seedNoteIds, outgoing); var depths = entryNoteId is null ? notes.Keys.ToDictionary(id => id, _ => 0, StringComparer.OrdinalIgnoreCase) : CalculateDepths(seedNoteIds, outgoing, included); if (includeUnreachable && entryNoteId is not null) { foreach (var noteId in notes.Keys.Where(id => !depths.ContainsKey(id))) { depths[noteId] = -1; } } var filteredEdges = edges .Where(edge => included.Contains(edge.SourceId) && included.Contains(edge.TargetId)) .ToArray(); var crossFamilyEdges = filteredEdges .Where(edge => !ShareFamily(familiesByNoteId[edge.SourceId], familiesByNoteId[edge.TargetId])) .ToArray(); var referencedOnlyIncoming = included .Where(id => entryNoteId is not null) .Where(id => !depths.ContainsKey(id)) .ToArray(); foreach (var orphan in referencedOnlyIncoming) { warnings.Add($"Unreachable from entry note: {notes[orphan].RelativePath}"); } var partitions = BuildPartitions(notes, familiesByNoteId, filteredEdges, depths, included, entryNoteId); return new VaultGraphModel { VaultRoot = vaultRoot, EntryNoteId = entryNoteId, SeedNoteIds = seedNoteIds, NotesById = notes, AllEdges = filteredEdges, FamiliesByNoteId = familiesByNoteId, DepthById = depths, IncludedNoteIds = included, Partitions = partitions, CrossFamilyEdges = crossFamilyEdges, Warnings = warnings, }; } private static IEnumerable ExtractTargets( NoteRecord source, IReadOnlyDictionary notes, IReadOnlyDictionary byRelativeStem, IReadOnlyDictionary byBaseName, List warnings) { var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Match match in LinkPatterns.Wikilinks.Matches(source.Content)) { var rawTarget = match.Groups[1].Value; if (TryResolveWikilinkTarget(source, rawTarget, byRelativeStem, byBaseName, out var resolved, out var warning) && resolved is not null) { if (yielded.Add(resolved.Id)) { yield return resolved.Id; } } else if (warning is not null) { warnings.Add(warning); } } foreach (Match match in LinkPatterns.MarkdownLinks.Matches(source.Content)) { var rawTarget = match.Groups[1].Value; if (rawTarget.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || rawTarget.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || rawTarget.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) || rawTarget.StartsWith("#", StringComparison.Ordinal)) { continue; } var resolvedPath = Path.GetFullPath(Path.Combine(source.DirectoryPath, rawTarget)); if (!resolvedPath.StartsWith(source.VaultRoot, StringComparison.OrdinalIgnoreCase) || !resolvedPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) { continue; } var relativeStem = NormalizePathStem(Path.GetRelativePath(source.VaultRoot, resolvedPath)); if (byRelativeStem.TryGetValue(relativeStem, out var resolved)) { if (yielded.Add(resolved.Id)) { yield return resolved.Id; } } else { warnings.Add($"Unresolved markdown link in {source.RelativePath}: {rawTarget}"); } } } private static bool TryResolveWikilinkTarget( NoteRecord source, string rawTarget, IReadOnlyDictionary byRelativeStem, IReadOnlyDictionary byBaseName, out NoteRecord? resolved, out string? warning) { warning = null; resolved = null; var normalized = NormalizePathStem(rawTarget); if (string.IsNullOrWhiteSpace(normalized)) { return false; } if (normalized.Contains('/')) { if (byRelativeStem.TryGetValue(normalized, out resolved)) { return true; } var suffixMatches = byRelativeStem .Where(pair => pair.Key.EndsWith(normalized, StringComparison.OrdinalIgnoreCase)) .Select(pair => pair.Value) .DistinctBy(note => note.Id, StringComparer.OrdinalIgnoreCase) .ToArray(); if (suffixMatches.Length == 1) { resolved = suffixMatches[0]; return true; } warning = $"Unresolved wikilink in {source.RelativePath}: [[{rawTarget}]]"; return false; } if (byBaseName.TryGetValue(normalized, out var baseMatches)) { if (baseMatches.Length == 1) { resolved = baseMatches[0]; return true; } warning = $"Ambiguous wikilink in {source.RelativePath}: [[{rawTarget}]] -> " + string.Join(", ", baseMatches.Select(match => match.RelativePath)); return false; } warning = $"Unresolved wikilink in {source.RelativePath}: [[{rawTarget}]]"; return false; } private static string? ResolveEntryNote( string entryNote, IReadOnlyDictionary notes, IReadOnlyDictionary byRelativeStem, IReadOnlyDictionary byBaseName) { var normalized = NormalizePathStem(entryNote); if (string.IsNullOrWhiteSpace(normalized)) { return null; } if (byRelativeStem.TryGetValue(normalized, out var direct)) { return direct.Id; } return byBaseName.TryGetValue(normalized, out var candidates) && candidates.Length == 1 ? candidates[0].Id : null; } private static Dictionary> BuildAdjacency( IEnumerable edges, Func keySelector, Func valueSelector) { var adjacency = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var edge in edges) { var key = keySelector(edge); if (!adjacency.TryGetValue(key, out var values)) { values = new HashSet(StringComparer.OrdinalIgnoreCase); adjacency[key] = values; } values.Add(valueSelector(edge)); } return adjacency; } private static IReadOnlyList ResolveSeedNotes( string entryNoteId, IReadOnlyDictionary notes, IReadOnlyDictionary> familiesByNoteId, IReadOnlyDictionary byBaseName) { var seeds = new HashSet(StringComparer.OrdinalIgnoreCase) { entryNoteId }; var entryFamilies = familiesByNoteId[entryNoteId]; var entryNote = notes[entryNoteId]; // Only auto-include sibling family roots when the chosen entry note is one of the // explicit family maps. That keeps subtree runs narrow while still letting the top-level // source tree map pull in the separate control-flow map. if (!entryFamilies.Any(family => string.Equals(entryNote.BaseName, family.GetDefaultRootTitle(), StringComparison.OrdinalIgnoreCase))) { return seeds.OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToArray(); } foreach (var family in Enum.GetValues()) { if (entryFamilies.Contains(family)) { continue; } var rootTitle = family.GetDefaultRootTitle(); if (!byBaseName.TryGetValue(rootTitle, out var candidates)) { continue; } var match = candidates.SingleOrDefault(candidate => familiesByNoteId[candidate.Id].Contains(family)); if (match is not null) { seeds.Add(match.Id); } } return seeds.OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToArray(); } private static HashSet TraverseFromEntries( IReadOnlyCollection entryNoteIds, IReadOnlyDictionary> outgoing) { var visited = new HashSet(entryNoteIds, StringComparer.OrdinalIgnoreCase); var queue = new Queue(); foreach (var entryNoteId in entryNoteIds) { queue.Enqueue(entryNoteId); } while (queue.Count > 0) { var current = queue.Dequeue(); if (!outgoing.TryGetValue(current, out var neighbors)) { continue; } foreach (var neighbor in neighbors) { if (visited.Add(neighbor)) { queue.Enqueue(neighbor); } } } return visited; } private static Dictionary CalculateDepths( IReadOnlyCollection entryNoteIds, IReadOnlyDictionary> outgoing, IReadOnlyCollection included) { var depths = entryNoteIds.ToDictionary( id => id, _ => 0, StringComparer.OrdinalIgnoreCase); var queue = new Queue(entryNoteIds); while (queue.Count > 0) { var current = queue.Dequeue(); var currentDepth = depths[current]; if (!outgoing.TryGetValue(current, out var neighbors)) { continue; } foreach (var neighbor in neighbors.Where(included.Contains)) { if (depths.TryAdd(neighbor, currentDepth + 1)) { queue.Enqueue(neighbor); } } } return depths; } private static string NormalizePathStem(string raw) { var normalized = raw .Replace('\\', '/') .Trim(); if (normalized.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) { normalized = normalized[..^3]; } normalized = normalized.TrimStart('/'); return normalized; } private static IReadOnlyList BuildPartitions( IReadOnlyDictionary notes, IReadOnlyDictionary> familiesByNoteId, IReadOnlyList filteredEdges, IReadOnlyDictionary depths, IReadOnlyCollection included, string? entryNoteId) { var partitions = new List(); var sourceTreeNodes = included .Where(id => familiesByNoteId[id].Contains(GraphFamily.SourceTree)) .ToHashSet(StringComparer.OrdinalIgnoreCase); var sourceTreeEdges = filteredEdges .Where(edge => sourceTreeNodes.Contains(edge.SourceId) && sourceTreeNodes.Contains(edge.TargetId)) .ToArray(); var sourceTreeRoles = sourceTreeNodes.ToDictionary( id => id, _ => GraphPartitionNodeRole.Primary, StringComparer.OrdinalIgnoreCase); partitions.Add(new GraphPartitionModel { Key = "source-tree", Title = "Source Tree Graph", Family = GraphFamily.SourceTree, IncludedNoteIds = sourceTreeNodes, Edges = sourceTreeEdges, NodeRoles = sourceTreeRoles, EntryNoteId = entryNoteId is not null && sourceTreeNodes.Contains(entryNoteId) ? entryNoteId : null, }); var controlFlowPrimaryNodes = included .Where(id => familiesByNoteId[id].Contains(GraphFamily.ControlFlow)) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (controlFlowPrimaryNodes.Count > 0) { var controlFlowAnchorNodes = filteredEdges .Where(edge => controlFlowPrimaryNodes.Contains(edge.SourceId) ^ controlFlowPrimaryNodes.Contains(edge.TargetId)) .Select(edge => controlFlowPrimaryNodes.Contains(edge.SourceId) ? edge.TargetId : edge.SourceId) .Where(id => !controlFlowPrimaryNodes.Contains(id) && !string.Equals(id, entryNoteId, StringComparison.OrdinalIgnoreCase)) .ToHashSet(StringComparer.OrdinalIgnoreCase); var controlFlowNodes = controlFlowPrimaryNodes .Concat(controlFlowAnchorNodes) .ToHashSet(StringComparer.OrdinalIgnoreCase); var controlFlowEdges = filteredEdges .Where(edge => controlFlowNodes.Contains(edge.SourceId) && controlFlowNodes.Contains(edge.TargetId) && (controlFlowPrimaryNodes.Contains(edge.SourceId) || controlFlowPrimaryNodes.Contains(edge.TargetId))) .ToArray(); var roles = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var nodeId in controlFlowPrimaryNodes) { roles[nodeId] = GraphPartitionNodeRole.Primary; } foreach (var nodeId in controlFlowAnchorNodes) { roles[nodeId] = GraphPartitionNodeRole.Anchor; } var controlEntry = controlFlowPrimaryNodes .OrderBy(id => depths.GetValueOrDefault(id, int.MaxValue)) .ThenBy(id => id, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(); partitions.Add(new GraphPartitionModel { Key = "control-flow", Title = "Control Flow Graph", Family = GraphFamily.ControlFlow, IncludedNoteIds = controlFlowNodes, Edges = controlFlowEdges, NodeRoles = roles, EntryNoteId = controlEntry, }); } return partitions; } private static bool ShareFamily(IReadOnlySet left, IReadOnlySet right) => left.Any(right.Contains); } sealed record NoteRecord( string VaultRoot, string Id, string BaseName, string RelativePath, string RelativeStem, string FullPath, string DirectoryPath, string Content) { public static NoteRecord FromFile(string vaultRoot, string path) { var fullPath = Path.GetFullPath(path); var relativePath = Path.GetRelativePath(vaultRoot, fullPath).Replace('\\', '/'); var relativeStem = Path.ChangeExtension(relativePath, null)!.Replace('\\', '/'); return new NoteRecord( vaultRoot, relativeStem, Path.GetFileNameWithoutExtension(fullPath), relativePath, relativeStem, fullPath, Path.GetDirectoryName(fullPath)!, File.ReadAllText(fullPath)); } } sealed record EdgeRecord(string SourceId, string TargetId); sealed class GraphPartitionModel { public required string Key { get; init; } public required string Title { get; init; } public required GraphFamily Family { get; init; } public required IReadOnlyCollection IncludedNoteIds { get; init; } public required IReadOnlyList Edges { get; init; } public required IReadOnlyDictionary NodeRoles { get; init; } public required string? EntryNoteId { get; init; } } static class GraphLayoutBuilder { public static Graph Build(GraphPartitionModel partition, VaultGraphModel model, GraphGenerationRequest request) { var graph = new Graph($"{request.GraphTitle} - {partition.Title}") { Attr = { LayerDirection = LayerDirection.TB, NodeSeparation = partition.Family == GraphFamily.ControlFlow ? 56 : 42, LayerSeparation = partition.Family == GraphFamily.ControlFlow ? 104 : 86, BackgroundColor = Color.White, }, }; graph.LayoutAlgorithmSettings = CreateLayoutSettings(request.LayoutMode, partition); var metricsByNodeId = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var noteId in partition.IncludedNoteIds.OrderBy(id => model.DepthById.GetValueOrDefault(id)).ThenBy(id => id, StringComparer.OrdinalIgnoreCase)) { var note = model.NotesById[noteId]; var depth = model.DepthById.GetValueOrDefault(noteId, -1); var isRoot = string.Equals(noteId, partition.EntryNoteId, StringComparison.OrdinalIgnoreCase); var families = model.FamiliesByNoteId[noteId]; var role = partition.NodeRoles[noteId]; var label = CreateDisplayLabel(note); var metrics = LabelMetrics.Estimate(label, depth, isRoot, role == GraphPartitionNodeRole.Anchor); metricsByNodeId[noteId] = metrics; var node = graph.AddNode(noteId); node.LabelText = label; node.Attr.Shape = Shape.Box; ApplyNodeStyle(node, depth, isRoot, families, role, metrics); node.NodeBoundaryDelegate = _ => CurveFactory.CreateRectangle(metrics.Width, metrics.Height, new Point()); if (node.Label is not null) { node.Label.Width = metrics.LabelWidth; node.Label.Height = metrics.LabelHeight; node.Label.FontSize = isRoot ? 16 : role == GraphPartitionNodeRole.Anchor ? 12 : 13; } } foreach (var edgeRecord in partition.Edges) { var edge = graph.AddEdge(edgeRecord.SourceId, edgeRecord.TargetId); var sourceRole = partition.NodeRoles[edgeRecord.SourceId]; var targetRole = partition.NodeRoles[edgeRecord.TargetId]; edge.Attr.Color = sourceRole == GraphPartitionNodeRole.Anchor || targetRole == GraphPartitionNodeRole.Anchor ? Color.DarkGray : partition.Family == GraphFamily.ControlFlow ? Color.IndianRed : Color.Gray; edge.Attr.LineWidth = sourceRole == GraphPartitionNodeRole.Anchor || targetRole == GraphPartitionNodeRole.Anchor ? 0.9 : 1.1; } graph.CreateGeometryGraph(); foreach (var drawingNode in graph.Nodes) { var metrics = metricsByNodeId[drawingNode.Id]; drawingNode.GeometryNode.BoundaryCurve = NodeBoundaryCurves.GetNodeBoundaryCurve(drawingNode, metrics.Width, metrics.Height); if (drawingNode.Label is not null) { drawingNode.Label.GeometryLabel = new Microsoft.Msagl.Core.Layout.Label( metrics.LabelWidth, metrics.LabelHeight, drawingNode.GeometryNode); } } LayoutHelpers.CalculateLayout(graph.GeometryGraph, graph.LayoutAlgorithmSettings, null); return graph; } private static void ApplyNodeStyle( Node node, int depth, bool isRoot, IReadOnlySet families, GraphPartitionNodeRole role, LabelMetrics metrics) { node.Attr.LabelMargin = metrics.Padding; if (isRoot) { node.Attr.FillColor = Color.LightSteelBlue; node.Attr.Color = Color.MidnightBlue; node.Attr.LineWidth = 2.2; return; } if (role == GraphPartitionNodeRole.Anchor) { node.Attr.FillColor = Color.Gainsboro; node.Attr.Color = Color.DimGray; node.Attr.LineWidth = 1.0; return; } if (families.Contains(GraphFamily.ControlFlow)) { node.Attr.FillColor = depth switch { <= 1 => Color.MistyRose, 2 => Color.Linen, _ => Color.SeaShell, }; node.Attr.Color = Color.IndianRed; node.Attr.LineWidth = 1.5; return; } node.Attr.FillColor = depth switch { 0 => Color.LightSteelBlue, 1 => Color.LightGoldenrodYellow, 2 => Color.Honeydew, 3 => Color.AliceBlue, _ => Color.White, }; node.Attr.Color = Color.DarkSlateGray; node.Attr.LineWidth = 1.2; } private static Microsoft.Msagl.Core.Layout.LayoutAlgorithmSettings CreateLayoutSettings( LayoutMode layoutMode, GraphPartitionModel partition) { return layoutMode switch { LayoutMode.Mds => new MdsLayoutSettings { NodeSeparation = partition.Family == GraphFamily.ControlFlow ? 88 : 60, ScaleX = partition.Family == GraphFamily.ControlFlow ? 1.45 : 1.24, ScaleY = partition.Family == GraphFamily.ControlFlow ? 1.3 : 1.38, IterationsWithMajorization = partition.Family == GraphFamily.ControlFlow ? 52 : 46, EdgeRoutingSettings = { EdgeRoutingMode = EdgeRoutingMode.StraightLine, }, }, _ => new SugiyamaLayoutSettings { NodeSeparation = partition.Family == GraphFamily.ControlFlow ? 90 : 56, LayerSeparation = partition.Family == GraphFamily.ControlFlow ? 132 : 112, EdgeRoutingSettings = { EdgeRoutingMode = EdgeRoutingMode.Spline, }, }, }; } private static string CreateDisplayLabel(NoteRecord note) { return note.BaseName.Length <= 36 ? note.BaseName : $"{note.BaseName[..33]}..."; } } readonly record struct LabelMetrics(double Width, double Height, double LabelWidth, double LabelHeight, int Padding) { public static LabelMetrics Estimate(string label, int depth, bool isRoot, bool isAnchor) { var fontSize = isRoot ? 16 : isAnchor ? 12 : 13; var padding = isRoot ? 16 : isAnchor ? 10 : 12; var charactersPerLine = isAnchor ? 24 : depth <= 1 ? 26 : 22; var wrappedLines = Math.Max(1, (int)Math.Ceiling(label.Length / (double)charactersPerLine)); var lineHeight = fontSize * 1.55; var labelHeight = Math.Max(lineHeight, wrappedLines * lineHeight); var longestLine = Math.Min(label.Length, charactersPerLine); var labelWidth = Math.Max(80, longestLine * (fontSize * 0.64)); return new LabelMetrics( Width: labelWidth + padding * 2, Height: labelHeight + padding * 2, LabelWidth: labelWidth, LabelHeight: labelHeight, Padding: padding); } } sealed class LayoutReport { public required string GeneratedAtUtc { get; init; } public required string VaultRoot { get; init; } public required string GraphKey { get; init; } public required string GraphTitle { get; init; } public required string? EntryNote { get; init; } public required string LayoutMode { get; init; } public required int NodeCount { get; init; } public required int EdgeCount { get; init; } public required IReadOnlyList Warnings { get; init; } public required IReadOnlyList Nodes { get; init; } public required IReadOnlyList Edges { get; init; } public static LayoutReport From(GraphPartitionModel partition, VaultGraphModel model, Graph graph, GraphGenerationRequest request) { var nodeLookup = graph.Nodes.ToDictionary(node => node.Id, StringComparer.OrdinalIgnoreCase); var nodes = partition.IncludedNoteIds .OrderBy(id => model.DepthById.GetValueOrDefault(id)) .ThenBy(id => id, StringComparer.OrdinalIgnoreCase) .Select(id => { var note = model.NotesById[id]; var node = nodeLookup[id]; return new LayoutNodeRecord { Id = id, Title = note.BaseName, RelativePath = note.RelativePath, Depth = model.DepthById.GetValueOrDefault(id, -1), FamilyMembership = model.FamiliesByNoteId[id].Select(f => f.ToApiValue()).OrderBy(x => x).ToArray(), Role = partition.NodeRoles[id].ToApiValue(), Bounds = new BoundsRecord { Left = node.BoundingBox.Left, Bottom = node.BoundingBox.Bottom, Width = node.BoundingBox.Width, Height = node.BoundingBox.Height, }, IsEntry = string.Equals(id, partition.EntryNoteId, StringComparison.OrdinalIgnoreCase), }; }) .ToArray(); var edges = graph.Edges .Select(edge => new LayoutEdgeRecord { SourceId = edge.SourceNode.Id, TargetId = edge.TargetNode.Id, Label = edge.LabelText, }) .ToArray(); return new LayoutReport { GeneratedAtUtc = DateTime.UtcNow.ToString("O"), VaultRoot = model.VaultRoot, GraphKey = partition.Key, GraphTitle = partition.Title, EntryNote = partition.EntryNoteId, LayoutMode = request.LayoutMode.ToApiValue(), NodeCount = nodes.Length, EdgeCount = edges.Length, Warnings = model.Warnings, Nodes = nodes, Edges = edges, }; } } sealed class LayoutNodeRecord { public required string Id { get; init; } public required string Title { get; init; } public required string RelativePath { get; init; } public required int Depth { get; init; } public required IReadOnlyList FamilyMembership { get; init; } public required string Role { get; init; } public required bool IsEntry { get; init; } public required BoundsRecord Bounds { get; init; } } sealed class LayoutEdgeRecord { public required string SourceId { get; init; } public required string TargetId { get; init; } public string? Label { get; init; } } sealed class BoundsRecord { public required double Left { get; init; } public required double Bottom { get; init; } public required double Width { get; init; } public required double Height { get; init; } } sealed class GraphPartitionResult { public required string Key { get; init; } public required string Title { get; init; } public required string SvgPath { get; init; } public required string MsaglSvgPath { get; init; } public required string RichSvgPath { get; init; } public required string JsonPath { get; init; } public required string RendererMode { get; init; } public required int NodeCount { get; init; } public required int EdgeCount { get; init; } } sealed class CrossFamilyLinkReport { public required int Count { get; init; } public required IReadOnlyList Links { get; init; } public static CrossFamilyLinkReport From(VaultGraphModel model) { var links = model.CrossFamilyEdges .OrderBy(edge => edge.SourceId, StringComparer.OrdinalIgnoreCase) .ThenBy(edge => edge.TargetId, StringComparer.OrdinalIgnoreCase) .Select(edge => new CrossFamilyLinkRecord { SourceId = edge.SourceId, SourceTitle = model.NotesById[edge.SourceId].BaseName, SourceFamilies = model.FamiliesByNoteId[edge.SourceId].Select(f => f.ToApiValue()).OrderBy(x => x).ToArray(), TargetId = edge.TargetId, TargetTitle = model.NotesById[edge.TargetId].BaseName, TargetFamilies = model.FamiliesByNoteId[edge.TargetId].Select(f => f.ToApiValue()).OrderBy(x => x).ToArray(), }) .ToArray(); return new CrossFamilyLinkReport { Count = links.Length, Links = links, }; } } sealed class CrossFamilyLinkRecord { public required string SourceId { get; init; } public required string SourceTitle { get; init; } public required IReadOnlyList SourceFamilies { get; init; } public required string TargetId { get; init; } public required string TargetTitle { get; init; } public required IReadOnlyList TargetFamilies { get; init; } } static class LinkPatterns { public static readonly Regex Wikilinks = new(@"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]", RegexOptions.Compiled); public static readonly Regex MarkdownLinks = new(@"\[[^\]]+\]\(([^)#]+)(?:#[^)]+)?\)", RegexOptions.Compiled); } enum GraphFamily { SourceTree, ControlFlow, } enum GraphPartitionNodeRole { Primary, Anchor, } static class GraphFamilyExtensions { public static string GetDefaultRootTitle(this GraphFamily family) => family switch { GraphFamily.SourceTree => "Source Tree Map", GraphFamily.ControlFlow => "Control Flow Map", _ => throw new ArgumentOutOfRangeException(nameof(family), family, null), }; public static string ToApiValue(this GraphFamily family) => family switch { GraphFamily.SourceTree => "source-tree", GraphFamily.ControlFlow => "control-flow", _ => throw new ArgumentOutOfRangeException(nameof(family), family, null), }; } static class GraphPartitionNodeRoleExtensions { public static string ToApiValue(this GraphPartitionNodeRole role) => role switch { GraphPartitionNodeRole.Primary => "primary", GraphPartitionNodeRole.Anchor => "anchor", _ => throw new ArgumentOutOfRangeException(nameof(role), role, null), }; } static class NoteFamilyClassifier { public static IReadOnlySet Classify(NoteRecord note) { var annotated = FrontMatterGraphFamilyParser.TryParseFamilies(note.Content); if (annotated.Count > 0) { return annotated; } if (note.BaseName.Contains("Control Flow", StringComparison.OrdinalIgnoreCase)) { return new HashSet { GraphFamily.ControlFlow }; } return new HashSet { GraphFamily.SourceTree }; } } static class FrontMatterGraphFamilyParser { private static readonly string[] FamilyKeys = [ "norn-family", "norn-families", "graph-family", "graph-families", ]; public static IReadOnlySet TryParseFamilies(string content) { var result = new HashSet(); if (!content.StartsWith("---", StringComparison.Ordinal)) { return result; } var lines = content.Replace("\r\n", "\n").Split('\n'); if (lines.Length < 3 || lines[0].Trim() != "---") { return result; } for (var i = 1; i < lines.Length; i++) { var line = lines[i]; if (line.Trim() == "---") { break; } var colon = line.IndexOf(':'); if (colon <= 0) { continue; } var key = line[..colon].Trim(); if (!FamilyKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) { continue; } var value = line[(colon + 1)..].Trim(); if (!string.IsNullOrWhiteSpace(value)) { foreach (var family in ParseFamilyValues(value)) { result.Add(family); } continue; } for (var j = i + 1; j < lines.Length; j++) { var nested = lines[j].Trim(); if (nested == "---" || !nested.StartsWith("-", StringComparison.Ordinal)) { break; } foreach (var family in ParseFamilyValues(nested[1..].Trim())) { result.Add(family); } } } return result; } private static IEnumerable ParseFamilyValues(string raw) { var normalized = raw .Trim() .Trim('[', ']'); foreach (var token in normalized.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var cleaned = token.Trim().Trim('"', '\''); if (cleaned.Equals("source-tree", StringComparison.OrdinalIgnoreCase) || cleaned.Equals("source_tree", StringComparison.OrdinalIgnoreCase) || cleaned.Equals("source tree", StringComparison.OrdinalIgnoreCase)) { yield return GraphFamily.SourceTree; } else if (cleaned.Equals("control-flow", StringComparison.OrdinalIgnoreCase) || cleaned.Equals("control_flow", StringComparison.OrdinalIgnoreCase) || cleaned.Equals("control flow", StringComparison.OrdinalIgnoreCase)) { yield return GraphFamily.ControlFlow; } } } } sealed class StringTupleComparer : IEqualityComparer<(string left, string right)> { public static readonly StringTupleComparer OrdinalIgnoreCase = new(StringComparer.OrdinalIgnoreCase); private readonly StringComparer _comparer; private StringTupleComparer(StringComparer comparer) { _comparer = comparer; } public bool Equals((string left, string right) x, (string left, string right) y) => _comparer.Equals(x.left, y.left) && _comparer.Equals(x.right, y.right); public int GetHashCode((string left, string right) obj) => HashCode.Combine(_comparer.GetHashCode(obj.left), _comparer.GetHashCode(obj.right)); } static class EnumerableExtensions { public static IEnumerable DistinctBy( this IEnumerable source, Func keySelector, IEqualityComparer? comparer = null) { var seen = new HashSet(comparer); foreach (var item in source) { if (seen.Add(keySelector(item))) { yield return item; } } } public static TValue GetValueOrDefault( this IReadOnlyDictionary dictionary, TKey key, TValue fallback = default!) where TKey : notnull { return dictionary.TryGetValue(key, out var value) ? value : fallback; } }