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.
This commit is contained in:
parent
f45b31defa
commit
5a26de8a11
4 changed files with 365 additions and 0 deletions
50
Noz.PowerShell/Cmdlets/RenameNozNoteCmdlet.cs
Normal file
50
Noz.PowerShell/Cmdlets/RenameNozNoteCmdlet.cs
Normal file
|
|
@ -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<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
NozCli.Core/Client/NoteRenamer.cs
Normal file
125
NozCli.Core/Client/NoteRenamer.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
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 ---
|
||||||
|
}
|
||||||
|
}
|
||||||
102
NozCli.Tests/NoteRenamerTests.cs
Normal file
102
NozCli.Tests/NoteRenamerTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
NozCli/Commands/MvCommand.cs
Normal file
88
NozCli/Commands/MvCommand.cs
Normal file
|
|
@ -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 <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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue