Wp2ghost/Wp2GhostUI/Components/Pages/Home.razor
2026-10-14 19:12:29 +02:00

562 lines
27 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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