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