Compare commits

..

No commits in common. "bfd702e47090f657fcf61713e63d5766dc0d3e57" and "979f5bd7677b8cf6ce1a8639752b4b7490ce218b" have entirely different histories.

55 changed files with 100 additions and 2423 deletions

View file

@ -1,27 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release
- name: Test
run: dotnet test NozCli.Tests/NozCli.Tests.csproj --no-build -c Release --verbosity normal

View file

@ -1,81 +1,26 @@
# Changelog
All notable changes to noz-cli are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning: [Semantic Versioning](https://semver.org/).
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — versioning follows [Semantic Versioning](https://semver.org/).
---
## [Unreleased]
## [0.2.0] — 2026-05-17
### Added
**REPL commands**
- `mv <old> <new>` — rename a note and atomically rewrite all inbound wikilinks and markdown links
- `mkdir <name>` — create a folder by writing a `_index.md`; navigates into the new folder immediately
- `link <source> <target>` — add a wikilink to the "Siehe auch" section (or create it); `--both` for bidirectional
- `search <query>` — full-text search via Meilisearch; context-aware (defaults to current topic); `--topic`, `--limit` flags
- `templates` — list available server-side note templates
- `new --template <name> <slug>` — create a note from a template with `{{title}}`, `{{date}}`, `{{topic}}` placeholders
- `cat --raw <slug>` — print raw markdown (default now renders via MarkdownRenderer)
- `clear` — clear the terminal screen
**PowerShell module**
- `Get-NozGraph` — all wikilink edges as `NozGraphEdge` objects; filterable by `-Source` / `-Target`
- `Find-NozNote` — Meilisearch full-text search; `-Topic`, `-Limit`
- `Measure-NozGarden` — garden-wide statistics: note counts by status/topic/lang, link count, orphan count
- `Set-NozNoteStatus` — patch the `status:` frontmatter field in place; `SupportsShouldProcess`
- `Get-NozOrphans` — notes with no incoming wikilinks; `-Topic` filter
- `Rename-NozNote` — wraps `NoteRenamer` for pipeline use; `-PassThru`, `-WhatIf`
- `Get-NozTemplate` — list server-side templates
- `Add-NozLink` — add wikilinks between notes; `-Both`, `-Section`
**Server API routes** (noz Next.js app)
- `GET /api/cli/graph` — full wikilink adjacency list from pre-built index
- `GET /api/cli/search` — Meilisearch / local-search with `?q`, `?topic`, `?limit`
- `PUT /api/cli/folders/[...path]` — create `_index.md` for a folder
- `GET /api/cli/templates` — list templates from `_templates/`
- `GET /api/cli/templates/[name]` — fetch a single template body
- `PUT /api/cli/templates/[name]` — create or update a template
**Core library**
- `NoteRenamer` — fetches inbound edges, creates new note, rewrites links, deletes old slug
- `NoteLinkHelper``AddLink()` inserts wikilinks into existing sections or appends new ones; `ApplyPlaceholders()` for template rendering
- `NozApiClient.GetGraphAsync()`, `SearchAsync()`, `GetTemplatesAsync()`, `GetTemplateAsync()`, `PutFolderIndexAsync()`
**Templates**
- Four sample templates in `content/noosphere/_templates/`: `rezept`, `buch-notiz`, `konzept`, `meeting`
### Fixed
- `walkNotes` now skips directories with `_` prefix (previously only filenames were checked — `_templates/` content was indexed as regular notes)
- Notes listing API now includes the `date` field
---
## [0.1.0] — 2026-05-16
### Added
- Interactive REPL with topic-based navigation (`cd`, `ls`, `ls -la`, `tree`)
- `cat` — render a note with MarkdownRenderer (headings, blockquotes, code blocks, inline formatting)
- `edit` — open note in terminal editor (nano/vim) or VS Code / Zed / Sublime (`-c`)
- `new` — create a note with frontmatter template, open in editor
- `rm` — delete a note from server
- `sync` — pull notes from server to local directory (parallel downloads, SHA-256 hash diffing, `--all`, `--dry-run`)
- `push` — push local `.md` files to server with slug validation
- `status` — diff between a local folder and the server
- `config` — view and set `editor` / `code_editor` preferences
- `version` — print version and git hash
- `help` — list all commands with usage
- `exit` / `quit` / `q` — quit the REPL
- JSON note cache (`~/.cache/noz/notes.json`) — instant startup, background refresh, `chmod 600`, atomic write
- Cache age shown in startup stats line ("vor 5 Min.")
- Ctrl+C: idle → exit REPL; during command → cancel command only
- `cat` — print raw markdown of a note
- `edit` — open note in terminal editor (nano/vim) or VS Code (`-c` flag)
- `new` — create a new note from a template
- `rm` — delete a note
- `sync` — pull notes from server to local directory (parallel, hash-diffed)
- `push` — push local markdown files to server
- `search` — full-text search via Meilisearch
- `config` — view and set editor preferences
- `version` — show version and git hash
- JSON note cache (`~/.cache/noz/notes.json`) for instant startup + background refresh
- Cache timestamp displayed in startup stats line
- Native AOT binary for macOS arm64 (~7.6 MB, no runtime required)
- Tab completion for commands, slugs, and folder segments
- `SlugGuard` — path traversal prevention for all slug inputs and file operations
- `MarkdownRenderer` — segment-based inline escaping (prevents Spectre markup injection from note content)
- PowerShell module `Noz` with `Get-NozNote`, `Get-NozNoteContent`, `Set-NozNote`, `New-NozNote`, `Remove-NozNote`
- xUnit test suite (52 tests): `SlugGuardTests`, `ReplContextTests`, `NotesCacheTests`, `NoteRenamerTests`, `NoteLinkHelperTests`
- Forgejo Actions CI pipeline (`.forgejo/workflows/ci.yml`): build + test on every push/PR
- Version string from `AssemblyInformationalVersion` with embedded git short hash
- Tab completion for commands, slugs and folder segments

View file

@ -1,43 +0,0 @@
using System.Management.Automation;
using NozCli.Core.Client;
namespace Noz.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Add, "NozLink", SupportsShouldProcess = true)]
public sealed class AddNozLinkCmdlet : NozCmdletBase
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
public string Source { get; set; } = "";
[Parameter(Mandatory = true, Position = 1)]
public string Target { get; set; } = "";
[Parameter]
public SwitchParameter Both { get; set; }
[Parameter]
public string Section { get; set; } = "Siehe auch";
protected override void ProcessRecord()
{
if (!ShouldProcess(Source, $"Add-NozLink → {Target}")) return;
LinkOne(Source, Target);
if (Both) LinkOne(Target, Source);
}
private void LinkOne(string source, string target)
{
var note = Run(Api.GetNoteAsync(source));
var updated = NoteLinkHelper.AddLink(note.Markdown, target, Section);
if (updated == note.Markdown)
{
WriteVerbose($"{source} → {target}: bereits verlinkt.");
return;
}
Run(Api.PutNoteAsync(source, updated));
WriteVerbose($"{source} → {target}: verlinkt.");
}
}

View file

@ -1,35 +0,0 @@
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

@ -1,38 +0,0 @@
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

@ -1,41 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
/// <summary>List notes from the noz server.</summary>
/// <example>
/// 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
/// </example>
[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));
}
}

View file

@ -1,32 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
/// <summary>Get the markdown content of a note.</summary>
/// <example>
/// Get-NozNoteContent -Slug psychologie/flow-theorie
/// "psychologie/flow-theorie" | Get-NozNoteContent
/// Get-NozNote -Topic psychologie | Get-NozNoteContent | Select-Object Slug, Markdown
/// </example>
[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;
}

View file

@ -1,31 +0,0 @@
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

@ -1,23 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Get, "NozTemplate")]
[OutputType(typeof(NozTemplate))]
public sealed class GetNozTemplateCmdlet : NozCmdletBase
{
[Parameter(Position = 0)]
public string? Name { get; set; }
protected override void ProcessRecord()
{
var templates = Run(Api.GetTemplatesAsync());
var filtered = Name is null
? templates
: templates.Where(t => t.Name.Contains(Name, StringComparison.OrdinalIgnoreCase)).ToList();
foreach (var t in filtered.OrderBy(t => t.Name))
WriteObject(new NozTemplate { Name = t.Name, Description = t.Description });
}
}

View file

@ -1,41 +0,0 @@
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

@ -1,59 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
/// <summary>Create a new note on the server.</summary>
/// <example>
/// New-NozNote -Slug psychologie/neue-notiz -Title "Neue Notiz"
/// New-NozNote -Slug biosysteme/pilze -Title "Pilze" -Topic biosysteme -Status budding -Lang de
/// </example>
[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));
}
}

View file

@ -1,24 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
/// <summary>Delete a note from the server.</summary>
/// <example>
/// 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
/// </example>
[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}");
}
}

