feat: graph and search API client with PS analysis cmdlets

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.
This commit is contained in:
kryptomrx 2026-05-17 00:27:45 +02:00
parent 0eebffe745
commit f45b31defa
14 changed files with 333 additions and 1 deletions

View file

@ -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,
});
}
}
}

View file

@ -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),
});
}
}
}

View file

@ -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<string>(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));
}
}

View file

@ -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<string>(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,
});
}
}

View file

@ -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));
}
}
}

View file

@ -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 = @()

View file

@ -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<string, int> NotesByTopic { get; init; } = new();
public Dictionary<string, int> NotesByLang { get; init; } = new();
}

View file

@ -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}";
}

View file

@ -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;
}

View file

@ -78,6 +78,69 @@ public class NozApiClient : IDisposable
res.EnsureSuccessStatusCode();
}
public async Task<List<TemplateInfo>> 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<TemplateContent?> 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<GraphData> 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<List<SearchHit>> 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);

View file

@ -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<GraphNode> Nodes, List<GraphEdge> Edges);

View file

@ -0,0 +1,10 @@
namespace NozCli.Core.Models;
public record SearchHit(
string Slug,
string Title,
string? Description,
string Topic,
string Status,
string Lang
);

View file

@ -12,6 +12,14 @@ namespace NozCli.Core.Serialization;
[JsonSerializable(typeof(NotesCacheFile))]
[JsonSerializable(typeof(List<NoteInfo>))]
[JsonSerializable(typeof(NoteInfo))]
[JsonSerializable(typeof(GraphData))]
[JsonSerializable(typeof(List<GraphNode>))]
[JsonSerializable(typeof(List<GraphEdge>))]
[JsonSerializable(typeof(List<SearchHit>))]
[JsonSerializable(typeof(FolderBody))]
[JsonSerializable(typeof(List<TemplateInfo>))]
[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);

View file

@ -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(),
};