using System.Text.Json; using System.Text.RegularExpressions; using HtmlAgilityPack; namespace wp2ghost { // ============================================================================= // InternalLinkManager — merkt sich, welcher WordPress-Beitrag welchen Ghost-Slug // bekommen hat, und schreibt Links zwischen den eigenen Beiträgen auf Ghost um. // ============================================================================= static class InternalLinkManager { // WordPress-Slug (dekodiert, klein) → Ghost-Slug private static readonly Dictionary SlugMap = new(); // WordPress-ID → Ghost-Slug (für Links der Form ?p=123) private static readonly Dictionary IdMap = new(); private static string MapPath = ""; public static int Count => SlugMap.Count; public static void Init(string baseDir) { MapPath = Path.Combine(baseDir, "internal_links.txt"); SlugMap.Clear(); IdMap.Clear(); if (File.Exists(MapPath)) { foreach (var line in File.ReadAllLines(MapPath)) { var parts = line.Split('|'); if (parts.Length == 3 && int.TryParse(parts[0], out int wpId)) Add(wpId, parts[1], parts[2]); } } // Bereits abgerufene Beiträge übernehmen (auch aus Läufen vor Einführung dieser Datei) string postsDir = Path.Combine(baseDir, "ghostPosts"); if (!Directory.Exists(postsDir)) return; bool added = false; foreach (var file in Directory.GetFiles(postsDir, "*.json")) { try { using var doc = JsonDocument.Parse(File.ReadAllText(file)); var root = doc.RootElement; int wpId = root.GetProperty("wp_id").GetInt32(); string wpSlug = root.GetProperty("wp_slug").GetString() ?? ""; if (wpSlug.Length == 0 || IdMap.ContainsKey(wpId)) continue; Add(wpId, wpSlug, SlugHelper.Clean(wpSlug, $"post-{wpId}")); added = true; } catch { } } if (added) Save(); } /// Merkt sich die Zuordnung eines abgerufenen Beitrags. public static void Register(int wpId, string? wpSlug, string ghostSlug) { if (string.IsNullOrEmpty(wpSlug)) return; Add(wpId, wpSlug, ghostSlug); Save(); } private static void Add(int wpId, string wpSlug, string ghostSlug) { SlugMap[NormalizeSlug(wpSlug)] = ghostSlug; IdMap[wpId] = ghostSlug; } private static void Save() { var bySlug = SlugMap.ToDictionary(kv => kv.Value, kv => kv.Key); File.WriteAllLines(MapPath, IdMap.OrderBy(kv => kv.Key) .Select(kv => $"{kv.Key}|{(bySlug.TryGetValue(kv.Value, out var wpSlug) ? wpSlug : kv.Value)}|{kv.Value}")); } private static string NormalizeSlug(string slug) { try { slug = Uri.UnescapeDataString(slug); } catch { } return slug.Trim().ToLowerInvariant(); } /// /// Schreibt Links auf eigene WordPress-Beiträge, -Seiten, Kategorien, Schlagwörter und die /// Startseite auf die entsprechende Ghost-Adresse um. Links auf noch nicht abgerufene Beiträge /// bleiben stehen und werden als offen zurückgegeben — beim nächsten Lauf, wenn das Ziel /// bekannt ist, werden sie automatisch ersetzt. /// /// Basis der neuen Adressen: Ghost-URL oder "__GHOST_URL__" (ZIP-Export) public static (string Html, int Replaced, List Unresolved) Rewrite(string html, string wpBaseUrl, string ghostBase) { var unresolved = new List(); if (string.IsNullOrWhiteSpace(html) || !Uri.TryCreate(wpBaseUrl, UriKind.Absolute, out var wpUri)) return (html, 0, unresolved); string wpHost = StripWww(wpUri.Host); ghostBase = ghostBase.TrimEnd('/'); var doc = new HtmlDocument(); doc.LoadHtml(html); var anchors = doc.DocumentNode.SelectNodes("//a[@href]"); if (anchors == null) return (html, 0, unresolved); int replaced = 0; foreach (var anchor in anchors.ToList()) { string href = HtmlEntity.DeEntitize(anchor.GetAttributeValue("href", "")).Trim(); if (!Uri.TryCreate(href, UriKind.Absolute, out var uri) || StripWww(uri.Host) != wpHost) continue; // Dateien (Bilder, PDFs ...) und technische Adressen sind keine Beitragslinks string path = uri.AbsolutePath; if (path.StartsWith("/wp-content/", StringComparison.OrdinalIgnoreCase) || path.StartsWith("/wp-json/", StringComparison.OrdinalIgnoreCase) || path.StartsWith("/wp-admin", StringComparison.OrdinalIgnoreCase)) continue; string? target = Resolve(uri); if (target != null) { anchor.SetAttributeValue("href", ghostBase + target + uri.Fragment); // Interne Links sollen nicht in neuem Tab öffnen anchor.Attributes.Remove("target"); anchor.Attributes.Remove("rel"); replaced++; } else if (IsOnlyImage(anchor)) { // Bild, das auf seine WordPress-Anhangseite verlinkt: die Seite gibt es in Ghost nicht foreach (var child in anchor.ChildNodes.ToList()) anchor.ParentNode!.InsertBefore(child, anchor); anchor.Remove(); replaced++; } else if (!unresolved.Contains(href)) { unresolved.Add(href); } } return (replaced > 0 ? doc.DocumentNode.OuterHtml : html, replaced, unresolved); } // Liefert den Ghost-Pfad ("/slug/") oder null, wenn das Ziel (noch) unbekannt ist private static string? Resolve(Uri uri) { var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); // ?p=123 var idMatch = Regex.Match(uri.Query, @"[?&](?:p|page_id)=(\d+)"); if (idMatch.Success) return IdMap.TryGetValue(int.Parse(idMatch.Groups[1].Value), out string? byId) ? $"/{byId}/" : null; if (segments.Length == 0) return "/"; // Startseite string last = NormalizeSlug(segments[^1]); // Kategorien und Schlagwörter sind in Ghost beides Tags if (segments[0].Equals("category", StringComparison.OrdinalIgnoreCase) || segments[0].Equals("tag", StringComparison.OrdinalIgnoreCase)) { if (segments.Length < 2) return null; // Ignorierte Kategorien ("Allgemein") gibt es in Ghost nicht als Tag → Startseite if (CategoryRules.IsIgnored(last)) return "/"; string tagSlug = SlugHelper.Clean(last, ""); return tagSlug.Length > 0 ? $"/tag/{tagSlug}/" : null; } // /slug/ oder Datums-Permalink /2023/03/05/slug/ — davor dürfen nur Zahlen stehen if (segments.Take(segments.Length - 1).All(s => s.All(char.IsDigit)) && SlugMap.TryGetValue(last, out string? ghostSlug)) return $"/{ghostSlug}/"; return null; } private static bool IsOnlyImage(HtmlNode anchor) => anchor.SelectSingleNode(".//img") != null && string.IsNullOrWhiteSpace(HtmlEntity.DeEntitize(anchor.InnerText)); private static string StripWww(string host) => host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host.Substring(4).ToLowerInvariant() : host.ToLowerInvariant(); } }