View file

@ -1,50 +0,0 @@
using System.Management.Automation;
using NozCli.Core.Client;
namespace Noz.PowerShell.Cmdlets;
[Cmdlet(VerbsCommon.Rename, "NozNote", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)]
[OutputType(typeof(NozNote))]
public sealed class RenameNozNoteCmdlet : NozCmdletBase
{
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
public string Slug { get; set; } = "";
[Parameter(Mandatory = true, Position = 1)]
public string NewSlug { get; set; } = "";
[Parameter]
public SwitchParameter PassThru { get; set; }
protected override void ProcessRecord()
{
if (Slug == NewSlug)
{
WriteWarning("Quelle und Ziel sind identisch — nichts zu tun.");
return;
}
if (!ShouldProcess(Slug, $"Rename-NozNote → {NewSlug}")) return;
var steps = new List<string>();
var result = Run(NoteRenamer.ExecuteAsync(
Api, Slug, NewSlug,
onProgress: msg => WriteVerbose(msg)));
WriteVerbose($"Fertig: {result.LinksUpdated} Link(s) aktualisiert.");
if (result.LinksUpdated > 0)
{
foreach (var updated in result.UpdatedNotes)
WriteVerbose($" • {updated}");
}
if (PassThru)
{
var notes = Run(Api.GetNotesAsync());
var renamed = notes.Find(n => n.Slug == NewSlug);
if (renamed is not null) WriteObject(NozNote.From(renamed));
}
}
}

View file

@ -1,31 +0,0 @@
using System.Management.Automation;
namespace Noz.PowerShell.Cmdlets;
/// <summary>Update the markdown content of an existing note.</summary>
/// <example>
/// Set-NozNote -Slug psychologie/test -Markdown "# Test`n`nHello."
/// Get-Content note.md -Raw | Set-NozNote -Slug psychologie/test
/// </example>
[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));
}
}

View file

@ -1,49 +0,0 @@
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

@ -1,29 +0,0 @@
#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

View file

@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NozCli.Core\NozCli.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="PowerShellStandard.Library" Version="5.1.1" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Noz.PowerShell</AssemblyName>
<RootNamespace>Noz.PowerShell</RootNamespace>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
</Project>

View file

@ -1,34 +0,0 @@
@{
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',
'Get-NozGraph',
'Find-NozNote',
'Measure-NozGarden',
'Set-NozNoteStatus',
'Get-NozOrphans',
'Rename-NozNote',
'Get-NozTemplate',
'Add-NozLink'
)
FunctionsToExport = @()
AliasesToExport = @()
PrivateData = @{
PSData = @{
Tags = @('noz', 'digital-garden', 'notes', 'markdown')
ProjectUri = 'https://git.kryptomrx.de/krypto/noz-cli'
}
}
}

View file

@ -1,47 +0,0 @@
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 <server-url> <token>"),
"NozNotInitialized",
ErrorCategory.NotSpecified,
targetObject: null));
Config = cfg!;
Api = new NozApiClient(cfg!);
}
protected override void StopProcessing() => Api?.Dispose();
protected T Run<T>(Task<T> 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));
}

View file

@ -1,15 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,29 +0,0 @@
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,
};
}

View file

@ -1,13 +0,0 @@
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

@ -1,9 +0,0 @@
namespace Noz.PowerShell;
public sealed class NozTemplate
{
public string Name { get; init; } = "";
public string? Description { get; init; }
public override string ToString() => Name;
}

View file

@ -1,60 +0,0 @@
using System.Text.RegularExpressions;
namespace NozCli.Core.Client;
public static class NoteLinkHelper
{
/// Inserts a wikilink to <paramref name="targetSlug"/> into <paramref name="markdown"/>.
/// Appends to an existing section matching <paramref name="sectionHeader"/>,
/// or creates that section at the end of the note if absent.
public static string AddLink(string markdown, string targetSlug, string sectionHeader = "Siehe auch")
{
var link = $"- [[{targetSlug}]]";
// Find the section heading (## Siehe auch, ## See also, etc.)
var sectionMatch = Regex.Match(
markdown,
$@"(?m)^##\s+{Regex.Escape(sectionHeader)}\s*$",
RegexOptions.IgnoreCase);
if (sectionMatch.Success)
{
// Already has this link?
if (markdown.Contains($"[[{targetSlug}]]", StringComparison.Ordinal))
return markdown;
// Insert at the end of this section's content (before next ## heading or EOF)
var afterSection = markdown.IndexOf('\n', sectionMatch.Index + sectionMatch.Length);
if (afterSection < 0) afterSection = markdown.Length;
var nextHeading = Regex.Match(
markdown[afterSection..],
@"(?m)^##\s",
RegexOptions.IgnoreCase);
var insertAt = nextHeading.Success
? afterSection + nextHeading.Index
: markdown.Length;
var before = markdown[..insertAt].TrimEnd();
var after = markdown[insertAt..];
return before + "\n" + link + "\n" + (after.Length > 0 ? "\n" + after.TrimStart() : "");
}
// No section found — create one at the end
return markdown.TrimEnd() + $"\n\n## {sectionHeader}\n\n{link}\n";
}
/// Replaces template placeholders with actual values.
public static string ApplyPlaceholders(
string template, string title, string topic, string lang, string status)
{
return template
.Replace("{{title}}", title, StringComparison.Ordinal)
.Replace("{{topic}}", topic, StringComparison.Ordinal)
.Replace("{{lang}}", lang, StringComparison.Ordinal)
.Replace("{{status}}", status, StringComparison.Ordinal)
.Replace("{{date}}", DateTime.UtcNow.ToString("yyyy-MM-dd"), StringComparison.Ordinal);
}
}

View file

@ -1,125 +0,0 @@
using System.Text.RegularExpressions;
using NozCli.Core.Models;
namespace NozCli.Core.Client;
public static class NoteRenamer
{
public record Result(List<string> UpdatedNotes, int LinksUpdated);
/// Renames a note and rewrites all inbound wikilinks/markdown-links in one atomic sequence.
/// Progress callback receives human-readable step descriptions.
public static async Task<Result> ExecuteAsync(
NozApiClient api,
string oldSlug,
string newSlug,
Action<string>? onProgress = null,
CancellationToken ct = default)
{
// 1. Fetch inbound edges before we change anything
onProgress?.Invoke("Hole Graph-Daten…");
var graph = await api.GetGraphAsync(ct);
var inbound = graph.Edges
.Where(e => e.Target == oldSlug)
.Select(e => e.Source)
.Distinct()
.ToList();
// 2. Fetch the original note
onProgress?.Invoke($"Lese {oldSlug}…");
var original = await api.GetNoteAsync(oldSlug, ct);
// 3. Patch frontmatter topic if the slug prefix (= topic) changes
var patchedContent = PatchTopicIfNeeded(original.Markdown, oldSlug, newSlug);
// 4. Create note under new slug
onProgress?.Invoke($"Erstelle {newSlug}…");
await api.PutNoteAsync(newSlug, patchedContent, ct);
// 5. Update every note that links to the old slug
var updatedNotes = new List<string>();
foreach (var sourceSlug in inbound)
{
onProgress?.Invoke($"Aktualisiere Links in {sourceSlug}…");
try
{
var src = await api.GetNoteAsync(sourceSlug, ct);
var updated = ReplaceReferences(src.Markdown, oldSlug, newSlug);
if (updated != src.Markdown)
{
await api.PutNoteAsync(sourceSlug, updated, ct);
updatedNotes.Add(sourceSlug);
}
}
catch (HttpRequestException)
{
// Don't abort the whole rename if one note fails to update
onProgress?.Invoke($" ⚠ {sourceSlug} konnte nicht aktualisiert werden");
}
}
// 6. Delete old slug — do this last so we never lose data on partial failure
onProgress?.Invoke($"Lösche {oldSlug}…");
await api.DeleteNoteAsync(oldSlug, ct);
return new Result(updatedNotes, updatedNotes.Count);
}
// ── Slug reference replacement ────────────────────────────────────────────
private static string ReplaceReferences(string markdown, string oldSlug, string newSlug)
{
// [[old-slug]] → [[new-slug]]
// [[old-slug|Anzeigetext]] → [[new-slug|Anzeigetext]]
markdown = Regex.Replace(
markdown,
@"\[\[" + Regex.Escape(oldSlug) + @"(\|[^\]]+)?\]\]",
m => $"[[{newSlug}{m.Groups[1].Value}]]");
// [text](/noosphere/old-slug) → [text](/noosphere/new-slug)
// [text](/noosphere/old-slug#anchor) → [text](/noosphere/new-slug#anchor)
markdown = Regex.Replace(
markdown,
@"\[([^\]]+)\]\(/noosphere/" + Regex.Escape(oldSlug) + @"(#[^\)]*)?\)",
m => $"[{m.Groups[1].Value}](/noosphere/{newSlug}{m.Groups[2].Value})");
return markdown;
}
// ── Frontmatter topic patch ───────────────────────────────────────────────
private static string PatchTopicIfNeeded(string markdown, string oldSlug, string newSlug)
{
var oldTopic = FirstSegment(oldSlug);
var newTopic = FirstSegment(newSlug);
if (oldTopic == newTopic || oldTopic is null || newTopic is null)
return markdown;
// Only patch within the frontmatter block
var (fmStart, fmEnd) = FindFrontmatterBounds(markdown);
if (fmStart < 0) return markdown;
var block = markdown[fmStart..fmEnd];
var patched = Regex.Replace(
block,
@"(?m)^(topic:\s*)" + Regex.Escape(oldTopic) + @"(\s*)$",
$"$1{newTopic}$2");
return markdown[..fmStart] + patched + markdown[fmEnd..];
}
private static string? FirstSegment(string slug)
{
var idx = slug.IndexOf('/');
return idx > 0 ? slug[..idx] : null;
}
private static (int start, int end) FindFrontmatterBounds(string markdown)
{
if (!markdown.StartsWith("---", StringComparison.Ordinal)) return (-1, -1);
var closeIdx = markdown.IndexOf("\n---", 3, StringComparison.Ordinal);
if (closeIdx < 0) return (-1, -1);
return (0, closeIdx + 4); // include closing ---
}
}

