feat: Protokoll tab as a core-style list table

The change summary is the wide primary column (key "title", so it cannot
be hidden) with the tool's human label below and row actions "Revision
ansehen" / "Objekt öffnen". Objects get human names ("Seite: Kontakt",
"Menü: …", "Einstellungen: Titel der Website"; deleted/trashed marked),
primed per page with one query each for posts, menus and users. Times are
relative within 24 h. Non-ok results show a badge and a marked row instead
of an Ergebnis column. Page size and columns via Screen Options; the tool
filter shows labels grouped by integration, dates have visible labels,
and an active filter offers "Filter zurücksetzen" (also in the empty state).

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 19:02:47 +02:00
co-authored by Claude Opus 5.5
parent 22c4cc1264
commit 0f23192873
9 changed files with 1124 additions and 351 deletions
+210 -116
View File
@@ -6,30 +6,49 @@ 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).
* List of change log entries (spec §8.3, ruling C5/Q13), built like WordPress' own list
* screens: the change is the primary column with row actions, the other columns can be
* hidden and the page size set via "Ansicht anpassen". WP_List_Table must be loaded before
* this file (LogTab takes care of that).
*/
final class LogListTable extends \WP_List_Table
{
private const PER_PAGE = 50;
/** Longer client names are shortened in the "Wer" column (full name as tooltip). */
private const CLIENT_LENGTH = 22;
/** Entries younger than a day show a relative time. */
private const RELATIVE_SECONDS = 86400;
/** @var (callable(): void)|null */
private $renderFilters;
private LogObjects $objects;
private int $now;
/**
* @param array{
* ability: string, user_id: int, result?: string, date_from?: string, date_to?: string
* } $filters
* @param (callable(): void)|null $renderFilters prints the filter controls into the top table nav
* @param array<string, string> $toolLabels ability name => human label
* @param int|null $now current Unix time (tests), default time()
*/
public function __construct(private ChangeLog $log, private array $filters, ?callable $renderFilters = null)
{
public function __construct(
private ChangeLog $log,
private array $filters,
?callable $renderFilters = null,
private array $toolLabels = [],
?LogObjects $objects = null,
?int $now = null
) {
$this->renderFilters = $renderFilters;
$this->objects = $objects ?? new LogObjects();
$this->now = $now ?? time();
parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
}
@@ -37,29 +56,55 @@ final class LogListTable extends \WP_List_Table
public function get_columns(): array
{
return [
'created_at' => __('Zeit', 'wp-lit-mcp'),
'user' => __('Benutzer', 'wp-lit-mcp'),
'client' => __('Client', 'wp-lit-mcp'),
'ability' => __('Tool', 'wp-lit-mcp'),
// Key "title" as on core's list screens: WordPress never offers it for hiding.
'title' => __('Änderung', 'wp-lit-mcp'),
'object' => __('Objekt', 'wp-lit-mcp'),
'summary' => __('Zusammenfassung', 'wp-lit-mcp'),
'revision' => __('Revision', 'wp-lit-mcp'),
'result' => __('Ergebnis', 'wp-lit-mcp'),
'user' => __('Wer', 'wp-lit-mcp'),
'created_at' => __('Zeit', 'wp-lit-mcp'),
];
}
/** @return string */
protected function get_default_primary_column_name()
{
return 'title';
}
/**
* Column headers and hidden columns come from WordPress (get_column_info()), so the
* "Ansicht anpassen" choices apply. Users and objects of the page are loaded in one go.
*/
public function prepare_items(): void
{
$perPage = $this->get_items_per_page(LogTab::PER_PAGE_OPTION, LogTab::PER_PAGE_DEFAULT);
$page = max(1, (int) $this->get_pagenum());
$data = $this->log->query($this->filters, self::PER_PAGE, ($page - 1) * self::PER_PAGE);
$data = $this->log->query($this->filters, $perPage, ($page - 1) * $perPage);
$this->items = $data['rows'];
$this->_column_headers = [$this->get_columns(), [], []];
$this->set_pagination_args(['total_items' => $data['total'], 'per_page' => self::PER_PAGE]);
$this->set_pagination_args(['total_items' => $data['total'], 'per_page' => $perPage]);
$userIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): int => (int) ($row['user_id'] ?? 0),
$this->items
))));
if ($userIds !== []) {
cache_users($userIds);
}
$this->objects->prime($this->items);
}
public function no_items(): void
{
esc_html_e('Keine Einträge.', 'wp-lit-mcp');
if (! LogTab::hasActiveFilter($this->filters)) {
esc_html_e('Noch keine Änderungen protokolliert.', 'wp-lit-mcp');
return;
}
printf(
'%s <a href="%s">%s</a>',
esc_html__('Keine Einträge für diese Filter.', 'wp-lit-mcp'),
esc_url(SettingsPage::url('protokoll')),
esc_html__('Filter zurücksetzen', 'wp-lit-mcp')
);
}
/**
@@ -79,142 +124,191 @@ final class LogListTable extends \WP_List_Table
}
/**
* 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.
* Marks rows whose write did not complete (result "partial": failed after an
* irreversible step, spec §2 principle 7/8.2) so admins notice them while scrolling.
*
* @param array<string, mixed> $item
*/
public function single_row($item): void
{
$class = ($item['result'] ?? '') === 'partial' ? ' class="lit-mcp-partial"' : '';
printf('<tr%s>', $class); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string.
$status = self::status((string) ($item['result'] ?? ''));
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed class names.
echo $status === null ? '<tr>' : '<tr class="lit-mcp-row-' . $status[0] . '">';
$this->single_row_columns($item);
echo '</tr>';
}
/**
* Stored in UTC (MySQL format); shown in the site's timezone and date/time format,
* with the UTC value as tooltip.
* Badge class and word for a result that is not "ok", null for "ok".
*
* @return array{0: string, 1: string}|null
*/
private static function status(string $result): ?array
{
return match ($result) {
'ok' => null,
'partial' => ['warning', __('teilweise', 'wp-lit-mcp')],
'failed', 'error' => ['error', __('fehlgeschlagen', 'wp-lit-mcp')],
default => ['error', __('unbekannt', 'wp-lit-mcp')],
};
}
/**
* Primary column: the summary, below it the tool's human label (technical name as tooltip).
*
* @param array<string, mixed> $item
*/
protected function column_title($item): string
{
$status = self::status((string) ($item['result'] ?? ''));
$badge = $status === null ? '' : sprintf(
'<span class="lit-mcp-badge lit-mcp-badge-%s">%s</span> ',
esc_attr($status[0]),
esc_html($status[1])
);
$ability = (string) ($item['ability'] ?? '');
$tool = isset($this->toolLabels[$ability])
? sprintf(
'<span class="lit-mcp-muted" title="%s">%s</span>',
esc_attr($ability),
esc_html($this->toolLabels[$ability])
)
: '<span class="lit-mcp-muted">' . esc_html($ability) . '</span>';
return sprintf(
'<div class="lit-mcp-summary">%s%s</div>%s',
$badge,
esc_html((string) ($item['summary'] ?? '')),
$tool
);
}
/**
* "Revision ansehen" and "Objekt öffnen" – each only where it leads somewhere the
* current user may go.
*
* @param array<string, mixed> $item
* @param string $column_name
* @param string $primary
*/
protected function handle_row_actions($item, $column_name, $primary): string
{
if ($column_name !== $primary) {
return '';
}
$actions = [];
$revision = $this->revisionUrl((int) ($item['revision_id'] ?? 0));
if ($revision !== '') {
$actions['revision'] = sprintf(
'<a href="%s">%s</a>',
esc_url($revision),
esc_html__('Revision ansehen', 'wp-lit-mcp')
);
}
$object = $this->object($item);
if ($object['url'] !== '') {
$actions['edit'] = sprintf(
'<a href="%s">%s</a>',
esc_url($object['url']),
esc_html__('Objekt öffnen', 'wp-lit-mcp')
);
}
return $actions === []
? (string) parent::handle_row_actions($item, $column_name, $primary)
: (string) $this->row_actions($actions);
}
/** @param array<string, mixed> $item */
protected function column_object($item): string
{
return $this->object($item)['html'];
}
/**
* @param array<string, mixed> $item
* @return array{html: string, url: string}
*/
private function object(array $item): array
{
return $this->objects->describe((string) ($item['object_type'] ?? ''), (string) ($item['object_id'] ?? ''));
}
/**
* The WordPress user; below it the connected client, shortened (full name as tooltip).
*
* @param array<string, mixed> $item
*/
protected function column_user($item): string
{
$userId = (int) ($item['user_id'] ?? 0);
$user = get_userdata($userId);
$html = esc_html(is_object($user) ? (string) $user->display_name : '#' . $userId);
$client = (string) ($item['client'] ?? '');
if ($client !== '') {
$html .= sprintf(
'<br><span class="lit-mcp-muted" title="%s">%s</span>',
esc_attr($client),
esc_html(mb_strimwidth($client, 0, self::CLIENT_LENGTH, '…'))
);
}
return $html;
}
/**
* Stored in UTC (MySQL format). Within the last 24 hours relative ("vor 5 Minuten"),
* otherwise date and time in the site's timezone and formats; full time plus UTC as
* tooltip, as on WordPress' comments screen.
*
* @param array<string, mixed> $item
*/
protected function column_created_at($item): string
{
$utc = (string) ($item['created_at'] ?? '');
$timestamp = preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $utc) === 1
$timestamp = preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $utc) === 1
? strtotime($utc . ' UTC')
: false;
if ($timestamp === false) {
return '–';
}
$format = get_option('date_format') . ' ' . get_option('time_format');
$date = (string) wp_date((string) get_option('date_format'), $timestamp);
$time = (string) wp_date((string) get_option('time_format'), $timestamp);
$age = $this->now - $timestamp;
$text = $age >= 0 && $age < self::RELATIVE_SECONDS
/* translators: %s: time span, e.g. "5 Minuten" */
? sprintf(__('vor %s', 'wp-lit-mcp'), human_time_diff($timestamp, $this->now))
/* translators: 1: date, 2: time */
: sprintf(__('%1$s um %2$s', 'wp-lit-mcp'), $date, $time);
return sprintf(
'<span title="%s">%s</span>',
esc_attr('UTC: ' . $utc),
esc_html((string) wp_date($format, $timestamp))
);
}
/** @param array<string, mixed> $item */
protected function column_ability($item): string
{
return '<span class="lit-mcp-tool-name">' . esc_html((string) ($item['ability'] ?? '')) . '</span>';
}
/** @param array<string, mixed> $item */
protected function column_result($item): string
{
$result = (string) ($item['result'] ?? '');
[$icon, $word] = match ($result) {
'ok' => ['yes-alt', __('erfolgreich', 'wp-lit-mcp')],
'partial' => ['warning', __('teilweise', 'wp-lit-mcp')],
default => ['dismiss', __('unbekannt', 'wp-lit-mcp')],
};
return sprintf(
'<span class="lit-mcp-result lit-mcp-result-%1$s">'
. '<span class="dashicons dashicons-%1$s" aria-hidden="true"></span> %2$s</span>',
esc_attr($icon),
esc_html($word)
'<time datetime="%s" title="%s">%s</time>',
esc_attr(gmdate('c', $timestamp)),
esc_attr(sprintf('%s %s (UTC: %s)', $date, $time, $utc)),
esc_html($text)
);
}
/**
* @param array<string, mixed> $item
* @param string $column_name
* Only 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).
*/
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
private function revisionUrl(int $revisionId): string
{
if ($revisionId <= 0) {
return '–';
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 '';
}
return sprintf(
'<a href="%s">%s</a>',
esc_url(admin_url('revision.php?revision=' . $revisionId)),
esc_html__('Revision ansehen', 'wp-lit-mcp')
);
return admin_url('revision.php?revision=' . $revisionId);
}
}