Files
WP-LIT-MCP/src/Admin/LogTab.php
T
Jan-Niclas LoosenandClaude Fable 5.1 1b73f0652a fix: Admin-UX aus dem Review – Live-Gruppenfeedback, Protokollfilter, Divi-Hinweis, Rollen-Rückfrage
- Tools: „Ganze Gruppe“ und „Nur lesen“ wirken sofort auf die Zeilen
  (Option „wie Gruppe (…)“, gesperrte Selects mit Hinweis), der Server
  bleibt die Wahrheit; Markup trägt die nötigen data-Attribute.
- Protokoll: Filter in eigenem Block über der Tabelle (Core blendet
  .tablenav .actions auf Telefonen aus); „Protokoll leeren“ nur bei
  Einträgen, rechtsbündig, mit Gesamtzahl über ChangeLog::count().
- Divi: Beschreibung unter „Prüfung starten“, Button primär solange
  ungetestet ohne frischen Bericht.
- Zugriff: Rückfrage vor dem Speichern ohne Rolle oder ohne eigene Rolle.
- Verbindung: HTTPS-Hinweis nicht doppelt neben der Statusliste.
- Mobil: breitere Einstellungs-Spalte im Tools-Reiter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 16:39:44 +02:00

348 lines
13 KiB
PHP
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.
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Limits;
defined('ABSPATH') || exit;
/**
* Tab "Protokoll": filterable list of change log entries, links to revisions, and
* "Protokoll leeren" (spec §8.3). Page size and visible columns via WordPress'
* "Ansicht anpassen" (screen options), as on the core list screens.
*/
final class LogTab implements AdminTab
{
public const CLEAR_ACTION = 'lit_mcp_clear_log';
/** Screen option "Einträge pro Seite" (user option; WordPress needs the "_per_page" end). */
public const PER_PAGE_OPTION = 'lit_mcp_log_per_page';
public const PER_PAGE_DEFAULT = 20;
private const RESULTS = ['ok', 'partial', 'refused'];
private AdminPostAction $action;
/** Created on the screen's load hook so WordPress knows its columns for the screen options. */
private ?LogListTable $table = null;
public function __construct(
private ChangeLog $log,
private Limits $limits,
private IntegrationRegistry $registry,
?callable $terminate = null
) {
$this->action = new AdminPostAction($terminate);
}
public function slug(): string
{
return 'protokoll';
}
public function label(): string
{
return __('Protokoll', 'wp-lit-mcp');
}
public function registerSettings(): void
{
}
public function registerActions(): void
{
add_action('admin_post_' . self::CLEAR_ACTION, [$this, 'handleClear']);
add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'loadScreen']);
add_filter('set_screen_option_' . self::PER_PAGE_OPTION, [$this, 'savePerPage'], 10, 3);
}
/**
* Load hook of the settings page: on this tab only, "Einträge pro Seite" in the screen
* options, and the list table built early so its columns can be hidden there too
* (WP_List_Table registers them for the screen, as on edit.php).
*/
public function loadScreen(): void
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
$tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
if ($tab !== $this->slug()) {
return;
}
add_screen_option('per_page', [
'label' => __('Einträge pro Seite', 'wp-lit-mcp'),
'default' => self::PER_PAGE_DEFAULT,
'option' => self::PER_PAGE_OPTION,
]);
$this->table = $this->createTable();
}
/**
* Lets WordPress save the page size (it already checked nonce and range 1–999).
*
* @return int|false
*/
public function savePerPage(mixed $status, string $option, mixed $value): mixed
{
return $option === self::PER_PAGE_OPTION ? (int) $value : $status;
}
/** @param array<string, int|string> $filters */
public static function hasActiveFilter(array $filters): bool
{
foreach ($filters as $value) {
if ($value !== '' && $value !== 0) {
return true;
}
}
return false;
}
/** @return array{ability: string, user_id: int} */
public function filters(): array
{
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters.
$ability = isset($_GET['ability']) ? sanitize_text_field(wp_unslash((string) $_GET['ability'])) : '';
$userId = isset($_GET['user_id']) ? absint($_GET['user_id']) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return [
'ability' => preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#D', $ability) === 1 ? $ability : '',
'user_id' => $userId,
];
}
/**
* Additional whitelisted filters (ruling C5/Q13: ability, result, user, date range).
* Kept separate from filters() so that method's return shape stays exactly what is
* unit-tested (and what earlier callers of this tab may already rely on).
*
* @return array{result: string, date_from: string, date_to: string}
*/
public function extraFilters(): array
{
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters.
$result = isset($_GET['result']) ? sanitize_text_field(wp_unslash((string) $_GET['result'])) : '';
$dateFrom = isset($_GET['date_from']) ? sanitize_text_field(wp_unslash((string) $_GET['date_from'])) : '';
$dateTo = isset($_GET['date_to']) ? sanitize_text_field(wp_unslash((string) $_GET['date_to'])) : '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return [
'result' => in_array($result, self::RESULTS, true) ? $result : '',
'date_from' => self::isDate($dateFrom) ? $dateFrom : '',
'date_to' => self::isDate($dateTo) ? $dateTo : '',
];
}
private static function isDate(string $value): bool
{
return preg_match('#^\d{4}-\d{2}-\d{2}$#D', $value) === 1;
}
private function createTable(): LogListTable
{
if (! class_exists('WP_List_Table')) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
$filters = $this->filters();
$extra = $this->extraFilters();
return new LogListTable($this->log, $filters + $extra, $this->toolLabels());
}
/** @return array<string, string> ability name => label */
private function toolLabels(): array
{
$labels = [];
foreach ($this->registry->abilities() as $ability) {
$labels[$ability->name()] = $ability->label();
}
return $labels;
}
public function render(): void
{
$days = $this->limits->logRetentionDays();
printf(
'<p class="description">%s %s</p>',
esc_html__('Das Protokoll enthält nur Verweise, keine Inhalte.', 'wp-lit-mcp'),
sprintf(
/* translators: %s: link "N Tagen" to the Limits tab */
esc_html__('Einträge werden nach %s automatisch gelöscht.', 'wp-lit-mcp'),
sprintf(
'<a href="%s">%s</a>',
esc_url(SettingsPage::url('limits')),
esc_html(sprintf(
/* translators: %d: number of days */
_n('%d Tag', '%d Tagen', $days, 'wp-lit-mcp'),
$days
))
)
)
);
echo '<form method="get">';
printf('<input type="hidden" name="page" value="%s">', esc_attr(SettingsPage::SLUG));
printf('<input type="hidden" name="tab" value="%s">', esc_attr($this->slug()));
// Own block, not the table nav: core hides ".tablenav .actions" on phones.
echo '<div class="lit-mcp-log-filters">';
$this->renderFilterControls($this->filters(), $this->extraFilters(), $this->toolLabels());
echo '</div>';
$table = $this->table ?? $this->createTable();
$table->prepare_items();
$table->display();
echo '</form>';
// The whole log, not the filtered page: "leeren" removes everything.
$total = $this->log->count();
if ($total > 0) {
$this->renderClearForm($total);
}
}
/**
* @param array{ability: string, user_id: int} $filters
* @param array{result: string, date_from: string, date_to: string} $extra
* @param array<string, string> $labels ability name => label
*/
private function renderFilterControls(array $filters, array $extra, array $labels): void
{
self::filterLabel('ability', __('Nach Tool filtern', 'wp-lit-mcp'));
echo '<select name="ability" id="lit-mcp-filter-ability"><option value="">'
. esc_html__('Alle Tools', 'wp-lit-mcp') . '</option>';
foreach ($this->toolOptionGroups() as $group => $abilities) {
printf('<optgroup label="%s">', esc_attr($group));
foreach ($abilities as $ability) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr($ability),
selected($filters['ability'], $ability, false),
esc_html($labels[$ability] ?? $ability)
);
}
echo '</optgroup>';
}
echo '</select>';
self::filterLabel('user-id', __('Nach Benutzer filtern', 'wp-lit-mcp'));
wp_dropdown_users([
'name' => 'user_id',
'id' => 'lit-mcp-filter-user-id',
'show_option_all' => __('Alle Benutzer', 'wp-lit-mcp'),
'selected' => $filters['user_id'],
]);
self::filterLabel('result', __('Nach Ergebnis filtern', 'wp-lit-mcp'));
echo '<select name="result" id="lit-mcp-filter-result">';
printf('<option value="">%s</option>', esc_html__('Alle Ergebnisse', 'wp-lit-mcp'));
$labels = [
'ok' => __('erfolgreich', 'wp-lit-mcp'),
'partial' => __('teilweise', 'wp-lit-mcp'),
'refused' => __('abgelehnt', 'wp-lit-mcp'),
];
foreach (self::RESULTS as $result) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr($result),
selected($extra['result'], $result, false),
esc_html($labels[$result])
);
}
echo '</select>';
foreach (['date_from' => __('von', 'wp-lit-mcp'), 'date_to' => __('bis', 'wp-lit-mcp')] as $name => $label) {
$id = 'lit-mcp-filter-' . str_replace('_', '-', $name);
printf(
'<span class="lit-mcp-date"><label for="%1$s">%2$s</label>'
. '<input type="date" name="%3$s" id="%1$s" value="%4$s"></span>',
esc_attr($id),
esc_html($label),
esc_attr($name),
esc_attr($extra[$name])
);
}
submit_button(__('Filtern', 'wp-lit-mcp'), '', 'filter_action', false, ['id' => 'lit-mcp-filter-submit']);
if (self::hasActiveFilter($filters + $extra)) {
printf(
'<a class="lit-mcp-reset" href="%s">%s</a>',
esc_url(SettingsPage::url($this->slug())),
esc_html__('Filter zurücksetzen', 'wp-lit-mcp')
);
}
}
/**
* The tools found in the log, grouped like the "Tools" tab (integration order);
* tools no longer available land in "Weitere".
*
* @return array<string, list<string>> group label => ability names
*/
private function toolOptionGroups(): array
{
$logged = array_flip($this->log->abilities());
$groupLabels = $this->registry->labels();
$groups = [];
foreach ($this->registry->grouped() as $group => $abilities) {
foreach ($abilities as $ability) {
if (isset($logged[$ability->name()])) {
$groups[$groupLabels[$group] ?? $group][] = $ability->name();
unset($logged[$ability->name()]);
}
}
}
if ($logged !== []) {
$groups[__('Weitere', 'wp-lit-mcp')] = array_map('strval', array_keys($logged));
}
return $groups;
}
private static function filterLabel(string $field, string $text): void
{
printf(
'<label class="screen-reader-text" for="lit-mcp-filter-%s">%s</label>',
esc_attr($field),
esc_html($text)
);
}
private function renderClearForm(int $total): void
{
printf('<div class="lit-mcp-clear-log"><form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::CLEAR_ACTION));
wp_nonce_field(self::CLEAR_ACTION);
$confirmMessage = esc_js(__(
'Das gesamte Protokoll unwiderruflich löschen?',
'wp-lit-mcp'
));
submit_button(
sprintf(
/* translators: %d: number of log entries */
_n('Protokoll leeren (%d Eintrag)', 'Protokoll leeren (%d Einträge)', $total, 'wp-lit-mcp'),
$total
),
'delete',
'submit',
false,
['onclick' => "return window.confirm('" . $confirmMessage . "');"]
);
echo '</form></div>';
}
public function handleClear(): void
{
$this->action->guard();
check_admin_referer(self::CLEAR_ACTION);
$this->log->clear();
$this->action->redirect($this->slug(), 'cleared');
}
}