feat: link and templates commands with note linking helper

NoteLinkHelper inserts wikilinks into an existing 'Siehe auch' section
or appends one. ApplyPlaceholders fills {{title}}/{{date}}/{{topic}} in
template bodies. new --template <name> fetches a server-side template
before opening the editor. SlugGuard moved to NozCli.Core in a previous
session — remove stale copy from NozCli.
This commit is contained in:
kryptomrx 2026-05-17 00:28:22 +02:00
parent e9217ecb77
commit 58410f8d9f
11 changed files with 376 additions and 46 deletions

View file

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

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

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

View file

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

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

View file

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

View file

@ -0,0 +1,67 @@
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,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using NozCli.Core.Client;
using NozCli.Core.Models; using NozCli.Core.Models;
using NozCli.Repl; using NozCli.Repl;
using Spectre.Console; using Spectre.Console;
@ -9,19 +10,20 @@ public class NewCommand : ICommand
{ {
public string Name => "new"; public string Name => "new";
public string Description => "Create a new note and open it in nano (or -c for VS Code)"; public string Description => "Create a new note and open it in nano (or -c for VS Code)";
public string Usage => "new [-c] <slug-or-name>"; public string Usage => "new [-c] [--template <name>] <slug-or-name>";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{ {
var useCode = args.Contains("-c") || args.Contains("--code"); var useCode = args.Contains("-c") || args.Contains("--code");
var nameArg = args.FirstOrDefault(a => !a.StartsWith('-')); var templateName = ParseFlag(args, "--template") ?? ParseFlag(args, "-t");
if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] <slug-or-name>[/]"); return; } var nameArg = args.FirstOrDefault(a => !a.StartsWith('-'));
if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] [--template <name>] <slug-or-name>[/]"); return; }
var slug = ctx.ResolveNewSlug(nameArg); var slug = ctx.ResolveNewSlug(nameArg);
if (ctx.NoteCache.Any(n => n.Slug == slug)) if (ctx.NoteCache.Any(n => n.Slug == slug))
{ {
AnsiConsole.MarkupLine($"[yellow]Note already exists:[/] {slug} [dim](use 'edit' to modify it)[/]"); AnsiConsole.MarkupLine($"[yellow]Notiz existiert bereits:[/] {slug} [dim](use 'edit' to modify it)[/]");
return; return;
} }
@ -29,6 +31,27 @@ public class NewCommand : ICommand
var title = ToTitle(slug.Split('/').Last()); var title = ToTitle(slug.Split('/').Last());
var lang = "de"; 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 = $""" var template = $"""
--- ---
title: "{title}" title: "{title}"
@ -38,8 +61,7 @@ public class NewCommand : ICommand
lang: {lang} lang: {lang}
--- ---
# {title} {noteBody.TrimStart()}
"""; """;
var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md"); var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md");
@ -90,4 +112,9 @@ public class NewCommand : ICommand
string.Join(' ', slug.Split('-').Select(w => string.Join(' ', slug.Split('-').Select(w =>
w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : 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;
}
} }

View file

@ -0,0 +1,39 @@
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,34 +0,0 @@
using System.Text.RegularExpressions;
namespace NozCli.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;
}
}