feat: admin settings page UI/UX pass with WordPress-native patterns
- Header: h1 + logo in a wrapping flex row, hr.wp-header-end before the tabs, aria-current on the active tab; own CSS/JS enqueued only on this screen. - Verbindung: ordered "So verbindest du Claude" steps, copy button (core clipboard.js + wp.a11y.speak), Site-Health-style status list, German details. - Zugriff: roles as form-table fieldset, connections with site date format, empty-state row, confirm on revoke; ConnectionUsage records last use via WP_Application_Passwords::record_application_password_usage (once a day). - Tools: plain-language legend, counter as inline notice, groups as core postboxes (postbox.js toggles, per-user closed state, summary when closed), fixed column widths with new "Art" column, submit above and below. - Protokoll: site-timezone times with UTC tooltip, result icons, filters with labels in the list table nav, retention link to Limits. - Limits: short labels with units and descriptions; snapshot count hidden but preserved. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+67
-30
@@ -89,16 +89,17 @@ final class AccessTab implements AdminTab
|
||||
public function render(): void
|
||||
{
|
||||
$allowed = $this->access->allowedRoles();
|
||||
$legend = __('Rollen mit Zugriff', 'wp-lit-mcp');
|
||||
|
||||
echo '<h2>' . esc_html__('Freigegebene Rollen', 'wp-lit-mcp') . '</h2>';
|
||||
echo '<p>' . esc_html__(
|
||||
'Nur Benutzer dieser Rollen können Tools nutzen; zusätzlich gelten ihre normalen WordPress-Rechte.',
|
||||
'wp-lit-mcp'
|
||||
) . '</p>';
|
||||
echo '<form method="post" action="options.php">';
|
||||
settings_fields(self::GROUP);
|
||||
// Makes an all-unchecked submission arrive as [''] instead of dropping the field.
|
||||
printf('<input type="hidden" name="%s[]" value="">', esc_attr(Access::OPTION));
|
||||
printf(
|
||||
'<table class="form-table" role="presentation"><tbody><tr><th scope="row">%1$s</th><td>'
|
||||
. '<fieldset><legend class="screen-reader-text"><span>%1$s</span></legend>',
|
||||
esc_html($legend)
|
||||
);
|
||||
foreach (wp_roles()->get_names() as $role => $name) {
|
||||
printf(
|
||||
'<label><input type="checkbox" name="%s[]" value="%s"%s> %s</label><br>',
|
||||
@@ -108,6 +109,10 @@ final class AccessTab implements AdminTab
|
||||
esc_html(translate_user_role((string) $name))
|
||||
);
|
||||
}
|
||||
echo '</fieldset><p class="description">'
|
||||
. esc_html__('Nur Benutzer dieser Rollen können Claude verbinden.', 'wp-lit-mcp') . ' '
|
||||
. esc_html__('Innerhalb der Tools gelten zusätzlich ihre normalen WordPress-Rechte.', 'wp-lit-mcp')
|
||||
. '</p></td></tr></tbody></table>';
|
||||
submit_button();
|
||||
echo '</form>';
|
||||
|
||||
@@ -160,31 +165,42 @@ final class AccessTab implements AdminTab
|
||||
|
||||
private function renderConnections(): void
|
||||
{
|
||||
echo '<h2>' . esc_html__('Aktive Verbindungen', 'wp-lit-mcp') . '</h2>';
|
||||
echo '<h2>' . esc_html__('Verbindungen', 'wp-lit-mcp') . '</h2>';
|
||||
$connections = $this->connections->all();
|
||||
|
||||
printf(
|
||||
'<table class="widefat striped lit-mcp-connections"><thead><tr>'
|
||||
. '<th scope="col" class="column-user">%s</th><th scope="col" class="column-client">%s</th>'
|
||||
. '<th scope="col" class="column-created">%s</th><th scope="col" class="column-last-used">%s</th>'
|
||||
. '<th scope="col" class="column-actions"><span class="screen-reader-text">%s</span></th>'
|
||||
. '</tr></thead><tbody>',
|
||||
esc_html__('Benutzer', 'wp-lit-mcp'),
|
||||
esc_html__('Client', 'wp-lit-mcp'),
|
||||
esc_html__('Verbunden am', 'wp-lit-mcp'),
|
||||
esc_html__('Zuletzt genutzt', 'wp-lit-mcp'),
|
||||
esc_html__('Aktionen', 'wp-lit-mcp')
|
||||
);
|
||||
|
||||
if ($connections === []) {
|
||||
echo '<p>' . esc_html__('Keine aktiven Verbindungen.', 'wp-lit-mcp') . '</p>';
|
||||
return;
|
||||
printf(
|
||||
'<tr class="no-items"><td class="colspanchange" colspan="5">%s %s <a href="%s">%s</a></td></tr>',
|
||||
esc_html__('Noch keine Verbindung.', 'wp-lit-mcp'),
|
||||
esc_html__('So verbindest du Claude:', 'wp-lit-mcp'),
|
||||
esc_url(SettingsPage::url('verbindung')),
|
||||
esc_html__('Tab „Verbindung“', 'wp-lit-mcp')
|
||||
);
|
||||
}
|
||||
|
||||
echo '<table class="widefat striped"><thead><tr>';
|
||||
$headings = [
|
||||
__('Benutzer', 'wp-lit-mcp'),
|
||||
__('Client', 'wp-lit-mcp'),
|
||||
__('Erstellt', 'wp-lit-mcp'),
|
||||
__('Zuletzt genutzt', 'wp-lit-mcp'),
|
||||
'',
|
||||
];
|
||||
foreach ($headings as $heading) {
|
||||
echo '<th>' . esc_html($heading) . '</th>';
|
||||
}
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
foreach ($connections as $connection) {
|
||||
$this->renderConnectionRow($connection);
|
||||
}
|
||||
|
||||
echo '</tbody></table>';
|
||||
|
||||
if ($connections !== []) {
|
||||
echo '<p class="description">' . esc_html__(
|
||||
'„Zuletzt genutzt“ wird wie bei Anwendungspasswörtern höchstens einmal am Tag aktualisiert.',
|
||||
'wp-lit-mcp'
|
||||
) . '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,21 +209,42 @@ final class AccessTab implements AdminTab
|
||||
*/
|
||||
private function renderConnectionRow(array $connection): void
|
||||
{
|
||||
$dateFormat = (string) get_option('date_format', 'd.m.Y');
|
||||
$timeFormat = (string) get_option('time_format', 'H:i');
|
||||
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html($connection['user_login']) . '</td>';
|
||||
printf('<td>%s<br><code>%s</code></td>', esc_html($connection['name']), esc_html($connection['app_id']));
|
||||
echo '<td>' . esc_html((string) wp_date('Y-m-d H:i', $connection['created'])) . '</td>';
|
||||
echo '<td class="column-user">' . esc_html($connection['user_login']) . '</td>';
|
||||
printf(
|
||||
'<td class="column-client"><strong>%s</strong><br><span class="lit-mcp-muted">%s</span></td>',
|
||||
esc_html($connection['name']),
|
||||
esc_html($connection['app_id'])
|
||||
);
|
||||
printf(
|
||||
'<td class="column-created">%s</td>',
|
||||
esc_html((string) wp_date($dateFormat . ' ' . $timeFormat, $connection['created']))
|
||||
);
|
||||
$lastUsed = $connection['last_used'] === null
|
||||
? '–'
|
||||
: (string) wp_date('Y-m-d H:i', $connection['last_used']);
|
||||
echo '<td>' . esc_html($lastUsed) . '</td>';
|
||||
echo '<td>';
|
||||
: (string) wp_date($dateFormat, $connection['last_used']);
|
||||
echo '<td class="column-last-used">' . esc_html($lastUsed) . '</td>';
|
||||
echo '<td class="column-actions">';
|
||||
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
|
||||
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::REVOKE_ACTION));
|
||||
printf('<input type="hidden" name="user_id" value="%s">', esc_attr((string) $connection['user_id']));
|
||||
printf('<input type="hidden" name="uuid" value="%s">', esc_attr($connection['uuid']));
|
||||
wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']);
|
||||
submit_button(__('Widerrufen', 'wp-lit-mcp'), 'delete small', 'submit', false);
|
||||
$confirm = esc_js(sprintf(
|
||||
/* translators: %s: client name */
|
||||
__('Verbindung „%s“ widerrufen? Claude muss sich danach neu anmelden.', 'wp-lit-mcp'),
|
||||
$connection['name']
|
||||
));
|
||||
submit_button(
|
||||
__('Widerrufen', 'wp-lit-mcp'),
|
||||
'delete small',
|
||||
'submit',
|
||||
false,
|
||||
['onclick' => "return window.confirm('" . $confirm . "');"]
|
||||
);
|
||||
echo '</form></td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ class ConnectionStatus
|
||||
$code = (int) wp_remote_retrieve_response_code($response);
|
||||
$challenge = (string) wp_remote_retrieve_header($response, 'www-authenticate');
|
||||
$result = $code === 401 && str_starts_with($challenge, 'Bearer')
|
||||
? ['ok' => true, 'message' => 'Ohne Token: 401 mit WWW-Authenticate – korrekt.']
|
||||
? ['ok' => true, 'message' => 'Erreichbar; verlangt wie erwartet eine Anmeldung (HTTP 401).']
|
||||
: ['ok' => false, 'message' => sprintf('Erwartet 401 mit WWW-Authenticate, erhalten HTTP %d.', $code)];
|
||||
}
|
||||
|
||||
@@ -132,14 +132,19 @@ class ConnectionStatus
|
||||
: __('Website läuft ohne HTTPS – claude.ai verbindet sich nur über HTTPS', 'wp-lit-mcp'),
|
||||
],
|
||||
[
|
||||
'label' => __('Discovery (/.well-known/)', 'wp-lit-mcp'),
|
||||
'label' => __('OAuth-Discovery', 'wp-lit-mcp'),
|
||||
'ok' => $discovery === null ? null : ($discovery['status'] ?? '') === 'good',
|
||||
'message' => $discovery === null
|
||||
? __('noch nicht geprüft', 'wp-lit-mcp')
|
||||
: wp_strip_all_tags((string) ($discovery['label'] ?? '')),
|
||||
'message' => match (true) {
|
||||
$discovery === null => __('noch nicht geprüft', 'wp-lit-mcp'),
|
||||
($discovery['status'] ?? '') === 'good' => __('Discovery-Dokumente erreichbar', 'wp-lit-mcp'),
|
||||
default => __(
|
||||
'Discovery-Dokumente (/.well-known/) nicht erreichbar – Details unter Website-Zustand',
|
||||
'wp-lit-mcp'
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
'label' => __('Endpunkt ohne Token', 'wp-lit-mcp'),
|
||||
'label' => __('MCP-Endpunkt', 'wp-lit-mcp'),
|
||||
'ok' => $endpoint['ok'] ?? null,
|
||||
'message' => $endpoint['message'] ?? __('noch nicht geprüft', 'wp-lit-mcp'),
|
||||
],
|
||||
|
||||
+38
-14
@@ -30,29 +30,53 @@ final class ConnectionTab implements AdminTab
|
||||
|
||||
public function render(): void
|
||||
{
|
||||
echo '<h2>' . esc_html__('MCP-Server', 'wp-lit-mcp') . '</h2>';
|
||||
echo '<h2>' . esc_html__('So verbindest du Claude', 'wp-lit-mcp') . '</h2>';
|
||||
echo '<ol class="lit-mcp-steps"><li>';
|
||||
printf(
|
||||
'<p><label for="lit-mcp-url">%s</label><br>'
|
||||
. '<input id="lit-mcp-url" type="text" class="large-text code" readonly value="%s"></p>',
|
||||
esc_html__('Server-URL für den Connector', 'wp-lit-mcp'),
|
||||
esc_attr(ConnectionStatus::serverUrl())
|
||||
'<label for="lit-mcp-url">%s</label>'
|
||||
. '<span class="lit-mcp-copy-row copy-to-clipboard-container">'
|
||||
. '<input id="lit-mcp-url" type="text" class="large-text code" readonly value="%s">'
|
||||
. '<button type="button" class="button lit-mcp-copy" data-clipboard-target="#lit-mcp-url">%s</button>'
|
||||
. '<span class="success hidden" aria-hidden="true">%s</span></span>',
|
||||
esc_html__('Server-URL kopieren:', 'wp-lit-mcp'),
|
||||
esc_attr(ConnectionStatus::serverUrl()),
|
||||
esc_html__('URL kopieren', 'wp-lit-mcp'),
|
||||
esc_html__('Kopiert!', 'wp-lit-mcp')
|
||||
);
|
||||
echo '<p>' . esc_html__(
|
||||
'In claude.ai unter Einstellungen → Connectors → „Add custom connector“ eintragen;',
|
||||
echo '</li><li>' . esc_html__(
|
||||
'In claude.ai unter Einstellungen → Connectors auf „Add custom connector“ klicken und die URL einfügen.',
|
||||
'wp-lit-mcp'
|
||||
) . ' ' . esc_html__('Die Anmeldung läuft danach über diese Website.', 'wp-lit-mcp') . '</p>';
|
||||
) . '</li><li>';
|
||||
printf(
|
||||
/* translators: %s: link to the "Zugriff" tab */
|
||||
esc_html__('Anmelden und den Zugriff bestätigen. Danach erscheint die Verbindung unter %s.', 'wp-lit-mcp'),
|
||||
sprintf('<a href="%s">%s</a>', esc_url(SettingsPage::url('zugriff')), esc_html__('Zugriff', 'wp-lit-mcp'))
|
||||
);
|
||||
echo '</li></ol>';
|
||||
|
||||
echo '<h2>' . esc_html__('Status', 'wp-lit-mcp') . '</h2><table class="widefat striped"><tbody>';
|
||||
echo '<h2>' . esc_html__('Status', 'wp-lit-mcp') . '</h2><ul class="lit-mcp-status">';
|
||||
foreach ($this->status->checks(true) as $check) {
|
||||
$state = $check['ok'] === null ? '–' : ($check['ok'] ? '✔' : '✖');
|
||||
[$icon, $state] = match ($check['ok']) {
|
||||
true => ['yes-alt', __('In Ordnung:', 'wp-lit-mcp')],
|
||||
false => ['dismiss', __('Problem:', 'wp-lit-mcp')],
|
||||
default => ['warning', __('Hinweis:', 'wp-lit-mcp')],
|
||||
};
|
||||
printf(
|
||||
'<tr><th scope="row">%s</th><td>%s</td><td>%s</td></tr>',
|
||||
esc_html($check['label']),
|
||||
'<li class="lit-mcp-status-%1$s"><span class="dashicons dashicons-%1$s" aria-hidden="true"></span>'
|
||||
. '<span class="screen-reader-text">%2$s</span> <strong>%3$s</strong>'
|
||||
. ' <span class="lit-mcp-status-detail">%4$s</span></li>',
|
||||
esc_attr($icon),
|
||||
esc_html($state),
|
||||
esc_html($check['label']),
|
||||
esc_html($check['message'])
|
||||
);
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
echo '<p>' . esc_html__('Weitere Prüfungen unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp') . '</p>';
|
||||
echo '</ul>';
|
||||
printf(
|
||||
'<p class="description">%s <a href="%s">%s</a>.</p>',
|
||||
esc_html__('Weitere Prüfungen unter', 'wp-lit-mcp'),
|
||||
esc_url(admin_url('site-health.php')),
|
||||
esc_html__('Werkzeuge → Website-Zustand', 'wp-lit-mcp')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Admin;
|
||||
|
||||
use LIT\MCP\Server\ServerRegistrar;
|
||||
use Throwable;
|
||||
use WP_REST_Request;
|
||||
use WPMedia\MCP\OAuth\Auth\JWT;
|
||||
use WPMedia\MCP\OAuth\Auth\SecretManager;
|
||||
|
||||
/**
|
||||
* Records when an OAuth connection was last used, for "Zuletzt genutzt" in the tab "Zugriff".
|
||||
*
|
||||
* mcp-oauth authenticates with a JWT bound to an application password but never records
|
||||
* its usage (WordPress only does that for Basic-Auth application passwords), so last_used
|
||||
* stayed empty. After an authenticated request to our MCP route this calls WordPress' own
|
||||
* WP_Application_Passwords::record_application_password_usage(), which writes at most once
|
||||
* a day per password – the same data and throttle as in the user's profile.
|
||||
*/
|
||||
final class ConnectionUsage
|
||||
{
|
||||
/** @var callable(string): ?array<string, mixed> */
|
||||
private $decode;
|
||||
|
||||
/** @var callable(int, string): mixed */
|
||||
private $recordUsage;
|
||||
|
||||
/**
|
||||
* @param (callable(string): ?array<string, mixed>)|null $decode JWT → claims, null if invalid.
|
||||
* @param (callable(int, string): mixed)|null $recordUsage user id, application password uuid.
|
||||
*/
|
||||
public function __construct(?callable $decode = null, ?callable $recordUsage = null)
|
||||
{
|
||||
$this->decode = $decode ?? static function (string $token): ?array {
|
||||
$claims = JWT::decode($token, SecretManager::get_secret());
|
||||
|
||||
return is_array($claims) ? $claims : null;
|
||||
};
|
||||
$this->recordUsage = $recordUsage ?? static function (int $userId, string $uuid): void {
|
||||
\WP_Application_Passwords::record_application_password_usage($userId, $uuid);
|
||||
};
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
add_filter('rest_request_after_callbacks', [$this, 'record'], 10, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $handler
|
||||
* @return mixed The response, unchanged.
|
||||
*/
|
||||
public function record(mixed $response, array $handler, WP_REST_Request $request): mixed
|
||||
{
|
||||
$route = '/' . ServerRegistrar::NAMESPACE . '/' . ServerRegistrar::ROUTE;
|
||||
if ($request->get_route() !== $route || is_wp_error($response)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$header = $request->get_header('authorization');
|
||||
$userId = get_current_user_id();
|
||||
if (! is_string($header) || stripos($header, 'Bearer ') !== 0 || $userId <= 0) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
$claims = ($this->decode)(substr($header, 7));
|
||||
$uuid = is_array($claims) ? (string) ($claims['app_pass_id'] ?? '') : '';
|
||||
// Only the password of the user this request actually authenticated as.
|
||||
if ($uuid !== '' && (int) ($claims['sub'] ?? 0) === $userId) {
|
||||
($this->recordUsage)($userId, $uuid);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Bookkeeping only – never break the MCP response.
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
+37
-19
@@ -7,7 +7,8 @@ namespace LIT\MCP\Admin;
|
||||
use LIT\MCP\Limits;
|
||||
|
||||
/**
|
||||
* Tab "Limits": upload/download size, log retention, snapshot count (spec §8.4).
|
||||
* Tab "Limits": upload/download size and log retention (spec §8.4). The snapshot count is
|
||||
* kept as a hidden field until the design system feature that uses it exists.
|
||||
* Changes apply immediately – Limits::all() always reads the current option.
|
||||
*/
|
||||
final class LimitsTab implements AdminTab
|
||||
@@ -41,39 +42,56 @@ final class LimitsTab implements AdminTab
|
||||
{
|
||||
$values = (new Limits())->all();
|
||||
$bounds = Limits::bounds();
|
||||
$labels = [
|
||||
'upload_mb' => sprintf(
|
||||
/* translators: 1: maximum value in MB, 2: cap for Base64 transfers in MB */
|
||||
__(
|
||||
'Maximale Upload-/Download-Größe (MB, höchstens %1$d; Base64-Upload/Download höchstens %2$d MB)',
|
||||
'wp-lit-mcp'
|
||||
$fields = [
|
||||
'upload_mb' => [
|
||||
__('Maximale Dateigröße', 'wp-lit-mcp'),
|
||||
__('MB', 'wp-lit-mcp'),
|
||||
sprintf(
|
||||
/* translators: 1: maximum value in MB, 2: cap for Base64 transfers in MB */
|
||||
__('Höchstens %1$d MB; für Base64-Übertragungen höchstens %2$d MB.', 'wp-lit-mcp'),
|
||||
$bounds['upload_mb'][1],
|
||||
Limits::INLINE_MAX_MB
|
||||
),
|
||||
$bounds['upload_mb'][1],
|
||||
Limits::INLINE_MAX_MB
|
||||
),
|
||||
'log_retention_days' => __('Protokoll aufbewahren (Tage)', 'wp-lit-mcp'),
|
||||
'snapshot_count' => __('Anzahl Designsystem-Snapshots (ab Etappe 4)', 'wp-lit-mcp'),
|
||||
],
|
||||
'log_retention_days' => [
|
||||
__('Protokoll aufbewahren', 'wp-lit-mcp'),
|
||||
__('Tage', 'wp-lit-mcp'),
|
||||
__('Ältere Einträge im Protokoll werden automatisch gelöscht.', 'wp-lit-mcp'),
|
||||
],
|
||||
];
|
||||
|
||||
echo '<form method="post" action="options.php">';
|
||||
settings_fields(self::GROUP);
|
||||
echo '<table class="form-table"><tbody>';
|
||||
foreach ($labels as $key => $label) {
|
||||
echo '<table class="form-table" role="presentation"><tbody>';
|
||||
foreach ($fields as $key => [$label, $unit, $description]) {
|
||||
[$min, $max] = $bounds[$key];
|
||||
printf(
|
||||
'<tr><th scope="row"><label for="lit-mcp-%1$s">%2$s</label></th><td>'
|
||||
. '<input id="lit-mcp-%1$s" type="number" min="%3$d" max="%4$d" name="%5$s[%1$s]" value="%6$d">'
|
||||
. '</td></tr>',
|
||||
. '<input id="lit-mcp-%1$s" class="small-text" type="number" min="%3$d" max="%4$d"'
|
||||
. ' name="%5$s[%1$s]" value="%6$d" aria-describedby="lit-mcp-%1$s-description">'
|
||||
. ' <span class="lit-mcp-unit">%7$s</span>'
|
||||
. '<p class="description" id="lit-mcp-%1$s-description">%8$s</p>',
|
||||
esc_attr($key),
|
||||
esc_html($label),
|
||||
esc_attr((string) $min),
|
||||
esc_attr((string) $max),
|
||||
esc_attr(Limits::OPTION),
|
||||
esc_attr((string) $values[$key])
|
||||
esc_attr((string) $values[$key]),
|
||||
esc_html($unit),
|
||||
esc_html($description)
|
||||
);
|
||||
if ($key === 'upload_mb') {
|
||||
$this->maybeRenderUploadSizeNotice($values['upload_mb']);
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
$this->maybeRenderUploadSizeNotice($values['upload_mb']);
|
||||
// Design system snapshots are not available yet: no visible setting, but keep the saved value.
|
||||
printf(
|
||||
'<input type="hidden" name="%s[snapshot_count]" value="%d">',
|
||||
esc_attr(Limits::OPTION),
|
||||
(int) $values['snapshot_count']
|
||||
);
|
||||
submit_button();
|
||||
echo '</form>';
|
||||
}
|
||||
@@ -96,6 +114,6 @@ final class LimitsTab implements AdminTab
|
||||
$uploadMb,
|
||||
$serverMaxMb
|
||||
);
|
||||
printf('<p class="description">%s</p>', esc_html($message));
|
||||
printf('<div class="notice notice-warning inline"><p>%s</p></div>', esc_html($message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,18 @@ final class LogListTable extends \WP_List_Table
|
||||
{
|
||||
private const PER_PAGE = 50;
|
||||
|
||||
/** @var (callable(): void)|null */
|
||||
private $renderFilters;
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
public function __construct(private ChangeLog $log, private array $filters)
|
||||
public function __construct(private ChangeLog $log, private array $filters, ?callable $renderFilters = null)
|
||||
{
|
||||
$this->renderFilters = $renderFilters;
|
||||
parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
|
||||
}
|
||||
|
||||
@@ -32,7 +37,7 @@ final class LogListTable extends \WP_List_Table
|
||||
public function get_columns(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => __('Zeit (UTC)', 'wp-lit-mcp'),
|
||||
'created_at' => __('Zeit', 'wp-lit-mcp'),
|
||||
'user' => __('Benutzer', 'wp-lit-mcp'),
|
||||
'client' => __('Client', 'wp-lit-mcp'),
|
||||
'ability' => __('Tool', 'wp-lit-mcp'),
|
||||
@@ -57,6 +62,22 @@ final class LogListTable extends \WP_List_Table
|
||||
esc_html_e('Keine Einträge.', '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>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -65,12 +86,61 @@ final class LogListTable extends \WP_List_Table
|
||||
*/
|
||||
public function single_row($item): void
|
||||
{
|
||||
$style = ($item['result'] ?? '') === 'partial' ? ' style="background:#fcf3e3;"' : '';
|
||||
printf('<tr%s>', $style); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string.
|
||||
$class = ($item['result'] ?? '') === 'partial' ? ' class="lit-mcp-partial"' : '';
|
||||
printf('<tr%s>', $class); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string.
|
||||
$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.
|
||||
*
|
||||
* @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
|
||||
? strtotime($utc . ' UTC')
|
||||
: false;
|
||||
if ($timestamp === false) {
|
||||
return '–';
|
||||
}
|
||||
|
||||
$format = get_option('date_format') . ' ' . get_option('time_format');
|
||||
|
||||
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', $result],
|
||||
};
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @param string $column_name
|
||||
@@ -144,7 +214,7 @@ final class LogListTable extends \WP_List_Table
|
||||
return sprintf(
|
||||
'<a href="%s">%s</a>',
|
||||
esc_url(admin_url('revision.php?revision=' . $revisionId)),
|
||||
esc_html__('ansehen / wiederherstellen', 'wp-lit-mcp')
|
||||
esc_html__('Revision ansehen', 'wp-lit-mcp')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+51
-28
@@ -94,21 +94,34 @@ final class LogTab implements AdminTab
|
||||
|
||||
$filters = $this->filters();
|
||||
$extra = $this->extraFilters();
|
||||
$days = $this->limits->logRetentionDays();
|
||||
printf(
|
||||
'<p>%s</p>',
|
||||
esc_html(sprintf(
|
||||
/* translators: %d: number of days */
|
||||
__('Einträge werden nach %d Tagen automatisch gelöscht (einstellbar im Tab „Limits“).', 'wp-lit-mcp'),
|
||||
$this->limits->logRetentionDays()
|
||||
))
|
||||
'<p class="description">%s %s</p>',
|
||||
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(
|
||||
'<a href="%s">%s</a>',
|
||||
esc_url(SettingsPage::url('limits')),
|
||||
esc_html(sprintf(
|
||||
/* translators: %d: number of days */
|
||||
_n('%d Tag', '%d Tagen', $days, 'wp-lit-mcp'),
|
||||
$days
|
||||
))
|
||||
)
|
||||
)
|
||||
);
|
||||
printf('<p>%s</p>', esc_html__('Das Protokoll enthält nur Verweise, keine Inhalte.', 'wp-lit-mcp'));
|
||||
|
||||
$this->renderFilterForm($filters, $extra);
|
||||
|
||||
$table = new LogListTable($this->log, $filters + $extra);
|
||||
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->prepare_items();
|
||||
$table->display();
|
||||
echo '</form>';
|
||||
|
||||
$this->renderClearForm();
|
||||
}
|
||||
@@ -117,13 +130,11 @@ final class LogTab implements AdminTab
|
||||
* @param array{ability: string, user_id: int} $filters
|
||||
* @param array{result: string, date_from: string, date_to: string} $extra
|
||||
*/
|
||||
private function renderFilterForm(array $filters, array $extra): void
|
||||
private function renderFilterControls(array $filters, array $extra): void
|
||||
{
|
||||
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()));
|
||||
|
||||
echo '<select name="ability"><option value="">' . esc_html__('Alle Tools', 'wp-lit-mcp') . '</option>';
|
||||
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>',
|
||||
@@ -131,39 +142,51 @@ final class LogTab implements AdminTab
|
||||
selected($filters['ability'], $ability, false)
|
||||
);
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '</select>';
|
||||
|
||||
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'],
|
||||
]);
|
||||
echo ' ';
|
||||
|
||||
echo '<select name="result">';
|
||||
self::filterLabel('result', __('Nach Ergebnis filtern', 'wp-lit-mcp'));
|
||||
echo '<select name="result" id="lit-mcp-filter-result">';
|
||||
printf('<option value="">%s</option>', esc_html__('Alle Ergebnisse', 'wp-lit-mcp'));
|
||||
$labels = ['ok' => __('erfolgreich', 'wp-lit-mcp'), 'partial' => __('teilweise', 'wp-lit-mcp')];
|
||||
foreach (self::RESULTS as $result) {
|
||||
printf(
|
||||
'<option value="%1$s"%2$s>%1$s</option>',
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr($result),
|
||||
selected($extra['result'], $result, false)
|
||||
selected($extra['result'], $result, false),
|
||||
esc_html($labels[$result])
|
||||
);
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '</select>';
|
||||
|
||||
self::filterLabel('date-from', __('Von Datum', 'wp-lit-mcp'));
|
||||
printf(
|
||||
'<label>%s <input type="date" name="date_from" value="%s"></label> ',
|
||||
esc_html__('von', 'wp-lit-mcp'),
|
||||
'<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(
|
||||
'<label>%s <input type="date" name="date_to" value="%s"></label> ',
|
||||
esc_html__('bis', 'wp-lit-mcp'),
|
||||
'<input type="date" name="date_to" id="lit-mcp-filter-date-to" value="%s">',
|
||||
esc_attr($extra['date_to'])
|
||||
);
|
||||
|
||||
submit_button(__('Filtern', 'wp-lit-mcp'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
submit_button(__('Filtern', 'wp-lit-mcp'), '', 'filter_action', false, ['id' => 'lit-mcp-filter-submit']);
|
||||
}
|
||||
|
||||
private static function filterLabel(string $field, string $text): void
|
||||
{
|
||||
printf(
|
||||
'<label class="screen-reader-text" for="lit-mcp-filter-%s">%s</label>',
|
||||
esc_attr($field),
|
||||
esc_html($text)
|
||||
);
|
||||
}
|
||||
|
||||
private function renderClearForm(): void
|
||||
|
||||
+48
-12
@@ -11,15 +11,24 @@ final class SettingsPage
|
||||
{
|
||||
public const SLUG = 'lit-mcp';
|
||||
|
||||
/** Hook suffix WordPress gives this page (add_options_page → "settings_page_<slug>"). */
|
||||
public const SCREEN_HOOK = 'settings_page_' . self::SLUG;
|
||||
|
||||
/** @var array<string, AdminTab> */
|
||||
private array $tabs = [];
|
||||
|
||||
/**
|
||||
* @param list<AdminTab> $tabs The first tab is the default.
|
||||
* @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none).
|
||||
* @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none).
|
||||
* @param string $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS).
|
||||
* @param string $version Asset version for cache busting.
|
||||
*/
|
||||
public function __construct(array $tabs, private string $logoUrl = '')
|
||||
{
|
||||
public function __construct(
|
||||
array $tabs,
|
||||
private string $logoUrl = '',
|
||||
private string $assetsUrl = '',
|
||||
private string $version = ''
|
||||
) {
|
||||
foreach ($tabs as $tab) {
|
||||
$this->tabs[$tab->slug()] = $tab;
|
||||
}
|
||||
@@ -29,6 +38,7 @@ final class SettingsPage
|
||||
{
|
||||
add_action('admin_menu', [$this, 'addMenu']);
|
||||
add_action('admin_init', [$this, 'registerSettings']);
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
|
||||
foreach ($this->tabs as $tab) {
|
||||
$tab->registerActions();
|
||||
}
|
||||
@@ -52,6 +62,29 @@ final class SettingsPage
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Own CSS and a small script, only on this page. The script builds on WordPress' own
|
||||
* clipboard.js and wp.a11y.speak (as the media modal's "Copy URL") and postbox.js.
|
||||
*/
|
||||
public function enqueueAssets(string $hook): void
|
||||
{
|
||||
if ($hook !== self::SCREEN_HOOK || $this->assetsUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$base = rtrim($this->assetsUrl, '/');
|
||||
wp_enqueue_style('lit-mcp-admin', $base . '/css/admin.css', [], $this->version);
|
||||
$deps = ['clipboard', 'wp-a11y'];
|
||||
if ($this->currentTab() === 'tools') {
|
||||
// Collapsible tool groups use WordPress' own postbox toggles (remembered per user).
|
||||
$deps[] = 'postbox';
|
||||
}
|
||||
wp_enqueue_script('lit-mcp-admin', $base . '/js/admin.js', $deps, $this->version, true);
|
||||
wp_localize_script('lit-mcp-admin', 'litMcpAdmin', [
|
||||
'copied' => __('Die URL wurde in die Zwischenablage kopiert.', 'wp-lit-mcp'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function tabSlugs(): array
|
||||
{
|
||||
@@ -81,16 +114,19 @@ final class SettingsPage
|
||||
}
|
||||
|
||||
$current = $this->currentTab();
|
||||
echo '<div class="wrap">';
|
||||
echo '<div class="wrap lit-mcp">';
|
||||
echo '<div class="lit-mcp-header"><h1>' . esc_html__('LIT MCP', 'wp-lit-mcp') . '</h1>';
|
||||
$this->renderLogo();
|
||||
echo '<h1>' . esc_html__('LIT MCP', 'wp-lit-mcp') . '</h1>';
|
||||
// Admin notices of other plugins are moved right after this marker – above the tabs.
|
||||
echo '</div><hr class="wp-header-end">';
|
||||
$this->renderNotice();
|
||||
echo '<nav class="nav-tab-wrapper">';
|
||||
printf('<nav class="nav-tab-wrapper wp-clearfix" aria-label="%s">', esc_attr__('Bereiche', 'wp-lit-mcp'));
|
||||
foreach ($this->tabs as $slug => $tab) {
|
||||
printf(
|
||||
'<a href="%s" class="nav-tab%s">%s</a>',
|
||||
'<a href="%s" class="nav-tab%s"%s>%s</a>',
|
||||
esc_url(self::url($slug)),
|
||||
$slug === $current ? ' nav-tab-active' : '',
|
||||
$slug === $current ? ' aria-current="page"' : '',
|
||||
esc_html($tab->label())
|
||||
);
|
||||
}
|
||||
@@ -106,11 +142,11 @@ final class SettingsPage
|
||||
}
|
||||
|
||||
printf(
|
||||
'<div class="lit-mcp-header"><a href="https://loosen-it.de" target="_blank" rel="noopener">'
|
||||
. '<img src="%s" alt="Loosen-IT Logo" class="lit-mcp-logo"></a></div>'
|
||||
. '<style>.lit-mcp-header{position:relative}.lit-mcp-logo{position:absolute;padding:10px;right:0;'
|
||||
. 'top:0;height:30px;width:auto}@media (max-width:782px){.lit-mcp-logo{height:24px}}</style>',
|
||||
esc_url($this->logoUrl)
|
||||
'<a class="lit-mcp-logo-link" href="https://loosen-it.de" target="_blank" rel="noopener">'
|
||||
. '<img src="%s" alt="Loosen-IT Logo" class="lit-mcp-logo">'
|
||||
. '<span class="screen-reader-text"> %s</span></a>',
|
||||
esc_url($this->logoUrl),
|
||||
esc_html__('(öffnet in einem neuen Tab)', 'wp-lit-mcp')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+198
-69
@@ -61,79 +61,177 @@ final class ToolsTab implements AdminTab
|
||||
|
||||
public function render(): void
|
||||
{
|
||||
echo '<p>'
|
||||
. esc_html__('Direkt: sofort als eigenes Tool sichtbar.', 'wp-lit-mcp') . ' '
|
||||
. esc_html__('Indirekt: nur über die Meta-Tools (discover/execute) erreichbar.', 'wp-lit-mcp') . ' '
|
||||
. esc_html__('Deaktiviert: gar nicht verfügbar.', 'wp-lit-mcp')
|
||||
. '</p>';
|
||||
echo '<p>'
|
||||
. esc_html__(
|
||||
'Die Server-Anweisungen beim Verbinden nennen immer die aktuell direkt sichtbaren Tools.',
|
||||
'wp-lit-mcp'
|
||||
) . ' '
|
||||
. esc_html__(
|
||||
'Änderungen gelten also ab der nächsten Verbindung; in claude.ai ggf. den Connector neu verbinden.',
|
||||
'wp-lit-mcp'
|
||||
)
|
||||
. '</p>';
|
||||
echo '<p>' . esc_html__(
|
||||
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
|
||||
'wp-lit-mcp'
|
||||
) . '</p>';
|
||||
$policy = $this->policy->withGroups($this->registry->groupMap());
|
||||
$groups = $this->registry->grouped();
|
||||
|
||||
$this->renderIntro(isset($groups['divi']));
|
||||
$this->renderCounter(count($policy->directNames($this->registry->abilities())));
|
||||
|
||||
// Lets WordPress' postbox script remember which groups each user closed.
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core nonce markup.
|
||||
echo wp_nonce_field('closedpostboxes', 'closedpostboxesnonce', false, false);
|
||||
|
||||
echo '<form method="post" action="options.php">';
|
||||
settings_fields(self::GROUP);
|
||||
foreach ($this->registry->grouped() as $group => $abilities) {
|
||||
$this->renderGroup($policy, $group, $abilities);
|
||||
submit_button(null, 'primary', 'submit-top');
|
||||
echo '<div class="lit-mcp-groups">';
|
||||
$closed = $this->savedClosedBoxes();
|
||||
foreach ($groups as $group => $abilities) {
|
||||
$this->renderGroup($policy, $group, $abilities, $closed);
|
||||
}
|
||||
echo '</div>';
|
||||
submit_button();
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
private function renderCounter(int $direct): void
|
||||
private function renderIntro(bool $diviAvailable): void
|
||||
{
|
||||
echo '<p><strong>' . esc_html(sprintf(
|
||||
/* translators: %d: number of direct tools */
|
||||
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
|
||||
$direct
|
||||
)) . '</strong></p>';
|
||||
echo '<p class="description">'
|
||||
. esc_html__('Lege fest, welche Tools Claude nutzen kann.', 'wp-lit-mcp') . ' '
|
||||
. esc_html__('Änderungen gelten ab der nächsten Verbindung.', 'wp-lit-mcp') . ' '
|
||||
. esc_html__('In claude.ai den Connector dafür gegebenenfalls neu verbinden.', 'wp-lit-mcp')
|
||||
. '</p>';
|
||||
|
||||
if ($direct > self::DIRECT_WARNING_THRESHOLD) {
|
||||
echo '<p class="description"><strong>' . esc_html__('Achtung:', 'wp-lit-mcp') . '</strong> '
|
||||
. esc_html__(
|
||||
'Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.',
|
||||
'wp-lit-mcp'
|
||||
) . ' '
|
||||
. esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp')
|
||||
. '</p>';
|
||||
echo '<dl class="lit-mcp-legend">';
|
||||
$legend = [
|
||||
__('Direkt', 'wp-lit-mcp') => __('Claude sieht das Tool sofort.', 'wp-lit-mcp'),
|
||||
__('Indirekt', 'wp-lit-mcp') => __('Claude findet es bei Bedarf über die Tool-Suche.', 'wp-lit-mcp'),
|
||||
__('Aus', 'wp-lit-mcp') => __('Nicht verfügbar.', 'wp-lit-mcp'),
|
||||
__('Nur lesen', 'wp-lit-mcp') => __('Schaltet alle schreibenden Tools einer Gruppe aus.', 'wp-lit-mcp'),
|
||||
];
|
||||
foreach ($legend as $term => $text) {
|
||||
printf('<dt>%s</dt><dd>%s</dd>', esc_html($term), esc_html($text));
|
||||
}
|
||||
echo '</dl>';
|
||||
|
||||
if ($diviAvailable) {
|
||||
echo '<p class="description">' . esc_html__(
|
||||
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
|
||||
'wp-lit-mcp'
|
||||
) . '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<Ability> $abilities */
|
||||
private function renderGroup(ExposurePolicy $policy, string $group, array $abilities): void
|
||||
private function renderCounter(int $direct): void
|
||||
{
|
||||
$count = count($abilities);
|
||||
$mixed = false;
|
||||
foreach ($abilities as $ability) {
|
||||
$mixed = $mixed || $policy->hasToolOverride($ability->name());
|
||||
}
|
||||
|
||||
$warn = $direct > self::DIRECT_WARNING_THRESHOLD;
|
||||
$counter = sprintf(
|
||||
/* translators: %d: number of direct tools */
|
||||
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
|
||||
$direct
|
||||
);
|
||||
printf(
|
||||
'<details open class="lit-mcp-tool-group"><summary>%s</summary>',
|
||||
esc_html(sprintf(
|
||||
/* translators: 1: group label, 2: number of tools */
|
||||
_n('%1$s (%2$d Tool)', '%1$s (%2$d Tools)', $count, 'wp-lit-mcp'),
|
||||
$this->groupLabel($group),
|
||||
$count
|
||||
))
|
||||
'<div class="notice notice-%s inline"><p><strong>%s</strong>',
|
||||
$warn ? 'warning' : 'info',
|
||||
esc_html($counter)
|
||||
);
|
||||
|
||||
$groupMode = $policy->groupMode($group);
|
||||
if ($warn) {
|
||||
echo ' – '
|
||||
. esc_html__('Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.', 'wp-lit-mcp')
|
||||
. ' '
|
||||
. esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp');
|
||||
}
|
||||
echo '</p></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed postbox ids the user saved via WordPress' postbox script, or null if never saved.
|
||||
*
|
||||
* @return list<string>|null
|
||||
*/
|
||||
private function savedClosedBoxes(): ?array
|
||||
{
|
||||
$saved = get_user_option('closedpostboxes_' . SettingsPage::SCREEN_HOOK);
|
||||
|
||||
return is_array($saved) ? array_values(array_filter($saved, 'is_string')) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Ability> $abilities
|
||||
* @param list<string>|null $closedBoxes
|
||||
*/
|
||||
private function renderGroup(ExposurePolicy $policy, string $group, array $abilities, ?array $closedBoxes): void
|
||||
{
|
||||
$count = count($abilities);
|
||||
$label = $this->groupLabel($group);
|
||||
$boxId = 'lit-mcp-group-' . self::idPart($group);
|
||||
$modes = [ExposurePolicy::DIRECT => 0, ExposurePolicy::INDIRECT => 0, ExposurePolicy::DISABLED => 0];
|
||||
$mixed = false;
|
||||
foreach ($abilities as $ability) {
|
||||
$modes[$policy->mode($ability)]++;
|
||||
$mixed = $mixed || $policy->hasToolOverride($ability->name());
|
||||
}
|
||||
// Groups start open; one whose tools are all off may start closed. A saved user choice wins.
|
||||
$closed = $closedBoxes === null
|
||||
? $count > 0 && $modes[ExposurePolicy::DISABLED] === $count
|
||||
: in_array($boxId, $closedBoxes, true);
|
||||
|
||||
printf('<div id="%s" class="postbox lit-mcp-group%s">', esc_attr($boxId), $closed ? ' closed' : '');
|
||||
echo '<div class="postbox-header">';
|
||||
printf(
|
||||
'<p><label>%s <select name="%s[%s]">',
|
||||
esc_html__('Ganze Gruppe:', 'wp-lit-mcp'),
|
||||
'<h2 class="hndle">%s <span class="count">(%s)</span> <span class="lit-mcp-group-summary">%s</span></h2>',
|
||||
esc_html($label),
|
||||
esc_html(sprintf(
|
||||
/* translators: %d: number of tools */
|
||||
_n('%d Tool', '%d Tools', $count, 'wp-lit-mcp'),
|
||||
$count
|
||||
)),
|
||||
esc_html($this->summary($modes, $policy->isReadonly($group)))
|
||||
);
|
||||
$this->renderGroupControls($policy, $group, $mixed);
|
||||
printf(
|
||||
'<div class="handle-actions hide-if-no-js"><button type="button" class="handlediv" aria-expanded="%s">'
|
||||
. '<span class="screen-reader-text">%s</span>'
|
||||
. '<span class="toggle-indicator" aria-hidden="true"></span></button></div>',
|
||||
$closed ? 'false' : 'true',
|
||||
esc_html(sprintf(
|
||||
/* translators: %s: group label */
|
||||
__('Gruppe ein-/ausblenden: %s', 'wp-lit-mcp'),
|
||||
$label
|
||||
))
|
||||
);
|
||||
echo '</div><div class="inside">';
|
||||
|
||||
printf(
|
||||
'<table class="widefat striped lit-mcp-tools-table"><thead><tr>'
|
||||
. '<th scope="col" class="column-tool">%s</th><th scope="col" class="column-kind">%s</th>'
|
||||
. '<th scope="col" class="column-mode">%s</th></tr></thead><tbody>',
|
||||
esc_html__('Tool', 'wp-lit-mcp'),
|
||||
esc_html__('Art', 'wp-lit-mcp'),
|
||||
esc_html__('Einstellung', 'wp-lit-mcp')
|
||||
);
|
||||
$groupMode = $policy->groupMode($group);
|
||||
foreach ($abilities as $ability) {
|
||||
$this->renderTool($policy, $ability, $groupMode !== null);
|
||||
}
|
||||
echo '</tbody></table></div></div>';
|
||||
}
|
||||
|
||||
/** @param array<string, int> $modes */
|
||||
private function summary(array $modes, bool $readonly): string
|
||||
{
|
||||
$summary = sprintf(
|
||||
/* translators: 1: number of direct tools, 2: indirect, 3: off */
|
||||
__('%1$d direkt · %2$d indirekt · %3$d aus', 'wp-lit-mcp'),
|
||||
$modes[ExposurePolicy::DIRECT],
|
||||
$modes[ExposurePolicy::INDIRECT],
|
||||
$modes[ExposurePolicy::DISABLED]
|
||||
);
|
||||
|
||||
return $readonly ? __('nur lesen', 'wp-lit-mcp') . ' · ' . $summary : $summary;
|
||||
}
|
||||
|
||||
private function renderGroupControls(ExposurePolicy $policy, string $group, bool $mixed): void
|
||||
{
|
||||
$selectId = 'lit-mcp-group-mode-' . self::idPart($group);
|
||||
$groupMode = $policy->groupMode($group);
|
||||
|
||||
echo '<div class="lit-mcp-group-controls">';
|
||||
printf(
|
||||
'<span class="lit-mcp-control"><label for="%1$s">%2$s</label> <select name="%3$s[%4$s]" id="%1$s">',
|
||||
esc_attr($selectId),
|
||||
esc_html__('Ganze Gruppe', 'wp-lit-mcp'),
|
||||
esc_attr(ExposurePolicy::OPTION),
|
||||
esc_attr(ExposurePolicy::GROUP_PREFIX . $group)
|
||||
);
|
||||
@@ -150,25 +248,20 @@ final class ToolsTab implements AdminTab
|
||||
esc_html($this->modeLabel($mode))
|
||||
);
|
||||
}
|
||||
echo '</select></label> ';
|
||||
echo '</select></span>';
|
||||
printf(
|
||||
'<label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label>',
|
||||
'<span class="lit-mcp-control"><label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label></span>',
|
||||
esc_attr(ExposurePolicy::OPTION),
|
||||
esc_attr(ExposurePolicy::READONLY_PREFIX . $group),
|
||||
checked($policy->isReadonly($group), true, false),
|
||||
esc_html__('nur lesen (schreibende Tools dieser Gruppe aus)', 'wp-lit-mcp')
|
||||
esc_html__('Nur lesen', 'wp-lit-mcp')
|
||||
);
|
||||
if ($mixed) {
|
||||
echo ' <em>' . esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp') . '</em>';
|
||||
echo '<span class="description">'
|
||||
. esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp')
|
||||
. '</span>';
|
||||
}
|
||||
echo '</p>';
|
||||
|
||||
echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Tool', 'wp-lit-mcp') . '</th><th>'
|
||||
. esc_html__('Einstellung', 'wp-lit-mcp') . '</th></tr></thead><tbody>';
|
||||
foreach ($abilities as $ability) {
|
||||
$this->renderTool($policy, $ability, $groupMode !== null);
|
||||
}
|
||||
echo '</tbody></table></details>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
private function renderTool(ExposurePolicy $policy, Ability $ability, bool $groupOverride): void
|
||||
@@ -177,11 +270,23 @@ final class ToolsTab implements AdminTab
|
||||
$own = $policy->toolOverride($name);
|
||||
$locked = $policy->disabledByReadonly($ability);
|
||||
$required = in_array($name, ExposurePolicy::REQUIRED, true);
|
||||
$selectId = 'lit-mcp-tool-' . self::idPart($name);
|
||||
|
||||
printf(
|
||||
'<tr><td><strong>%s</strong><br><code>%s</code></td><td><select name="%s[%s]"%s>',
|
||||
'<tr><td class="column-tool"><strong>%1$s</strong><br><span class="lit-mcp-tool-name">%2$s</span></td>'
|
||||
. '<td class="column-kind">%3$s</td><td class="column-mode">'
|
||||
. '<label class="screen-reader-text" for="%4$s">%5$s</label>'
|
||||
. '<select name="%6$s[%7$s]"%8$s id="%4$s">',
|
||||
esc_html($ability->label()),
|
||||
esc_html($name),
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in kind().
|
||||
$this->kind($ability),
|
||||
esc_attr($selectId),
|
||||
esc_html(sprintf(
|
||||
/* translators: %s: tool label */
|
||||
__('Einstellung für %s', 'wp-lit-mcp'),
|
||||
$ability->label()
|
||||
)),
|
||||
esc_attr(ExposurePolicy::OPTION),
|
||||
esc_attr($name),
|
||||
$locked ? ' disabled' : ''
|
||||
@@ -220,17 +325,41 @@ final class ToolsTab implements AdminTab
|
||||
}
|
||||
|
||||
$note = match (true) {
|
||||
$locked => __('durch ‚nur lesen‘ aus', 'wp-lit-mcp'),
|
||||
$locked => __('Durch „Nur lesen“ aus', 'wp-lit-mcp'),
|
||||
$own === null && $policy->groupModeAdjusted($ability)
|
||||
=> __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'),
|
||||
default => '',
|
||||
};
|
||||
if ($note !== '') {
|
||||
echo ' <em>' . esc_html($note) . '</em>';
|
||||
echo '<p class="description">' . esc_html($note) . '</p>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
|
||||
/** "liest" / "schreibt" / "löscht" from the ability's annotations, with a dashicon. */
|
||||
private function kind(Ability $ability): string
|
||||
{
|
||||
$annotations = $ability->annotations();
|
||||
[$icon, $text] = match (true) {
|
||||
($annotations['destructive'] ?? false) === true => ['trash', __('löscht', 'wp-lit-mcp')],
|
||||
($annotations['readonly'] ?? false) === true => ['visibility', __('liest', 'wp-lit-mcp')],
|
||||
default => ['edit', __('schreibt', 'wp-lit-mcp')],
|
||||
};
|
||||
|
||||
return sprintf(
|
||||
'<span class="lit-mcp-kind" title="%2$s"><span class="dashicons dashicons-%1$s" aria-hidden="true"></span>'
|
||||
. ' <span class="lit-mcp-kind-text">%2$s</span></span>',
|
||||
esc_attr($icon),
|
||||
esc_html($text)
|
||||
);
|
||||
}
|
||||
|
||||
/** Safe fragment for an HTML id from a group id or tool name ("lit/guide" → "lit-guide"). */
|
||||
private static function idPart(string $value): string
|
||||
{
|
||||
return trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($value)), '-');
|
||||
}
|
||||
|
||||
private function groupLabel(string $group): string
|
||||
{
|
||||
return match ($group) {
|
||||
@@ -248,7 +377,7 @@ final class ToolsTab implements AdminTab
|
||||
return match ($mode) {
|
||||
ExposurePolicy::DIRECT => __('direkt', 'wp-lit-mcp'),
|
||||
ExposurePolicy::INDIRECT => __('indirekt', 'wp-lit-mcp'),
|
||||
default => __('deaktiviert', 'wp-lit-mcp'),
|
||||
default => __('aus', 'wp-lit-mcp'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -11,6 +11,7 @@ use LIT\MCP\Admin\AdminNotices;
|
||||
use LIT\MCP\Admin\ConnectionRepository;
|
||||
use LIT\MCP\Admin\ConnectionStatus;
|
||||
use LIT\MCP\Admin\ConnectionTab;
|
||||
use LIT\MCP\Admin\ConnectionUsage;
|
||||
use LIT\MCP\Admin\LimitsTab;
|
||||
use LIT\MCP\Admin\LogTab;
|
||||
use LIT\MCP\Admin\SettingsPage;
|
||||
@@ -87,13 +88,20 @@ final class Plugin
|
||||
|
||||
$status = new ConnectionStatus();
|
||||
(new SiteHealth($status))->register();
|
||||
self::$settingsPage = new SettingsPage([
|
||||
$tabs = [
|
||||
new ConnectionTab($status),
|
||||
new AccessTab($access, new ConnectionRepository()),
|
||||
new ToolsTab($registry, $policy),
|
||||
new LogTab($changeLog, $limits),
|
||||
new LimitsTab(),
|
||||
], plugins_url('assets/img/loosen-it-logo.svg', self::$pluginFile));
|
||||
];
|
||||
self::$settingsPage = new SettingsPage(
|
||||
$tabs,
|
||||
plugins_url('assets/img/loosen-it-logo.svg', self::$pluginFile),
|
||||
plugins_url('assets', self::$pluginFile),
|
||||
self::VERSION
|
||||
);
|
||||
(new ConnectionUsage())->register();
|
||||
if (is_admin()) {
|
||||
self::$settingsPage->register();
|
||||
(new AdminNotices($status))->register();
|
||||
|
||||
Reference in New Issue
Block a user