Norn/Program.cs
2026-05-25 13:51:36 +01:00

1547 lines
55 KiB
C#

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<int> 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<NornMcpTools>();
await builder.Build().RunAsync();
return 0;
}
}
static class ProgramEntry
{
public static async Task<int> 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 <path> [--output-dir <path>] [--entry-note <name>] [--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<GraphGenerationResult> 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<GraphPartitionResult>();
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<string> 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<GraphPartitionResult> Graphs { get; init; }
public required IReadOnlyList<string> 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<GraphGenerationResult> 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<string> SeedNoteIds { get; init; }
public required IReadOnlyDictionary<string, NoteRecord> NotesById { get; init; }
public required IReadOnlyList<EdgeRecord> AllEdges { get; init; }
public required IReadOnlyDictionary<string, IReadOnlySet<GraphFamily>> FamiliesByNoteId { get; init; }
public required IReadOnlyDictionary<string, int> DepthById { get; init; }
public required IReadOnlyCollection<string> IncludedNoteIds { get; init; }
public required IReadOnlyList<GraphPartitionModel> Partitions { get; init; }
public required IReadOnlyList<EdgeRecord> CrossFamilyEdges { get; init; }
public required IReadOnlyList<string> Warnings { get; init; }
public static VaultGraphModel Load(string vaultRoot, string? entryNote, bool includeUnreachable)
{
var warnings = new List<string>();
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<string>()
: ResolveSeedNotes(entryNoteId, notes, familiesByNoteId, byBaseName);
var included = includeUnreachable || entryNoteId is null
? new HashSet<string>(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<string> ExtractTargets(
NoteRecord source,
IReadOnlyDictionary<string, NoteRecord> notes,
IReadOnlyDictionary<string, NoteRecord> byRelativeStem,
IReadOnlyDictionary<string, NoteRecord[]> byBaseName,
List<string> warnings)
{
var yielded = new HashSet<string>(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<string, NoteRecord> byRelativeStem,
IReadOnlyDictionary<string, NoteRecord[]> 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<string, NoteRecord> notes,
IReadOnlyDictionary<string, NoteRecord> byRelativeStem,
IReadOnlyDictionary<string, NoteRecord[]> 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<string, HashSet<string>> BuildAdjacency(
IEnumerable<EdgeRecord> edges,
Func<EdgeRecord, string> keySelector,
Func<EdgeRecord, string> valueSelector)
{
var adjacency = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var edge in edges)
{
var key = keySelector(edge);
if (!adjacency.TryGetValue(key, out var values))
{
values = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
adjacency[key] = values;
}
values.Add(valueSelector(edge));
}
return adjacency;
}
private static IReadOnlyList<string> ResolveSeedNotes(
string entryNoteId,
IReadOnlyDictionary<string, NoteRecord> notes,
IReadOnlyDictionary<string, IReadOnlySet<GraphFamily>> familiesByNoteId,
IReadOnlyDictionary<string, NoteRecord[]> byBaseName)
{
var seeds = new HashSet<string>(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<GraphFamily>())
{
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<string> TraverseFromEntries(
IReadOnlyCollection<string> entryNoteIds,
IReadOnlyDictionary<string, HashSet<string>> outgoing)
{
var visited = new HashSet<string>(entryNoteIds, StringComparer.OrdinalIgnoreCase);
var queue = new Queue<string>();
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<string, int> CalculateDepths(
IReadOnlyCollection<string> entryNoteIds,
IReadOnlyDictionary<string, HashSet<string>> outgoing,
IReadOnlyCollection<string> included)
{
var depths = entryNoteIds.ToDictionary(
id => id,
_ => 0,
StringComparer.OrdinalIgnoreCase);
var queue = new Queue<string>(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<GraphPartitionModel> BuildPartitions(
IReadOnlyDictionary<string, NoteRecord> notes,
IReadOnlyDictionary<string, IReadOnlySet<GraphFamily>> familiesByNoteId,
IReadOnlyList<EdgeRecord> filteredEdges,
IReadOnlyDictionary<string, int> depths,
IReadOnlyCollection<string> included,
string? entryNoteId)
{
var partitions = new List<GraphPartitionModel>();
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<string, GraphPartitionNodeRole>(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<GraphFamily> left, IReadOnlySet<GraphFamily> 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<string> IncludedNoteIds { get; init; }
public required IReadOnlyList<EdgeRecord> Edges { get; init; }
public required IReadOnlyDictionary<string, GraphPartitionNodeRole> 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<string, LabelMetrics>(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<GraphFamily> 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<string> Warnings { get; init; }
public required IReadOnlyList<LayoutNodeRecord> Nodes { get; init; }
public required IReadOnlyList<LayoutEdgeRecord> 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<string> 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<CrossFamilyLinkRecord> 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<string> SourceFamilies { get; init; }
public required string TargetId { get; init; }
public required string TargetTitle { get; init; }
public required IReadOnlyList<string> 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<GraphFamily> 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> { GraphFamily.ControlFlow };
}
return new HashSet<GraphFamily> { GraphFamily.SourceTree };
}
}
static class FrontMatterGraphFamilyParser
{
private static readonly string[] FamilyKeys =
[
"norn-family",
"norn-families",
"graph-family",
"graph-families",
];
public static IReadOnlySet<GraphFamily> TryParseFamilies(string content)
{
var result = new HashSet<GraphFamily>();
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<GraphFamily> 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<TSource> DistinctBy<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null)
{
var seen = new HashSet<TKey>(comparer);
foreach (var item in source)
{
if (seen.Add(keySelector(item)))
{
yield return item;
}
}
}
public static TValue GetValueOrDefault<TKey, TValue>(
this IReadOnlyDictionary<TKey, TValue> dictionary,
TKey key,
TValue fallback = default!)
where TKey : notnull
{
return dictionary.TryGetValue(key, out var value) ? value : fallback;
}
}