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}"); + } +}