- bevor mergen der UIs

This commit is contained in:
Roland 2026-10-14 19:12:29 +02:00
parent 24d88e224b
commit 20c49bb1c5
98 changed files with 9879 additions and 594 deletions

13
.gitignore vendored
View file

@ -62,3 +62,16 @@
/images/size/w1000/2026/09/20222424_25.jpg
/images/size/w2000/2026/09/20222424_25.jpg
/images/size/w600/2026/09/20222424_25.jpg
/Wp2GhostUI/bin/*
/Wp2GhostUI/obj/*
/Wp2GhostUI/.vs/ProjectEvaluation/wp2ghostui.metadata.v10.bin
/Wp2GhostUI/.vs/ProjectEvaluation/wp2ghostui.projects.v10.bin
/Wp2GhostUI/.vs/ProjectEvaluation/wp2ghostui.strings.v10.bin
/Wp2GhostUI/.vs/Wp2GhostUI.slnx/config/applicationhost.config
/Wp2GhostUI/.vs/Wp2GhostUI.slnx/FileContentIndex/40e405e5-3c47-4edc-97d5-e8275cf9e7f1.vsidx
/Wp2GhostUI/.vs/Wp2GhostUI.slnx/FileContentIndex/d82e15b7-7808-418b-9912-cb7d1ef677b8.vsidx
/Wp2GhostUI/.vs/Wp2GhostUI.slnx/v18/.futdcache.v2
/Wp2GhostUI/.vs/Wp2GhostUI.slnx/v18/.suo
/Wp2GhostUI/.vs/Wp2GhostUI.slnx/v18/DocumentLayout.json
/GhostImageManager/logs/ghost-image-manager-20261011.log

View file

@ -0,0 +1,140 @@
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using HtmlAgilityPack;
namespace wp2ghost
{
// =============================================================================
// CommentBlockBuilder — WordPress-Kommentare als lesbaren Block ans Beitragsende hängen.
// Ghost bietet keinen Weg, Kommentare zu importieren; so bleiben Name, Datum und Text
// erhalten, ohne dass Mitglieder angelegt oder E-Mail-Adressen übertragen werden.
// =============================================================================
static class CommentBlockBuilder
{
// Kennzeichnet den Block im HTML (und ist die CSS-Klasse für das Theme)
private const string BlockClass = "wp-comments";
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-AT");
private static readonly HashSet<string> AllowedTags = new() { "a", "p", "br", "strong", "b", "em", "i", "blockquote", "code" };
// Pingbacks ("[…] Auszug […]") und Fediverse-Reaktionen ("… gefiel dies!") sind keine Kommentare
private static readonly Regex PingbackRegex = new(@"^\[(…|\.\.\.)\]", RegexOptions.Compiled);
private static readonly Regex ReactionRegex = new(@"^(…|\.\.\.)\s*(gefiel dies|hat dies repostet)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static bool IsRealComment(WpComment comment)
{
// Neuere Plugin-Version liefert den Typ mit
if (!string.IsNullOrEmpty(comment.Type))
return comment.Type == "comment";
string text = WebUtility.HtmlDecode(comment.Content ?? "").Trim();
return text.Length > 0 && !PingbackRegex.IsMatch(text) && !ReactionRegex.IsMatch(text);
}
public static int CountRealComments(IEnumerable<WpComment>? comments) => (comments ?? Enumerable.Empty<WpComment>()).Count(IsRealComment);
/// <summary>Hängt den Kommentar-Block an (einmalig — ein vorhandener Block wird nicht verdoppelt).</summary>
public static string Append(string html, IEnumerable<WpComment>? comments)
{
if (html.Contains($"class=\"{BlockClass}\"")) return html;
string block = Build(comments);
return block.Length > 0 ? html + "\n" + block : html;
}
public static string Build(IEnumerable<WpComment>? comments)
{
var real = (comments ?? Enumerable.Empty<WpComment>())
.Where(IsRealComment)
.OrderBy(c => ParseDate(c.Date) ?? DateTime.MaxValue)
.ThenBy(c => c.Id)
.ToList();
if (real.Count == 0) return "";
// Antworten unter ihren Kommentar hängen; fehlt der Bezug, steht der Kommentar auf oberster Ebene
var ids = real.Select(c => c.Id).ToHashSet();
var repliesByParent = real.Where(c => c.Parent > 0 && c.Parent != c.Id && ids.Contains(c.Parent)).ToLookup(c => c.Parent);
var topLevel = real.Where(c => !(c.Parent > 0 && c.Parent != c.Id && ids.Contains(c.Parent)));
var sb = new StringBuilder();
// HTML-Karte: Ghost übernimmt den Inhalt unverändert
sb.Append("<!--kg-card-begin: html-->\n");
sb.Append($"<section class=\"{BlockClass}\">\n");
sb.Append($"<h2 class=\"wp-comments__title\">Kommentare aus dem alten Blog ({real.Count})</h2>\n");
AppendList(sb, topLevel, repliesByParent, isReplies: false, depth: 0);
sb.Append("</section>\n");
sb.Append("<!--kg-card-end: html-->");
return sb.ToString();
}
private static void AppendList(StringBuilder sb, IEnumerable<WpComment> comments, ILookup<int, WpComment> repliesByParent, bool isReplies, int depth)
{
sb.Append(isReplies ? "<ol class=\"wp-comments__list wp-comments__replies\">\n" : "<ol class=\"wp-comments__list\">\n");
foreach (var comment in comments)
{
string name = WebUtility.HtmlEncode(WebUtility.HtmlDecode(comment.AuthorName ?? "").Trim());
if (name.Length == 0) name = "Anonym";
var date = ParseDate(comment.Date);
sb.Append($"<li class=\"wp-comment\" id=\"wp-comment-{comment.Id}\">\n");
sb.Append($"<div class=\"wp-comment__meta\"><span class=\"wp-comment__author\">{name}</span>");
if (date.HasValue)
sb.Append($" <time class=\"wp-comment__date\" datetime=\"{date.Value:yyyy-MM-dd}\">{date.Value.ToString("d. MMMM yyyy", German)}</time>");
sb.Append("</div>\n");
sb.Append($"<div class=\"wp-comment__text\">{SanitizeContent(comment.Content ?? "")}</div>\n");
// Tiefe begrenzen, falls die Bezüge in WordPress einen Kreis bilden
if (depth < 10 && repliesByParent[comment.Id].Any())
AppendList(sb, repliesByParent[comment.Id], repliesByParent, isReplies: true, depth + 1);
sb.Append("</li>\n");
}
sb.Append("</ol>\n");
}
private static DateTime? ParseDate(string? date) =>
DateTime.TryParse(date, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) ? parsed : null;
/// <summary>
/// Kommentartext ist fremde Eingabe: nur einfache Formatierung bleibt erhalten,
/// alles andere (Skripte, Attribute, unbekannte Tags) wird entfernt.
/// </summary>
private static string SanitizeContent(string content)
{
var doc = new HtmlDocument();
doc.LoadHtml(content);
foreach (var node in doc.DocumentNode.Descendants().Where(n => n.NodeType == HtmlNodeType.Comment || n.Name is "script" or "style").ToList())
node.Remove();
foreach (var element in doc.DocumentNode.Descendants().Where(n => n.NodeType == HtmlNodeType.Element).ToList())
{
if (!AllowedTags.Contains(element.Name))
{
// Tag entfernen, Inhalt behalten
foreach (var child in element.ChildNodes.ToList())
element.ParentNode!.InsertBefore(child, element);
element.Remove();
continue;
}
string href = element.Name == "a" ? HtmlEntity.DeEntitize(element.GetAttributeValue("href", "")).Trim() : "";
element.Attributes.RemoveAll();
if (element.Name != "a") continue;
if (href.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || href.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
element.SetAttributeValue("href", href);
element.SetAttributeValue("rel", "nofollow noopener");
}
}
string html = doc.DocumentNode.InnerHtml.Replace("\r\n", "\n").Trim();
if (html.Contains("<p", StringComparison.OrdinalIgnoreCase)) return html;
// Wie WordPress: Leerzeile = neuer Absatz, Zeilenumbruch = <br>
var paragraphs = Regex.Split(html, @"\n\s*\n").Select(p => p.Trim()).Where(p => p.Length > 0);
return string.Concat(paragraphs.Select(p => $"<p>{p.Replace("\n", "<br>")}</p>"));
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -86,13 +86,15 @@ namespace wp2ghost
return result?.Posts?.FirstOrDefault();
}
public static async Task<GhostAdminPost?> CreatePostAsync(string title, string html, string status = "published", string? slug = null)
public static async Task<GhostAdminPost?> CreatePostAsync(string title, string html, string status = "published", string? slug = null, string? publishedAt = null)
{
var post = new GhostAdminPostEdit
{
Title = title,
Html = html,
Status = status
Status = status,
PublishedAt = publishedAt,
CreatedAt = publishedAt
};
if (slug != null) post.Slug = slug;
@ -113,7 +115,7 @@ namespace wp2ghost
return result?.Posts?.FirstOrDefault();
}
public static async Task<GhostAdminPost?> UpdatePostAsync(string postId, string title, string html, string updatedAt, string status = "published")
public static async Task<GhostAdminPost?> UpdatePostAsync(string postId, string title, string html, string updatedAt, string status = "published", string? publishedAt = null)
{
var post = new GhostAdminPostEdit
{
@ -121,7 +123,8 @@ namespace wp2ghost
Title = title,
Html = html,
Status = status,
UpdatedAt = updatedAt
UpdatedAt = updatedAt,
PublishedAt = publishedAt
};
var body = new { posts = new[] { post } };
@ -149,6 +152,102 @@ namespace wp2ghost
}
// ===================== Tags =====================
/// <summary>Alle Tags mit der Anzahl ihrer Posts.</summary>
public static async Task<List<GhostAdminTag>> BrowseTagsWithCountsAsync()
{
const int limit = 100;
var all = new List<GhostAdminTag>();
for (int page = 1; ; page++)
{
var request = await CreateRequestAsync(HttpMethod.Get, $"tags/?limit={limit}&page={page}&include=count.posts&order=name%20asc");
var response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var tags = JsonSerializer.Deserialize<GhostAdminTagsResponse>(await response.Content.ReadAsStringAsync())?.Tags ?? new();
all.AddRange(tags);
if (tags.Count < limit) break;
}
return all;
}
/// <summary>Alle Posts in Ghost (jeden Status), nur mit den Kopfdaten — ohne Inhalt.</summary>
public static async Task<List<GhostAdminPost>> BrowseAllPostsAsync()
{
const int limit = 100;
var all = new List<GhostAdminPost>();
for (int page = 1; ; page++)
{
var request = await CreateRequestAsync(HttpMethod.Get, $"posts/?limit={limit}&page={page}&fields=id,title,slug,status,created_at,updated_at,published_at,feature_image&order=published_at%20desc");
var response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var posts = JsonSerializer.Deserialize<GhostAdminPostsResponse>(await response.Content.ReadAsStringAsync())?.Posts ?? new();
all.AddRange(posts);
if (posts.Count < limit) break;
}
return all;
}
/// <summary>
/// Ändert einzelne Felder eines Posts. Gesendet wird nur, was in "edit" gesetzt ist
/// (plus updated_at als Schutz vor gleichzeitigen Änderungen); alles andere bleibt unberührt.
/// </summary>
public static async Task<bool> UpdatePostFieldsAsync(string postId, GhostAdminPostEdit edit)
{
var body = new { posts = new[] { edit } };
var request = await CreateRequestAsync(HttpMethod.Put, $"posts/{postId}/");
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
string error = await response.Content.ReadAsStringAsync();
Console.WriteLine($" [Ghost-Fehler] {response.StatusCode}: {error}");
return false;
}
return true;
}
/// <summary>Alle Posts (jeden Status) mit einem bestimmten Tag, jeweils mit ihrer vollständigen Tag-Liste.</summary>
public static async Task<List<GhostAdminPost>> BrowsePostsByTagAsync(string tagSlug)
{
const int limit = 50;
var all = new List<GhostAdminPost>();
for (int page = 1; ; page++)
{
string filter = Uri.EscapeDataString($"tag:{tagSlug}");
var request = await CreateRequestAsync(HttpMethod.Get, $"posts/?limit={limit}&page={page}&filter={filter}&include=tags&order=published_at%20asc");
var response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var posts = JsonSerializer.Deserialize<GhostAdminPostsResponse>(await response.Content.ReadAsStringAsync())?.Posts ?? new();
all.AddRange(posts);
if (posts.Count < limit) break;
}
return all;
}
/// <summary>
/// Setzt die Tags eines Posts. Ghost ersetzt die Zuordnung komplett, daher muss die
/// vollständige Liste in der gewünschten Reihenfolge übergeben werden. Inhalt, Titel usw.
/// bleiben unverändert.
/// </summary>
public static async Task<bool> SetPostTagsAsync(string postId, string updatedAt, IEnumerable<string> tagIds)
{
var post = new GhostAdminPostEdit
{
UpdatedAt = updatedAt,
Tags = tagIds.Select(id => new GhostAdminTagRef { Id = id }).ToList()
};
var body = new { posts = new[] { post } };
var request = await CreateRequestAsync(HttpMethod.Put, $"posts/{postId}/");
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
string error = await response.Content.ReadAsStringAsync();
Console.WriteLine($" [Ghost-Fehler] {response.StatusCode}: {error}");
return false;
}
return true;
}
public static async Task<List<GhostAdminTag>> BrowseTagsAsync(string filter = "", int limit = 100)
{
// Browse-Endpunkte sind paginiert -> alle Seiten laden
@ -209,7 +308,9 @@ namespace wp2ghost
else
{
Console.Write($" [Tag] Erstelle '{name}'...");
var newTag = await CreateTagAsync(name, cat.Slug);
// Leerer Fallback → Ghost bildet den Slug selbst aus dem Namen
string tagSlug = SlugHelper.Clean(cat.Slug, "");
var newTag = await CreateTagAsync(name, tagSlug.Length > 0 ? tagSlug : null);
if (newTag != null)
{
Console.WriteLine(" OK");
@ -297,7 +398,7 @@ namespace wp2ghost
return rest.Length > 0 ? rest + "\n" + meta : meta;
}
public static async Task<GhostAdminPost?> CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List<GhostAdminTag> tags, string? codeinjectionHead = null)
public static async Task<GhostAdminPost?> CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List<GhostAdminTag> tags, string? codeinjectionHead = null, string? publishedAt = null)
{
var post = new GhostAdminPostEdit
{
@ -305,7 +406,9 @@ namespace wp2ghost
Html = html,
Status = status,
FeatureImage = featureImage,
CodeinjectionHead = codeinjectionHead
CodeinjectionHead = codeinjectionHead,
PublishedAt = publishedAt,
CreatedAt = publishedAt
};
if (slug != null) post.Slug = slug;
if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList();
@ -325,7 +428,7 @@ namespace wp2ghost
return result?.Posts?.FirstOrDefault();
}
public static async Task<GhostAdminPost?> UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List<GhostAdminTag> tags, string? codeinjectionHead = null)
public static async Task<GhostAdminPost?> UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List<GhostAdminTag> tags, string? codeinjectionHead = null, string? publishedAt = null)
{
var post = new GhostAdminPostEdit
{
@ -335,7 +438,8 @@ namespace wp2ghost
Status = status,
UpdatedAt = updatedAt,
FeatureImage = featureImage,
CodeinjectionHead = codeinjectionHead
CodeinjectionHead = codeinjectionHead,
PublishedAt = publishedAt
};
if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList();
@ -419,6 +523,58 @@ namespace wp2ghost
catch { return false; }
}
// Video-Uploads dauern länger als der Standard-Timeout von 100 Sekunden
private static readonly HttpClient mediaUploadClient = new() { Timeout = TimeSpan.FromMinutes(60) };
/// <summary>Lädt ein Video über die Media-API hoch und liefert seine Adresse in Ghost.</summary>
public static async Task<string?> UploadMediaAsync(string localPath)
{
if (!File.Exists(localPath))
{
Console.WriteLine($" [Ghost-Fehler] Datei nicht gefunden: {localPath}");
return null;
}
string fileName = Path.GetFileName(localPath);
string mimeType = Path.GetExtension(fileName).ToLowerInvariant() switch
{
".mp4" or ".m4v" => "video/mp4",
".webm" => "video/webm",
".ogv" => "video/ogg",
".mov" => "video/quicktime",
_ => "application/octet-stream"
};
try
{
var request = await CreateRequestAsync(HttpMethod.Post, "media/upload/");
await using var fileStream = File.OpenRead(localPath);
var fileContent = new StreamContent(fileStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType);
var formContent = new MultipartFormDataContent();
formContent.Add(fileContent, "file", fileName);
request.Content = formContent;
var response = await mediaUploadClient.SendAsync(request);
string body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
string hint = response.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge
? " (Datei größer als das Upload-Limit des Ghost-Servers)" : "";
Console.WriteLine($" [Ghost-Fehler] Video-Upload fehlgeschlagen: {response.StatusCode}{hint}: {body}");
return null;
}
using var json = JsonDocument.Parse(body);
return json.RootElement.GetProperty("media")[0].GetProperty("url").GetString();
}
catch (Exception ex)
{
Console.WriteLine($" [Ghost-Fehler] Video-Upload fehlgeschlagen: {ex.Message}");
return null;
}
}
public static async Task<string?> UploadImageIfNewAsync(string localPath, DateTime? postDate = null)
{
if (!File.Exists(localPath))
@ -488,7 +644,7 @@ namespace wp2ghost
}
Console.Write($" [Update] {title}...");
var updatedPost = await UpdatePostAsync(existing.Id!, title, html, existing.UpdatedAt ?? "");
var updatedPost = await UpdatePostAsync(existing.Id!, title, html, existing.UpdatedAt ?? "", publishedAt: DateHelper.ToGhostUtc(localPost.PublishedAt));
if (updatedPost != null)
{
Console.WriteLine(" OK");
@ -500,7 +656,7 @@ namespace wp2ghost
else
{
Console.Write($" [Neu] {title}...");
var newPost = await CreatePostAsync(title, html, "published", slug);
var newPost = await CreatePostAsync(title, html, "published", slug, DateHelper.ToGhostUtc(localPost.PublishedAt));
if (newPost != null)
{
Console.WriteLine(" OK");
@ -560,6 +716,13 @@ namespace wp2ghost
[JsonPropertyName("codeinjection_head")]
public string? CodeinjectionHead { get; set; }
[JsonPropertyName("published_at")]
public string? PublishedAt { get; set; }
// Nur gefüllt, wenn mit include=tags abgefragt
[JsonPropertyName("tags")]
public List<GhostAdminTag>? Tags { get; set; }
}
public class GhostAdminPostEdit
@ -596,6 +759,15 @@ namespace wp2ghost
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<GhostAdminTagRef>? Tags { get; set; }
// Veröffentlichungs-/Erstelldatum (UTC). Ohne Angabe setzt Ghost den Zeitpunkt des Imports.
[JsonPropertyName("published_at")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PublishedAt { get; set; }
[JsonPropertyName("created_at")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CreatedAt { get; set; }
[JsonPropertyName("meta_title")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MetaTitle { get; set; }
@ -641,6 +813,16 @@ namespace wp2ghost
[JsonPropertyName("slug")]
public string? Slug { get; set; }
// Nur gefüllt, wenn mit include=count.posts abgefragt
[JsonPropertyName("count")]
public GhostAdminTagCount? Count { get; set; }
}
public class GhostAdminTagCount
{
[JsonPropertyName("posts")]
public int Posts { get; set; }
}
public class GhostAdminTagEdit

View file

@ -36,6 +36,7 @@ namespace wp2ghost
[JsonPropertyName("keywords")] public string? Keywords { get; set; }
[JsonPropertyName("comments")] public List<WpComment> Comments { get; set; } = new();
[JsonPropertyName("image_files")] public List<string> ImageFiles { get; set; } = new();
[JsonPropertyName("media_files")] public List<string> MediaFiles { get; set; } = new();
[JsonPropertyName("html")] public string? Html { get; set; }
}
}

View file

@ -32,15 +32,30 @@ namespace wp2ghost
return;
}
string outputPath = Path.Combine(BaseDir, Program.OutputZipPath);
string outputPath = Path.Combine(BaseDir, Importer.OutputZipPath);
try
{
//PostFetcher.SaveAllMarkdownFiles();
// Links zwischen den eigenen Beiträgen auf Ghost umschreiben (Kopie, Original bleibt unverändert)
int internalLinks = 0;
var exportPosts = posts.Select(p =>
{
var rewritten = InternalLinkManager.Rewrite(p.Html ?? "", Importer.WpBaseUrl, "__GHOST_URL__");
internalLinks += rewritten.Replaced;
return new GhostPost
{
Id = p.Id, Title = p.Title, Slug = p.Slug, Html = rewritten.Html, Markdown = p.Markdown,
FeatureImage = p.FeatureImage, Status = p.Status, CreatedAt = p.CreatedAt, PublishedAt = p.PublishedAt
};
}).ToList();
if (internalLinks > 0)
Console.WriteLine($" {internalLinks} interne(r) Link(s) auf Ghost umgeschrieben");
string jsonString = JsonSerializer.Serialize(new GhostRoot
{
Db = new List<GhostDb> { new GhostDb { Data = new GhostData { Posts = posts.ToList() } } }
Db = new List<GhostDb> { new GhostDb { Data = new GhostData { Posts = exportPosts } } }
}, new JsonSerializerOptions
{
WriteIndented = true,
@ -87,6 +102,20 @@ namespace wp2ghost
}
}
}
// Videos unkomprimiert ablegen (MP4 lässt sich nicht weiter packen)
foreach (var file in MediaManager.CachedFiles)
{
if (File.Exists(file.LocalPath))
{
var fileEntry = archive.CreateEntry($"content/media/{file.RelativePath}", CompressionLevel.NoCompression);
using (var entryStream = fileEntry.Open())
using (var fileStream = File.OpenRead(file.LocalPath))
{
await fileStream.CopyToAsync(entryStream);
}
}
}
}
Console.WriteLine();
@ -94,6 +123,7 @@ namespace wp2ghost
Console.WriteLine($" Ghost ZIP erstellt!");
Console.WriteLine($" Beiträge: {posts.Count}");
Console.WriteLine($" Bilder: {ImageManager.CachedFiles.Count} Dateien");
Console.WriteLine($" Videos: {MediaManager.CachedFiles.Count} Dateien");
Console.WriteLine($" Ausgabe: {outputPath}");
Console.WriteLine("=========================================");
}

View file

@ -80,30 +80,123 @@ namespace wp2ghost
{ "wp-block-jetpack-story", "kg-card kg-gallery-card" },
};
public static string Convert(string html)
// Ghost-Galerien fassen maximal 9 Bilder (3 pro Zeile) — größere WordPress-Galerien
// werden in mehrere aufeinanderfolgende Ghost-Galerien aufgeteilt.
private const int MaxGalleryImages = 9;
private const int GalleryImagesPerRow = 3;
// Liefert die echten Maße eines bereits heruntergeladenen Bildes (per src-URL)
private static Func<string, (int Width, int Height)?>? _imageSizeResolver;
private static int _ghostGalleryCount;
private static int _skippedGalleryImages;
private static int _captionedGalleryImages;
public static string Convert(string html, Func<string, (int Width, int Height)?>? imageSizeResolver = null)
{
if (string.IsNullOrWhiteSpace(html)) return string.Empty;
_imageSizeResolver = imageSizeResolver;
_ghostGalleryCount = 0;
_skippedGalleryImages = 0;
_captionedGalleryImages = 0;
var htmlDoc = new HtmlDocument();
htmlDoc.LoadHtml(html);
RemoveJunkCaptions(htmlDoc);
int brokenImages = RemoveBrokenImages(htmlDoc);
if (brokenImages > 0)
Console.WriteLine($" [Warnung] {brokenImages} Bild(er) mit blob:-Quelle entfernt (schon in WordPress defekt)");
int totalGalleries = 0;
totalGalleries += ConvertClassicGalleries(htmlDoc);
totalGalleries += ConvertGutenbergGalleries(htmlDoc);
totalGalleries += ConvertJetpackGalleries(htmlDoc);
totalGalleries += ConvertCoBlocksGalleries(htmlDoc);
// Gutenberg Gallery
totalGalleries += ConvertImageGalleries(htmlDoc,
"//figure[contains(@class, 'wp-block-gallery')] | " +
"//div[contains(@class, 'wp-block-gallery')] | " +
"//ul[contains(@class, 'wp-block-gallery')]");
// Jetpack Tiled Gallery
totalGalleries += ConvertImageGalleries(htmlDoc,
"//div[contains(@class, 'wp-block-jetpack-tiled-gallery')] | " +
"//figure[contains(@class, 'wp-block-jetpack-tiled-gallery')]");
// CoBlocks Gallery
totalGalleries += ConvertImageGalleries(htmlDoc,
"//figure[contains(@class, 'wp-block-coblocks-gallery-masonry')] | " +
"//figure[contains(@class, 'wp-block-coblocks-gallery-stacked')] | " +
"//figure[contains(@class, 'wp-block-coblocks-gallery-collage')] | " +
"//figure[contains(@class, 'wp-block-coblocks-gallery-thumbnails')]");
if (totalGalleries > 0)
Console.WriteLine($" [Gallery] {totalGalleries} Galerie(n) konvertiert");
{
string split = _ghostGalleryCount > totalGalleries ? $" → {_ghostGalleryCount} Ghost-Galerien (max. {MaxGalleryImages} Bilder)" : "";
Console.WriteLine($" [Gallery] {totalGalleries} Galerie(n) konvertiert{split}");
}
if (_captionedGalleryImages > 0)
Console.WriteLine($" [Gallery] {_captionedGalleryImages} Galerie-Bild(er) mit Unterschrift als Einzelbild ausgegeben");
if (_skippedGalleryImages > 0)
Console.WriteLine($" [Warnung] {_skippedGalleryImages} Galerie-Bild(er) ohne gültige Bildquelle (data:) ausgelassen");
ConvertSingleImages(htmlDoc);
ConvertFigures(htmlDoc);
ConvertWpClasses(htmlDoc);
AddTargetBlankToExternalLinks(htmlDoc);
_imageSizeResolver = null;
return htmlDoc.DocumentNode.OuterHtml;
}
// =========================================================================
// Bilder mit blob:-Quelle entfernen: der Upload ist in WordPress nie
// abgeschlossen worden, es gibt keine Datei, die übernommen werden könnte.
// =========================================================================
private static int RemoveBrokenImages(HtmlDocument htmlDoc)
{
var imgs = htmlDoc.DocumentNode.SelectNodes("//img[starts-with(@src, 'blob:')]");
if (imgs == null) return 0;
int count = 0;
foreach (var img in imgs.ToList())
{
if (!string.IsNullOrEmpty(GetBestImageUrl(img)) && !GetBestImageUrl(img).StartsWith("blob:"))
continue; // data-orig-file/srcset liefern noch eine echte Quelle
// Umschließende Einzelbild-Figure mit entfernen, wenn sie nur dieses Bild enthält
var figure = img.Ancestors("figure").FirstOrDefault();
HtmlNode toRemove = figure != null && figure.SelectNodes(".//img")?.Count == 1 ? figure : img;
toRemove.Remove();
count++;
}
return count;
}
// =========================================================================
// Platzhalter-Unterschriften entfernen, die die Kamera/Handy-App automatisch
// einträgt ("default") — sie gelten für kein Bild.
// =========================================================================
private static void RemoveJunkCaptions(HtmlDocument htmlDoc)
{
var captions = htmlDoc.DocumentNode.SelectNodes("//figcaption");
if (captions == null) return;
foreach (var caption in captions.ToList())
{
string text = HtmlEntity.DeEntitize(caption.InnerText).Trim();
if (text.Equals("default", StringComparison.OrdinalIgnoreCase))
caption.Remove();
}
}
private static string? GetCaptionHtml(HtmlNode? figcaption)
{
if (figcaption == null || string.IsNullOrWhiteSpace(HtmlEntity.DeEntitize(figcaption.InnerText)))
return null;
return figcaption.InnerHtml.Trim();
}
// =========================================================================
// Gallery-Typ 1: WordPress Classic Gallery
// HTML: <div class='gallery gallery-columns-3'>
@ -127,74 +220,31 @@ namespace wp2ghost
var items = gallery.SelectNodes(".//figure[contains(@class, 'gallery-item')]");
if (items == null || items.Count == 0) continue;
var ghostGallery = htmlDoc.CreateElement("figure");
SetClasses(ghostGallery, "kg-card kg-gallery-card");
AddWideClass(ghostGallery, gallery);
var container = htmlDoc.CreateElement("div");
container.AddClass("kg-gallery-container");
var wrappers = new List<HtmlNode>();
var imageCaptions = new List<string>();
var images = new List<(HtmlNode Wrapper, string? Caption)>();
foreach (var item in items)
{
var img = item.SelectSingleNode(".//img");
if (img == null) continue;
// Bild-Titel aus data-image-caption Attribut extrahieren
string dataCaption = img.GetAttributeValue("data-image-caption", "").Trim();
if (!string.IsNullOrEmpty(dataCaption))
imageCaptions.Add(dataCaption);
// Auch figcaption innerhalb des gallery-items prüfen
var figCap = item.SelectSingleNode(".//figcaption");
if (figCap != null && !string.IsNullOrWhiteSpace(figCap.InnerText))
{
string capText = figCap.InnerText.Trim();
if (!string.IsNullOrEmpty(capText) && !imageCaptions.Contains(capText))
imageCaptions.Add(capText);
var wrapper = TryBuildGalleryImage(htmlDoc, img);
if (wrapper != null)
images.Add((wrapper, GetCaptionHtml(item.SelectSingleNode(".//figcaption"))));
}
string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) continue;
if (images.Count == 0) continue;
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
}
if (wrappers.Count == 0) continue;
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
ghostGallery.AppendChild(container);
// Gallery-Bild-Titel übertragen, wenn vorhanden
if (imageCaptions.Count > 0)
{
// Ersten nicht-leeren Titel nehmen als Gallery-Kapitel
string firstCaption = imageCaptions[0];
var newCaption = htmlDoc.CreateElement("figcaption");
newCaption.InnerHtml = firstCaption;
ghostGallery.AppendChild(newCaption);
ghostGallery.AddClass("kg-card-hascaption");
}
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
ReplaceWithGhostGalleries(htmlDoc, gallery, images);
count++;
}
return count;
}
// =========================================================================
// Gallery-Typ 2: WordPress Gutenberg Gallery
// Gallery-Typ 2-4: Gutenberg, Jetpack Tiled, CoBlocks — alle <img> des Blocks
// =========================================================================
private static int ConvertGutenbergGalleries(HtmlDocument htmlDoc)
private static int ConvertImageGalleries(HtmlDocument htmlDoc, string xpath)
{
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
"//figure[contains(@class, 'wp-block-gallery')] | " +
"//div[contains(@class, 'wp-block-gallery')] | " +
"//ul[contains(@class, 'wp-block-gallery')]"
);
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(xpath);
if (galleryNodes == null) return 0;
int count = 0;
@ -203,163 +253,158 @@ namespace wp2ghost
var imgs = gallery.SelectNodes(".//img");
if (imgs == null || imgs.Count == 0) continue;
var images = new List<(HtmlNode Wrapper, string? Caption)>();
foreach (var img in imgs)
{
var wrapper = TryBuildGalleryImage(htmlDoc, img);
if (wrapper == null) continue;
// Unterschrift des einzelnen Bildes: figcaption seiner eigenen Figure (nicht der Galerie)
var imageFigure = img.Ancestors().TakeWhile(a => a != gallery).FirstOrDefault(a => a.Name == "figure");
images.Add((wrapper, GetCaptionHtml(imageFigure?.SelectSingleNode(".//figcaption"))));
}
if (images.Count == 0) continue;
ReplaceWithGhostGalleries(htmlDoc, gallery, images);
count++;
}
return count;
}
// =========================================================================
// Galerie-Helfer: Aufteilen, Unterschriften, Wide-Klasse, Bild-Wrapper, Zeilen
// =========================================================================
/// <summary>
/// Ersetzt eine WordPress-Galerie durch Ghost-Karten in der ursprünglichen Bildreihenfolge.
/// Eine Ghost-Galerie hat nur EINE Unterschrift für alle Bilder; damit jede Unterschrift
/// bei ihrem Bild bleibt, wird ein Bild mit Unterschrift als eigenes Einzelbild ausgegeben
/// und die Bilder davor/danach bilden jeweils eigene Galerien (max. 9 Bilder).
/// </summary>
private static void ReplaceWithGhostGalleries(HtmlDocument htmlDoc, HtmlNode gallery, List<(HtmlNode Wrapper, string? Caption)> images)
{
var parent = gallery.ParentNode;
if (parent == null) return;
string? galleryCaption = TakeGalleryCaption(gallery);
HtmlNode? lastGallery = null;
var run = new List<HtmlNode>();
void FlushRun()
{
if (run.Count == 1)
{
parent.InsertBefore(BuildImageCard(htmlDoc, run[0], null), gallery);
}
else
{
foreach (var chunk in SplitGallery(run))
{
var ghostGallery = htmlDoc.CreateElement("figure");
SetClasses(ghostGallery, "kg-card kg-gallery-card");
AddWideClass(ghostGallery, gallery);
// Neue Format-Prüfung: data-carousel-extra Attribut speichern
string carouselExtra = gallery.GetAttributeValue("data-carousel-extra", "");
var container = htmlDoc.CreateElement("div");
container.AddClass("kg-gallery-container");
var wrappers = new List<HtmlNode>();
foreach (var img in imgs)
{
string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) continue;
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
}
if (wrappers.Count == 0) continue;
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
AppendGalleryRows(htmlDoc, container, chunk, GalleryImagesPerRow);
ghostGallery.AppendChild(container);
// Alle individuellen Bildunterschriften aus der Gutenberg-Gallery sammeln
var captionNodes = gallery.SelectNodes(".//figcaption");
if (captionNodes != null && captionNodes.Count > 0)
{
var distinctCaptions = captionNodes
.Select(n => n.InnerText.Trim())
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct()
.ToList();
parent.InsertBefore(ghostGallery, gallery);
lastGallery = ghostGallery;
_ghostGalleryCount++;
}
}
run.Clear();
}
if (distinctCaptions.Count > 0)
foreach (var (wrapper, caption) in images)
{
if (caption == null)
{
run.Add(wrapper);
continue;
}
FlushRun();
parent.InsertBefore(BuildImageCard(htmlDoc, wrapper, caption), gallery);
_captionedGalleryImages++;
}
FlushRun();
// Unterschrift der ganzen Galerie: an die letzte Teil-Galerie, sonst als Absatz dahinter
if (galleryCaption != null)
{
if (lastGallery != null)
{
var newCaption = htmlDoc.CreateElement("figcaption");
newCaption.InnerHtml = string.Join(" · ", distinctCaptions);
ghostGallery.AppendChild(newCaption);
ghostGallery.AddClass("kg-card-hascaption");
newCaption.InnerHtml = galleryCaption;
lastGallery.AppendChild(newCaption);
lastGallery.AddClass("kg-card-hascaption");
}
}
// data-carousel-extra als Ghost-Attribut übertragen (falls vorhanden)
if (!string.IsNullOrEmpty(carouselExtra))
else
{
var extraAttr = htmlDoc.CreateAttribute("data-carousel-extra");
extraAttr.Value = carouselExtra;
ghostGallery.Attributes.Append(extraAttr);
var paragraph = htmlDoc.CreateElement("p");
paragraph.InnerHtml = galleryCaption;
parent.InsertBefore(paragraph, gallery);
}
}
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
count++;
}
return count;
parent.RemoveChild(gallery);
}
// =========================================================================
// Gallery-Typ 3: Jetpack Tiled Gallery
// =========================================================================
private static int ConvertJetpackGalleries(HtmlDocument htmlDoc)
// Unterschrift der ganzen Galerie: direktes figcaption-Kind (Gutenberg) oder ein
// figcaption unmittelbar HINTER der Galerie (CoBlocks: class="blocks-gallery-caption").
private static string? TakeGalleryCaption(HtmlNode gallery)
{
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
"//div[contains(@class, 'wp-block-jetpack-tiled-gallery')] | " +
"//figure[contains(@class, 'wp-block-jetpack-tiled-gallery')]"
);
if (galleryNodes == null) return 0;
int count = 0;
foreach (var gallery in galleryNodes.ToList())
var captionNode = gallery.SelectSingleNode("./figcaption");
if (captionNode == null)
{
var imgs = gallery.SelectNodes(".//img");
if (imgs == null || imgs.Count == 0) continue;
var next = gallery.NextSibling;
while (next != null && next.NodeType != HtmlNodeType.Element && string.IsNullOrWhiteSpace(next.InnerText))
next = next.NextSibling;
if (next != null && next.Name == "figcaption")
captionNode = next;
}
if (captionNode == null) return null;
var ghostGallery = htmlDoc.CreateElement("figure");
SetClasses(ghostGallery, "kg-card kg-gallery-card");
AddWideClass(ghostGallery, gallery);
var container = htmlDoc.CreateElement("div");
container.AddClass("kg-gallery-container");
var wrappers = new List<HtmlNode>();
foreach (var img in imgs)
{
string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) continue;
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
string? caption = GetCaptionHtml(captionNode);
captionNode.Remove();
return caption;
}
if (wrappers.Count == 0) continue;
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
ghostGallery.AppendChild(container);
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
count++;
}
return count;
}
// =========================================================================
// Gallery-Typ 4: CoBlocks Gallery
// =========================================================================
private static int ConvertCoBlocksGalleries(HtmlDocument htmlDoc)
private static HtmlNode BuildImageCard(HtmlDocument htmlDoc, HtmlNode galleryImageWrapper, string? captionHtml)
{
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
"//figure[contains(@class, 'wp-block-coblocks-gallery-masonry')] | " +
"//figure[contains(@class, 'wp-block-coblocks-gallery-stacked')] | " +
"//figure[contains(@class, 'wp-block-coblocks-gallery-collage')] | " +
"//figure[contains(@class, 'wp-block-coblocks-gallery-thumbnails')]"
);
if (galleryNodes == null) return 0;
var figure = htmlDoc.CreateElement("figure");
SetClasses(figure, "kg-card kg-image-card");
int count = 0;
foreach (var gallery in galleryNodes.ToList())
var img = galleryImageWrapper.SelectSingleNode("./img")!;
img.Remove();
figure.AppendChild(img);
if (!string.IsNullOrEmpty(captionHtml))
{
var imgs = gallery.SelectNodes(".//img");
if (imgs == null || imgs.Count == 0) continue;
var ghostGallery = htmlDoc.CreateElement("figure");
SetClasses(ghostGallery, "kg-card kg-gallery-card");
AddWideClass(ghostGallery, gallery);
var container = htmlDoc.CreateElement("div");
container.AddClass("kg-gallery-container");
var wrappers = new List<HtmlNode>();
foreach (var img in imgs)
{
string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) continue;
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
var caption = htmlDoc.CreateElement("figcaption");
caption.InnerHtml = captionHtml;
figure.AppendChild(caption);
figure.AddClass("kg-card-hascaption");
}
return figure;
}
if (wrappers.Count == 0) continue;
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
ghostGallery.AppendChild(container);
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
count++;
}
return count;
}
// =========================================================================
// Galerie-Helfer: Spaltenanzahl, Wide-Klasse, Bild-Wrapper, Zeilen
// =========================================================================
private static int GetGalleryColumns(HtmlNode gallery)
private static List<List<HtmlNode>> SplitGallery(List<HtmlNode> wrappers)
{
string classes = gallery.GetAttributeValue("class", "");
var match = Regex.Match(classes, @"columns-(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out int cols) && cols >= 1 && cols <= 9)
return cols;
return 3;
var chunks = new List<List<HtmlNode>>();
for (int i = 0; i < wrappers.Count;)
{
int remaining = wrappers.Count - i;
int take = Math.Min(MaxGalleryImages, remaining);
// Kein einzelnes Bild als letzte Galerie übrig lassen (10 → 8 + 2)
if (remaining - take == 1) take--;
chunks.Add(wrappers.GetRange(i, take));
i += take;
}
return chunks;
}
private static void AddWideClass(HtmlNode ghostGallery, HtmlNode originalGallery)
@ -369,6 +414,21 @@ namespace wp2ghost
ghostGallery.AddClass("kg-width-wide");
}
private static HtmlNode? TryBuildGalleryImage(HtmlDocument htmlDoc, HtmlNode img)
{
string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) return null;
// data:-Bilder (Platzhalter) lassen sich nicht als Galerie-Bild exportieren
if (src.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
_skippedGalleryImages++;
return null;
}
return BuildGalleryImage(htmlDoc, img, src);
}
private static HtmlNode BuildGalleryImage(HtmlDocument htmlDoc, HtmlNode originalImg, string src)
{
var imgWrapper = htmlDoc.CreateElement("div");
@ -377,11 +437,19 @@ namespace wp2ghost
var newImg = htmlDoc.CreateElement("img");
newImg.SetAttributeValue("src", src);
// Ghost verwirft Galerie-Bilder ohne Breite/Höhe → möglichst die echten Maße der Datei verwenden
double w = 0, h = 0;
string origSize = originalImg.GetAttributeValue("data-orig-size", "");
if (!string.IsNullOrEmpty(origSize))
var realSize = _imageSizeResolver?.Invoke(src);
if (realSize.HasValue)
{
w = realSize.Value.Width;
h = realSize.Value.Height;
}
if (w <= 0 || h <= 0)
{
string origSize = originalImg.GetAttributeValue("data-orig-size", "");
var parts = origSize.Split(',');
if (parts.Length == 2 &&
double.TryParse(parts[0].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double ow) &&
@ -402,17 +470,17 @@ namespace wp2ghost
}
}
if (w > 0 && h > 0)
// Maße unbekannt (z.B. extern eingebundenes Bild): 3:2 annehmen, statt das Bild zu verlieren
if (w <= 0 || h <= 0)
{
w = 1200;
h = 800;
}
newImg.SetAttributeValue("width", ((int)w).ToString(CultureInfo.InvariantCulture));
newImg.SetAttributeValue("height", ((int)h).ToString(CultureInfo.InvariantCulture));
string ratio = (w / h).ToString("G6", CultureInfo.InvariantCulture);
imgWrapper.SetAttributeValue("style", $"flex: {ratio} 1 0%;");
}
else
{
imgWrapper.SetAttributeValue("style", "flex: 1 1 0%;");
}
newImg.SetAttributeValue("loading", "lazy");
newImg.SetAttributeValue("alt", originalImg.GetAttributeValue("alt", ""));
@ -511,7 +579,8 @@ namespace wp2ghost
var parentFigure = img.Ancestors("figure").FirstOrDefault();
if (parentFigure != null)
{
SetClasses(parentFigure, "kg-card kg-image-card");
bool hasCaption = GetCaptionHtml(parentFigure.SelectSingleNode("./figcaption")) != null;
SetClasses(parentFigure, hasCaption ? "kg-card kg-image-card kg-card-hascaption" : "kg-card kg-image-card");
img.AddClass("kg-image");
}
else

View file

@ -15,6 +15,9 @@ namespace wp2ghost
private static List<(string RelativePath, string LocalPath)> FilesToZip = new();
private static string LocalImageDir = "";
private static string CachePath = "";
// Angehängte, aber im Beitrag nicht verwendete Bilder — bewusst außerhalb von images/
private const string UnusedImageDirName = "images_unbenutzt";
private static string UnusedImageDir = "";
private static readonly Regex SizeSuffixRegex = new(@"-\d+x\d+(?=\.\w+$)", RegexOptions.Compiled);
private static readonly Regex ScaledSuffixRegex = new(@"-scaled(?=\.\w+$)", RegexOptions.Compiled);
@ -46,6 +49,30 @@ namespace wp2ghost
return false;
}
private const string GhostImagePrefix = "__GHOST_URL__/content/images/";
private static readonly Dictionary<string, (int Width, int Height)?> SizeCache = new();
/// <summary>
/// Echte Maße eines lokal vorhandenen Bildes anhand seiner Ghost-URL
/// (__GHOST_URL__/content/images/...). null, wenn die URL kein lokales Bild ist.
/// </summary>
public static (int Width, int Height)? TryGetImageSize(string ghostUrl)
{
if (!ghostUrl.StartsWith(GhostImagePrefix)) return null;
string relativePath = ghostUrl.Substring(GhostImagePrefix.Length);
if (SizeCache.TryGetValue(relativePath, out var known)) return known;
(int Width, int Height)? size = null;
try
{
var info = Image.Identify(Path.Combine(LocalImageDir, relativePath));
if (info != null) size = (info.Width, info.Height);
}
catch { }
SizeCache[relativePath] = size;
return size;
}
private static bool TryGetCached(string key, out (string RelativePath, string LocalPath) file)
{
if (Cache.TryGetValue(key, out var cached))
@ -71,6 +98,7 @@ namespace wp2ghost
public static void Init(string baseDir)
{
LocalImageDir = Path.Combine(baseDir, "images");
UnusedImageDir = Path.Combine(baseDir, UnusedImageDirName);
CachePath = Path.Combine(baseDir, "imagecache.txt");
if (!Directory.Exists(LocalImageDir))
@ -96,6 +124,8 @@ namespace wp2ghost
Directory.Delete(LocalImageDir, true);
Directory.CreateDirectory(LocalImageDir);
}
if (Directory.Exists(UnusedImageDir))
Directory.Delete(UnusedImageDir, true);
Cache.Clear();
FilesToZip.Clear();
SaveCache();
@ -137,28 +167,15 @@ namespace wp2ghost
/// </summary>
public static async Task<(string RelativePath, string LocalPath)?> DownloadBestAsync(IEnumerable<string> variants, DateTime? postDate)
{
// Query/Fragment entfernen: ?w=1024 & Co. liefern höchstens eine verkleinerte Fassung
var urls = variants
.Where(u => !string.IsNullOrEmpty(u))
.Select(u => u.Split('?', '#')[0])
.Where(u => Uri.IsWellFormedUriString(u, UriKind.Absolute))
.Distinct()
.ToList();
var urls = CleanUrls(variants);
if (urls.Count == 0) return null;
string key = GetImageKey(urls[0]);
if (TryGetCached(key, out var cachedFile))
return cachedFile;
// Reihenfolge: Original -> -scaled -> erratenes Original -> Größenvarianten (größte zuerst)
var candidates = new List<string>();
candidates.AddRange(urls.Where(IsMainImage));
candidates.AddRange(urls.Where(u => ScaledSuffixRegex.IsMatch(u) && !SizeSuffixRegex.IsMatch(u)));
candidates.Add(ScaledSuffixRegex.Replace(SizeSuffixRegex.Replace(urls[0], ""), ""));
candidates.AddRange(urls.Where(u => SizeSuffixRegex.IsMatch(u)).OrderByDescending(GetVariantArea));
string lastError = "";
foreach (string candidate in candidates.Distinct())
foreach (string candidate in GetDownloadCandidates(urls))
{
try
{
@ -201,6 +218,59 @@ namespace wp2ghost
return null;
}
// Query/Fragment entfernen: ?w=1024 & Co. liefern höchstens eine verkleinerte Fassung
private static List<string> CleanUrls(IEnumerable<string> variants) => variants
.Where(u => !string.IsNullOrEmpty(u))
.Select(u => u.Split('?', '#')[0])
.Where(u => Uri.IsWellFormedUriString(u, UriKind.Absolute))
.Distinct()
.ToList();
// Reihenfolge: Original -> -scaled -> erratenes Original -> Größenvarianten (größte zuerst)
private static IEnumerable<string> GetDownloadCandidates(List<string> urls)
{
var candidates = new List<string>();
candidates.AddRange(urls.Where(IsMainImage));
candidates.AddRange(urls.Where(u => ScaledSuffixRegex.IsMatch(u) && !SizeSuffixRegex.IsMatch(u)));
candidates.Add(ScaledSuffixRegex.Replace(SizeSuffixRegex.Replace(urls[0], ""), ""));
candidates.AddRange(urls.Where(u => SizeSuffixRegex.IsMatch(u)).OrderByDescending(GetVariantArea));
return candidates.Distinct();
}
/// <summary>
/// Lädt ein am Beitrag angehängtes, aber im Beitrag nicht verwendetes Bild in den
/// Extra-Ordner (images_unbenutzt/&lt;WP-ID&gt;/). Diese Bilder werden weder nach Ghost
/// hochgeladen noch in die ZIP gepackt.
/// </summary>
public static async Task<bool> DownloadUnusedAsync(IEnumerable<string> variants, int wpId)
{
var urls = CleanUrls(variants);
if (urls.Count == 0) return false;
string dir = Path.Combine(UnusedImageDir, wpId.ToString());
string lastError = "";
foreach (string candidate in GetDownloadCandidates(urls))
{
string localPath = Path.Combine(dir, Path.GetFileName(new Uri(candidate).LocalPath));
if (File.Exists(localPath)) return true;
try
{
byte[] imageBytes = await httpClient.GetByteArrayAsync(candidate);
Directory.CreateDirectory(dir);
await File.WriteAllBytesAsync(localPath, imageBytes);
Console.WriteLine($" [Bild] Nicht im Beitrag verwendet → {UnusedImageDirName}/{wpId}/{Path.GetFileName(localPath)}");
return true;
}
catch (Exception ex)
{
lastError = ex.Message;
}
}
Console.WriteLine($" [Bild-Fehler] Konnte {urls[0]} nicht laden: {lastError}");
return false;
}
private static long GetVariantArea(string url)
{
var m = Regex.Match(url, @"-(\d+)x(\d+)(?=\.\w+$)");

View file

@ -0,0 +1,181 @@
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();
}
}

View file

@ -0,0 +1,130 @@
using System.Net;
using System.Net.Sockets;
namespace wp2ghost
{
// =============================================================================
// LinkChecker — prüft, ob ein Link tot ist.
//
// Gemeldet wird nur, was eindeutig ist: Seite nicht gefunden (404/410), Domain existiert
// nicht, Server nimmt keine Verbindung an, oder die Adresse ist formal ungültig.
// Alles Unklare gilt als erreichbar — viele Server weisen Skripte ab (403, 405, 429),
// antworten langsam oder haben kurz einen Fehler (5xx), obwohl der Link im Browser funktioniert.
// =============================================================================
/// <summary>
/// Der tote Link, zu dem der Import gerade eine Entscheidung erfragt. Die Web-Oberfläche liest
/// das, um passend dazu eine Suche nach Ersatz anzubieten.
/// </summary>
public record LinkQuestion(string Text, string Href, string Reason)
{
public static volatile LinkQuestion? Current;
}
static class LinkChecker
{
private static readonly HttpClient httpClient = CreateClient();
// Derselbe Link kommt oft in vielen Beiträgen vor → pro Programmlauf nur einmal prüfen
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, string?> Cache = new();
private static HttpClient CreateClient()
{
// Wie ein Browser auftreten: ohne User-Agent bzw. über HTTP/1.1 antworten manche
// Server (u. a. wordpress.com) mit 403, obwohl die Seite existiert
var client = new HttpClient(new SocketsHttpHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = 10,
AutomaticDecompression = DecompressionMethods.All,
ConnectTimeout = TimeSpan.FromSeconds(10)
})
{
Timeout = TimeSpan.FromSeconds(15),
DefaultRequestVersion = HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36");
client.DefaultRequestHeaders.Accept.ParseAdd("text/html,application/xhtml+xml,*/*;q=0.8");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("de,en;q=0.8");
return client;
}
/// <summary>
/// Liefert null, wenn der Link in Ordnung (oder nicht eindeutig prüfbar) ist,
/// sonst den Grund, warum er als tot gilt.
/// </summary>
public static async Task<string?> CheckAsync(string url)
{
if (string.IsNullOrWhiteSpace(url)) return "leere Adresse";
url = url.Trim();
// Sprungmarken und relative Adressen sind interne Links
if (url.StartsWith("#") || url.StartsWith("/") || !url.Contains(':')) return null;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return "Adresse ist formal ungültig";
// E-Mail-, Telefon- und ähnliche Links lassen sich nicht abrufen und sind gültig
if (uri.Scheme is "mailto" or "tel" or "sms" or "geo" or "whatsapp") return null;
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
return $"Adresse mit '{uri.Scheme}:' funktioniert im Web nicht";
string key = uri.GetLeftPart(UriPartial.Query);
if (Cache.TryGetValue(key, out string? known)) return known;
// Erst HEAD (schnell); viele Server beantworten HEAD falsch oder gar nicht → dann GET
string? reason = await ProbeAsync(uri, HttpMethod.Head);
if (reason != null)
reason = await ProbeAsync(uri, HttpMethod.Get);
Cache[key] = reason;
return reason;
}
// \"https://…\" oder http://https://… — eine intakte Adresse mit Müll davor/dahinter
private static readonly System.Text.RegularExpressions.Regex WrappedUrlRegex = new(
@"^(?:https?://)?[\\""'\s]+(https?://[^\s\\""']+)[\\""'\s]*$", System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>
/// Repariert Adressen, die eindeutig nur falsch eingefasst sind. Liefert die korrigierte
/// Adresse oder null, wenn nichts zu reparieren ist oder das Ergebnis nicht eindeutig wäre.
/// </summary>
public static string? TryRepair(string url)
{
var match = WrappedUrlRegex.Match(url.Trim());
if (!match.Success) return null;
string inner = match.Groups[1].Value;
return Uri.TryCreate(inner, UriKind.Absolute, out _) ? inner : null;
}
private static async Task<string?> ProbeAsync(Uri uri, HttpMethod method)
{
try
{
using var request = new HttpRequestMessage(method, uri);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
return response.StatusCode switch
{
HttpStatusCode.NotFound => "Seite nicht gefunden (404)",
HttpStatusCode.Gone => "Seite wurde entfernt (410)",
// HEAD wird oft pauschal abgelehnt → mit GET nachprüfen; bei GET gilt das als erreichbar
_ when method == HttpMethod.Head && (int)response.StatusCode >= 400 => $"HTTP {(int)response.StatusCode}",
_ => null
};
}
catch (HttpRequestException ex) when (ex.InnerException is SocketException socket)
{
return socket.SocketErrorCode switch
{
SocketError.HostNotFound or SocketError.NoData => "Domain existiert nicht",
SocketError.ConnectionRefused => "Server nimmt keine Verbindung an",
// Zeitüberschreitung, Verbindungsabbruch usw.: nicht eindeutig
_ => method == HttpMethod.Head ? "keine Antwort" : null
};
}
catch
{
// Zeitüberschreitung, TLS-Problem, Protokollfehler: nicht eindeutig → mit GET nachprüfen, dann gelten lassen
return method == HttpMethod.Head ? "keine Antwort" : null;
}
}
}
}

