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
+53 -20
View File
@@ -296,42 +296,75 @@
}
}
/* Tab "Protokoll". */
.lit-mcp .wp-list-table .column-created_at {
width: 10em;
/* Tab "Protokoll": a core-style list table; the change is the wide primary column. */
.lit-mcp .lit_mcp_log_entries .column-object {
width: 24%;
}
.lit-mcp .wp-list-table .column-user,
.lit-mcp .wp-list-table .column-client {
width: 8em;
.lit-mcp .lit_mcp_log_entries .column-user {
width: 12em;
}
.lit-mcp .wp-list-table .column-ability,
.lit-mcp .wp-list-table .column-object {
.lit-mcp .lit_mcp_log_entries .column-created_at {
width: 11em;
}
.lit-mcp .wp-list-table .column-revision,
.lit-mcp .wp-list-table .column-result {
width: 8em;
.lit-mcp .lit_mcp_log_entries .column-object,
.lit-mcp .lit_mcp_log_entries .column-user .lit-mcp-muted {
overflow-wrap: anywhere;
}
.lit-mcp .wp-list-table .lit-mcp-partial {
.lit-mcp-summary {
margin-bottom: 2px;
}
/* Non-ok results: a notice-coloured badge and a marked row (like core's unapproved comments);
#the-list beats core's "no shadow on the last row". */
.lit-mcp-badge {
display: inline-block;
padding: 0 6px;
border-radius: 2px;
font-size: 12px;
font-weight: 600;
line-height: 1.7;
}
.lit-mcp-badge-warning {
background: #fcf9e8;
color: #614a19;
box-shadow: inset 0 0 0 1px #dba617;
}
.lit-mcp-result .dashicons {
font-size: 18px;
width: 18px;
height: 18px;
vertical-align: text-bottom;
.lit-mcp-badge-error {
background: #fcf0f1;
color: #8a2424;
box-shadow: inset 0 0 0 1px #d63638;
}
.lit-mcp .tablenav .actions select,
.lit-mcp .tablenav .actions input[type="date"] {
.lit-mcp #the-list tr.lit-mcp-row-warning > :first-child {
box-shadow: inset 4px 0 0 #dba617;
}
.lit-mcp #the-list tr.lit-mcp-row-error > :first-child {
box-shadow: inset 4px 0 0 #d63638;
}
.lit-mcp .tablenav .actions select {
max-width: 14rem;
}
.lit-mcp-date {
float: left;
display: flex;
align-items: center;
gap: 4px;
margin-right: 6px;
max-width: 12.5rem;
}
.lit-mcp .tablenav .lit-mcp-reset {
display: inline-block;
line-height: 2.30769231;
margin-left: 8px;
}
/* Tab "Limits". */
+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);
}
}
+208
View File
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use WP_Post;
use WP_Term;
/**
* Human names for the objects in the change log ("Seite: Kontakt" instead of "page 834"),
* linked to their edit screen where one exists and the current user may use it. prime()
* loads the posts and menus of a whole list page in one query each (no query per row).
* Deliberately not final so the list table can be unit-tested with a mock.
*/
class LogObjects
{
/** @var array<int, WP_Term> nav_menu terms of the current page, by term id */
private array $menus = [];
/** @param list<array<string, mixed>> $rows */
public function prime(array $rows): void
{
$postIds = [];
$menuIds = [];
foreach ($rows as $row) {
$type = (string) ($row['object_type'] ?? '');
$id = (string) ($row['object_id'] ?? '');
if (! ctype_digit($id)) {
continue;
}
if ($type === 'nav_menu') {
$menuIds[] = (int) $id;
} elseif ($this->isPostType($type)) {
$postIds[] = (int) $id;
}
}
if ($postIds !== []) {
_prime_post_caches(array_values(array_unique($postIds)), false, false);
}
if ($menuIds !== []) {
$terms = get_terms([
'taxonomy' => 'nav_menu',
'include' => array_values(array_unique($menuIds)),
'hide_empty' => false,
]);
foreach (is_array($terms) ? $terms : [] as $term) {
if ($term instanceof WP_Term) {
$this->menus[(int) $term->term_id] = $term;
}
}
}
}
/**
* @return array{html: string, url: string} escaped HTML for the "Objekt" column and the
* edit URL ('' when there is none to offer)
*/
public function describe(string $type, string $id): array
{
$numeric = ctype_digit($id);
return match (true) {
$type === '' && $id === '' => ['html' => '–', 'url' => ''],
$type === 'nav_menu' && $numeric => $this->menu((int) $id),
$type === 'menu_location' => [
'html' => $this->prefixed(__('Menüposition', 'wp-lit-mcp'), $this->location($id)),
'url' => '',
],
$type === 'option' => $this->options($id),
$numeric && $this->isPostType($type) => $this->post($type, (int) $id),
default => ['html' => '<code>' . esc_html(trim($type . ' ' . $id)) . '</code>', 'url' => ''],
};
}
private function isPostType(string $type): bool
{
return $type !== '' && post_type_exists($type);
}
/** @return array{html: string, url: string} */
private function post(string $type, int $id): array
{
$typeLabel = $this->postTypeLabel($type);
$post = get_post($id);
if (! $post instanceof WP_Post) {
return ['html' => $this->prefixed($typeLabel, esc_html($this->deleted($id))), 'url' => ''];
}
$title = trim((string) $post->post_title) !== ''
? (string) $post->post_title
: __('(kein Titel)', 'wp-lit-mcp');
if ($post->post_status === 'trash') {
return [
'html' => $this->prefixed($typeLabel, esc_html(sprintf(
/* translators: %s: post title */
__('%s (im Papierkorb)', 'wp-lit-mcp'),
$title
))),
'url' => '',
];
}
$url = get_edit_post_link($id, 'raw');
return $this->linked($typeLabel, $title, is_string($url) ? $url : '');
}
private function postTypeLabel(string $type): string
{
if ($type === 'attachment') {
return __('Medium', 'wp-lit-mcp');
}
$object = get_post_type_object($type);
return is_object($object) && isset($object->labels->singular_name)
? (string) $object->labels->singular_name
: $type;
}
/** @return array{html: string, url: string} */
private function menu(int $id): array
{
$label = __('Menü', 'wp-lit-mcp');
if (! isset($this->menus[$id])) {
return ['html' => $this->prefixed($label, esc_html($this->deleted($id))), 'url' => ''];
}
$url = current_user_can('edit_theme_options') ? admin_url('nav-menus.php?action=edit&menu=' . $id) : '';
return $this->linked($label, (string) $this->menus[$id]->name, $url);
}
private function location(string $location): string
{
$registered = get_registered_nav_menus();
return isset($registered[$location])
? esc_html((string) $registered[$location])
: '<code>' . esc_html($location) . '</code>';
}
/** @return array{html: string, url: string} */
private function options(string $keys): array
{
$names = array_values(array_filter(array_map('trim', explode(',', $keys)), 'strlen'));
$known = self::optionLabels();
$labels = array_map(
static fn (string $key): string => isset($known[$key])
? esc_html($known[$key])
: '<code>' . esc_html($key) . '</code>',
$names
);
$prefix = count($names) > 1 ? __('Einstellungen', 'wp-lit-mcp') : __('Einstellung', 'wp-lit-mcp');
return ['html' => $this->prefixed($prefix, implode(', ', $labels)), 'url' => ''];
}
/**
* Labels as on WordPress' own settings screens; unknown keys are shown as code.
*
* @return array<string, string>
*/
private static function optionLabels(): array
{
return [
'blogname' => __('Titel der Website', 'wp-lit-mcp'),
'blogdescription' => __('Untertitel', 'wp-lit-mcp'),
'WPLANG' => __('Sprache der Website', 'wp-lit-mcp'),
'timezone_string' => __('Zeitzone', 'wp-lit-mcp'),
'date_format' => __('Datumsformat', 'wp-lit-mcp'),
'time_format' => __('Zeitformat', 'wp-lit-mcp'),
'show_on_front' => __('Startseite zeigt', 'wp-lit-mcp'),
'page_on_front' => __('Startseite', 'wp-lit-mcp'),
'page_for_posts' => __('Beitragsseite', 'wp-lit-mcp'),
'posts_per_page' => __('Beiträge pro Seite', 'wp-lit-mcp'),
'default_comment_status' => __('Kommentare erlauben', 'wp-lit-mcp'),
'default_ping_status' => __('Pingbacks erlauben', 'wp-lit-mcp'),
'permalink_structure' => __('Permalink-Struktur', 'wp-lit-mcp'),
'site_icon' => __('Website-Icon', 'wp-lit-mcp'),
];
}
private function deleted(int $id): string
{
/* translators: %d: object id */
return sprintf(__('#%d (gelöscht)', 'wp-lit-mcp'), $id);
}
/** @return array{html: string, url: string} */
private function linked(string $prefix, string $name, string $url): array
{
$html = $url !== ''
? sprintf('<a href="%s">%s</a>', esc_url($url), esc_html($name))
: esc_html($name);
return ['html' => $this->prefixed($prefix, $html), 'url' => $url];
}
/** @param string $html already escaped */
private function prefixed(string $label, string $html): string
{
return esc_html($label) . ': ' . $html;
}
}
+139 -25
View File
@@ -5,21 +5,34 @@ declare(strict_types=1);
namespace LIT\MCP\Admin;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Limits;
/**
* Tab "Protokoll": filterable list of change log entries, links to revisions, and
* "Protokoll leeren" (spec §8.3).
* "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'];
private AdminPostAction $action;
public function __construct(private ChangeLog $log, private Limits $limits, ?callable $terminate = null)
{
/** 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);
}
@@ -40,6 +53,51 @@ final class LogTab implements AdminTab
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} */
@@ -83,7 +141,7 @@ final class LogTab implements AdminTab
return preg_match('#^\d{4}-\d{2}-\d{2}$#D', $value) === 1;
}
public function render(): void
private function createTable(): LogListTable
{
if (! class_exists('WP_List_Table')) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
@@ -91,6 +149,23 @@ final class LogTab implements AdminTab
$filters = $this->filters();
$extra = $this->extraFilters();
$labels = [];
foreach ($this->registry->abilities() as $ability) {
$labels[$ability->name()] = $ability->label();
}
return new LogListTable(
$this->log,
$filters + $extra,
function () use ($filters, $extra, $labels): void {
$this->renderFilterControls($filters, $extra, $labels);
},
$labels
);
}
public function render(): void
{
$days = $this->limits->logRetentionDays();
printf(
'<p class="description">%s %s</p>',
@@ -113,9 +188,7 @@ final class LogTab implements AdminTab
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()));
$table = new LogListTable($this->log, $filters + $extra, function () use ($filters, $extra): void {
$this->renderFilterControls($filters, $extra);
});
$table = $this->table ?? $this->createTable();
$table->prepare_items();
$table->display();
echo '</form>';
@@ -124,20 +197,26 @@ final class LogTab implements AdminTab
}
/**
* @param array{ability: string, user_id: int} $filters
* @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): void
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->log->abilities() as $ability) {
printf(
'<option value="%1$s"%2$s>%1$s</option>',
esc_attr($ability),
selected($filters['ability'], $ability, false)
);
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>';
@@ -163,18 +242,53 @@ final class LogTab implements AdminTab
}
echo '</select>';
self::filterLabel('date-from', __('Von Datum', 'wp-lit-mcp'));
printf(
'<input type="date" name="date_from" id="lit-mcp-filter-date-from" value="%s">',
esc_attr($extra['date_from'])
);
self::filterLabel('date-to', __('Bis Datum', 'wp-lit-mcp'));
printf(
'<input type="date" name="date_to" id="lit-mcp-filter-date-to" value="%s">',
esc_attr($extra['date_to'])
);
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
+1 -1
View File
@@ -92,7 +92,7 @@ final class Plugin
new ConnectionTab($status),
new AccessTab($access, new ConnectionRepository()),
new ToolsTab($registry, $policy),
new LogTab($changeLog, $limits),
new LogTab($changeLog, $limits, $registry),
new LimitsTab(),
];
self::$settingsPage = new SettingsPage(
+43
View File
@@ -59,16 +59,59 @@ if (! class_exists('WP_List_Table')) {
echo '</tr>';
}
/** Like the real class: the per-page screen option of the current user, else the default. */
protected function get_items_per_page($option, $default_value = 20): int
{
$perPage = (int) get_user_option($option);
return $perPage < 1 ? $default_value : $perPage;
}
protected function get_default_primary_column_name()
{
$columns = $this->get_columns();
return (string) array_key_first($columns);
}
/** @param array<string, mixed> $item */
protected function single_row_columns($item): void
{
$primary = $this->get_default_primary_column_name();
foreach (array_keys($this->get_columns()) as $column_name) {
$method = 'column_' . $column_name;
$value = method_exists($this, $method) ? $this->{$method}($item) : $this->column_default($item, $column_name);
$value .= $this->handle_row_actions($item, $column_name, $primary);
printf('<td class="%s">%s</td>', $column_name, $value);
}
}
/** Like the real class: only the mobile toggle in the primary column. */
protected function handle_row_actions($item, $column_name, $primary)
{
return $column_name === $primary ? $this->toggleRow() : '';
}
/** @param array<string, string> $actions */
protected function row_actions($actions, $always_visible = false)
{
if ($actions === []) {
return '';
}
$links = [];
foreach ($actions as $action => $link) {
$links[] = "<span class='{$action}'>{$link}</span>";
}
return '<div class="row-actions">' . implode(' | ', $links) . '</div>' . $this->toggleRow();
}
private function toggleRow(): string
{
return '<button type="button" class="toggle-row"><span class="screen-reader-text">Show more details</span></button>';
}
/** @param array<string, mixed> $item */
protected function column_default($item, $column_name): string
{
+176 -153
View File
@@ -6,27 +6,31 @@ namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\LogListTable;
use LIT\MCP\Admin\LogObjects;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
use WP_Term;
final class LogListTableTest extends TestCase
{
private const NOW = 1790441353; // 2026-09-26 16:49:13 UTC
protected function setUp(): void
{
parent::setUp();
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
// Harmless defaults so single_row()'s full column pass doesn't blow up in tests
// that only care about a subset of columns; overridden per test where needed.
// Harmless defaults so a full row renders in tests that only care about one column.
Functions\when('get_userdata')->justReturn(false);
Functions\when('post_type_exists')->justReturn(false);
Functions\when('get_post')->justReturn(null);
Functions\when('get_term')->justReturn(null);
Functions\when('current_user_can')->justReturn(false);
Functions\when('get_user_option')->justReturn(false);
Functions\when('get_option')->alias(static fn (string $key) => $key === 'date_format' ? 'j. F Y' : 'H:i');
Functions\when('wp_date')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts);
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
}
protected function tearDown(): void
@@ -35,232 +39,251 @@ final class LogListTableTest extends TestCase
parent::tearDown();
}
public function testPrepareItemsQueriesTheCurrentPageAndSetsPagination(): void
/**
* @param array<string, mixed> $filters
* @param array<string, string> $toolLabels
*/
private function table(array $filters = [], array $toolLabels = [], ?LogObjects $objects = null, ?ChangeLog $log = null): LogListTable
{
return new LogListTable(
$log ?? Mockery::mock(ChangeLog::class),
$filters + ['ability' => '', 'user_id' => 0],
null,
$toolLabels,
$objects,
self::NOW
);
}
public function testPrepareItemsUsesThePerPageScreenOptionAndPrimesUsersAndObjectsOnce(): void
{
$_GET['paged'] = '3';
Functions\when('get_user_option')->alias(static fn (string $key) => $key === 'lit_mcp_log_per_page' ? '30' : false);
$rows = [
['id' => '2', 'user_id' => '3', 'object_type' => 'page', 'object_id' => '12'],
['id' => '1', 'user_id' => '3', 'object_type' => 'page', 'object_id' => '13'],
['id' => '0', 'user_id' => '4', 'object_type' => 'option', 'object_id' => 'blogname'],
];
$log = Mockery::mock(ChangeLog::class);
$log->expects('query')
->with(['ability' => 'lit/content-update', 'user_id' => 0], 50, 100)
->andReturn(['rows' => [['id' => '1']], 'total' => 120]);
->with(['ability' => 'lit/content-update', 'user_id' => 0], 30, 60)
->andReturn(['rows' => $rows, 'total' => 120]);
Functions\when('post_type_exists')->justReturn(true);
Functions\expect('cache_users')->once()->with([3, 4]);
Functions\expect('_prime_post_caches')->once()->with([12, 13], false, false);
$table = new LogListTable($log, ['ability' => 'lit/content-update', 'user_id' => 0]);
$table = $this->table(['ability' => 'lit/content-update'], [], new LogObjects(), $log);
$table->prepare_items();
self::assertSame([['id' => '1']], $table->items);
self::assertSame(['total_items' => 120, 'per_page' => 50], $table->pagination_args());
self::assertSame($rows, $table->items);
self::assertSame(['total_items' => 120, 'per_page' => 30], $table->pagination_args());
}
public function testColumnDefaultEscapesUntrustedClientAndSummaryText(): void
public function testPrepareItemsDefaultsToTwentyPerPage(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$log = Mockery::mock(ChangeLog::class);
$log->expects('query')->with(['ability' => '', 'user_id' => 0], 20, 0)->andReturn(['rows' => [], 'total' => 0]);
$item = ['client' => 'Claude <script>alert(1)</script>', 'summary' => '<b>böse</b> Zusammenfassung'];
$table = $this->table([], [], null, $log);
$table->prepare_items();
self::assertStringNotContainsString('<script>', $this->columnDefault($table, $item, 'client'));
self::assertStringContainsString('&lt;script&gt;', $this->columnDefault($table, $item, 'client'));
self::assertStringNotContainsString('<b>', $this->columnDefault($table, $item, 'summary'));
self::assertSame(['total_items' => 0, 'per_page' => 20], $table->pagination_args());
}
public function testColumnDefaultShowsUserDisplayNameOrFallback(): void
public function testColumnsPutTheChangeFirstAsTheUnhideablePrimaryColumn(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan <b>']);
$table = $this->table();
$withUser = $this->columnDefault($table, ['user_id' => 3], 'user');
self::assertStringContainsString('Jan &lt;b&gt;', $withUser);
self::assertSame(
['title' => 'Änderung', 'object' => 'Objekt', 'user' => 'Wer', 'created_at' => 'Zeit'],
$table->get_columns()
);
// "title" like core's own primary columns: WordPress never offers it for hiding (WP_Screen).
self::assertSame('title', $this->invoke($table, 'get_default_primary_column_name'));
}
public function testColumnDefaultUserFallsBackToIdWhenUserIsGone(): void
public function testChangeColumnShowsTheSummaryAndTheToolsHumanLabel(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
Functions\when('get_userdata')->justReturn(false);
$table = $this->table([], ['lit/media-upload-from-url' => 'Datei von URL <importieren>']);
self::assertSame('#7', $this->columnDefault($table, ['user_id' => 7], 'user'));
$html = $this->column($table, ['summary' => 'Datei <b>X</b> importiert', 'ability' => 'lit/media-upload-from-url', 'result' => 'ok'], 'title');
self::assertStringContainsString('Datei &lt;b&gt;X&lt;/b&gt; importiert', $html);
self::assertStringContainsString(
'<span class="lit-mcp-muted" title="lit/media-upload-from-url">Datei von URL &lt;importieren&gt;</span>',
$html
);
self::assertStringNotContainsString('lit-mcp-badge', $html);
}
public function testColumnDefaultLinksTheObjectForAKnownPostType(): void
public function testChangeColumnFallsBackToTheToolNameForAnUnknownTool(): void
{
Functions\when('post_type_exists')->justReturn(true);
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=12&action=edit');
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$html = $this->column($this->table(), ['summary' => 'x', 'ability' => 'lit/gone<', 'result' => 'ok'], 'title');
$html = $this->columnDefault($table, ['object_type' => 'page', 'object_id' => '12'], 'object');
self::assertStringContainsString('href="https://kunde.de/wp-admin/post.php?post=12&action=edit"', $html);
self::assertStringContainsString('page 12', $html);
self::assertStringContainsString('<span class="lit-mcp-muted">lit/gone&lt;</span>', $html);
}
public function testColumnDefaultShowsPlainLabelWhenGetEditPostLinkRefusesIt(): void
/** @dataProvider notOk */
public function testANotOkResultGetsABadgeAndAMarkedRow(string $result, string $class, string $word): void
{
Functions\when('post_type_exists')->justReturn(true);
Functions\when('get_edit_post_link')->justReturn(null);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$table = $this->table();
$html = $this->columnDefault($table, ['object_type' => 'page', 'object_id' => '12'], 'object');
$html = $this->column($table, ['summary' => 'x', 'ability' => 'lit/a', 'result' => $result], 'title');
self::assertStringContainsString('<span class="lit-mcp-badge lit-mcp-badge-' . $class . '">' . $word . '</span>', $html);
self::assertSame('page 12', $html);
}
public function testColumnDefaultLinksAnAttachmentToTheMediaEditScreen(): void
{
Functions\when('post_type_exists')->justReturn(true);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5]));
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$html = $this->columnDefault($table, ['object_type' => 'attachment', 'object_id' => '5'], 'object');
self::assertStringContainsString('href="https://kunde.de/wp-admin/upload.php?item=5"', $html);
}
public function testColumnDefaultShowsPlainLabelForADeletedAttachment(): void
{
Functions\when('get_post')->justReturn(null);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
self::assertSame('attachment 5', $this->columnDefault($table, ['object_type' => 'attachment', 'object_id' => '5'], 'object'));
}
public function testColumnDefaultLinksANavMenuToTheMenusScreen(): void
{
Functions\when('get_term')->justReturn(new WP_Term(['term_id' => 3, 'taxonomy' => 'nav_menu']));
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$html = $this->columnDefault($table, ['object_type' => 'nav_menu', 'object_id' => '3'], 'object');
self::assertStringContainsString('href="https://kunde.de/wp-admin/nav-menus.php?menu=3"', $html);
}
/** @dataProvider unlinkableObjectTypes */
public function testColumnDefaultNeverLinksSettingsOrMenuLocations(string $type, string $id): void
{
Functions\when('post_type_exists')->justReturn(false);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
self::assertSame(trim($type . ' ' . $id), $this->columnDefault($table, ['object_type' => $type, 'object_id' => $id], 'object'));
ob_start();
$table->single_row(['result' => $result, 'summary' => 'x', 'ability' => 'lit/a']);
self::assertStringStartsWith('<tr class="lit-mcp-row-' . $class . '">', (string) ob_get_clean());
}
/** @return list<list<string>> */
public static function unlinkableObjectTypes(): array
public static function notOk(): array
{
return [
['option', 'blogname,blogdescription'],
['menu_location', 'primary'],
['unknown', '42'],
['partial', 'warning', 'teilweise'],
['failed', 'error', 'fehlgeschlagen'],
['<b>kaputt', 'error', 'unbekannt'],
];
}
public function testRevisionColumnShowsADashWithoutARevision(): void
public function testAnOkRowIsNotMarked(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
ob_start();
$this->table()->single_row(['result' => 'ok', 'summary' => 'x', 'ability' => 'lit/a']);
self::assertSame('–', $this->columnDefault($table, ['revision_id' => 0], 'revision'));
self::assertStringStartsWith('<tr>', (string) ob_get_clean());
}
public function testRevisionColumnLinksOnlyWhenTheUserCanEditTheParentPost(): void
public function testRowActionsOfferTheRevisionAndTheObjectOnlyWhereTheyExist(): void
{
$objects = Mockery::mock(LogObjects::class);
$objects->allows('describe')->with('page', '12')->andReturn(['html' => 'Seite: X', 'url' => 'https://kunde.de/wp-admin/post.php?post=12&action=edit']);
$objects->allows('describe')->with('option', 'blogname')->andReturn(['html' => 'Einstellung: Titel', 'url' => '']);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 811, 'post_type' => 'revision', 'post_parent' => 12]));
Functions\when('current_user_can')->justReturn(true);
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $cap === 'edit_post' && $id === 12);
$table = $this->table([], [], $objects);
$html = $this->columnDefault($table, ['revision_id' => 811], 'revision');
$both = $this->rowActions($table, ['object_type' => 'page', 'object_id' => '12', 'revision_id' => '811']);
self::assertStringContainsString('<a href="https://kunde.de/wp-admin/revision.php?revision=811">Revision ansehen</a>', $both);
self::assertStringContainsString('<a href="https://kunde.de/wp-admin/post.php?post=12&action=edit">Objekt öffnen</a>', $both);
self::assertStringContainsString('href="https://kunde.de/wp-admin/revision.php?revision=811"', $html);
$none = $this->rowActions($table, ['object_type' => 'option', 'object_id' => 'blogname', 'revision_id' => null]);
self::assertStringNotContainsString('row-actions', $none);
self::assertStringContainsString('toggle-row', $none, 'the mobile toggle stays');
self::assertSame('', $this->invoke($table, 'handle_row_actions', ['object_type' => 'option'], 'user', 'title'));
}
public function testRevisionColumnHidesTheLinkWithoutEditPermission(): void
public function testRevisionActionNeedsEditRightsOnTheParentPost(): void
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 811, 'post_type' => 'revision', 'post_parent' => 12]));
Functions\when('current_user_can')->justReturn(false);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
self::assertSame('–', $this->columnDefault($table, ['revision_id' => 811], 'revision'));
$html = $this->rowActions($this->table(), ['object_type' => 'x', 'object_id' => '1', 'revision_id' => '811']);
self::assertStringNotContainsString('revision.php', $html);
}
public function testSingleRowHighlightsPartialResults(): void
public function testObjectColumnUsesTheHumanObjectDescription(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$objects = Mockery::mock(LogObjects::class);
$objects->expects('describe')->with('page', '12')->andReturn(['html' => 'Seite: <a href="u">X</a>', 'url' => 'u']);
self::assertSame('Seite: <a href="u">X</a>', $this->column($this->table([], [], $objects), ['object_type' => 'page', 'object_id' => '12'], 'object'));
}
public function testUserColumnShowsTheNameAndTheShortenedClientWithFullNameAsTitle(): void
{
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan <b>']);
$client = 'lit-mcp-integration-test-client-<script>';
$html = $this->column($this->table(), ['user_id' => 3, 'client' => $client], 'user');
self::assertStringStartsWith('Jan &lt;b&gt;<br>', $html);
self::assertStringContainsString('title="lit-mcp-integration-test-client-&lt;script&gt;"', $html);
self::assertStringContainsString('>lit-mcp-integration-t…</span>', $html);
self::assertStringNotContainsString('<script>', $html);
}
public function testUserColumnFallsBackToTheIdAndOmitsAnEmptyClient(): void
{
self::assertSame('#7', $this->column($this->table(), ['user_id' => 7, 'client' => ''], 'user'));
self::assertSame(
'#7<br><span class="lit-mcp-muted" title="Claude">Claude</span>',
$this->column($this->table(), ['user_id' => 7, 'client' => 'Claude'], 'user')
);
}
public function testTimeIsRelativeWithinADay(): void
{
Functions\when('human_time_diff')->alias(static fn (int $from, int $to): string => (($to - $from) / 60) . ' Minuten');
$html = $this->column($this->table(), ['created_at' => '2026-09-26 16:44:13'], 'created_at');
self::assertStringContainsString('>vor 5 Minuten</time>', $html);
self::assertStringContainsString('datetime="2026-09-26T16:44:13+00:00"', $html);
self::assertStringContainsString('title="j. F Y@' . (self::NOW - 300) . ' H:i@' . (self::NOW - 300) . ' (UTC: 2026-09-26 16:44:13)"', $html);
}
public function testOlderTimesShowTheSiteDateAndTime(): void
{
$ts = gmmktime(10, 39, 13, 9, 24, 2026);
$html = $this->column($this->table(), ['created_at' => '2026-09-24 10:39:13'], 'created_at');
self::assertStringContainsString('>j. F Y@' . $ts . ' um H:i@' . $ts . '</time>', $html);
self::assertSame('–', $this->column($this->table(), ['created_at' => 'kaputt'], 'created_at'));
}
public function testEmptyStateWithoutFilters(): void
{
ob_start();
$table->single_row(['result' => 'partial']);
$partial = (string) ob_get_clean();
$this->table()->no_items();
self::assertSame('Noch keine Änderungen protokolliert.', (string) ob_get_clean());
}
public function testEmptyStateWithFiltersOffersTheReset(): void
{
ob_start();
$table->single_row(['result' => 'ok']);
$ok = (string) ob_get_clean();
$this->table(['result' => 'partial'])->no_items();
self::assertStringContainsString('<tr class="lit-mcp-partial">', $partial);
self::assertSame('<tr>', substr($ok, 0, 4));
self::assertSame(
'Keine Einträge für diese Filter. <a href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=protokoll">Filter zurücksetzen</a>',
(string) ob_get_clean()
);
}
public function testTimeColumnShowsSiteTimeInWordPressFormatWithUtcAsTitle(): void
{
Functions\when('get_option')->alias(static fn (string $key) => $key === 'date_format' ? 'd.m.Y' : 'H:i');
Functions\when('wp_date')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
self::assertSame('Zeit', $table->get_columns()['created_at']);
$html = $this->column($table, ['created_at' => '2026-09-26 10:39:13'], 'created_at');
self::assertStringContainsString('d.m.Y H:i@' . gmmktime(10, 39, 13, 9, 26, 2026), $html);
self::assertStringContainsString('title="UTC: 2026-09-26 10:39:13"', $html);
self::assertSame('–', $this->column($table, ['created_at' => 'kaputt'], 'created_at'));
}
/** @dataProvider results */
public function testResultColumnShowsIconAndWord(string $result, string $icon, string $word): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$html = $this->column($table, ['result' => $result], 'result');
self::assertStringContainsString('dashicons-' . $icon, $html);
self::assertStringContainsString($word, $html);
self::assertStringNotContainsString('error', $html);
}
/** @return list<list<string>> */
public static function results(): array
{
return [
['ok', 'yes-alt', 'erfolgreich'],
['partial', 'warning', 'teilweise'],
['error<b>', 'dismiss', 'unbekannt'],
];
}
public function testToolColumnIsMutedTextAndTopNavRendersTheGivenFilters(): void
public function testTopNavRendersTheGivenFilters(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0], static function (): void {
echo '<select name="ability"></select>';
});
self::assertSame(
'<span class="lit-mcp-tool-name">lit/x&lt;</span>',
$this->column($table, ['ability' => 'lit/x<'], 'ability')
);
ob_start();
$table->display();
self::assertStringContainsString('<div class="alignleft actions"><select name="ability"></select></div>', (string) ob_get_clean());
}
/** @param array<string, mixed> $item */
private function rowActions(LogListTable $table, array $item): string
{
return (string) $this->invoke($table, 'handle_row_actions', $item, 'title', 'title');
}
private function invoke(LogListTable $table, string $method, mixed ...$args): mixed
{
return (new \ReflectionMethod($table, $method))->invoke($table, ...$args);
}
/** @param array<string, mixed> $item */
private function column(LogListTable $table, array $item, string $column): string
{
$method = 'column_' . $column;
if (method_exists($table, $method)) {
return (string) (new \ReflectionMethod($table, $method))->invoke($table, $item);
return (string) $this->invoke($table, $method, $item);
}
return $this->columnDefault($table, $item, $column);
}
/** @param array<string, mixed> $item */
private function columnDefault(LogListTable $table, array $item, string $column): string
{
return (string) (new \ReflectionMethod($table, 'column_default'))->invoke($table, $item, $column);
return (string) $this->invoke($table, 'column_default', $item, $column);
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\LogObjects;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
use WP_Term;
final class LogObjectsTest extends TestCase
{
/** @var array<int, WP_Post> */
private array $posts = [];
protected function setUp(): void
{
parent::setUp();
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
Functions\when('post_type_exists')->alias(static fn (string $type): bool => in_array($type, ['page', 'post', 'attachment'], true));
Functions\when('get_post_type_object')->alias(static fn (string $type): object => (object) [
'labels' => (object) ['singular_name' => ['page' => 'Seite', 'post' => 'Beitrag'][$type] ?? $type],
]);
Functions\when('get_post')->alias(fn ($id) => $this->posts[(int) $id] ?? null);
Functions\when('get_edit_post_link')->alias(
static fn ($id, $context = 'display'): string => 'https://kunde.de/wp-admin/post.php?post=' . $id . '&action=edit'
);
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']);
}
public function testAPageShowsItsTypeLabelAndLinkedTitle(): void
{
$this->posts[834] = new WP_Post(['ID' => 834, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => '[Test] <b>Kontakt</b>']);
$object = (new LogObjects())->describe('page', '834');
self::assertSame(
'Seite: <a href="https://kunde.de/wp-admin/post.php?post=834&action=edit">[Test] &lt;b&gt;Kontakt&lt;/b&gt;</a>',
$object['html']
);
self::assertSame('https://kunde.de/wp-admin/post.php?post=834&action=edit', $object['url']);
}
public function testAnUntitledPostSaysSo(): void
{
$this->posts[5] = new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_status' => 'draft', 'post_title' => '']);
self::assertStringContainsString('>(kein Titel)</a>', (new LogObjects())->describe('post', '5')['html']);
}
public function testNoLinkWhenTheUserMayNotEditThePost(): void
{
Functions\when('get_edit_post_link')->justReturn(null);
$this->posts[834] = new WP_Post(['ID' => 834, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Kontakt']);
self::assertSame(['html' => 'Seite: Kontakt', 'url' => ''], (new LogObjects())->describe('page', '834'));
}
public function testATrashedPostIsNamedButNotLinked(): void
{
$this->posts[783] = new WP_Post(['ID' => 783, 'post_type' => 'page', 'post_status' => 'trash', 'post_title' => 'LIT Smoke Seite']);
self::assertSame(
['html' => 'Seite: LIT Smoke Seite (im Papierkorb)', 'url' => ''],
(new LogObjects())->describe('page', '783')
);
}
public function testADeletedPostShowsItsIdAsDeleted(): void
{
self::assertSame(['html' => 'Seite: #834 (gelöscht)', 'url' => ''], (new LogObjects())->describe('page', '834'));
}
public function testAnAttachmentIsAMediumLinkedToTheMediaEditScreen(): void
{
$this->posts[830] = new WP_Post(['ID' => 830, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => 'Massivholzmoebel']);
$object = (new LogObjects())->describe('attachment', '830');
self::assertSame(
'Medium: <a href="https://kunde.de/wp-admin/post.php?post=830&action=edit">Massivholzmoebel</a>',
$object['html']
);
self::assertSame('Medium: #799 (gelöscht)', (new LogObjects())->describe('attachment', '799')['html']);
}
public function testANavMenuIsLinkedToTheMenusScreen(): void
{
Functions\when('get_terms')->justReturn([new WP_Term(['term_id' => 21, 'name' => 'Haupt<menü>', 'taxonomy' => 'nav_menu'])]);
$objects = new LogObjects();
$objects->prime([['object_type' => 'nav_menu', 'object_id' => '21'], ['object_type' => 'nav_menu', 'object_id' => '22']]);
self::assertSame(
[
'html' => 'Menü: <a href="https://kunde.de/wp-admin/nav-menus.php?action=edit&menu=21">Haupt&lt;menü&gt;</a>',
'url' => 'https://kunde.de/wp-admin/nav-menus.php?action=edit&menu=21',
],
$objects->describe('nav_menu', '21')
);
self::assertSame(['html' => 'Menü: #22 (gelöscht)', 'url' => ''], $objects->describe('nav_menu', '22'));
}
public function testANavMenuIsNotLinkedWithoutTheMenuCapability(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\when('get_terms')->justReturn([new WP_Term(['term_id' => 21, 'name' => 'Hauptmenü', 'taxonomy' => 'nav_menu'])]);
$objects = new LogObjects();
$objects->prime([['object_type' => 'nav_menu', 'object_id' => '21']]);
self::assertSame(['html' => 'Menü: Hauptmenü', 'url' => ''], $objects->describe('nav_menu', '21'));
}
public function testAMenuLocationShowsItsRegisteredLabel(): void
{
self::assertSame(['html' => 'Menüposition: Hauptmenü', 'url' => ''], (new LogObjects())->describe('menu_location', 'primary-menu'));
self::assertSame(
['html' => 'Menüposition: <code>footer&lt;x&gt;</code>', 'url' => ''],
(new LogObjects())->describe('menu_location', 'footer<x>')
);
}
public function testOptionsShowHumanLabelsAndUnknownKeysAsCode(): void
{
self::assertSame(
['html' => 'Einstellungen: Titel der Website, Untertitel', 'url' => ''],
(new LogObjects())->describe('option', 'blogname,blogdescription')
);
self::assertSame(
['html' => 'Einstellung: <code>foo&lt;b&gt;</code>', 'url' => ''],
(new LogObjects())->describe('option', 'foo<b>')
);
}
public function testAnUnknownObjectTypeIsShownAsCode(): void
{
self::assertSame(['html' => '<code>shop_thing 42</code>', 'url' => ''], (new LogObjects())->describe('shop_thing', '42'));
self::assertSame(['html' => '–', 'url' => ''], (new LogObjects())->describe('', ''));
}
public function testPrimeLoadsAllPostsAndMenusOfThePageInOneGoEach(): void
{
Functions\expect('_prime_post_caches')->once()->with([834, 830], false, false);
Functions\expect('get_terms')->once()->with(['taxonomy' => 'nav_menu', 'include' => [21], 'hide_empty' => false])->andReturn([]);
(new LogObjects())->prime([
['object_type' => 'page', 'object_id' => '834'],
['object_type' => 'attachment', 'object_id' => '830'],
['object_type' => 'page', 'object_id' => '834'],
['object_type' => 'nav_menu', 'object_id' => '21'],
['object_type' => 'option', 'object_id' => 'blogname'],
['object_type' => 'page', 'object_id' => 'x'],
]);
}
public function testPrimeQueriesNothingWithoutPostsOrMenus(): void
{
Functions\expect('_prime_post_caches')->never();
Functions\expect('get_terms')->never();
(new LogObjects())->prime([['object_type' => 'option', 'object_id' => 'blogname']]);
}
}
+127 -36
View File
@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Admin\LogTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
@@ -16,13 +19,45 @@ final class LogTabTest extends TestCase
{
protected function tearDown(): void
{
unset($_GET['ability'], $_GET['user_id'], $_GET['result'], $_GET['date_from'], $_GET['date_to']);
unset($_GET['ability'], $_GET['user_id'], $_GET['result'], $_GET['date_from'], $_GET['date_to'], $_GET['tab']);
parent::tearDown();
}
private function tab(ChangeLog $log): LogTab
{
return new LogTab($log, new Limits(), $this->registry());
}
private function registry(): IntegrationRegistry
{
return new IntegrationRegistry(
$this->integration('media', 'Medien', ['lit/media-upload-from-url' => 'Datei von URL importieren']),
$this->integration('wordpress', 'WordPress', ['lit/content-update' => 'Inhalt <ändern>'])
);
}
/** @param array<string, string> $abilities name => label */
private function integration(string $id, string $label, array $abilities): Integration
{
$mocks = [];
foreach ($abilities as $name => $abilityLabel) {
$ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn($name);
$ability->allows('label')->andReturn($abilityLabel);
$mocks[] = $ability;
}
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn($id);
$integration->allows('label')->andReturn($label);
$integration->allows('isAvailable')->andReturn(true);
$integration->allows('abilities')->andReturn($mocks);
return $integration;
}
public function testFiltersAreReadSanitized(): void
{
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
$tab = $this->tab(Mockery::mock(ChangeLog::class));
$_GET['ability'] = 'lit/content-update';
$_GET['user_id'] = '3x';
@@ -34,7 +69,7 @@ final class LogTabTest extends TestCase
public function testExtraFiltersWhitelistResultAndDateRange(): void
{
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
$tab = $this->tab(Mockery::mock(ChangeLog::class));
self::assertSame(
['result' => '', 'date_from' => '', 'date_to' => ''],
@@ -54,7 +89,7 @@ final class LogTabTest extends TestCase
public function testExtraFiltersRejectUnknownResultAndMalformedDates(): void
{
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
$tab = $this->tab(Mockery::mock(ChangeLog::class));
$_GET['result'] = 'ok"><script>';
$_GET['date_from'] = 'not-a-date';
@@ -70,7 +105,7 @@ final class LogTabTest extends TestCase
public function testFiltersRejectATrailingNewlineEvenWithoutTheSanitizer(): void
{
Functions\when('sanitize_text_field')->returnArg();
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
$tab = $this->tab(Mockery::mock(ChangeLog::class));
$_GET['ability'] = "lit/content-update\n";
$_GET['date_from'] = "2026-09-01\n";
$_GET['date_to'] = "2026-09-25\n";
@@ -93,7 +128,7 @@ final class LogTabTest extends TestCase
$log->expects('clear')->once();
$terminated = false;
(new LogTab($log, new Limits(), static function () use (&$terminated): void {
(new LogTab($log, new Limits(), $this->registry(), static function () use (&$terminated): void {
$terminated = true;
}))->handleClear();
@@ -110,24 +145,100 @@ final class LogTabTest extends TestCase
$log->expects('clear')->never();
$this->expectException(RuntimeException::class);
(new LogTab($log, new Limits()))->handleClear();
$this->tab($log)->handleClear();
}
public function testRegistersTheClearAction(): void
public function testRegistersTheClearActionAndTheScreenOptions(): void
{
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
$tab = $this->tab(Mockery::mock(ChangeLog::class));
Functions\expect('add_action')->once()->with('admin_post_lit_mcp_clear_log', [$tab, 'handleClear']);
Functions\expect('add_action')->once()->with('load-settings_page_lit-mcp', [$tab, 'loadScreen']);
Functions\expect('add_filter')->once()->with('set_screen_option_lit_mcp_log_per_page', [$tab, 'savePerPage'], 10, 3);
$tab->registerActions();
}
public function testLoadScreenAddsThePerPageScreenOptionOnlyOnThisTab(): void
{
Functions\stubTranslationFunctions();
Functions\when('sanitize_key')->returnArg();
$tab = $this->tab(Mockery::mock(ChangeLog::class));
Functions\expect('add_screen_option')->never();
$_GET['tab'] = 'tools';
$tab->loadScreen();
Functions\expect('add_screen_option')->once()->with('per_page', [
'label' => 'Einträge pro Seite',
'default' => 20,
'option' => 'lit_mcp_log_per_page',
]);
$_GET['tab'] = 'protokoll';
$tab->loadScreen();
}
public function testSavePerPageKeepsOnlyItsOwnOption(): void
{
$tab = $this->tab(Mockery::mock(ChangeLog::class));
self::assertSame(40, $tab->savePerPage(false, 'lit_mcp_log_per_page', '40'));
self::assertFalse($tab->savePerPage(false, 'other_per_page', '40'));
}
public function testRenderShowsFiltersAndAConfirmProtectedClearButton(): void
{
$html = $this->render(['lit/content-update', 'lit/media-upload-from-url', 'lit/x"><script>']);
self::assertStringContainsString('name="result"', $html);
self::assertStringNotContainsString('<script>', $html);
self::assertStringContainsString('lit_mcp_clear_log', $html);
self::assertStringContainsString('confirm(', $html);
self::assertStringContainsString('nonce-lit_mcp_clear_log', $html);
// One intro line; the retention days link to the Limits tab.
self::assertMatchesRegularExpression('#<p class="description">[^<]*<a href="[^"]*tab=limits">90 Tagen</a>#', $html);
// Selects keep screen-reader labels; the dates have short visible ones.
foreach (['ability', 'user-id', 'result'] as $field) {
self::assertStringContainsString('<label class="screen-reader-text" for="lit-mcp-filter-' . $field . '">', $html);
}
self::assertStringContainsString('<label for="lit-mcp-filter-date-from">von</label>', $html);
self::assertStringContainsString('<label for="lit-mcp-filter-date-to">bis</label>', $html);
self::assertStringContainsString('<option value="ok">erfolgreich</option>', $html);
self::assertStringContainsString('<option value="partial">teilweise</option>', $html);
self::assertStringNotContainsString('Filter zurücksetzen', $html, 'no reset without an active filter');
}
public function testToolFilterShowsHumanLabelsGroupedByIntegration(): void
{
$_GET['ability'] = 'lit/content-update';
$html = $this->render(['lit/content-update', 'lit/gone', 'lit/media-upload-from-url']);
self::assertStringContainsString(
'<optgroup label="Medien"><option value="lit/media-upload-from-url">Datei von URL importieren</option></optgroup>'
. '<optgroup label="WordPress"><option value="lit/content-update" selected="selected">Inhalt &lt;ändern&gt;</option></optgroup>'
. '<optgroup label="Weitere"><option value="lit/gone">lit/gone</option></optgroup>',
$html
);
}
public function testAnActiveFilterOffersTheReset(): void
{
$_GET['date_from'] = '2026-09-01';
$html = $this->render([]);
self::assertStringContainsString(
'<a class="lit-mcp-reset" href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=protokoll">Filter zurücksetzen</a>',
$html
);
}
/** @param list<string> $abilities abilities found in the log */
private function render(array $abilities): string
{
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
Functions\when('selected')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' selected="selected"' : '');
Functions\when('checked')->justReturn('');
Functions\when('settings_fields')->justReturn(null);
Functions\when('submit_button')->alias(static function (
string $text = '',
string $type = 'primary',
@@ -150,36 +261,16 @@ final class LogTabTest extends TestCase
});
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
Functions\when('get_option')->justReturn([]);
Functions\when('get_user_option')->justReturn(false);
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
$log = Mockery::mock(ChangeLog::class);
$log->allows('abilities')->andReturn(['lit/content-update', 'lit/x"><script>']);
$log->allows('abilities')->andReturn($abilities);
$log->allows('query')->andReturn(['rows' => [], 'total' => 0]);
ob_start();
(new LogTab($log, new Limits()))->render();
$html = (string) ob_get_clean();
$this->tab($log)->render();
self::assertStringContainsString('name="result"', $html);
self::assertStringContainsString('name="date_from"', $html);
self::assertStringContainsString('name="date_to"', $html);
self::assertStringContainsString('lit/content-update', $html);
self::assertStringNotContainsString('<script>', $html);
self::assertStringContainsString('lit_mcp_clear_log', $html);
self::assertStringContainsString('confirm(', $html);
self::assertStringContainsString('nonce-lit_mcp_clear_log', $html);
// One intro line; the retention days link to the Limits tab.
self::assertMatchesRegularExpression('#<p class="description">[^<]*<a href="[^"]*tab=limits">90 Tagen</a>#', $html);
// Every filter control has a (screen-reader) label.
foreach (['ability', 'user_id', 'result', 'date_from', 'date_to'] as $name) {
self::assertMatchesRegularExpression(
'#<label class="screen-reader-text" for="lit-mcp-filter-' . str_replace('_', '-', $name) . '">#',
$html,
'label for ' . $name
);
}
self::assertStringContainsString('<option value="ok">erfolgreich</option>', $html);
self::assertStringContainsString('<option value="partial">teilweise</option>', $html);
return (string) ob_get_clean();
}
}