action = new AdminPostAction($terminate); } public function slug(): string { return 'protokoll'; } public function label(): string { return __('Protokoll', 'wp-lit-mcp'); } public function registerSettings(): void { } public function registerActions(): void { add_action('admin_post_' . self::CLEAR_ACTION, [$this, 'handleClear']); add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'loadScreen']); add_filter('set_screen_option_' . self::PER_PAGE_OPTION, [$this, 'savePerPage'], 10, 3); } /** * Load hook of the settings page: on this tab only, "Einträge pro Seite" in the screen * options, and the list table built early so its columns can be hidden there too * (WP_List_Table registers them for the screen, as on edit.php). */ public function loadScreen(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch. $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : ''; if ($tab !== $this->slug()) { return; } add_screen_option('per_page', [ 'label' => __('Einträge pro Seite', 'wp-lit-mcp'), 'default' => self::PER_PAGE_DEFAULT, 'option' => self::PER_PAGE_OPTION, ]); $this->table = $this->createTable(); } /** * Lets WordPress save the page size (it already checked nonce and range 1–999). * * @return int|false */ public function savePerPage(mixed $status, string $option, mixed $value): mixed { return $option === self::PER_PAGE_OPTION ? (int) $value : $status; } /** @param array $filters */ public static function hasActiveFilter(array $filters): bool { foreach ($filters as $value) { if ($value !== '' && $value !== 0) { return true; } } return false; } /** @return array{ability: string, user_id: int} */ public function filters(): array { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters. $ability = isset($_GET['ability']) ? sanitize_text_field(wp_unslash((string) $_GET['ability'])) : ''; $userId = isset($_GET['user_id']) ? absint($_GET['user_id']) : 0; // phpcs:enable WordPress.Security.NonceVerification.Recommended return [ 'ability' => preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#D', $ability) === 1 ? $ability : '', 'user_id' => $userId, ]; } /** * Additional whitelisted filters (ruling C5/Q13: ability, result, user, date range). * Kept separate from filters() so that method's return shape stays exactly what is * unit-tested (and what earlier callers of this tab may already rely on). * * @return array{result: string, date_from: string, date_to: string} */ public function extraFilters(): array { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters. $result = isset($_GET['result']) ? sanitize_text_field(wp_unslash((string) $_GET['result'])) : ''; $dateFrom = isset($_GET['date_from']) ? sanitize_text_field(wp_unslash((string) $_GET['date_from'])) : ''; $dateTo = isset($_GET['date_to']) ? sanitize_text_field(wp_unslash((string) $_GET['date_to'])) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended return [ 'result' => in_array($result, self::RESULTS, true) ? $result : '', 'date_from' => self::isDate($dateFrom) ? $dateFrom : '', 'date_to' => self::isDate($dateTo) ? $dateTo : '', ]; } private static function isDate(string $value): bool { return preg_match('#^\d{4}-\d{2}-\d{2}$#D', $value) === 1; } private function createTable(): LogListTable { if (! class_exists('WP_List_Table')) { require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; } $filters = $this->filters(); $extra = $this->extraFilters(); return new LogListTable($this->log, $filters + $extra, $this->toolLabels()); } /** @return array ability name => label */ private function toolLabels(): array { $labels = []; foreach ($this->registry->abilities() as $ability) { $labels[$ability->name()] = $ability->label(); } return $labels; } public function render(): void { $days = $this->limits->logRetentionDays(); printf( '

%s %s

', esc_html__('Das Protokoll enthält nur Verweise, keine Inhalte.', 'wp-lit-mcp'), sprintf( /* translators: %s: link "N Tagen" to the Limits tab */ esc_html__('Einträge werden nach %s automatisch gelöscht.', 'wp-lit-mcp'), sprintf( '%s', esc_url(SettingsPage::url('limits')), esc_html(sprintf( /* translators: %d: number of days */ _n('%d Tag', '%d Tagen', $days, 'wp-lit-mcp'), $days )) ) ) ); echo '
'; printf('', esc_attr(SettingsPage::SLUG)); printf('', esc_attr($this->slug())); // Own block, not the table nav: core hides ".tablenav .actions" on phones. echo '
'; $this->renderFilterControls($this->filters(), $this->extraFilters(), $this->toolLabels()); echo '
'; $table = $this->table ?? $this->createTable(); $table->prepare_items(); $table->display(); echo '
'; // The whole log, not the filtered page: "leeren" removes everything. $total = $this->log->count(); if ($total > 0) { $this->renderClearForm($total); } } /** * @param array{ability: string, user_id: int} $filters * @param array{result: string, date_from: string, date_to: string} $extra * @param array $labels ability name => label */ private function renderFilterControls(array $filters, array $extra, array $labels): void { self::filterLabel('ability', __('Nach Tool filtern', 'wp-lit-mcp')); echo ''; self::filterLabel('user-id', __('Nach Benutzer filtern', 'wp-lit-mcp')); wp_dropdown_users([ 'name' => 'user_id', 'id' => 'lit-mcp-filter-user-id', 'show_option_all' => __('Alle Benutzer', 'wp-lit-mcp'), 'selected' => $filters['user_id'], ]); self::filterLabel('result', __('Nach Ergebnis filtern', 'wp-lit-mcp')); echo ''; 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 { printf( '', esc_attr($field), esc_html($text) ); } private function renderClearForm(int $total): void { printf('
', esc_url(admin_url('admin-post.php'))); printf('', esc_attr(self::CLEAR_ACTION)); wp_nonce_field(self::CLEAR_ACTION); $confirmMessage = esc_js(__( 'Das gesamte Protokoll unwiderruflich löschen?', 'wp-lit-mcp' )); submit_button( sprintf( /* translators: %d: number of log entries */ _n('Protokoll leeren (%d Eintrag)', 'Protokoll leeren (%d Einträge)', $total, 'wp-lit-mcp'), $total ), 'delete', 'submit', false, ['onclick' => "return window.confirm('" . $confirmMessage . "');"] ); echo '
'; } public function handleClear(): void { $this->action->guard(); check_admin_referer(self::CLEAR_ACTION); $this->log->clear(); $this->action->redirect($this->slug(), 'cleared'); } }