feat: change log tab with filters, revision links and clear button

ChangeLog gains query()/abilities()/clear() (filters: ability, user,
result, date range - all optional and additive). LogTab/LogListTable
render the "Protokoll" admin tab: WP_List_Table with pagination, links
to the post/attachment/nav_menu edit screen by object_type, a revision
link shown only when the user may edit the revision's parent post, and
a nonce+manage_options-gated "Protokoll leeren" with a JS confirmation
and highlighted 'partial' rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 04:12:13 +02:00
co-authored by Claude Opus 5.5
parent 9104989c43
commit 522f5bbd19
9 changed files with 940 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Post;
use WP_Term;
// phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps -- method names are defined by WP_List_Table.
/**
* List of change log entries (spec §8.3, ruling C5/Q13). WP_List_Table must be loaded
* before this file (LogTab::render() takes care of that).
*/
final class LogListTable extends \WP_List_Table
{
private const PER_PAGE = 50;
/**
* @param array{
* ability: string, user_id: int, result?: string, date_from?: string, date_to?: string
* } $filters
*/
public function __construct(private ChangeLog $log, private array $filters)
{
parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
}
/** @return array<string, string> */
public function get_columns(): array
{
return [
'created_at' => __('Zeit (UTC)', 'wp-lit-mcp'),
'user' => __('Benutzer', 'wp-lit-mcp'),
'client' => __('Client', 'wp-lit-mcp'),
'ability' => __('Tool', 'wp-lit-mcp'),
'object' => __('Objekt', 'wp-lit-mcp'),
'summary' => __('Zusammenfassung', 'wp-lit-mcp'),
'revision' => __('Revision', 'wp-lit-mcp'),
'result' => __('Ergebnis', 'wp-lit-mcp'),
];
}
public function prepare_items(): void
{
$page = max(1, (int) $this->get_pagenum());
$data = $this->log->query($this->filters, self::PER_PAGE, ($page - 1) * self::PER_PAGE);
$this->items = $data['rows'];
$this->_column_headers = [$this->get_columns(), [], []];
$this->set_pagination_args(['total_items' => $data['total'], 'per_page' => self::PER_PAGE]);
}
public function no_items(): void
{
esc_html_e('Keine Einträge.', 'wp-lit-mcp');
}
/**
* Highlights rows with result "partial" (a write that failed after an irreversible
* step, spec §2 principle 7/8.2) so admins notice them without reading every row.
*
* @param array<string, mixed> $item
*/
public function single_row($item): void
{
$style = ($item['result'] ?? '') === 'partial' ? ' style="background:#fcf3e3;"' : '';
printf('<tr%s>', $style); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string.
$this->single_row_columns($item);
echo '</tr>';
}
/**
* @param array<string, mixed> $item
* @param string $column_name
*/
protected function column_default($item, $column_name): string
{
return match ($column_name) {
'user' => $this->userName((int) ($item['user_id'] ?? 0)),
'object' => $this->objectLink((string) ($item['object_type'] ?? ''), (string) ($item['object_id'] ?? '')),
'revision' => $this->revisionLink((int) ($item['revision_id'] ?? 0)),
default => esc_html((string) ($item[$column_name] ?? '')),
};
}
private function userName(int $userId): string
{
$user = get_userdata($userId);
return esc_html(is_object($user) ? (string) $user->display_name : '#' . $userId);
}
/**
* Links by object_type (ruling C5/Q13): known post types (not attachment) via
* get_edit_post_link(); attachment via the media edit screen; nav_menu (a term id)
* via the menus screen; settings/menu_location/anything else has no admin screen
* to link to and is shown as plain text.
*/
private function objectLink(string $objectType, string $objectId): string
{
$label = trim($objectType . ' ' . $objectId);
$url = $objectId !== '' && ctype_digit($objectId) ? $this->urlFor($objectType, (int) $objectId) : '';
return $url !== '' ? sprintf('<a href="%s">%s</a>', esc_url($url), esc_html($label)) : esc_html($label);
}
private function urlFor(string $objectType, int $id): string
{
if ($objectType === 'attachment') {
return get_post($id) instanceof WP_Post ? admin_url('upload.php?item=' . $id) : '';
}
if ($objectType === 'nav_menu') {
return get_term($id, 'nav_menu') instanceof WP_Term ? admin_url('nav-menus.php?menu=' . $id) : '';
}
if ($objectType !== '' && post_type_exists($objectType)) {
$link = get_edit_post_link($id, 'raw');
return is_string($link) ? $link : '';
}
return '';
}
/**
* Only shown when the user could actually act on it: revision_id set and the
* revision's parent post is one the current user may edit (ruling C5/Q13).
*/
private function revisionLink(int $revisionId): string
{
if ($revisionId <= 0) {
return '–';
}
$revision = get_post($revisionId);
$parentId = $revision instanceof WP_Post ? (int) $revision->post_parent : 0;
if ($parentId <= 0 || ! current_user_can('edit_post', $parentId)) {
return '–';
}
return sprintf(
'<a href="%s">%s</a>',
esc_url(admin_url('revision.php?revision=' . $revisionId)),
esc_html__('ansehen / wiederherstellen', 'wp-lit-mcp')
);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Limits;
/**
* Tab "Protokoll": filterable list of change log entries, links to revisions, and
* "Protokoll leeren" (spec §8.3).
*/
final class LogTab implements AdminTab
{
public const CLEAR_ACTION = 'lit_mcp_clear_log';
private const RESULTS = ['ok', 'partial'];
/** @var callable(): void */
private $terminate;
public function __construct(private ChangeLog $log, private Limits $limits, ?callable $terminate = null)
{
$this->terminate = $terminate ?? static function (): void {
exit;
};
}
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']);
}
/** @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-]+$#', $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}$#', $value) === 1;
}
public function render(): void
{
if (! class_exists('WP_List_Table')) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
$filters = $this->filters();
$extra = $this->extraFilters();
printf(
'<p>%s</p>',
esc_html(sprintf(
/* translators: %d: number of days */
__('Einträge werden nach %d Tagen automatisch gelöscht (einstellbar im Tab „Limits“).', 'wp-lit-mcp'),
$this->limits->logRetentionDays()
))
);
printf('<p>%s</p>', esc_html__('Das Protokoll enthält nur Verweise, keine Inhalte.', 'wp-lit-mcp'));
$this->renderFilterForm($filters, $extra);
$table = new LogListTable($this->log, $filters + $extra);
$table->prepare_items();
$table->display();
$this->renderClearForm();
}
/**
* @param array{ability: string, user_id: int} $filters
* @param array{result: string, date_from: string, date_to: string} $extra
*/
private function renderFilterForm(array $filters, array $extra): void
{
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()));
echo '<select name="ability"><option value="">' . esc_html__('Alle Tools', 'wp-lit-mcp') . '</option>';
foreach ($this->log->abilities() as $ability) {
printf(
'<option value="%1$s"%2$s>%1$s</option>',
esc_attr($ability),
selected($filters['ability'], $ability, false)
);
}
echo '</select> ';
wp_dropdown_users([
'name' => 'user_id',
'show_option_all' => __('Alle Benutzer', 'wp-lit-mcp'),
'selected' => $filters['user_id'],
]);
echo ' ';
echo '<select name="result">';
printf('<option value="">%s</option>', esc_html__('Alle Ergebnisse', 'wp-lit-mcp'));
foreach (self::RESULTS as $result) {
printf(
'<option value="%1$s"%2$s>%1$s</option>',
esc_attr($result),
selected($extra['result'], $result, false)
);
}
echo '</select> ';
printf(
'<label>%s <input type="date" name="date_from" value="%s"></label> ',
esc_html__('von', 'wp-lit-mcp'),
esc_attr($extra['date_from'])
);
printf(
'<label>%s <input type="date" name="date_to" value="%s"></label> ',
esc_html__('bis', 'wp-lit-mcp'),
esc_attr($extra['date_to'])
);
submit_button(__('Filtern', 'wp-lit-mcp'), 'secondary', '', false);
echo '</form>';
}
private function renderClearForm(): void
{
printf('<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(
__('Protokoll leeren', 'wp-lit-mcp'),
'delete',
'submit',
false,
['onclick' => "return window.confirm('" . $confirmMessage . "');"]
);
echo '</form>';
}
public function handleClear(): void
{
if (! current_user_can('manage_options')) {
wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]);
}
check_admin_referer(self::CLEAR_ACTION);
$this->log->clear();
wp_safe_redirect(SettingsPage::url($this->slug(), ['lit_mcp_notice' => 'cleared']));
($this->terminate)();
}
}