View file

@ -78,69 +78,6 @@ 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

@ -1,5 +0,0 @@
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

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

View file

@ -1,4 +0,0 @@
namespace NozCli.Core.Models;
public record TemplateInfo(string Name, string? Description);
public record TemplateContent(string Name, string? Description, string Body);

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

View file

@ -1,34 +0,0 @@
using System.Text.RegularExpressions;
namespace NozCli.Core.Security;
/// Validates slugs before they are sent to the server or written to disk.
public static partial class SlugGuard
{
// Valid slug: lowercase alphanumeric + hyphens, optionally separated by /
// Examples: "hydroponik", "biosysteme/hydroponik", "a/b/c"
[GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*(?:/[a-z0-9][a-z0-9\-]*)*$")]
private static partial Regex ValidSlugRegex();
/// Returns true if the slug is safe to use (no path traversal, valid chars).
public static bool IsValid(string slug) =>
!string.IsNullOrWhiteSpace(slug) &&
!slug.Contains("..") &&
!slug.Contains("//") &&
ValidSlugRegex().IsMatch(slug);
/// Resolves a slug to a file path inside baseDir.
/// Returns null if the resulting path would escape baseDir (path traversal).
public static string? ToSafePath(string baseDir, string slug, string extension = ".md")
{
var rel = slug.Replace('/', Path.DirectorySeparatorChar) + extension;
var fullPath = Path.GetFullPath(Path.Combine(baseDir, rel));
var baseFull = Path.GetFullPath(baseDir);
// Ensure the resolved path stays strictly inside baseDir
return fullPath.StartsWith(baseFull + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase)
? fullPath
: null;
}
}

View file

@ -12,14 +12,6 @@ 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)]
@ -27,4 +19,3 @@ public partial class NozJsonContext : JsonSerializerContext { }
internal partial class NozJsonContextPretty : JsonSerializerContext { }
public record PushBody(string Markdown);
public record FolderBody(string Title, string? Description);

View file

@ -1,86 +0,0 @@
using Xunit;
using NozCli.Core.Client;
namespace NozCli.Tests;
public class NoteLinkHelperTests
{
// ── AddLink — neue Sektion ────────────────────────────────────────────────
[Fact]
public void AddLink_NoSection_AppendsSieheAuch()
{
var md = "# Notiz\n\nEtwas Text.";
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
Assert.Contains("## Siehe auch", result);
Assert.Contains("- [[psychologie/flow]]", result);
}
[Fact]
public void AddLink_NoSection_BodyUntouched()
{
var md = "# Notiz\n\nEtwas Text.";
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
Assert.StartsWith("# Notiz\n\nEtwas Text.", result);
}
// ── AddLink — existierende Sektion ────────────────────────────────────────
[Fact]
public void AddLink_ExistingSection_AppendsToSection()
{
var md = "# Notiz\n\nText.\n\n## Siehe auch\n\n- [[anderes/link]]\n";
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
Assert.Contains("- [[anderes/link]]", result);
Assert.Contains("- [[psychologie/flow]]", result);
}
[Fact]
public void AddLink_AlreadyLinked_NoChange()
{
var md = "# Notiz\n\n## Siehe auch\n\n- [[psychologie/flow]]\n";
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
Assert.Equal(md, result);
}
[Fact]
public void AddLink_ExistingSection_DoesNotBleedIntoNextSection()
{
var md = "# Notiz\n\n## Siehe auch\n\n- [[a]]\n\n## Weiteres\n\nAnderer Inhalt.\n";
var result = NoteLinkHelper.AddLink(md, "b/new-note");
Assert.Contains("- [[b/new-note]]", result);
// The new link should appear before "## Weiteres"
var linkIdx = result.IndexOf("- [[b/new-note]]", StringComparison.Ordinal);
var weitersIdx = result.IndexOf("## Weiteres", StringComparison.Ordinal);
Assert.True(linkIdx < weitersIdx);
}
// ── ApplyPlaceholders ─────────────────────────────────────────────────────
[Fact]
public void ApplyPlaceholders_ReplacesAllTokens()
{
var body = "# {{title}}\n\ntopic: {{topic}}\nlang: {{lang}}\nstatus: {{status}}";
var result = NoteLinkHelper.ApplyPlaceholders(body, "Mein Rezept", "kochen", "de", "seed");
Assert.Contains("# Mein Rezept", result);
Assert.Contains("topic: kochen", result);
Assert.Contains("lang: de", result);
Assert.Contains("status: seed", result);
Assert.DoesNotContain("{{", result);
}
[Fact]
public void ApplyPlaceholders_DateIsCurrentDay()
{
var body = "Datum: {{date}}";
var result = NoteLinkHelper.ApplyPlaceholders(body, "", "", "", "");
var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
Assert.Contains(today, result);
}
}

View file

@ -1,102 +0,0 @@
using Xunit;
using System.Reflection;
namespace NozCli.Tests;
// Tests the private ReplaceReferences + PatchTopicIfNeeded methods via reflection
// so we don't expose them publicly just for testing.
public class NoteRenamerTests
{
private static string ReplaceReferences(string md, string oldSlug, string newSlug)
{
var method = typeof(NozCli.Core.Client.NoteRenamer)
.GetMethod("ReplaceReferences", BindingFlags.NonPublic | BindingFlags.Static)!;
return (string)method.Invoke(null, [md, oldSlug, newSlug])!;
}
private static string PatchTopicIfNeeded(string md, string oldSlug, string newSlug)
{
var method = typeof(NozCli.Core.Client.NoteRenamer)
.GetMethod("PatchTopicIfNeeded", BindingFlags.NonPublic | BindingFlags.Static)!;
return (string)method.Invoke(null, [md, oldSlug, newSlug])!;
}
// ── Wikilink replacement ─────────────────────────────────────────────────
[Fact]
public void ReplaceReferences_BareWikilink_IsReplaced()
{
var md = "Siehe [[psychologie/flow]] für Details.";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal("Siehe [[psychologie/new-flow]] für Details.", result);
}
[Fact]
public void ReplaceReferences_WikilinkWithDisplay_PreservesDisplay()
{
var md = "Lies [[psychologie/flow|Flow-Zustand]] weiter.";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal("Lies [[psychologie/new-flow|Flow-Zustand]] weiter.", result);
}
[Fact]
public void ReplaceReferences_MarkdownLink_IsReplaced()
{
var md = "Siehe [Flow](/noosphere/psychologie/flow) hier.";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal("Siehe [Flow](/noosphere/psychologie/new-flow) hier.", result);
}
[Fact]
public void ReplaceReferences_MarkdownLinkWithAnchor_PreservesAnchor()
{
var md = "[Abschnitt](/noosphere/psychologie/flow#abschnitt-2)";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal("[Abschnitt](/noosphere/psychologie/new-flow#abschnitt-2)", result);
}
[Fact]
public void ReplaceReferences_UnrelatedLinks_AreUntouched()
{
var md = "[[other/note]] und [Ext](https://example.com/psychologie/flow)";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal(md, result);
}
[Fact]
public void ReplaceReferences_MultipleOccurrences_AllReplaced()
{
var md = "[[psychologie/flow]] und nochmal [[psychologie/flow|Flow]].";
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
Assert.DoesNotContain("psychologie/flow]]", result);
Assert.Equal(2, result.Split("psychologie/new-flow").Length - 1);
}
// ── Topic frontmatter patch ───────────────────────────────────────────────
[Fact]
public void PatchTopic_SameTopic_NoChange()
{
var md = "---\ntitle: Flow\ntopic: psychologie\n---\nInhalt";
var result = PatchTopicIfNeeded(md, "psychologie/flow", "psychologie/new-flow");
Assert.Equal(md, result);
}
[Fact]
public void PatchTopic_DifferentTopic_UpdatesFrontmatter()
{
var md = "---\ntitle: Flow\ntopic: psychologie\n---\nInhalt";
var result = PatchTopicIfNeeded(md, "psychologie/flow", "biosysteme/flow");
Assert.Contains("topic: biosysteme", result);
Assert.DoesNotContain("topic: psychologie", result);
Assert.Contains("Inhalt", result); // body untouched
}
[Fact]
public void PatchTopic_NoFrontmatter_NoChange()
{
var md = "# Kein Frontmatter\nNur Inhalt";
var result = PatchTopicIfNeeded(md, "psychologie/flow", "biosysteme/flow");
Assert.Equal(md, result);
}
}

