563 lines
27 KiB
Text
563 lines
27 KiB
Text
|
|
@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 "";
|
|||
|
|
}
|
|||
|
|
}
|