From 5a26de8a1147618b0c31b6e18dde0def8b9c61fc Mon Sep 17 00:00:00 2001 From: kryptomrx Date: Sun, 17 May 2026 00:27:53 +0200 Subject: [PATCH] feat: mv command with atomic note renaming and link rewriting NoteRenamer fetches inbound graph edges, creates the note under the new slug (patching the topic frontmatter field if the prefix changes), rewrites all wikilinks and markdown links in linking notes, then deletes the old slug. Tests cover wikilink/markdown-link replacement and topic patching. --- Noz.PowerShell/Cmdlets/RenameNozNoteCmdlet.cs | 50 +++++++ NozCli.Core/Client/NoteRenamer.cs | 125 ++++++++++++++++++ NozCli.Tests/NoteRenamerTests.cs | 102 ++++++++++++++ NozCli/Commands/MvCommand.cs | 88 ++++++++++++ 4 files changed, 365 insertions(+) create mode 100644 Noz.PowerShell/Cmdlets/RenameNozNoteCmdlet.cs create mode 100644 NozCli.Core/Client/NoteRenamer.cs create mode 100644 NozCli.Tests/NoteRenamerTests.cs create mode 100644 NozCli/Commands/MvCommand.cs diff --git a/Noz.PowerShell/Cmdlets/RenameNozNoteCmdlet.cs b/Noz.PowerShell/Cmdlets/RenameNozNoteCmdlet.cs new file mode 100644 index 0000000..cfd0e37 --- /dev/null +++ b/Noz.PowerShell/Cmdlets/RenameNozNoteCmdlet.cs @@ -0,0 +1,50 @@ +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(); + + 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)); + } + } +} diff --git a/NozCli.Core/Client/NoteRenamer.cs b/NozCli.Core/Client/NoteRenamer.cs new file mode 100644 index 0000000..f5b5679 --- /dev/null +++ b/NozCli.Core/Client/NoteRenamer.cs @@ -0,0 +1,125 @@ +using System.Text.RegularExpressions; +using NozCli.Core.Models; + +namespace NozCli.Core.Client; + +public static class NoteRenamer +{ + public record Result(List 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 ExecuteAsync( + NozApiClient api, + string oldSlug, + string newSlug, + Action? 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(); + 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 --- + } +} diff --git a/NozCli.Tests/NoteRenamerTests.cs b/NozCli.Tests/NoteRenamerTests.cs new file mode 100644 index 0000000..f03dea7 --- /dev/null +++ b/NozCli.Tests/NoteRenamerTests.cs @@ -0,0 +1,102 @@ +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); + } +} diff --git a/NozCli/Commands/MvCommand.cs b/NozCli/Commands/MvCommand.cs new file mode 100644 index 0000000..54e56b1 --- /dev/null +++ b/NozCli/Commands/MvCommand.cs @@ -0,0 +1,88 @@ +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 "; + + public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) + { + if (args.Length < 2) + { + AnsiConsole.MarkupLine("[red]Usage:[/] mv "); + 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}"); + } +}