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',
|
||||
'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 = @()
|
||||
|
|
|
|||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
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(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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue