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.
125 lines
4.8 KiB
C#
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 ---
|
|
}
|
|
}
|