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', 'Get-NozNoteContent',
'Set-NozNote', 'Set-NozNote',
'New-NozNote', 'New-NozNote',
'Remove-NozNote' 'Remove-NozNote',
'Get-NozGraph',
'Find-NozNote',
'Measure-NozGarden',
'Set-NozNoteStatus',
'Get-NozOrphans',
'Rename-NozNote',
'Get-NozTemplate',
'Add-NozLink'
) )
FunctionsToExport = @() 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(); 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) public async Task RebuildIndexAsync(CancellationToken ct = default)
{ {
var res = await _http.PostAsync("api/cli/rebuild", null, ct); 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(NotesCacheFile))]
[JsonSerializable(typeof(List<NoteInfo>))] [JsonSerializable(typeof(List<NoteInfo>))]
[JsonSerializable(typeof(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 { } public partial class NozJsonContext : JsonSerializerContext { }
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, WriteIndented = true)] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, WriteIndented = true)]
@ -19,3 +27,4 @@ public partial class NozJsonContext : JsonSerializerContext { }
internal partial class NozJsonContextPretty : JsonSerializerContext { } internal partial class NozJsonContextPretty : JsonSerializerContext { }
public record PushBody(string Markdown); public record PushBody(string Markdown);
public record FolderBody(string Title, string? Description);

View file

@ -23,6 +23,10 @@ public class CommandRegistry
new RmCommand(), new RmCommand(),
new ConfigCommand(), new ConfigCommand(),
new TreeCommand(), new TreeCommand(),
new MkdirCommand(),
new MvCommand(),
new LinkCommand(),
new TemplatesCommand(),
new VersionCommand(), new VersionCommand(),
new ClearCommand(), new ClearCommand(),
}; };