View file

@ -0,0 +1,240 @@
using System.Buffers.Binary;
namespace wp2ghost
{
// =============================================================================
// MediaManager — Videos herunterladen, cachen, lokal verwalten (analog ImageManager)
// =============================================================================
static class MediaManager
{
public const string GhostMediaPrefix = "__GHOST_URL__/content/media/";
// Videos sind groß: eigener Client mit langem Timeout
private static readonly HttpClient httpClient = new() { Timeout = TimeSpan.FromMinutes(30) };
private class Entry
{
public string SourceUrl = "";
public string RelativePath = "";
// Adressen in Ghost nach dem Hochladen, eine je Ghost-Instanz (leer = noch nirgends hochgeladen)
public List<string> GhostUrls = new();
}
// Schlüssel: WordPress-URL ohne Query
private static readonly Dictionary<string, Entry> Cache = new(StringComparer.OrdinalIgnoreCase);
private static readonly List<(string RelativePath, string LocalPath)> FilesToZip = new();
private static string LocalMediaDir = "";
private static string CachePath = "";
public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip;
public static void Init(string baseDir)
{
LocalMediaDir = Path.Combine(baseDir, "media");
CachePath = Path.Combine(baseDir, "mediacache.txt");
LoadCache();
}
public static string GetLocalPath(string relativePath) => Path.Combine(LocalMediaDir, relativePath);
/// <summary>Lädt ein Video von WordPress herunter (oder nimmt die bereits geladene Datei).</summary>
public static async Task<(string RelativePath, string LocalPath)?> DownloadAsync(string url, DateTime? postDate)
{
string cleanUrl = url.Split('?', '#')[0];
if (!Uri.TryCreate(cleanUrl, UriKind.Absolute, out var uri)) return null;
if (Cache.TryGetValue(cleanUrl, out var cached) && File.Exists(GetLocalPath(cached.RelativePath)))
return Register(cached.RelativePath);
try
{
var date = postDate ?? DateTime.Now;
string datePath = $"{date:yyyy}/{date:MM}";
string localDir = Path.Combine(LocalMediaDir, datePath);
Directory.CreateDirectory(localDir);
// Originalnamen behalten, aber nie eine vorhandene Datei überschreiben
string fileName = Path.GetFileName(Uri.UnescapeDataString(uri.AbsolutePath));
string name = Path.GetFileNameWithoutExtension(fileName), ext = Path.GetExtension(fileName);
string relativePath = $"{datePath}/{fileName}";
for (int i = 2; File.Exists(GetLocalPath(relativePath)); i++)
relativePath = $"{datePath}/{name}-{i}{ext}";
string localPath = GetLocalPath(relativePath);
Console.Write($" [Video] Lade {fileName} ...");
using (var response = await httpClient.GetAsync(cleanUrl, HttpCompletionOption.ResponseHeadersRead))
{
response.EnsureSuccessStatusCode();
// Erst in eine .part-Datei, damit ein Abbruch keine halbe Videodatei hinterlässt
string partPath = localPath + ".part";
await using (var target = File.Create(partPath))
await response.Content.CopyToAsync(target);
File.Move(partPath, localPath, overwrite: true);
}
Console.WriteLine($" {new FileInfo(localPath).Length / 1048576.0:0.0} MB → media/{relativePath}");
Cache[cleanUrl] = new Entry { SourceUrl = cleanUrl, RelativePath = relativePath };
SaveCache();
return Register(relativePath);
}
catch (Exception ex)
{
Console.WriteLine($"\n [Video-Fehler] Konnte {cleanUrl} nicht laden: {ex.Message}");
return null;
}
}
private static (string RelativePath, string LocalPath) Register(string relativePath)
{
var file = (relativePath, GetLocalPath(relativePath));
if (!FilesToZip.Any(f => f.RelativePath == relativePath))
FilesToZip.Add(file);
return file;
}
/// <summary>Adresse des Videos in der Ghost-Instanz unter ghostBase, falls es dorthin schon hochgeladen wurde.</summary>
public static string? GetUploadedUrl(string relativePath, string ghostBase)
{
string prefix = ghostBase.TrimEnd('/') + "/";
return Cache.Values.Where(e => e.RelativePath == relativePath)
.SelectMany(e => e.GhostUrls)
.FirstOrDefault(url => url.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}
/// <summary>Merkt sich die Adresse nach dem Hochladen; eine ältere Adresse derselben Instanz wird ersetzt.</summary>
public static void SetUploadedUrl(string relativePath, string ghostUrl)
{
var entry = Cache.Values.FirstOrDefault(e => e.RelativePath == relativePath);
if (entry == null) return;
string? host = SiteOf(ghostUrl);
entry.GhostUrls.RemoveAll(url => SiteOf(url) == host);
entry.GhostUrls.Add(ghostUrl);
SaveCache();
}
// Schema, Host und Port — daran erkennt man, zu welcher Ghost-Instanz eine Adresse gehört
private static string? SiteOf(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.GetLeftPart(UriPartial.Authority).ToLowerInvariant() : null;
private static void SaveCache()
{
File.WriteAllLines(CachePath, Cache.Values.Select(e => string.Join("|", new[] { e.SourceUrl, e.RelativePath }.Concat(e.GhostUrls))));
}
private static void LoadCache()
{
if (!File.Exists(CachePath)) return;
try
{
foreach (var line in File.ReadAllLines(CachePath))
{
var parts = line.Split('|');
// Ab dem dritten Feld folgen die Adressen in Ghost (je Instanz eine)
if (parts.Length >= 2)
Cache[parts[0]] = new Entry { SourceUrl = parts[0], RelativePath = parts[1], GhostUrls = parts.Skip(2).Where(p => p.Length > 0).ToList() };
}
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Laden des Video-Caches: {ex.Message}");
}
}
// =========================================================================
// MP4-Kopfdaten lesen: Breite, Höhe und Dauer für die Ghost-Video-Karte
// (moov/mvhd = Dauer, moov/trak/tkhd = Bildgröße inkl. Drehung)
// =========================================================================
public static (int Width, int Height, double DurationSeconds)? ProbeMp4(string localPath)
{
try
{
using var stream = File.OpenRead(localPath);
byte[]? moov = ReadTopLevelBox(stream, "moov");
if (moov == null) return null;
int width = 0, height = 0;
double duration = 0;
foreach (var (type, start, length) in EnumerateBoxes(moov, 0, moov.Length))
{
if (type == "mvhd")
{
var box = moov.AsSpan(start, length);
bool v1 = box[0] == 1;
uint timescale = BinaryPrimitives.ReadUInt32BigEndian(box.Slice(v1 ? 20 : 12));
ulong ticks = v1 ? BinaryPrimitives.ReadUInt64BigEndian(box.Slice(24)) : BinaryPrimitives.ReadUInt32BigEndian(box.Slice(16));
if (timescale > 0) duration = (double)ticks / timescale;
}
else if (type == "trak" && width == 0)
{
foreach (var (subType, subStart, subLength) in EnumerateBoxes(moov, start, length))
{
if (subType != "tkhd") continue;
var box = moov.AsSpan(subStart, subLength);
int matrix = box[0] == 1 ? 52 : 40;
int w = (int)(BinaryPrimitives.ReadUInt32BigEndian(box.Slice(matrix + 36)) >> 16);
int h = (int)(BinaryPrimitives.ReadUInt32BigEndian(box.Slice(matrix + 40)) >> 16);
if (w == 0 || h == 0) continue; // Tonspur
// Hochkant gefilmte Handy-Videos sind um 90°/270° gedreht gespeichert
bool rotated = BinaryPrimitives.ReadInt32BigEndian(box.Slice(matrix)) == 0
&& BinaryPrimitives.ReadInt32BigEndian(box.Slice(matrix + 4)) != 0;
(width, height) = rotated ? (h, w) : (w, h);
}
}
}
return width > 0 && height > 0 ? (width, height, duration) : null;
}
catch
{
return null;
}
}
private static byte[]? ReadTopLevelBox(Stream stream, string wanted)
{
var header = new byte[16];
while (stream.Position + 8 <= stream.Length)
{
long boxStart = stream.Position;
stream.ReadExactly(header, 0, 8);
long size = BinaryPrimitives.ReadUInt32BigEndian(header);
string type = System.Text.Encoding.ASCII.GetString(header, 4, 4);
int headerSize = 8;
if (size == 1)
{
stream.ReadExactly(header, 8, 8);
size = (long)BinaryPrimitives.ReadUInt64BigEndian(header.AsSpan(8));
headerSize = 16;
}
else if (size == 0)
{
size = stream.Length - boxStart;
}
if (size < headerSize) return null;
if (type == wanted)
{
var content = new byte[size - headerSize];
stream.ReadExactly(content);
return content;
}
stream.Position = boxStart + size;
}
return null;
}
// Liefert Typ sowie Start und Länge des Inhalts (ohne Kopf) jeder Box im Bereich
private static IEnumerable<(string Type, int Start, int Length)> EnumerateBoxes(byte[] data, int start, int length)
{
int position = start, end = start + length;
while (position + 8 <= end)
{
int size = (int)BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(position));
if (size < 8 || position + size > end) yield break;
yield return (System.Text.Encoding.ASCII.GetString(data, position + 4, 4), position + 8, size - 8);
position += size;
}
}
}
}

View file

@ -0,0 +1,410 @@
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace wp2ghost
{
// =============================================================================
// Prüffunktion für bereits nach Ghost gesendete Posts.
//
// Jede Prüfung (IPostCheck) vergleicht einen Post in Ghost mit seiner lokalen JSON-Datei
// und liefert bei einer Abweichung eine Korrektur (PostFix). Alle Korrekturen eines Posts
// werden zusammen in EINER Änderung an Ghost gesendet.
//
// Neue Prüfung hinzufügen: Klasse mit IPostCheck schreiben und in PostChecker.Checks eintragen.
// =============================================================================
/// <summary>Ein Post in Ghost zusammen mit dem, was lokal über ihn bekannt ist.</summary>
public class PostCheckContext
{
public GhostAdminPost Ghost { get; init; } = new();
public GhostPostFile Local { get; init; } = new();
}
/// <summary>Eine gefundene Abweichung und wie sie behoben wird.</summary>
public class PostFix
{
/// <summary>Für die Anzeige, z. B. "Datum 10.10.2026 14:03 → 19.11.2022 21:17".</summary>
public string Description { get; init; } = "";
/// <summary>Trägt die Korrektur in die Änderung ein, die an Ghost gesendet wird.</summary>
public Action<GhostAdminPostEdit> Apply { get; init; } = _ => { };
}
public interface IPostCheck
{
string Name { get; }
/// <summary>Liefert null, wenn der Post in Ordnung ist, sonst die nötige Korrektur.</summary>
PostFix? Check(PostCheckContext context);
}
/// <summary>Das Veröffentlichungsdatum in Ghost muss dem des WordPress-Beitrags entsprechen.</summary>
public class PublishDateCheck : IPostCheck
{
public string Name => "Postdatum auf das WordPress-Datum korrigieren";
public PostFix? Check(PostCheckContext context)
{
// Entwürfe und geplante Posts haben (noch) kein gültiges Veröffentlichungsdatum
if (context.Ghost.Status != "published") return null;
string? expected = DateHelper.ToGhostUtc(context.Local.PublishedAt ?? context.Local.CreatedAt);
if (expected == null) return null;
var expectedDate = DateTimeOffset.Parse(expected, CultureInfo.InvariantCulture);
bool hasActual = DateTimeOffset.TryParse(context.Ghost.PublishedAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var actualDate);
// Ghost speichert auf die Sekunde genau
if (hasActual && Math.Abs((actualDate - expectedDate).TotalSeconds) < 60) return null;
string actualText = hasActual ? actualDate.ToLocalTime().ToString("dd.MM.yyyy HH:mm") : "(keines)";
return new PostFix
{
Description = $"Datum {actualText} → {expectedDate.ToLocalTime():dd.MM.yyyy HH:mm}",
Apply = edit => edit.PublishedAt = expected
};
}
}
// =============================================================================
// SlugAssignments — vom User getroffene Zuordnungen "Slug in Ghost → WordPress-Beitrag".
// Nötig, wenn der Slug in Ghost nicht dem entspricht, den der Importer bilden würde
// (z. B. wegen fehlerhafter Zeichen, einer Kollision oder weil er in Ghost geändert wurde).
// =============================================================================
public static class SlugAssignments
{
// Ghost-Slug → WordPress-ID; 0 = dieser Ghost-Post soll ignoriert werden
private static readonly Dictionary<string, int> ByGhostSlug = new(StringComparer.OrdinalIgnoreCase);
private static string FilePath = "";
/// <summary>Lädt die Zuordnungen einer Ghost-Instanz (jede Instanz hat ihre eigene Datei).</summary>
public static void Init(string filePath)
{
FilePath = filePath;
ByGhostSlug.Clear();
if (!File.Exists(FilePath)) return;
foreach (string line in File.ReadAllLines(FilePath))
{
var parts = line.Split('|');
if (parts.Length == 2 && parts[0].Length > 0)
ByGhostSlug[parts[0]] = parts[1] == "ignorieren" ? 0 : int.TryParse(parts[1], out int id) ? id : 0;
}
}
public static bool IsIgnored(string ghostSlug) => ByGhostSlug.TryGetValue(ghostSlug, out int id) && id == 0;
public static int? GetWpId(string ghostSlug) => ByGhostSlug.TryGetValue(ghostSlug, out int id) && id > 0 ? id : null;
/// <summary>Der Slug, unter dem dieser WordPress-Beitrag in Ghost liegt — falls er zugeordnet wurde.</summary>
public static string? GetGhostSlug(int wpId) =>
wpId > 0 ? ByGhostSlug.FirstOrDefault(kv => kv.Value == wpId).Key : null;
public static void Assign(string ghostSlug, int wpId)
{
ByGhostSlug[ghostSlug] = wpId;
Save();
}
public static void Ignore(string ghostSlug)
{
ByGhostSlug[ghostSlug] = 0;
Save();
}
private static void Save()
{
if (FilePath.Length == 0) return;
File.WriteAllLines(FilePath, ByGhostSlug.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}|{(kv.Value == 0 ? "ignorieren" : kv.Value.ToString())}"));
}
}
public static class PostChecker
{
// Alle verfügbaren Prüfungen — hier werden neue eingetragen
private static readonly List<IPostCheck> Checks = new()
{
new PublishDateCheck()
};
public static async Task RunAsync(string baseDir)
{
// 1. Welche Prüfungen?
var selected = Checks;
if (Checks.Count > 1)
{
Console.WriteLine(" Verfügbare Prüfungen:");
for (int i = 0; i < Checks.Count; i++)
Console.WriteLine($" [{i + 1}] {Checks[i].Name}");
Console.Write(" Welche ausführen? (Nummern mit Komma, leer = alle): ");
string input = Console.ReadLine()?.Trim() ?? "";
if (input.Length > 0)
{
selected = input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => int.TryParse(s, out int n) && n >= 1 && n <= Checks.Count ? Checks[n - 1] : null)
.Where(c => c != null).Select(c => c!).Distinct().ToList();
if (selected.Count == 0)
{
Console.WriteLine(" Keine gültige Auswahl.");
return;
}
}
}
else
{
Console.WriteLine($" Prüfung: {Checks[0].Name}");
}
try
{
// 2. Lokale JSON-Dateien (auch archivierte) bereitlegen
var locals = LoadLocalPosts(baseDir);
if (locals.Count == 0)
{
Console.WriteLine(" Keine lokalen JSON-Dateien in ghostPosts/ (oder ghostPosts/archiv/) gefunden.");
return;
}
// Derselbe bereinigte Slug, unter dem der Import den Post in Ghost anlegt
var localBySlug = new Dictionary<string, GhostPostFile>(StringComparer.OrdinalIgnoreCase);
foreach (var local in locals.Values)
localBySlug[SlugHelper.Clean(local.Slug, $"post-{local.WpId}")] = local;
// 3. Posts aus Ghost holen und den lokalen Dateien zuordnen
Console.Write(" Lade Posts aus Ghost...");
var ghostPosts = await GhostApi.BrowseAllPostsAsync();
Console.WriteLine($" {ghostPosts.Count}");
var pairs = new List<PostCheckContext>();
var unmatched = new List<GhostAdminPost>();
int ignored = 0;
foreach (var ghostPost in ghostPosts)
{
string slug = ghostPost.Slug ?? "";
if (SlugAssignments.IsIgnored(slug)) { ignored++; continue; }
// Eine Zuordnung des Users hat Vorrang vor dem Slug-Vergleich
GhostPostFile? local = null;
if (SlugAssignments.GetWpId(slug) is int assignedId) locals.TryGetValue(assignedId, out local);
if (local == null) localBySlug.TryGetValue(slug, out local);
if (local != null) pairs.Add(new PostCheckContext { Ghost = ghostPost, Local = local });
else unmatched.Add(ghostPost);
}
// 4. Für Posts ohne Zuordnung den User fragen
if (unmatched.Count > 0)
{
var taken = pairs.Select(p => p.Local.WpId).ToHashSet();
pairs.AddRange(AskForAssignments(unmatched, locals, taken, ref ignored));
}
Console.WriteLine($" {pairs.Count} von {ghostPosts.Count} Posts in Ghost sind einem WordPress-Beitrag zugeordnet" +
$"{(ignored > 0 ? $", {ignored} ignoriert" : "")}" +
$"{(ghostPosts.Count - pairs.Count - ignored > 0 ? $", {ghostPosts.Count - pairs.Count - ignored} ohne Zuordnung (nicht geprüft)" : "")}.");
// 5. Prüfen
var findings = new List<(GhostAdminPost Post, List<PostFix> Fixes)>();
foreach (var context in pairs)
{
var fixes = selected.Select(c => c.Check(context)).Where(f => f != null).Select(f => f!).ToList();
if (fixes.Count > 0) findings.Add((context.Ghost, fixes));
}
if (findings.Count == 0)
{
Console.WriteLine(" Alles in Ordnung – nichts zu korrigieren.");
return;
}
Console.WriteLine($" {findings.Count} Post(s) mit Abweichungen:");
foreach (var (post, fixes) in findings)
{
Console.WriteLine($" {post.Title}");
foreach (var fix in fixes)
Console.WriteLine($" {fix.Description}");
}
// 6. Korrigieren
Console.Write(" Korrigieren? (a = alle / e = einzeln fragen / n = nichts): ");
string mode = Console.ReadLine()?.Trim().ToLowerInvariant() ?? "";
if (mode != "a" && mode != "e") return;
int done = 0, skipped = 0;
foreach (var (post, fixes) in findings)
{
if (mode == "e")
{
Console.Write($" {post.Title}: {string.Join("; ", fixes.Select(f => f.Description))} – korrigieren? (j/n): ");
if (Console.ReadLine()?.Trim().ToLowerInvariant() != "j") { skipped++; continue; }
}
else
{
Console.Write($" [{done + 1}/{findings.Count}] {post.Title}...");
}
// Alle Korrekturen dieses Posts in einer Änderung
var edit = new GhostAdminPostEdit { UpdatedAt = post.UpdatedAt };
foreach (var fix in fixes) fix.Apply(edit);
bool ok = await GhostApi.UpdatePostFieldsAsync(post.Id!, edit);
Console.WriteLine(ok ? " OK" : " FEHLER");
if (!ok)
{
Console.WriteLine($" ABBRUCH wegen eines Fehlers. {done} Post(s) korrigiert.");
return;
}
done++;
}
Console.WriteLine($" {done} Post(s) korrigiert{(skipped > 0 ? $", {skipped} übersprungen" : "")}.");
}
catch (OperationCanceledException)
{
throw; // Abbruch durch den User (Web-Oberfläche) nicht als Fehler melden
}
catch (Exception ex)
{
Console.WriteLine($" [Fehler] {ex.Message}");
}
}
/// <summary>
/// Fragt für jeden Ghost-Post ohne Zuordnung, zu welchem WordPress-Beitrag er gehört.
/// Der User kann einen Vorschlag wählen, eine WordPress-ID angeben, den Slug dauerhaft
/// ignorieren oder die Frage auf später verschieben. Zuordnungen und "ignorieren" werden gemerkt.
/// </summary>
private static List<PostCheckContext> AskForAssignments(List<GhostAdminPost> unmatched, Dictionary<int, GhostPostFile> locals, HashSet<int> taken, ref int ignored)
{
var assigned = new List<PostCheckContext>();
Console.WriteLine();
Console.WriteLine($" {unmatched.Count} Post(s) in Ghost lassen sich über den Slug keinem WordPress-Beitrag zuordnen.");
Console.WriteLine(" Eingabe je Post: Nummer eines Vorschlags, w<ID> für eine WordPress-ID (z. B. w861),");
Console.WriteLine(" i = dauerhaft ignorieren, Enter = später, x = alle restlichen später");
for (int index = 0; index < unmatched.Count; index++)
{
var ghostPost = unmatched[index];
string slug = ghostPost.Slug ?? "";
// Vorschläge: noch nicht zugeordnete WordPress-Beiträge, ähnlichste zuerst
var suggestions = locals.Values
.Where(l => !taken.Contains(l.WpId))
.Select(l => (Local: l, Score: Similarity(ghostPost, l)))
.Where(s => s.Score >= 0.34)
.OrderByDescending(s => s.Score)
.Take(5)
.ToList();
bool exactTitle = suggestions.Count > 0 && suggestions[0].Score >= 1.0
&& (suggestions.Count == 1 || suggestions[1].Score < 1.0);
Console.WriteLine();
Console.WriteLine($" [{index + 1}/{unmatched.Count}] Ghost: \"{ghostPost.Title}\"");
Console.WriteLine($" Slug: {slug}");
if (suggestions.Count == 0)
Console.WriteLine(" Kein ähnlicher WordPress-Beitrag gefunden.");
for (int i = 0; i < suggestions.Count; i++)
Console.WriteLine($" [{i + 1}] WP {suggestions[i].Local.WpId}: \"{System.Net.WebUtility.HtmlDecode(suggestions[i].Local.Title ?? "")}\" ({Uri.UnescapeDataString(suggestions[i].Local.WpSlug ?? "")})");
GhostPostFile? chosen = null;
bool decided = false, stop = false;
while (!decided)
{
Console.Write(exactTitle ? " Zuordnen? [Enter = Vorschlag 1 (gleicher Titel) / Nummer / w<ID> / i = ignorieren / s = später / x = Rest später]: "
: " Zuordnen? [Nummer / w<ID> / i = ignorieren / Enter = später / x = Rest später]: ");
string? raw = Console.ReadLine();
if (raw == null) { stop = true; break; }
string input = raw.Trim().ToLowerInvariant();
if (input == "x") { stop = true; break; }
if (input == "i")
{
SlugAssignments.Ignore(slug);
ignored++;
Console.WriteLine(" -> wird dauerhaft ignoriert");
decided = true;
}
else if (input == "s" || (input == "" && !exactTitle))
{
decided = true; // später erneut fragen
}
else if (input == "" && exactTitle)
{
chosen = suggestions[0].Local;
}
else if (int.TryParse(input, out int number) && number >= 1 && number <= suggestions.Count)
{
chosen = suggestions[number - 1].Local;
}
else if (input.StartsWith("w") && int.TryParse(input.Substring(1), out int wpId))
{
if (!locals.TryGetValue(wpId, out chosen))
Console.WriteLine($" Für WordPress-ID {wpId} gibt es keine lokale JSON-Datei.");
else if (taken.Contains(wpId))
{
Console.WriteLine($" WordPress-Beitrag {wpId} ist bereits einem anderen Ghost-Post zugeordnet.");
chosen = null;
}
}
else
{
Console.WriteLine(" Ungültige Eingabe.");
}
if (chosen != null)
{
SlugAssignments.Assign(slug, chosen.WpId);
taken.Add(chosen.WpId);
assigned.Add(new PostCheckContext { Ghost = ghostPost, Local = chosen });
Console.WriteLine($" -> zugeordnet zu WP {chosen.WpId}: \"{System.Net.WebUtility.HtmlDecode(chosen.Title ?? "")}\"");
decided = true;
}
}
if (stop) break;
}
Console.WriteLine();
return assigned;
}
// Ähnlichkeit 0..1: gleicher Titel = 1, sonst gemeinsame Wörter im Titel bzw. gemeinsamer Slug-Anfang
private static double Similarity(GhostAdminPost ghost, GhostPostFile local)
{
string ghostTitle = Normalize(ghost.Title), localTitle = Normalize(local.Title);
if (ghostTitle.Length > 0 && ghostTitle == localTitle) return 1.0;
var a = ghostTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet();
var b = localTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet();
double titleScore = a.Count == 0 || b.Count == 0 ? 0 : (double)a.Intersect(b).Count() / a.Union(b).Count();
string ghostSlug = ghost.Slug ?? "", localSlug = SlugHelper.Clean(local.Slug, "");
int common = 0;
while (common < ghostSlug.Length && common < localSlug.Length && ghostSlug[common] == localSlug[common]) common++;
double slugScore = ghostSlug.Length == 0 || localSlug.Length == 0 ? 0 : (double)common / Math.Max(ghostSlug.Length, localSlug.Length);
// Knapp unter 1, damit nur ein wirklich gleicher Titel als "gleich" gilt
return Math.Min(0.99, Math.Max(titleScore, slugScore));
}
private static string Normalize(string? text) =>
Regex.Replace(System.Net.WebUtility.HtmlDecode(text ?? "").ToLowerInvariant(), @"[^\p{L}\p{Nd}]+", " ").Trim();
// Alle lokalen JSON-Dateien nach WordPress-ID
private static Dictionary<int, GhostPostFile> LoadLocalPosts(string baseDir)
{
var byId = new Dictionary<int, GhostPostFile>();
string postsDir = Path.Combine(baseDir, "ghostPosts");
// Archiv zuerst: gibt es einen Post in beiden Ordnern, gilt die neuere Datei in ghostPosts/
foreach (string dir in new[] { Path.Combine(postsDir, "archiv"), postsDir })
{
if (!Directory.Exists(dir)) continue;
foreach (string file in Directory.GetFiles(dir, "*.json"))
{
try
{
var post = JsonSerializer.Deserialize<GhostPostFile>(File.ReadAllText(file));
if (post != null && post.WpId > 0) byId[post.WpId] = post;
}
catch { }
}
}
return byId;
}
}
}

