- 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>
348 lines
13 KiB
PHP
348 lines
13 KiB
PHP
<?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');
|
||
}
|
||
}
|