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:
parent
0eebffe745
commit
f45b31defa
14 changed files with 333 additions and 1 deletions
35
Noz.PowerShell/Cmdlets/FindNozNoteCmdlet.cs
Normal file
35
Noz.PowerShell/Cmdlets/FindNozNoteCmdlet.cs
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Noz.PowerShell/Cmdlets/GetNozGraphCmdlet.cs
Normal file
38
Noz.PowerShell/Cmdlets/GetNozGraphCmdlet.cs
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Noz.PowerShell/Cmdlets/GetNozOrphansCmdlet.cs
Normal file
31
Noz.PowerShell/Cmdlets/GetNozOrphansCmdlet.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Noz.PowerShell/Cmdlets/MeasureNozGardenCmdlet.cs
Normal file
41
Noz.PowerShell/Cmdlets/MeasureNozGardenCmdlet.cs
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Noz.PowerShell/Cmdlets/SetNozNoteStatusCmdlet.cs
Normal file
49
Noz.PowerShell/Cmdlets/SetNozNoteStatusCmdlet.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = @()
|
||||||
|
|
|
||||||
15
Noz.PowerShell/NozGardenStats.cs
Normal file
15
Noz.PowerShell/NozGardenStats.cs
Normal 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();
|
||||||
|
}
|
||||||
11
Noz.PowerShell/NozGraphEdge.cs
Normal file
11
Noz.PowerShell/NozGraphEdge.cs
Normal 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}";
|
||||||
|
}
|
||||||
13
Noz.PowerShell/NozSearchHit.cs
Normal file
13
Noz.PowerShell/NozSearchHit.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
5
NozCli.Core/Models/GraphData.cs
Normal file
5
NozCli.Core/Models/GraphData.cs
Normal 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);
|
||||||
10
NozCli.Core/Models/SearchHit.cs
Normal file
10
NozCli.Core/Models/SearchHit.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace NozCli.Core.Models;
|
||||||
|
|
||||||
|
public record SearchHit(
|
||||||
|
string Slug,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
string Topic,
|
||||||
|
string Status,
|
||||||
|
string Lang
|
||||||
|
);
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue