Compare commits
No commits in common. "20c49bb1c5abd8fa74b9ea8787fc94d6dc8764b0" and "a730639c2ab28880389c17de6aaa216f5d18dd8a" have entirely different histories.
20c49bb1c5
...
a730639c2a
100 changed files with 899 additions and 10118 deletions
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -62,16 +62,3 @@
|
|||
/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
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
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
|
|
@ -67,7 +67,7 @@ namespace wp2ghost
|
|||
|
||||
public static async Task<List<GhostAdminPost>> BrowsePostsAsync(string filter = "", int limit = 50)
|
||||
{
|
||||
string endpoint = $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&fields=id,title,slug,status,created_at,updated_at,html,feature_image,codeinjection_head";
|
||||
string endpoint = $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&fields=id,title,slug,status,created_at,updated_at,html,feature_image";
|
||||
var request = await CreateRequestAsync(HttpMethod.Get, endpoint);
|
||||
var response = await httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
|
@ -86,15 +86,13 @@ namespace wp2ghost
|
|||
return result?.Posts?.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static async Task<GhostAdminPost?> CreatePostAsync(string title, string html, string status = "published", string? slug = null, string? publishedAt = null)
|
||||
public static async Task<GhostAdminPost?> CreatePostAsync(string title, string html, string status = "published", string? slug = null)
|
||||
{
|
||||
var post = new GhostAdminPostEdit
|
||||
{
|
||||
Title = title,
|
||||
Html = html,
|
||||
Status = status,
|
||||
PublishedAt = publishedAt,
|
||||
CreatedAt = publishedAt
|
||||
Status = status
|
||||
};
|
||||
if (slug != null) post.Slug = slug;
|
||||
|
||||
|
|
@ -115,7 +113,7 @@ namespace wp2ghost
|
|||
return result?.Posts?.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static async Task<GhostAdminPost?> UpdatePostAsync(string postId, string title, string html, string updatedAt, string status = "published", string? publishedAt = null)
|
||||
public static async Task<GhostAdminPost?> UpdatePostAsync(string postId, string title, string html, string updatedAt, string status = "published")
|
||||
{
|
||||
var post = new GhostAdminPostEdit
|
||||
{
|
||||
|
|
@ -123,8 +121,7 @@ namespace wp2ghost
|
|||
Title = title,
|
||||
Html = html,
|
||||
Status = status,
|
||||
UpdatedAt = updatedAt,
|
||||
PublishedAt = publishedAt
|
||||
UpdatedAt = updatedAt
|
||||
};
|
||||
|
||||
var body = new { posts = new[] { post } };
|
||||
|
|
@ -152,120 +149,17 @@ 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
|
||||
var all = new List<GhostAdminTag>();
|
||||
for (int page = 1; ; page++)
|
||||
{
|
||||
string endpoint = $"tags/?limit={limit}&page={page}&fields=id,name,slug";
|
||||
if (!string.IsNullOrEmpty(filter))
|
||||
endpoint += $"&filter={Uri.EscapeDataString(filter)}";
|
||||
var request = await CreateRequestAsync(HttpMethod.Get, endpoint);
|
||||
var response = await httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode) break;
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var tags = JsonSerializer.Deserialize<GhostAdminTagsResponse>(json)?.Tags ?? new();
|
||||
all.AddRange(tags);
|
||||
if (tags.Count < limit) break;
|
||||
}
|
||||
return all;
|
||||
string endpoint = $"tags/?limit={limit}&fields=id,name,slug";
|
||||
if (!string.IsNullOrEmpty(filter))
|
||||
endpoint += $"&filter={Uri.EscapeDataString(filter)}";
|
||||
var request = await CreateRequestAsync(HttpMethod.Get, endpoint);
|
||||
var response = await httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode) return new();
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<GhostAdminTagsResponse>(json);
|
||||
return result?.Tags ?? new();
|
||||
}
|
||||
|
||||
public static async Task<GhostAdminTag?> CreateTagAsync(string name, string? slug = null)
|
||||
|
|
@ -291,31 +185,24 @@ namespace wp2ghost
|
|||
{
|
||||
var result = new List<GhostAdminTag>();
|
||||
var existingTags = await BrowseTagsAsync();
|
||||
var existingByName = new Dictionary<string, GhostAdminTag>();
|
||||
foreach (var t in existingTags.Where(t => t.Name != null))
|
||||
existingByName.TryAdd(t.Name!.ToLowerInvariant(), t);
|
||||
var existingByName = existingTags.Where(t => t.Name != null).ToDictionary(t => t.Name!.ToLowerInvariant(), t => t);
|
||||
|
||||
foreach (var cat in categories)
|
||||
{
|
||||
// WordPress liefert Namen HTML-kodiert (z.B. "Tipps & Tricks")
|
||||
string name = System.Net.WebUtility.HtmlDecode(cat.Name ?? "").Trim();
|
||||
string name = cat.Name ?? "";
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
if (existingByName.TryGetValue(name.ToLowerInvariant(), out var existing))
|
||||
{
|
||||
// Reihenfolge bleibt erhalten: der erste Tag ist in Ghost der primäre
|
||||
if (!result.Contains(existing)) result.Add(existing);
|
||||
result.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Write($" [Tag] Erstelle '{name}'...");
|
||||
// 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);
|
||||
var newTag = await CreateTagAsync(name, cat.Slug);
|
||||
if (newTag != null)
|
||||
{
|
||||
Console.WriteLine(" OK");
|
||||
result.Add(newTag);
|
||||
existingByName[name.ToLowerInvariant()] = newTag;
|
||||
}
|
||||
else
|
||||
Console.WriteLine(" FEHLER");
|
||||
|
|
@ -375,30 +262,7 @@ namespace wp2ghost
|
|||
}
|
||||
|
||||
// ===================== Posts (erweitert) =====================
|
||||
private static readonly System.Text.RegularExpressions.Regex MetaKeywordsRegex = new(
|
||||
@"<meta\s+name\s*=\s*[""']keywords[""'][^>]*>\s*",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Ghost kennt kein meta_keywords-Feld -> Keywords als <meta name="keywords"> in den
|
||||
/// Code-Injection-Header des Posts schreiben. Vorhandener Header-Code bleibt erhalten,
|
||||
/// ein bereits vorhandenes Keywords-Meta wird ersetzt.
|
||||
/// </summary>
|
||||
public static string? BuildKeywordsHead(string? existingHead, IEnumerable<string> keywords)
|
||||
{
|
||||
var list = keywords
|
||||
.Select(k => System.Net.WebUtility.HtmlDecode(k ?? "").Trim())
|
||||
.Where(k => k.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (list.Count == 0) return null;
|
||||
|
||||
string rest = MetaKeywordsRegex.Replace(existingHead ?? "", "").Trim();
|
||||
string meta = $"<meta name=\"keywords\" content=\"{System.Net.WebUtility.HtmlEncode(string.Join(", ", list))}\">";
|
||||
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, string? publishedAt = null)
|
||||
public static async Task<GhostAdminPost?> CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List<GhostAdminTag> tags, string? metaKeywords = null)
|
||||
{
|
||||
var post = new GhostAdminPostEdit
|
||||
{
|
||||
|
|
@ -406,9 +270,7 @@ namespace wp2ghost
|
|||
Html = html,
|
||||
Status = status,
|
||||
FeatureImage = featureImage,
|
||||
CodeinjectionHead = codeinjectionHead,
|
||||
PublishedAt = publishedAt,
|
||||
CreatedAt = publishedAt
|
||||
MetaKeywords = metaKeywords
|
||||
};
|
||||
if (slug != null) post.Slug = slug;
|
||||
if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList();
|
||||
|
|
@ -428,7 +290,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, string? publishedAt = null)
|
||||
public static async Task<GhostAdminPost?> UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List<GhostAdminTag> tags, string? metaKeywords = null)
|
||||
{
|
||||
var post = new GhostAdminPostEdit
|
||||
{
|
||||
|
|
@ -438,8 +300,7 @@ namespace wp2ghost
|
|||
Status = status,
|
||||
UpdatedAt = updatedAt,
|
||||
FeatureImage = featureImage,
|
||||
CodeinjectionHead = codeinjectionHead,
|
||||
PublishedAt = publishedAt
|
||||
MetaKeywords = metaKeywords
|
||||
};
|
||||
if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList();
|
||||
|
||||
|
|
@ -523,58 +384,6 @@ 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))
|
||||
|
|
@ -644,7 +453,7 @@ namespace wp2ghost
|
|||
}
|
||||
|
||||
Console.Write($" [Update] {title}...");
|
||||
var updatedPost = await UpdatePostAsync(existing.Id!, title, html, existing.UpdatedAt ?? "", publishedAt: DateHelper.ToGhostUtc(localPost.PublishedAt));
|
||||
var updatedPost = await UpdatePostAsync(existing.Id!, title, html, existing.UpdatedAt ?? "");
|
||||
if (updatedPost != null)
|
||||
{
|
||||
Console.WriteLine(" OK");
|
||||
|
|
@ -656,7 +465,7 @@ namespace wp2ghost
|
|||
else
|
||||
{
|
||||
Console.Write($" [Neu] {title}...");
|
||||
var newPost = await CreatePostAsync(title, html, "published", slug, DateHelper.ToGhostUtc(localPost.PublishedAt));
|
||||
var newPost = await CreatePostAsync(title, html, "published", slug);
|
||||
if (newPost != null)
|
||||
{
|
||||
Console.WriteLine(" OK");
|
||||
|
|
@ -713,16 +522,6 @@ namespace wp2ghost
|
|||
|
||||
[JsonPropertyName("featured")]
|
||||
public bool Featured { get; set; }
|
||||
|
||||
[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
|
||||
|
|
@ -759,15 +558,6 @@ 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; }
|
||||
|
|
@ -776,9 +566,9 @@ namespace wp2ghost
|
|||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? MetaDescription { get; set; }
|
||||
|
||||
[JsonPropertyName("codeinjection_head")]
|
||||
[JsonPropertyName("meta_keywords")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CodeinjectionHead { get; set; }
|
||||
public string? MetaKeywords { get; set; }
|
||||
}
|
||||
|
||||
public class GhostImageUploadResponse
|
||||
|
|
@ -813,16 +603,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ namespace wp2ghost
|
|||
[JsonPropertyName("slug")] public string? Slug { get; set; }
|
||||
[JsonPropertyName("html")] public string? Html { get; set; }
|
||||
[JsonPropertyName("markdown")] public string? Markdown { get; set; }
|
||||
[JsonPropertyName("feature_image")] public string? FeatureImage { get; set; }
|
||||
[JsonPropertyName("status")] public string? Status { get; set; }
|
||||
[JsonPropertyName("created_at")] public string? CreatedAt { get; set; }
|
||||
[JsonPropertyName("published_at")] public string? PublishedAt { get; set; }
|
||||
|
|
@ -36,7 +35,6 @@ 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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,30 +32,15 @@ namespace wp2ghost
|
|||
return;
|
||||
}
|
||||
|
||||
string outputPath = Path.Combine(BaseDir, Importer.OutputZipPath);
|
||||
string outputPath = Path.Combine(BaseDir, Program.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 = exportPosts } } }
|
||||
Db = new List<GhostDb> { new GhostDb { Data = new GhostData { Posts = posts.ToList() } } }
|
||||
}, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
|
|
@ -102,20 +87,6 @@ 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();
|
||||
|
|
@ -123,7 +94,6 @@ 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("=========================================");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,123 +80,30 @@ namespace wp2ghost
|
|||
{ "wp-block-jetpack-story", "kg-card kg-gallery-card" },
|
||||
};
|
||||
|
||||
// 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)
|
||||
public static string Convert(string html)
|
||||
{
|
||||
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);
|
||||
|
||||
// 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')]");
|
||||
totalGalleries += ConvertGutenbergGalleries(htmlDoc);
|
||||
totalGalleries += ConvertJetpackGalleries(htmlDoc);
|
||||
totalGalleries += ConvertCoBlocksGalleries(htmlDoc);
|
||||
|
||||
if (totalGalleries > 0)
|
||||
{
|
||||
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");
|
||||
Console.WriteLine($" [Gallery] {totalGalleries} Galerie(n) konvertiert");
|
||||
|
||||
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'>
|
||||
|
|
@ -220,31 +127,74 @@ namespace wp2ghost
|
|||
var items = gallery.SelectNodes(".//figure[contains(@class, 'gallery-item')]");
|
||||
if (items == null || items.Count == 0) continue;
|
||||
|
||||
var images = new List<(HtmlNode Wrapper, string? Caption)>();
|
||||
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>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var img = item.SelectSingleNode(".//img");
|
||||
if (img == null) continue;
|
||||
|
||||
var wrapper = TryBuildGalleryImage(htmlDoc, img);
|
||||
if (wrapper != null)
|
||||
images.Add((wrapper, GetCaptionHtml(item.SelectSingleNode(".//figcaption"))));
|
||||
// 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);
|
||||
}
|
||||
|
||||
string src = GetBestImageUrl(img);
|
||||
if (string.IsNullOrEmpty(src)) continue;
|
||||
|
||||
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
|
||||
}
|
||||
|
||||
if (images.Count == 0) continue;
|
||||
if (wrappers.Count == 0) continue;
|
||||
|
||||
ReplaceWithGhostGalleries(htmlDoc, gallery, images);
|
||||
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);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Gallery-Typ 2-4: Gutenberg, Jetpack Tiled, CoBlocks — alle <img> des Blocks
|
||||
// Gallery-Typ 2: WordPress Gutenberg Gallery
|
||||
// =========================================================================
|
||||
private static int ConvertImageGalleries(HtmlDocument htmlDoc, string xpath)
|
||||
private static int ConvertGutenbergGalleries(HtmlDocument htmlDoc)
|
||||
{
|
||||
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(xpath);
|
||||
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
|
||||
"//figure[contains(@class, 'wp-block-gallery')] | " +
|
||||
"//div[contains(@class, 'wp-block-gallery')] | " +
|
||||
"//ul[contains(@class, 'wp-block-gallery')]"
|
||||
);
|
||||
if (galleryNodes == null) return 0;
|
||||
|
||||
int count = 0;
|
||||
|
|
@ -253,158 +203,163 @@ namespace wp2ghost
|
|||
var imgs = gallery.SelectNodes(".//img");
|
||||
if (imgs == null || imgs.Count == 0) continue;
|
||||
|
||||
var images = new List<(HtmlNode Wrapper, string? Caption)>();
|
||||
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)
|
||||
{
|
||||
var wrapper = TryBuildGalleryImage(htmlDoc, img);
|
||||
if (wrapper == null) continue;
|
||||
string src = GetBestImageUrl(img);
|
||||
if (string.IsNullOrEmpty(src)) 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"))));
|
||||
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
|
||||
}
|
||||
|
||||
if (images.Count == 0) continue;
|
||||
if (wrappers.Count == 0) continue;
|
||||
|
||||
ReplaceWithGhostGalleries(htmlDoc, gallery, images);
|
||||
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
|
||||
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();
|
||||
|
||||
if (distinctCaptions.Count > 0)
|
||||
{
|
||||
var newCaption = htmlDoc.CreateElement("figcaption");
|
||||
newCaption.InnerHtml = string.Join(" · ", distinctCaptions);
|
||||
ghostGallery.AppendChild(newCaption);
|
||||
ghostGallery.AddClass("kg-card-hascaption");
|
||||
}
|
||||
}
|
||||
|
||||
// data-carousel-extra als Ghost-Attribut übertragen (falls vorhanden)
|
||||
if (!string.IsNullOrEmpty(carouselExtra))
|
||||
{
|
||||
var extraAttr = htmlDoc.CreateAttribute("data-carousel-extra");
|
||||
extraAttr.Value = carouselExtra;
|
||||
ghostGallery.Attributes.Append(extraAttr);
|
||||
}
|
||||
|
||||
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Galerie-Helfer: Aufteilen, Unterschriften, Wide-Klasse, Bild-Wrapper, Zeilen
|
||||
// Gallery-Typ 3: Jetpack Tiled Gallery
|
||||
// =========================================================================
|
||||
|
||||
/// <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)
|
||||
private static int ConvertJetpackGalleries(HtmlDocument htmlDoc)
|
||||
{
|
||||
var parent = gallery.ParentNode;
|
||||
if (parent == null) return;
|
||||
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;
|
||||
|
||||
string? galleryCaption = TakeGalleryCaption(gallery);
|
||||
HtmlNode? lastGallery = null;
|
||||
var run = new List<HtmlNode>();
|
||||
|
||||
void FlushRun()
|
||||
int count = 0;
|
||||
foreach (var gallery in galleryNodes.ToList())
|
||||
{
|
||||
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);
|
||||
var imgs = gallery.SelectNodes(".//img");
|
||||
if (imgs == null || imgs.Count == 0) continue;
|
||||
|
||||
var container = htmlDoc.CreateElement("div");
|
||||
container.AddClass("kg-gallery-container");
|
||||
AppendGalleryRows(htmlDoc, container, chunk, GalleryImagesPerRow);
|
||||
ghostGallery.AppendChild(container);
|
||||
var ghostGallery = htmlDoc.CreateElement("figure");
|
||||
SetClasses(ghostGallery, "kg-card kg-gallery-card");
|
||||
AddWideClass(ghostGallery, gallery);
|
||||
|
||||
parent.InsertBefore(ghostGallery, gallery);
|
||||
lastGallery = ghostGallery;
|
||||
_ghostGalleryCount++;
|
||||
}
|
||||
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));
|
||||
}
|
||||
run.Clear();
|
||||
|
||||
if (wrappers.Count == 0) continue;
|
||||
|
||||
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
|
||||
ghostGallery.AppendChild(container);
|
||||
|
||||
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
|
||||
count++;
|
||||
}
|
||||
|
||||
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 = galleryCaption;
|
||||
lastGallery.AppendChild(newCaption);
|
||||
lastGallery.AddClass("kg-card-hascaption");
|
||||
}
|
||||
else
|
||||
{
|
||||
var paragraph = htmlDoc.CreateElement("p");
|
||||
paragraph.InnerHtml = galleryCaption;
|
||||
parent.InsertBefore(paragraph, gallery);
|
||||
}
|
||||
}
|
||||
|
||||
parent.RemoveChild(gallery);
|
||||
return count;
|
||||
}
|
||||
|
||||
// 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)
|
||||
// =========================================================================
|
||||
// Gallery-Typ 4: CoBlocks Gallery
|
||||
// =========================================================================
|
||||
private static int ConvertCoBlocksGalleries(HtmlDocument htmlDoc)
|
||||
{
|
||||
var captionNode = gallery.SelectSingleNode("./figcaption");
|
||||
if (captionNode == null)
|
||||
{
|
||||
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 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;
|
||||
|
||||
string? caption = GetCaptionHtml(captionNode);
|
||||
captionNode.Remove();
|
||||
return caption;
|
||||
int count = 0;
|
||||
foreach (var gallery in galleryNodes.ToList())
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
if (wrappers.Count == 0) continue;
|
||||
|
||||
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
|
||||
ghostGallery.AppendChild(container);
|
||||
|
||||
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static HtmlNode BuildImageCard(HtmlDocument htmlDoc, HtmlNode galleryImageWrapper, string? captionHtml)
|
||||
// =========================================================================
|
||||
// Galerie-Helfer: Spaltenanzahl, Wide-Klasse, Bild-Wrapper, Zeilen
|
||||
// =========================================================================
|
||||
private static int GetGalleryColumns(HtmlNode gallery)
|
||||
{
|
||||
var figure = htmlDoc.CreateElement("figure");
|
||||
SetClasses(figure, "kg-card kg-image-card");
|
||||
|
||||
var img = galleryImageWrapper.SelectSingleNode("./img")!;
|
||||
img.Remove();
|
||||
figure.AppendChild(img);
|
||||
|
||||
if (!string.IsNullOrEmpty(captionHtml))
|
||||
{
|
||||
var caption = htmlDoc.CreateElement("figcaption");
|
||||
caption.InnerHtml = captionHtml;
|
||||
figure.AppendChild(caption);
|
||||
figure.AddClass("kg-card-hascaption");
|
||||
}
|
||||
return figure;
|
||||
}
|
||||
|
||||
private static List<List<HtmlNode>> SplitGallery(List<HtmlNode> wrappers)
|
||||
{
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
private static void AddWideClass(HtmlNode ghostGallery, HtmlNode originalGallery)
|
||||
|
|
@ -414,21 +369,6 @@ 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");
|
||||
|
|
@ -437,19 +377,11 @@ 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;
|
||||
|
||||
var realSize = _imageSizeResolver?.Invoke(src);
|
||||
if (realSize.HasValue)
|
||||
string origSize = originalImg.GetAttributeValue("data-orig-size", "");
|
||||
if (!string.IsNullOrEmpty(origSize))
|
||||
{
|
||||
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) &&
|
||||
|
|
@ -470,17 +402,17 @@ namespace wp2ghost
|
|||
}
|
||||
}
|
||||
|
||||
// Maße unbekannt (z.B. extern eingebundenes Bild): 3:2 annehmen, statt das Bild zu verlieren
|
||||
if (w <= 0 || h <= 0)
|
||||
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("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%;");
|
||||
|
||||
newImg.SetAttributeValue("loading", "lazy");
|
||||
newImg.SetAttributeValue("alt", originalImg.GetAttributeValue("alt", ""));
|
||||
|
|
@ -571,16 +503,10 @@ namespace wp2ghost
|
|||
string src = GetBestImageUrl(img);
|
||||
if (string.IsNullOrEmpty(src)) continue;
|
||||
|
||||
// Beste Variante als src setzen; srcset/sizes verweisen auf WordPress-Größenvarianten
|
||||
img.SetAttributeValue("src", src);
|
||||
img.Attributes.Remove("srcset");
|
||||
img.Attributes.Remove("sizes");
|
||||
|
||||
var parentFigure = img.Ancestors("figure").FirstOrDefault();
|
||||
if (parentFigure != null)
|
||||
{
|
||||
bool hasCaption = GetCaptionHtml(parentFigure.SelectSingleNode("./figcaption")) != null;
|
||||
SetClasses(parentFigure, hasCaption ? "kg-card kg-image-card kg-card-hascaption" : "kg-card kg-image-card");
|
||||
SetClasses(parentFigure, "kg-card kg-image-card");
|
||||
img.AddClass("kg-image");
|
||||
}
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace wp2ghost
|
||||
{
|
||||
|
|
@ -10,95 +10,45 @@ namespace wp2ghost
|
|||
static class ImageManager
|
||||
{
|
||||
private static readonly HttpClient httpClient = new();
|
||||
// Schlüssel: Bild-Schlüssel (siehe GetImageKey) — alle WordPress-Varianten eines Bildes teilen sich einen Eintrag
|
||||
private static Dictionary<string, (string SourceUrl, string RelativePath, string LocalPath)> Cache = new();
|
||||
private static Dictionary<string, (string RelativePath, string LocalPath)> Cache = new();
|
||||
private static Dictionary<string, string> WordpressToGhostImageName = new();
|
||||
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);
|
||||
|
||||
public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip;
|
||||
|
||||
/// <summary>
|
||||
/// Schlüssel, unter dem alle Varianten desselben WordPress-Bildes zusammenfallen:
|
||||
/// Pfad ohne Host, Query (?w=1024), Größen-Suffix (-1024x683) und -scaled.
|
||||
/// </summary>
|
||||
public static string GetImageKey(string url)
|
||||
{
|
||||
string path;
|
||||
try { path = new Uri(url).AbsolutePath; }
|
||||
catch { path = url.Split('?', '#')[0]; }
|
||||
path = SizeSuffixRegex.Replace(path, "");
|
||||
path = ScaledSuffixRegex.Replace(path, "");
|
||||
return path.ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static bool TryGetRelativePath(string url, out string relativePath)
|
||||
{
|
||||
if (TryGetCached(GetImageKey(url), out var cached))
|
||||
// 1. Direkte URL-Prüfung im Cache
|
||||
if (Cache.TryGetValue(url, out var cached))
|
||||
{
|
||||
relativePath = cached.RelativePath;
|
||||
return true;
|
||||
}
|
||||
relativePath = "";
|
||||
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
|
||||
// 2. Prüfung über WordPress-Dateinamen (fürFeatured Images mit anderer URL)
|
||||
string wpName = Path.GetFileNameWithoutExtension(new Uri(url).AbsolutePath);
|
||||
if (WordpressToGhostImageName.TryGetValue(wpName, out string? relPath) && !string.IsNullOrEmpty(relPath))
|
||||
{
|
||||
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))
|
||||
{
|
||||
if (File.Exists(cached.LocalPath))
|
||||
string localPath = Path.Combine(LocalImageDir, relPath);
|
||||
if (File.Exists(localPath))
|
||||
{
|
||||
file = (cached.RelativePath, cached.LocalPath);
|
||||
RegisterForZip(file);
|
||||
relativePath = relPath;
|
||||
// Auch im Cache eintragen für zukünftige Suche
|
||||
Cache[url] = (relPath, localPath);
|
||||
return true;
|
||||
}
|
||||
Cache.Remove(key);
|
||||
WordpressToGhostImageName.Remove(wpName);
|
||||
}
|
||||
file = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void RegisterForZip((string RelativePath, string LocalPath) file)
|
||||
{
|
||||
if (!FilesToZip.Any(f => f.RelativePath == file.RelativePath))
|
||||
FilesToZip.Add(file);
|
||||
relativePath = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
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))
|
||||
|
|
@ -113,7 +63,10 @@ namespace wp2ghost
|
|||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var kvp in Cache)
|
||||
lines.Add($"{kvp.Value.SourceUrl}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}|{kvp.Key}");
|
||||
{
|
||||
string wpName = Path.GetFileNameWithoutExtension(new Uri(kvp.Key).AbsolutePath);
|
||||
lines.Add($"{kvp.Key}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}|{wpName}");
|
||||
}
|
||||
File.WriteAllLines(CachePath, lines);
|
||||
}
|
||||
|
||||
|
|
@ -124,8 +77,6 @@ namespace wp2ghost
|
|||
Directory.Delete(LocalImageDir, true);
|
||||
Directory.CreateDirectory(LocalImageDir);
|
||||
}
|
||||
if (Directory.Exists(UnusedImageDir))
|
||||
Directory.Delete(UnusedImageDir, true);
|
||||
Cache.Clear();
|
||||
FilesToZip.Clear();
|
||||
SaveCache();
|
||||
|
|
@ -138,7 +89,7 @@ namespace wp2ghost
|
|||
{
|
||||
Console.WriteLine("\nLokal gespeicherte Bilder:");
|
||||
foreach (var kvp in Cache.Take(20))
|
||||
Console.WriteLine($" {kvp.Value.SourceUrl} -> images/{kvp.Value.RelativePath}");
|
||||
Console.WriteLine($" {kvp.Key} -> images/{kvp.Value.RelativePath}");
|
||||
if (Cache.Count > 20)
|
||||
Console.WriteLine($" ... und {Cache.Count - 20} weitere");
|
||||
}
|
||||
|
|
@ -152,7 +103,7 @@ namespace wp2ghost
|
|||
var uri = new Uri(url);
|
||||
string fileName = Path.GetFileName(uri.LocalPath);
|
||||
// WordPress-Sizes: -300x200, -scaled, -1024x768 etc.
|
||||
if (SizeSuffixRegex.IsMatch(fileName))
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(fileName, @"-\d+x\d+(?=\.\w+$)"))
|
||||
return false;
|
||||
if (fileName.Contains("-scaled"))
|
||||
return false;
|
||||
|
|
@ -161,125 +112,86 @@ namespace wp2ghost
|
|||
catch { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lädt EIN Bild für eine Gruppe von URL-Varianten desselben WordPress-Bildes
|
||||
/// (Original, -scaled, -1024x683, ?w=...). Es wird die beste verfügbare Variante geladen.
|
||||
/// </summary>
|
||||
public static async Task<(string RelativePath, string LocalPath)?> DownloadBestAsync(IEnumerable<string> variants, DateTime? postDate)
|
||||
public static async Task<(string RelativePath, string LocalPath)?> DownloadAsync(string imageUrl, DateTime? postDate)
|
||||
{
|
||||
var urls = CleanUrls(variants);
|
||||
if (urls.Count == 0) return null;
|
||||
if (string.IsNullOrEmpty(imageUrl) || !Uri.IsWellFormedUriString(imageUrl, UriKind.Absolute))
|
||||
return null;
|
||||
|
||||
string key = GetImageKey(urls[0]);
|
||||
if (TryGetCached(key, out var cachedFile))
|
||||
return cachedFile;
|
||||
string wpImageName = Path.GetFileNameWithoutExtension(new Uri(imageUrl).AbsolutePath);
|
||||
|
||||
string lastError = "";
|
||||
foreach (string candidate in GetDownloadCandidates(urls))
|
||||
if (WordpressToGhostImageName.TryGetValue(wpImageName, out string? ghostRelativePath) && !string.IsNullOrEmpty(ghostRelativePath))
|
||||
{
|
||||
try
|
||||
string localPath = Path.Combine(LocalImageDir, ghostRelativePath);
|
||||
if (File.Exists(localPath))
|
||||
{
|
||||
byte[] imageBytes = await httpClient.GetByteArrayAsync(candidate);
|
||||
|
||||
var now = DateTime.Now;
|
||||
string datePath = $"{now:yyyy}/{now:MM}";
|
||||
string localDir = Path.Combine(LocalImageDir, datePath);
|
||||
if (!Directory.Exists(localDir))
|
||||
Directory.CreateDirectory(localDir);
|
||||
|
||||
// Eindeutigen Dateinamen bestimmen (nie eine vorhandene Datei wiederverwenden)
|
||||
var pDate = postDate ?? now;
|
||||
string ext = Path.GetExtension(new Uri(candidate).AbsolutePath);
|
||||
int counter = 1 + new DirectoryInfo(localDir).GetFiles().Length;
|
||||
string relativePath, localPath;
|
||||
do
|
||||
{
|
||||
relativePath = $"{datePath}/{pDate:yyyyMMdd}_{counter++}{ext}";
|
||||
localPath = Path.Combine(LocalImageDir, relativePath);
|
||||
} while (File.Exists(localPath));
|
||||
|
||||
await File.WriteAllBytesAsync(localPath, imageBytes);
|
||||
Console.WriteLine($" [Bild] Heruntergeladen: {Path.GetFileName(new Uri(candidate).AbsolutePath)} → {relativePath}");
|
||||
|
||||
ResizeImage(localPath);
|
||||
|
||||
var fileResult = (relativePath, localPath);
|
||||
Cache[key] = (candidate, relativePath, localPath);
|
||||
RegisterForZip(fileResult);
|
||||
return fileResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
Console.WriteLine($" [Bild] Bereits heruntergeladen (Wörterbuch): {wpImageName} → {ghostRelativePath}");
|
||||
return (ghostRelativePath, localPath);
|
||||
}
|
||||
WordpressToGhostImageName.Remove(wpImageName);
|
||||
}
|
||||
|
||||
Console.WriteLine($" [Bild-Fehler] Konnte {urls[0]} nicht laden: {lastError}");
|
||||
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/<WP-ID>/). 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))
|
||||
if (Cache.TryGetValue(imageUrl, out var cachedFile))
|
||||
{
|
||||
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;
|
||||
}
|
||||
if (File.Exists(cachedFile.LocalPath))
|
||||
return cachedFile;
|
||||
Cache.Remove(imageUrl);
|
||||
}
|
||||
|
||||
Console.WriteLine($" [Bild-Fehler] Konnte {urls[0]} nicht laden: {lastError}");
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
string fileName = Path.GetFileName(new Uri(imageUrl).AbsolutePath);
|
||||
string urlHash = GetHashCode(imageUrl);
|
||||
var datePrefix = DateTime.Now;
|
||||
string datePath = $"{datePrefix:yyyy}/{datePrefix:MM}";
|
||||
var localPath = Path.Combine(LocalImageDir, datePath);
|
||||
if (!string.IsNullOrEmpty(localPath) && !Directory.Exists(localPath))
|
||||
Directory.CreateDirectory(localPath);
|
||||
|
||||
private static long GetVariantArea(string url)
|
||||
{
|
||||
var m = Regex.Match(url, @"-(\d+)x(\d+)(?=\.\w+$)");
|
||||
return m.Success ? long.Parse(m.Groups[1].Value) * long.Parse(m.Groups[2].Value) : 0;
|
||||
var counter = 1 + new DirectoryInfo(localPath).GetFiles().Length;
|
||||
var pDate = postDate ?? DateTime.Now;
|
||||
string uniqueFileName = $"{pDate:yyyymmdd}_{counter}{Path.GetExtension(fileName)}";
|
||||
string relativePath = $"{datePath}/{uniqueFileName}";
|
||||
string fallBackPath = $"{datePath}/{uniqueFileName}";
|
||||
localPath = Path.Combine(LocalImageDir, relativePath);
|
||||
fallBackPath = Path.Combine(LocalImageDir, fallBackPath);
|
||||
|
||||
|
||||
|
||||
|
||||
if (File.Exists(fallBackPath))
|
||||
{
|
||||
File.Move(fallBackPath, localPath);
|
||||
Console.WriteLine($" [Bild] Bereits vorhanden: {relativePath}");
|
||||
}
|
||||
else if (!File.Exists(localPath))
|
||||
{
|
||||
byte[] imageBytes = await httpClient.GetByteArrayAsync(imageUrl);
|
||||
await File.WriteAllBytesAsync(localPath, imageBytes);
|
||||
Console.WriteLine($" [Bild] Heruntergeladen: {relativePath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($" [Bild] Bereits vorhanden: {relativePath}");
|
||||
}
|
||||
|
||||
ResizeImage(localPath);
|
||||
|
||||
var fileResult = (relativePath, localPath);
|
||||
Cache[imageUrl] = fileResult;
|
||||
WordpressToGhostImageName[wpImageName] = relativePath;
|
||||
FilesToZip.Add(fileResult);
|
||||
return fileResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" [Bild-Fehler] Konnte {imageUrl} nicht laden: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResizeImage(string localPath)
|
||||
{
|
||||
const int maxSize = 2048;
|
||||
const int maxSize = 1024;
|
||||
try
|
||||
{
|
||||
using var image = Image.Load(localPath);
|
||||
|
|
@ -311,6 +223,17 @@ namespace wp2ghost
|
|||
}
|
||||
}
|
||||
|
||||
private static string GetHashCode(string input)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
int hash = 17;
|
||||
foreach (char c in input)
|
||||
hash = hash * 23 + c;
|
||||
return hash.ToString("x8");
|
||||
}
|
||||
}
|
||||
|
||||
private static void LoadCache()
|
||||
{
|
||||
if (!File.Exists(CachePath)) return;
|
||||
|
|
@ -322,8 +245,10 @@ namespace wp2ghost
|
|||
var parts = line.Split('|');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
// Schlüssel immer aus der URL ableiten (ältere Cache-Dateien enthalten nur den Dateinamen)
|
||||
Cache[GetImageKey(parts[0])] = (parts[0], parts[1], parts[2]);
|
||||
Cache[parts[0]] = (parts[1], parts[2]);
|
||||
// WordPress-Dateinamen auch ins Dictionary laden
|
||||
if (parts.Length >= 4 && !string.IsNullOrEmpty(parts[3]))
|
||||
WordpressToGhostImageName[parts[3]] = parts[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,13 +38,6 @@ 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.");
|
||||
}
|
||||
|
||||
|
|
@ -132,16 +125,8 @@ namespace wp2ghost
|
|||
}
|
||||
|
||||
string htmlContent = wpPost.Content ?? "";
|
||||
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);
|
||||
var parsedDate = DateTime.MinValue;
|
||||
var postDate = DateTime.TryParse(wpPost.Date, out parsedDate) ? parsedDate : new DateTime(2026, 9, 1);
|
||||
|
||||
// Featured Image: falls von der WP API nicht gesetzt, ersten Bild-URL aus HTML nehmen
|
||||
if (string.IsNullOrEmpty(wpPost.FeaturedImage))
|
||||
|
|
@ -158,86 +143,82 @@ namespace wp2ghost
|
|||
}
|
||||
}
|
||||
|
||||
// Alle internen Bild-URLs im HTML (src, srcset, data-*, href) — keine externen Links
|
||||
// Alle internen Bild-URLs aus dem HTML extrahieren (keine externen Links)
|
||||
var htmlImageUrls = ExtractImageUrls(htmlContent, wpHost);
|
||||
|
||||
// Varianten desselben Bildes (Original, -scaled, -1024x683, ?w=...) zusammenfassen:
|
||||
// pro Bild wird genau EINE Datei geladen, auf die alle Varianten umgeschrieben werden.
|
||||
var imageGroups = new Dictionary<string, List<string>>();
|
||||
var mediaUrls = (wpPost.MediaFiles ?? new()).Where(u => IsInternalUrl(u, wpHost)).ToList();
|
||||
if (!string.IsNullOrEmpty(wpPost.FeaturedImage))
|
||||
mediaUrls.Add(wpPost.FeaturedImage);
|
||||
foreach (string url in htmlImageUrls.Concat(mediaUrls))
|
||||
{
|
||||
string key = ImageManager.GetImageKey(url);
|
||||
if (!imageGroups.TryGetValue(key, out var variants))
|
||||
imageGroups[key] = variants = new List<string>();
|
||||
variants.Add(url);
|
||||
}
|
||||
// Bevorzugte Media-Dateien ermitteln
|
||||
var preferredMedia = GetPreferredMediaFiles(wpPost.MediaFiles, htmlImageUrls, wpHost);
|
||||
|
||||
// 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)
|
||||
// Prüfe: Referenzierte Bilder im HTML, die NICHT in MediaFiles sind
|
||||
if (htmlImageUrls.Count > 0 && wpPost.MediaFiles != null)
|
||||
{
|
||||
// Nur angehängte, nicht verwendete Bilder: in den Extra-Ordner, nicht nach Ghost
|
||||
if (!usedKeys.Contains(group.Key))
|
||||
var mediaFileNames = wpPost.MediaFiles.Select(m => GetFileNameFromUrl(m).ToLowerInvariant()).ToHashSet();
|
||||
foreach (var imgUrl in htmlImageUrls)
|
||||
{
|
||||
await ImageManager.DownloadUnusedAsync(group.Value, wpId);
|
||||
continue;
|
||||
string imgFileName = GetFileNameFromUrl(imgUrl).ToLowerInvariant();
|
||||
if (!mediaFileNames.Contains(imgFileName))
|
||||
{
|
||||
Console.WriteLine($" [Warnung] Bild im HTML nicht in MediaFiles: {Path.GetFileName(new Uri(imgUrl).AbsolutePath)}");
|
||||
}
|
||||
}
|
||||
|
||||
var downloaded = await ImageManager.DownloadBestAsync(group.Value, postDate);
|
||||
if (!downloaded.HasValue) continue;
|
||||
ghostPathByKey[group.Key] = $"__GHOST_URL__/content/images/{downloaded.Value.RelativePath}";
|
||||
if (!imageFiles.Contains(downloaded.Value.RelativePath))
|
||||
imageFiles.Add(downloaded.Value.RelativePath);
|
||||
}
|
||||
|
||||
// Längste URLs zuerst ersetzen, damit "bild.jpg" nicht in "bild.jpg?w=1024" hineinersetzt
|
||||
foreach (string url in htmlImageUrls.OrderByDescending(u => u.Length))
|
||||
if (preferredMedia.Count > 0)
|
||||
{
|
||||
if (ghostPathByKey.TryGetValue(ImageManager.GetImageKey(url), out string? ghostMediaPath))
|
||||
htmlContent = htmlContent.Replace(url, ghostMediaPath);
|
||||
foreach (string mediaUrl in preferredMedia)
|
||||
{
|
||||
var downloaded = await ImageManager.DownloadAsync(mediaUrl, postDate);
|
||||
if (downloaded.HasValue)
|
||||
{
|
||||
string ghostMediaPath = $"__GHOST_URL__/content/images/{downloaded.Value.RelativePath}";
|
||||
htmlContent = htmlContent.Replace(mediaUrl, ghostMediaPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WordPress HTML zu Ghost-kompatibles HTML konvertieren (Galerien, Figures, etc.)
|
||||
htmlContent = HtmlGhostConverter.Convert(htmlContent, ImageManager.TryGetImageSize);
|
||||
htmlContent = HtmlGhostConverter.Convert(htmlContent);
|
||||
|
||||
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)
|
||||
// Lokale Bildpfade für JSON-Datei sammeln
|
||||
var imageFiles = new List<string>();
|
||||
if (preferredMedia.Count > 0)
|
||||
{
|
||||
htmlContent = CommentBlockBuilder.Append(htmlContent, wpPost.Comments);
|
||||
Console.WriteLine($" [Kommentare] {realComments} Kommentar(e) angehängt");
|
||||
foreach (string mediaUrl in preferredMedia)
|
||||
{
|
||||
if (ImageManager.TryGetRelativePath(mediaUrl, out string relPath))
|
||||
imageFiles.Add(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Featured Image (wurde oben mit heruntergeladen)
|
||||
// Featured Image verarbeiten
|
||||
string featureImageGhostPath = "";
|
||||
if (!string.IsNullOrEmpty(wpPost.FeaturedImage)
|
||||
&& ghostPathByKey.TryGetValue(ImageManager.GetImageKey(wpPost.FeaturedImage), out string? featPath))
|
||||
if (!string.IsNullOrEmpty(wpPost.FeaturedImage))
|
||||
{
|
||||
featureImageGhostPath = featPath;
|
||||
if (ImageManager.TryGetRelativePath(wpPost.FeaturedImage, out string featRel))
|
||||
{
|
||||
featureImageGhostPath = $"__GHOST_URL__/content/images/{featRel}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var featDownload = await ImageManager.DownloadAsync(wpPost.FeaturedImage, postDate);
|
||||
if (featDownload.HasValue)
|
||||
{
|
||||
featureImageGhostPath = $"__GHOST_URL__/content/images/{featDownload.Value.RelativePath}";
|
||||
if (!imageFiles.Contains(featDownload.Value.RelativePath))
|
||||
imageFiles.Add(featDownload.Value.RelativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ghostPost = new GhostPost
|
||||
{
|
||||
Id = internalId.ToString(),
|
||||
Title = wpPost.Title,
|
||||
Slug = SlugHelper.Clean(wpPost.Slug, $"post-{wpId}"),
|
||||
Slug = wpPost.Slug,
|
||||
Html = htmlContent,
|
||||
FeatureImage = string.IsNullOrEmpty(featureImageGhostPath) ? null : featureImageGhostPath,
|
||||
Status = "published",
|
||||
CreatedAt = postDateUtc,
|
||||
PublishedAt = postDateUtc
|
||||
CreatedAt = postDate.ToString("o"),
|
||||
PublishedAt = postDate.ToString("o")
|
||||
};
|
||||
|
||||
GhostPosts.Add(ghostPost);
|
||||
|
|
@ -249,18 +230,17 @@ namespace wp2ghost
|
|||
WpSlug = wpPost.Slug,
|
||||
GhostId = ghostPost.Id,
|
||||
Title = wpPost.Title,
|
||||
Slug = SlugHelper.Clean(wpPost.Slug, $"post-{wpId}"),
|
||||
Slug = wpPost.Slug,
|
||||
Excerpt = wpPost.Excerpt,
|
||||
Status = "published",
|
||||
CreatedAt = postDateUtc,
|
||||
PublishedAt = postDateUtc,
|
||||
CreatedAt = postDate.ToString("o"),
|
||||
PublishedAt = postDate.ToString("o"),
|
||||
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");
|
||||
|
|
@ -269,7 +249,6 @@ namespace wp2ghost
|
|||
// Cache sofort speichern (nicht erst beim Beenden)
|
||||
ImageManager.SaveCache();
|
||||
|
||||
InternalLinkManager.Register(wpId, wpPost.Slug, ghostPostFile.Slug ?? "");
|
||||
MarkProcessed(wpId, wpPost.Slug);
|
||||
internalId++;
|
||||
}
|
||||
|
|
@ -306,97 +285,81 @@ namespace wp2ghost
|
|||
// Console.WriteLine($"Markdown-Dateien gespeichert: {GhostPosts.Count} Dateien unter {MarkdownDir}");
|
||||
//}
|
||||
|
||||
// Bild-URL irgendwo im HTML: src, srcset, data-orig-file, data-large-file, href ...
|
||||
// inkl. Query (?w=1024), damit beim Ersetzen nichts davon stehen bleibt.
|
||||
private static readonly System.Text.RegularExpressions.Regex ImageUrlRegex = new(
|
||||
@"https?://[^\s""'<>()]+?\.(?:jpe?g|png|gif|webp|svg|bmp|tiff?)(?:\?[^\s""'<>]*)?(?=[\s""'<>,)&\\]|$)",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
|
||||
|
||||
private static List<string> ExtractImageUrls(string html, string wpHost)
|
||||
{
|
||||
var urls = new List<string>();
|
||||
foreach (System.Text.RegularExpressions.Match m in ImageUrlRegex.Matches(html))
|
||||
var imageExts = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".tif" };
|
||||
|
||||
// 1) <img src="..."> extrahieren
|
||||
var imgMatches = System.Text.RegularExpressions.Regex.Matches(html, @"<img\s+[^>]*src=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
foreach (System.Text.RegularExpressions.Match m in imgMatches)
|
||||
{
|
||||
string url = m.Value;
|
||||
if (IsInternalUrl(url, wpHost) && !urls.Contains(url))
|
||||
string url = m.Groups[1].Value;
|
||||
if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url))
|
||||
urls.Add(url);
|
||||
}
|
||||
|
||||
// 2) data-orig-file (Vollbild) extrahieren
|
||||
var origFileMatches = System.Text.RegularExpressions.Regex.Matches(html, @"data-orig-file=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
foreach (System.Text.RegularExpressions.Match m in origFileMatches)
|
||||
{
|
||||
string url = m.Groups[1].Value;
|
||||
if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url))
|
||||
urls.Add(url);
|
||||
}
|
||||
|
||||
// 3) data-large-file extrahieren
|
||||
var largeFileMatches = System.Text.RegularExpressions.Regex.Matches(html, @"data-large-file=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
foreach (System.Text.RegularExpressions.Match m in largeFileMatches)
|
||||
{
|
||||
string url = m.Groups[1].Value;
|
||||
if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url))
|
||||
urls.Add(url);
|
||||
}
|
||||
|
||||
// 4) srcset (größte Variante pro img) extrahieren
|
||||
var srcsetMatches = System.Text.RegularExpressions.Regex.Matches(html, @"srcset=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
foreach (System.Text.RegularExpressions.Match m in srcsetMatches)
|
||||
{
|
||||
string srcset = m.Groups[1].Value;
|
||||
string widestUrl = GetWidestFromSrcset(srcset);
|
||||
if (!string.IsNullOrEmpty(widestUrl) && IsInternalUrl(widestUrl, wpHost) && !urls.Contains(widestUrl))
|
||||
urls.Add(widestUrl);
|
||||
}
|
||||
|
||||
// 5) <a href="..."> mit Bildendung (Figure-Links auf Originalbild)
|
||||
var aMatches = System.Text.RegularExpressions.Regex.Matches(html, @"<a\s+[^>]*href=[""']([^""']+)[""'][^>]*>", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
foreach (System.Text.RegularExpressions.Match m in aMatches)
|
||||
{
|
||||
string url = m.Groups[1].Value;
|
||||
if (string.IsNullOrEmpty(url)) continue;
|
||||
try
|
||||
{
|
||||
string ext = Path.GetExtension(new Uri(url).AbsolutePath).ToLowerInvariant();
|
||||
if (imageExts.Contains(ext) && IsInternalUrl(url, wpHost) && !urls.Contains(url))
|
||||
urls.Add(url);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ersetzt jedes auf WordPress gehostete <video> 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)
|
||||
private static string GetWidestFromSrcset(string srcset)
|
||||
{
|
||||
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 bestUrl = "";
|
||||
int bestWidth = 0;
|
||||
foreach (string part in srcset.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
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)
|
||||
string[] tokens = part.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length < 2) continue;
|
||||
if (int.TryParse(tokens[1].Replace("w", ""), out int w) && w > bestWidth)
|
||||
{
|
||||
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);
|
||||
bestWidth = w;
|
||||
bestUrl = tokens[0];
|
||||
}
|
||||
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;
|
||||
return bestUrl;
|
||||
}
|
||||
|
||||
private static bool IsInternalUrl(string url, string wpHost)
|
||||
|
|
@ -413,6 +376,107 @@ namespace wp2ghost
|
|||
}
|
||||
}
|
||||
|
||||
private static string NormalizeImageUrl(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
string path = uri.AbsolutePath;
|
||||
// Entferne -scaled UND Dimensionspostfixe für den Vergleich
|
||||
path = System.Text.RegularExpressions.Regex.Replace(path, @"-scaled(?=\.\w+$)", "");
|
||||
path = System.Text.RegularExpressions.Regex.Replace(path, @"-\d+x\d+(?=\.\w+$)", "");
|
||||
return path;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFileNameFromUrl(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Path.GetFileName(new Uri(url).AbsolutePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetPreferredMediaFiles(List<string>? mediaFiles, List<string> htmlImageUrls, string wpHost)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var imageExts = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".tif" };
|
||||
|
||||
// Alle internen Bild-URLs zusammenführen: MediaFiles + HTML-referenzierte
|
||||
var allUrls = new List<string>();
|
||||
if (mediaFiles != null)
|
||||
allUrls.AddRange(mediaFiles.Where(u => IsInternalUrl(u, wpHost)));
|
||||
foreach (var u in htmlImageUrls)
|
||||
{
|
||||
if (!allUrls.Contains(u))
|
||||
allUrls.Add(u);
|
||||
}
|
||||
|
||||
if (allUrls.Count == 0)
|
||||
return result;
|
||||
|
||||
// Dateinamen aus den HTML-Links extrahieren
|
||||
var htmlFileNames = htmlImageUrls.Select(u => GetFileNameFromUrl(u).ToLowerInvariant()).ToHashSet();
|
||||
|
||||
// Gruppiere nach normalisiertem Dateinamen (ohne -scaled und Dimensionspostfixe)
|
||||
var grouped = new Dictionary<string, List<string>>();
|
||||
foreach (var url in allUrls)
|
||||
{
|
||||
string normalized = NormalizeImageUrl(url);
|
||||
if (!grouped.ContainsKey(normalized))
|
||||
grouped[normalized] = new List<string>();
|
||||
grouped[normalized].Add(url);
|
||||
}
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
// Nur Hauptbilder berücksichtigen (keine WordPress-Sizes wie -300x200, keine -scaled)
|
||||
var mainImages = group.Value.Where(u => ImageManager.IsMainImage(u)).ToList();
|
||||
if (mainImages.Count == 0)
|
||||
{
|
||||
Console.WriteLine($" [Bild] Übersprungen (kein Hauptbild): {Path.GetFileName(new Uri(@group.Value[0]).AbsolutePath)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mainImages.Count == 1)
|
||||
{
|
||||
result.Add(mainImages[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Prüfe ob eine der Varianten als <a href> im HTML referenziert wird
|
||||
string? linked = mainImages.FirstOrDefault(u => htmlFileNames.Contains(GetFileNameFromUrl(u).ToLowerInvariant()));
|
||||
|
||||
if (linked != null)
|
||||
{
|
||||
result.Add(linked);
|
||||
var skipped = mainImages.Where(u => u != linked).ToList();
|
||||
foreach (var s in skipped)
|
||||
Console.WriteLine($" [Bild] Übersprungen (nicht verlinkt): {Path.GetFileName(new Uri(s).AbsolutePath)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keine Verlinkung: Original (ohne -scaled) bevorzugen
|
||||
var preferred = mainImages.FirstOrDefault(u => !u.Contains("-scaled")) ?? mainImages[0];
|
||||
result.Add(preferred);
|
||||
var skipped = mainImages.Where(u => u != preferred).ToList();
|
||||
foreach (var s in skipped)
|
||||
Console.WriteLine($" [Bild] Übersprungen (scaled): {Path.GetFileName(new Uri(s).AbsolutePath)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void MarkProcessed(int wpId, string? wpSlug = null)
|
||||
{
|
||||
ProcessedPosts.Add(wpId);
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
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, <video>-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 (<presto-player src="https://vimeo.com/ID/HASH">) funktionieren nur mit
|
||||
/// dem WordPress-Plugin. Sie werden durch eine normale Einbettung bzw. das enthaltene
|
||||
/// <video> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,10 +19,7 @@ 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; }
|
||||
|
|
@ -40,40 +37,6 @@ 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; }
|
||||
|
|
@ -82,8 +45,5 @@ 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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,13 +33,12 @@ else
|
|||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Autor</label>
|
||||
|
|
@ -61,17 +60,7 @@ else
|
|||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Kategorie</label>
|
||||
<select class="form-select" @bind="_categoryFilter" @bind:after="ApplyFilters">
|
||||
<option value="">Alle Kategorien</option>
|
||||
@foreach (var category in AvailableCategories)
|
||||
{
|
||||
<option value="@category.Key">@category.Value</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 d-flex align-items-end mt-3">
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<button class="btn btn-outline-secondary me-2" @onclick="ResetFilters" title="Filter zurücksetzen">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</button>
|
||||
|
|
@ -100,10 +89,10 @@ else
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
@if (SelectedDeletableImages.Any())
|
||||
@if (SelectedOrphanedImages.Any())
|
||||
{
|
||||
<button class="btn btn-danger me-2" @onclick="DeleteSelected">
|
||||
Auswahl löschen (@SelectedDeletableImages.Count)
|
||||
<button class="btn btn-danger me-2" @onclick="DeleteSelectedOrphaned">
|
||||
Auswahl löschen (@SelectedOrphanedImages.Count)
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
|
@ -115,11 +104,6 @@ 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>
|
||||
|
|
@ -134,13 +118,19 @@ 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))
|
||||
{
|
||||
<span class="ms-2">
|
||||
(@(!string.IsNullOrWhiteSpace(_authorFilter) ? "Autor" : "")
|
||||
@(!string.IsNullOrWhiteSpace(_authorFilter) && !string.IsNullOrWhiteSpace(_themeFilter) ? " + " : "")
|
||||
@(!string.IsNullOrWhiteSpace(_themeFilter) ? "Thema" : ""))
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
|
|
@ -149,12 +139,6 @@ 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">
|
||||
|
|
@ -162,7 +146,7 @@ else
|
|||
{
|
||||
<div class="col">
|
||||
<div class="card h-100 @GetCardClass(image)">
|
||||
@if (image.CanDelete)
|
||||
@if (image.IsOrphaned)
|
||||
{
|
||||
<div class="card-header py-1">
|
||||
<div class="form-check">
|
||||
|
|
@ -200,14 +184,7 @@ else
|
|||
}
|
||||
</p>
|
||||
|
||||
@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)
|
||||
@if (image.IsOrphaned)
|
||||
{
|
||||
<div class="alert alert-warning py-1 px-2 mb-2 small">
|
||||
⚠️ Verwaist (nicht in Posts verwendet)
|
||||
|
|
@ -222,7 +199,7 @@ else
|
|||
{
|
||||
<li>
|
||||
<span class="badge @(post.IsFeatureImage ? "bg-primary" : "bg-secondary") me-1">
|
||||
@(post.IsFeatureImage ? "Feature" : post.IsInContent ? "Content" : "Website")
|
||||
@(post.IsFeatureImage ? "Feature" : "Content")
|
||||
</span>
|
||||
<span title="@post.Title">@post.Title</span>
|
||||
</li>
|
||||
|
|
@ -242,7 +219,7 @@ else
|
|||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => ShowEditDialog(image)">
|
||||
Bearbeiten
|
||||
</button>
|
||||
@if (image.CanDelete)
|
||||
@if (image.IsOrphaned)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ShowDeleteDialog(image)">
|
||||
Löschen
|
||||
|
|
@ -325,14 +302,7 @@ else
|
|||
<div class="modal-body">
|
||||
<p>Möchtest du wirklich <strong>@_selectedImage.FileName</strong> löschen?</p>
|
||||
<div class="alert alert-warning">
|
||||
@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>
|
||||
}
|
||||
⚠️ Dieses Bild ist verwaist und wird in keinem Post verwendet.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
|
@ -356,25 +326,21 @@ else
|
|||
<button type="button" class="btn-close" @onclick="CloseBulkDeleteDialog"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<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>
|
||||
<p>Möchtest du wirklich <strong>@SelectedOrphanedImages.Count</strong> verwaiste Bilder löschen?</p>
|
||||
<ul class="list-group small">
|
||||
@foreach (var img in SelectedDeletableImages.Take(10))
|
||||
@foreach (var img in SelectedOrphanedImages.Take(10))
|
||||
{
|
||||
<li class="list-group-item">@img.FileName</li>
|
||||
}
|
||||
@if (SelectedDeletableImages.Count > 10)
|
||||
@if (SelectedOrphanedImages.Count > 10)
|
||||
{
|
||||
<li class="list-group-item text-muted">... und @(SelectedDeletableImages.Count - 10) weitere</li>
|
||||
<li class="list-group-item text-muted">... und @(SelectedOrphanedImages.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="DeleteSelectedConfirmed" disabled="@_isDeleting">
|
||||
<button type="button" class="btn btn-danger" @onclick="DeleteSelectedOrphanedConfirmed" disabled="@_isDeleting">
|
||||
@if (_isDeleting)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
|
|
@ -458,11 +424,6 @@ 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();
|
||||
|
|
@ -476,7 +437,6 @@ else
|
|||
private int _orphanedCount;
|
||||
private int _featureCount;
|
||||
private int _contentCount;
|
||||
private string _categoryFilter = "";
|
||||
|
||||
private GhostImage? _selectedImage;
|
||||
private bool _showReplaceDialog;
|
||||
|
|
@ -535,8 +495,8 @@ else
|
|||
// Ghost-Benutzer laden
|
||||
_ghostUsers = await GhostApi.GetUsersAsync(instance);
|
||||
|
||||
_allImages = await ImageAnalyzer.AnalyzeImagesAsync(instance);
|
||||
_warningMessage = ImageAnalyzer.LastWarning;
|
||||
var filter = string.IsNullOrWhiteSpace(_searchTerm) ? "" : $"title:~'{EscapeFilter(_searchTerm)}'";
|
||||
_allImages = await ImageAnalyzer.AnalyzeImagesAsync(instance, filter);
|
||||
CalculateCounts();
|
||||
ApplyFilters();
|
||||
_hasLoaded = true;
|
||||
|
|
@ -558,13 +518,11 @@ else
|
|||
_orphanedCount = _allImages.Count(i => i.IsOrphaned);
|
||||
_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> SelectedDeletableImages => _filteredImages.Where(i => _selectedImageUrls.Contains(i.Url) && i.CanDelete).ToList();
|
||||
private List<GhostImage> SelectedOrphanedImages => _filteredImages.Where(i => _selectedImageUrls.Contains(i.Url) && i.IsOrphaned).ToList();
|
||||
|
||||
private List<KeyValuePair<string, string>> AvailableAuthors => _allImages
|
||||
.Where(i => !string.IsNullOrEmpty(i.AuthorId))
|
||||
|
|
@ -580,16 +538,8 @@ else
|
|||
.OrderBy(t => t)
|
||||
.ToList();
|
||||
|
||||
private List<KeyValuePair<string, string>> AvailableCategories => _allImages
|
||||
.Where(i => !string.IsNullOrEmpty(i.Category))
|
||||
.GroupBy(i => i.Category!)
|
||||
.Select(g => new KeyValuePair<string, string>(g.Key, g.Key))
|
||||
.OrderBy(kvp => kvp.Value)
|
||||
.ToList();
|
||||
|
||||
private int GetAuthorCount(string authorId) => _allImages.Count(i => i.AuthorId == authorId);
|
||||
private int GetThemeCount(string theme) => _allImages.Count(i => i.Theme == theme);
|
||||
private int GetCategoryCount(string category) => _allImages.Count(i => i.Category == category);
|
||||
|
||||
private void ApplyFilters()
|
||||
{
|
||||
|
|
@ -601,23 +551,6 @@ 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);
|
||||
|
|
@ -625,9 +558,6 @@ else
|
|||
if (!string.IsNullOrWhiteSpace(_themeFilter))
|
||||
query = query.Where(i => i.Theme == _themeFilter);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_categoryFilter))
|
||||
query = query.Where(i => i.Category == _categoryFilter);
|
||||
|
||||
_filteredImages = query.ToList();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
|
@ -636,9 +566,7 @@ else
|
|||
{
|
||||
_authorFilter = "";
|
||||
_themeFilter = "";
|
||||
_categoryFilter = "";
|
||||
_searchTerm = "";
|
||||
_nameSearch = "";
|
||||
_currentFilter = ImageFilter.All;
|
||||
ApplyFilters();
|
||||
}
|
||||
|
|
@ -662,30 +590,20 @@ 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;
|
||||
SetSelection(_filteredImages.Where(i => i.IsOrphaned), _selectAllOrphaned);
|
||||
}
|
||||
|
||||
private void ToggleSelectAllOriginals(ChangeEventArgs e)
|
||||
{
|
||||
_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 (_selectAllOrphaned)
|
||||
{
|
||||
if (selected)
|
||||
foreach (var image in _allImages.Where(i => i.IsOrphaned))
|
||||
_selectedImageUrls.Add(image.Url);
|
||||
else
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var image in _allImages.Where(i => i.IsOrphaned))
|
||||
_selectedImageUrls.Remove(image.Url);
|
||||
}
|
||||
StateHasChanged();
|
||||
|
|
@ -731,39 +649,24 @@ else
|
|||
_showBulkDeleteDialog = false;
|
||||
}
|
||||
|
||||
private void DeleteSelected()
|
||||
private void DeleteSelectedOrphaned()
|
||||
{
|
||||
if (SelectedDeletableImages.Any())
|
||||
if (SelectedOrphanedImages.Any())
|
||||
ShowBulkDeleteDialog();
|
||||
}
|
||||
|
||||
private async Task DeleteSelectedConfirmed()
|
||||
private async Task DeleteSelectedOrphanedConfirmed()
|
||||
{
|
||||
_isDeleting = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await DeletionService.DeleteImagesAsync(SelectedOrphanedImages);
|
||||
_selectedImageUrls.Clear();
|
||||
_selectAllOrphaned = false;
|
||||
_selectAllOriginals = false;
|
||||
_successMessage = $"{result.Success} Bilder gelöscht, {result.Failed} fehlgeschlagen.";
|
||||
await LoadImages();
|
||||
_successMessage = $"{success} Dateien gelöscht, {failed} fehlgeschlagen.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -789,10 +692,9 @@ else
|
|||
_selectedImageUrls.Remove(_selectedImage.Url);
|
||||
if (_selectedImage.ManagedImageId.HasValue)
|
||||
await DbService.DeleteAsync(_selectedImage.ManagedImageId.Value);
|
||||
var deletedName = _selectedImage.FileName;
|
||||
_successMessage = $"{_selectedImage.FileName} gelöscht.";
|
||||
CloseDeleteDialog();
|
||||
await LoadImages();
|
||||
_successMessage = $"{deletedName} gelöscht.";
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -946,13 +848,16 @@ 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
|
||||
Orphaned
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,9 +36,6 @@ public class AppDbContext : DbContext
|
|||
entity.Property(e => e.Iso).HasMaxLength(50);
|
||||
entity.Property(e => e.FocalLength).HasMaxLength(50);
|
||||
entity.Property(e => e.Theme).HasMaxLength(500);
|
||||
entity.Property(e => e.Category).HasMaxLength(200);
|
||||
entity.Property(e => e.CategorySecondary).HasMaxLength(200);
|
||||
entity.Property(e => e.Keywords).HasMaxLength(500);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,10 +41,4 @@ public class ManagedImage
|
|||
public DateTime FirstSeen { get; set; }
|
||||
public DateTime LastUpdated { get; set; }
|
||||
public bool IsOrphaned { get; set; }
|
||||
|
||||
// Kategorien / Tags
|
||||
public string? Category { get; set; } // Primäre Kategorie
|
||||
public string? CategorySecondary { get; set; } // Sekundäre Kategorie (falls mehrere)
|
||||
public bool HasMultipleCategories { get; set; }
|
||||
public string? Keywords { get; set; } // HTML Meta Keywords
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,18 +14,10 @@ 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
|
||||
|
|
|
|||
|
|
@ -6,9 +6,6 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -77,20 +77,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -15,35 +15,27 @@ public class GhostApiService
|
|||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <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")
|
||||
public async Task<List<GhostPost>> GetPostsAsync(GhostInstance instance, string filter = "", int limit = 100)
|
||||
{
|
||||
_logger.LogInformation("Hole {Resource} von {Url} (Filter: {Filter}, Limit: {Limit})", resource, instance.Url, filter, limit);
|
||||
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);
|
||||
|
||||
var request = CreateRequest(HttpMethod.Get, instance, endpoint);
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Ghost API Fehler beim Laden der Posts: {StatusCode} - {Error}", response.StatusCode, errorContent);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var posts = new List<GhostPost>();
|
||||
|
||||
for (var page = 1; ; page++)
|
||||
if (doc.RootElement.TryGetProperty("posts", out var postsElement))
|
||||
{
|
||||
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);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_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);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty(resource, out var postsElement) || postsElement.GetArrayLength() == 0)
|
||||
break;
|
||||
|
||||
foreach (var post in postsElement.EnumerateArray())
|
||||
{
|
||||
posts.Add(new GhostPost
|
||||
|
|
@ -52,72 +44,18 @@ 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} {Resource} von Ghost geladen", posts.Count, resource);
|
||||
_logger.LogInformation("{Count} Posts von Ghost geladen", posts.Count);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -24,75 +24,71 @@ public class ImageAnalyzerService
|
|||
_logger = logger;
|
||||
}
|
||||
|
||||
// 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)
|
||||
public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance, string searchTerm = "")
|
||||
{
|
||||
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. 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);
|
||||
// 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);
|
||||
|
||||
// 2. Bild-Referenzen sammeln. Schlüssel: relativer Pfad unter content/images (Ghost-Bilder)
|
||||
// bzw. die vollständige URL (externe Bilder)
|
||||
// 2. Bild-Referenzen aus Posts sammeln
|
||||
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))
|
||||
GetReference(GetReferenceKey(post.FeatureImage), post).IsFeatureImage = true;
|
||||
|
||||
// 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))
|
||||
{
|
||||
if (!ContentImageRegex.IsMatch(imgUrl))
|
||||
GetReference(imgUrl, post).IsInContent = true;
|
||||
}
|
||||
}
|
||||
if (!imageReferences.ContainsKey(post.FeatureImage))
|
||||
imageReferences[post.FeatureImage] = new List<PostReference>();
|
||||
|
||||
// Tags, Benutzer und Website-Einstellungen (Logo, Icon, Cover ...)
|
||||
foreach (var (label, json) in await _ghostApiService.GetOtherImageSourcesAsync(instance))
|
||||
{
|
||||
var source = new GhostPost { Id = label, Title = label };
|
||||
foreach (var path in ExtractContentImagePaths(json))
|
||||
GetReference(path, source);
|
||||
imageReferences[post.FeatureImage].Add(new PostReference
|
||||
{
|
||||
PostId = post.Id,
|
||||
Title = post.Title,
|
||||
Slug = post.Slug,
|
||||
IsFeatureImage = true,
|
||||
IsInContent = false
|
||||
});
|
||||
}
|
||||
|
||||
// Bilder im HTML
|
||||
if (!string.IsNullOrEmpty(post.Html))
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Lokale Dateien scannen
|
||||
|
|
@ -100,24 +96,11 @@ 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
|
||||
{
|
||||
|
|
@ -127,19 +110,10 @@ public class ImageAnalyzerService
|
|||
Url = absoluteUrl,
|
||||
FileSize = file.Length,
|
||||
LastModified = file.LastWriteTime,
|
||||
UsedInPosts = refs ?? new List<PostReference>(),
|
||||
IsOrphaned = refs == null
|
||||
UsedInPosts = imageReferences.TryGetValue(absoluteUrl, out var refs) ? refs : new List<PostReference>(),
|
||||
IsOrphaned = !imageReferences.ContainsKey(absoluteUrl)
|
||||
};
|
||||
|
||||
// "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);
|
||||
|
||||
|
|
@ -159,28 +133,26 @@ public class ImageAnalyzerService
|
|||
}
|
||||
|
||||
// Auch URLs aus Posts, die lokal nicht existieren, hinzufügen (z.B. externe Bilder)
|
||||
foreach (var key in imageReferences.Keys)
|
||||
foreach (var url in imageReferences.Keys)
|
||||
{
|
||||
if (localPaths.Contains(key))
|
||||
continue;
|
||||
|
||||
var url = IsExternalUrl(key) ? key : $"{instance.Url.TrimEnd('/')}/content/images/{key}";
|
||||
var localPath = ConvertUrlToLocalPath(instance, url);
|
||||
|
||||
var externalImage = new GhostImage
|
||||
if (!File.Exists(localPath) && !images.Any(i => i.Url.Equals(url, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Url = url,
|
||||
FileName = Path.GetFileName(url),
|
||||
RelativePath = GetRelativePath(instance.ImageFolderPath, localPath),
|
||||
FilePath = localPath,
|
||||
UsedInPosts = imageReferences[key],
|
||||
IsOrphaned = false
|
||||
};
|
||||
var externalImage = new GhostImage
|
||||
{
|
||||
Url = url,
|
||||
FileName = Path.GetFileName(url),
|
||||
RelativePath = GetRelativePath(instance.ImageFolderPath, localPath),
|
||||
FilePath = localPath,
|
||||
UsedInPosts = imageReferences[url],
|
||||
IsOrphaned = false
|
||||
};
|
||||
|
||||
var managedImage = await _dbService.SyncImageAsync(externalImage, instance.Id, null);
|
||||
ApplyDatabaseData(externalImage, managedImage);
|
||||
var managedImage = await _dbService.SyncImageAsync(externalImage, instance.Id, null);
|
||||
ApplyDatabaseData(externalImage, managedImage);
|
||||
|
||||
images.Add(externalImage);
|
||||
images.Add(externalImage);
|
||||
}
|
||||
}
|
||||
|
||||
var orphanedCount = images.Count(i => i.IsOrphaned);
|
||||
|
|
@ -207,7 +179,6 @@ 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)
|
||||
|
|
@ -261,35 +232,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -14,43 +14,23 @@ public class ImageDeletionService
|
|||
public Task<bool> DeleteImageAsync(GhostImage image)
|
||||
{
|
||||
_logger.LogInformation("Lösche Bild: {FileName} ({FilePath})", image.FileName, image.FilePath);
|
||||
var deleted = DeleteFile(image.FilePath);
|
||||
if (deleted)
|
||||
DeleteSizeVariants(image);
|
||||
return Task.FromResult(deleted);
|
||||
return Task.FromResult(DeleteFile(image.FilePath));
|
||||
}
|
||||
|
||||
// Von Ghost erzeugte Varianten unter size/w600/..., size/w600/format/webp/... mit entfernen
|
||||
private void DeleteSizeVariants(GhostImage image)
|
||||
public Task<(int Success, int Failed)> DeleteImagesAsync(IEnumerable<GhostImage> images)
|
||||
{
|
||||
var relativePath = image.RelativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
if (!image.FilePath.EndsWith(relativePath, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
int success = 0;
|
||||
int failed = 0;
|
||||
|
||||
var sizeRoot = Path.Combine(image.FilePath[..^relativePath.Length], "size");
|
||||
if (!Directory.Exists(sizeRoot))
|
||||
return;
|
||||
|
||||
try
|
||||
foreach (var image in images)
|
||||
{
|
||||
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)));
|
||||
if (DeleteFile(image.FilePath))
|
||||
success++;
|
||||
else
|
||||
failed++;
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -1,78 +0,0 @@
|
|||
(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">×</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();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{{!< 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>
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{{!< 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>
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
{{!< 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>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
{{!< 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}}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{{!-- Seitenwechsel der Beitragsübersicht --}}
|
||||
<nav class="pagination" aria-label="{{t "Pagination"}}">
|
||||
{{#if prev}}
|
||||
<a class="pagination__link" href="{{page_url prev}}">← {{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"}} →</a>
|
||||
{{else}}
|
||||
<span></span>
|
||||
{{/if}}
|
||||
</nav>
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{{!--
|
||||
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>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{{!-- 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>
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
{{!-- 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>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{{!-- 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">
|
||||
© {{date format="YYYY"}} {{@site.title}}
|
||||
{{#if @custom.footer_text}}· {{@custom.footer_text}}{{/if}}
|
||||
· {{t "Powered by"}} <a href="https://ghost.org/" target="_blank" rel="noopener">Ghost</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
{{!-- 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>
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{{!-- 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>
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
{{!< 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">·</span>
|
||||
{{/if}}
|
||||
<time datetime="{{date format="YYYY-MM-DD"}}">{{date format="D. MMMM YYYY"}}</time>
|
||||
<span aria-hidden="true">·</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">← {{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"}} →</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}}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{{!< 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>
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
<!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>
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -1,78 +0,0 @@
|
|||
(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">×</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();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{{!< 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>
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{{!< 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>
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
{{!< 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>
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
{{!< 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}}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{{!-- „Ü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"}} →</a>{{/if}}
|
||||
{{> "social-links"}}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
{{!--
|
||||
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}}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{{!-- Seitenwechsel der Beitragsübersicht --}}
|
||||
<nav class="pagination" aria-label="{{t "Pagination"}}">
|
||||
{{#if prev}}
|
||||
<a class="pagination__link" href="{{page_url prev}}">← {{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"}} →</a>
|
||||
{{else}}
|
||||
<span></span>
|
||||
{{/if}}
|
||||
</nav>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{{!--
|
||||
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>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{{!-- 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">
|
||||
© {{date format="YYYY"}} {{@site.title}}
|
||||
{{#if @custom.footer_text}}· {{@custom.footer_text}}{{/if}}
|
||||
· {{t "Powered by"}} <a href="https://ghost.org/" target="_blank" rel="noopener">Ghost</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
{{!-- 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>
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
{{!-- 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>
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{{!-- 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"}} →</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>
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
{{!< 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">·</span>
|
||||
{{/if}}
|
||||
<time datetime="{{date format="YYYY-MM-DD"}}">{{date format="D. MMMM YYYY"}}</time>
|
||||
<span aria-hidden="true">·</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">← {{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"}} →</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}}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{{!< 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>
|
||||
Binary file not shown.
|
|
@ -1,511 +0,0 @@
|
|||
/* ==========================================================================
|
||||
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; }
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
(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">×</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();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{{!< 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>
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{{!< 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>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{{!< 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>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{{!< 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}}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{{!-- Seitenwechsel der Beitragsübersicht --}}
|
||||
<nav class="pagination" aria-label="{{t "Pagination"}}">
|
||||
{{#if prev}}
|
||||
<a class="btn btn--outline pagination__newer" href="{{page_url prev}}">← {{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"}} →</a>
|
||||
{{else}}
|
||||
<span class="pagination__spacer"></span>
|
||||
{{/if}}
|
||||
</nav>
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
{{!-- 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>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{{!-- 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>© {{date format="YYYY"}} {{@site.title}}</span>
|
||||
<span>{{t "Powered by"}} <a href="https://ghost.org/" target="_blank" rel="noopener">Ghost</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
{{!-- 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>
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
{{!< 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">·</span>
|
||||
<span>{{reading_time minute=(t "1 min read") minutes=(t "% min read")}}</span>
|
||||
{{#if primary_author}}
|
||||
<span class="post-meta__sep">·</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">← {{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"}} →</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}}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{{!< 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>
|
||||
|
|
@ -175,26 +175,16 @@ function rbr_get_post_details_with_media($data) {
|
|||
// Duplikate entfernen
|
||||
$unique_media = array_values(array_unique($media_urls));
|
||||
|
||||
// 4. Kategorien - bestimme das Primäre (erste Kategorie oder nach slug sortiert)
|
||||
// 4. Kategorien
|
||||
$categories = array();
|
||||
$primary_category = null;
|
||||
$cats = get_the_category($post_id);
|
||||
if (!empty($cats) && !is_wp_error($cats)) {
|
||||
// Sortiere nach term_id (älteste/erste Kategorie wird primär)
|
||||
usort($cats, function($a, $b) {
|
||||
return $a->term_id <=> $b->term_id;
|
||||
});
|
||||
foreach ($cats as $cat) {
|
||||
$is_primary = ($cat->term_id === $cats[0]->term_id);
|
||||
$categories[] = array(
|
||||
'id' => intval($cat->term_id),
|
||||
'name' => $cat->name,
|
||||
'slug' => $cat->slug,
|
||||
'primary' => $is_primary,
|
||||
'id' => intval($cat->term_id),
|
||||
'name' => $cat->name,
|
||||
'slug' => $cat->slug,
|
||||
);
|
||||
if ($is_primary) {
|
||||
$primary_category = $cat->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,9 +236,7 @@ function rbr_get_post_details_with_media($data) {
|
|||
$wp_comments = get_comments(array(
|
||||
'post_id' => $post_id,
|
||||
'status' => 'approve',
|
||||
'number' => 500,
|
||||
'orderby' => 'comment_date_gmt',
|
||||
'order' => 'ASC',
|
||||
'number' => 100,
|
||||
));
|
||||
foreach ($wp_comments as $comment) {
|
||||
$comments[] = array(
|
||||
|
|
@ -258,10 +246,6 @@ 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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -270,8 +254,6 @@ 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(
|
||||
|
|
@ -280,7 +262,6 @@ function rbr_get_post_details_with_media($data) {
|
|||
),
|
||||
'featured_image' => $featured_image_url,
|
||||
'categories' => $categories,
|
||||
'primary_category' => $primary_category,
|
||||
'tags' => $tags,
|
||||
'keywords' => $keywords,
|
||||
'comments' => $comments,
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,562 +0,0 @@
|
|||
@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 & 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 "";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<Router AppAssembly="typeof(RenderModes).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" />
|
||||
</Found>
|
||||
</Router>
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
@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
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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();
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
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<ID>"), 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);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"profiles": {
|
||||
"Wp2GhostUI": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:49517;http://localhost:49518"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<Solution>
|
||||
<Project Path="Wp2GhostUI.csproj" />
|
||||
</Solution>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"DataDir": "",
|
||||
"Urls": "http://localhost:5080"
|
||||
}
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
/* 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; }
|
||||
}
|
||||
Loading…
Reference in a new issue