From db9aabd2b736c677453839e1d9ff56716c902542 Mon Sep 17 00:00:00 2001 From: kryptomrx Date: Sat, 16 May 2026 23:42:27 +0200 Subject: [PATCH] feat(powershell): Noz PowerShell module with pipeline-native cmdlets - Get-NozNote: list notes, filter by -Topic, -Status, -Lang - Get-NozNoteContent: fetch markdown, accepts pipeline input by Slug - Set-NozNote: update note content, accepts markdown from pipeline - New-NozNote: create note with frontmatter template - Remove-NozNote: delete note, ShouldProcess + ConfirmImpact.High for safety - NozNote output object: clean PS-friendly properties (no internal Hash field) - NozCmdletBase: shared config loading, API client lifecycle, error translation - Noz.psd1: module manifest for PS 7.2+ - Install-NozModule.ps1: build + install to user module path - NozCli.Core retargeted to net8.0 for PS 7.4 LTS compatibility --- Noz.PowerShell/Cmdlets/GetNozNoteCmdlet.cs | 41 +++++++++++++ .../Cmdlets/GetNozNoteContentCmdlet.cs | 32 ++++++++++ Noz.PowerShell/Cmdlets/NewNozNoteCmdlet.cs | 59 +++++++++++++++++++ Noz.PowerShell/Cmdlets/RemoveNozNoteCmdlet.cs | 24 ++++++++ Noz.PowerShell/Cmdlets/SetNozNoteCmdlet.cs | 31 ++++++++++ Noz.PowerShell/Install-NozModule.ps1 | 29 +++++++++ Noz.PowerShell/Noz.PowerShell.csproj | 20 +++++++ Noz.PowerShell/Noz.psd1 | 26 ++++++++ Noz.PowerShell/NozCmdletBase.cs | 47 +++++++++++++++ Noz.PowerShell/NozNote.cs | 29 +++++++++ NozCli.Core/NozCli.Core.csproj | 2 +- noz-cli.slnx | 1 + 12 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 Noz.PowerShell/Cmdlets/GetNozNoteCmdlet.cs create mode 100644 Noz.PowerShell/Cmdlets/GetNozNoteContentCmdlet.cs create mode 100644 Noz.PowerShell/Cmdlets/NewNozNoteCmdlet.cs create mode 100644 Noz.PowerShell/Cmdlets/RemoveNozNoteCmdlet.cs create mode 100644 Noz.PowerShell/Cmdlets/SetNozNoteCmdlet.cs create mode 100644 Noz.PowerShell/Install-NozModule.ps1 create mode 100644 Noz.PowerShell/Noz.PowerShell.csproj create mode 100644 Noz.PowerShell/Noz.psd1 create mode 100644 Noz.PowerShell/NozCmdletBase.cs create mode 100644 Noz.PowerShell/NozNote.cs diff --git a/Noz.PowerShell/Cmdlets/GetNozNoteCmdlet.cs b/Noz.PowerShell/Cmdlets/GetNozNoteCmdlet.cs new file mode 100644 index 0000000..a020e8d --- /dev/null +++ b/Noz.PowerShell/Cmdlets/GetNozNoteCmdlet.cs @@ -0,0 +1,41 @@ +using System.Management.Automation; + +namespace Noz.PowerShell.Cmdlets; + +/// List notes from the noz server. +/// +/// Get-NozNote +/// Get-NozNote -Topic psychologie +/// Get-NozNote -Status seed -Lang de +/// Get-NozNote | Group-Object Topic | Sort-Object Count -Descending +/// Get-NozNote | Export-Csv ~/notes.csv +/// +[Cmdlet(VerbsCommon.Get, "NozNote")] +[OutputType(typeof(NozNote))] +public sealed class GetNozNoteCmdlet : NozCmdletBase +{ + [Parameter(Position = 0)] + public string? Topic { get; set; } + + [Parameter] + [ValidateSet("seed", "budding", "evergreen")] + public string? Status { get; set; } + + [Parameter] + [ValidateSet("de", "en")] + public string? Lang { get; set; } + + protected override void ProcessRecord() + { + var notes = Run(Api.GetNotesAsync()); + + var filtered = notes.AsEnumerable(); + + if (Topic is not null) filtered = filtered.Where(n => n.Topic == Topic); + if (Status is not null) filtered = filtered.Where(n => n.Status == Status); + if (Lang is not null) filtered = filtered.Where(n => n.Lang == Lang); + + foreach (var note in filtered.OrderBy(n => n.Slug)) + WriteObject(NozNote.From(note)); + } +} diff --git a/Noz.PowerShell/Cmdlets/GetNozNoteContentCmdlet.cs b/Noz.PowerShell/Cmdlets/GetNozNoteContentCmdlet.cs new file mode 100644 index 0000000..0ff7587 --- /dev/null +++ b/Noz.PowerShell/Cmdlets/GetNozNoteContentCmdlet.cs @@ -0,0 +1,32 @@ +using System.Management.Automation; + +namespace Noz.PowerShell.Cmdlets; + +/// Get the markdown content of a note. +/// +/// Get-NozNoteContent -Slug psychologie/flow-theorie +/// "psychologie/flow-theorie" | Get-NozNoteContent +/// Get-NozNote -Topic psychologie | Get-NozNoteContent | Select-Object Slug, Markdown +/// +[Cmdlet(VerbsCommon.Get, "NozNoteContent")] +[OutputType(typeof(NozNoteContent))] +public sealed class GetNozNoteContentCmdlet : NozCmdletBase +{ + [Parameter(Mandatory = true, Position = 0, + ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Slug { get; set; } = ""; + + protected override void ProcessRecord() + { + var content = Run(Api.GetNoteAsync(Slug)); + WriteObject(new NozNoteContent { Slug = content.Slug, Markdown = content.Markdown }); + } +} + +public sealed class NozNoteContent +{ + public string Slug { get; init; } = ""; + public string Markdown { get; init; } = ""; + + public override string ToString() => Slug; +} diff --git a/Noz.PowerShell/Cmdlets/NewNozNoteCmdlet.cs b/Noz.PowerShell/Cmdlets/NewNozNoteCmdlet.cs new file mode 100644 index 0000000..9000f96 --- /dev/null +++ b/Noz.PowerShell/Cmdlets/NewNozNoteCmdlet.cs @@ -0,0 +1,59 @@ +using System.Management.Automation; + +namespace Noz.PowerShell.Cmdlets; + +/// Create a new note on the server. +/// +/// New-NozNote -Slug psychologie/neue-notiz -Title "Neue Notiz" +/// New-NozNote -Slug biosysteme/pilze -Title "Pilze" -Topic biosysteme -Status budding -Lang de +/// +[Cmdlet(VerbsCommon.New, "NozNote", SupportsShouldProcess = true)] +[OutputType(typeof(NozNote))] +public sealed class NewNozNoteCmdlet : NozCmdletBase +{ + [Parameter(Mandatory = true, Position = 0)] + public string Slug { get; set; } = ""; + + [Parameter(Mandatory = true, Position = 1)] + public string Title { get; set; } = ""; + + [Parameter] + public string? Topic { get; set; } + + [Parameter] + [ValidateSet("seed", "budding", "evergreen")] + public string Status { get; set; } = "seed"; + + [Parameter] + [ValidateSet("de", "en")] + public string Lang { get; set; } = "de"; + + [Parameter] + public string Description { get; set; } = ""; + + protected override void ProcessRecord() + { + if (!ShouldProcess(Slug, "Create note")) return; + + var topic = Topic ?? (Slug.Contains('/') ? Slug.Split('/')[0] : Slug); + var markdown = $""" + --- + title: "{Title}" + description: "{Description}" + topic: {topic} + status: {Status} + lang: {Lang} + --- + + # {Title} + + """; + + Run(Api.PutNoteAsync(Slug, markdown)); + + var notes = Run(Api.GetNotesAsync()); + var created = notes.FirstOrDefault(n => n.Slug == Slug); + if (created is not null) + WriteObject(NozNote.From(created)); + } +} diff --git a/Noz.PowerShell/Cmdlets/RemoveNozNoteCmdlet.cs b/Noz.PowerShell/Cmdlets/RemoveNozNoteCmdlet.cs new file mode 100644 index 0000000..d782dd2 --- /dev/null +++ b/Noz.PowerShell/Cmdlets/RemoveNozNoteCmdlet.cs @@ -0,0 +1,24 @@ +using System.Management.Automation; + +namespace Noz.PowerShell.Cmdlets; + +/// Delete a note from the server. +/// +/// Remove-NozNote -Slug psychologie/alte-notiz +/// Get-NozNote -Status seed | Where-Object Topic -eq "draft" | Remove-NozNote -WhatIf +/// Get-NozNote -Status seed | Where-Object Topic -eq "draft" | Remove-NozNote -Confirm:$false +/// +[Cmdlet(VerbsCommon.Remove, "NozNote", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] +public sealed class RemoveNozNoteCmdlet : NozCmdletBase +{ + [Parameter(Mandatory = true, Position = 0, + ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Slug { get; set; } = ""; + + protected override void ProcessRecord() + { + if (!ShouldProcess(Slug, "Delete note")) return; + Run(Api.DeleteNoteAsync(Slug)); + WriteVerbose($"Deleted: {Slug}"); + } +} diff --git a/Noz.PowerShell/Cmdlets/SetNozNoteCmdlet.cs b/Noz.PowerShell/Cmdlets/SetNozNoteCmdlet.cs new file mode 100644 index 0000000..e2e392b --- /dev/null +++ b/Noz.PowerShell/Cmdlets/SetNozNoteCmdlet.cs @@ -0,0 +1,31 @@ +using System.Management.Automation; + +namespace Noz.PowerShell.Cmdlets; + +/// Update the markdown content of an existing note. +/// +/// Set-NozNote -Slug psychologie/test -Markdown "# Test`n`nHello." +/// Get-Content note.md -Raw | Set-NozNote -Slug psychologie/test +/// +[Cmdlet(VerbsCommon.Set, "NozNote", SupportsShouldProcess = true)] +[OutputType(typeof(NozNote))] +public sealed class SetNozNoteCmdlet : NozCmdletBase +{ + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + public string Slug { get; set; } = ""; + + [Parameter(Mandatory = true, Position = 1, ValueFromPipeline = true)] + public string Markdown { get; set; } = ""; + + protected override void ProcessRecord() + { + if (!ShouldProcess(Slug, "Update note")) return; + + Run(Api.PutNoteAsync(Slug, Markdown)); + + var notes = Run(Api.GetNotesAsync()); + var updated = notes.FirstOrDefault(n => n.Slug == Slug); + if (updated is not null) + WriteObject(NozNote.From(updated)); + } +} diff --git a/Noz.PowerShell/Install-NozModule.ps1 b/Noz.PowerShell/Install-NozModule.ps1 new file mode 100644 index 0000000..6c49744 --- /dev/null +++ b/Noz.PowerShell/Install-NozModule.ps1 @@ -0,0 +1,29 @@ +#Requires -Version 7.2 +<# +.SYNOPSIS + Builds and installs the Noz PowerShell module for the current user. +.EXAMPLE + ./Install-NozModule.ps1 + ./Install-NozModule.ps1 -Verbose +#> + +$ErrorActionPreference = 'Stop' + +$moduleDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell' 'Modules' 'Noz' + +Write-Host "Building Noz.PowerShell..." -ForegroundColor Cyan +dotnet build "$PSScriptRoot/Noz.PowerShell.csproj" -c Release -v quiet + +$src = "$PSScriptRoot/bin/Release/net8.0" + +Write-Host "Installing to: $moduleDir" -ForegroundColor Cyan +New-Item -ItemType Directory -Force -Path $moduleDir | Out-Null + +# Copy DLLs and manifest +Copy-Item "$src/Noz.PowerShell.dll" $moduleDir -Force +Copy-Item "$src/NozCli.Core.dll" $moduleDir -Force +Copy-Item "$PSScriptRoot/Noz.psd1" $moduleDir -Force + +Write-Host "Done. Import with:" -ForegroundColor Green +Write-Host " Import-Module Noz" -ForegroundColor White +Write-Host " Get-NozNote -Topic psychologie" -ForegroundColor White diff --git a/Noz.PowerShell/Noz.PowerShell.csproj b/Noz.PowerShell/Noz.PowerShell.csproj new file mode 100644 index 0000000..4f5d5bb --- /dev/null +++ b/Noz.PowerShell/Noz.PowerShell.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + net8.0 + enable + enable + Noz.PowerShell + Noz.PowerShell + true + + + diff --git a/Noz.PowerShell/Noz.psd1 b/Noz.PowerShell/Noz.psd1 new file mode 100644 index 0000000..461091b --- /dev/null +++ b/Noz.PowerShell/Noz.psd1 @@ -0,0 +1,26 @@ +@{ + ModuleVersion = '0.1.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + Author = 'Bruno Deanoz' + Description = 'PowerShell module for noz — a self-hosted digital garden' + PowerShellVersion = '7.2' + RootModule = 'Noz.PowerShell.dll' + + CmdletsToExport = @( + 'Get-NozNote', + 'Get-NozNoteContent', + 'Set-NozNote', + 'New-NozNote', + 'Remove-NozNote' + ) + + FunctionsToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('noz', 'digital-garden', 'notes', 'markdown') + ProjectUri = 'https://git.kryptomrx.de/krypto/noz-cli' + } + } +} diff --git a/Noz.PowerShell/NozCmdletBase.cs b/Noz.PowerShell/NozCmdletBase.cs new file mode 100644 index 0000000..9c93bf4 --- /dev/null +++ b/Noz.PowerShell/NozCmdletBase.cs @@ -0,0 +1,47 @@ +using System.Management.Automation; +using NozCli.Core.Client; +using NozCli.Core.Config; + +namespace Noz.PowerShell; + +public abstract class NozCmdletBase : PSCmdlet +{ + protected LocalConfig Config { get; private set; } = null!; + protected NozApiClient Api { get; private set; } = null!; + + protected override void BeginProcessing() + { + var cfg = LocalConfig.Load(); + if (cfg is null) + ThrowTerminatingError(new ErrorRecord( + new InvalidOperationException( + "noz is not initialized. Run: noz init "), + "NozNotInitialized", + ErrorCategory.NotSpecified, + targetObject: null)); + + Config = cfg!; + Api = new NozApiClient(cfg!); + } + + protected override void StopProcessing() => Api?.Dispose(); + + protected T Run(Task task) + { + try { return task.GetAwaiter().GetResult(); } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + ThrowTerminatingError(new ErrorRecord(ex, "NozUnauthorized", + ErrorCategory.AuthenticationError, null)); + throw; + } + catch (HttpRequestException ex) + { + ThrowTerminatingError(new ErrorRecord(ex, "NozApiError", + ErrorCategory.ConnectionError, null)); + throw; + } + } + + protected void Run(Task task) => Run(task.ContinueWith(_ => true)); +} diff --git a/Noz.PowerShell/NozNote.cs b/Noz.PowerShell/NozNote.cs new file mode 100644 index 0000000..fac164c --- /dev/null +++ b/Noz.PowerShell/NozNote.cs @@ -0,0 +1,29 @@ +using NozCli.Core.Models; + +namespace Noz.PowerShell; + +/// PS-friendly output object for a noz note. +/// Clean property set — no internal fields like Hash. +public sealed class NozNote +{ + public string Slug { get; init; } = ""; + public string Title { get; init; } = ""; + public string Status { get; init; } = ""; + public string Topic { get; init; } = ""; + public string Lang { get; init; } = ""; + public string? Date { get; init; } + public bool Private { get; init; } + + public override string ToString() => Slug; + + internal static NozNote From(NoteInfo n) => new() + { + Slug = n.Slug, + Title = n.Title, + Status = n.Status, + Topic = n.Topic, + Lang = n.Lang, + Date = n.Date, + Private = n.Private, + }; +} diff --git a/NozCli.Core/NozCli.Core.csproj b/NozCli.Core/NozCli.Core.csproj index 37780e4..6aa5f96 100644 --- a/NozCli.Core/NozCli.Core.csproj +++ b/NozCli.Core/NozCli.Core.csproj @@ -1,7 +1,7 @@  - net10.0 + net8.0 enable enable diff --git a/noz-cli.slnx b/noz-cli.slnx index 9c52bf0..118ed83 100644 --- a/noz-cli.slnx +++ b/noz-cli.slnx @@ -1,4 +1,5 @@ +