From f45b31defa4f52f82b2d9d2ef70a4f145fb89da2 Mon Sep 17 00:00:00 2001 From: kryptomrx Date: Sun, 17 May 2026 00:27:45 +0200 Subject: [PATCH] feat: graph and search API client with PS analysis cmdlets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Get-NozGraph, Find-NozNote, Measure-NozGarden, Set-NozNoteStatus, Get-NozOrphans — graph edges, Meilisearch search, garden stats, frontmatter status patch, orphan detection from inbound link analysis. --- Noz.PowerShell/Cmdlets/FindNozNoteCmdlet.cs | 35 +++++++++++ Noz.PowerShell/Cmdlets/GetNozGraphCmdlet.cs | 38 +++++++++++ Noz.PowerShell/Cmdlets/GetNozOrphansCmdlet.cs | 31 +++++++++ .../Cmdlets/MeasureNozGardenCmdlet.cs | 41 ++++++++++++ .../Cmdlets/SetNozNoteStatusCmdlet.cs | 49 +++++++++++++++ Noz.PowerShell/Noz.psd1 | 10 ++- Noz.PowerShell/NozGardenStats.cs | 15 +++++ Noz.PowerShell/NozGraphEdge.cs | 11 ++++ Noz.PowerShell/NozSearchHit.cs | 13 ++++ NozCli.Core/Client/NozApiClient.cs | 63 +++++++++++++++++++ NozCli.Core/Models/GraphData.cs | 5 ++ NozCli.Core/Models/SearchHit.cs | 10 +++ NozCli.Core/Serialization/NozJsonContext.cs | 9 +++ NozCli/Repl/CommandRegistry.cs | 4 ++ 14 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 Noz.PowerShell/Cmdlets/FindNozNoteCmdlet.cs create mode 100644 Noz.PowerShell/Cmdlets/GetNozGraphCmdlet.cs create mode 100644 Noz.PowerShell/Cmdlets/GetNozOrphansCmdlet.cs create mode 100644 Noz.PowerShell/Cmdlets/MeasureNozGardenCmdlet.cs create mode 100644 Noz.PowerShell/Cmdlets/SetNozNoteStatusCmdlet.cs create mode 100644 Noz.PowerShell/NozGardenStats.cs create mode 100644 Noz.PowerShell/NozGraphEdge.cs create mode 100644 Noz.PowerShell/NozSearchHit.cs create mode 100644 NozCli.Core/Models/GraphData.cs create mode 100644 NozCli.Core/Models/SearchHit.cs diff --git a/Noz.PowerShell/Cmdlets/FindNozNoteCmdlet.cs b/Noz.PowerShell/Cmdlets/FindNozNoteCmdlet.cs new file mode 100644 index 0000000..16187e8 --- /dev/null +++ b/Noz.PowerShell/Cmdlets/FindNozNoteCmdlet.cs @@ -0,0 +1,35 @@ +using System.Management.Automation; + +namespace Noz.PowerShell.Cmdlets; + +[Cmdlet(VerbsCommon.Find, "NozNote")] +[OutputType(typeof(NozSearchHit))] +public sealed class FindNozNoteCmdlet : NozCmdletBase +{ + [Parameter(Mandatory = true, Position = 0)] + public string Query { get; set; } = ""; + + [Parameter] + public string? Topic { get; set; } + + [Parameter] + public int Limit { get; set; } = 20; + + protected override void ProcessRecord() + { + var hits = Run(Api.SearchAsync(Query, Topic, Limit)); + + foreach (var h in hits) + { + WriteObject(new NozSearchHit + { + Slug = h.Slug, + Title = h.Title, + Description = h.Description, + Topic = h.Topic, + Status = h.Status, + Lang = h.Lang, + }); + } + } +} diff --git a/Noz.PowerShell/Cmdlets/GetNozGraphCmdlet.cs b/Noz.PowerShell/Cmdlets/GetNozGraphCmdlet.cs new file mode 100644 index 0000000..68fcf81 --- /dev/null +++ b/Noz.PowerShell/Cmdlets/GetNozGraphCmdlet.cs @@ -0,0 +1,38 @@ +using System.Management.Automation; + +namespace Noz.PowerShell.Cmdlets; + +[Cmdlet(VerbsCommon.Get, "NozGraph")] +[OutputType(typeof(NozGraphEdge))] +public sealed class GetNozGraphCmdlet : NozCmdletBase +{ + [Parameter] + public string? Source { get; set; } + + [Parameter] + public string? Target { get; set; } + + protected override void ProcessRecord() + { + var graph = Run(Api.GetGraphAsync()); + var nodeMap = graph.Nodes.ToDictionary(n => n.Id, n => n.Title); + + var edges = graph.Edges.AsEnumerable(); + + if (Source is not null) + edges = edges.Where(e => e.Source.Contains(Source, StringComparison.OrdinalIgnoreCase)); + if (Target is not null) + edges = edges.Where(e => e.Target.Contains(Target, StringComparison.OrdinalIgnoreCase)); + + foreach (var e in edges) + { + WriteObject(new NozGraphEdge + { + Source = e.Source, + Target = e.Target, + SourceTitle = nodeMap.GetValueOrDefault(e.Source, e.Source), + TargetTitle = nodeMap.GetValueOrDefault(e.Target, e.Target), + }); + } + } +} diff --git a/Noz.PowerShell/Cmdlets/GetNozOrphansCmdlet.cs b/Noz.PowerShell/Cmdlets/GetNozOrphansCmdlet.cs new file mode 100644 index 0000000..ca6cbaa --- /dev/null +++ b/Noz.PowerShell/Cmdlets/GetNozOrphansCmdlet.cs @@ -0,0 +1,31 @@ +using System.Management.Automation; + +namespace Noz.PowerShell.Cmdlets; + +/// Returns notes that have no incoming wikilinks — good candidates for cleanup or linking. +[Cmdlet(VerbsCommon.Get, "NozOrphans")] +[OutputType(typeof(NozNote))] +public sealed class GetNozOrphansCmdlet : NozCmdletBase +{ + [Parameter] + public string? Topic { get; set; } + + protected override void ProcessRecord() + { + var notesTask = Api.GetNotesAsync(); + var graphTask = Api.GetGraphAsync(); + + var notes = Run(notesTask); + var graph = Run(graphTask); + + var targets = new HashSet(graph.Edges.Select(e => e.Target)); + + var orphans = notes.Where(n => !targets.Contains(n.Slug)); + + if (Topic is not null) + orphans = orphans.Where(n => string.Equals(n.Topic, Topic, StringComparison.OrdinalIgnoreCase)); + + foreach (var n in orphans.OrderBy(n => n.Topic).ThenBy(n => n.Slug)) + WriteObject(NozNote.From(n)); + } +} diff --git a/Noz.PowerShell/Cmdlets/MeasureNozGardenCmdlet.cs b/Noz.PowerShell/Cmdlets/MeasureNozGardenCmdlet.cs new file mode 100644 index 0000000..1b6c286 --- /dev/null +++ b/Noz.PowerShell/Cmdlets/MeasureNozGardenCmdlet.cs @@ -0,0 +1,41 @@ +using System.Management.Automation; + +namespace Noz.PowerShell.Cmdlets; + +[Cmdlet(VerbsDiagnostic.Measure, "NozGarden")] +[OutputType(typeof(NozGardenStats))] +public sealed class MeasureNozGardenCmdlet : NozCmdletBase +{ + protected override void ProcessRecord() + { + var notesTask = Api.GetNotesAsync(); + var graphTask = Api.GetGraphAsync(); + + var notes = Run(notesTask); + var graph = Run(graphTask); + + var targets = new HashSet(graph.Edges.Select(e => e.Target)); + var orphans = notes.Count(n => !targets.Contains(n.Slug)); + + var byTopic = notes + .GroupBy(n => n.Topic ?? "—") + .ToDictionary(g => g.Key, g => g.Count()); + + var byLang = notes + .GroupBy(n => n.Lang) + .ToDictionary(g => g.Key, g => g.Count()); + + WriteObject(new NozGardenStats + { + TotalNotes = notes.Count, + SeedCount = notes.Count(n => n.Status == "seed"), + BuddingCount = notes.Count(n => n.Status == "budding"), + EvergreenCount = notes.Count(n => n.Status == "evergreen"), + TopicCount = byTopic.Count, + LinkCount = graph.Edges.Count, + OrphanCount = orphans, + NotesByTopic = byTopic, + NotesByLang = byLang, + }); + } +} diff --git a/Noz.PowerShell/Cmdlets/SetNozNoteStatusCmdlet.cs b/Noz.PowerShell/Cmdlets/SetNozNoteStatusCmdlet.cs new file mode 100644 index 0000000..c2ae503 --- /dev/null +++ b/Noz.PowerShell/Cmdlets/SetNozNoteStatusCmdlet.cs @@ -0,0 +1,49 @@ +using System.Management.Automation; +using System.Text.RegularExpressions; + +namespace Noz.PowerShell.Cmdlets; + +[Cmdlet(VerbsCommon.Set, "NozNoteStatus", SupportsShouldProcess = true)] +[OutputType(typeof(NozNote))] +public sealed partial class SetNozNoteStatusCmdlet : NozCmdletBase +{ + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + public string Slug { get; set; } = ""; + + [Parameter(Mandatory = true, Position = 1)] + [ValidateSet("seed", "budding", "evergreen")] + public string Status { get; set; } = ""; + + [Parameter] + public SwitchParameter PassThru { get; set; } + + [GeneratedRegex(@"(?m)^(status:\s*)(?:seed|budding|evergreen)(\s*)$")] + private static partial Regex StatusPattern(); + + protected override void ProcessRecord() + { + var content = Run(Api.GetNoteAsync(Slug)); + + var patched = StatusPattern().Replace(content.Markdown, $"$1{Status}$2"); + + if (patched == content.Markdown) + { + WriteVerbose($"{Slug}: status is already '{Status}', no change."); + if (PassThru) WriteObject(Run(Api.GetNotesAsync()).Find(n => n.Slug == Slug) is { } n + ? NozNote.From(n) : null); + return; + } + + if (!ShouldProcess(Slug, $"Set status → {Status}")) return; + + Run(Api.PutNoteAsync(Slug, patched)); + WriteVerbose($"{Slug}: status updated to '{Status}'."); + + if (PassThru) + { + var notes = Run(Api.GetNotesAsync()); + var updated = notes.Find(n => n.Slug == Slug); + if (updated is not null) WriteObject(NozNote.From(updated)); + } + } +} diff --git a/Noz.PowerShell/Noz.psd1 b/Noz.PowerShell/Noz.psd1 index 461091b..a54d015 100644 --- a/Noz.PowerShell/Noz.psd1 +++ b/Noz.PowerShell/Noz.psd1 @@ -11,7 +11,15 @@ 'Get-NozNoteContent', 'Set-NozNote', 'New-NozNote', - 'Remove-NozNote' + 'Remove-NozNote', + 'Get-NozGraph', + 'Find-NozNote', + 'Measure-NozGarden', + 'Set-NozNoteStatus', + 'Get-NozOrphans', + 'Rename-NozNote', + 'Get-NozTemplate', + 'Add-NozLink' ) FunctionsToExport = @() diff --git a/Noz.PowerShell/NozGardenStats.cs b/Noz.PowerShell/NozGardenStats.cs new file mode 100644 index 0000000..c183079 --- /dev/null +++ b/Noz.PowerShell/NozGardenStats.cs @@ -0,0 +1,15 @@ +namespace Noz.PowerShell; + +public sealed class NozGardenStats +{ + public int TotalNotes { get; init; } + public int SeedCount { get; init; } + public int BuddingCount { get; init; } + public int EvergreenCount { get; init; } + public int TopicCount { get; init; } + public int LinkCount { get; init; } + public int OrphanCount { get; init; } + + public Dictionary NotesByTopic { get; init; } = new(); + public Dictionary NotesByLang { get; init; } = new(); +} diff --git a/Noz.PowerShell/NozGraphEdge.cs b/Noz.PowerShell/NozGraphEdge.cs new file mode 100644 index 0000000..ba0fe21 --- /dev/null +++ b/Noz.PowerShell/NozGraphEdge.cs @@ -0,0 +1,11 @@ +namespace Noz.PowerShell; + +public sealed class NozGraphEdge +{ + public string Source { get; init; } = ""; + public string Target { get; init; } = ""; + public string SourceTitle { get; init; } = ""; + public string TargetTitle { get; init; } = ""; + + public override string ToString() => $"{Source} → {Target}"; +} diff --git a/Noz.PowerShell/NozSearchHit.cs b/Noz.PowerShell/NozSearchHit.cs new file mode 100644 index 0000000..62d4838 --- /dev/null +++ b/Noz.PowerShell/NozSearchHit.cs @@ -0,0 +1,13 @@ +namespace Noz.PowerShell; + +public sealed class NozSearchHit +{ + public string Slug { get; init; } = ""; + public string Title { get; init; } = ""; + public string? Description { get; init; } + public string Topic { get; init; } = ""; + public string Status { get; init; } = ""; + public string Lang { get; init; } = ""; + + public override string ToString() => Slug; +} diff --git a/NozCli.Core/Client/NozApiClient.cs b/NozCli.Core/Client/NozApiClient.cs index 7ff857d..b9eb898 100644 --- a/NozCli.Core/Client/NozApiClient.cs +++ b/NozCli.Core/Client/NozApiClient.cs @@ -78,6 +78,69 @@ public class NozApiClient : IDisposable res.EnsureSuccessStatusCode(); } + public async Task> GetTemplatesAsync(CancellationToken ct = default) + { + var res = await _http.GetFromJsonAsync("api/cli/templates", NozJsonContext.Default.JsonElement, ct); + return res.GetProperty("templates").EnumerateArray().Select(t => new TemplateInfo( + Name: t.GetProperty("name").GetString()!, + Description: t.TryGetProperty("description", out var d) ? d.GetString() : null + )).ToList(); + } + + public async Task GetTemplateAsync(string name, CancellationToken ct = default) + { + var res = await _http.GetAsync($"api/cli/templates/{Uri.EscapeDataString(name)}", ct); + if (res.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + res.EnsureSuccessStatusCode(); + var json = await res.Content.ReadFromJsonAsync(NozJsonContext.Default.JsonElement, ct); + return new TemplateContent( + Name: json.GetProperty("name").GetString()!, + Description: json.TryGetProperty("description", out var d) ? d.GetString() : null, + Body: json.GetProperty("body").GetString()! + ); + } + + public async Task PutFolderIndexAsync( + string folderPath, string title, string? description = null, CancellationToken ct = default) + { + var body = JsonSerializer.Serialize(new FolderBody(title, description), NozJsonContext.Default.FolderBody); + var content = new StringContent(body, Encoding.UTF8, "application/json"); + var res = await _http.PutAsync($"api/cli/folders/{folderPath}", content, ct); + res.EnsureSuccessStatusCode(); + } + + public async Task GetGraphAsync(CancellationToken ct = default) + { + var res = await _http.GetFromJsonAsync("api/cli/graph", NozJsonContext.Default.JsonElement, ct); + var nodes = res.GetProperty("nodes").EnumerateArray().Select(n => new GraphNode( + Id: n.GetProperty("id").GetString()!, + Title: n.GetProperty("title").GetString()!, + Topic: n.GetProperty("topic").GetString()!, + Status: n.GetProperty("status").GetString()! + )).ToList(); + var edges = res.GetProperty("edges").EnumerateArray().Select(e => new GraphEdge( + Source: e.GetProperty("source").GetString()!, + Target: e.GetProperty("target").GetString()! + )).ToList(); + return new GraphData(nodes, edges); + } + + public async Task> SearchAsync( + string query, string? topic = null, int limit = 20, CancellationToken ct = default) + { + var url = $"api/cli/search?q={Uri.EscapeDataString(query)}&limit={limit}"; + if (topic is not null) url += $"&topic={Uri.EscapeDataString(topic)}"; + var res = await _http.GetFromJsonAsync(url, NozJsonContext.Default.JsonElement, ct); + return res.GetProperty("hits").EnumerateArray().Select(h => new SearchHit( + Slug: h.GetProperty("slug").GetString()!, + Title: h.GetProperty("title").GetString()!, + Description: h.TryGetProperty("description", out var d) ? d.GetString() : null, + Topic: h.GetProperty("topic").GetString()!, + Status: h.GetProperty("status").GetString()!, + Lang: h.GetProperty("lang").GetString()! + )).ToList(); + } + public async Task RebuildIndexAsync(CancellationToken ct = default) { var res = await _http.PostAsync("api/cli/rebuild", null, ct); diff --git a/NozCli.Core/Models/GraphData.cs b/NozCli.Core/Models/GraphData.cs new file mode 100644 index 0000000..dd0f893 --- /dev/null +++ b/NozCli.Core/Models/GraphData.cs @@ -0,0 +1,5 @@ +namespace NozCli.Core.Models; + +public record GraphNode(string Id, string Title, string Topic, string Status); +public record GraphEdge(string Source, string Target); +public record GraphData(List Nodes, List Edges); diff --git a/NozCli.Core/Models/SearchHit.cs b/NozCli.Core/Models/SearchHit.cs new file mode 100644 index 0000000..3739867 --- /dev/null +++ b/NozCli.Core/Models/SearchHit.cs @@ -0,0 +1,10 @@ +namespace NozCli.Core.Models; + +public record SearchHit( + string Slug, + string Title, + string? Description, + string Topic, + string Status, + string Lang +); diff --git a/NozCli.Core/Serialization/NozJsonContext.cs b/NozCli.Core/Serialization/NozJsonContext.cs index 1aadfc3..4590d3f 100644 --- a/NozCli.Core/Serialization/NozJsonContext.cs +++ b/NozCli.Core/Serialization/NozJsonContext.cs @@ -12,6 +12,14 @@ namespace NozCli.Core.Serialization; [JsonSerializable(typeof(NotesCacheFile))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(NoteInfo))] +[JsonSerializable(typeof(GraphData))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(FolderBody))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(TemplateInfo))] +[JsonSerializable(typeof(TemplateContent))] public partial class NozJsonContext : JsonSerializerContext { } [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, WriteIndented = true)] @@ -19,3 +27,4 @@ public partial class NozJsonContext : JsonSerializerContext { } internal partial class NozJsonContextPretty : JsonSerializerContext { } public record PushBody(string Markdown); +public record FolderBody(string Title, string? Description); diff --git a/NozCli/Repl/CommandRegistry.cs b/NozCli/Repl/CommandRegistry.cs index 4a2548e..95b6c80 100644 --- a/NozCli/Repl/CommandRegistry.cs +++ b/NozCli/Repl/CommandRegistry.cs @@ -23,6 +23,10 @@ public class CommandRegistry new RmCommand(), new ConfigCommand(), new TreeCommand(), + new MkdirCommand(), + new MvCommand(), + new LinkCommand(), + new TemplatesCommand(), new VersionCommand(), new ClearCommand(), };