View file

@ -38,6 +38,13 @@ namespace wp2ghost
{
ProcessedPosts.Clear();
File.WriteAllText(ProcessedPostsPath, "wp_id,wp_slug,ghost_id" + Environment.NewLine);
if (Directory.Exists(GhostPostsDir))
{
foreach (var file in Directory.GetFiles(GhostPostsDir, "*.json"))
{
try { File.Delete(file); } catch { }
}
}
Console.WriteLine("Abgefragte Posts wurden zurückgesetzt.");
}
@ -125,8 +132,16 @@ namespace wp2ghost
}
string htmlContent = wpPost.Content ?? "";
var parsedDate = DateTime.MinValue;
var postDate = DateTime.TryParse(wpPost.Date, out parsedDate) ? parsedDate : new DateTime(2026, 9, 1);
htmlContent = await VideoLinkCleaner.RemoveDeadVideosAsync(htmlContent);
// Veröffentlichungsdatum des WordPress-Beitrags: für Ghost in UTC, lokal für Dateinamen
string? postDateUtc = DateHelper.FromGmt(wpPost.DateGmt) ?? DateHelper.ToGhostUtc(wpPost.Date);
if (postDateUtc == null)
Console.WriteLine(" [Warnung] WordPress liefert kein lesbares Datum für diesen Beitrag");
var postDate = DateTime.TryParse(wpPost.Date, out var parsedDate) ? parsedDate : DateTime.Now;
// Auf WordPress gehostete Videos herunterladen und als Ghost-Video-Karte einbinden
var mediaFiles = new List<string>();
htmlContent = await MigrateVideosAsync(htmlContent, wpHost, postDate, mediaFiles);
// Featured Image: falls von der WP API nicht gesetzt, ersten Bild-URL aus HTML nehmen
if (string.IsNullOrEmpty(wpPost.FeaturedImage))
@ -160,10 +175,22 @@ namespace wp2ghost
variants.Add(url);
}
// Im Beitrag verwendet = kommt im HTML vor oder ist das Featured Image
var usedKeys = htmlImageUrls.Select(ImageManager.GetImageKey).ToHashSet();
if (!string.IsNullOrEmpty(wpPost.FeaturedImage))
usedKeys.Add(ImageManager.GetImageKey(wpPost.FeaturedImage));
var imageFiles = new List<string>();
var ghostPathByKey = new Dictionary<string, string>();
foreach (var group in imageGroups)
{
// Nur angehängte, nicht verwendete Bilder: in den Extra-Ordner, nicht nach Ghost
if (!usedKeys.Contains(group.Key))
{
await ImageManager.DownloadUnusedAsync(group.Value, wpId);
continue;
}
var downloaded = await ImageManager.DownloadBestAsync(group.Value, postDate);
if (!downloaded.HasValue) continue;
ghostPathByKey[group.Key] = $"__GHOST_URL__/content/images/{downloaded.Value.RelativePath}";
@ -179,12 +206,20 @@ namespace wp2ghost
}
// WordPress HTML zu Ghost-kompatibles HTML konvertieren (Galerien, Figures, etc.)
htmlContent = HtmlGhostConverter.Convert(htmlContent);
htmlContent = HtmlGhostConverter.Convert(htmlContent, ImageManager.TryGetImageSize);
int remainingWpImages = ExtractImageUrls(htmlContent, wpHost).Count;
if (remainingWpImages > 0)
Console.WriteLine($" [Warnung] {remainingWpImages} Bild-URL(s) zeigen weiterhin auf WordPress");
// WordPress-Kommentare als Block ans Beitragsende hängen
int realComments = CommentBlockBuilder.CountRealComments(wpPost.Comments);
if (realComments > 0)
{
htmlContent = CommentBlockBuilder.Append(htmlContent, wpPost.Comments);
Console.WriteLine($" [Kommentare] {realComments} Kommentar(e) angehängt");
}
// Featured Image (wurde oben mit heruntergeladen)
string featureImageGhostPath = "";
if (!string.IsNullOrEmpty(wpPost.FeaturedImage)
@ -197,12 +232,12 @@ namespace wp2ghost
{
Id = internalId.ToString(),
Title = wpPost.Title,
Slug = wpPost.Slug,
Slug = SlugHelper.Clean(wpPost.Slug, $"post-{wpId}"),
Html = htmlContent,
FeatureImage = string.IsNullOrEmpty(featureImageGhostPath) ? null : featureImageGhostPath,
Status = "published",
CreatedAt = postDate.ToString("o"),
PublishedAt = postDate.ToString("o")
CreatedAt = postDateUtc,
PublishedAt = postDateUtc
};
GhostPosts.Add(ghostPost);
@ -214,17 +249,18 @@ namespace wp2ghost
WpSlug = wpPost.Slug,
GhostId = ghostPost.Id,
Title = wpPost.Title,
Slug = wpPost.Slug,
Slug = SlugHelper.Clean(wpPost.Slug, $"post-{wpId}"),
Excerpt = wpPost.Excerpt,
Status = "published",
CreatedAt = postDate.ToString("o"),
PublishedAt = postDate.ToString("o"),
CreatedAt = postDateUtc,
PublishedAt = postDateUtc,
FeatureImage = featureImageGhostPath,
Categories = wpPost.Categories ?? new(),
Tags = wpPost.Tags ?? new(),
Keywords = wpPost.Keywords ?? "",
Comments = wpPost.Comments ?? new(),
ImageFiles = imageFiles,
MediaFiles = mediaFiles,
Html = htmlContent
};
string jsonPath = Path.Combine(GhostPostsDir, $"{ghostPostFile.WpId}.json");
@ -233,6 +269,7 @@ namespace wp2ghost
// Cache sofort speichern (nicht erst beim Beenden)
ImageManager.SaveCache();
InternalLinkManager.Register(wpId, wpPost.Slug, ghostPostFile.Slug ?? "");
MarkProcessed(wpId, wpPost.Slug);
internalId++;
}
@ -287,6 +324,81 @@ namespace wp2ghost
return urls;
}
/// <summary>
/// Ersetzt jedes auf WordPress gehostete &lt;video&gt; durch eine Ghost-Video-Karte, deren Quelle
/// auf die heruntergeladene Datei zeigt (__GHOST_URL__/content/media/...).
/// </summary>
private static async Task<string> MigrateVideosAsync(string html, string wpHost, DateTime postDate, List<string> mediaFiles)
{
if (!html.Contains("<video", StringComparison.OrdinalIgnoreCase)) return html;
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(html);
var videos = doc.DocumentNode.SelectNodes("//video");
if (videos == null) return html;
bool changed = false;
foreach (var video in videos.ToList())
{
string url = video.GetAttributeValue("src", "");
if (url.Length == 0)
url = video.SelectSingleNode("./source[@src]")?.GetAttributeValue("src", "") ?? "";
url = HtmlAgilityPack.HtmlEntity.DeEntitize(url).Trim();
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase) || !IsInternalUrl(url, wpHost)) continue;
var downloaded = await MediaManager.DownloadAsync(url, postDate);
if (!downloaded.HasValue) continue;
if (!mediaFiles.Contains(downloaded.Value.RelativePath))
mediaFiles.Add(downloaded.Value.RelativePath);
// Die umschließende Figure (wp-block-video) wird mit ersetzt, ihre Unterschrift übernommen
var figure = video.Ancestors("figure").FirstOrDefault();
bool figureOnlyHoldsVideo = figure != null && figure.SelectSingleNode(".//img") == null && figure.SelectNodes(".//video")?.Count == 1;
string caption = figureOnlyHoldsVideo ? figure!.SelectSingleNode(".//figcaption")?.InnerHtml.Trim() ?? "" : "";
var card = doc.CreateElement("figure");
card.SetAttributeValue("class", "kg-card kg-video-card kg-width-regular" + (caption.Length > 0 ? " kg-card-hascaption" : ""));
var container = doc.CreateElement("div");
container.SetAttributeValue("class", "kg-video-container");
var newVideo = doc.CreateElement("video");
newVideo.SetAttributeValue("src", MediaManager.GhostMediaPrefix + downloaded.Value.RelativePath);
newVideo.SetAttributeValue("playsinline", "");
newVideo.SetAttributeValue("preload", "metadata");
container.AppendChild(newVideo);
// Ghost braucht Bildgröße und Dauer für die Darstellung der Karte
var info = MediaManager.ProbeMp4(downloaded.Value.LocalPath);
if (info.HasValue)
{
newVideo.SetAttributeValue("width", info.Value.Width.ToString());
newVideo.SetAttributeValue("height", info.Value.Height.ToString());
int seconds = (int)Math.Round(info.Value.DurationSeconds);
var duration = doc.CreateElement("div");
duration.SetAttributeValue("class", "kg-video-duration");
duration.InnerHtml = $"{seconds / 60}:{seconds % 60:00}";
container.AppendChild(duration);
}
else
{
Console.WriteLine($" [Warnung] Bildgröße/Dauer von {Path.GetFileName(downloaded.Value.LocalPath)} nicht lesbar");
}
card.AppendChild(container);
if (caption.Length > 0)
{
var figcaption = doc.CreateElement("figcaption");
figcaption.InnerHtml = caption;
card.AppendChild(figcaption);
}
var target = figureOnlyHoldsVideo ? figure! : video;
target.ParentNode!.ReplaceChild(card, target);
changed = true;
}
return changed ? doc.DocumentNode.OuterHtml : html;
}
private static bool IsInternalUrl(string url, string wpHost)
{
if (string.IsNullOrEmpty(wpHost)) return true;

View file

@ -0,0 +1,59 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace wp2ghost
{
// =============================================================================
// SlugHelper — WordPress-Slugs in saubere Ghost-Slugs (nur a-z, 0-9, "-") umwandeln
// =============================================================================
static class SlugHelper
{
// Griechisch → Latein (Akzente werden vorher abgetrennt)
private static readonly Dictionary<char, string> GreekToLatin = new()
{
['α'] = "a", ['β'] = "v", ['γ'] = "g", ['δ'] = "d", ['ε'] = "e", ['ζ'] = "z", ['η'] = "i", ['θ'] = "th",
['ι'] = "i", ['κ'] = "k", ['λ'] = "l", ['μ'] = "m", ['ν'] = "n", ['ξ'] = "x", ['ο'] = "o", ['π'] = "p",
['ρ'] = "r", ['σ'] = "s", ['ς'] = "s", ['τ'] = "t", ['υ'] = "y", ['φ'] = "f", ['χ'] = "ch", ['ψ'] = "ps", ['ω'] = "o"
};
private static readonly Dictionary<char, string> GermanToLatin = new()
{
['ä'] = "ae", ['ö'] = "oe", ['ü'] = "ue", ['ß'] = "ss"
};
/// <summary>
/// WordPress speichert nicht-lateinische Zeichen im Slug prozentkodiert
/// ("%ce%b3%ce%b9%ce%b1" = "για"). Die Kodierung wird aufgelöst, Griechisch nach Latein
/// umgeschrieben ("gia") und alles übrige auf a-z, 0-9 und "-" reduziert.
/// </summary>
public static string Clean(string? wpSlug, string fallback)
{
string slug = wpSlug ?? "";
try { slug = Uri.UnescapeDataString(slug); } catch { }
// Reste einer abgeschnittenen Kodierung entfernen (WordPress kürzt lange Slugs mitten im Zeichen)
slug = Regex.Replace(slug, "%[0-9a-fA-F]{0,2}", "");
slug = slug.Replace('<27>', '-').ToLowerInvariant();
foreach (var (german, latin) in GermanToLatin)
slug = slug.Replace(german.ToString(), latin);
// Akzente abtrennen: "ί" → "ι", "é" → "e"
slug = string.Concat(slug.Normalize(NormalizationForm.FormD)
.Where(c => CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark));
slug = slug.Replace("ου", "ou");
var result = new StringBuilder();
foreach (char c in slug)
{
if (GreekToLatin.TryGetValue(c, out string? latin)) result.Append(latin);
else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) result.Append(c);
else result.Append('-');
}
string cleaned = Regex.Replace(result.ToString(), "-{2,}", "-").Trim('-');
return cleaned.Length > 0 ? cleaned : fallback;
}
}
}

View file

@ -0,0 +1,261 @@
using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using HtmlAgilityPack;
namespace wp2ghost
{
// =============================================================================
// VideoLinkCleaner — tote Verweise auf Videos (Links, <video>, Einbettungen) entfernen
// =============================================================================
static class VideoLinkCleaner
{
private static readonly HttpClient httpClient = CreateClient();
// Ergebnis pro URL merken: dieselben Videos kommen in mehreren Beiträgen vor
private static readonly Dictionary<string, bool> DeadCache = new();
private static readonly string[] VideoExtensions = { ".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv", ".3gp", ".wmv" };
private static readonly string[] VideoHosts = { "youtube.com", "youtube-nocookie.com", "youtu.be", "vimeo.com", "videopress.com", "video.wordpress.com", "videos.files.wordpress.com" };
private static readonly Regex VimeoRegex = new(@"vimeo\.com/(?:video/)?(\d+)(?:/([0-9a-f]+))?", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex VimeoHashRegex = new(@"[?&]h=([0-9a-f]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex YouTubeRegex = new(@"(?:youtube(?:-nocookie)?\.com/(?:embed/|shorts/|watch\?(?:[^""'\s]*&)?v=)|youtu\.be/)([\w-]{11})", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static HttpClient CreateClient()
{
// HTTP/2 ist nötig: über HTTP/1.1 beantworten die wordpress.com-Videoserver
// jede Anfrage dieses Clients mit 403 — auch für funktionierende Videos.
var client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(10),
DefaultRequestVersion = HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36");
return client;
}
/// <summary>
/// Entfernt Links, &lt;video&gt;-Elemente und Einbettungen, deren Video nicht mehr existiert.
/// Nicht eindeutig prüfbare Fälle (Timeout, Serverfehler) bleiben unverändert.
/// </summary>
public static async Task<string> RemoveDeadVideosAsync(string html)
{
if (string.IsNullOrWhiteSpace(html)) return html;
var doc = new HtmlDocument();
doc.LoadHtml(html);
int changes = ConvertPrestoPlayers(doc);
if (changes > 0)
Console.WriteLine($" [Video] {changes} Presto-Player-Block/Blöcke in normale Video-Einbettung umgewandelt");
var nodes = doc.DocumentNode.SelectNodes("//a[@href] | //video | //iframe[@src] | //embed[@src]");
if (nodes == null) return changes > 0 ? doc.DocumentNode.OuterHtml : html;
foreach (var node in nodes.ToList())
{
string url = GetUrl(node);
// <video> mit lokaler Quelle (file://, blob:, content://) hat nie funktioniert
if (node.Name == "video" && Uri.TryCreate(url, UriKind.Absolute, out var videoUri)
&& videoUri.Scheme != Uri.UriSchemeHttp && videoUri.Scheme != Uri.UriSchemeHttps)
{
Console.WriteLine($" [Video] Totes Video entfernt (lokale Quelle): {url}");
RemoveNode(node);
changes++;
continue;
}
if (!IsVideoUrl(node, url)) continue;
if (!await IsDeadAsync(url, isVideoFile: node.Name == "video" || HasVideoExtension(url))) continue;
Console.WriteLine($" [Video] Totes Video entfernt ({DescribeNode(node)}): {url}");
RemoveNode(node);
changes++;
}
return changes > 0 ? doc.DocumentNode.OuterHtml : html;
}
/// <summary>
/// Presto-Player-Blöcke (&lt;presto-player src="https://vimeo.com/ID/HASH"&gt;) funktionieren nur mit
/// dem WordPress-Plugin. Sie werden durch eine normale Einbettung bzw. das enthaltene
/// &lt;video&gt; ersetzt, damit das Video in Ghost abspielbar bleibt.
/// </summary>
private static int ConvertPrestoPlayers(HtmlDocument doc)
{
var players = doc.DocumentNode.SelectNodes("//presto-player");
if (players == null) return 0;
int converted = 0;
foreach (var player in players.ToList())
{
string src = HtmlEntity.DeEntitize(player.GetAttributeValue("src", "")).Trim();
var vimeo = VimeoRegex.Match(src);
var youtube = YouTubeRegex.Match(src);
HtmlNode? replacement = null;
if (vimeo.Success)
{
// Der Hash gehört bei nicht gelisteten Videos zwingend dazu
string hash = vimeo.Groups[2].Success ? "?h=" + vimeo.Groups[2].Value : "";
replacement = CreateEmbedIframe(doc, $"https://player.vimeo.com/video/{vimeo.Groups[1].Value}{hash}");
}
else if (youtube.Success)
{
replacement = CreateEmbedIframe(doc, $"https://www.youtube.com/embed/{youtube.Groups[1].Value}");
}
else
{
// Selbst gehostet: das enthaltene <video> übernehmen
replacement = player.SelectSingleNode(".//video");
replacement?.Remove();
}
if (replacement == null || player.ParentNode == null) continue;
player.ParentNode.ReplaceChild(replacement, player);
converted++;
}
return converted;
}
private static HtmlNode CreateEmbedIframe(HtmlDocument doc, string src)
{
var iframe = doc.CreateElement("iframe");
iframe.SetAttributeValue("src", src);
iframe.SetAttributeValue("width", "640");
iframe.SetAttributeValue("height", "360");
iframe.SetAttributeValue("frameborder", "0");
iframe.SetAttributeValue("allow", "autoplay; fullscreen; picture-in-picture");
iframe.SetAttributeValue("allowfullscreen", "");
return iframe;
}
private static string GetUrl(HtmlNode node)
{
string raw = node.Name switch
{
"a" => node.GetAttributeValue("href", ""),
"video" => node.GetAttributeValue("src", "") is { Length: > 0 } src
? src
: node.SelectSingleNode("./source[@src]")?.GetAttributeValue("src", "") ?? "",
_ => node.GetAttributeValue("src", "")
};
return HtmlEntity.DeEntitize(raw).Trim();
}
private static string DescribeNode(HtmlNode node) => node.Name switch
{
"a" => "Link",
"video" => "Video",
_ => "Einbettung"
};
private static bool IsVideoUrl(HtmlNode node, string url)
{
// Nur absolute URLs sind prüfbar
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
return false;
if (node.Name == "video") return true;
string host = uri.Host.ToLowerInvariant();
if (VideoHosts.Any(h => host == h || host.EndsWith("." + h))) return true;
return HasVideoExtension(url);
}
private static bool HasVideoExtension(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) && VideoExtensions.Contains(Path.GetExtension(uri.AbsolutePath).ToLowerInvariant());
private static async Task<bool> IsDeadAsync(string url, bool isVideoFile)
{
if (DeadCache.TryGetValue(url, out bool known)) return known;
bool dead;
var vimeo = VimeoRegex.Match(url);
var youtube = YouTubeRegex.Match(url);
if (vimeo.Success)
{
// Die Player-Seite antwortet Skripten mit 401/403 — maßgeblich ist die oEmbed-Auskunft.
// Nicht gelistete Videos brauchen dafür ihren Hash (?h=...).
string hash = vimeo.Groups[2].Success ? vimeo.Groups[2].Value : VimeoHashRegex.Match(url).Groups[1].Value;
string target = $"https://vimeo.com/{vimeo.Groups[1].Value}" + (hash.Length > 0 ? "/" + hash : "");
var status = await GetStatusAsync("https://vimeo.com/api/oembed.json?url=" + Uri.EscapeDataString(target));
dead = false;
if (status == HttpStatusCode.NotFound)
{
// oEmbed meldet 404 auch für private Videos; die Player-Seite nur für gelöschte
var playerStatus = await GetStatusAsync($"https://player.vimeo.com/video/{vimeo.Groups[1].Value}");
dead = playerStatus == HttpStatusCode.NotFound;
if (!dead)
Console.WriteLine($" [Warnung] Vimeo-Video ist privat und für Leser nicht abspielbar (bleibt erhalten): {url}");
}
}
else if (youtube.Success)
{
// Die Watch-/Embed-Seite liefert auch für gelöschte Videos 200 — oEmbed meldet 404
string target = "https://www.youtube.com/watch?v=" + youtube.Groups[1].Value;
var status = await GetStatusAsync("https://www.youtube.com/oembed?format=json&url=" + Uri.EscapeDataString(target));
dead = status == HttpStatusCode.NotFound || status == HttpStatusCode.BadRequest;
}
else
{
var status = await GetStatusAsync(url);
// 403 gilt nur bei einer Videodatei als tot — Seiten/Einbettungen sperren damit oft bloß Skripte aus
dead = status == HttpStatusCode.NotFound || status == HttpStatusCode.Gone || status == HostNotFound
|| (isVideoFile && status == HttpStatusCode.Forbidden);
}
DeadCache[url] = dead;
return dead;
}
// Kein echter HTTP-Status: der Hostname existiert nicht mehr
private const HttpStatusCode HostNotFound = (HttpStatusCode)(-1);
/// <summary>HTTP-Status der URL; null, wenn er nicht ermittelt werden konnte (Timeout o.ä.).</summary>
private static async Task<HttpStatusCode?> GetStatusAsync(string url)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 0); // Video nicht herunterladen
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
return response.StatusCode;
}
catch (HttpRequestException ex) when (ex.InnerException is SocketException { SocketErrorCode: SocketError.HostNotFound })
{
return HostNotFound;
}
catch
{
return null;
}
}
private static void RemoveNode(HtmlNode node)
{
if (node.Name == "a")
{
// Linktext bleibt stehen — außer er ist selbst nur die URL
string text = HtmlEntity.DeEntitize(node.InnerText).Trim();
bool keepContent = text.Length > 0 && !text.StartsWith("http", StringComparison.OrdinalIgnoreCase) && node.ParentNode != null;
if (keepContent)
{
foreach (var child in node.ChildNodes.ToList())
node.ParentNode!.InsertBefore(child, node);
}
node.Remove();
return;
}
// Umschließende Figure (wp-block-video / wp-block-embed) samt Unterschrift mit entfernen,
// wenn sie nichts anderes enthält
var figure = node.Ancestors("figure").FirstOrDefault();
bool figureOnlyHoldsThis = figure != null
&& figure.SelectSingleNode(".//img") == null
&& (figure.SelectNodes(".//video | .//iframe | .//embed")?.Count ?? 0) == 1;
(figureOnlyHoldsThis ? figure! : node).Remove();
}
}
}

View file

@ -19,7 +19,10 @@ namespace wp2ghost
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("title")] public string? Title { get; set; }
[JsonPropertyName("slug")] public string? Slug { get; set; }
// Veröffentlichungsdatum in der Ortszeit des Blogs (ohne Zeitzone)
[JsonPropertyName("date")] public string? Date { get; set; }
// Dasselbe in UTC — ab Plugin-Version mit date_gmt
[JsonPropertyName("date_gmt")] public string? DateGmt { get; set; }
[JsonPropertyName("content")] public string? Content { get; set; }
[JsonPropertyName("excerpt")] public string? Excerpt { get; set; }
[JsonPropertyName("featured_image")] public string? FeaturedImage { get; set; }
@ -37,6 +40,40 @@ namespace wp2ghost
[JsonPropertyName("slug")] public string? Slug { get; set; }
}
// Datumsangaben für Ghost: immer UTC im Format 2022-11-19T09:30:00.000Z
public static class DateHelper
{
private const string GhostFormat = "yyyy-MM-dd'T'HH:mm:ss.000'Z'";
/// <summary>
/// Wandelt ein Datum in UTC um. Ein Datum ohne Zeitzone (so liefert es WordPress) wird
/// als Ortszeit dieses Rechners gedeutet. Liefert null, wenn es nicht lesbar ist.
/// </summary>
public static string? ToGhostUtc(string? date) => Convert(date, System.Globalization.DateTimeStyles.AssumeLocal);
/// <summary>Wie ToGhostUtc, aber ein Datum ohne Zeitzone gilt als UTC (WordPress "date_gmt").</summary>
public static string? FromGmt(string? date) => Convert(date, System.Globalization.DateTimeStyles.AssumeUniversal);
private static string? Convert(string? date, System.Globalization.DateTimeStyles assume)
{
if (string.IsNullOrWhiteSpace(date)) return null;
if (!DateTimeOffset.TryParse(date, System.Globalization.CultureInfo.InvariantCulture, assume, out var parsed)) return null;
if (parsed.Year < 1990) return null; // WordPress-Platzhalter 0000-00-00
return parsed.UtcDateTime.ToString(GhostFormat, System.Globalization.CultureInfo.InvariantCulture);
}
}
// Kategorien, die nie nach Ghost übernommen werden
public static class CategoryRules
{
private static readonly string[] IgnoredNames = { "Allgemein" };
public static bool IsIgnored(string? name) =>
IgnoredNames.Contains(System.Net.WebUtility.HtmlDecode(name ?? "").Trim(), StringComparer.OrdinalIgnoreCase);
public static bool IsIgnored(WpCategory category) => IsIgnored(category.Name) || IsIgnored(category.Slug);
}
public class WpComment
{
[JsonPropertyName("id")] public int Id { get; set; }
@ -45,5 +82,8 @@ namespace wp2ghost
[JsonPropertyName("author_url")] public string? AuthorUrl { get; set; }
[JsonPropertyName("content")] public string? Content { get; set; }
[JsonPropertyName("date")] public string? Date { get; set; }
// Ab Plugin-Version mit Kommentar-Typ: "comment", "pingback", "like", "repost" ...
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("parent")] public int Parent { get; set; }
}
}

View file

