noz-cli/NozCli.Core/Client/NoteRenamer.cs
kryptomrx 5a26de8a11 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.
2026-05-17 00:27:53 +02:00

125 lines
4.8 KiB
C#

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 ---
}
}