View file

@ -1,23 +0,0 @@
using Xunit;
using NozCli.Repl;
namespace NozCli.Tests;
public class NotesCacheTests
{
[Theory]
[InlineData(10, "gerade eben")]
[InlineData(59, "gerade eben")]
[InlineData(60, "vor 1 Min.")]
[InlineData(300, "vor 5 Min.")]
[InlineData(3599, "vor 59 Min.")]
[InlineData(3600, "vor 1 Std.")]
[InlineData(7200, "vor 2 Std.")]
[InlineData(86400, "vor 1 Tagen")]
public void TimeAgo_ReturnsCorrectLabel(int secondsAgo, string expected)
{
var dt = DateTimeOffset.UtcNow.AddSeconds(-secondsAgo);
var result = NotesCache.TimeAgo(dt);
Assert.Equal(expected, result);
}
}

View file

@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NozCli.Core\NozCli.Core.csproj" />
<ProjectReference Include="..\NozCli\NozCli.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Disable AOT for the test project even though NozCli has PublishAot=true -->
<PublishAot>false</PublishAot>
</PropertyGroup>
</Project>

View file

@ -1,165 +0,0 @@
using Xunit;
using NozCli.Core.Config;
using NozCli.Core.Models;
using NozCli.Repl;
namespace NozCli.Tests;
public class ReplContextTests
{
// Builds a ReplContext with a fixed NoteCache — no API calls made.
private static ReplContext BuildCtx(params NoteInfo[] notes)
{
var cfg = new LocalConfig { ServerUrl = "http://localhost:9999", Token = "test" };
var ctx = new ReplContext(null!, cfg); // Api is null — not used in navigation
ctx.NoteCache = [.. notes];
return ctx;
}
private static NoteInfo Note(string slug, string topic, string status = "seed") =>
new(slug, $"Title {slug}", status, topic, "de", false, null);
// ── CurrentPath ──────────────────────────────────────────────────────────
[Fact]
public void CurrentPath_AtRoot_IsWave()
{
var ctx = BuildCtx();
Assert.Equal("~", ctx.CurrentPath);
}
[Fact]
public void CurrentPath_InTopic_ShowsTopic()
{
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
ctx.NavigateTo("psychologie");
Assert.Equal("~/psychologie", ctx.CurrentPath);
}
[Fact]
public void CurrentPath_InSubfolder_ShowsFullPath()
{
var ctx = BuildCtx(Note("psychologie/sub/deep", "psychologie"));
ctx.NavigateTo("psychologie");
ctx.NavigateTo("sub");
Assert.Equal("~/psychologie/sub", ctx.CurrentPath);
}
// ── FoldersInView ────────────────────────────────────────────────────────
[Fact]
public void FoldersInView_AtRoot_ReturnsDistinctTopics()
{
var ctx = BuildCtx(
Note("psychologie/a", "psychologie"),
Note("psychologie/b", "psychologie"),
Note("biosysteme/c", "biosysteme"));
var folders = ctx.FoldersInView().OrderBy(f => f).ToList();
Assert.Equal(["biosysteme", "psychologie"], folders);
}
[Fact]
public void FoldersInView_InTopic_ReturnsSubfolders()
{
var ctx = BuildCtx(
Note("psychologie/sub/note", "psychologie"),
Note("psychologie/flat-note", "psychologie"));
ctx.NavigateTo("psychologie");
var folders = ctx.FoldersInView().ToList();
Assert.Contains("sub", folders);
Assert.DoesNotContain("flat-note", folders);
}
// ── NavigateTo / NavigateUp ──────────────────────────────────────────────
[Fact]
public void NavigateTo_ValidTopic_ReturnsTrue()
{
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
Assert.True(ctx.NavigateTo("psychologie"));
Assert.Equal("psychologie", ctx.CurrentTopic);
}
[Fact]
public void NavigateTo_UnknownTopic_ReturnsFalse()
{
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
Assert.False(ctx.NavigateTo("doesnotexist"));
Assert.Null(ctx.CurrentTopic);
}
[Fact]
public void NavigateUp_FromTopic_ReturnsToRoot()
{
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
ctx.NavigateTo("psychologie");
ctx.NavigateUp();
Assert.Null(ctx.CurrentTopic);
Assert.Equal("~", ctx.CurrentPath);
}
[Fact]
public void NavigateUp_FromSubfolder_ReturnsToTopic()
{
var ctx = BuildCtx(Note("psychologie/sub/deep", "psychologie"));
ctx.NavigateTo("psychologie");
ctx.NavigateTo("sub");
ctx.NavigateUp();
Assert.Equal("psychologie", ctx.CurrentTopic);
Assert.Null(ctx.CurrentSubPath);
}
// ── SlugRelative ─────────────────────────────────────────────────────────
[Fact]
public void SlugRelative_InTopic_StripsTopicPrefix()
{
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
ctx.NavigateTo("psychologie");
Assert.Equal("flow", ctx.SlugRelative("psychologie/flow"));
}
[Fact]
public void SlugRelative_AtRoot_ReturnsFullSlug()
{
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
Assert.Equal("psychologie/flow", ctx.SlugRelative("psychologie/flow"));
}
// ── NotesInView ──────────────────────────────────────────────────────────
[Fact]
public void NotesInView_InTopic_FiltersCorrectly()
{
var ctx = BuildCtx(
Note("psychologie/a", "psychologie"),
Note("biosysteme/b", "biosysteme"));
ctx.NavigateTo("psychologie");
var notes = ctx.NotesInView().ToList();
Assert.Single(notes);
Assert.Equal("psychologie/a", notes[0].Slug);
}
// ── ResolveSlug ──────────────────────────────────────────────────────────
[Fact]
public void ResolveSlug_ShortName_InTopic_ResolvesToFullSlug()
{
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
ctx.NavigateTo("psychologie");
Assert.Equal("psychologie/flow", ctx.ResolveSlug("flow"));
}
[Fact]
public void ResolveSlug_AlreadyFullSlug_ReturnsUnchanged()
{
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
Assert.Equal("psychologie/flow", ctx.ResolveSlug("psychologie/flow"));
}
}

View file

@ -1,68 +0,0 @@
using Xunit;
using NozCli.Core.Security;
namespace NozCli.Tests;
public class SlugGuardTests
{
// ── IsValid ──────────────────────────────────────────────────────────────
[Theory]
[InlineData("simple-note", true)]
[InlineData("psychologie/test", true)]
[InlineData("biosysteme/sub/deep", true)]
[InlineData("note123", true)]
[InlineData("a", true)]
public void IsValid_AcceptsValidSlugs(string slug, bool expected)
=> Assert.Equal(expected, SlugGuard.IsValid(slug));
[Theory]
[InlineData("../../etc/passwd", false)] // path traversal
[InlineData("../escape", false)] // path traversal
[InlineData("note/../other", false)] // embedded traversal
[InlineData("UPPERCASE", false)] // uppercase not allowed
[InlineData("has space", false)] // spaces not allowed
[InlineData("", false)] // empty
[InlineData(" ", false)] // whitespace
[InlineData("note//double", false)] // double slash
[InlineData("/leading-slash", false)] // leading slash
[InlineData("trailing/", false)] // trailing slash
[InlineData("Sonderzeichen!", false)] // special chars
public void IsValid_RejectsInvalidSlugs(string slug, bool expected)
=> Assert.Equal(expected, SlugGuard.IsValid(slug));
// ── ToSafePath ───────────────────────────────────────────────────────────
[Fact]
public void ToSafePath_ValidSlug_ReturnsPathInsideBase()
{
var baseDir = Path.Combine(Path.GetTempPath(), "noz-test");
var result = SlugGuard.ToSafePath(baseDir, "psychologie/test");
Assert.NotNull(result);
Assert.StartsWith(baseDir, result);
Assert.EndsWith(".md", result);
Assert.Contains("psychologie", result);
}
[Theory]
[InlineData("../../etc/passwd")]
[InlineData("../escape")]
[InlineData("sub/../../outside")]
public void ToSafePath_TraversalSlug_ReturnsNull(string slug)
{
var baseDir = Path.Combine(Path.GetTempPath(), "noz-test");
Assert.Null(SlugGuard.ToSafePath(baseDir, slug));
}
[Fact]
public void ToSafePath_NestedSlug_BuildsCorrectPath()
{
var baseDir = "/tmp/vault";
var result = SlugGuard.ToSafePath(baseDir, "a/b/c");
Assert.NotNull(result);
var expected = Path.Combine("/tmp/vault", "a", "b", "c") + ".md";
Assert.Equal(expected, result);
}
}

View file

@ -1,4 +1,3 @@
using NozCli.Rendering;
using NozCli.Repl;
using Spectre.Console;
@ -7,15 +6,13 @@ namespace NozCli.Commands;
public class CatCommand : ICommand
{
public string Name => "cat";
public string Description => "Print a note — rendered by default, --raw for plain markdown";
public string Usage => "cat [--raw] <slug>";
public string Description => "Print the raw markdown of a note";
public string Usage => "cat <slug>";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
var raw = args.Contains("--raw") || args.Contains("-r");
var slug = args.FirstOrDefault(a => !a.StartsWith('-'));
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat [--raw] <slug>[/]"); return; }
var slug = args.FirstOrDefault();
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat <slug>[/]"); return; }
slug = ctx.ResolveSlug(slug);
@ -23,9 +20,6 @@ public class CatCommand : ICommand
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
note = await ctx.Api.GetNoteAsync(slug, ct));
if (raw)
AnsiConsole.WriteLine(note.Markdown);
else
MarkdownRenderer.Render(note.Markdown);
AnsiConsole.WriteLine(note.Markdown);
}
}

View file

@ -28,30 +28,22 @@ public class EditCommand : ICommand
var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md");
await File.WriteAllTextAsync(tmp, note.Markdown);
string edited;
try
var (editor, editorArgs) = useCode
? ResolveCode(tmp, ctx.Config.CodeEditor)
: ResolveTerminal(tmp, ctx.Config.Editor);
AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]");
var proc = Process.Start(new ProcessStartInfo
{
var (editor, editorArgs) = useCode
? ResolveCode(tmp, ctx.Config.CodeEditor)
: ResolveTerminal(tmp, ctx.Config.Editor);
AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]");
var proc = Process.Start(new ProcessStartInfo
{
FileName = editor,
Arguments = editorArgs,
UseShellExecute = false,
});
proc?.WaitForExit();
edited = await File.ReadAllTextAsync(tmp);
}
finally
{
if (File.Exists(tmp)) File.Delete(tmp);
}
FileName = editor,
Arguments = editorArgs,
UseShellExecute = false,
});
proc?.WaitForExit();
var edited = await File.ReadAllTextAsync(tmp);
var newHash = Sha256(edited);
File.Delete(tmp);
if (newHash == note.Hash)
{

View file

@ -1,67 +0,0 @@
using NozCli.Core.Client;
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class LinkCommand : ICommand
{
public string Name => "link";
public string Description => "Add a wikilink from one note to another (builds the graph)";
public string Usage => "link <source> <target> [--both] [--section \"Siehe auch\"]";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
var both = args.Contains("--both");
var section = ParseFlag(args, "--section") ?? "Siehe auch";
var positional = args.Where(a => !a.StartsWith('-')).ToArray();
if (positional.Length < 2)
{
AnsiConsole.MarkupLine("[red]Usage:[/] link <source> <target> [--both] [--section \"Heading\"]");
return;
}
var source = ctx.ResolveSlug(positional[0]);
var target = ctx.ResolveSlug(positional[1]);
if (source == target)
{
AnsiConsole.MarkupLine("[yellow]Quelle und Ziel sind identisch.[/]");
return;
}
await LinkOneWay(ctx.Api, source, target, section, ct);
if (both)
await LinkOneWay(ctx.Api, target, source, section, ct);
}
private static async Task LinkOneWay(
NozCli.Core.Client.NozApiClient api,
string source, string target, string section, CancellationToken ct)
{
NozCli.Core.Models.NoteContent note = default!;
await AnsiConsole.Status().StartAsync($"Lese {source}…", async _ =>
note = await api.GetNoteAsync(source, ct));
var updated = NoteLinkHelper.AddLink(note.Markdown, target, section);
if (updated == note.Markdown)
{
AnsiConsole.MarkupLine($"[dim]{source}[/] → [dim]{target}[/] [yellow]bereits verlinkt[/]");
return;
}
await AnsiConsole.Status().StartAsync($"Aktualisiere {source}…", async _ =>
await api.PutNoteAsync(source, updated, ct));
AnsiConsole.MarkupLine($"[green]✓[/] [cyan]{source}[/] → [cyan]{target}[/] verlinkt");
}
private static string? ParseFlag(string[] args, string flag)
{
var idx = Array.IndexOf(args, flag);
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
}
}

View file

@ -1,80 +0,0 @@
using NozCli.Core.Security;
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class MkdirCommand : ICommand
{
public string Name => "mkdir";
public string Description => "Create a new folder (writes _index.md)";
public string Usage => "mkdir <folder-name>";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
var folderArg = args.FirstOrDefault();
if (folderArg is null)
{
AnsiConsole.MarkupLine("[red]Usage:[/] mkdir <folder-name>");
return;
}
// Resolve full folder path relative to current navigation
var folderPath = ResolveFolderPath(folderArg.TrimEnd('/'), ctx);
if (!SlugGuard.IsValid(folderPath))
{
AnsiConsole.MarkupLine("[red]Ungültiger Ordnername.[/] Nur Kleinbuchstaben, Ziffern und Bindestriche erlaubt.");
return;
}
// Derive a readable title from the last segment
var lastSegment = folderPath.Split('/')[^1];
var title = ToTitle(lastSegment);
await AnsiConsole.Status().StartAsync($"Erstelle [cyan]{folderPath}[/]…", async _ =>
await ctx.Api.PutFolderIndexAsync(folderPath, title, ct: ct));
// Navigate into the new folder
NavigateInto(folderPath, ctx);
AnsiConsole.MarkupLine($"[green]✓[/] Ordner erstellt → {ctx.CurrentPath}");
AnsiConsole.MarkupLine($"[dim]Erstelle deine erste Notiz mit [white]new <name>[/][/]");
}
private static string ResolveFolderPath(string segment, ReplContext ctx)
{
// Already a full multi-segment path (user typed full slug)
if (segment.Contains('/')) return segment;
if (ctx.CurrentTopic is null)
return segment;
if (ctx.CurrentSubPath is null)
return $"{ctx.CurrentTopic}/{segment}";
return $"{ctx.CurrentTopic}/{ctx.CurrentSubPath}/{segment}";
}
private static void NavigateInto(string folderPath, ReplContext ctx)
{
var parts = folderPath.Split('/');
if (parts.Length == 1)
{
ctx.CurrentTopic = parts[0];
ctx.CurrentSubPath = null;
}
else
{
ctx.CurrentTopic = parts[0];
ctx.CurrentSubPath = string.Join('/', parts[1..]);
}
}
// "new-subfolder" → "New Subfolder"
private static string ToTitle(string slug) =>
string.Join(' ', slug.Split('-').Select(Capitalize));
private static string Capitalize(string s) =>
s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..];
}

View file

@ -1,88 +0,0 @@
using NozCli.Core.Client;
using NozCli.Core.Security;
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class MvCommand : ICommand
{
public string Name => "mv";
public string Description => "Rename a note and update all inbound links";
public string Usage => "mv <old-name> <new-name>";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
if (args.Length < 2)
{
AnsiConsole.MarkupLine("[red]Usage:[/] mv <old-name> <new-name>");
return;
}
// Resolve short names to full slugs
var oldSlug = ctx.ResolveSlug(args[0]);
var newSlug = ctx.ResolveNewSlug(args[1]);
if (!SlugGuard.IsValid(oldSlug) || !SlugGuard.IsValid(newSlug))
{
AnsiConsole.MarkupLine("[red]Ungültiger Slug.[/] Nur Kleinbuchstaben, Ziffern und Bindestriche erlaubt.");
return;
}
if (oldSlug == newSlug)
{
AnsiConsole.MarkupLine("[yellow]Quelle und Ziel sind identisch — nichts zu tun.[/]");
return;
}
if (!ctx.NoteCache.Any(n => n.Slug == oldSlug))
{
AnsiConsole.MarkupLine($"[red]Notiz nicht gefunden:[/] {oldSlug}");
return;
}
if (ctx.NoteCache.Any(n => n.Slug == newSlug))
{
AnsiConsole.MarkupLine($"[red]Ziel existiert bereits:[/] {newSlug}");
return;
}
AnsiConsole.MarkupLine($"[dim]Umbenennen:[/] [cyan]{oldSlug}[/] → [green]{newSlug}[/]");
NoteRenamer.Result result = default!;
await AnsiConsole.Progress()
.AutoClear(false)
.HideCompleted(false)
.StartAsync(async prog =>
{
var task = prog.AddTask("Umbenennen…", maxValue: 100);
result = await NoteRenamer.ExecuteAsync(
ctx.Api, oldSlug, newSlug,
onProgress: msg =>
{
task.Description = msg;
task.Increment(20);
},
ct: ct);
task.Value = 100;
task.Description = "Fertig";
});
// Update local cache: swap old entry for a patched new one
var oldEntry = ctx.NoteCache.FirstOrDefault(n => n.Slug == oldSlug);
if (oldEntry is not null)
{
ctx.NoteCache.Remove(oldEntry);
ctx.NoteCache.Add(oldEntry with { Slug = newSlug });
}
var linkInfo = result.LinksUpdated > 0
? $" [dim]· {result.LinksUpdated} Link(s) aktualisiert[/]"
: "";
AnsiConsole.MarkupLine($"[green]✓[/] {oldSlug} → {newSlug}{linkInfo}");
}
}

View file

@ -1,5 +1,6 @@
using System.Diagnostics;
using NozCli.Core.Client;
using System.Security.Cryptography;
using System.Text;
using NozCli.Core.Models;
using NozCli.Repl;
using Spectre.Console;
@ -10,20 +11,19 @@ public class NewCommand : ICommand
{
public string Name => "new";
public string Description => "Create a new note and open it in nano (or -c for VS Code)";
public string Usage => "new [-c] [--template <name>] <slug-or-name>";
public string Usage => "new [-c] <slug-or-name>";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
var useCode = args.Contains("-c") || args.Contains("--code");
var templateName = ParseFlag(args, "--template") ?? ParseFlag(args, "-t");
var nameArg = args.FirstOrDefault(a => !a.StartsWith('-'));
if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] [--template <name>] <slug-or-name>[/]"); return; }
var useCode = args.Contains("-c") || args.Contains("--code");
var nameArg = args.FirstOrDefault(a => !a.StartsWith('-'));
if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] <slug-or-name>[/]"); return; }
var slug = ctx.ResolveNewSlug(nameArg);
if (ctx.NoteCache.Any(n => n.Slug == slug))
{
AnsiConsole.MarkupLine($"[yellow]Notiz existiert bereits:[/] {slug} [dim](use 'edit' to modify it)[/]");
AnsiConsole.MarkupLine($"[yellow]Note already exists:[/] {slug} [dim](use 'edit' to modify it)[/]");
return;
}
@ -31,27 +31,6 @@ public class NewCommand : ICommand
var title = ToTitle(slug.Split('/').Last());
var lang = "de";
string noteBody;
if (templateName is not null)
{
TemplateContent? tmpl = null;
await AnsiConsole.Status().StartAsync($"Lade Template '{templateName}'…", async _ =>
tmpl = await ctx.Api.GetTemplateAsync(templateName, ct));
if (tmpl is null)
{
AnsiConsole.MarkupLine($"[red]Template '{templateName}' nicht gefunden.[/] Verfügbare Templates mit [white]templates[/] anzeigen.");
return;
}
noteBody = NoteLinkHelper.ApplyPlaceholders(tmpl.Body, title, topic, lang, "seed");
}
else
{
noteBody = $"# {title}\n\n";
}
var template = $"""
---
title: "{title}"
@ -61,34 +40,28 @@ public class NewCommand : ICommand
lang: {lang}
---
{noteBody.TrimStart()}
# {title}
""";
var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md");
await File.WriteAllTextAsync(tmp, template);
string markdown;
try
{
var (editor, editorArgs) = useCode
? EditCommand.ResolveCode(tmp, ctx.Config.CodeEditor)
: EditCommand.ResolveTerminal(tmp, ctx.Config.Editor);
var (editor, editorArgs) = useCode
? EditCommand.ResolveCode(tmp, ctx.Config.CodeEditor)
: EditCommand.ResolveTerminal(tmp, ctx.Config.Editor);
AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]");
var proc = Process.Start(new ProcessStartInfo
{
FileName = editor,
Arguments = editorArgs,
UseShellExecute = false,
});
proc?.WaitForExit();
markdown = await File.ReadAllTextAsync(tmp);
}
finally
AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]");
var proc = Process.Start(new ProcessStartInfo
{
if (File.Exists(tmp)) File.Delete(tmp);
}
FileName = editor,
Arguments = editorArgs,
UseShellExecute = false,
});
proc?.WaitForExit();
var markdown = await File.ReadAllTextAsync(tmp);
File.Delete(tmp);
if (markdown.Trim() == template.Trim())
{
@ -112,9 +85,6 @@ public class NewCommand : ICommand
string.Join(' ', slug.Split('-').Select(w =>
w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : w));
private static string? ParseFlag(string[] args, string flag)
{
var idx = Array.IndexOf(args, flag);
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
}
private static string Sha256(string text) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower();
}

View file

@ -1,5 +1,4 @@
using NozCli.Repl;
using NozCli.Core.Security;
using Spectre.Console;
namespace NozCli.Commands;
@ -55,13 +54,6 @@ public class PushCommand : ICommand
var relative = Path.GetRelativePath(localPath, file);
var slug = relative.Replace(Path.DirectorySeparatorChar, '/').Replace(".md", "");
if (!SlugGuard.IsValid(slug))
{
AnsiConsole.MarkupLine($"[yellow]⚠ Skipped invalid slug:[/] {slug}");
task.Increment(1);
continue;
}
var markdown = await File.ReadAllTextAsync(file);
var localHash = NozCli.Core.Client.HashHelper.Sha256(markdown);

View file

@ -1,61 +0,0 @@
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class SearchCommand : ICommand
{
public string Name => "search";
public string Description => "Full-text search via Meilisearch";
public string Usage => "search <query> [--topic <topic>] [--limit <n>]";
private static readonly Dictionary<string, string> StatusDot = new()
{
["seed"] = "[yellow]◦[/]",
["budding"] = "[magenta]◈[/]",
["evergreen"] = "[cyan]◉[/]",
};
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
var topic = ParseFlag(args, "--topic") ?? ctx.CurrentTopic;
var limitRaw = ParseFlag(args, "--limit");
var limit = limitRaw is not null && int.TryParse(limitRaw, out var l) ? l : 10;
var query = string.Join(' ', args.Where(a => !a.StartsWith('-') &&
a != ParseFlag(args, "--topic") &&
a != ParseFlag(args, "--limit")));
if (string.IsNullOrWhiteSpace(query))
{
AnsiConsole.MarkupLine("[red]Usage:[/] search <query> [--topic <topic>] [--limit <n>]");
return;
}
List<NozCli.Core.Models.SearchHit> hits = [];
await AnsiConsole.Status().StartAsync($"Suche nach »{query}«…", async _ =>
hits = await ctx.Api.SearchAsync(query, topic, limit, ct));
if (hits.Count == 0)
{
AnsiConsole.MarkupLine($"[dim]Keine Treffer für »{Markup.Escape(query)}«.[/]");
return;
}
AnsiConsole.MarkupLine($"[dim]{hits.Count} Treffer[/]\n");
foreach (var h in hits)
{
var dot = StatusDot.GetValueOrDefault(h.Status, "·");
var desc = h.Description is { Length: > 0 } d ? $"\n [dim]{Markup.Escape(d)}[/]" : "";
AnsiConsole.MarkupLine($" {dot} [cyan]{Markup.Escape(h.Slug)}[/] [white]{Markup.Escape(h.Title)}[/]{desc}");
}
AnsiConsole.WriteLine();
}
private static string? ParseFlag(string[] args, string flag)
{
var idx = Array.IndexOf(args, flag);
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
}
}

View file

@ -1,7 +1,6 @@
using NozCli.Core.Client;
using NozCli.Core.Models;
using NozCli.Repl;
using NozCli.Core.Security;
using Spectre.Console;
namespace NozCli.Commands;
@ -52,13 +51,7 @@ public class SyncCommand : ICommand
foreach (var note in noteList)
{
if (!SlugGuard.IsValid(note.Slug))
{
AnsiConsole.MarkupLine($"[yellow]⚠ Skipped unsafe slug from server:[/] {note.Slug}");
continue;
}
var filePath = SlugGuard.ToSafePath(localPath, note.Slug);
var filePath = SlugToPath(localPath, note.Slug);
if (!force && note.Hash is not null && File.Exists(filePath))
{
@ -111,7 +104,7 @@ public class SyncCommand : ICommand
try
{
var content = await ctx.Api.GetNoteAsync(note.Slug, innerCt);
var filePath = SlugGuard.ToSafePath(localPath, note.Slug)!;
var filePath = SlugToPath(localPath, note.Slug);
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
await File.WriteAllTextAsync(filePath, content.Markdown, innerCt);
@ -143,4 +136,6 @@ public class SyncCommand : ICommand
}
}
private static string SlugToPath(string baseDir, string slug) =>
Path.Combine(baseDir, slug.Replace('/', Path.DirectorySeparatorChar) + ".md");
}

View file

@ -1,39 +0,0 @@
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
public class TemplatesCommand : ICommand
{
public string Name => "templates";
public string Description => "List available note templates";
public string Usage => "templates";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
List<NozCli.Core.Models.TemplateInfo> templates = [];
await AnsiConsole.Status().StartAsync("Lade Templates…", async _ =>
templates = await ctx.Api.GetTemplatesAsync(ct));
if (templates.Count == 0)
{
AnsiConsole.MarkupLine("[dim]Keine Templates vorhanden.[/]");
AnsiConsole.MarkupLine("[dim]Lege Templates in [white]content/noosphere/_templates/<name>.md[/] an.[/]");
return;
}
var table = new Table()
.AddColumn("[dim]Name[/]")
.AddColumn("[dim]Beschreibung[/]")
.Border(TableBorder.None)
.HideHeaders();
foreach (var t in templates.OrderBy(t => t.Name))
table.AddRow(
$"[cyan]{Markup.Escape(t.Name)}[/]",
Markup.Escape(t.Description ?? "—"));
AnsiConsole.Write(table);
AnsiConsole.MarkupLine($"[dim]Verwenden mit:[/] [white]new --template <name> <notiz-name>[/]");
}
}

View file

@ -1,195 +0,0 @@
using System.Text;
using System.Text.RegularExpressions;
using Spectre.Console;
namespace NozCli.Rendering;
/// Terminal markdown renderer — converts common markdown to Spectre.Console output.
/// Handles: headings, bold/italic, inline code, fenced code blocks,
/// bullet/numbered lists, blockquotes, horizontal rules.
public static partial class MarkdownRenderer
{
public static void Render(string markdown)
{
var lines = markdown.ReplaceLineEndings("\n").Split('\n');
var inCode = false;
var codeLang = "";
var codeBlock = new StringBuilder();
AnsiConsole.WriteLine();
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
// ── Fenced code block ────────────────────────────────────────────
if (line.TrimStart().StartsWith("```"))
{
if (!inCode)
{
inCode = true;
codeLang = line.TrimStart()[3..].Trim();
codeBlock.Clear();
}
else
{
inCode = false;
RenderCodeBlock(codeBlock.ToString().TrimEnd(), codeLang);
}
continue;
}
if (inCode)
{
codeBlock.AppendLine(line);
continue;
}
// ── Horizontal rule ──────────────────────────────────────────────
if (HrRegex().IsMatch(line))
{
AnsiConsole.Write(new Rule() { Style = new Style(new Color(60, 60, 80)) });
continue;
}
// ── Headings ─────────────────────────────────────────────────────
if (line.StartsWith("### "))
{
AnsiConsole.MarkupLine($"\n [bold white]{Markup.Escape(line[4..])}[/]");
continue;
}
if (line.StartsWith("## "))
{
AnsiConsole.MarkupLine($"\n[bold cyan]{Markup.Escape(line[3..])}[/]");
continue;
}
if (line.StartsWith("# "))
{
AnsiConsole.MarkupLine($"\n[bold underline cyan]{Markup.Escape(line[2..])}[/]\n");
continue;
}
// ── Blockquote ───────────────────────────────────────────────────
if (line.StartsWith("> "))
{
var content = ApplyInline(line[2..]);
AnsiConsole.MarkupLine($"[grey46]┃[/] [dim italic]{content}[/]");
continue;
}
// ── Bullet list ──────────────────────────────────────────────────
var bulletMatch = BulletRegex().Match(line);
if (bulletMatch.Success)
{
var indent = bulletMatch.Groups[1].Value.Length / 2;
var content = ApplyInline(bulletMatch.Groups[2].Value);
var pad = new string(' ', indent * 2);
AnsiConsole.MarkupLine($"{pad} [dim cyan]•[/] {content}");
continue;
}
// ── Numbered list ────────────────────────────────────────────────
var numMatch = NumberedRegex().Match(line);
if (numMatch.Success)
{
var num = numMatch.Groups[1].Value;
var content = ApplyInline(numMatch.Groups[2].Value);
AnsiConsole.MarkupLine($" [dim cyan]{num}.[/] {content}");
continue;
}
// ── Empty line ───────────────────────────────────────────────────
if (string.IsNullOrWhiteSpace(line))
{
AnsiConsole.WriteLine();
continue;
}
// ── Paragraph ────────────────────────────────────────────────────
AnsiConsole.MarkupLine($" {ApplyInline(line)}");
}
AnsiConsole.WriteLine();
}
// ── Code block rendering ─────────────────────────────────────────────────
private static void RenderCodeBlock(string code, string lang)
{
var header = lang.Length > 0 ? $"[dim]{Markup.Escape(lang)}[/]" : "[dim]code[/]";
var panel = new Panel(new Text(code, new Style(new Color(200, 200, 220))))
{
Header = new PanelHeader($" {header} "),
Border = BoxBorder.Rounded,
BorderStyle = new Style(new Color(60, 60, 80)),
Padding = new Padding(1, 0),
};
AnsiConsole.WriteLine();
AnsiConsole.Write(panel);
}
// ── Inline formatting ────────────────────────────────────────────────────
private static string ApplyInline(string raw)
{
// Collect all pattern matches, escape text between them.
// This ensures brackets/special chars in regular text don't break Spectre markup.
(Regex regex, Func<Match, string> format)[] patterns =
[
(InlineCodeRegex(), m => $"[bold cyan on grey7] {Markup.Escape(m.Groups[1].Value)} [/]"),
(BoldRegex(), m => $"[bold]{Markup.Escape(m.Groups[1].Value)}[/]"),
(ItalicRegex(), m => $"[italic]{Markup.Escape(m.Groups[1].Value)}[/]"),
];
var spans = new List<(int start, int end, string replacement)>();
foreach (var (regex, format) in patterns)
{
foreach (Match m in regex.Matches(raw))
{
// Skip if overlapping with an already-claimed span
if (spans.Exists(s => m.Index < s.end && m.Index + m.Length > s.start))
continue;
spans.Add((m.Index, m.Index + m.Length, format(m)));
}
}
spans.Sort((a, b) => a.start.CompareTo(b.start));
var sb = new StringBuilder();
var pos = 0;
foreach (var (start, end, replacement) in spans)
{
if (pos < start)
sb.Append(Markup.Escape(raw[pos..start]));
sb.Append(replacement);
pos = end;
}
if (pos < raw.Length)
sb.Append(Markup.Escape(raw[pos..]));
return sb.ToString();
}
// ── Compiled regexes (AOT-safe via [GeneratedRegex]) ────────────────────
[GeneratedRegex(@"^[-*_]{3,}\s*$")]
private static partial Regex HrRegex();
[GeneratedRegex(@"^( *)[-*+] (.+)$")]
private static partial Regex BulletRegex();
[GeneratedRegex(@"^(\d+)\. (.+)$")]
private static partial Regex NumberedRegex();
[GeneratedRegex(@"`([^`]+)`")]
private static partial Regex InlineCodeRegex();
[GeneratedRegex(@"\*\*([^*]+)\*\*")]
private static partial Regex BoldRegex();
[GeneratedRegex(@"(?<!\*)\*(?!\*)([^*]+)\*(?!\*)")]
private static partial Regex ItalicRegex();
}

View file

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

View file

@ -35,9 +35,6 @@ public static class NotesCache
var tmp = CachePath + ".tmp";
await File.WriteAllTextAsync(tmp, json);
File.Move(tmp, CachePath, overwrite: true);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(CachePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch { /* cache write failures are non-fatal */ }
}

View file

@ -70,7 +70,6 @@ public class ReplContext
var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length;
return NotesInView()
.Where(n => SlugRelative(n.Slug).Split('/').Length > depth + 1)
.Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth))
.Where(s => s is not null)
.Distinct(StringComparer.OrdinalIgnoreCase)!;

239
README.md
View file

