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:
+210
-116
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user