diff --git a/Noz.PowerShell/Cmdlets/AddNozLinkCmdlet.cs b/Noz.PowerShell/Cmdlets/AddNozLinkCmdlet.cs new file mode 100644 index 0000000..d7a3c05 --- /dev/null +++ b/Noz.PowerShell/Cmdlets/AddNozLinkCmdlet.cs @@ -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."); + } +} diff --git a/Noz.PowerShell/Cmdlets/GetNozTemplateCmdlet.cs b/Noz.PowerShell/Cmdlets/GetNozTemplateCmdlet.cs new file mode 100644 index 0000000..cc25213 --- /dev/null +++ b/Noz.PowerShell/Cmdlets/GetNozTemplateCmdlet.cs @@ -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 }); + } +} diff --git a/Noz.PowerShell/NozTemplate.cs b/Noz.PowerShell/NozTemplate.cs new file mode 100644 index 0000000..f3c0b69 --- /dev/null +++ b/Noz.PowerShell/NozTemplate.cs @@ -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; +} diff --git a/NozCli.Core/Client/NoteLinkHelper.cs b/NozCli.Core/Client/NoteLinkHelper.cs new file mode 100644 index 0000000..afbe4ea --- /dev/null +++ b/NozCli.Core/Client/NoteLinkHelper.cs @@ -0,0 +1,60 @@ +using System.Text.RegularExpressions; + +namespace NozCli.Core.Client; + +public static class NoteLinkHelper +{ + /// Inserts a wikilink to into . + /// Appends to an existing section matching , + /// 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); + } +} diff --git a/NozCli.Core/Models/TemplateInfo.cs b/NozCli.Core/Models/TemplateInfo.cs new file mode 100644 index 0000000..e0b07be --- /dev/null +++ b/NozCli.Core/Models/TemplateInfo.cs @@ -0,0 +1,4 @@ +namespace NozCli.Core.Models; + +public record TemplateInfo(string Name, string? Description); +public record TemplateContent(string Name, string? Description, string Body); diff --git a/NozCli.Tests/NoteLinkHelperTests.cs b/NozCli.Tests/NoteLinkHelperTests.cs new file mode 100644 index 0000000..c13083d --- /dev/null +++ b/NozCli.Tests/NoteLinkHelperTests.cs @@ -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); + } +} diff --git a/NozCli/Commands/CatCommand.cs b/NozCli/Commands/CatCommand.cs index 898b018..46d6dc0 100644 --- a/NozCli/Commands/CatCommand.cs +++ b/NozCli/Commands/CatCommand.cs @@ -1,3 +1,4 @@ +using NozCli.Rendering; using NozCli.Repl; using Spectre.Console; @@ -6,13 +7,15 @@ namespace NozCli.Commands; public class CatCommand : ICommand { public string Name => "cat"; - public string Description => "Print the raw markdown of a note"; - public string Usage => "cat "; + public string Description => "Print a note — rendered by default, --raw for plain markdown"; + public string Usage => "cat [--raw] "; public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) { - var slug = args.FirstOrDefault(); - if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat [/]"); return; } + 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] [/]"); return; } slug = ctx.ResolveSlug(slug); @@ -20,6 +23,9 @@ public class CatCommand : ICommand await AnsiConsole.Status().StartAsync("Fetching…", async _ => note = await ctx.Api.GetNoteAsync(slug, ct)); - AnsiConsole.WriteLine(note.Markdown); + if (raw) + AnsiConsole.WriteLine(note.Markdown); + else + MarkdownRenderer.Render(note.Markdown); } } diff --git a/NozCli/Commands/LinkCommand.cs b/NozCli/Commands/LinkCommand.cs new file mode 100644 index 0000000..a69bf30 --- /dev/null +++ b/NozCli/Commands/LinkCommand.cs @@ -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 [--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 [--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; + } +} diff --git a/NozCli/Commands/NewCommand.cs b/NozCli/Commands/NewCommand.cs index be84167..b1623ea 100644 --- a/NozCli/Commands/NewCommand.cs +++ b/NozCli/Commands/NewCommand.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using NozCli.Core.Client; using NozCli.Core.Models; using NozCli.Repl; using Spectre.Console; @@ -9,19 +10,20 @@ 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] "; + public string Usage => "new [-c] [--template ] "; public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) { - 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] [/]"); return; } + 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 ] [/]"); return; } var slug = ctx.ResolveNewSlug(nameArg); 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; } @@ -29,6 +31,27 @@ 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}" @@ -38,8 +61,7 @@ public class NewCommand : ICommand lang: {lang} --- - # {title} - + {noteBody.TrimStart()} """; 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 => 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; + } } diff --git a/NozCli/Commands/TemplatesCommand.cs b/NozCli/Commands/TemplatesCommand.cs new file mode 100644 index 0000000..641f5b0 --- /dev/null +++ b/NozCli/Commands/TemplatesCommand.cs @@ -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 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/.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 [/]"); + } +} diff --git a/NozCli/Security/SlugGuard.cs b/NozCli/Security/SlugGuard.cs deleted file mode 100644 index 668f183..0000000 --- a/NozCli/Security/SlugGuard.cs +++ /dev/null @@ -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; - } -}