Wp2ghost/CSharpGhostImporter/InternalLinkManager.cs
2026-10-14 19:12:29 +02:00

181 lines
8.1 KiB
C#

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<string, string> SlugMap = new();
// WordPress-ID → Ghost-Slug (für Links der Form ?p=123)
private static readonly Dictionary<int, string> 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();
}
/// <summary>Merkt sich die Zuordnung eines abgerufenen Beitrags.</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
/// <param name="ghostBase">Basis der neuen Adressen: Ghost-URL oder "__GHOST_URL__" (ZIP-Export)</param>
public static (string Html, int Replaced, List<string> Unresolved) Rewrite(string html, string wpBaseUrl, string ghostBase)
{
var unresolved = new List<string>();
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();
}
}