From 0f23192873977ca44378f91332a3a8421a28371a Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 19:02:47 +0200 Subject: [PATCH] feat: Protokoll tab as a core-style list table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- assets/css/admin.css | 73 ++++-- src/Admin/LogListTable.php | 326 ++++++++++++++++--------- src/Admin/LogObjects.php | 208 ++++++++++++++++ src/Admin/LogTab.php | 164 +++++++++++-- src/Plugin.php | 2 +- tests/Stubs/WpListTable.php | 43 ++++ tests/Unit/Admin/LogListTableTest.php | 329 ++++++++++++++------------ tests/Unit/Admin/LogObjectsTest.php | 167 +++++++++++++ tests/Unit/Admin/LogTabTest.php | 163 ++++++++++--- 9 files changed, 1124 insertions(+), 351 deletions(-) create mode 100644 src/Admin/LogObjects.php create mode 100644 tests/Unit/Admin/LogObjectsTest.php diff --git a/assets/css/admin.css b/assets/css/admin.css index 5007c14..6f8eac9 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -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". */ diff --git a/src/Admin/LogListTable.php b/src/Admin/LogListTable.php index ad27788..20f64a9 100644 --- a/src/Admin/LogListTable.php +++ b/src/Admin/LogListTable.php @@ -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 $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 %s', + 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 $item */ public function single_row($item): void { - $class = ($item['result'] ?? '') === 'partial' ? ' class="lit-mcp-partial"' : ''; - printf('', $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 ? '' : ''; $this->single_row_columns($item); echo ''; } /** - * 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 $item + */ + protected function column_title($item): string + { + $status = self::status((string) ($item['result'] ?? '')); + $badge = $status === null ? '' : sprintf( + '%s ', + esc_attr($status[0]), + esc_html($status[1]) + ); + + $ability = (string) ($item['ability'] ?? ''); + $tool = isset($this->toolLabels[$ability]) + ? sprintf( + '%s', + esc_attr($ability), + esc_html($this->toolLabels[$ability]) + ) + : '' . esc_html($ability) . ''; + + return sprintf( + '
%s%s
%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 $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( + '%s', + esc_url($revision), + esc_html__('Revision ansehen', 'wp-lit-mcp') + ); + } + $object = $this->object($item); + if ($object['url'] !== '') { + $actions['edit'] = sprintf( + '%s', + 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 $item */ + protected function column_object($item): string + { + return $this->object($item)['html']; + } + + /** + * @param array $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 $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( + '
%s', + 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 $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( - '%s', - esc_attr('UTC: ' . $utc), - esc_html((string) wp_date($format, $timestamp)) - ); - } - - /** @param array $item */ - protected function column_ability($item): string - { - return '' . esc_html((string) ($item['ability'] ?? '')) . ''; - } - - /** @param array $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( - '' - . ' %2$s', - esc_attr($icon), - esc_html($word) + '', + esc_attr(gmdate('c', $timestamp)), + esc_attr(sprintf('%s %s (UTC: %s)', $date, $time, $utc)), + esc_html($text) ); } /** - * @param array $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('%s', 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( - '%s', - esc_url(admin_url('revision.php?revision=' . $revisionId)), - esc_html__('Revision ansehen', 'wp-lit-mcp') - ); + return admin_url('revision.php?revision=' . $revisionId); } } diff --git a/src/Admin/LogObjects.php b/src/Admin/LogObjects.php new file mode 100644 index 0000000..6e017ad --- /dev/null +++ b/src/Admin/LogObjects.php @@ -0,0 +1,208 @@ + nav_menu terms of the current page, by term id */ + private array $menus = []; + + /** @param list> $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' => '' . esc_html(trim($type . ' ' . $id)) . '', '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]) + : '' . esc_html($location) . ''; + } + + /** @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]) + : '' . esc_html($key) . '', + $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 + */ + 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('%s', 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; + } +} diff --git a/src/Admin/LogTab.php b/src/Admin/LogTab.php index d0630c3..b0eef8c 100644 --- a/src/Admin/LogTab.php +++ b/src/Admin/LogTab.php @@ -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 $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( '

%s %s

', @@ -113,9 +188,7 @@ final class LogTab implements AdminTab echo '
'; printf('', esc_attr(SettingsPage::SLUG)); printf('', 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 '
'; @@ -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 $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 ''; @@ -163,18 +242,53 @@ final class LogTab implements AdminTab } echo ''; - self::filterLabel('date-from', __('Von Datum', 'wp-lit-mcp')); - printf( - '', - esc_attr($extra['date_from']) - ); - self::filterLabel('date-to', __('Bis Datum', 'wp-lit-mcp')); - printf( - '', - 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( + '' + . '', + 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( + '%s', + 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> 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 diff --git a/src/Plugin.php b/src/Plugin.php index bd0527d..0956152 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -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( diff --git a/tests/Stubs/WpListTable.php b/tests/Stubs/WpListTable.php index 3509108..6542ed8 100644 --- a/tests/Stubs/WpListTable.php +++ b/tests/Stubs/WpListTable.php @@ -59,16 +59,59 @@ if (! class_exists('WP_List_Table')) { echo ''; } + /** 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 $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('%s', $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 $actions */ + protected function row_actions($actions, $always_visible = false) + { + if ($actions === []) { + return ''; + } + + $links = []; + foreach ($actions as $action => $link) { + $links[] = "{$link}"; + } + + return '
' . implode(' | ', $links) . '
' . $this->toggleRow(); + } + + private function toggleRow(): string + { + return ''; + } + /** @param array $item */ protected function column_default($item, $column_name): string { diff --git a/tests/Unit/Admin/LogListTableTest.php b/tests/Unit/Admin/LogListTableTest.php index cee62d2..ee931f1 100644 --- a/tests/Unit/Admin/LogListTableTest.php +++ b/tests/Unit/Admin/LogListTableTest.php @@ -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 $filters + * @param array $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 ', 'summary' => 'böse Zusammenfassung']; + $table = $this->table([], [], null, $log); + $table->prepare_items(); - self::assertStringNotContainsString('