using System.Reflection; using System.Text.Json; using w4c_workflows.Models.Nodes; namespace w4c_workflows.Services.Nodes; /// /// The catalog of known node types. It is the single source of truth for the /// compiler/validator and the authoring UI (palette + generic parameter forms). /// /// Blueprints come from two places: /// - built-in core nodes, shipped as embedded JSON under Catalog/; /// - additional packs (connectors, OpenAPI imports) loaded from files. /// public class NodeBlueprintCatalog { private const string EmbeddedRoot = "w4c_workflows.Services.Nodes.Catalog."; private readonly IReadOnlyDictionary> _byType; public NodeBlueprintCatalog(IEnumerable blueprints) { var byType = new Dictionary>(StringComparer.Ordinal); foreach (var blueprint in blueprints) { if (string.IsNullOrWhiteSpace(blueprint.Type)) throw new InvalidOperationException("a node blueprint is missing its type id"); if (!byType.TryGetValue(blueprint.Type, out var versions)) byType[blueprint.Type] = versions = new List(); if (versions.Any(existing => SameVersion(existing.Version, blueprint.Version))) throw new InvalidOperationException( $"duplicate node blueprint '{blueprint.Type}' version {blueprint.Version}"); versions.Add(blueprint); } foreach (var versions in byType.Values) versions.Sort((a, b) => a.Version.CompareTo(b.Version)); _byType = byType; } /// All registered blueprints, newest version first within each type. public IReadOnlyList All => _byType.Values.SelectMany(v => v).ToList(); /// Distinct category labels, sorted, for the palette's category rail. public IReadOnlyList Categories => _byType.Values.SelectMany(v => v) .SelectMany(b => b.Categories) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(c => c, StringComparer.OrdinalIgnoreCase) .ToList(); public bool Contains(string type) => _byType.ContainsKey(type); /// The default (highest) version of a type, or null when unknown. public NodeBlueprint? Get(string type) => _byType.TryGetValue(type, out var versions) ? versions[^1] : null; /// /// Resolves a specific version. A requested version of 0/null means "default" /// (highest). Otherwise the closest version not newer than requested is used, /// so a task pinned to 2.5 keeps working after 2.6 ships. /// public NodeBlueprint? Resolve(string type, double? version = null) { if (!_byType.TryGetValue(type, out var versions)) return null; if (version is null or 0) return versions[^1]; NodeBlueprint? match = null; foreach (var candidate in versions) { if (candidate.Version <= version.Value + 0.0001) match = candidate; else break; } return match; } /// All versions of a type, newest first. public IReadOnlyList Versions(string type) => _byType.TryGetValue(type, out var versions) ? versions.OrderByDescending(v => v.Version).ToList() : Array.Empty(); /// /// Filters the palette. matches type, display name, /// description or category; matches ; /// matches a category label. /// public IReadOnlyList Search(string? query = null, string? kind = null, string? category = null) { IEnumerable results = _byType.Values.Select(v => v[^1]); if (!string.IsNullOrWhiteSpace(kind)) results = results.Where(b => string.Equals(b.Kind, kind, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(category)) results = results.Where(b => b.Categories.Any(c => string.Equals(c, category, StringComparison.OrdinalIgnoreCase))); if (!string.IsNullOrWhiteSpace(query)) { var needle = query.Trim(); results = results.Where(b => Contains(b.Type, needle) || Contains(b.DisplayName, needle) || Contains(b.Description, needle) || b.Categories.Any(c => Contains(c, needle))); } return results .OrderBy(b => b.DisplayName, StringComparer.OrdinalIgnoreCase) .ToList(); } /// Loads core blueprints embedded in the running assembly. public static IReadOnlyList LoadEmbedded() => LoadEmbedded(typeof(NodeBlueprintCatalog).Assembly); /// Loads every Catalog/*.json embedded in . public static IReadOnlyList LoadEmbedded(Assembly assembly) { var blueprints = new List(); foreach (var resource in assembly.GetManifestResourceNames()) { if (!resource.StartsWith(EmbeddedRoot, StringComparison.Ordinal) || !resource.EndsWith(".json", StringComparison.Ordinal)) { continue; } using var stream = assembly.GetManifestResourceStream(resource); if (stream == null) continue; using var reader = new StreamReader(stream); var json = reader.ReadToEnd(); var blueprint = JsonSerializer.Deserialize(json, NodeJson.Options) ?? throw new InvalidOperationException($"node blueprint '{resource}' is empty"); blueprints.Add(blueprint); } return blueprints; } /// Loads all *.json blueprints from a directory (connector packs). public static IReadOnlyList LoadDirectory(string directory) { var blueprints = new List(); if (!Directory.Exists(directory)) return blueprints; foreach (var file in Directory.EnumerateFiles(directory, "*.json", SearchOption.AllDirectories)) { var json = File.ReadAllText(file); var blueprint = JsonSerializer.Deserialize(json, NodeJson.Options) ?? throw new InvalidOperationException($"node blueprint '{file}' is empty"); blueprints.Add(blueprint); } return blueprints; } private static bool SameVersion(double a, double b) => Math.Abs(a - b) < 0.0001; private static bool Contains(string? haystack, string needle) => haystack?.Contains(needle, StringComparison.OrdinalIgnoreCase) == true; }