@ -34,11 +34,12 @@ else
</select>
</div>
<div class="col-md-2">
<label class="form-label">Suche nach Post</label>
<div class="input-group">
<input type="text" class="form-control" @bind="_searchTerm" @bind:event="oninput" placeholder="Post-Titel..." />
<button class="btn btn-outline-secondary" @onclick="LoadImages" disabled="@_isLoading">Suchen</button>
<label class="form-label">Suche nach Bildname</label>
<input type="search" class="form-control" @bind="_nameSearch" @bind:event="oninput" @bind:after="ApplyFilters" placeholder="Dateiname oder Pfad..." />
</div>
<div class="col-md-2">
<label class="form-label">Suche nach Post</label>
<input type="search" class="form-control" @bind="_searchTerm" @bind:event="oninput" @bind:after="ApplyFilters" placeholder="Post-Titel..." />
</div>
<div class="col-md-2">
<label class="form-label">Autor</label>
@ -70,7 +71,7 @@ else
}
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="col-12 d-flex align-items-end mt-3">
<button class="btn btn-outline-secondary me-2" @onclick="ResetFilters" title="Filter zurücksetzen">
<i class="bi bi-x-circle"></i>
</button>
@ -99,10 +100,10 @@ else
</ul>
</div>
@if (SelectedOrphanedImages.Any())
@if (SelectedDeletableImages.Any())
{
<button class="btn btn-danger me-2" @onclick="DeleteSelectedOrphaned">
Auswahl löschen (@SelectedOrphanedImages.Count)
<button class="btn btn-danger me-2" @onclick="DeleteSelected">
Auswahl löschen (@SelectedDeletableImages.Count)
</button>
}
}
@ -114,6 +115,11 @@ else
<div class="alert alert-danger">@_errorMessage</div>
}
@if (!string.IsNullOrEmpty(_warningMessage))
{
<div class="alert alert-warning">@_warningMessage</div>
}
@if (!string.IsNullOrEmpty(_successMessage))
{
<div class="alert alert-success">@_successMessage</div>
@ -128,20 +134,13 @@ else
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.Feature)" @onclick="() => SetFilter(ImageFilter.Feature)">Feature (@_featureCount)</button>
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.Content)" @onclick="() => SetFilter(ImageFilter.Content)">Content (@_contentCount)</button>
<button class="btn btn-outline-danger @GetFilterClass(ImageFilter.Orphaned)" @onclick="() => SetFilter(ImageFilter.Orphaned)">Verwaist (@_orphanedCount)</button>
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.Original)" @onclick="() => SetFilter(ImageFilter.Original)">Originale (@_originalCount)</button>
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.Category)" @onclick="() => SetFilter(ImageFilter.Category)">Kategorie (@_categoryCount)</button>
</div>
</div>
<div class="col-auto">
<span class="text-muted small">
Gefiltert: <strong>@_filteredImages.Count</strong> von @_allImages.Count
@if (!string.IsNullOrWhiteSpace(_authorFilter) || !string.IsNullOrWhiteSpace(_themeFilter) || !string.IsNullOrWhiteSpace(_categoryFilter))
{
<text>
@if (!string.IsNullOrWhiteSpace(_authorFilter)): @:Autor
@if (!string.IsNullOrWhiteSpace(_themeFilter)): @:Thema
@if (!string.IsNullOrWhiteSpace(_categoryFilter)): @:Kategorie
</text>
}
</span>
</div>
<div class="col-auto">
@ -150,6 +149,12 @@ else
<label class="form-check-label" for="selectAllOrphaned">Alle verwaisten auswählen</label>
</div>
</div>
<div class="col-auto">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAllOriginals" @onchange="ToggleSelectAllOriginals" checked="@_selectAllOriginals" />
<label class="form-check-label" for="selectAllOriginals">Alle Originale auswählen</label>
</div>
</div>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
@ -157,7 +162,7 @@ else
{
<div class="col">
<div class="card h-100 @GetCardClass(image)">
@if (image.IsOrphaned)
@if (image.CanDelete)
{
<div class="card-header py-1">
<div class="form-check">
@ -195,7 +200,14 @@ else
}
</p>
@if (image.IsOrphaned)
@if (image.IsOriginal)
{
<div class="alert alert-info py-1 px-2 mb-2 small">
Originaldatei von <strong>@image.OriginalOf</strong>
@(image.IsOrphaned ? "(Bild wird nicht verwendet)" : "(Bild wird verwendet)")
</div>
}
else if (image.IsOrphaned)
{
<div class="alert alert-warning py-1 px-2 mb-2 small">
⚠️ Verwaist (nicht in Posts verwendet)
@ -210,7 +222,7 @@ else
{
<li>
<span class="badge @(post.IsFeatureImage ? "bg-primary" : "bg-secondary") me-1">
@(post.IsFeatureImage ? "Feature" : "Content")
@(post.IsFeatureImage ? "Feature" : post.IsInContent ? "Content" : "Website")
</span>
<span title="@post.Title">@post.Title</span>
</li>
@ -230,7 +242,7 @@ else
<button class="btn btn-sm btn-outline-secondary" @onclick="() => ShowEditDialog(image)">
Bearbeiten
</button>
@if (image.IsOrphaned)
@if (image.CanDelete)
{
<button class="btn btn-sm btn-outline-danger" @onclick="() => ShowDeleteDialog(image)">
Löschen
@ -313,7 +325,14 @@ else
<div class="modal-body">
<p>Möchtest du wirklich <strong>@_selectedImage.FileName</strong> löschen?</p>
<div class="alert alert-warning">
⚠️ Dieses Bild ist verwaist und wird in keinem Post verwendet.
@if (_selectedImage.IsOriginal)
{
<text>Das ist die unkomprimierte Originaldatei von <strong>@_selectedImage.OriginalOf</strong>. Das Bild selbst bleibt erhalten.</text>
}
else
{
<text>⚠️ Dieses Bild ist verwaist und wird in keinem Post verwendet.</text>
}
</div>
</div>
<div class="modal-footer">
@ -337,21 +356,25 @@ else
<button type="button" class="btn-close" @onclick="CloseBulkDeleteDialog"></button>
</div>
<div class="modal-body">
<p>Möchtest du wirklich <strong>@SelectedOrphanedImages.Count</strong> verwaiste Bilder löschen?</p>
<p>
Möchtest du wirklich <strong>@SelectedDeletableImages.Count</strong> Dateien löschen
(@SelectedDeletableImages.Count(i => i.IsOriginal) Originaldateien,
@SelectedDeletableImages.Count(i => !i.IsOriginal) verwaiste Bilder)?
</p>
<ul class="list-group small">
@foreach (var img in SelectedOrphanedImages.Take(10))
@foreach (var img in SelectedDeletableImages.Take(10))
{
<li class="list-group-item">@img.FileName</li>
}
@if (SelectedOrphanedImages.Count > 10)
@if (SelectedDeletableImages.Count > 10)
{
<li class="list-group-item text-muted">... und @(SelectedOrphanedImages.Count - 10) weitere</li>
<li class="list-group-item text-muted">... und @(SelectedDeletableImages.Count - 10) weitere</li>
}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseBulkDeleteDialog">Abbrechen</button>
<button type="button" class="btn btn-danger" @onclick="DeleteSelectedOrphanedConfirmed" disabled="@_isDeleting">
<button type="button" class="btn btn-danger" @onclick="DeleteSelectedConfirmed" disabled="@_isDeleting">
@if (_isDeleting)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
@ -435,6 +458,11 @@ else
private List<GhostInstance> _instances = new();
private string _selectedInstanceId = "";
private string _searchTerm = "";
private string _nameSearch = "";
private string? _warningMessage;
private int _originalCount;
private int _categoryCount;
private bool _selectAllOriginals;
private string _authorFilter = "";
private string _themeFilter = "";
private List<GhostImage> _allImages = new();
@ -507,8 +535,8 @@ else
// Ghost-Benutzer laden
_ghostUsers = await GhostApi.GetUsersAsync(instance);
var filter = string.IsNullOrWhiteSpace(_searchTerm) ? "" : $"title:~'{EscapeFilter(_searchTerm)}'";
_allImages = await ImageAnalyzer.AnalyzeImagesAsync(instance, filter);
_allImages = await ImageAnalyzer.AnalyzeImagesAsync(instance);
_warningMessage = ImageAnalyzer.LastWarning;
CalculateCounts();
ApplyFilters();
_hasLoaded = true;
@ -531,11 +559,12 @@ else
_featureCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsFeatureImage));
_contentCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsInContent));
_categoryCount = _allImages.Count(i => !string.IsNullOrEmpty(i.Category));
_originalCount = _allImages.Count(i => i.IsOriginal);
}
private List<GhostImage> FilteredImages => _filteredImages;
private List<GhostImage> SelectedOrphanedImages => _filteredImages.Where(i => _selectedImageUrls.Contains(i.Url) && i.IsOrphaned).ToList();
private List<GhostImage> SelectedDeletableImages => _filteredImages.Where(i => _selectedImageUrls.Contains(i.Url) && i.CanDelete).ToList();
private List<KeyValuePair<string, string>> AvailableAuthors => _allImages
.Where(i => !string.IsNullOrEmpty(i.AuthorId))
@ -572,6 +601,23 @@ else
query = query.Where(i => i.UsedInPosts.Any(r => r.IsFeatureImage));
else if (_currentFilter == ImageFilter.Content)
query = query.Where(i => i.UsedInPosts.Any(r => r.IsInContent));
else if (_currentFilter == ImageFilter.Original)
query = query.Where(i => i.IsOriginal);
else if (_currentFilter == ImageFilter.Category)
query = query.Where(i => !string.IsNullOrEmpty(i.Category));
if (!string.IsNullOrWhiteSpace(_nameSearch))
{
var name = _nameSearch.Trim();
query = query.Where(i => i.FileName.Contains(name, StringComparison.OrdinalIgnoreCase)
|| i.RelativePath.Contains(name, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(_searchTerm))
{
var title = _searchTerm.Trim();
query = query.Where(i => i.UsedInPosts.Any(r => r.Title.Contains(title, StringComparison.OrdinalIgnoreCase)));
}
if (!string.IsNullOrWhiteSpace(_authorFilter))
query = query.Where(i => i.AuthorId == _authorFilter);
@ -592,6 +638,7 @@ else
_themeFilter = "";
_categoryFilter = "";
_searchTerm = "";
_nameSearch = "";
_currentFilter = ImageFilter.All;
ApplyFilters();
}
@ -615,20 +662,30 @@ else
_selectedImageUrls.Remove(image.Url);
_selectAllOrphaned = false;
_selectAllOriginals = false;
StateHasChanged();
}
// Wirkt auf die aktuell angezeigten (gefilterten) Bilder
private void ToggleSelectAllOrphaned(ChangeEventArgs e)
{
_selectAllOrphaned = e.Value is bool b && b;
if (_selectAllOrphaned)
{
foreach (var image in _allImages.Where(i => i.IsOrphaned))
_selectedImageUrls.Add(image.Url);
SetSelection(_filteredImages.Where(i => i.IsOrphaned), _selectAllOrphaned);
}
else
private void ToggleSelectAllOriginals(ChangeEventArgs e)
{
foreach (var image in _allImages.Where(i => i.IsOrphaned))
_selectAllOriginals = e.Value is bool b && b;
SetSelection(_filteredImages.Where(i => i.IsOriginal), _selectAllOriginals);
}
private void SetSelection(IEnumerable<GhostImage> images, bool selected)
{
foreach (var image in images)
{
if (selected)
_selectedImageUrls.Add(image.Url);
else
_selectedImageUrls.Remove(image.Url);
}
StateHasChanged();
@ -674,24 +731,39 @@ else
_showBulkDeleteDialog = false;
}
private void DeleteSelectedOrphaned()
private void DeleteSelected()
{
if (SelectedOrphanedImages.Any())
if (SelectedDeletableImages.Any())
ShowBulkDeleteDialog();
}
private async Task DeleteSelectedOrphanedConfirmed()
private async Task DeleteSelectedConfirmed()
{
_isDeleting = true;
StateHasChanged();
try
{
var result = await DeletionService.DeleteImagesAsync(SelectedOrphanedImages);
int success = 0, failed = 0;
foreach (var image in SelectedDeletableImages)
{
if (await DeletionService.DeleteImageAsync(image))
{
success++;
if (image.ManagedImageId.HasValue)
await DbService.DeleteAsync(image.ManagedImageId.Value);
}
else
{
failed++;
}
}
_selectedImageUrls.Clear();
_selectAllOrphaned = false;
_successMessage = $"{result.Success} Bilder gelöscht, {result.Failed} fehlgeschlagen.";
_selectAllOriginals = false;
await LoadImages();
_successMessage = $"{success} Dateien gelöscht, {failed} fehlgeschlagen.";
}
catch (Exception ex)
{
@ -717,9 +789,10 @@ else
_selectedImageUrls.Remove(_selectedImage.Url);
if (_selectedImage.ManagedImageId.HasValue)
await DbService.DeleteAsync(_selectedImage.ManagedImageId.Value);
_successMessage = $"{_selectedImage.FileName} gelöscht.";
var deletedName = _selectedImage.FileName;
CloseDeleteDialog();
await LoadImages();
_successMessage = $"{deletedName} gelöscht.";
}
else
{
@ -873,17 +946,13 @@ else
await JsRuntime.InvokeVoidAsync("downloadFileFromBase64", fileName, base64, contentType);
}
private static string EscapeFilter(string term)
{
return term.Replace("'", "\\'");
}
private enum ImageFilter
{
All,
Feature,
Content,
Orphaned,
Original,
Category
}
}

View file

@ -14,10 +14,18 @@ public class GhostImage
public bool IsOrphaned { get; set; } = true;
public List<PostReference> UsedInPosts { get; set; } = new();
// Ghost legt beim Upload neben "bild.jpg" die unkomprimierte Kopie "bild_o.jpg" ab.
// Diese wird nie in Posts referenziert; OriginalOf enthält den Dateinamen des zugehörigen Bildes.
public bool IsOriginal { get; set; }
public string? OriginalOf { get; set; }
public bool CanDelete => IsOrphaned || IsOriginal;
// Aus der Datenbank
public string? AuthorId { get; set; }
public string? AuthorName { get; set; }
public string? Theme { get; set; }
public string? Category { get; set; }
public DateTime? CaptureDate { get; set; }
// EXIF

View file

@ -6,6 +6,9 @@ public class GhostPost
public string Title { get; set; } = "";
public string Slug { get; set; } = "";
public string Html { get; set; } = "";
public string Lexical { get; set; } = "";
// og_image, twitter_image, Code-Injection – nur für die Suche nach Bild-Referenzen
public string OtherImageFields { get; set; } = "";
public string? FeatureImage { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }

View file

@ -77,6 +77,20 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
// EnsureCreated ergänzt keine Spalten in einer bestehenden DB – später hinzugekommene nachziehen
var existingColumns = db.Database.SqlQueryRaw<string>("SELECT name AS Value FROM pragma_table_info('ManagedImages')").ToList();
foreach (var (column, type) in new[]
{
("Category", "TEXT NULL"),
("CategorySecondary", "TEXT NULL"),
("HasMultipleCategories", "INTEGER NOT NULL DEFAULT 0"),
("Keywords", "TEXT NULL")
})
{
if (!existingColumns.Contains(column, StringComparer.OrdinalIgnoreCase))
db.Database.ExecuteSqlRaw($"ALTER TABLE ManagedImages ADD COLUMN {column} {type}");
}
}
// Configure the HTTP request pipeline.

View file

