Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
317 lines
11 KiB
PHP
317 lines
11 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Admin;
|
||
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
// 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), 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
|
||
{
|
||
/** 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,
|
||
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<string, string> */
|
||
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 <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')
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 '<div class="alignleft actions">';
|
||
($this->renderFilters)();
|
||
echo '</div>';
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
{
|
||
$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>';
|
||
}
|
||
|
||
/**
|
||
* 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}$/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(
|
||
'<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)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|