$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, 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]); } /** @return array */ public function get_columns(): array { return [ // Key "title" as on core's list screens: WordPress never offers it for hiding. 'title' => __('Änderung', 'wp-lit-mcp'), 'object' => __('Objekt', '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, $perPage, ($page - 1) * $perPage); $this->items = $data['rows']; $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 { 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') ); } /** * Filters left of the pagination in the top table nav, as on WordPress' own list screens. * * @param string $which */ protected function extra_tablenav($which): void { if ($which !== 'top' || $this->renderFilters === null) { return; } echo '
'; ($this->renderFilters)(); echo '
'; } /** * 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 { $status = self::status((string) ($item['result'] ?? '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed class names. echo $status === null ? '' : ''; $this->single_row_columns($item); echo ''; } /** * 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}$/D', $utc) === 1 ? strtotime($utc . ' UTC') : false; if ($timestamp === false) { return '–'; } $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( '', esc_attr(gmdate('c', $timestamp)), esc_attr(sprintf('%s %s (UTC: %s)', $date, $time, $utc)), esc_html($text) ); } /** * 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). */ private function revisionUrl(int $revisionId): string { if ($revisionId <= 0) { return ''; } $revision = get_post($revisionId); $parentId = $revision instanceof WP_Post ? (int) $revision->post_parent : 0; if ($parentId <= 0 || ! current_user_can('edit_post', $parentId)) { return ''; } return admin_url('revision.php?revision=' . $revisionId); } }