@ -1,214 +1,79 @@
# noz-cli
A terminal REPL and PowerShell module for [noz](https://git.kryptomrx.de/krypto/noz) — a self-hosted digital garden built with Next.js.
A terminal REPL for [noz](https://github.com/kryptomrx/noz) — a self-hosted digital garden built with Next.js.
Navigate, create, and manage your notes from the command line. Ships as a Native AOT binary (~7.6 MB, no .NET runtime required) and a PowerShell module for scripting and automation.
Navigate, read, and edit your notes from the command line. Native AOT binary, no .NET runtime required.
---
## Install
### macOS / Linux — binary
Download from [Releases](../../releases) and place on your PATH:
Download the latest binary from [Releases](../../releases) and place it on your PATH:
```bash
# macOS Apple Silicon
curl -L https://git.kryptomrx.de/krypto/noz-cli/releases/latest/download/noz-osx-arm64 \
-o /usr/local/bin/noz && chmod +x /usr/local/bin/noz
# macOS (Apple Silicon)
curl -L https://github.com/kryptomrx/noz-cli/releases/latest/download/noz-osx-arm64 -o /usr/local/bin/noz
chmod +x /usr/local/bin/noz
```
### PowerShell module
Requires PowerShell 7.2+ and .NET 8.
```powershell
git clone https://git.kryptomrx.de/krypto/noz-cli
cd noz-cli
./Noz.PowerShell/Install-NozModule.ps1
```
---
## Setup
```bash
noz init https://your-noz-instance.com your-api-token
```
Writes `~/.config/noz/config.json`. The token is the CLI token configured in your `noosphere.toml` under `[cli]`.
This writes `~/.config/noz/config.json`.
---
## Usage
## REPL
```bash
```
noz
```
Starts the interactive REPL. A local note cache (`~/.cache/noz/notes.json`) is used for instant startup; a background refresh runs silently.
Starts the interactive REPL. Type `help` for a list of commands.
### Navigation
| Command | Description |
|---------|-------------|
| `ls` | List topics (at root) or notes in the current folder |
| `ls -la` | Detailed list with status, date, language |
| `cd <topic>` | Enter a topic or sub-folder |
| `cd ..` | Go up one level |
| `tree` | Full hierarchy as a tree |
```
~ » ls # list topics
~ » cd psychologie # enter a topic
~/psychologie » ls -la # detailed view with status, date, title
~/psychologie » tree # full hierarchy as a tree
~/psychologie » cd .. # go up
```
### Notes
| Command | Description |
|---------|-------------|
| `cat <slug>` | Print a note (rendered markdown) |
| `cat --raw <slug>` | Print raw markdown |
| `new <name>` | Create a note and open in editor |
| `new -c <name>` | Create a note and open in VS Code |
| `new --template <name> <slug>` | Create from a template |
| `edit <slug>` | Edit a note in terminal editor |
| `edit -c <slug>` | Edit in VS Code |
| `mv <old> <new>` | Rename a note — rewrites all inbound wikilinks |
| `rm <slug>` | Delete a note |
| `search <query>` | Full-text search (Meilisearch) |
| `search <query> --topic <topic>` | Search within a topic |
### Folders
| Command | Description |
|---------|-------------|
| `mkdir <name>` | Create a folder (writes `_index.md`, navigates in) |
### Linking
| Command | Description |
|---------|-------------|
| `link <source> <target>` | Add a wikilink from source to target |
| `link <source> <target> --both` | Bidirectional link |
### Templates
| Command | Description |
|---------|-------------|
| `templates` | List available templates |
| `new --template <name> <slug>` | Create a note from a template |
Templates live in `content/noosphere/_templates/<name>.md` on the server. Placeholders: `{{title}}`, `{{topic}}`, `{{date}}`, `{{status}}`, `{{lang}}`.
```
~ » cat psychologie/flow-theorie # print raw markdown
~ » edit flow-theorie # open in terminal editor
~ » edit -c flow-theorie # open in VS Code
~ » new meine-neue-notiz # create note from template
~ » rm alte-notiz # delete a note
```
### Sync
| Command | Description |
|---------|-------------|
| `sync <local-path>` | Pull changed notes from server to local folder |
| `sync <local-path> --all` | Pull everything (ignore local hashes) |
| `sync <local-path> --dry-run` | Preview what would be downloaded |
| `push <local-path>` | Push local `.md` files to server |
| `status <local-path>` | Show diff between local folder and server |
```
~ » sync ~/vault # pull changed notes to local folder
~ » sync ~/vault --all # pull everything (ignore local hashes)
~ » sync ~/vault --dry-run # preview what would be downloaded
~ » push ~/vault # push local .md files to server
```
### Other
| Command | Description |
|---------|-------------|
| `version` | Show version and git hash |
| `config` | Show editor settings |
| `config editor <name>` | Set terminal editor |
| `config code-editor <name>` | Set GUI editor |
| `clear` | Clear the screen |
| `help` | List all commands |
| `exit` | Quit |
**Ctrl+C** while idle exits the REPL. During a command it cancels only that command.
---
## PowerShell Module
After installing, import with:
```powershell
Import-Module Noz
```
### Note commands
```powershell
# List and filter notes
Get-NozNote
Get-NozNote -Topic psychologie
Get-NozNote -Status evergreen
Get-NozNote -Lang de
# Read content
Get-NozNoteContent -Slug psychologie/flow-theorie
# Create / update / delete
New-NozNote -Slug kochen/pasta -Title "Pasta Carbonara" -Topic kochen
Set-NozNote -Slug psychologie/flow-theorie -Markdown $content
Remove-NozNote -Slug alte-notiz
# Rename (rewrites all inbound links)
Rename-NozNote -Slug psychologie/old -NewSlug psychologie/new
# Change status
Set-NozNoteStatus -Slug psychologie/flow-theorie -Status evergreen
~ » version # show version + git hash
~ » config # show editor settings
~ » config editor nano # set terminal editor
~ » config code-editor code # set GUI editor
~ » exit
```
### Search
```powershell
Find-NozNote "flow"
Find-NozNote "hydroponik" -Topic biosysteme -Limit 5
```
### Graph & analysis
```powershell
# All wikilink edges
Get-NozGraph
Get-NozGraph -Source psychologie/flow
# Notes with no incoming links
Get-NozOrphans
Get-NozOrphans -Topic psychologie
# Garden-wide statistics
Measure-NozGarden
# Link two notes
Add-NozLink -Source psychologie/flow -Target biosysteme/brs-konzept
Add-NozLink -Source a -Target b -Both
```
### Templates
```powershell
Get-NozTemplate
Get-NozTemplate rezept
```
### Pipeline examples
```powershell
# Promote all seed notes in a topic to budding
Get-NozNote -Topic psychologie -Status seed |
Set-NozNoteStatus -Status budding -WhatIf
# Export all evergreen notes as markdown files
Get-NozNote -Status evergreen |
ForEach-Object { Get-NozNoteContent $_.Slug } |
ForEach-Object { Set-Content "$($_.Slug).md" $_.Markdown }
# Find orphans and link them to a topic overview
Get-NozOrphans -Topic biosysteme |
Add-NozLink -Target biosysteme/uebersicht
```
---
## Configuration
`~/.config/noz/config.json`:
Editor preferences are stored in `~/.config/noz/config.json`:
```json
{
@ -219,41 +84,19 @@ Get-NozOrphans -Topic biosysteme |
}
```
Editor is auto-detected from `$EDITOR` if not set. VS Code, Zed, and Sublime Text are detected automatically for `-c` mode.
---
## Server-side setup
The CLI needs the `[cli]` block in your `noosphere.toml`:
```toml
[cli]
token = "your-secret-token"
allow_push = true
allow_delete = true
allow_vault = false
allow_rebuild = false
```
---
## Building from source
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download) and `clang`.
```bash
git clone https://git.kryptomrx.de/krypto/noz-cli
git clone https://github.com/kryptomrx/noz-cli
cd noz-cli
# Run without AOT
# Run (no AOT)
dotnet run --project NozCli
# Native AOT binary — macOS Apple Silicon
# Native AOT binary (macOS arm64)
dotnet publish NozCli/NozCli.csproj -r osx-arm64 -c Release
# Run tests
dotnet test NozCli.Tests/NozCli.Tests.csproj -c Release
```
On macOS, AOT linking requires OpenSSL via Homebrew:
@ -262,9 +105,7 @@ On macOS, AOT linking requires OpenSSL via Homebrew:
brew install openssl
```
---
## License
[Polyform Noncommercial 1.0.0](LICENSE) — free for personal and non-commercial use.
Commercial licensing: [bruno.deanoz@protonmail.com](mailto:bruno.deanoz@protonmail.com).
For commercial licensing, contact [Bruno Deanoz](mailto:bruno.deanoz1@gmail.com).

View file

@ -1,6 +1,4 @@
<Solution>
<Project Path="NozCli.Core/NozCli.Core.csproj" />
<Project Path="NozCli/NozCli.csproj" />
<Project Path="Noz.PowerShell/Noz.PowerShell.csproj" />
<Project Path="NozCli.Tests/NozCli.Tests.csproj" />
</Solution>