@ -15,10 +15,18 @@ public class GhostApiService
_logger = logger;
}
public async Task<List<GhostPost>> GetPostsAsync(GhostInstance instance, string filter = "", int limit = 100)
/// <summary>
/// Lädt alle Einträge einer Ressource ("posts" oder "pages") – seitenweise, bis nichts mehr kommt.
/// </summary>
public async Task<List<GhostPost>> GetPostsAsync(GhostInstance instance, string filter = "", int limit = 50, string resource = "posts")
{
var endpoint = $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&formats=html,plaintext&fields=id,title,slug,feature_image,html,created_at,updated_at,status";
_logger.LogInformation("Hole Posts von {Url} (Filter: {Filter}, Limit: {Limit})", instance.Url, filter, limit);
_logger.LogInformation("Hole {Resource} von {Url} (Filter: {Filter}, Limit: {Limit})", resource, instance.Url, filter, limit);
var posts = new List<GhostPost>();
for (var page = 1; ; page++)
{
var endpoint = $"{resource}/?limit={limit}&page={page}&filter={Uri.EscapeDataString(filter)}&formats=html,lexical" +
"&fields=id,title,slug,feature_image,og_image,twitter_image,codeinjection_head,codeinjection_foot,html,lexical,created_at,updated_at,status";
var request = CreateRequest(HttpMethod.Get, instance, endpoint);
var response = await _httpClient.SendAsync(request);
@ -26,16 +34,16 @@ public class GhostApiService
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("Ghost API Fehler beim Laden der Posts: {StatusCode} - {Error}", response.StatusCode, errorContent);
_logger.LogError("Ghost API Fehler beim Laden von {Resource}: {StatusCode} - {Error}", resource, response.StatusCode, errorContent);
response.EnsureSuccessStatusCode();
}
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
var posts = new List<GhostPost>();
if (doc.RootElement.TryGetProperty("posts", out var postsElement))
{
if (!doc.RootElement.TryGetProperty(resource, out var postsElement) || postsElement.GetArrayLength() == 0)
break;
foreach (var post in postsElement.EnumerateArray())
{
posts.Add(new GhostPost
@ -44,18 +52,72 @@ public class GhostApiService
Title = GetString(post, "title"),
Slug = GetString(post, "slug"),
Html = GetString(post, "html"),
Lexical = GetString(post, "lexical"),
OtherImageFields = string.Join("\n",
GetString(post, "og_image"),
GetString(post, "twitter_image"),
GetString(post, "codeinjection_head"),
GetString(post, "codeinjection_foot")),
FeatureImage = GetString(post, "feature_image"),
CreatedAt = GetDateTime(post, "created_at"),
UpdatedAt = GetDateTime(post, "updated_at"),
Status = GetString(post, "status")
});
}
var totalPages = 1;
if (doc.RootElement.TryGetProperty("meta", out var meta) &&
meta.TryGetProperty("pagination", out var pagination) &&
pagination.TryGetProperty("pages", out var pagesElement))
totalPages = pagesElement.GetInt32();
if (page >= totalPages)
break;
}
_logger.LogInformation("{Count} Posts von Ghost geladen", posts.Count);
_logger.LogInformation("{Count} {Resource} von Ghost geladen", posts.Count, resource);
return posts;
}
/// <summary>
/// Bildquellen außerhalb von Posts/Seiten: Tags, Benutzer und Website-Einstellungen (Logo, Icon, Cover ...).
/// Liefert je Quelle eine Bezeichnung und das rohe JSON, in dem nach Bild-URLs gesucht wird.
/// </summary>
public async Task<List<(string Label, string Json)>> GetOtherImageSourcesAsync(GhostInstance instance)
{
var sources = new List<(string Label, string Json)>();
foreach (var (resource, prefix) in new[] { ("tags", "Tag"), ("users", "Benutzer") })
{
var doc = await GetJsonAsync(instance, $"{resource}/?limit=all");
if (doc != null && doc.RootElement.TryGetProperty(resource, out var items))
{
foreach (var item in items.EnumerateArray())
sources.Add(($"{prefix}: {GetString(item, "name")}", item.GetRawText()));
}
}
var settings = await GetJsonAsync(instance, "settings/");
if (settings != null)
sources.Add(("Website-Einstellungen", settings.RootElement.GetRawText()));
return sources;
}
private async Task<JsonDocument?> GetJsonAsync(GhostInstance instance, string endpoint)
{
var request = CreateRequest(HttpMethod.Get, instance, endpoint);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Ghost API: {Endpoint} nicht lesbar ({StatusCode}) – Bilder daraus werden nicht als verwendet erkannt", endpoint, response.StatusCode);
return null;
}
return JsonDocument.Parse(await response.Content.ReadAsStringAsync());
}
public async Task<GhostPost?> GetPostAsync(GhostInstance instance, string postId)
{
_logger.LogInformation("Lade Post {PostId} von {Url}", postId, instance.Url);

View file

@ -24,71 +24,75 @@ public class ImageAnalyzerService
_logger = logger;
}
public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance, string searchTerm = "")
// Hinweis der letzten Analyse (z.B. Bildordner passt nicht zur Ghost-Instanz)
public string? LastWarning { get; private set; }
// Findet Bildpfade unterhalb von /content/images/ in HTML, Lexical-JSON und API-Feldern.
// Größen-Varianten (size/w600/..., size/w600/format/webp/...) werden auf das Ursprungsbild zurückgeführt.
private static readonly Regex ContentImageRegex = new(
@"content\\?/images\\?/(?:size\\?/w\d+\\?/(?:format\\?/\w+\\?/)?)?(?<path>[^""'\s\\()<>?#,]+(?:\\?/[^""'\s\\()<>?#,]+)*)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance)
{
var images = new List<GhostImage>();
LastWarning = null;
_logger.LogInformation("Starte Bildanalyse für Instanz {InstanceName} ({Url}), Ordner: {Folder}", instance.Name, instance.Url, instance.ImageFolderPath);
if (!Directory.Exists(instance.ImageFolderPath))
{
_logger.LogError("Image folder not found: {Path}", instance.ImageFolderPath);
LastWarning = $"Bildordner nicht gefunden: {instance.ImageFolderPath}";
return images;
}
// 1. Alle Posts abrufen
_logger.LogInformation("Rufe Posts mit Filter '{Filter}' ab", searchTerm);
var posts = await _ghostApiService.GetPostsAsync(instance, searchTerm);
_logger.LogInformation("{Count} Posts geladen, extrahiere Bild-Referenzen", posts.Count);
// 1. Immer ALLE Posts und Seiten abrufen – sonst gelten Bilder aus nicht geladenen Posts als verwaist
var posts = await _ghostApiService.GetPostsAsync(instance);
posts.AddRange(await _ghostApiService.GetPostsAsync(instance, resource: "pages"));
_logger.LogInformation("{Count} Posts/Seiten geladen, extrahiere Bild-Referenzen", posts.Count);
// 2. Bild-Referenzen aus Posts sammeln
// 2. Bild-Referenzen sammeln. Schlüssel: relativer Pfad unter content/images (Ghost-Bilder)
// bzw. die vollständige URL (externe Bilder)
var imageReferences = new Dictionary<string, List<PostReference>>(StringComparer.OrdinalIgnoreCase);
PostReference GetReference(string key, GhostPost post)
{
if (!imageReferences.TryGetValue(key, out var list))
imageReferences[key] = list = new List<PostReference>();
var reference = list.FirstOrDefault(r => r.PostId == post.Id);
if (reference == null)
{
reference = new PostReference { PostId = post.Id, Title = post.Title, Slug = post.Slug };
list.Add(reference);
}
return reference;
}
foreach (var post in posts)
{
// Feature Image
if (!string.IsNullOrEmpty(post.FeatureImage))
{
if (!imageReferences.ContainsKey(post.FeatureImage))
imageReferences[post.FeatureImage] = new List<PostReference>();
GetReference(GetReferenceKey(post.FeatureImage), post).IsFeatureImage = true;
imageReferences[post.FeatureImage].Add(new PostReference
// Ghost-Bilder im Inhalt: HTML (src, srcset, Hintergrundbilder), Lexical (Galerien, Karten), og/twitter
foreach (var path in ExtractContentImagePaths($"{post.Html}\n{post.Lexical}\n{post.OtherImageFields}"))
GetReference(path, post).IsInContent = true;
// Externe Bilder im HTML
foreach (var imgUrl in ExtractImageUrls(post.Html))
{
PostId = post.Id,
Title = post.Title,
Slug = post.Slug,
IsFeatureImage = true,
IsInContent = false
});
if (!ContentImageRegex.IsMatch(imgUrl))
GetReference(imgUrl, post).IsInContent = true;
}
}
// Bilder im HTML
if (!string.IsNullOrEmpty(post.Html))
// Tags, Benutzer und Website-Einstellungen (Logo, Icon, Cover ...)
foreach (var (label, json) in await _ghostApiService.GetOtherImageSourcesAsync(instance))
{
var htmlImages = ExtractImageUrls(post.Html);
foreach (var imgUrl in htmlImages)
{
if (!imageReferences.ContainsKey(imgUrl))
imageReferences[imgUrl] = new List<PostReference>();
// Prüfen ob bereits als Feature Image vorhanden
var existing = imageReferences[imgUrl].FirstOrDefault(r => r.PostId == post.Id);
if (existing != null)
{
existing.IsInContent = true;
}
else
{
imageReferences[imgUrl].Add(new PostReference
{
PostId = post.Id,
Title = post.Title,
Slug = post.Slug,
IsFeatureImage = false,
IsInContent = true
});
}
}
}
var source = new GhostPost { Id = label, Title = label };
foreach (var path in ExtractContentImagePaths(json))
GetReference(path, source);
}
// 3. Lokale Dateien scannen
@ -96,11 +100,24 @@ public class ImageAnalyzerService
var localFiles = ScanLocalImageFiles(instance.ImageFolderPath);
_logger.LogInformation("{Count} lokale Bilddateien gefunden", localFiles.Count);
var localPaths = localFiles
.Select(f => GetRelativePath(instance.ImageFolderPath, f.FullName))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var ghostReferenceCount = imageReferences.Keys.Count(k => !IsExternalUrl(k));
if (ghostReferenceCount > 0 && localFiles.Count > 0 && !imageReferences.Keys.Any(localPaths.Contains))
{
LastWarning = $"Keines der {ghostReferenceCount} in Ghost verwendeten Bilder liegt im Ordner {instance.ImageFolderPath}. " +
"Der Ordner ist vermutlich nicht (mehr) der content/images-Ordner dieser Ghost-Instanz – alle lokalen Dateien erscheinen deshalb als verwaist.";
_logger.LogWarning("{Warning}", LastWarning);
}
// 4. GhostImage-Objekte erstellen und mit DB synchronisieren
foreach (var file in localFiles)
{
var absoluteUrl = ConvertLocalPathToUrl(instance, file.FullName);
var relativePath = GetRelativePath(instance.ImageFolderPath, file.FullName);
imageReferences.TryGetValue(relativePath, out var refs);
var image = new GhostImage
{
@ -110,10 +127,19 @@ public class ImageAnalyzerService
Url = absoluteUrl,
FileSize = file.Length,
LastModified = file.LastWriteTime,
UsedInPosts = imageReferences.TryGetValue(absoluteUrl, out var refs) ? refs : new List<PostReference>(),
IsOrphaned = !imageReferences.ContainsKey(absoluteUrl)
UsedInPosts = refs ?? new List<PostReference>(),
IsOrphaned = refs == null
};
// "bild_o.jpg" ist Ghosts Originalkopie von "bild.jpg": zählt als verwendet, solange das Bild verwendet wird
var basePath = GetOriginalBasePath(relativePath);
if (refs == null && basePath != null && localPaths.Contains(basePath))
{
image.IsOriginal = true;
image.OriginalOf = Path.GetFileName(basePath);
image.IsOrphaned = !imageReferences.ContainsKey(basePath);
}
// Dimensionen ermitteln
GetImageDimensions(image);
@ -133,18 +159,21 @@ public class ImageAnalyzerService
}
// Auch URLs aus Posts, die lokal nicht existieren, hinzufügen (z.B. externe Bilder)
foreach (var url in imageReferences.Keys)
foreach (var key in imageReferences.Keys)
{
if (localPaths.Contains(key))
continue;
var url = IsExternalUrl(key) ? key : $"{instance.Url.TrimEnd('/')}/content/images/{key}";
var localPath = ConvertUrlToLocalPath(instance, url);
if (!File.Exists(localPath) && !images.Any(i => i.Url.Equals(url, StringComparison.OrdinalIgnoreCase)))
{
var externalImage = new GhostImage
{
Url = url,
FileName = Path.GetFileName(url),
RelativePath = GetRelativePath(instance.ImageFolderPath, localPath),
FilePath = localPath,
UsedInPosts = imageReferences[url],
UsedInPosts = imageReferences[key],
IsOrphaned = false
};
@ -153,7 +182,6 @@ public class ImageAnalyzerService
images.Add(externalImage);
}
}
var orphanedCount = images.Count(i => i.IsOrphaned);
_logger.LogInformation("Bildanalyse abgeschlossen: {Total} Bilder, {Orphaned} verwaist, {Used} verwendet", images.Count, orphanedCount, images.Count - orphanedCount);
@ -179,6 +207,7 @@ public class ImageAnalyzerService
image.AuthorId = managedImage.AuthorId;
image.AuthorName = managedImage.AuthorName;
image.Theme = managedImage.Theme;
image.Category = managedImage.Category;
// CaptureDate aus DB hat Priorität, falls manuell überschrieben
if (managedImage.CaptureDate.HasValue)
@ -232,6 +261,35 @@ public class ImageAnalyzerService
return urls.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
private static List<string> ExtractContentImagePaths(string text)
{
return ContentImageRegex.Matches(text)
.Select(m => Uri.UnescapeDataString(m.Groups["path"].Value.Replace("\\/", "/")))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
// Ghost-Bild -> relativer Pfad, alles andere -> URL unverändert
private static string GetReferenceKey(string url)
{
return ExtractContentImagePaths(url).FirstOrDefault() ?? url;
}
private static bool IsExternalUrl(string key)
{
return key.Contains("://") || key.StartsWith("//") || key.StartsWith("data:", StringComparison.OrdinalIgnoreCase);
}
// "2026/09/bild_o.jpg" -> "2026/09/bild.jpg"; null, wenn der Name nicht auf _o endet
private static string? GetOriginalBasePath(string relativePath)
{
var extension = Path.GetExtension(relativePath);
var withoutExtension = relativePath[..^extension.Length];
return withoutExtension.EndsWith("_o", StringComparison.OrdinalIgnoreCase)
? withoutExtension[..^2] + extension
: null;
}
private string ConvertLocalPathToUrl(GhostInstance instance, string localPath)
{
var relativePath = GetRelativePath(instance.ImageFolderPath, localPath);

View file

@ -14,23 +14,43 @@ public class ImageDeletionService
public Task<bool> DeleteImageAsync(GhostImage image)
{
_logger.LogInformation("Lösche Bild: {FileName} ({FilePath})", image.FileName, image.FilePath);
return Task.FromResult(DeleteFile(image.FilePath));
var deleted = DeleteFile(image.FilePath);
if (deleted)
DeleteSizeVariants(image);
return Task.FromResult(deleted);
}
public Task<(int Success, int Failed)> DeleteImagesAsync(IEnumerable<GhostImage> images)
// Von Ghost erzeugte Varianten unter size/w600/..., size/w600/format/webp/... mit entfernen
private void DeleteSizeVariants(GhostImage image)
{
int success = 0;
int failed = 0;
var relativePath = image.RelativePath.Replace('/', Path.DirectorySeparatorChar);
if (!image.FilePath.EndsWith(relativePath, StringComparison.OrdinalIgnoreCase))
return;
foreach (var image in images)
var sizeRoot = Path.Combine(image.FilePath[..^relativePath.Length], "size");
if (!Directory.Exists(sizeRoot))
return;
try
{
if (DeleteFile(image.FilePath))
success++;
else
failed++;
foreach (var widthDir in Directory.EnumerateDirectories(sizeRoot))
{
var candidates = new List<string> { Path.Combine(widthDir, relativePath) };
var formatRoot = Path.Combine(widthDir, "format");
if (Directory.Exists(formatRoot))
candidates.AddRange(Directory.EnumerateDirectories(formatRoot).Select(d => Path.Combine(d, relativePath)));
foreach (var variant in candidates.Where(File.Exists))
{
File.Delete(variant);
_logger.LogInformation("Größen-Variante gelöscht: {Path}", variant);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Größen-Varianten von {File} konnten nicht vollständig gelöscht werden", image.FileName);
}
return Task.FromResult((success, failed));
}
private bool DeleteFile(string filePath)

BIN
GhostTheme/fernweh.zip Normal file

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,78 @@
(function () {
'use strict';
// Farbschema umschalten (hell/dunkel) und merken
var toggle = document.querySelector('.theme-toggle');
if (toggle) {
toggle.addEventListener('click', function () {
var dark = document.documentElement.getAttribute('data-theme') !== 'dark';
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
try { localStorage.setItem('theme', dark ? 'dark' : 'light'); } catch (e) {}
});
}
// Menü auf kleinen Bildschirmen auf-/zuklappen
var navToggle = document.querySelector('.nav-toggle');
var nav = document.getElementById('site-nav');
if (navToggle && nav) {
navToggle.addEventListener('click', function () {
var open = nav.classList.toggle('is-open');
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
// Aufklappmenüs per Klick/Tippen auf den Pfeil (mit der Maus öffnen sie auch beim Überfahren)
var submenuToggles = document.querySelectorAll('.nav-submenu-toggle');
var closeSubmenus = function (except) {
Array.prototype.forEach.call(document.querySelectorAll('.nav-has-children.is-open'), function (item) {
// Der Eintrag selbst und seine Oberpunkte bleiben offen
if (except && (item === except || item.contains(except))) return;
item.classList.remove('is-open');
item.querySelector(':scope > .nav-submenu-toggle').setAttribute('aria-expanded', 'false');
});
};
Array.prototype.forEach.call(submenuToggles, function (button) {
button.addEventListener('click', function (event) {
event.stopPropagation();
var item = button.parentNode;
closeSubmenus(item);
var open = item.classList.toggle('is-open');
button.setAttribute('aria-expanded', open ? 'true' : 'false');
});
});
document.addEventListener('click', function (event) {
if (!event.target.closest('.nav-has-children')) closeSubmenus(null);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') closeSubmenus(null);
});
// Bildanzeige: Klick auf ein Bild im Beitrag zeigt es groß
var content = document.querySelector('.gh-content');
if (content) {
var lightbox = document.createElement('div');
lightbox.className = 'lightbox';
lightbox.hidden = true;
lightbox.innerHTML = '<button class="lightbox__close" type="button" aria-label="Close">&times;</button><img alt="">';
document.body.appendChild(lightbox);
var lightboxImage = lightbox.querySelector('img');
var close = function () {
lightbox.hidden = true;
lightboxImage.removeAttribute('src');
};
content.addEventListener('click', function (event) {
var img = event.target.closest('.kg-image-card img, .kg-gallery-image img');
// Bilder, die selbst ein Link sind, behalten ihr Linkziel
if (!img || img.closest('a')) return;
lightboxImage.src = img.currentSrc || img.src;
lightboxImage.alt = img.alt || '';
lightbox.hidden = false;
});
lightbox.addEventListener('click', close);
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && !lightbox.hidden) close();
});
}
})();

View file

@ -0,0 +1,24 @@
{{!< default}}
{{!-- Übersicht eines Autors --}}
<main class="site-main container">
{{#author}}
<header class="archive-header">
{{#if profile_image}}
<img class="archive-header__portrait" src="{{img_url profile_image size="xs"}}" alt="{{name}}">
{{/if}}
<span class="label">{{t "Author"}}</span>
<h1 class="archive-header__title">{{name}}</h1>
{{#if bio}}<p class="archive-header__text">{{bio}}</p>{{/if}}
</header>
{{/author}}
<div class="post-grid">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
{{pagination}}
</main>

View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="{{@site.locale}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{meta_title}}</title>
{{!-- Gewähltes Farbschema vor dem ersten Zeichnen setzen, damit nichts aufblitzt --}}
<script>
(function () {
try {
var stored = localStorage.getItem('theme');
var dark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="{{asset "css/screen.css"}}">
{{ghost_head}}
</head>
<body class="{{body_class}}">
{{> "site-header"}}
{{{body}}}
{{> "site-footer"}}
<script src="{{asset "js/main.js"}}" defer></script>
{{ghost_foot}}
</body>
</html>

View file

@ -0,0 +1,9 @@
{{!< default}}
{{!-- Fehlerseite (z. B. 404) --}}
<main class="site-main container">
<section class="error-page">
<p class="error-page__code">{{statusCode}}</p>
<p class="error-page__message">{{message}}</p>
<a class="btn" href="{{@site.url}}">{{t "Back to the homepage"}}</a>
</section>
</main>

View file

@ -0,0 +1,60 @@
{{!< default}}
{{!--
Beitragsübersicht. Auf der Startseite (Seite 1) steht oben der Aufmacher: der neueste
Beitrag groß in der Mitte, der zweite und dritte schmal links und rechts daneben.
Darunter folgen die Themenblöcke und die übrigen Beiträge. Ab Seite 2 nur die Beiträge.
--}}
<main class="site-main container with-sidebar">
<div class="with-sidebar__content">
{{#is "home"}}
<section class="hero">
{{#foreach posts from="1" to="1"}}
<div class="hero__lead">{{> "post-card" eager=true sizes="(max-width: 900px) 100vw, 620px"}}</div>
{{/foreach}}
{{#foreach posts from="2" to="3"}}
<div class="hero__side">{{> "post-card" eager=true sizes="(max-width: 900px) 50vw, 200px"}}</div>
{{/foreach}}
</section>
{{#if @custom.home_tags}}
{{#get "tags" filter=(concat "slug:[" @custom.home_tags "]") limit="6"}}
{{#foreach tags}}{{> "tag-section"}}{{/foreach}}
{{/get}}
{{else}}
{{#get "tags" limit="3" include="count.posts" order="count.posts desc" filter="visibility:public"}}
{{#foreach tags}}{{> "tag-section"}}{{/foreach}}
{{/get}}
{{/if}}
<section class="section">
<header class="section__header">
<h2 class="section__title">{{t "Latest posts"}}</h2>
</header>
<div class="post-grid">
{{#foreach posts from="4"}}
{{> "post-card"}}
{{/foreach}}
</div>
</section>
{{else}}
<section class="section section--first">
<header class="section__header">
<h1 class="section__title">{{t "Latest posts"}}</h1>
</header>
<div class="post-grid">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
</section>
{{/is}}
{{pagination}}
</div>
{{> "sidebar"}}
</main>

View file

@ -0,0 +1,31 @@
{
"Newer Posts": "Neuere Beiträge",
"Older Posts": "Ältere Beiträge",
"Newer Post": "Neuerer Beitrag",
"Older Post": "Älterer Beitrag",
"More posts": "Weitere Beiträge",
"Keep reading": "Weiterlesen",
"Latest posts": "Neueste Beiträge",
"Featured": "Empfohlen",
"Topics": "Themen",
"Topic": "Thema",
"Author": "Autor",
"About us": "Über uns",
"View all": "Alle ansehen",
"By": "Von",
"Page {page} of {pages}": "Seite {page} von {pages}",
"Pagination": "Seitenwechsel",
"Menu": "Menü",
"Footer": "Fußzeile",
"Search": "Suche",
"Subscribe": "Abonnieren",
"Account": "Konto",
"Light": "Hell",
"Dark": "Dunkel",
"Toggle dark mode": "Dunkles Farbschema umschalten",
"1 min read": "1 Min. Lesezeit",
"% min read": "% Min. Lesezeit",
"Powered by": "Betrieben mit",
"Back to the homepage": "Zur Startseite",
"Close": "Schließen"
}

View file

@ -0,0 +1,31 @@
{
"Newer Posts": "Newer Posts",
"Older Posts": "Older Posts",
"Newer Post": "Newer Post",
"Older Post": "Older Post",
"More posts": "More posts",
"Keep reading": "Keep reading",
"Latest posts": "Latest posts",
"Featured": "Featured",
"Topics": "Topics",
"Topic": "Topic",
"Author": "Author",
"About us": "About us",
"View all": "View all",
"By": "By",
"Page {page} of {pages}": "Page {page} of {pages}",
"Pagination": "Pagination",
"Menu": "Menu",
"Footer": "Footer",
"Search": "Search",
"Subscribe": "Subscribe",
"Account": "Account",
"Light": "Light",
"Dark": "Dark",
"Toggle dark mode": "Toggle dark mode",
"1 min read": "1 min read",
"% min read": "% min read",
"Powered by": "Powered by",
"Back to the homepage": "Back to the homepage",
"Close": "Close"
}

View file

@ -0,0 +1,78 @@
{
"name": "fernweh",
"description": "Minimalistisches Reiseblog-Theme im Magazinstil: großer Aufmacher, Themenblöcke und Seitenleiste",
"version": "1.0.0",
"license": "MIT",
"engines": {
"ghost": ">=5.0.0"
},
"author": {
"name": "Roland Brunner",
"email": "roland.brunner@gmx.at"
},
"keywords": [
"ghost",
"theme",
"ghost-theme"
],
"config": {
"posts_per_page": 12,
"image_sizes": {
"xs": { "width": 160 },
"s": { "width": 400 },
"m": { "width": 750 },
"l": { "width": 1140 },
"xl": { "width": 1920 }
},
"card_assets": true,
"custom": {
"home_tags": {
"type": "text",
"group": "homepage",
"description": "Themenblöcke: Tag-Slugs mit Komma, ohne Leerzeichen (leer = die drei größten Themen)"
},
"about_image": {
"type": "image",
"group": "homepage",
"description": "Rundes Bild über dem Kasten „Über uns“ in der Seitenleiste"
},
"about_text": {
"type": "text",
"group": "homepage",
"description": "Kurzer Text im Kasten „Über uns“"
},
"about_url": {
"type": "text",
"group": "homepage",
"description": "Adresse der „Über uns“-Seite"
},
"banner_image": {
"type": "image",
"group": "homepage",
"description": "Bild des Banners in der Seitenleiste"
},
"banner_title": {
"type": "text",
"group": "homepage",
"description": "Schriftzug auf dem Banner"
},
"banner_url": {
"type": "text",
"group": "homepage",
"description": "Linkziel des Banners"
},
"instagram_url": {
"type": "text",
"description": "Instagram-Profil (vollständige Adresse)"
},
"youtube_url": {
"type": "text",
"description": "YouTube-Kanal (vollständige Adresse)"
},
"footer_text": {
"type": "text",
"description": "Zusätzlicher Text in der Fußzeile"
}
}
}
}

View file

@ -0,0 +1,35 @@
{{!< default}}
{{!-- Statische Seite --}}
{{#post}}
<main class="site-main">
<article class="article {{post_class}}">
{{#match @page.show_title_and_feature_image}}
<header class="article__header container container--narrow">
<h1 class="article__title">{{title}}</h1>
{{#if custom_excerpt}}
<p class="article__excerpt">{{custom_excerpt}}</p>
{{/if}}
</header>
{{#if feature_image}}
<figure class="article__image container">
<img
srcset="{{img_url feature_image size="m"}} 750w,
{{img_url feature_image size="l"}} 1140w,
{{img_url feature_image size="xl"}} 1920w"
sizes="(max-width: 1240px) 100vw, 1200px"
src="{{img_url feature_image size="l"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}">
{{#if feature_image_caption}}<figcaption>{{feature_image_caption}}</figcaption>{{/if}}
</figure>
{{/if}}
{{/match}}
<section class="gh-content">
{{content}}
</section>
</article>
</main>
{{/post}}

View file

@ -0,0 +1,16 @@
{{!-- Seitenwechsel der Beitragsübersicht --}}
<nav class="pagination" aria-label="{{t "Pagination"}}">
{{#if prev}}
<a class="pagination__link" href="{{page_url prev}}">&larr; {{t "Newer Posts"}}</a>
{{else}}
<span></span>
{{/if}}
<span class="meta pagination__info">{{t "Page {page} of {pages}" page=page pages=pages}}</span>
{{#if next}}
<a class="pagination__link pagination__link--older" href="{{page_url next}}">{{t "Older Posts"}} &rarr;</a>
{{else}}
<span></span>
{{/if}}
</nav>

View file

@ -0,0 +1,26 @@
{{!--
Beitragskarte. Welche Teile sichtbar sind (Thema, Auszug, Autor, Datum), bestimmt
der umgebende Block in screen.css.
Parameter: sizes = sizes-Angabe des Bildes, eager = Bild sofort laden (Aufmacher)
--}}
<article class="card {{post_class}}">
{{#if feature_image}}
<a class="card__image" href="{{url}}" tabindex="-1" aria-hidden="true">
<img
{{#unless eager}}loading="lazy"{{/unless}}
srcset="{{img_url feature_image size="s"}} 400w,
{{img_url feature_image size="m"}} 750w,
{{img_url feature_image size="l"}} 1140w"
sizes="{{#if sizes}}{{sizes}}{{else}}(max-width: 640px) 100vw, 400px{{/if}}"
src="{{img_url feature_image size="m"}}"
alt="">
</a>
{{/if}}
<div class="card__body">
{{#primary_tag}}<a class="label card__tag" href="{{url}}">{{name}}</a>{{/primary_tag}}
<h2 class="card__title"><a href="{{url}}">{{title}}</a></h2>
{{#if excerpt}}<p class="card__excerpt">{{excerpt words="30"}}</p>{{/if}}
{{#primary_author}}<p class="meta card__author">{{t "By"}} {{name}}</p>{{/primary_author}}
<time class="meta card__date" datetime="{{date format="YYYY-MM-DD"}}">{{date format="D. MMMM YYYY"}}</time>
</div>
</article>

View file

@ -0,0 +1,16 @@
{{!-- Kurze Beitragsliste der Seitenleiste: Titel und Datum links, kleines Bild rechts --}}
<ul class="mini-list">
{{#foreach posts}}
<li class="mini-post">
<div class="mini-post__text">
<a class="mini-post__title" href="{{url}}">{{title}}</a>
<time class="meta" datetime="{{date format="YYYY-MM-DD"}}">{{date format="D. MMMM YYYY"}}</time>
</div>
{{#if feature_image}}
<a class="mini-post__image" href="{{url}}" tabindex="-1" aria-hidden="true">
<img loading="lazy" src="{{img_url feature_image size="xs"}}" alt="">
</a>
{{/if}}
</li>
{{/foreach}}
</ul>

View file

@ -0,0 +1,54 @@
{{!-- Seitenleiste der Beitragsübersicht: Über uns, Banner, empfohlene Beiträge, Themen --}}
<aside class="sidebar">
{{#if @custom.about_image}}
<section class="widget widget--about">
{{#if @custom.about_url}}<a href="{{@custom.about_url}}">{{/if}}
<img class="widget__portrait" loading="lazy" src="{{img_url @custom.about_image size="s"}}" alt="{{t "About us"}}">
{{#if @custom.about_url}}</a>{{/if}}
<h2 class="widget__title">{{#if @custom.about_url}}<a href="{{@custom.about_url}}">{{t "About us"}}</a>{{else}}{{t "About us"}}{{/if}}</h2>
{{#if @custom.about_text}}<p class="widget__text">{{@custom.about_text}}</p>{{/if}}
</section>
{{else if @custom.about_text}}
<section class="widget widget--about">
<h2 class="widget__title">{{#if @custom.about_url}}<a href="{{@custom.about_url}}">{{t "About us"}}</a>{{else}}{{t "About us"}}{{/if}}</h2>
<p class="widget__text">{{@custom.about_text}}</p>
</section>
{{/if}}
{{#if @custom.banner_image}}
<section class="widget">
<a class="banner" {{#if @custom.banner_url}}href="{{@custom.banner_url}}"{{/if}}>
<img loading="lazy" src="{{img_url @custom.banner_image size="s"}}" alt="{{@custom.banner_title}}">
{{#if @custom.banner_title}}<span class="banner__title">{{@custom.banner_title}}</span>{{/if}}
</a>
</section>
{{/if}}
{{!-- Empfohlene Beiträge; gibt es keine, stehen hier die neuesten --}}
{{#get "posts" filter="featured:true" limit="4"}}
<section class="widget">
<h2 class="widget__title">{{t "Featured"}}</h2>
{{> "sidebar-posts"}}
</section>
{{else}}
{{#get "posts" limit="4"}}
<section class="widget">
<h2 class="widget__title">{{t "Latest posts"}}</h2>
{{> "sidebar-posts"}}
</section>
{{/get}}
{{/get}}
{{#get "tags" limit="12" include="count.posts" order="count.posts desc" filter="visibility:public"}}
<section class="widget">
<h2 class="widget__title">{{t "Topics"}}</h2>
<ul class="tag-index">
{{#foreach tags}}
<li><a href="{{url}}">{{name}}</a><span class="meta">{{count.posts}}</span></li>
{{/foreach}}
</ul>
</section>
{{/get}}
</aside>

View file

@ -0,0 +1,16 @@
{{!-- Fußzeile: Titel, zweites Menü und Urheberzeile --}}
<footer class="site-footer container">
<div class="site-footer__inner">
<a class="site-footer__title" href="{{@site.url}}">{{@site.title}}</a>
<nav class="site-footer__nav" aria-label="{{t "Footer"}}">
{{navigation type="secondary"}}
</nav>
<p class="site-footer__copy">
&copy; {{date format="YYYY"}} {{@site.title}}
{{#if @custom.footer_text}}&middot; {{@custom.footer_text}}{{/if}}
&middot; {{t "Powered by"}} <a href="https://ghost.org/" target="_blank" rel="noopener">Ghost</a>
</p>
</div>
</footer>

View file

@ -0,0 +1,129 @@
{{!-- Kopfbereich: schmale Menüleiste, darunter Logo und Untertitel über einer kräftigen Linie --}}
<header class="site-header container">
<div class="topbar">
<button class="nav-toggle" type="button" aria-label="{{t "Menu"}}" aria-expanded="false" aria-controls="site-nav">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
<nav class="site-nav" id="site-nav" aria-label="{{t "Menu"}}">
{{navigation}}
</nav>
{{!--
Aufklappmenüs: Ghost kennt nur ein flaches Menü. Die Ebene ergibt sich aus den
Bindestrichen am Anfang des Namens:
Unsere Reisen
- Europa (Unterpunkt von "Unsere Reisen")
-- Zypern (Unterpunkt von "Europa")
- Asien
Das Skript steht direkt hinter dem Menü, damit nichts sichtbar umspringt.
--}}
<script>
(function () {
var list = document.querySelector('#site-nav > ul');
if (!list) return;
var chevron = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>';
var submenuOf = function (parent) {
var submenu = parent.querySelector(':scope > .nav-submenu');
if (submenu) return submenu;
submenu = document.createElement('ul');
submenu.className = 'nav-submenu';
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'nav-submenu-toggle';
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-label', parent.querySelector('a').textContent);
toggle.innerHTML = chevron;
parent.classList.add('nav-has-children');
parent.appendChild(toggle);
parent.appendChild(submenu);
return submenu;
};
// parents[0] = letzter Hauptpunkt, parents[1] = letzter Unterpunkt der ersten Ebene
var parents = [null, null];
Array.prototype.slice.call(list.children).forEach(function (item) {
var link = item.querySelector('a');
if (!link) return;
var match = /^\s*((?:[-–—]\s*)+)(.+)$/.exec(link.textContent);
var depth = match ? Math.min(match[1].replace(/\s/g, '').length, 2) : 0;
// Ohne passenden Oberpunkt eine Ebene höher einhängen
while (depth > 0 && !parents[depth - 1]) depth--;
if (depth === 0) {
parents = [item, null];
return;
}
link.textContent = match[2];
submenuOf(parents[depth - 1]).appendChild(item);
if (depth === 1) parents[1] = item;
// Ist ein Unterpunkt die aktuelle Seite, werden auch seine Oberpunkte hervorgehoben
if (item.classList.contains('nav-current')) {
for (var i = 0; i < depth; i++) parents[i].classList.add('nav-current-parent');
}
});
})();
</script>
<div class="topbar__actions">
{{#if @site.members_enabled}}
{{#if @member}}
<a class="btn" href="#/portal/account" data-portal="account">{{t "Account"}}</a>
{{else}}
<a class="btn" href="#/portal/signup" data-portal="signup">{{t "Subscribe"}}</a>
{{/if}}
{{/if}}
<button class="theme-toggle" type="button" aria-label="{{t "Toggle dark mode"}}">
<svg class="theme-toggle__sun" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="theme-toggle__moon" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<span class="theme-toggle__light">{{t "Light"}}</span>
<span class="theme-toggle__dark">{{t "Dark"}}</span>
</button>
</div>
<div class="topbar__icons">
{{#if @site.facebook}}
<a class="icon-link" href="{{social_url type="facebook"}}" target="_blank" rel="noopener" aria-label="Facebook">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor"><path d="M13.5 22v-8.5h2.9l.45-3.4H13.5V7.95c0-.98.27-1.65 1.68-1.65h1.8V3.26A24 24 0 0 0 14.36 3.1c-2.6 0-4.37 1.58-4.37 4.5v2.5H7.05v3.4H10V22h3.5z"/></svg>
</a>
{{/if}}
{{#if @site.twitter}}
<a class="icon-link" href="{{social_url type="twitter"}}" target="_blank" rel="noopener" aria-label="X">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M17.75 3h3.07l-6.7 7.66L22 21h-6.17l-4.83-6.32L5.47 21H2.4l7.17-8.2L2 3h6.33l4.37 5.77L17.75 3zm-1.08 16.17h1.7L7.4 4.73H5.58l11.09 14.44z"/></svg>
</a>
{{/if}}
{{#if @custom.instagram_url}}
<a class="icon-link" href="{{@custom.instagram_url}}" target="_blank" rel="noopener" aria-label="Instagram">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/></svg>
</a>
{{/if}}
{{#if @custom.youtube_url}}
<a class="icon-link" href="{{@custom.youtube_url}}" target="_blank" rel="noopener" aria-label="YouTube">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M21.6 7.2a2.5 2.5 0 0 0-1.76-1.77C18.28 5 12 5 12 5s-6.28 0-7.84.43A2.5 2.5 0 0 0 2.4 7.2 26 26 0 0 0 2 12a26 26 0 0 0 .4 4.8 2.5 2.5 0 0 0 1.76 1.77C5.72 19 12 19 12 19s6.28 0 7.84-.43a2.5 2.5 0 0 0 1.76-1.77A26 26 0 0 0 22 12a26 26 0 0 0-.4-4.8zM10 15V9l5.2 3-5.2 3z"/></svg>
</a>
{{/if}}
<button class="icon-link" type="button" data-ghost-search aria-label="{{t "Search"}}">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
</button>
</div>
</div>
<div class="masthead">
<a class="site-brand" href="{{@site.url}}">
{{#if @site.logo}}
<img class="site-brand__logo" src="{{img_url @site.logo size="s"}}" alt="{{@site.title}}">
{{else}}
<span class="site-brand__title">{{@site.title}}</span>
{{/if}}
</a>
{{#if @site.description}}
<p class="masthead__tagline">{{@site.description}}</p>
{{/if}}
</div>
</header>

View file

@ -0,0 +1,14 @@
{{!-- Themenblock der Startseite: die drei neuesten Beiträge eines Tags (Kontext: Tag) --}}
<section class="section tag-section">
<header class="section__header">
<h2 class="section__title"><a href="{{url}}">{{name}}</a></h2>
<a class="section__more" href="{{url}}">{{t "View all"}}</a>
</header>
{{#get "posts" filter="tag:{{slug}}" limit="3" include="tags,authors"}}
<div class="post-grid">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
{{/get}}
</section>

View file

@ -0,0 +1,90 @@
{{!< default}}
{{!-- Einzelner Beitrag --}}
{{#post}}
<main class="site-main">
<article class="article {{post_class}}">
<header class="article__header container container--narrow">
{{#primary_tag}}<a class="label" href="{{url}}">{{name}}</a>{{/primary_tag}}
<h1 class="article__title">{{title}}</h1>
{{#if custom_excerpt}}
<p class="article__excerpt">{{custom_excerpt}}</p>
{{/if}}
<div class="meta article__meta">
{{#if primary_author}}
<span>{{t "By"}} {{authors separator=", "}}</span>
<span aria-hidden="true">&middot;</span>
{{/if}}
<time datetime="{{date format="YYYY-MM-DD"}}">{{date format="D. MMMM YYYY"}}</time>
<span aria-hidden="true">&middot;</span>
<span>{{reading_time minute=(t "1 min read") minutes=(t "% min read")}}</span>
</div>
</header>
{{#if feature_image}}
<figure class="article__image container">
<img
srcset="{{img_url feature_image size="m"}} 750w,
{{img_url feature_image size="l"}} 1140w,
{{img_url feature_image size="xl"}} 1920w"
sizes="(max-width: 1240px) 100vw, 1200px"
src="{{img_url feature_image size="l"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}">
{{#if feature_image_caption}}<figcaption>{{feature_image_caption}}</figcaption>{{/if}}
</figure>
{{/if}}
<section class="gh-content">
{{content}}
</section>
{{#if tags}}
<footer class="article__tags container container--narrow">
{{#foreach tags}}<a class="label label--boxed" href="{{url}}">{{name}}</a>{{/foreach}}
</footer>
{{/if}}
</article>
<nav class="post-nav container container--narrow" aria-label="{{t "More posts"}}">
{{#next_post}}
<a class="post-nav__link" href="{{url}}">
<span class="meta">&larr; {{t "Newer Post"}}</span>
<span class="post-nav__title">{{title}}</span>
</a>
{{else}}
<span></span>
{{/next_post}}
{{#prev_post}}
<a class="post-nav__link post-nav__link--older" href="{{url}}">
<span class="meta">{{t "Older Post"}} &rarr;</span>
<span class="post-nav__title">{{title}}</span>
</a>
{{/prev_post}}
</nav>
{{#if primary_tag}}
{{#get "posts" filter="primary_tag:{{primary_tag.slug}}+id:-{{id}}" limit="3" include="tags,authors"}}
<section class="section related container">
<header class="section__header">
<h2 class="section__title">{{t "Keep reading"}}</h2>
</header>
<div class="post-grid">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
</section>
{{/get}}
{{/if}}
{{#if comments}}
<section class="article__comments container container--narrow">
{{comments}}
</section>
{{/if}}
</main>
{{/post}}

View file

@ -0,0 +1,21 @@
{{!< default}}
{{!-- Übersicht eines Themas --}}
<main class="site-main container">
{{#tag}}
<header class="archive-header">
<span class="label">{{t "Topic"}}</span>
<h1 class="archive-header__title">{{name}}</h1>
{{#if description}}<p class="archive-header__text">{{description}}</p>{{/if}}
</header>
{{/tag}}
<div class="post-grid">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
{{pagination}}
</main>

View file

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vorschau 2</title>
<link rel="stylesheet" href="fernweh2/assets/css/screen.css">
</head>
<body>
<header class="site-header container">
<a class="site-brand" href="#"><span class="site-brand__title">Fernweh</span></a>
<nav class="site-nav" id="site-nav"><ul class="nav">
<li class="nav-current"><a href="#">Start</a></li>
<li class="nav-has-children"><a href="#">Unsere Reisen</a><button class="nav-submenu-toggle"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg></button><ul class="nav-submenu"><li><a href="#">Europa</a></li><li><a href="#">Asien</a></li></ul></li>
<li><a href="#">Tipps</a></li><li><a href="#">Über uns</a></li>
</ul></nav>
<div class="site-actions">
<button class="icon-link"><svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></button>
<button class="icon-link theme-toggle"><svg class="theme-toggle__moon" viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><svg class="theme-toggle__sun" viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="4"/></svg></button>
<a class="btn" href="#">Abonnieren</a>
<button class="icon-link nav-toggle"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 7h16M4 12h16M4 17h16"/></svg></button>
</div>
</header>
<main class="site-main container">
<article class="hero"><img class="hero__image" id="heroimg"><div class="hero__text"><a class="hero__tag" href="#">Norwegen</a><h2 class="hero__title"><a href="#">Durch die Fjorde Norwegens</a></h2><p class="hero__excerpt">Zwei Wochen mit Fähre, Zelt und sehr wenig Schlaf unter der Mitternachtssonne.</p><time class="hero__date">4. Oktober 2026</time></div></article>
<div class="post-grid post-grid--two post-grid--lead" id="lead"></div>
<section class="section"><header class="section__header"><h2 class="section__title">Reiseziele</h2></header><div class="tiles" id="tiles"></div></section>
<section class="section tag-section"><header class="section__header"><h2 class="section__title">Europa</h2><a class="section__more" href="#">Alle ansehen →</a></header><div class="post-grid post-grid--two" id="g1"></div></section>
<section class="section"><header class="section__header"><h2 class="section__title">Weitere Beiträge</h2></header><div class="post-grid" id="g2"></div></section>
<nav class="pagination"><span></span><span class="meta">Seite 1 von 9</span><a class="pagination__link pagination__link--older" href="#">Ältere Beiträge →</a></nav>
<section class="about"><img class="about__portrait" id="about"><div class="about__text"><h2 class="section__title">Über uns</h2><p>Wir sind Anna und Roland und reisen seit 2009 gemeinsam um die Welt.</p><a class="about__more" href="#">Mehr über uns →</a><div class="social-links"><a class="icon-link" href="#"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9.75h4V21H3V9.75z"/></svg></a></div></div></section>
</main>
<article class="article"><header class="article__header container container--narrow"><a class="label">Zypern</a><h1 class="article__title">Drei Wochen im Troodos-Gebirge</h1><p class="article__excerpt">Von Bergdörfern, Klöstern und sehr viel Halloumi.</p><div class="meta article__meta"><span>Von Roland</span><span>·</span><time>4. Oktober 2026</time><span>·</span><span>6 Min. Lesezeit</span></div></header>
<section class="gh-content"><p>Unser Weg führte uns zuerst nach Omodos, wo <a href="#">die Zeit</a> stehen geblieben scheint. Der Text läuft in einer angenehm schmalen Spalte.</p><h2>Die Anreise</h2><p>Noch ein Absatz mit etwas Text, damit man den Zeilenabstand beurteilen kann.</p><blockquote>Reisen ist die einzige Sache, die man kauft und die einen reicher macht.</blockquote><figure class="kg-card kg-image-card kg-width-wide"><img id="wide"><figcaption>Breites Bild</figcaption></figure><hr><ul><li>Punkt eins</li><li>Punkt zwei</li></ul></section>
<footer class="article__tags container container--narrow"><a class="label label--boxed">Zypern</a><a class="label label--boxed">Wandern</a></footer></article>
<footer class="site-footer"><div class="site-footer__inner container"><a class="site-footer__title">Fernweh</a><nav class="site-footer__nav"><ul class="nav"><li><a>Impressum</a></li><li><a>Datenschutz</a></li></ul></nav><div class="social-links"><a class="icon-link" href="#"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9.75h4V21H3V9.75z"/></svg></a></div><p class="site-footer__copy">© 2026 Fernweh · Betrieben mit Ghost</p></div></footer>
<script>
var hues=[200,30,150,280,10,90,220,330,60,180,250,120];
function img(i,w,h){return 'data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="'+w+'" height="'+h+'"><rect width="100%" height="100%" fill="hsl('+hues[i%12]+',25%,55%)"/></svg>');}
var titles=['Durch die Fjorde Norwegens','Trends in Lissabon: Azulejos','Interview: Sarah aus Florenz','Ein Wochenende in Triest','Mit dem Zug durch Vietnam','Wandern auf Madeira','Die schönsten Strände Zyperns','Kyoto im Herbst','Roadtrip durch Schottland','Marrakesch für Einsteiger','Inselhüpfen in Griechenland','Winter in Lappland'];
function card(i,tag){return '<article class="card"><a class="card__image"><img src="'+img(i,750,560)+'"></a><div class="card__body"><a class="label card__tag">'+tag+'</a><h2 class="card__title"><a href="#">'+titles[i%12]+'</a></h2><p class="card__excerpt">Unser Weg führte uns über kleine Küstenstraßen zu Orten, die in keinem Reiseführer stehen.</p><time class="meta card__date">4. Oktober 2026</time></div></article>';}
document.getElementById('heroimg').src=img(0,1920,960);
document.getElementById('lead').innerHTML=card(1,'Portugal')+card(2,'Italien');
document.getElementById('g1').innerHTML=card(3,'Europa')+card(4,'Europa');
var a='';for(var i=3;i<12;i++)a+=card(i,'Reise');document.getElementById('g2').innerHTML=a;
var names=['Europa','Asien','Zypern','Italien','Norwegen','Wandern','Städte','Inseln'];a='';for(i=0;i<8;i++)a+='<a class="tile'+(i<5?' tile--image':'')+'" href="#">'+(i<5?'<img src="'+img(i+2,400,500)+'">':'')+'<span class="tile__text"><span class="tile__name">'+names[i]+'</span><span class="tile__count">'+(84-i*9)+' Beiträge</span></span></a>';document.getElementById('tiles').innerHTML=a;
document.getElementById('about').src=img(4,400,400);document.getElementById('wide').src=img(8,1140,600);
if(location.hash==='#dark')document.documentElement.setAttribute('data-theme','dark');
</script>
</body>
</html>

BIN
GhostTheme/fernweh2.zip Normal file

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,78 @@
(function () {
'use strict';
// Farbschema umschalten (hell/dunkel) und merken
var toggle = document.querySelector('.theme-toggle');
if (toggle) {
toggle.addEventListener('click', function () {
var dark = document.documentElement.getAttribute('data-theme') !== 'dark';
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
try { localStorage.setItem('theme', dark ? 'dark' : 'light'); } catch (e) {}
});
}
// Menü auf kleinen Bildschirmen auf-/zuklappen
var navToggle = document.querySelector('.nav-toggle');
var nav = document.getElementById('site-nav');
if (navToggle && nav) {
navToggle.addEventListener('click', function () {
var open = nav.classList.toggle('is-open');
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
// Aufklappmenüs per Klick/Tippen auf den Pfeil (mit der Maus öffnen sie auch beim Überfahren)
var submenuToggles = document.querySelectorAll('.nav-submenu-toggle');
var closeSubmenus = function (except) {
Array.prototype.forEach.call(document.querySelectorAll('.nav-has-children.is-open'), function (item) {
// Der Eintrag selbst und seine Oberpunkte bleiben offen
if (except && (item === except || item.contains(except))) return;
item.classList.remove('is-open');
item.querySelector(':scope > .nav-submenu-toggle').setAttribute('aria-expanded', 'false');
});
};
Array.prototype.forEach.call(submenuToggles, function (button) {
button.addEventListener('click', function (event) {
event.stopPropagation();
var item = button.parentNode;
closeSubmenus(item);
var open = item.classList.toggle('is-open');
button.setAttribute('aria-expanded', open ? 'true' : 'false');
});
});
document.addEventListener('click', function (event) {
if (!event.target.closest('.nav-has-children')) closeSubmenus(null);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') closeSubmenus(null);
});
// Bildanzeige: Klick auf ein Bild im Beitrag zeigt es groß
var content = document.querySelector('.gh-content');
if (content) {
var lightbox = document.createElement('div');
lightbox.className = 'lightbox';
lightbox.hidden = true;
lightbox.innerHTML = '<button class="lightbox__close" type="button" aria-label="Close">&times;</button><img alt="">';
document.body.appendChild(lightbox);
var lightboxImage = lightbox.querySelector('img');
var close = function () {
lightbox.hidden = true;
lightboxImage.removeAttribute('src');
};
content.addEventListener('click', function (event) {
var img = event.target.closest('.kg-image-card img, .kg-gallery-image img');
// Bilder, die selbst ein Link sind, behalten ihr Linkziel
if (!img || img.closest('a')) return;
lightboxImage.src = img.currentSrc || img.src;
lightboxImage.alt = img.alt || '';
lightbox.hidden = false;
});
lightbox.addEventListener('click', close);
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && !lightbox.hidden) close();
});
}
})();

View file

@ -0,0 +1,24 @@
{{!< default}}
{{!-- Übersicht eines Autors --}}
<main class="site-main container">
{{#author}}
<header class="archive-header">
{{#if profile_image}}
<img class="archive-header__portrait" src="{{img_url profile_image size="xs"}}" alt="{{name}}">
{{/if}}
<span class="label">{{t "Author"}}</span>
<h1 class="archive-header__title">{{name}}</h1>
{{#if bio}}<p class="archive-header__text">{{bio}}</p>{{/if}}
</header>
{{/author}}
<div class="post-grid">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
{{pagination}}
</main>

View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="{{@site.locale}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{meta_title}}</title>
{{!-- Gewähltes Farbschema vor dem ersten Zeichnen setzen, damit nichts aufblitzt --}}
<script>
(function () {
try {
var stored = localStorage.getItem('theme');
var dark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="{{asset "css/screen.css"}}">
{{ghost_head}}
</head>
<body class="{{body_class}}">
{{> "site-header"}}
{{{body}}}
{{> "site-footer"}}
<script src="{{asset "js/main.js"}}" defer></script>
{{ghost_foot}}
</body>
</html>

View file

@ -0,0 +1,9 @@
{{!< default}}
{{!-- Fehlerseite (z. B. 404) --}}
<main class="site-main container">
<section class="error-page">
<p class="error-page__code">{{statusCode}}</p>
<p class="error-page__message">{{message}}</p>
<a class="btn" href="{{@site.url}}">{{t "Back to the homepage"}}</a>
</section>
</main>

View file

@ -0,0 +1,81 @@
{{!< default}}
{{!--
Beitragsübersicht. Auf der Startseite (Seite 1) steht der neueste Beitrag als großes Bild
über die volle Breite, darunter zwei große Karten, die Reiseziele, die Themenblöcke und
die übrigen Beiträge. Ab Seite 2 nur die Beiträge.
--}}
<main class="site-main container">
{{#is "home"}}
{{#foreach posts from="1" to="1"}}
<article class="hero{{#unless feature_image}} hero--plain{{/unless}}">
{{#if feature_image}}
<img class="hero__image"
srcset="{{img_url feature_image size="m"}} 750w,
{{img_url feature_image size="l"}} 1140w,
{{img_url feature_image size="xl"}} 1920w"
sizes="(max-width: 1240px) 100vw, 1200px"
src="{{img_url feature_image size="l"}}"
alt="">
{{/if}}
<div class="hero__text">
{{#primary_tag}}<a class="hero__tag" href="{{url}}">{{name}}</a>{{/primary_tag}}
<h2 class="hero__title"><a href="{{url}}">{{title}}</a></h2>
{{#if custom_excerpt}}<p class="hero__excerpt">{{custom_excerpt}}</p>{{/if}}
<time class="hero__date" datetime="{{date format="YYYY-MM-DD"}}">{{date format="D. MMMM YYYY"}}</time>
</div>
</article>
{{/foreach}}
<div class="post-grid post-grid--two post-grid--lead">
{{#foreach posts from="2" to="3"}}
{{> "post-card" sizes="(max-width: 640px) 100vw, 600px"}}
{{/foreach}}
</div>
{{> "destinations"}}
{{#if @custom.home_tags}}
{{#get "tags" filter=(concat "slug:[" @custom.home_tags "]") limit="6"}}
{{#foreach tags}}{{> "tag-section"}}{{/foreach}}
{{/get}}
{{else}}
{{#get "tags" limit="3" include="count.posts" order="count.posts desc" filter="visibility:public"}}
{{#foreach tags}}{{> "tag-section"}}{{/foreach}}
{{/get}}
{{/if}}
<section class="section">
<header class="section__header">
<h2 class="section__title">{{t "More posts"}}</h2>
</header>
<div class="post-grid">
{{#foreach posts from="4"}}
{{> "post-card"}}
{{/foreach}}
</div>
</section>
{{else}}
<section class="section">
<header class="section__header">
<h1 class="section__title">{{t "Latest posts"}}</h1>
</header>
<div class="post-grid">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
</section>
{{/is}}
{{pagination}}
{{#is "home"}}
{{#if @custom.about_text}}
{{> "about"}}
{{else if @custom.about_image}}
{{> "about"}}
{{/if}}
{{/is}}
</main>

View file

@ -0,0 +1,32 @@
{
"Newer Posts": "Neuere Beiträge",
"Older Posts": "Ältere Beiträge",
"Newer Post": "Neuerer Beitrag",
"Older Post": "Älterer Beitrag",
"More posts": "Weitere Beiträge",
"Keep reading": "Weiterlesen",
"Latest posts": "Neueste Beiträge",
"Destinations": "Reiseziele",
"No posts": "Keine Beiträge",
"% post": "% Beitrag",
"% posts": "% Beiträge",
"Topic": "Thema",
"Author": "Autor",
"About us": "Über uns",
"More about us": "Mehr über uns",
"View all": "Alle ansehen",
"By": "Von",
"Page {page} of {pages}": "Seite {page} von {pages}",
"Pagination": "Seitenwechsel",
"Menu": "Menü",
"Footer": "Fußzeile",
"Search": "Suche",
"Subscribe": "Abonnieren",
"Account": "Konto",
"Toggle dark mode": "Dunkles Farbschema umschalten",
"1 min read": "1 Min. Lesezeit",
"% min read": "% Min. Lesezeit",
"Powered by": "Betrieben mit",
"Back to the homepage": "Zur Startseite",
"Close": "Schließen"
}

View file

@ -0,0 +1,32 @@
{
"Newer Posts": "Newer Posts",
"Older Posts": "Older Posts",
"Newer Post": "Newer Post",
"Older Post": "Older Post",
"More posts": "More posts",
"Keep reading": "Keep reading",
"Latest posts": "Latest posts",
"Destinations": "Destinations",
"No posts": "No posts",
"% post": "% post",
"% posts": "% posts",
"Topic": "Topic",
"Author": "Author",
"About us": "About us",
"More about us": "More about us",
"View all": "View all",
"By": "By",
"Page {page} of {pages}": "Page {page} of {pages}",
"Pagination": "Pagination",
"Menu": "Menu",
"Footer": "Footer",
"Search": "Search",
"Subscribe": "Subscribe",
"Account": "Account",
"Toggle dark mode": "Toggle dark mode",
"1 min read": "1 min read",
"% min read": "% min read",
"Powered by": "Powered by",
"Back to the homepage": "Back to the homepage",
"Close": "Close"
}

View file

@ -0,0 +1,75 @@
{
"name": "fernweh2",
"description": "Ruhiges Reiseblog-Theme: großes Aufmacherbild, viel Weißraum, Reiseziele als Bildkacheln",
"version": "1.0.1",
"license": "MIT",
"engines": {
"ghost": ">=5.0.0"
},
"author": {
"name": "Roland Brunner",
"email": "roland.brunner@gmx.at"
},
"keywords": [
"ghost",
"theme",
"ghost-theme"
],
"config": {
"posts_per_page": 12,
"image_sizes": {
"xs": { "width": 160 },
"s": { "width": 400 },
"m": { "width": 750 },
"l": { "width": 1140 },
"xl": { "width": 1920 }
},
"card_assets": true,
"custom": {
"home_tags": {
"type": "text",
"group": "homepage",
"description": "Themenblöcke: Tag-Slugs mit Komma, ohne Leerzeichen (leer = die drei größten Themen)"
},
"about_image": {
"type": "image",
"group": "homepage",
"description": "Rundes Bild im Abschnitt „Über uns“ am Ende der Startseite"
},
"about_text": {
"type": "text",
"group": "homepage",
"description": "Kurzer Text im Abschnitt „Über uns“"
},
"about_url": {
"type": "text",
"group": "homepage",
"description": "Adresse der „Über uns“-Seite"
},
"linkedin_url": {
"type": "text",
"description": "LinkedIn-Profil (vollständige Adresse)"
},
"mastodon_url": {
"type": "text",
"description": "Mastodon-Profil (vollständige Adresse)"
},
"bluesky_url": {
"type": "text",
"description": "Bluesky-Profil (vollständige Adresse)"
},
"instagram_url": {
"type": "text",
"description": "Instagram-Profil (vollständige Adresse)"
},
"youtube_url": {
"type": "text",
"description": "YouTube-Kanal (vollständige Adresse)"
},
"footer_text": {
"type": "text",
"description": "Zusätzlicher Text in der Fußzeile"
}
}
}
}

View file

@ -0,0 +1,35 @@
{{!< default}}
{{!-- Statische Seite --}}
{{#post}}
<main class="site-main">
<article class="article {{post_class}}">
{{#match @page.show_title_and_feature_image}}
<header class="article__header container container--narrow">
<h1 class="article__title">{{title}}</h1>
{{#if custom_excerpt}}
<p class="article__excerpt">{{custom_excerpt}}</p>
{{/if}}
</header>
{{#if feature_image}}
<figure class="article__image container">
<img
srcset="{{img_url feature_image size="m"}} 750w,
{{img_url feature_image size="l"}} 1140w,
{{img_url feature_image size="xl"}} 1920w"
sizes="(max-width: 1240px) 100vw, 1200px"
src="{{img_url feature_image size="l"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}">
{{#if feature_image_caption}}<figcaption>{{feature_image_caption}}</figcaption>{{/if}}
</figure>
{{/if}}
{{/match}}
<section class="gh-content">
{{content}}
</section>
</article>
</main>
{{/post}}

View file

@ -0,0 +1,12 @@
{{!-- „Über uns“ am Ende der Startseite: rundes Bild, kurzer Text, soziale Netzwerke --}}
<section class="about">
{{#if @custom.about_image}}
<img class="about__portrait" loading="lazy" src="{{img_url @custom.about_image size="s"}}" alt="">
{{/if}}
<div class="about__text">
<h2 class="section__title">{{t "About us"}}</h2>
{{#if @custom.about_text}}<p>{{@custom.about_text}}</p>{{/if}}
{{#if @custom.about_url}}<a class="about__more" href="{{@custom.about_url}}">{{t "More about us"}} &rarr;</a>{{/if}}
{{> "social-links"}}
</div>
</section>

View file

@ -0,0 +1,37 @@
{{!--
Reiseziele: die acht größten Themen als Kacheln. Gezeigt wird das Bild des Tags;
hat der Tag keines, das Titelbild seines neuesten Beitrags. Ohne beides bleibt die
Kachel einfarbig. Das Bild muss direkt vor .tile__text stehen (siehe screen.css).
--}}
{{#get "tags" limit="8" include="count.posts" order="count.posts desc" filter="visibility:public"}}
<section class="section">
<header class="section__header">
<h2 class="section__title">{{t "Destinations"}}</h2>
</header>
<div class="tiles">
{{#foreach tags}}
<a class="tile" href="{{url}}">
{{#if feature_image}}
<img loading="lazy"
srcset="{{img_url feature_image size="s"}} 400w, {{img_url feature_image size="m"}} 750w"
sizes="(max-width: 640px) 50vw, 290px"
src="{{img_url feature_image size="s"}}" alt="">
{{else}}
{{#get "posts" filter="tag:{{slug}}+feature_image:-null" limit="1"}}
{{#foreach posts}}
<img loading="lazy"
srcset="{{img_url feature_image size="s"}} 400w, {{img_url feature_image size="m"}} 750w"
sizes="(max-width: 640px) 50vw, 290px"
src="{{img_url feature_image size="s"}}" alt="">
{{/foreach}}
{{/get}}
{{/if}}
<span class="tile__text">
<span class="tile__name">{{name}}</span>
<span class="tile__count">{{plural count.posts empty=(t "No posts") singular=(t "% post") plural=(t "% posts")}}</span>
</span>
</a>
{{/foreach}}
</div>
</section>
{{/get}}

View file

@ -0,0 +1,16 @@
{{!-- Seitenwechsel der Beitragsübersicht --}}
<nav class="pagination" aria-label="{{t "Pagination"}}">
{{#if prev}}
<a class="pagination__link" href="{{page_url prev}}">&larr; {{t "Newer Posts"}}</a>
{{else}}
<span></span>
{{/if}}
<span class="meta pagination__info">{{t "Page {page} of {pages}" page=page pages=pages}}</span>
{{#if next}}
<a class="pagination__link pagination__link--older" href="{{page_url next}}">{{t "Older Posts"}} &rarr;</a>
{{else}}
<span></span>
{{/if}}
</nav>

View file

@ -0,0 +1,24 @@
{{!--
Beitragskarte. Der Auszug ist nur in großen Karten sichtbar (siehe screen.css).
Parameter: sizes = sizes-Angabe des Bildes
--}}
<article class="card {{post_class}}">
{{#if feature_image}}
<a class="card__image" href="{{url}}" tabindex="-1" aria-hidden="true">
<img
loading="lazy"
srcset="{{img_url feature_image size="s"}} 400w,
{{img_url feature_image size="m"}} 750w,
{{img_url feature_image size="l"}} 1140w"
sizes="{{#if sizes}}{{sizes}}{{else}}(max-width: 640px) 100vw, 400px{{/if}}"
src="{{img_url feature_image size="m"}}"
alt="">
</a>
{{/if}}
<div class="card__body">
{{#primary_tag}}<a class="label card__tag" href="{{url}}">{{name}}</a>{{/primary_tag}}
<h2 class="card__title"><a href="{{url}}">{{title}}</a></h2>
{{#if excerpt}}<p class="card__excerpt">{{excerpt words="26"}}</p>{{/if}}
<time class="meta card__date" datetime="{{date format="YYYY-MM-DD"}}">{{date format="D. MMMM YYYY"}}</time>
</div>
</article>

View file

@ -0,0 +1,18 @@
{{!-- Fußzeile: Titel, zweites Menü, soziale Netzwerke und Urheberzeile --}}
<footer class="site-footer">
<div class="site-footer__inner container">
<a class="site-footer__title" href="{{@site.url}}">{{@site.title}}</a>
<nav class="site-footer__nav" aria-label="{{t "Footer"}}">
{{navigation type="secondary"}}
</nav>
{{> "social-links"}}
<p class="site-footer__copy">
&copy; {{date format="YYYY"}} {{@site.title}}
{{#if @custom.footer_text}}&middot; {{@custom.footer_text}}{{/if}}
&middot; {{t "Powered by"}} <a href="https://ghost.org/" target="_blank" rel="noopener">Ghost</a>
</p>
</div>
</footer>

View file

@ -0,0 +1,96 @@
{{!-- Kopfzeile: Logo links, Menü und Symbole rechts in einer Zeile --}}
<header class="site-header container">
<a class="site-brand" href="{{@site.url}}">
{{#if @site.logo}}
<img class="site-brand__logo" src="{{img_url @site.logo size="s"}}" alt="{{@site.title}}">
{{else}}
<span class="site-brand__title">{{@site.title}}</span>
{{/if}}
</a>
<nav class="site-nav" id="site-nav" aria-label="{{t "Menu"}}">
{{navigation}}
</nav>
{{!--
Aufklappmenüs: Ghost kennt nur ein flaches Menü. Die Ebene ergibt sich aus den
Bindestrichen am Anfang des Namens:
Unsere Reisen
- Europa (Unterpunkt von "Unsere Reisen")
-- Zypern (Unterpunkt von "Europa")
- Asien
Das Skript steht direkt hinter dem Menü, damit nichts sichtbar umspringt.
--}}
<script>
(function () {
var list = document.querySelector('#site-nav > ul');
if (!list) return;
var chevron = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>';
var submenuOf = function (parent) {
var submenu = parent.querySelector(':scope > .nav-submenu');
if (submenu) return submenu;
submenu = document.createElement('ul');
submenu.className = 'nav-submenu';
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'nav-submenu-toggle';
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-label', parent.querySelector('a').textContent);
toggle.innerHTML = chevron;
parent.classList.add('nav-has-children');
parent.appendChild(toggle);
parent.appendChild(submenu);
return submenu;
};
// parents[0] = letzter Hauptpunkt, parents[1] = letzter Unterpunkt der ersten Ebene
var parents = [null, null];
Array.prototype.slice.call(list.children).forEach(function (item) {
var link = item.querySelector('a');
if (!link) return;
var match = /^\s*((?:[-–—]\s*)+)(.+)$/.exec(link.textContent);
var depth = match ? Math.min(match[1].replace(/\s/g, '').length, 2) : 0;
// Ohne passenden Oberpunkt eine Ebene höher einhängen
while (depth > 0 && !parents[depth - 1]) depth--;
if (depth === 0) {
parents = [item, null];
return;
}
link.textContent = match[2];
submenuOf(parents[depth - 1]).appendChild(item);
if (depth === 1) parents[1] = item;
// Ist ein Unterpunkt die aktuelle Seite, werden auch seine Oberpunkte hervorgehoben
if (item.classList.contains('nav-current')) {
for (var i = 0; i < depth; i++) parents[i].classList.add('nav-current-parent');
}
});
})();
</script>
<div class="site-actions">
<button class="icon-link" type="button" data-ghost-search aria-label="{{t "Search"}}">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
</button>
<button class="icon-link theme-toggle" type="button" aria-label="{{t "Toggle dark mode"}}">
<svg class="theme-toggle__moon" viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<svg class="theme-toggle__sun" viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
</button>
{{#if @site.members_enabled}}
{{#if @member}}
<a class="btn" href="#/portal/account" data-portal="account">{{t "Account"}}</a>
{{else}}
<a class="btn" href="#/portal/signup" data-portal="signup">{{t "Subscribe"}}</a>
{{/if}}
{{/if}}
<button class="icon-link nav-toggle" type="button" aria-label="{{t "Menu"}}" aria-expanded="false" aria-controls="site-nav">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
</div>
</header>

View file

@ -0,0 +1,38 @@
{{!-- Verweise auf die sozialen Netzwerke (Fußzeile und „Über uns“) --}}
<div class="social-links">
{{#if @custom.linkedin_url}}
<a class="icon-link" href="{{@custom.linkedin_url}}" target="_blank" rel="noopener" aria-label="LinkedIn">
<svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9.75h4V21H3V9.75zm6.5 0h3.8v1.6h.06c.53-1 1.83-1.9 3.76-1.9 4.02 0 4.88 2.5 4.88 6.1V21h-4v-4.9c0-1.2-.02-2.7-1.7-2.7s-1.96 1.3-1.96 2.6V21h-4V9.75z"/></svg>
</a>
{{/if}}
{{#if @custom.mastodon_url}}
<a class="icon-link" href="{{@custom.mastodon_url}}" target="_blank" rel="me noopener" aria-label="Mastodon">
<svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor"><path d="M21.33 14.34c-.3 1.52-2.66 3.19-5.37 3.51-1.41.17-2.8.32-4.29.25-2.42-.11-4.34-.58-4.34-.58 0 .24.02.46.05.67.31 2.38 2.37 2.53 4.31 2.59 1.96.07 3.71-.48 3.71-.48l.08 1.77s-1.37.74-3.82.87c-1.35.07-3.02-.03-4.97-.55C2.46 21.27 1.73 16.74 1.62 12.14c-.03-1.36-.01-2.65-.01-3.73 0-4.7 3.08-6.08 3.08-6.08C6.24 1.62 8.9 1.32 11.67 1.3h.07c2.77.02 5.43.32 6.98 1.03 0 0 3.08 1.38 3.08 6.08 0 0 .04 3.47-.47 5.93zM18.4 8.83c0-1.16-.3-2.09-.89-2.77-.61-.68-1.41-1.03-2.4-1.03-1.15 0-2.02.44-2.6 1.32L12 7.3l-.56-.94c-.58-.88-1.45-1.32-2.6-1.32-.99 0-1.79.35-2.4 1.03-.59.68-.89 1.61-.89 2.77v5.68h2.25V9c0-1.16.49-1.75 1.47-1.75 1.08 0 1.62.7 1.62 2.08v3.02h2.24V9.34c0-1.38.54-2.08 1.62-2.08.98 0 1.47.59 1.47 1.75v5.51h2.25V8.83z"/></svg>
</a>
{{/if}}
{{#if @custom.bluesky_url}}
<a class="icon-link" href="{{@custom.bluesky_url}}" target="_blank" rel="noopener" aria-label="Bluesky">
<svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor"><path d="M5.2 3.6C7.95 5.66 10.9 9.85 12 12.1c1.1-2.25 4.05-6.44 6.8-8.5 1.98-1.5 5.2-2.65 5.2 1.03 0 .73-.42 6.17-.67 7.05-.86 3.07-3.99 3.85-6.78 3.38 4.87.83 6.11 3.57 3.43 6.32-5.08 5.21-7.3-1.31-7.87-2.98-.1-.31-.15-.45-.11-.33-.04-.12-.01.02-.11.33-.57 1.67-2.79 8.19-7.87 2.98-2.68-2.75-1.44-5.49 3.43-6.32-2.79.47-5.92-.31-6.78-3.38C.42 10.8 0 5.36 0 4.63 0 .95 3.22 2.1 5.2 3.6z"/></svg>
</a>
{{/if}}
{{#if @custom.instagram_url}}
<a class="icon-link" href="{{@custom.instagram_url}}" target="_blank" rel="noopener" aria-label="Instagram">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/></svg>
</a>
{{/if}}
{{#if @custom.youtube_url}}
<a class="icon-link" href="{{@custom.youtube_url}}" target="_blank" rel="noopener" aria-label="YouTube">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M21.6 7.2a2.5 2.5 0 0 0-1.76-1.77C18.28 5 12 5 12 5s-6.28 0-7.84.43A2.5 2.5 0 0 0 2.4 7.2 26 26 0 0 0 2 12a26 26 0 0 0 .4 4.8 2.5 2.5 0 0 0 1.76 1.77C5.72 19 12 19 12 19s6.28 0 7.84-.43a2.5 2.5 0 0 0 1.76-1.77A26 26 0 0 0 22 12a26 26 0 0 0-.4-4.8zM10 15V9l5.2 3-5.2 3z"/></svg>
</a>
{{/if}}
{{#if @site.facebook}}
<a class="icon-link" href="{{social_url type="facebook"}}" target="_blank" rel="noopener" aria-label="Facebook">
<svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor"><path d="M13.5 22v-8.5h2.9l.45-3.4H13.5V7.95c0-.98.27-1.65 1.68-1.65h1.8V3.26A24 24 0 0 0 14.36 3.1c-2.6 0-4.37 1.58-4.37 4.5v2.5H7.05v3.4H10V22h3.5z"/></svg>
</a>
{{/if}}
{{#if @site.twitter}}
<a class="icon-link" href="{{social_url type="twitter"}}" target="_blank" rel="noopener" aria-label="X">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor"><path d="M17.75 3h3.07l-6.7 7.66L22 21h-6.17l-4.83-6.32L5.47 21H2.4l7.17-8.2L2 3h6.33l4.37 5.77L17.75 3zm-1.08 16.17h1.7L7.4 4.73H5.58l11.09 14.44z"/></svg>
</a>
{{/if}}
</div>

View file

@ -0,0 +1,14 @@
{{!-- Themenblock der Startseite: die zwei neuesten Beiträge eines Tags als große Karten (Kontext: Tag) --}}
<section class="section tag-section">
<header class="section__header">
<h2 class="section__title"><a href="{{url}}">{{name}}</a></h2>
<a class="section__more" href="{{url}}">{{t "View all"}} &rarr;</a>
</header>
{{#get "posts" filter="tag:{{slug}}" limit="2" include="tags,authors"}}
<div class="post-grid post-grid--two">
{{#foreach posts}}
{{> "post-card" sizes="(max-width: 640px) 100vw, 600px"}}
{{/foreach}}
</div>
{{/get}}
</section>

View file

@ -0,0 +1,90 @@
{{!< default}}
{{!-- Einzelner Beitrag --}}
{{#post}}
<main class="site-main">
<article class="article {{post_class}}">
<header class="article__header container container--narrow">
{{#primary_tag}}<a class="label" href="{{url}}">{{name}}</a>{{/primary_tag}}
<h1 class="article__title">{{title}}</h1>
{{#if custom_excerpt}}
<p class="article__excerpt">{{custom_excerpt}}</p>
{{/if}}
<div class="meta article__meta">
{{#if primary_author}}
<span>{{t "By"}} {{authors separator=", "}}</span>
<span aria-hidden="true">&middot;</span>
{{/if}}
<time datetime="{{date format="YYYY-MM-DD"}}">{{date format="D. MMMM YYYY"}}</time>
<span aria-hidden="true">&middot;</span>
<span>{{reading_time minute=(t "1 min read") minutes=(t "% min read")}}</span>
</div>
</header>
{{#if feature_image}}
<figure class="article__image container">
<img
srcset="{{img_url feature_image size="m"}} 750w,
{{img_url feature_image size="l"}} 1140w,
{{img_url feature_image size="xl"}} 1920w"
sizes="(max-width: 1240px) 100vw, 1200px"
src="{{img_url feature_image size="l"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}">
{{#if feature_image_caption}}<figcaption>{{feature_image_caption}}</figcaption>{{/if}}
</figure>
{{/if}}
<section class="gh-content">
{{content}}
</section>
{{#if tags}}
<footer class="article__tags container container--narrow">
{{#foreach tags}}<a class="label label--boxed" href="{{url}}">{{name}}</a>{{/foreach}}
</footer>
{{/if}}
</article>
<nav class="post-nav container container--narrow" aria-label="{{t "More posts"}}">
{{#next_post}}
<a class="post-nav__link" href="{{url}}">
<span class="meta">&larr; {{t "Newer Post"}}</span>
<span class="post-nav__title">{{title}}</span>
</a>
{{else}}
<span></span>
{{/next_post}}
{{#prev_post}}
<a class="post-nav__link post-nav__link--older" href="{{url}}">
<span class="meta">{{t "Older Post"}} &rarr;</span>
<span class="post-nav__title">{{title}}</span>
</a>
{{/prev_post}}
</nav>
{{#if primary_tag}}
{{#get "posts" filter="primary_tag:{{primary_tag.slug}}+id:-{{id}}" limit="3" include="tags,authors"}}
<section class="section related container">
<header class="section__header">
<h2 class="section__title">{{t "Keep reading"}}</h2>
</header>
<div class="post-grid">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
</section>
{{/get}}
{{/if}}
{{#if comments}}
<section class="article__comments container container--narrow">
{{comments}}
</section>
{{/if}}
</main>
{{/post}}

View file

@ -0,0 +1,29 @@
{{!< default}}
{{!-- Übersicht eines Themas --}}
<main class="site-main container">
{{#tag}}
<header class="archive-header">
<span class="label">{{t "Topic"}}</span>
<h1 class="archive-header__title">{{name}}</h1>
{{#if description}}<p class="archive-header__text">{{description}}</p>{{/if}}
</header>
{{#if feature_image}}
<img class="archive-image"
srcset="{{img_url feature_image size="m"}} 750w,
{{img_url feature_image size="l"}} 1140w,
{{img_url feature_image size="xl"}} 1920w"
sizes="(max-width: 1240px) 100vw, 1200px"
src="{{img_url feature_image size="l"}}" alt="">
{{/if}}
{{/tag}}
<div class="post-grid">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
{{pagination}}
</main>

BIN
GhostTheme/unterwegs.zip Normal file

Binary file not shown.

View file

@ -0,0 +1,511 @@
/* ==========================================================================
Unterwegs — Ghost-Theme
Inhalt: 1 Variablen · 2 Grundlagen · 3 Kopfzeile · 4 Beitragsübersicht
5 Seitenwechsel · 6 Beitrag · 7 Inhalt/Editor-Karten · 8 Fußzeile
9 Bildanzeige · 10 Kleine Bildschirme
========================================================================== */
/* 1 Variablen
========================================================================== */
:root {
/* Akzentfarbe aus Ghost (Einstellungen → Design → Marke), sonst Rot */
--accent: var(--ghost-accent-color, #ff1a1a);
--tag-primary: #3b3bdb;
--tag-secondary: #6b6bbd;
--bg: #ffffff;
--bg-soft: #f1f2f4;
--text: #23262b;
--text-soft: #5b6470;
--border: #e3e5e8;
--shadow: 0 10px 30px rgba(20, 24, 31, 0.12);
--font-body: var(--gh-font-body, "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
--font-heading: var(--gh-font-heading, "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
--container: 1320px;
--content: 740px;
--radius: 8px;
}
html[data-theme="dark"] {
--bg: #15171a;
--bg-soft: #1f2226;
--text: #e9ebee;
--text-soft: #a3abb5;
--border: #2e3238;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
--tag-primary: #5a5af0;
--tag-secondary: #6f6fc0;
}
/* 2 Grundlagen
========================================================================== */
*, *::before, *::after { box-sizing: border-box; }
html { font-size: 62.5%; -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 1.7rem;
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
img, video { max-width: 100%; height: auto; display: block; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
line-height: 1.25;
margin: 0 0 0.5em;
color: var(--text);
}
.container {
width: 100%;
max-width: var(--container);
margin: 0 auto;
padding: 0 max(4vw, 20px);
}
.container--narrow { max-width: calc(var(--content) + 2 * max(4vw, 20px)); }
.site-main { padding-top: 48px; padding-bottom: 72px; min-height: 50vh; }
.btn {
display: inline-block;
padding: 10px 24px;
border: 2px solid var(--accent);
border-radius: 6px;
background: var(--accent);
color: #fff;
font-weight: 500;
line-height: 1.4;
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 35%, transparent);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn:hover { text-decoration: none; transform: translateY(-1px); box-shadow: 0 8px 20px color-mix(in srgb, var(--accent) 45%, transparent); }
.btn--outline { background: transparent; color: var(--accent); box-shadow: none; }
.btn--outline:hover { background: var(--accent); color: #fff; }
/* 3 Kopfzeile
Hintergrundbild (<img class="site-header__bg">), Abtönung (::before) und Inhalt liegen
in derselben Rasterzelle übereinander. Das Bild wird nie beschnitten: es bestimmt die
Höhe der Kopfzeile; ist es dafür zu hoch, wird es verkleinert und bleibt trotzdem ganz sichtbar.
========================================================================== */
.site-header {
position: relative;
display: grid;
background-color: var(--bg-soft);
--header-text: var(--text);
/* Hintergrund der Aufklappmenüs: derselbe Ton wie die Kopfzeile hinter dem Hauptmenü */
--header-menu-bg: color-mix(in srgb, var(--bg-soft) 92%, transparent);
}
.site-header > *,
.site-header::before { grid-area: 1 / 1; min-width: 0; }
.site-header__bg {
width: 100%;
height: auto;
max-height: min(60vh, 560px);
object-fit: contain;
object-position: center top;
}
/* Abtönung über dem Hintergrundbild */
.site-header::before { content: ""; z-index: 1; }
.site-header--overlay-light::before { background: color-mix(in srgb, var(--bg-soft) 60%, transparent); }
.site-header--overlay-dark { --header-text: #ffffff; --header-menu-bg: rgba(0, 0, 0, 0.78); }
.site-header--overlay-dark::before { background: rgba(0, 0, 0, 0.55); }
.site-header--overlay-none::before { display: none; }
/* Inhalt der Kopfzeile: Titel oben links, Menü oben rechts, Symbole unten rechts.
Der Bereich ist so hoch wie die ganze Kopfzeile (also wie das Hintergrundbild). */
.site-header__inner {
position: relative;
z-index: 2;
align-self: stretch;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
grid-template-areas:
"brand nav"
". ."
"actions actions";
gap: 6px 24px;
min-height: 92px;
padding: 10px 16px;
}
.site-brand { grid-area: brand; align-self: start; display: flex; align-items: center; gap: 12px; color: var(--header-text); }
.site-brand:hover { text-decoration: none; }
.site-brand__logo { max-height: 64px; width: auto; }
/* Titel bleibt einzeilig — bei Platzmangel bricht stattdessen das Menü um */
.site-brand__title { font-family: var(--font-heading); font-size: clamp(2.4rem, 3vw, 4rem); font-weight: 700; line-height: 1.1; white-space: nowrap; opacity: 0.85; }
.site-nav { grid-area: nav; align-self: start; min-width: 0; }
.site-nav ul { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 4px 6px; list-style: none; margin: 0; padding: 0; }
.site-nav a {
display: block;
padding: 6px 14px;
border-radius: 4px;
color: var(--header-text);
font-size: 1.7rem;
line-height: 1.5;
}
.site-nav a:hover { text-decoration: none; background: color-mix(in srgb, var(--header-text) 14%, transparent); }
/* Aktueller Menüpunkt: Akzentfarbe, Hintergrund zu 50 % durchscheinend */
.site-nav .nav-current > a,
.site-nav .nav-current-parent > a { background: color-mix(in srgb, var(--accent) 50%, transparent); color: #fff; }
/* Aufklappmenüs (bis zu zwei Ebenen): das Skript in site-header.hbs hängt Einträge, deren
Name mit "-" bzw. "--" beginnt, als .nav-submenu unter den vorherigen Eintrag der Ebene darüber */
.site-nav li { position: relative; }
.nav-has-children { display: flex; align-items: center; }
.nav-has-children > a { padding-right: 4px; }
.nav-submenu-toggle {
display: inline-flex;
align-items: center;
padding: 8px 8px 8px 2px;
border: 0;
background: none;
color: var(--header-text);
cursor: pointer;
}
.nav-submenu-toggle svg { transition: transform 0.15s ease; }
.nav-has-children.is-open > .nav-submenu-toggle svg { transform: rotate(180deg); }
.site-nav .nav-submenu {
display: none;
position: absolute;
top: 100%;
right: 0;
z-index: 50;
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
justify-content: flex-start;
gap: 2px;
min-width: 220px;
padding: 8px;
border-radius: var(--radius);
/* kein eigener Hintergrund: die Einträge stehen direkt über Kopfzeile bzw. Seite */
background: none;
}
.nav-has-children.is-open > .nav-submenu { display: flex; }
.site-nav .nav-submenu a { flex: 1; white-space: nowrap; }
/* Eintrag unter der Maus: Dunkelgrau zu 30 % */
.site-nav .nav-submenu a:hover,
.site-nav .nav-submenu a:focus-visible { background: rgba(51, 51, 51, 0.3); }
.site-nav .nav-submenu .nav-current > a:hover,
.site-nav .nav-submenu .nav-current-parent > a:hover { background: color-mix(in srgb, var(--accent) 50%, transparent); }
/* Helle Schrift bleibt ohne Hintergrund nur mit Schatten lesbar, wenn das Menü über die Kopfzeile hinausragt */
.site-header--overlay-dark .nav-submenu a { text-shadow: 0 1px 3px rgba(0, 0, 0, 0.75); }
.site-nav .nav-submenu .nav-has-children { justify-content: space-between; }
@media (min-width: 1001px) {
/* Mit der Maus bzw. Tastatur klappen die Menüs von selbst auf */
.nav-has-children:hover > .nav-submenu,
.nav-has-children:focus-within > .nav-submenu { display: flex; }
/* Zweite Ebene klappt seitlich nach links auf (das Menü steht am rechten Rand) */
.site-nav .nav-submenu .nav-submenu { top: -8px; right: 100%; margin-right: 4px; }
.site-nav .nav-submenu .nav-submenu-toggle svg { transform: rotate(90deg); }
.site-nav .nav-submenu .nav-has-children.is-open > .nav-submenu-toggle svg { transform: rotate(90deg); }
}
.site-actions { grid-area: actions; justify-self: end; align-self: end; display: flex; align-items: center; gap: 6px; }
.icon-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: var(--header-text);
cursor: pointer;
}
.icon-link:hover { background: color-mix(in srgb, var(--header-text) 14%, transparent); }
.theme-toggle__sun { display: none; }
html[data-theme="dark"] .theme-toggle__sun { display: block; }
html[data-theme="dark"] .theme-toggle__moon { display: none; }
.nav-toggle { grid-area: toggle; display: none; align-self: start; padding: 6px; border: 0; background: none; color: var(--header-text); cursor: pointer; }
/* 4 Beitragsübersicht
Raster mit 6 Spalten: Standard = 3 Beiträge pro Zeile (je 2 Spalten).
Erste Seite (.post-feed--lead): 1. Beitrag ganze Breite, 2. und 3. je die Hälfte.
========================================================================== */
.post-feed {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 56px 40px;
}
.post-card { grid-column: span 2; display: flex; flex-direction: column; min-width: 0; }
.post-card__image {
display: block;
overflow: hidden;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 22px;
aspect-ratio: 4 / 3;
background: var(--bg-soft);
}
.post-card__image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
.post-card__image:hover img { transform: scale(1.04); }
.post-card__body { display: flex; flex-direction: column; align-items: flex-start; flex: 1; }
.post-card__title { font-size: 2.2rem; font-weight: 700; margin: 0 0 12px; }
.post-card__title a { color: var(--text); }
.post-card__title a:hover { color: var(--accent); text-decoration: none; }
.post-card__excerpt { margin: 0 0 20px; color: var(--text); }
.post-card .btn { margin-top: auto; }
.tag-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.tag-badge {
padding: 2px 10px;
border-radius: 4px;
background: var(--tag-secondary);
color: #fff;
font-size: 1.5rem;
line-height: 1.5;
}
.tag-badge:first-child { background: var(--tag-primary); }
.tag-badge:hover { text-decoration: none; filter: brightness(1.12); }
.post-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 16px; color: var(--text-soft); font-size: 1.45rem; }
.post-meta svg { flex-shrink: 0; color: var(--text); }
/* Erste Seite: großer erster Beitrag, Bild links und Text rechts */
.post-feed--lead .post-card:nth-child(1) {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: 32px;
align-items: center;
}
.post-feed--lead .post-card:nth-child(1) .post-card__image { margin-bottom: 0; }
.post-feed--lead .post-card:nth-child(1) .post-card__title { font-size: clamp(2.4rem, 2.4vw, 3rem); }
.post-feed--lead .post-card:nth-child(1) .btn { margin-top: 0; }
.post-feed--lead .post-card--no-image:nth-child(1) { grid-template-columns: minmax(0, 1fr); }
/* Erste Seite: zweite Zeile mit zwei Beiträgen */
.post-feed--lead .post-card:nth-child(2),
.post-feed--lead .post-card:nth-child(3) { grid-column: span 3; }
.post-feed--lead .post-card:nth-child(2) .post-card__image,
.post-feed--lead .post-card:nth-child(3) .post-card__image { aspect-ratio: 16 / 9; }
/* Kopf von Tag- und Autorenseiten */
.archive-header { margin-bottom: 48px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }
.archive-header__label { display: block; color: var(--text-soft); font-size: 1.4rem; text-transform: uppercase; letter-spacing: 0.08em; }
.archive-header__title { font-size: clamp(3rem, 4vw, 4.4rem); margin: 4px 0 8px; }
.archive-header__description { margin: 0; color: var(--text-soft); max-width: var(--content); }
.archive-header__avatar { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; margin-bottom: 12px; }
/* 5 Seitenwechsel
========================================================================== */
.pagination {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 72px;
padding-top: 32px;
border-top: 1px solid var(--border);
}
.pagination__info { color: var(--text-soft); font-size: 1.5rem; text-align: center; }
.pagination__newer { justify-self: start; }
.pagination__older { justify-self: end; }
/* 6 Beitrag
========================================================================== */
.article__header { margin-bottom: 32px; }
.article__title { font-size: clamp(3.2rem, 4.5vw, 5rem); font-weight: 700; margin: 0 0 16px; }
.article__excerpt { margin: 0; font-size: 2rem; color: var(--text-soft); }
.article__image { margin-top: 0; margin-bottom: 48px; }
.article__image img { width: 100%; border-radius: var(--radius); box-shadow: var(--shadow); }
.article__image figcaption,
.gh-content figcaption { margin-top: 10px; color: var(--text-soft); font-size: 1.4rem; text-align: center; }
.article__comments { margin-top: 64px; }
.post-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-top: 72px; padding-top: 32px; border-top: 1px solid var(--border); }
.post-nav__link { display: flex; flex-direction: column; gap: 4px; color: var(--text); }
.post-nav__link:hover { text-decoration: none; color: var(--accent); }
.post-nav__link--older { text-align: right; grid-column: 2; }
.post-nav__label { color: var(--text-soft); font-size: 1.4rem; }
.post-nav__title { font-weight: 600; }
.error-page { padding: 80px 0; text-align: center; }
.error-page__code { margin: 0; font-size: 12rem; font-weight: 700; line-height: 1; color: var(--accent); }
.error-page__message { margin: 16px 0 32px; font-size: 2.6rem; }
/* 7 Inhalt und Editor-Karten
Alles im Inhalt ist auf Lesebreite begrenzt; breite/volle Karten brechen aus.
Die Karten selbst (Galerie, Video ...) gestaltet Ghost über "card_assets".
========================================================================== */
.gh-content { padding: 0 max(4vw, 20px); }
.gh-content > * { max-width: var(--content); margin: 0 auto 1.5em; }
.gh-content > .kg-width-wide { max-width: min(1140px, 100%); }
.gh-content > .kg-width-full { max-width: none; margin-left: calc(-1 * max(4vw, 20px)); margin-right: calc(-1 * max(4vw, 20px)); }
.gh-content > .kg-width-full img { width: 100%; }
.gh-content h1, .gh-content h2, .gh-content h3, .gh-content h4 { margin-top: 1.6em; margin-bottom: 0.5em; }
.gh-content h2 { font-size: 3rem; }
.gh-content h3 { font-size: 2.4rem; }
.gh-content h4 { font-size: 2rem; }
.gh-content a { text-decoration: underline; }
.gh-content blockquote { padding: 4px 0 4px 24px; border-left: 4px solid var(--accent); color: var(--text-soft); font-style: italic; }
.gh-content hr { border: 0; border-top: 1px solid var(--border); margin-top: 2.5em; margin-bottom: 2.5em; }
.gh-content pre { padding: 16px 20px; border-radius: var(--radius); background: #15171a; color: #e9ebee; overflow-x: auto; font-size: 1.5rem; }
.gh-content code { padding: 2px 5px; border-radius: 4px; background: var(--bg-soft); font-size: 0.9em; }
.gh-content pre code { padding: 0; background: none; }
.gh-content table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; font-size: 1.6rem; }
.gh-content th, .gh-content td { padding: 8px 12px; border: 1px solid var(--border); text-align: left; }
.gh-content th { background: var(--bg-soft); }
.gh-content iframe { max-width: 100%; }
.gh-content .kg-image { margin: 0 auto; border-radius: var(--radius); cursor: zoom-in; }
.gh-content .kg-gallery-image img { cursor: zoom-in; }
.gh-content .kg-card { margin-top: 2em; margin-bottom: 2em; }
/* Der Importer teilt große WordPress-Galerien in mehrere Ghost-Galerien (max. 9 Bilder):
direkt aufeinanderfolgende Galerien sollen wie eine einzige wirken */
.gh-content .kg-gallery-card + .kg-gallery-card { margin-top: -1.25em; }
.gh-content .kg-embed-card { display: flex; flex-direction: column; align-items: center; }
.gh-content .kg-embed-card iframe { width: 100%; aspect-ratio: 16 / 9; height: auto; }
/* Kommentare aus dem alten WordPress-Blog (vom Importer als HTML-Karte angehängt) */
.gh-content .wp-comments { margin-top: 4em; padding-top: 2em; border-top: 1px solid var(--border); }
.wp-comments__title { font-size: 2.4rem; margin: 0 0 1em; }
.wp-comments__list { list-style: none; margin: 0; padding: 0; }
.wp-comment { margin: 0 0 16px; padding: 16px 20px; border-radius: var(--radius); background: var(--bg-soft); }
.wp-comment__meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; margin-bottom: 6px; }
.wp-comment__author { font-weight: 700; }
.wp-comment__date { color: var(--text-soft); font-size: 1.4rem; }
.wp-comment__text { font-size: 1.6rem; }
.wp-comment__text p { margin: 0 0 0.6em; }
.wp-comment__text p:last-child { margin-bottom: 0; }
/* Antworten eingerückt unter ihrem Kommentar */
.wp-comments__replies { margin-top: 14px; padding-left: 18px; border-left: 3px solid var(--accent); }
.wp-comments__replies .wp-comment { margin-bottom: 10px; padding: 8px 0 8px 4px; background: none; }
.wp-comments__replies .wp-comment:last-child { margin-bottom: 0; }
/* 8 Fußzeile (schwarz)
========================================================================== */
.site-footer { background: #000; color: #b9bec6; font-size: 1.5rem; }
.site-footer a { color: #fff; }
.site-footer__inner { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 32px; padding-top: 56px; padding-bottom: 40px; }
.site-footer__title { font-family: var(--font-heading); font-size: 2.2rem; font-weight: 700; }
.site-footer__description { margin: 8px 0 0; max-width: 480px; }
.site-footer__nav ul { display: flex; flex-wrap: wrap; gap: 8px 24px; list-style: none; margin: 0; padding: 0; }
.site-footer__bottom {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px 24px;
padding-top: 20px;
padding-bottom: 28px;
border-top: 1px solid #2a2a2a;
font-size: 1.35rem;
}
/* 9 Bildanzeige (Klick auf ein Bild im Beitrag)
========================================================================== */
.lightbox {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.92);
cursor: zoom-out;
}
.lightbox[hidden] { display: none; }
.lightbox img { max-width: 100%; max-height: 100%; object-fit: contain; }
.lightbox__close { position: absolute; top: 12px; right: 16px; border: 0; background: none; color: #fff; font-size: 4rem; line-height: 1; cursor: pointer; }
/* 10 Kleine Bildschirme
========================================================================== */
@media (max-width: 1250px) {
/* Titel und Menü passen nicht mehr nebeneinander: Menü in eine eigene Zeile darunter */
.site-header__inner {
grid-template-rows: auto auto 1fr auto;
grid-template-areas:
"brand brand"
"nav nav"
". ."
"actions actions";
}
}
@media (max-width: 1000px) {
/* zwei Beiträge pro Zeile */
.post-card,
.post-feed--lead .post-card:nth-child(2),
.post-feed--lead .post-card:nth-child(3) { grid-column: span 3; }
/* Hamburger-Menü: Titel links, Knopf rechts, das Menü klappt darunter auf */
.site-header__inner {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
"brand toggle"
"nav nav"
". ."
"actions actions";
}
.nav-toggle { display: inline-flex; }
.site-nav { display: none; }
.site-nav.is-open { display: block; }
.site-nav ul { flex-direction: column; flex-wrap: nowrap; align-items: stretch; justify-content: flex-start; }
.site-nav > ul {
padding: 8px;
border-radius: var(--radius);
background: var(--header-menu-bg);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
/* Unterpunkte: eingerückt, per Pfeil auf- und zuklappbar (beide Ebenen) */
.nav-has-children { flex-wrap: wrap; }
.nav-has-children > a { flex: 1; }
.nav-submenu-toggle { padding: 10px 14px; }
.site-nav .nav-submenu {
position: static;
flex-basis: 100%;
min-width: 0;
margin: 2px 0 4px 14px;
padding: 0 0 0 8px;
border-left: 2px solid color-mix(in srgb, var(--header-text) 30%, transparent);
border-radius: 0;
background: none;
-webkit-backdrop-filter: none;
backdrop-filter: none;
box-shadow: none;
}
.site-nav .nav-submenu a { white-space: normal; }
}
@media (max-width: 700px) {
/* ein Beitrag pro Zeile, auch der erste untereinander */
.post-feed { gap: 48px 0; }
.post-card,
.post-feed--lead .post-card:nth-child(2),
.post-feed--lead .post-card:nth-child(3) { grid-column: 1 / -1; }
.post-feed--lead .post-card:nth-child(1) { grid-template-columns: minmax(0, 1fr); gap: 22px; }
.site-brand__logo { max-height: 44px; }
.site-brand { min-width: 0; }
.site-brand__title { white-space: normal; font-size: 2.2rem; }
.pagination { grid-template-columns: 1fr 1fr; }
.pagination__info { grid-column: 1 / -1; grid-row: 1; }
.post-nav { grid-template-columns: 1fr; }
.post-nav__link--older { grid-column: 1; text-align: left; }
}

View file

@ -0,0 +1,78 @@
(function () {
'use strict';
// Farbschema umschalten (hell/dunkel) und merken
var toggle = document.querySelector('.theme-toggle');
if (toggle) {
toggle.addEventListener('click', function () {
var dark = document.documentElement.getAttribute('data-theme') !== 'dark';
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
try { localStorage.setItem('theme', dark ? 'dark' : 'light'); } catch (e) {}
});
}
// Menü auf kleinen Bildschirmen auf-/zuklappen
var navToggle = document.querySelector('.nav-toggle');
var nav = document.getElementById('site-nav');
if (navToggle && nav) {
navToggle.addEventListener('click', function () {
var open = nav.classList.toggle('is-open');
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
// Aufklappmenüs per Klick/Tippen auf den Pfeil (mit der Maus öffnen sie auch beim Überfahren)
var submenuToggles = document.querySelectorAll('.nav-submenu-toggle');
var closeSubmenus = function (except) {
Array.prototype.forEach.call(document.querySelectorAll('.nav-has-children.is-open'), function (item) {
// Der Eintrag selbst und seine Oberpunkte bleiben offen
if (except && (item === except || item.contains(except))) return;
item.classList.remove('is-open');
item.querySelector(':scope > .nav-submenu-toggle').setAttribute('aria-expanded', 'false');
});
};
Array.prototype.forEach.call(submenuToggles, function (button) {
button.addEventListener('click', function (event) {
event.stopPropagation();
var item = button.parentNode;
closeSubmenus(item);
var open = item.classList.toggle('is-open');
button.setAttribute('aria-expanded', open ? 'true' : 'false');
});
});
document.addEventListener('click', function (event) {
if (!event.target.closest('.nav-has-children')) closeSubmenus(null);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') closeSubmenus(null);
});
// Bildanzeige: Klick auf ein Bild im Beitrag zeigt es groß
var content = document.querySelector('.gh-content');
if (content) {
var lightbox = document.createElement('div');
lightbox.className = 'lightbox';
lightbox.hidden = true;
lightbox.innerHTML = '<button class="lightbox__close" type="button" aria-label="Close">&times;</button><img alt="">';
document.body.appendChild(lightbox);
var lightboxImage = lightbox.querySelector('img');
var close = function () {
lightbox.hidden = true;
lightboxImage.removeAttribute('src');
};
content.addEventListener('click', function (event) {
var img = event.target.closest('.kg-image-card img, .kg-gallery-image img');
// Bilder, die selbst ein Link sind, behalten ihr Linkziel
if (!img || img.closest('a')) return;
lightboxImage.src = img.currentSrc || img.src;
lightboxImage.alt = img.alt || '';
lightbox.hidden = false;
});
lightbox.addEventListener('click', close);
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && !lightbox.hidden) close();
});
}
})();

View file

@ -0,0 +1,23 @@
{{!< default}}
{{!-- Übersicht aller Beiträge einer Autorin / eines Autors --}}
<main class="site-main container">
{{#author}}
<header class="archive-header">
{{#if profile_image}}
<img class="archive-header__avatar" src="{{img_url profile_image size="xs"}}" alt="{{name}}">
{{/if}}
<h1 class="archive-header__title">{{name}}</h1>
{{#if bio}}<p class="archive-header__description">{{bio}}</p>{{/if}}
</header>
{{/author}}
<div class="post-feed">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
{{pagination}}
</main>

View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="{{@site.locale}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{meta_title}}</title>
{{!-- Gewähltes Farbschema vor dem ersten Zeichnen setzen, damit nichts aufblitzt --}}
<script>
(function () {
try {
var stored = localStorage.getItem('theme');
var dark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="{{asset "css/screen.css"}}">
{{ghost_head}}
</head>
<body class="{{body_class}}">
{{> "site-header"}}
{{{body}}}
{{> "site-footer"}}
<script src="{{asset "js/main.js"}}" defer></script>
{{ghost_foot}}
</body>
</html>

View file

@ -0,0 +1,9 @@
{{!< default}}
{{!-- Fehlerseite (404 usw.) --}}
<main class="site-main container">
<section class="error-page">
<p class="error-page__code">{{statusCode}}</p>
<h1 class="error-page__message">{{message}}</h1>
<a class="btn" href="{{@site.url}}">{{t "Back to the homepage"}}</a>
</section>
</main>

View file

@ -0,0 +1,17 @@
{{!< default}}
{{!--
Startseite. Auf der ersten Seite steht der neueste Beitrag über die ganze Breite,
darunter zwei Beiträge, danach immer drei pro Zeile (siehe .post-feed--lead in screen.css).
Ab Seite 2 sind es durchgehend drei pro Zeile.
--}}
<main class="site-main container">
<div class="post-feed{{#match pagination.page 1}} post-feed--lead{{/match}}">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
{{pagination}}
</main>

View file

@ -0,0 +1,20 @@
{
"Read More": "Weiterlesen",
"Newer Posts": "Neuere Beiträge",
"Older Posts": "Ältere Beiträge",
"Newer Post": "Neuerer Beitrag",
"Older Post": "Älterer Beitrag",
"More posts": "Weitere Beiträge",
"Page {page} of {pages}": "Seite {page} von {pages}",
"Pagination": "Seitenwechsel",
"Menu": "Menü",
"Footer": "Fußzeile",
"Search": "Suche",
"Toggle dark mode": "Dunkles Farbschema umschalten",
"Tag": "Thema",
"1 min read": "1 Min. Lesezeit",
"% min read": "% Min. Lesezeit",
"Powered by": "Betrieben mit",
"Back to the homepage": "Zur Startseite",
"Close": "Schließen"
}

View file

@ -0,0 +1,20 @@
{
"Read More": "Read More",
"Newer Posts": "Newer Posts",
"Older Posts": "Older Posts",
"Newer Post": "Newer Post",
"Older Post": "Older Post",
"More posts": "More posts",
"Page {page} of {pages}": "Page {page} of {pages}",
"Pagination": "Pagination",
"Menu": "Menu",
"Footer": "Footer",
"Search": "Search",
"Toggle dark mode": "Toggle dark mode",
"Tag": "Tag",
"1 min read": "1 min read",
"% min read": "% min read",
"Powered by": "Powered by",
"Back to the homepage": "Back to the homepage",
"Close": "Close"
}

View file

@ -0,0 +1,62 @@
{
"name": "unterwegs",
"description": "Reiseblog-Theme: großer erster Beitrag, danach zwei, dann drei pro Zeile",
"version": "1.2.1",
"license": "MIT",
"engines": {
"ghost": ">=5.0.0"
},
"author": {
"name": "Roland Brunner",
"email": "roland.brunner@gmx.at"
},
"keywords": [
"ghost",
"theme",
"ghost-theme"
],
"config": {
"posts_per_page": 12,
"image_sizes": {
"xs": { "width": 160 },
"s": { "width": 400 },
"m": { "width": 750 },
"l": { "width": 1140 },
"xl": { "width": 1920 }
},
"card_assets": true,
"custom": {
"header_background": {
"type": "image",
"description": "Hintergrundbild der Kopfzeile (leer = Titelbild der Publikation)"
},
"header_overlay": {
"type": "select",
"options": ["Hell", "Dunkel", "Keine"],
"default": "Hell",
"description": "Abtönung über dem Hintergrundbild, damit Titel und Menü lesbar bleiben"
},
"show_site_title": {
"type": "boolean",
"default": true,
"description": "Titel der Seite neben dem Logo anzeigen"
},
"linkedin_url": {
"type": "text",
"description": "LinkedIn-Profil (vollständige Adresse)"
},
"bluesky_url": {
"type": "text",
"description": "Bluesky-Profil (vollständige Adresse)"
},
"mastodon_url": {
"type": "text",
"description": "Mastodon-Profil (vollständige Adresse)"
},
"footer_text": {
"type": "text",
"description": "Zusätzlicher Text in der Fußzeile"
}
}
}
}

View file

@ -0,0 +1,32 @@
{{!< default}}
{{!-- Statische Seite --}}
{{#post}}
<main class="site-main">
<article class="article {{post_class}}">
{{#match @page.show_title_and_feature_image}}
<header class="article__header container container--narrow">
<h1 class="article__title">{{title}}</h1>
</header>
{{#if feature_image}}
<figure class="article__image container">
<img
srcset="{{img_url feature_image size="m"}} 750w,
{{img_url feature_image size="l"}} 1140w,
{{img_url feature_image size="xl"}} 1920w"
sizes="(max-width: 1200px) 100vw, 1140px"
src="{{img_url feature_image size="l"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}">
{{#if feature_image_caption}}<figcaption>{{feature_image_caption}}</figcaption>{{/if}}
</figure>
{{/if}}
{{/match}}
<section class="gh-content">
{{content}}
</section>
</article>
</main>
{{/post}}

View file

@ -0,0 +1,16 @@
{{!-- Seitenwechsel der Beitragsübersicht --}}
<nav class="pagination" aria-label="{{t "Pagination"}}">
{{#if prev}}
<a class="btn btn--outline pagination__newer" href="{{page_url prev}}">&larr; {{t "Newer Posts"}}</a>
{{else}}
<span class="pagination__spacer"></span>
{{/if}}
<span class="pagination__info">{{t "Page {page} of {pages}" page=page pages=pages}}</span>
{{#if next}}
<a class="btn btn--outline pagination__older" href="{{page_url next}}">{{t "Older Posts"}} &rarr;</a>
{{else}}
<span class="pagination__spacer"></span>
{{/if}}
</nav>

View file

@ -0,0 +1,38 @@
{{!-- Ein Beitrag in der Übersicht --}}
<article class="post-card {{post_class}}{{#unless feature_image}} post-card--no-image{{/unless}}">
{{#if feature_image}}
<a class="post-card__image" href="{{url}}" tabindex="-1" aria-hidden="true">
<img
srcset="{{img_url feature_image size="s"}} 400w,
{{img_url feature_image size="m"}} 750w,
{{img_url feature_image size="l"}} 1140w"
sizes="(max-width: 700px) 100vw, 600px"
src="{{img_url feature_image size="m"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"
loading="lazy">
</a>
{{/if}}
<div class="post-card__body">
{{#if tags}}
<div class="tag-list">
{{#foreach tags}}<a class="tag-badge" href="{{url}}">{{name}}</a>{{/foreach}}
</div>
{{/if}}
<h2 class="post-card__title"><a href="{{url}}">{{title}}</a></h2>
<div class="post-meta">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<time datetime="{{date format="YYYY-MM-DD"}}">{{date format="D. MMMM YYYY"}}</time>
</div>
{{#if excerpt}}
<p class="post-card__excerpt">{{excerpt words="26"}}…</p>
{{/if}}
<a class="btn" href="{{url}}">{{t "Read More"}}</a>
</div>
</article>

View file

@ -0,0 +1,20 @@
{{!-- Schwarze Fußzeile --}}
<footer class="site-footer">
<div class="site-footer__inner container">
<div class="site-footer__brand">
<a class="site-footer__title" href="{{@site.url}}">{{@site.title}}</a>
{{#if @site.description}}<p class="site-footer__description">{{@site.description}}</p>{{/if}}
{{#if @custom.footer_text}}<p class="site-footer__description">{{@custom.footer_text}}</p>{{/if}}
</div>
<nav class="site-footer__nav" aria-label="{{t "Footer"}}">
{{navigation type="secondary"}}
</nav>
</div>
<div class="site-footer__bottom container">
<span>&copy; {{date format="YYYY"}} {{@site.title}}</span>
<span>{{t "Powered by"}} <a href="https://ghost.org/" target="_blank" rel="noopener">Ghost</a></span>
</div>
</footer>

View file

@ -0,0 +1,121 @@
{{!-- Kopfzeile: Logo links, Menü, Symbole rechts — dahinter das Hintergrundbild --}}
<header class="site-header site-header--overlay-{{#match @custom.header_overlay "Dunkel"}}dark{{else match @custom.header_overlay "Keine"}}none{{else}}light{{/match}}">
{{!-- Hintergrundbild als echtes Bild: so bestimmt es die Höhe der Kopfzeile und wird ganz gezeigt --}}
{{#if @custom.header_background}}
<img class="site-header__bg" alt=""
srcset="{{img_url @custom.header_background size="m"}} 750w, {{img_url @custom.header_background size="l"}} 1140w, {{img_url @custom.header_background size="xl"}} 1920w"
sizes="100vw" src="{{img_url @custom.header_background size="xl"}}">
{{else if @site.cover_image}}
<img class="site-header__bg" alt=""
srcset="{{img_url @site.cover_image size="m"}} 750w, {{img_url @site.cover_image size="l"}} 1140w, {{img_url @site.cover_image size="xl"}} 1920w"
sizes="100vw" src="{{img_url @site.cover_image size="xl"}}">
{{/if}}
<div class="site-header__inner">
<a class="site-brand" href="{{@site.url}}">
{{#if @site.logo}}
<img class="site-brand__logo" src="{{img_url @site.logo size="s"}}" alt="{{@site.title}}">
{{/if}}
{{#if @custom.show_site_title}}
<span class="site-brand__title">{{@site.title}}</span>
{{else}}
{{#unless @site.logo}}<span class="site-brand__title">{{@site.title}}</span>{{/unless}}
{{/if}}
</a>
<button class="nav-toggle" type="button" aria-label="{{t "Menu"}}" aria-expanded="false" aria-controls="site-nav">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
<nav class="site-nav" id="site-nav" aria-label="{{t "Menu"}}">
{{navigation}}
</nav>
{{!--
Aufklappmenüs: Ghost kennt nur ein flaches Menü. Die Ebene ergibt sich aus den
Bindestrichen am Anfang des Namens:
Unsere Reisen
- Europa (Unterpunkt von "Unsere Reisen")
-- Zypern (Unterpunkt von "Europa")
- Asien
Das Skript steht direkt hinter dem Menü, damit nichts sichtbar umspringt.
--}}
<script>
(function () {
var list = document.querySelector('#site-nav > ul');
if (!list) return;
var chevron = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>';
var submenuOf = function (parent) {
var submenu = parent.querySelector(':scope > .nav-submenu');
if (submenu) return submenu;
submenu = document.createElement('ul');
submenu.className = 'nav-submenu';
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'nav-submenu-toggle';
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-label', parent.querySelector('a').textContent);
toggle.innerHTML = chevron;
parent.classList.add('nav-has-children');
parent.appendChild(toggle);
parent.appendChild(submenu);
return submenu;
};
// parents[0] = letzter Hauptpunkt, parents[1] = letzter Unterpunkt der ersten Ebene
var parents = [null, null];
Array.prototype.slice.call(list.children).forEach(function (item) {
var link = item.querySelector('a');
if (!link) return;
var match = /^\s*((?:[-\u2013\u2014]\s*)+)(.+)$/.exec(link.textContent);
var depth = match ? Math.min(match[1].replace(/\s/g, '').length, 2) : 0;
// Ohne passenden Oberpunkt eine Ebene höher einhängen
while (depth > 0 && !parents[depth - 1]) depth--;
if (depth === 0) {
parents = [item, null];
return;
}
link.textContent = match[2];
submenuOf(parents[depth - 1]).appendChild(item);
if (depth === 1) parents[1] = item;
// Ist ein Unterpunkt die aktuelle Seite, werden auch seine Oberpunkte hervorgehoben
if (item.classList.contains('nav-current')) {
for (var i = 0; i < depth; i++) parents[i].classList.add('nav-current-parent');
}
});
})();
</script>
<div class="site-actions">
{{#if @custom.linkedin_url}}
<a class="icon-link" href="{{@custom.linkedin_url}}" target="_blank" rel="noopener" aria-label="LinkedIn">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9.75h4V21H3V9.75zm6.5 0h3.8v1.6h.06c.53-1 1.83-1.9 3.76-1.9 4.02 0 4.88 2.5 4.88 6.1V21h-4v-4.9c0-1.2-.02-2.7-1.7-2.7s-1.96 1.3-1.96 2.6V21h-4V9.75z"/></svg>
</a>
{{/if}}
{{#if @custom.bluesky_url}}
<a class="icon-link" href="{{@custom.bluesky_url}}" target="_blank" rel="noopener" aria-label="Bluesky">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M5.2 3.6C7.95 5.66 10.9 9.85 12 12.1c1.1-2.25 4.05-6.44 6.8-8.5 1.98-1.5 5.2-2.65 5.2 1.03 0 .73-.42 6.17-.67 7.05-.86 3.07-3.99 3.85-6.78 3.38 4.87.83 6.11 3.57 3.43 6.32-5.08 5.21-7.3-1.31-7.87-2.98-.1-.31-.15-.45-.11-.33-.04-.12-.01.02-.11.33-.57 1.67-2.79 8.19-7.87 2.98-2.68-2.75-1.44-5.49 3.43-6.32-2.79.47-5.92-.31-6.78-3.38C.42 10.8 0 5.36 0 4.63 0 .95 3.22 2.1 5.2 3.6z"/></svg>
</a>
{{/if}}
{{#if @custom.mastodon_url}}
<a class="icon-link" href="{{@custom.mastodon_url}}" target="_blank" rel="me noopener" aria-label="Mastodon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M21.33 14.34c-.3 1.52-2.66 3.19-5.37 3.51-1.41.17-2.8.32-4.29.25-2.42-.11-4.34-.58-4.34-.58 0 .24.02.46.05.67.31 2.38 2.37 2.53 4.31 2.59 1.96.07 3.71-.48 3.71-.48l.08 1.77s-1.37.74-3.82.87c-1.35.07-3.02-.03-4.97-.55C2.46 21.27 1.73 16.74 1.62 12.14c-.03-1.36-.01-2.65-.01-3.73 0-4.7 3.08-6.08 3.08-6.08C6.24 1.62 8.9 1.32 11.67 1.3h.07c2.77.02 5.43.32 6.98 1.03 0 0 3.08 1.38 3.08 6.08 0 0 .04 3.47-.47 5.93zM18.4 8.83c0-1.16-.3-2.09-.89-2.77-.61-.68-1.41-1.03-2.4-1.03-1.15 0-2.02.44-2.6 1.32L12 7.3l-.56-.94c-.58-.88-1.45-1.32-2.6-1.32-.99 0-1.79.35-2.4 1.03-.59.68-.89 1.61-.89 2.77v5.68h2.25V9c0-1.16.49-1.75 1.47-1.75 1.08 0 1.62.7 1.62 2.08v3.02h2.24V9.34c0-1.38.54-2.08 1.62-2.08.98 0 1.47.59 1.47 1.75v5.51h2.25V8.83z"/></svg>
</a>
{{/if}}
<button class="icon-link theme-toggle" type="button" aria-label="{{t "Toggle dark mode"}}">
<svg class="theme-toggle__moon" viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<svg class="theme-toggle__sun" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
</button>
<button class="icon-link" type="button" data-ghost-search aria-label="{{t "Search"}}">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
</button>
</div>
</div>
</header>

View file

@ -0,0 +1,74 @@
{{!< default}}
{{!-- Einzelner Beitrag --}}
{{#post}}
<main class="site-main">
<article class="article {{post_class}}">
<header class="article__header container container--narrow">
{{#if tags}}
<div class="tag-list">
{{#foreach tags}}<a class="tag-badge" href="{{url}}">{{name}}</a>{{/foreach}}
</div>
{{/if}}
<h1 class="article__title">{{title}}</h1>
<div class="post-meta">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<time datetime="{{date format="YYYY-MM-DD"}}">{{date format="D. MMMM YYYY"}}</time>
<span class="post-meta__sep">&middot;</span>
<span>{{reading_time minute=(t "1 min read") minutes=(t "% min read")}}</span>
{{#if primary_author}}
<span class="post-meta__sep">&middot;</span>
<span>{{authors separator=", "}}</span>
{{/if}}
</div>
{{#if custom_excerpt}}
<p class="article__excerpt">{{custom_excerpt}}</p>
{{/if}}
</header>
{{#if feature_image}}
<figure class="article__image container">
<img
srcset="{{img_url feature_image size="m"}} 750w,
{{img_url feature_image size="l"}} 1140w,
{{img_url feature_image size="xl"}} 1920w"
sizes="(max-width: 1200px) 100vw, 1140px"
src="{{img_url feature_image size="l"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}">
{{#if feature_image_caption}}<figcaption>{{feature_image_caption}}</figcaption>{{/if}}
</figure>
{{/if}}
<section class="gh-content">
{{content}}
</section>
</article>
<nav class="post-nav container container--narrow" aria-label="{{t "More posts"}}">
{{#next_post}}
<a class="post-nav__link post-nav__link--newer" href="{{url}}">
<span class="post-nav__label">&larr; {{t "Newer Post"}}</span>
<span class="post-nav__title">{{title}}</span>
</a>
{{else}}
<span></span>
{{/next_post}}
{{#prev_post}}
<a class="post-nav__link post-nav__link--older" href="{{url}}">
<span class="post-nav__label">{{t "Older Post"}} &rarr;</span>
<span class="post-nav__title">{{title}}</span>
</a>
{{/prev_post}}
</nav>
{{#if comments}}
<section class="article__comments container container--narrow">
{{comments}}
</section>
{{/if}}
</main>
{{/post}}

View file

@ -0,0 +1,21 @@
{{!< default}}
{{!-- Übersicht aller Beiträge eines Tags (in WordPress: Kategorie/Schlagwort) --}}
<main class="site-main container">
{{#tag}}
<header class="archive-header">
<span class="archive-header__label">{{t "Tag"}}</span>
<h1 class="archive-header__title">{{name}}</h1>
{{#if description}}<p class="archive-header__description">{{description}}</p>{{/if}}
</header>
{{/tag}}
<div class="post-feed">
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
</div>
{{pagination}}
</main>

View file

@ -246,7 +246,9 @@ function rbr_get_post_details_with_media($data) {
$wp_comments = get_comments(array(
'post_id' => $post_id,
'status' => 'approve',
'number' => 100,
'number' => 500,
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
));
foreach ($wp_comments as $comment) {
$comments[] = array(
@ -256,6 +258,10 @@ function rbr_get_post_details_with_media($data) {
'author_url' => $comment->comment_author_url,
'content' => $comment->comment_content,
'date' => mysql_to_rfc3339($comment->comment_date),
// Typ: 'comment', 'pingback', 'trackback' oder Reaktionen anderer Plugins ('like', 'repost')
'type' => get_comment_type($comment),
// Antwort auf diesen Kommentar (0 = keine Antwort)
'parent' => intval($comment->comment_parent),
);
}
@ -264,6 +270,8 @@ function rbr_get_post_details_with_media($data) {
'title' => $post->post_title,
'slug' => $post->post_name,
'date' => mysql_to_rfc3339($post->post_date),
// Dasselbe Datum in UTC, damit die Zeitzone beim Import eindeutig ist
'date_gmt' => mysql_to_rfc3339($post->post_date_gmt),
'content' => $content,
'excerpt' => wp_trim_words(strip_tags($post->post_excerpt), 55),
'author' => array(

View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<title>WP2Ghost</title>
<link rel="stylesheet" href="app.css" />
<HeadOutlet @rendermode="RenderModes.Server" />
</head>
<body>
<Routes @rendermode="RenderModes.Server" />
<script src="_framework/blazor.web.js"></script>
<script>
// Protokoll nach neuen Zeilen ans Ende rollen
window.wp2ghost = {
scrollToEnd: function (id) {
var element = document.getElementById(id);
if (element) element.scrollTop = element.scrollHeight;
},
focus: function (id) {
var element = document.getElementById(id);
if (element) element.focus();
}
};
</script>
</body>
</html>

View file

@ -0,0 +1,562 @@
@page "/"
@implements IDisposable
@inject ImporterSession Session
@inject PostCatalog Catalog
@inject LinkSearchService Search
@inject IJSRuntime JS
<header class="topbar">
<h1>WP2Ghost</h1>
<div class="topbar__status">
<span class="chip" title="@Importer.WpBaseUrl">WordPress: @Short(Importer.WpBaseUrl)</span>
@* Ziel für Senden, Prüfen und Abgleich *@
<span class="ghost-switch">
Ghost:
<span class="ghost-switch__options">
@for (int i = 0; i < Importer.GhostTargets.Length; i++)
{
int index = i;
var target = Importer.GhostTargets[index];
<button class="ghost-switch__option @(index == Importer.ActiveGhost ? "is-active" : "") @(target.IsConfigured ? "" : "is-warn")"
title="@(target.IsConfigured ? target.Url : "noch nicht eingerichtet")"
disabled="@(Session.IsBusy || index == Importer.ActiveGhost)"
@onclick="() => SwitchGhost(index)">
@Importer.GhostLabel(index)
</button>
}
</span>
</span>
<span class="chip" title="@Session.DataDir">Daten: @Session.DataDir</span>
</div>
<nav class="tabs">
<button class="tab @(_tab == "posts" ? "is-active" : "")" @onclick='() => _tab = "posts"'>Posts</button>
<button class="tab @(_tab == "settings" ? "is-active" : "")" @onclick='() => _tab = "settings"'>Einstellungen</button>
</nav>
</header>
<div class="layout">
<main class="main">
@if (_link != null && Session.Prompt != null)
{
@* Toter Link: Ersatz suchen, in der Vorschau ansehen und direkt übernehmen *@
<section class="card assistant">
<h2>Toter Link – Ersatz suchen</h2>
<div class="assistant__facts">
<div><span class="assistant__key">Linktext</span> @(_link.Text.Length > 0 ? _link.Text : "(ohne Text)")</div>
<div><span class="assistant__key">Alte Adresse</span> <a href="@_link.Href" target="_blank" rel="noopener noreferrer">@_link.Href</a></div>
<div><span class="assistant__key">Problem</span> @_link.Reason</div>
</div>
<form class="assistant__search" @onsubmit="() => SearchAsync()" @onsubmit:preventDefault>
<input type="search" @bind="_searchQuery" placeholder="Suchbegriff" />
<button class="btn" type="submit" disabled="@_searching">Suchen</button>
<a class="btn" href="@("https://www.google.com/search?q=" + Uri.EscapeDataString(_searchQuery))" target="_blank" rel="noopener noreferrer">Bei Google suchen ↗</a>
</form>
<div class="assistant__body">
<div class="assistant__results">
@if (_searching) { <p class="hint">Suche läuft …</p> }
@if (_searchError != null) { <p class="hint assistant__error">@_searchError</p> }
@if (!_searching && _searchError == null && _suggestions.Count == 0) { <p class="hint">Keine Treffer. Suchbegriff ändern oder eine Adresse rechts eintragen.</p> }
@foreach (var suggestion in _suggestions)
{
<div class="suggestion @(suggestion.Url == _previewUrl ? "is-active" : "")">
<div class="suggestion__title">
@if (suggestion.Source == "Archiv") { <span class="badge badge--warn">Archiv</span> }
@suggestion.Title
</div>
<div class="suggestion__url">@suggestion.Url</div>
@if (suggestion.Snippet.Length > 0) { <div class="suggestion__snippet">@suggestion.Snippet</div> }
<div class="suggestion__actions">
<button class="btn btn--small" @onclick="() => Preview(suggestion.Url)">Vorschau</button>
<button class="btn btn--small btn--primary" @onclick="() => Adopt(suggestion.Url)">Übernehmen</button>
</div>
</div>
}
</div>
<div class="assistant__preview">
<form class="assistant__address" @onsubmit="() => Preview(_addressInput)" @onsubmit:preventDefault>
<input type="url" @bind="_addressInput" placeholder="https://… Adresse eingeben oder einen Treffer wählen" />
<button class="btn btn--small" type="submit">Anzeigen</button>
<button class="btn btn--small btn--primary" type="button" disabled="@(!IsWebAddress(_addressInput))" @onclick="() => Adopt(_addressInput)">Diese Adresse übernehmen</button>
</form>
@if (_previewUrl.Length > 0)
{
<iframe class="assistant__frame" src="@_previewUrl" title="Vorschau" referrerpolicy="no-referrer"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"></iframe>
<p class="hint">
Bleibt die Vorschau leer, verbietet die Seite das Einbetten –
<a href="@_previewUrl" target="_blank" rel="noopener noreferrer">in neuem Tab öffnen ↗</a>.
Klickst du in der Vorschau weiter, bitte die gewünschte Adresse oben eintragen: die Oberfläche kann sie aus der Vorschau nicht auslesen.
</p>
}
else
{
<div class="assistant__placeholder">„Vorschau“ bei einem Treffer zeigt die Seite hier an.</div>
}
</div>
</div>
<p class="hint">Beibehalten oder Löschen wählst du rechts bei der Rückfrage.</p>
</section>
}
@if (_tab == "posts")
{
<section class="card">
<h2>Abläufe</h2>
<div class="actions">
<button class="btn btn--primary" disabled="@Session.IsBusy" @onclick="Fetch">Von WordPress abrufen</button>
<button class="btn btn--primary" disabled="@(Session.IsBusy || SelectedActive.Count == 0 || !GhostApi.IsConfigured())" @onclick="Send">
An @ActiveLabel senden (@SelectedActive.Count)
</button>
<button class="btn" disabled="@(Session.IsBusy || !GhostApi.IsConfigured())" @onclick="Check">Prüfen &amp; korrigieren</button>
<button class="btn" disabled="@(Session.IsBusy || !GhostApi.IsConfigured())" @onclick="Compare">Mit Ghost abgleichen</button>
</div>
<p class="hint">
Rückfragen (tote Links, primäre Kategorie, Untertag, „?“-Fälle) erscheinen rechts im Ablauf.
Gesendet, geprüft und abgeglichen wird mit der oben gewählten Ghost-Instanz.
@if (!GhostApi.IsConfigured())
{
<span> Für das Senden bitte zuerst unter „Einstellungen“ Ghost-URL und Admin-API-Schlüssel von @ActiveLabel eintragen.</span>
}
</p>
<details class="more">
<summary>Alle Beiträge neu abrufen</summary>
<p class="hint">
Der Abruf holt nur Beiträge, die noch nicht abgefragt wurden. Zurücksetzen leert diese Liste,
sodass der nächste Abruf alle Beiträge neu holt. Bereits abgerufene Dateien werden dabei überschrieben.
</p>
<label class="check"><input type="checkbox" @bind="_confirmReset" /> Ja, Liste der abgefragten Beiträge zurücksetzen</label>
<button class="btn btn--danger" disabled="@(Session.IsBusy || !_confirmReset)" @onclick="ResetProcessed">Zurücksetzen</button>
</details>
</section>
<section class="card">
<div class="card__head">
<h2>Abgerufene Posts <span class="count">@Visible.Count von @_rows.Count</span></h2>
<div class="filters">
<select @bind="_filter">
<option value="active">Offen</option>
<option value="archived">Archiviert</option>
<option value="all">Alle</option>
</select>
<input type="search" placeholder="Titel oder ID suchen" @bind="_search" @bind:event="oninput" />
</div>
</div>
<div class="selectbar">
<span>Bereich:</span>
<input type="number" placeholder="Von ID" @bind="_fromId" />
<input type="number" placeholder="Bis ID" @bind="_toId" />
<button class="btn btn--small" @onclick="SelectRange">auswählen</button>
<button class="btn btn--small" @onclick="SelectVisible">alle sichtbaren</button>
<button class="btn btn--small" disabled="@(_selected.Count == 0)" @onclick="() => _selected.Clear()">Auswahl aufheben</button>
<span class="spacer"></span>
<span class="count">@_selected.Count ausgewählt</span>
<button class="btn btn--small" disabled="@(Session.IsBusy || SelectedActive.Count == 0)" @onclick="Archive">Archivieren</button>
<button class="btn btn--small" disabled="@(Session.IsBusy || SelectedArchived.Count == 0)" @onclick="Restore">Zurückholen</button>
</div>
@if (_rows.Count == 0)
{
<p class="empty">Noch keine Posts abgerufen. „Von WordPress abrufen“ holt sie in den Datenordner.</p>
}
else
{
<div class="tablewrap">
<table class="posts">
<thead>
<tr>
<th class="col-check"></th>
<th class="col-id">ID</th>
<th>Titel</th>
<th class="col-date">Datum</th>
<th>Kategorien</th>
<th class="col-num" title="Bilder">Bilder</th>
<th class="col-num" title="Videos">Videos</th>
<th class="col-num" title="Kommentare">Komm.</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var row in Visible)
{
<tr class="@(_selected.Contains(row.WpId) ? "is-selected" : "")" @key="row.WpId">
<td class="col-check"><input type="checkbox" checked="@_selected.Contains(row.WpId)" @onchange="e => Toggle(row.WpId, (bool)e.Value!)" /></td>
<td class="col-id">@row.WpId</td>
<td class="col-title" title="@row.GhostSlug">@row.Title</td>
<td class="col-date">@(row.Date?.ToString("dd.MM.yyyy") ?? "—")</td>
<td class="col-cats">@string.Join(", ", row.Categories)</td>
<td class="col-num">@row.Images</td>
<td class="col-num">@(row.Videos > 0 ? row.Videos.ToString() : "")</td>
<td class="col-num">@(row.Comments > 0 ? row.Comments.ToString() : "")</td>
<td>
@if (row.Archived) { <span class="badge badge--muted">archiviert</span> }
@switch (row.Ghost)
{
case GhostState.Present: <span class="badge badge--ok">in @ActiveLabel</span> break;
case GhostState.WrongDate: <span class="badge badge--warn">in @ActiveLabel, Datum weicht ab</span> break;
case GhostState.Missing: <span class="badge">nicht in @ActiveLabel</span> break;
}
</td>
</tr>
}
</tbody>
</table>
</div>
@if (Catalog.GhostCheckedAt is DateTime checkedAt)
{
<p class="hint">Stand des Abgleichs mit @ActiveLabel: @checkedAt.ToString("dd.MM.yyyy HH:mm")</p>
}
else
{
<p class="hint">Ob ein Post schon in @ActiveLabel ist, zeigt die Liste nach „Mit Ghost abgleichen“.</p>
}
}
</section>
}
else
{
<section class="card">
<h2>Einstellungen</h2>
<div class="form">
<label>WordPress-URL
<input type="url" @bind="_wpUrl" placeholder="https://www.beispiel.at" />
</label>
<label>Maximale Anzahl Beiträge pro Abruf <span class="hint">(leer = alle)</span>
<input type="number" @bind="_maxPosts" min="1" />
</label>
@for (int i = 0; i < _ghostForms.Length; i++)
{
int index = i;
var form = _ghostForms[index];
<fieldset class="instance @(index == Importer.ActiveGhost ? "is-active" : "")">
<legend>Ghost-Instanz @(index + 1)@(index == Importer.ActiveGhost ? " (aktiv)" : "")</legend>
<label>Name <span class="hint">(erscheint im Umschalter oben, z. B. „Test“ oder „Live“)</span>
<input type="text" @bind="form.Name" placeholder="Ghost @(index + 1)" />
</label>
<label>Ghost-URL <span class="hint">(öffentliche Adresse der Seite – sie wird auch für interne Links verwendet)</span>
<input type="url" @bind="form.Url" placeholder="https://blog.beispiel.at" />
</label>
<label>Ghost Admin-API-Schlüssel <span class="hint">(@(string.IsNullOrEmpty(Importer.GhostTargets[index].AdminApiKey) ? "noch nicht gesetzt" : "gesetzt – leer lassen, um ihn zu behalten"))</span>
<input type="password" @bind="form.AdminKey" autocomplete="off" placeholder="id:secret" />
</label>
</fieldset>
}
<label>Name der ZIP-Datei für den Export
<input type="text" @bind="_zipPath" />
</label>
<div class="actions">
<button class="btn btn--primary" disabled="@Session.IsBusy" @onclick="SaveSettings">Speichern</button>
@if (_settingsMessage != null) { <span class="hint">@_settingsMessage</span> }
</div>
</div>
<p class="hint">
Gespeichert wird in <code>config.txt</code> im Datenordner – dieselbe Datei, die auch das Konsolenprogramm liest.
Das Archiv gilt für beide Ghost-Instanzen gemeinsam.
Bitte Konsole und Oberfläche nicht gleichzeitig auf demselben Datenordner arbeiten lassen.
</p>
</section>
}
</main>
<aside class="side">
<section class="card card--log">
<div class="card__head">
<h2>
Ablauf
@if (Session.IsBusy) { <span class="badge badge--run">@Session.CurrentTask läuft</span> }
</h2>
<div>
<button class="btn btn--small" disabled="@(!Session.IsBusy)" @onclick="Session.Cancel" title="Bricht bei der nächsten Rückfrage ab">Abbrechen</button>
<button class="btn btn--small" disabled="@Session.IsBusy" @onclick="Session.ClearLog">Leeren</button>
</div>
</div>
<div class="log" id="log">
@foreach (string line in Session.GetLines())
{
<div class="log__line @LineClass(line)">@line</div>
}
</div>
@if (Session.Prompt is string prompt)
{
<div class="prompt">
<div class="prompt__label">Rückfrage</div>
<div class="prompt__text">@prompt.Trim()</div>
<div class="prompt__options">
@foreach (var option in PromptOptions.Parse(prompt))
{
<button class="btn btn--option" @onclick="() => Reply(option.Value)">@option.Label</button>
}
</div>
<form class="prompt__input" @onsubmit="() => Reply(_answer)" @onsubmit:preventDefault>
<input id="answer" type="text" @bind="_answer" @bind:event="oninput" placeholder="Antwort eingeben …" autocomplete="off" />
<button class="btn btn--primary" type="submit">Senden</button>
</form>
</div>
}
else if (!Session.IsBusy)
{
<p class="hint">Kein Ablauf aktiv.</p>
}
</section>
</aside>
</div>
@code {
private string _tab = "posts";
private List<PostRow> _rows = new();
private readonly HashSet<int> _selected = new();
private string _filter = "active";
private string _search = "";
private int? _fromId, _toId;
private bool _confirmReset;
private string _answer = "";
private int _seenRuns;
private string? _lastPrompt;
// Einstellungen
private string _wpUrl = "", _zipPath = "";
private int? _maxPosts;
private string? _settingsMessage;
// Eingabefelder je Ghost-Instanz; der Schlüssel wird nie angezeigt
private class GhostForm { public string Name = "", Url = "", AdminKey = ""; }
private readonly GhostForm[] _ghostForms = Importer.GhostTargets.Select(_ => new GhostForm()).ToArray();
private static string ActiveLabel => Importer.GhostLabel(Importer.ActiveGhost);
private List<PostRow> Visible => _rows
.Where(r => _filter == "all" || (_filter == "archived") == r.Archived)
.Where(r => _search.Length == 0 || r.Title.Contains(_search, StringComparison.OrdinalIgnoreCase) || r.WpId.ToString().Contains(_search))
.ToList();
private List<PostRow> SelectedActive => _rows.Where(r => _selected.Contains(r.WpId) && !r.Archived).ToList();
private List<PostRow> SelectedArchived => _rows.Where(r => _selected.Contains(r.WpId) && r.Archived).ToList();
protected override void OnInitialized()
{
Session.Changed += OnSessionChanged;
_seenRuns = Session.CompletedRuns;
LoadSettings();
Reload();
}
public void Dispose() => Session.Changed -= OnSessionChanged;
// ---- Toter Link: Ersatz suchen ----
private LinkQuestion? _link;
private List<LinkSuggestion> _suggestions = new();
private string _searchQuery = "", _previewUrl = "", _addressInput = "";
private bool _searching;
private string? _searchError;
private static bool IsWebAddress(string text) =>
Uri.TryCreate(text.Trim(), UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
// Fragt der Import zu einem (anderen) toten Link, sofort passend dazu suchen
private void TrackLinkQuestion()
{
var current = Session.Prompt != null ? LinkQuestion.Current : null;
if (current == _link) return;
_link = current;
_suggestions = new();
_previewUrl = _addressInput = "";
_searchError = null;
if (current == null) return;
_searchQuery = current.Text;
_ = SearchAsync(includeArchive: true);
}
private async Task SearchAsync(bool includeArchive = false)
{
var link = _link;
if (link == null) return;
_searching = true;
_searchError = null;
StateHasChanged();
var found = new List<LinkSuggestion>();
try
{
// Archivfassung der alten Adresse nur beim ersten Suchen holen und danach behalten
var archived = includeArchive ? await Search.FindArchivedAsync(link.Href) : _suggestions.FirstOrDefault(s => s.Source == "Archiv");
if (archived != null) found.Add(archived);
found.AddRange(await Search.SearchAsync(_searchQuery));
}
catch (Exception ex)
{
_searchError = $"Die automatische Suche geht gerade nicht: {ex.Message}. „Bei Google suchen“ öffnet die Suche in einem neuen Tab; die gefundene Adresse kannst du rechts eintragen und übernehmen.";
}
// Inzwischen kann schon der nächste Link an der Reihe sein
if (link == _link)
{
_suggestions = found;
_searching = false;
await InvokeAsync(StateHasChanged);
}
}
private void Preview(string url)
{
url = url.Trim();
if (!IsWebAddress(url)) return;
_previewUrl = _addressInput = url;
}
// Übernehmen = im Import "bearbeiten" wählen und die neue Adresse eintragen
private void Adopt(string url)
{
url = url.Trim();
if (!IsWebAddress(url) || Session.Prompt is not string prompt) return;
if (prompt.Contains("Neue URL eingeben")) Session.Answer(url);
else Session.AnswerMany("e", url);
}
private void OnSessionChanged() => InvokeAsync(() =>
{
TrackLinkQuestion();
// Nach einem Ablauf haben sich Dateien geändert (abgerufen, archiviert ...)
if (Session.CompletedRuns != _seenRuns)
{
_seenRuns = Session.CompletedRuns;
Reload();
}
StateHasChanged();
});
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("wp2ghost.scrollToEnd", "log");
// Bei einer neuen Rückfrage direkt ins Antwortfeld
if (Session.Prompt != _lastPrompt)
{
_lastPrompt = Session.Prompt;
if (_lastPrompt != null) await JS.InvokeVoidAsync("wp2ghost.focus", "answer");
}
}
private void Reload()
{
_rows = Catalog.Load();
_selected.IntersectWith(_rows.Select(r => r.WpId));
}
// ---- Abläufe ----
private void Fetch() => Session.Run("Abruf von WordPress", () => PostFetcher.FetchFromWordPressAsync(Importer.WpBaseUrl, Importer.MaxPosts));
private void Send()
{
var posts = SelectedActive.OrderBy(r => r.WpId).Select(r => (r.Path, r.WpId)).ToList();
if (posts.Count == 0) return;
Session.Run($"Senden an {ActiveLabel} ({posts.Count} Post(s))", () => Importer.SendPostsAsync(posts));
}
private void Check() => Session.Run("Prüfen & korrigieren", () => PostChecker.RunAsync(Importer.BaseDir));
private void Compare() => Session.Run($"Abgleich mit {ActiveLabel}", () => Catalog.CompareWithGhostAsync());
// Andere Ghost-Instanz wählen: der Abgleich der Liste gilt dann nicht mehr
private void SwitchGhost(int index)
{
if (Session.IsBusy) return;
Importer.SwitchGhost(index);
Catalog.ResetGhostState();
Reload();
}
private void ResetProcessed()
{
_confirmReset = false;
Session.Run("Abgefragte Beiträge zurücksetzen", () => { PostFetcher.ClearProcessedPosts(); return Task.CompletedTask; });
}
private void Reply(string value)
{
Session.Answer(value);
_answer = "";
}
// ---- Auswahl ----
private void Toggle(int wpId, bool on)
{
if (on) _selected.Add(wpId); else _selected.Remove(wpId);
}
private void SelectRange()
{
if (_fromId == null) return;
int from = _fromId.Value, to = _toId ?? from;
if (to < from) (from, to) = (to, from);
foreach (var row in Visible.Where(r => r.WpId >= from && r.WpId <= to)) _selected.Add(row.WpId);
}
private void SelectVisible()
{
foreach (var row in Visible) _selected.Add(row.WpId);
}
private void Archive()
{
int moved = Catalog.Archive(SelectedActive);
Console.WriteLine($"{moved} Post(s) archiviert.");
Reload();
}
private void Restore()
{
int moved = Catalog.Restore(SelectedArchived);
Console.WriteLine($"{moved} Post(s) aus dem Archiv zurückgeholt.");
Reload();
}
// ---- Einstellungen ----
private void LoadSettings()
{
_wpUrl = Importer.WpBaseUrl;
_maxPosts = Importer.MaxPosts > 0 ? Importer.MaxPosts : null;
_zipPath = Importer.OutputZipPath;
for (int i = 0; i < _ghostForms.Length; i++)
{
_ghostForms[i].Name = Importer.GhostTargets[i].Name;
_ghostForms[i].Url = Importer.GhostTargets[i].Url;
_ghostForms[i].AdminKey = "";
}
}
private void SaveSettings()
{
Importer.WpBaseUrl = _wpUrl.Trim();
Importer.MaxPosts = _maxPosts is > 0 ? _maxPosts.Value : -1;
if (!string.IsNullOrWhiteSpace(_zipPath)) Importer.OutputZipPath = _zipPath.Trim();
for (int i = 0; i < _ghostForms.Length; i++)
{
var target = Importer.GhostTargets[i];
target.Name = _ghostForms[i].Name.Trim();
target.Url = _ghostForms[i].Url.Trim();
// Der Schlüssel wird nie angezeigt; ein leeres Feld lässt den gespeicherten unverändert
if (!string.IsNullOrWhiteSpace(_ghostForms[i].AdminKey)) target.AdminApiKey = _ghostForms[i].AdminKey.Trim();
}
Importer.ApplySettings();
// Neue Adresse oder neuer Schlüssel: der letzte Abgleich passt womöglich nicht mehr
Catalog.ResetGhostState();
Reload();
LoadSettings();
_settingsMessage = $"Gespeichert um {DateTime.Now:HH:mm:ss}.";
}
// ---- Darstellung ----
private static string Short(string url) => url.Replace("https://", "").Replace("http://", "").TrimEnd('/');
private static string LineClass(string line)
{
if (line.StartsWith("▶") || line.StartsWith("■") || line.StartsWith("===")) return "log__head";
if (line.Contains("[Fehler]") || line.Contains("FEHLER") || line.Contains("ABBRUCH") || line.Contains("-Fehler]")) return "log__error";
if (line.Contains("[Warnung]")) return "log__warn";
return "";
}
}

View file

@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
namespace Wp2GhostUI.Components;
public static class RenderModes
{
// Ohne Vorab-Rendern: die Seite hängt an einer laufenden Sitzung, zweimal aufbauen wäre nur Mehrarbeit
public static readonly IComponentRenderMode Server = new InteractiveServerRenderMode(prerender: false);
}

View file

@ -0,0 +1,5 @@
<Router AppAssembly="typeof(RenderModes).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" />
</Found>
</Router>

View file

@ -0,0 +1,10 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Wp2GhostUI
@using Wp2GhostUI.Components
@using wp2ghost

View file

@ -0,0 +1,190 @@
using System.Collections.Concurrent;
using System.Text;
namespace Wp2GhostUI;
/// <summary>
/// Verbindet den Importer mit der Oberfläche. Der Importer schreibt mit Console.Write und fragt mit
/// Console.ReadLine — beides wird hierher umgeleitet: Ausgaben landen im Protokoll, und bei einer
/// Rückfrage wartet der Ablauf, bis die Oberfläche eine Antwort liefert. So laufen Konsole und
/// Oberfläche garantiert durch dieselbe Logik, ohne dass Abfragen doppelt programmiert werden.
/// </summary>
public class ImporterSession
{
private const int MaxLines = 3000;
private readonly object _gate = new();
private readonly List<string> _lines = new();
private readonly StringBuilder _current = new(); // angefangene Zeile (z. B. eine Frage ohne Zeilenende)
private readonly BlockingCollection<string> _answers = new();
private CancellationTokenSource _cancel = new();
private Timer? _notifyTimer;
private bool _dirty;
public string DataDir { get; private set; } = "";
public bool IsBusy { get; private set; }
public string? CurrentTask { get; private set; }
/// <summary>Text der offenen Rückfrage; null, solange keine Antwort erwartet wird.</summary>
public string? Prompt { get; private set; }
/// <summary>Zählt abgeschlossene Abläufe — die Oberfläche lädt danach ihre Listen neu.</summary>
public int CompletedRuns { get; private set; }
/// <summary>Wird ausgelöst, wenn sich Protokoll, Rückfrage oder Status geändert haben (gebündelt).</summary>
public event Action? Changed;
public void Start(string dataDir)
{
DataDir = dataDir;
Console.SetOut(new SessionWriter(this));
Console.SetIn(new SessionReader(this));
Importer.Initialize(dataDir);
_notifyTimer = new Timer(_ =>
{
if (!_dirty) return;
_dirty = false;
Changed?.Invoke();
}, null, 150, 150);
}
/// <summary>Protokollzeilen plus die gerade angefangene Zeile.</summary>
public List<string> GetLines(int last = 400)
{
lock (_gate)
{
var result = _lines.Skip(Math.Max(0, _lines.Count - last)).ToList();
if (_current.Length > 0 && Prompt == null) result.Add(_current.ToString());
return result;
}
}
public void ClearLog()
{
lock (_gate) { _lines.Clear(); _current.Clear(); }
_dirty = true;
}
/// <summary>Startet einen Ablauf im Hintergrund. Es läuft immer nur einer gleichzeitig.</summary>
public bool Run(string name, Func<Task> work)
{
lock (_gate)
{
if (IsBusy) return false;
IsBusy = true;
CurrentTask = name;
_cancel = new CancellationTokenSource();
while (_answers.TryTake(out _)) { }
}
Append($"\n▶ {name} ({DateTime.Now:HH:mm:ss})\n");
_ = Task.Run(async () =>
{
try
{
await work();
Append($"■ {name} beendet\n");
}
catch (OperationCanceledException)
{
Append($"\n■ {name} abgebrochen\n");
}
catch (Exception ex)
{
Append($"\n[Fehler] {ex.Message}\n■ {name} mit Fehler beendet\n");
}
finally
{
lock (_gate)
{
IsBusy = false;
CurrentTask = null;
Prompt = null;
CompletedRuns++;
wp2ghost.LinkQuestion.Current = null;
}
_dirty = true;
}
});
return true;
}
/// <summary>Beantwortet die offene Rückfrage.</summary>
public void Answer(string text)
{
if (Prompt == null) return;
_answers.Add(text);
}
/// <summary>
/// Beantwortet die offene Rückfrage und gleich die darauf folgenden — z. B. erst "e" (bearbeiten)
/// und dann die neue Adresse.
/// </summary>
public void AnswerMany(params string[] answers)
{
if (Prompt == null) return;
foreach (string answer in answers) _answers.Add(answer);
}
/// <summary>Bricht den laufenden Ablauf bei der nächsten Rückfrage ab (mitten in einem Upload nicht möglich).</summary>
public void Cancel()
{
if (!IsBusy) return;
_cancel.Cancel();
}
internal void Append(string text)
{
lock (_gate)
{
foreach (char c in text)
{
if (c == '\r') continue;
if (c != '\n') { _current.Append(c); continue; }
_lines.Add(_current.ToString());
_current.Clear();
}
if (_lines.Count > MaxLines) _lines.RemoveRange(0, _lines.Count - MaxLines);
}
_dirty = true;
}
internal string? WaitForAnswer()
{
// Außerhalb eines Ablaufs fragt niemand — wie eine geschlossene Eingabe behandeln
if (!IsBusy) return null;
lock (_gate)
{
// Die angefangene Zeile ist die Frage; fehlt sie, die letzte vollständige Zeile
Prompt = _current.Length > 0 ? _current.ToString() : _lines.LastOrDefault() ?? "";
}
_dirty = true;
string answer;
try
{
answer = _answers.Take(_cancel.Token);
}
finally
{
lock (_gate) { Prompt = null; }
}
// Antwort wie in der Konsole hinter die Frage schreiben
Append(answer + "\n");
return answer;
}
private sealed class SessionWriter(ImporterSession session) : TextWriter
{
public override Encoding Encoding => Encoding.UTF8;
public override void Write(char value) => session.Append(value.ToString());
public override void Write(string? value) { if (value != null) session.Append(value); }
public override void WriteLine(string? value) => session.Append((value ?? "") + "\n");
public override void WriteLine() => session.Append("\n");
}
private sealed class SessionReader(ImporterSession session) : TextReader
{
public override string? ReadLine() => session.WaitForAnswer();
}
}

View file

@ -0,0 +1,133 @@
using System.Net;
using System.Text.Json;
using HtmlAgilityPack;
namespace Wp2GhostUI;
/// <summary>Ein Vorschlag für den Ersatz eines toten Links.</summary>
public record LinkSuggestion(string Title, string Url, string Snippet, string Source);
/// <summary>
/// Sucht Ersatz für einen toten Link: eine archivierte Fassung der alten Adresse (Internet Archive)
/// und Treffer einer Websuche nach dem Linktext (DuckDuckGo). Beides läuft auf dem Server, weil sich
/// Suchmaschinen nicht in eine Seite einbetten lassen.
/// </summary>
public class LinkSearchService
{
private readonly HttpClient _http;
public LinkSearchService()
{
_http = new HttpClient(new SocketsHttpHandler { AutomaticDecompression = DecompressionMethods.All }) { Timeout = TimeSpan.FromSeconds(15) };
_http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36");
_http.DefaultRequestHeaders.AcceptLanguage.ParseAdd("de,en;q=0.8");
}
/// <summary>Archivierte Fassung der toten Adresse, falls das Internet Archive eine hat.</summary>
public async Task<LinkSuggestion?> FindArchivedAsync(string deadUrl)
{
try
{
string json = await _http.GetStringAsync("https://archive.org/wayback/available?url=" + Uri.EscapeDataString(deadUrl));
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("archived_snapshots", out var snapshots) || !snapshots.TryGetProperty("closest", out var closest))
return null;
if (closest.TryGetProperty("available", out var available) && !available.GetBoolean()) return null;
string url = (closest.GetProperty("url").GetString() ?? "").Replace("http://web.archive.org", "https://web.archive.org");
string stamp = closest.TryGetProperty("timestamp", out var ts) ? ts.GetString() ?? "" : "";
string date = stamp.Length >= 8 ? $"{stamp.Substring(6, 2)}.{stamp.Substring(4, 2)}.{stamp.Substring(0, 4)}" : "";
return url.Length == 0 ? null
: new LinkSuggestion("Archivierte Fassung der alten Seite", url, date.Length > 0 ? $"Stand vom {date} im Internet Archive" : "Internet Archive", "Archiv");
}
catch
{
return null;
}
}
// Treffer je Suchbegriff merken: derselbe Linktext kommt oft in mehreren Beiträgen vor,
// und jede gesparte Abfrage schont das knappe Kontingent der Suchmaschine
private readonly Dictionary<string, List<LinkSuggestion>> _cache = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Websuche über DuckDuckGo. Die Suchmaschine sperrt automatische Abfragen nach wenigen Anfragen
/// für einige Zeit; dann gibt es eine Ausnahme mit verständlichem Text, und der User sucht über
/// "Bei Google suchen" im Browser weiter. (Bing als Ausweichquelle wurde verworfen: es liefert
/// automatischen Abfragen unpassende Treffer.)
/// </summary>
public async Task<List<LinkSuggestion>> SearchAsync(string query, int max = 8)
{
query = query.Trim();
if (query.Length == 0) return new();
if (_cache.TryGetValue(query, out var cached)) return cached;
var results = await SearchDuckDuckGoAsync(query, max)
?? throw new InvalidOperationException("die Suchmaschine lässt im Moment keine weiteren automatischen Abfragen zu (das legt sich nach einigen Minuten)");
_cache[query] = results;
return results;
}
// Liefert null, wenn die Suchmaschine die Abfrage abgewiesen hat (im Unterschied zu "keine Treffer")
private async Task<List<LinkSuggestion>?> SearchDuckDuckGoAsync(string query, int max)
{
string html;
try
{
using var response = await _http.GetAsync("https://html.duckduckgo.com/html/?kl=de-de&q=" + Uri.EscapeDataString(query));
// Bei einer Sperre antwortet DuckDuckGo mit 202 und einer Prüfseite statt Treffern
if (response.StatusCode != HttpStatusCode.OK) return null;
html = await response.Content.ReadAsStringAsync();
}
catch
{
return null;
}
var doc = new HtmlDocument();
doc.LoadHtml(html);
var results = new List<LinkSuggestion>();
var nodes = doc.DocumentNode.SelectNodes("//div[contains(@class,'result') and contains(@class,'results_links')]");
if (nodes == null) return html.Contains("anomaly", StringComparison.OrdinalIgnoreCase) ? null : results;
foreach (var node in nodes)
{
var link = node.SelectSingleNode(".//a[contains(@class,'result__a')]");
if (link == null) continue;
string? url = ResolveTarget(link.GetAttributeValue("href", ""));
if (url == null) continue;
string title = HtmlEntity.DeEntitize(link.InnerText).Trim();
string snippet = HtmlEntity.DeEntitize(node.SelectSingleNode(".//*[contains(@class,'result__snippet')]")?.InnerText ?? "").Trim();
if (results.All(r => r.Url != url))
results.Add(new LinkSuggestion(title, url, snippet, "Suche"));
if (results.Count >= max) break;
}
return results;
}
// Trefferlinks laufen über eine Weiterleitung: //duckduckgo.com/l/?uddg=<Zieladresse>&...
private static string? ResolveTarget(string href)
{
href = HtmlEntity.DeEntitize(href);
if (href.StartsWith("//")) href = "https:" + href;
if (!Uri.TryCreate(href, UriKind.Absolute, out var uri)) return null;
if (uri.Host.EndsWith("duckduckgo.com"))
{
// Werbetreffer (y.js) haben kein Ziel in "uddg"
foreach (string part in uri.Query.TrimStart('?').Split('&'))
{
if (part.StartsWith("uddg="))
{
string target = Uri.UnescapeDataString(part.Substring(5));
// Anzeigen zeigen wieder auf die Suchmaschine selbst
bool isAd = Uri.TryCreate(target, UriKind.Absolute, out var targetUri) && targetUri.Host.EndsWith("duckduckgo.com");
return target.StartsWith("http") && !isAd ? target : null;
}
}
return null;
}
return uri.Scheme is "http" or "https" ? href : null;
}
}

154
Wp2GhostUI/PostCatalog.cs Normal file
View file

@ -0,0 +1,154 @@
using System.Text.Json;
using wp2ghost;
namespace Wp2GhostUI;
public enum GhostState { Unknown, Missing, Present, WrongDate }
/// <summary>Eine Zeile der Post-Liste: das Wichtigste aus einer JSON-Datei in ghostPosts/.</summary>
public class PostRow
{
public int WpId { get; init; }
public string Title { get; init; } = "";
public string GhostSlug { get; init; } = "";
public DateTime? Date { get; init; }
public string? DateUtc { get; init; }
public List<string> Categories { get; init; } = new();
public int Images { get; init; }
public int Videos { get; init; }
public int Comments { get; init; }
public bool Archived { get; init; }
public string Path { get; init; } = "";
public GhostState Ghost { get; set; } = GhostState.Unknown;
}
/// <summary>
/// Liest die abgerufenen Posts aus dem Datenordner (ghostPosts/ und ghostPosts/archiv/).
/// Ob ein Post in Ghost liegt, gilt für die aktive Ghost-Instanz.
/// </summary>
public class PostCatalog(ImporterSession session)
{
// Gelesene Dateien merken, solange sie sich nicht ändern (die JSONs enthalten den ganzen Beitrag)
private readonly Dictionary<string, (DateTime Stamp, PostRow Row)> _cache = new();
// Ergebnis des letzten Abgleichs mit der aktiven Ghost-Instanz: Slug → Veröffentlichungsdatum
private Dictionary<string, string?>? _ghostPosts;
public DateTime? GhostCheckedAt { get; private set; }
private string PostsDir => System.IO.Path.Combine(session.DataDir, "ghostPosts");
private string ArchiveDir => System.IO.Path.Combine(PostsDir, "archiv");
public List<PostRow> Load()
{
var rows = new List<PostRow>();
foreach (var (dir, archived) in new[] { (PostsDir, false), (ArchiveDir, true) })
{
if (!Directory.Exists(dir)) continue;
foreach (string file in Directory.GetFiles(dir, "*.json"))
{
var stamp = File.GetLastWriteTimeUtc(file);
if (!_cache.TryGetValue(file, out var cached) || cached.Stamp != stamp)
{
var row = Read(file, archived);
if (row == null) continue;
_cache[file] = cached = (stamp, row);
}
rows.Add(cached.Row);
}
}
// Ein Post, der nach dem Archivieren neu abgerufen wurde, liegt in beiden Ordnern: die aktive Datei gilt
rows = rows.GroupBy(r => r.WpId).Select(g => g.OrderBy(r => r.Archived).First()).OrderBy(r => r.WpId).ToList();
foreach (var row in rows) row.Ghost = StateOf(row);
return rows;
}
/// <summary>Nach dem Wechsel der Ghost-Instanz gilt der letzte Abgleich nicht mehr.</summary>
public void ResetGhostState()
{
_ghostPosts = null;
GhostCheckedAt = null;
}
private static PostRow? Read(string file, bool archived)
{
try
{
var post = JsonSerializer.Deserialize<GhostPostFile>(File.ReadAllText(file));
if (post == null || post.WpId <= 0) return null;
string? utc = DateHelper.ToGhostUtc(post.PublishedAt ?? post.CreatedAt);
return new PostRow
{
WpId = post.WpId,
Title = System.Net.WebUtility.HtmlDecode(post.Title ?? "(kein Titel)"),
GhostSlug = SlugHelper.Clean(post.Slug, $"post-{post.WpId}"),
DateUtc = utc,
Date = utc == null ? null : DateTimeOffset.Parse(utc).LocalDateTime,
Categories = post.Categories.Where(c => !CategoryRules.IsIgnored(c))
.Select(c => System.Net.WebUtility.HtmlDecode(c.Name ?? "")).Where(n => n.Length > 0).ToList(),
Images = post.ImageFiles.Count,
Videos = post.MediaFiles.Count,
Comments = CommentBlockBuilder.CountRealComments(post.Comments),
Archived = archived,
Path = file
};
}
catch
{
return null;
}
}
/// <summary>Holt die Liste der Posts aus Ghost, um in der Liste anzuzeigen, was schon dort ist.</summary>
public async Task CompareWithGhostAsync()
{
Console.Write(" Lade Posts aus Ghost...");
var ghostPosts = await GhostApi.BrowseAllPostsAsync();
Console.WriteLine($" {ghostPosts.Count}");
_ghostPosts = ghostPosts.Where(p => p.Slug != null)
.GroupBy(p => p.Slug!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().PublishedAt, StringComparer.OrdinalIgnoreCase);
GhostCheckedAt = DateTime.Now;
var rows = Load();
Console.WriteLine($" In Ghost vorhanden: {rows.Count(r => r.Ghost is GhostState.Present or GhostState.WrongDate)} von {rows.Count} abgerufenen Posts" +
$" (davon {rows.Count(r => r.Ghost == GhostState.WrongDate)} mit abweichendem Datum).");
}
private GhostState StateOf(PostRow row)
{
if (_ghostPosts == null) return GhostState.Unknown;
// Eine Zuordnung aus der Prüffunktion hat Vorrang vor dem bereinigten Slug
string slug = SlugAssignments.GetGhostSlug(row.WpId) ?? row.GhostSlug;
if (!_ghostPosts.TryGetValue(slug, out string? publishedAt)) return GhostState.Missing;
if (row.DateUtc == null || publishedAt == null) return GhostState.Present;
bool sameDate = DateTimeOffset.TryParse(publishedAt, out var actual)
&& Math.Abs((actual - DateTimeOffset.Parse(row.DateUtc)).TotalSeconds) < 60;
return sameDate ? GhostState.Present : GhostState.WrongDate;
}
public int Archive(IEnumerable<PostRow> rows)
{
Directory.CreateDirectory(ArchiveDir);
int moved = 0;
foreach (var row in rows.Where(r => !r.Archived && File.Exists(r.Path)))
{
File.Move(row.Path, System.IO.Path.Combine(ArchiveDir, System.IO.Path.GetFileName(row.Path)), overwrite: true);
moved++;
}
return moved;
}
public int Restore(IEnumerable<PostRow> rows)
{
int moved = 0;
foreach (var row in rows.Where(r => r.Archived && File.Exists(r.Path)))
{
string target = System.IO.Path.Combine(PostsDir, System.IO.Path.GetFileName(row.Path));
if (File.Exists(target)) continue; // es gibt bereits eine neuere aktive Datei
File.Move(row.Path, target);
moved++;
}
return moved;
}
}

40
Wp2GhostUI/Program.cs Normal file
View file

@ -0,0 +1,40 @@
using Wp2GhostUI;
using Wp2GhostUI.Components;
var builder = WebApplication.CreateBuilder(args);
// Die Konsole gehört dem Importer (siehe ImporterSession) — das Framework soll nicht hineinschreiben
builder.Logging.ClearProviders();
builder.Logging.AddDebug();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddSingleton<ImporterSession>();
builder.Services.AddSingleton<PostCatalog>();
builder.Services.AddSingleton<LinkSearchService>();
var app = builder.Build();
// Datenordner: derselbe, in dem auch das Konsolenprogramm arbeitet (config.txt, ghostPosts/, images/ ...).
// Reihenfolge: Einstellung "DataDir" (appsettings.json oder --DataDir=...), sonst der Ordner des
// Konsolenprogramms, sonst ein eigener Ordner "data".
string dataDir = app.Configuration["DataDir"] ?? "";
if (string.IsNullOrWhiteSpace(dataDir))
{
string consoleDir = Path.GetFullPath(Path.Combine(app.Environment.ContentRootPath, "..", "CSharpGhostImporter", "bin", "Debug", "net8.0"));
dataDir = File.Exists(Path.Combine(consoleDir, "config.txt")) ? consoleDir : Path.Combine(app.Environment.ContentRootPath, "data");
}
dataDir = Path.GetFullPath(dataDir);
Directory.CreateDirectory(dataDir);
var realConsole = Console.Out;
app.Services.GetRequiredService<ImporterSession>().Start(dataDir);
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
realConsole.WriteLine($"WP2Ghost-Oberfläche: {app.Configuration["Urls"]}");
realConsole.WriteLine($"Datenordner: {dataDir}");
realConsole.WriteLine("Beenden mit Strg+C.");
app.Run();

View file

@ -0,0 +1,75 @@
namespace Wp2GhostUI;
/// <summary>Eine Antwortmöglichkeit einer Rückfrage: Beschriftung des Knopfs und was er sendet.</summary>
public record PromptOption(string Label, string Value);
/// <summary>
/// Liest aus dem Text einer Rückfrage die angebotenen Antworten, damit die Oberfläche dafür
/// Knöpfe anzeigen kann. Die Fragen des Importers folgen einem festen Muster, z. B.
/// "... (j/n): "
/// "[k=beibehalten / d=löschen / e=bearbeiten / b=Browser öffnen]: "
/// "(a = alle / e = einzeln fragen / n = nichts): "
/// "(mehrere mit Komma, leer = keiner, + = wie zuletzt: Zypern): "
/// Was sich nicht als Knopf darstellen lässt (z. B. "Nummer" oder "w&lt;ID&gt;"), tippt der User ins Textfeld.
/// </summary>
public static class PromptOptions
{
public static List<PromptOption> Parse(string? prompt)
{
var options = new List<PromptOption>();
string? group = LastGroup(prompt ?? "");
if (group == null) return options;
// Trennzeichen: " / ", sonst "/" (j/n), sonst ", "
string[] tokens = group.Contains(" / ") ? group.Split(" / ")
: group.Contains('/') && !group.Contains("://") ? group.Split('/')
: group.Split(", ");
foreach (string raw in tokens)
{
string token = raw.Trim();
if (token.Length == 0) continue;
int eq = token.IndexOf('=');
if (eq > 0)
{
string key = token.Substring(0, eq).Trim();
string label = token.Substring(eq + 1).Trim();
if (key.Equals("Enter", StringComparison.OrdinalIgnoreCase) || key.Equals("leer", StringComparison.OrdinalIgnoreCase))
options.Add(new PromptOption(Capitalize(label), ""));
else if (key.Length <= 2)
options.Add(new PromptOption($"{Capitalize(label)} ({key})", key));
// längere Schlüssel (z. B. "w<ID>") sind Platzhalter → Textfeld
}
else if (token.Length == 1)
{
options.Add(new PromptOption(token switch { "j" => "Ja", "n" => "Nein", _ => token }, token));
}
}
// Eine einzelne erkannte Angabe wie "(Nummer)" ist keine Auswahl
return options;
}
// Inhalt der letzten Klammer am Ende der Frage: [...] oder (...), auch mit Klammern darin
private static string? LastGroup(string prompt)
{
string text = prompt.TrimEnd().TrimEnd(':').TrimEnd();
if (text.Length == 0) return null;
char close = text[^1];
if (close != ']' && close != ')') return null;
char open = close == ']' ? '[' : '(';
int depth = 0;
for (int i = text.Length - 1; i >= 0; i--)
{
if (text[i] == close) depth++;
else if (text[i] == open && --depth == 0)
return text.Substring(i + 1, text.Length - i - 2);
}
return null;
}
private static string Capitalize(string text) =>
text.Length == 0 ? text : char.ToUpper(text[0]) + text.Substring(1);
}

View file

@ -0,0 +1,12 @@
{
"profiles": {
"Wp2GhostUI": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:49517;http://localhost:49518"
}
}
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Wp2GhostUI</RootNamespace>
</PropertyGroup>
<!-- Die Oberfläche benutzt die Abläufe des Konsolen-Importers direkt (gleiche Logik, gleiche Daten) -->
<ItemGroup>
<ProjectReference Include="..\CSharpGhostImporter\CSharpGhostImporter.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,3 @@
<Solution>
<Project Path="Wp2GhostUI.csproj" />
</Solution>

View file

@ -0,0 +1,4 @@
{
"DataDir": "",
"Urls": "http://localhost:5080"
}

279
Wp2GhostUI/wwwroot/app.css Normal file
View file

@ -0,0 +1,279 @@
/* WP2Ghost-Oberfläche */
:root {
--bg: #f4f5f7;
--card: #ffffff;
--text: #1f2328;
--muted: #667085;
--border: #dfe3e8;
--accent: #2b5fd9;
--accent-text: #ffffff;
--ok: #1a7f4b;
--ok-bg: #e3f6ec;
--warn: #9a5b00;
--warn-bg: #fdf0d5;
--error: #b42318;
--log-bg: #15181d;
--log-text: #d7dce3;
--radius: 8px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
font-size: 14px;
line-height: 1.45;
}
h1 { margin: 0; font-size: 20px; }
h2 { margin: 0 0 12px; font-size: 15px; }
code { padding: 1px 4px; border-radius: 4px; background: var(--bg); }
/* Kopfzeile */
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 20px;
padding: 10px 20px;
background: var(--card);
border-bottom: 1px solid var(--border);
}
.topbar__status { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; min-width: 0; }
.chip {
max-width: 420px;
padding: 2px 10px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip--ok { border-color: #b7e2c8; background: var(--ok-bg); color: var(--ok); }
.chip--warn { border-color: #f1d9a3; background: var(--warn-bg); color: var(--warn); }
/* Umschalter zwischen den beiden Ghost-Instanzen */
.ghost-switch { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
.ghost-switch__options { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
.ghost-switch__option {
max-width: 240px;
padding: 2px 12px;
border: 0;
background: none;
color: var(--muted);
font: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.ghost-switch__option + .ghost-switch__option { border-left: 1px solid var(--border); }
.ghost-switch__option:hover:not(:disabled) { background: var(--bg); }
.ghost-switch__option.is-active { background: var(--accent); color: var(--accent-text); font-weight: 600; }
.ghost-switch__option.is-warn:not(.is-active) { color: var(--warn); }
.ghost-switch__option:disabled { cursor: default; }
.tabs { display: flex; gap: 4px; }
.tab { padding: 6px 14px; border: 1px solid transparent; border-radius: 6px; background: none; color: var(--muted); font: inherit; cursor: pointer; }
.tab:hover { background: var(--bg); }
.tab.is-active { border-color: var(--border); background: var(--bg); color: var(--text); font-weight: 600; }
/* Aufteilung: links die Arbeit, rechts der Ablauf mit Rückfragen */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 34%);
gap: 16px;
align-items: start;
padding: 16px 20px;
}
.side { position: sticky; top: 16px; }
.card { margin-bottom: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); }
.card__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.card__head h2 { margin: 0; }
.count { color: var(--muted); font-size: 12px; font-weight: 400; }
.hint { margin: 8px 0 0; color: var(--muted); font-size: 12.5px; }
.empty { margin: 24px 0; color: var(--muted); text-align: center; }
.spacer { flex: 1; }
/* Knöpfe und Eingaben */
.btn {
padding: 7px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card);
color: var(--text);
font: inherit;
cursor: pointer;
}
.btn:hover:not(:disabled) { border-color: #b9c0cb; background: #f8f9fb; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--primary { border-color: var(--accent); background: var(--accent); color: var(--accent-text); }
.btn--primary:hover:not(:disabled) { border-color: #214cb3; background: #214cb3; }
.btn--danger { border-color: #e7b1ab; color: var(--error); }
.btn--small { padding: 4px 10px; font-size: 12.5px; }
.btn--option { border-color: var(--accent); color: var(--accent); font-weight: 600; }
input[type="text"], input[type="url"], input[type="password"], input[type="number"], input[type="search"], select {
padding: 6px 9px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card);
color: var(--text);
font: inherit;
}
input:focus, select:focus, .btn:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 1px; }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.filters { display: flex; flex-wrap: wrap; gap: 8px; }
.filters input[type="search"] { width: 220px; }
.check { display: inline-flex; align-items: center; gap: 6px; margin-right: 10px; }
.more { margin-top: 12px; }
.more summary { color: var(--muted); cursor: pointer; }
.selectbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 10px; color: var(--muted); font-size: 12.5px; }
.selectbar input[type="number"] { width: 92px; padding: 4px 8px; }
.form { display: grid; gap: 14px; max-width: 620px; }
.form label { display: grid; gap: 4px; font-weight: 600; }
.form .hint { margin: 0; font-weight: 400; }
.instance { display: grid; gap: 10px; margin: 0; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); }
.instance legend { padding: 0 6px; font-weight: 600; }
.instance.is-active { border-color: var(--accent); }
/* Post-Liste */
.tablewrap { max-height: calc(100vh - 400px); min-height: 240px; overflow: auto; border: 1px solid var(--border); border-radius: 6px; }
.posts { width: 100%; border-collapse: collapse; }
.posts th {
position: sticky;
top: 0;
z-index: 1;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
background: #f8f9fb;
color: var(--muted);
font-size: 12px;
font-weight: 600;
text-align: left;
white-space: nowrap;
}
.posts td { height: 38px; padding: 0 10px; border-bottom: 1px solid #eef0f3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.posts tr.is-selected td { background: #eef3fe; }
.posts tbody tr:hover td { background: #f6f8fb; }
.col-check { width: 34px; }
.col-id { width: 70px; color: var(--muted); font-variant-numeric: tabular-nums; }
.col-date { width: 96px; font-variant-numeric: tabular-nums; }
.col-num { width: 62px; text-align: right !important; font-variant-numeric: tabular-nums; }
.col-title { max-width: 360px; font-weight: 500; }
.col-cats { max-width: 220px; color: var(--muted); }
.badge { display: inline-block; margin-right: 4px; padding: 1px 8px; border-radius: 999px; background: #eef0f3; color: var(--muted); font-size: 12px; }
.badge--ok { background: var(--ok-bg); color: var(--ok); }
.badge--warn { background: var(--warn-bg); color: var(--warn); }
.badge--muted { background: #eceef1; }
.badge--run { margin-left: 6px; background: #e4ecfd; color: var(--accent); font-weight: 400; }
/* Toter Link: Suche nach Ersatz mit Vorschau */
.assistant { border: 2px solid var(--accent); }
.assistant__facts { display: grid; gap: 4px; margin-bottom: 12px; overflow-wrap: anywhere; }
.assistant__key { display: inline-block; min-width: 96px; color: var(--muted); font-size: 12.5px; }
.assistant__search { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.assistant__search input { flex: 1; min-width: 200px; }
.assistant__search a.btn { text-decoration: none; }
.assistant__body { display: grid; grid-template-columns: minmax(260px, 2fr) minmax(0, 3fr); gap: 14px; align-items: start; }
.assistant__results { display: grid; gap: 8px; max-height: 520px; overflow-y: auto; }
.assistant__error { color: var(--error); }
.suggestion { padding: 10px; border: 1px solid var(--border); border-radius: 6px; }
.suggestion.is-active { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, transparent); }
.suggestion__title { font-weight: 600; }
.suggestion__url { color: var(--ok); font-size: 12px; overflow-wrap: anywhere; }
.suggestion__snippet { margin-top: 4px; color: var(--muted); font-size: 12.5px; }
.suggestion__actions { display: flex; gap: 6px; margin-top: 8px; }
.assistant__address { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.assistant__address input { flex: 1; min-width: 180px; }
.assistant__frame { width: 100%; height: 460px; border: 1px solid var(--border); border-radius: 6px; background: #fff; }
.assistant__placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 160px;
border: 1px dashed var(--border);
border-radius: 6px;
color: var(--muted);
}
@media (max-width: 900px) {
.assistant__body { grid-template-columns: minmax(0, 1fr); }
}
/* Ablauf: Protokoll und Rückfrage */
.card--log { display: flex; flex-direction: column; max-height: calc(100vh - 110px); }
.log {
flex: 1;
min-height: 220px;
margin: 0;
padding: 10px 12px;
border-radius: 6px;
background: var(--log-bg);
color: var(--log-text);
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 12.5px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
overflow-y: auto;
}
/* Leerzeilen behalten ihre Höhe */
.log__line:empty::before { content: "\200b"; }
.log__head { color: #8fb4ff; font-weight: 600; }
.log__error { color: #ff9a8f; }
.log__warn { color: #ffd479; }
.prompt { margin-top: 12px; padding: 12px; border: 2px solid var(--accent); border-radius: var(--radius); background: #f3f6fe; }
.prompt__label { margin-bottom: 4px; color: var(--accent); font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
.prompt__text { margin-bottom: 10px; font-weight: 600; overflow-wrap: anywhere; }
.prompt__options { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.prompt__input { display: flex; gap: 6px; }
.prompt__input input { flex: 1; min-width: 0; }
@media (max-width: 1100px) {
.layout { grid-template-columns: minmax(0, 1fr); }
.side { position: static; }
.card--log { max-height: none; }
.log { max-height: 50vh; }
.tablewrap { max-height: 60vh; }
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171b;
--card: #1c2026;
--text: #e6e9ee;
--muted: #98a2b3;
--border: #2d333c;
--accent: #6b97ff;
--accent-text: #0d1117;
--ok: #6fd39b;
--ok-bg: #173526;
--warn: #f1c36a;
--warn-bg: #3a2e12;
--error: #ff8f85;
--log-bg: #0d1014;
}
.btn:hover:not(:disabled) { border-color: #46505d; background: #232830; }
.btn--primary:hover:not(:disabled) { border-color: #8aadff; background: #8aadff; }
.posts th { background: #20252c; }
.posts td { border-bottom-color: #262b33; }
.posts tr.is-selected td { background: #1d2a45; }
.posts tbody tr:hover td { background: #222830; }
.badge { background: #2a3038; }
.badge--muted { background: #262b33; }
.badge--run { background: #1d2a45; }
.prompt { background: #182238; }
}