179 lines
6.9 KiB
C#
179 lines
6.9 KiB
C#
using System.Reflection;
|
|
using System.Text.Json;
|
|
using w4c_workflows.Models.Nodes;
|
|
|
|
namespace w4c_workflows.Services.Nodes;
|
|
|
|
/// <summary>
|
|
/// 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 <c>Catalog/</c>;
|
|
/// - additional packs (connectors, OpenAPI imports) loaded from files.
|
|
/// </summary>
|
|
public class NodeBlueprintCatalog
|
|
{
|
|
private const string EmbeddedRoot = "w4c_workflows.Services.Nodes.Catalog.";
|
|
|
|
private readonly IReadOnlyDictionary<string, List<NodeBlueprint>> _byType;
|
|
|
|
public NodeBlueprintCatalog(IEnumerable<NodeBlueprint> blueprints)
|
|
{
|
|
var byType = new Dictionary<string, List<NodeBlueprint>>(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<NodeBlueprint>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>All registered blueprints, newest version first within each type.</summary>
|
|
public IReadOnlyList<NodeBlueprint> All =>
|
|
_byType.Values.SelectMany(v => v).ToList();
|
|
|
|
/// <summary>Distinct category labels, sorted, for the palette's category rail.</summary>
|
|
public IReadOnlyList<string> 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);
|
|
|
|
/// <summary>The default (highest) version of a type, or null when unknown.</summary>
|
|
public NodeBlueprint? Get(string type)
|
|
=> _byType.TryGetValue(type, out var versions) ? versions[^1] : null;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>All versions of a type, newest first.</summary>
|
|
public IReadOnlyList<NodeBlueprint> Versions(string type)
|
|
=> _byType.TryGetValue(type, out var versions)
|
|
? versions.OrderByDescending(v => v.Version).ToList()
|
|
: Array.Empty<NodeBlueprint>();
|
|
|
|
/// <summary>
|
|
/// Filters the palette. <paramref name="query"/> matches type, display name,
|
|
/// description or category; <paramref name="kind"/> matches <see cref="NodeKind"/>;
|
|
/// <paramref name="category"/> matches a category label.
|
|
/// </summary>
|
|
public IReadOnlyList<NodeBlueprint> Search(string? query = null, string? kind = null, string? category = null)
|
|
{
|
|
IEnumerable<NodeBlueprint> 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();
|
|
}
|
|
|
|
/// <summary>Loads core blueprints embedded in the running assembly.</summary>
|
|
public static IReadOnlyList<NodeBlueprint> LoadEmbedded()
|
|
=> LoadEmbedded(typeof(NodeBlueprintCatalog).Assembly);
|
|
|
|
/// <summary>Loads every <c>Catalog/*.json</c> embedded in <paramref name="assembly"/>.</summary>
|
|
public static IReadOnlyList<NodeBlueprint> LoadEmbedded(Assembly assembly)
|
|
{
|
|
var blueprints = new List<NodeBlueprint>();
|
|
|
|
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<NodeBlueprint>(json, NodeJson.Options)
|
|
?? throw new InvalidOperationException($"node blueprint '{resource}' is empty");
|
|
blueprints.Add(blueprint);
|
|
}
|
|
|
|
return blueprints;
|
|
}
|
|
|
|
/// <summary>Loads all <c>*.json</c> blueprints from a directory (connector packs).</summary>
|
|
public static IReadOnlyList<NodeBlueprint> LoadDirectory(string directory)
|
|
{
|
|
var blueprints = new List<NodeBlueprint>();
|
|
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<NodeBlueprint>(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;
|
|
}
|