181 lines
8.1 KiB
C#
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();
|
|
}
|
|
}
|