Files
WP-LIT-MCP/src/Admin/ToolsTab.php
T
Jan-Niclas LoosenandClaude Sonnet 5.5 96fa58188b feat(admin): Einstellungsseite überarbeitet, auch für das Handy
Verbindung: Probleme vor der Anleitung. Tools: Suche, Filter, Alle ein-/ausklappen, Zurücksetzen je Gruppe.
Divi-Reiter in Klartext mit Statussymbolen. Rollenhinweise, Hilfe-Tab, Reiter als Raster und Karten auf dem Handy,
Kontrast, Tippflächen, einheitliche Du-Anrede. Bestätigungen über data-lit-mcp-confirm statt Inline-onclick.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 16:36:16 +01:00

484 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Integrations\IntegrationRegistry;
defined('ABSPATH') || exit;
/**
* Tab "Tools": direct/indirect/disabled exposure per ability (spec §8.4), grouped by the
* integration the ability comes from. Each group has its own mode (inherited by its tools)
* and a "nur lesen" switch. Only the modes ExposurePolicy::isAllowed() allows for a given
* ability are offered; "Standard"/"wie Gruppe" saves nothing, so a later version's changed
* default takes effect automatically.
*/
final class ToolsTab implements AdminTab
{
public const GROUP = 'lit_mcp_tools';
/** More direct tools than this cost noticeable context in every conversation of the connected assistant. */
public const DIRECT_WARNING_THRESHOLD = 25;
/** Tools that remove something: "delete" (for good) or "trash" (to the trash). */
private const DELETING = [
'lit/media-delete' => 'delete',
'lit/divi-delete' => 'delete',
'lit/post-trash' => 'trash',
];
public function __construct(private IntegrationRegistry $registry, private ExposurePolicy $policy)
{
}
public function slug(): string
{
return 'tools';
}
public function label(): string
{
return __('Tools', 'wp-lit-mcp');
}
public function registerSettings(): void
{
register_setting(self::GROUP, ExposurePolicy::OPTION, [
'type' => 'array',
'sanitize_callback' => [$this, 'sanitize'],
'default' => [],
]);
}
/**
* Sanitize callback: ExposurePolicy::sanitize() restricted to the known group ids.
*
* @return array<string, string|true>
*/
public function sanitize(mixed $input): array
{
return ExposurePolicy::sanitize($input, $this->registry->ids());
}
public function registerActions(): void
{
}
public function render(): void
{
// Bind the policy to the groups of exactly the abilities listed below. The plugin's policy
// carries a lazy map of the same registry (resolved here anyway); a policy built without
// withGroups() would otherwise ignore every group mode and "nur lesen" switch on this page.
$policy = $this->policy->withGroups($this->registry->groupMap());
$groups = $this->registry->grouped();
$labels = $this->registry->labels();
$this->renderIntro();
$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);
submit_button(null, 'primary', 'submit-top');
$this->renderToolbar();
// The admin script mirrors a group's mode onto its rows before saving; it needs the label pattern.
printf(
'<div class="lit-mcp-groups" data-like-group="%s">',
/* translators: %s: mode inherited from the group */
esc_attr__('wie Gruppe (%s)', 'wp-lit-mcp')
);
$closed = $this->savedClosedBoxes();
foreach ($groups as $group => $abilities) {
$this->renderGroup($policy, $group, $labels[$group] ?? $group, $abilities, $closed);
}
echo '</div>';
submit_button();
echo '</form>';
}
private function renderIntro(): void
{
echo '<p class="description">'
. esc_html__('Lege fest, welche Tools der KI-Assistent nutzen kann.', 'wp-lit-mcp') . '</p>';
echo '<div class="notice notice-info inline"><p><strong>'
. esc_html__('Änderungen gelten ab der nächsten Verbindung.', 'wp-lit-mcp') . '</strong> '
. esc_html__('Stelle die Verbindung im KI-Assistenten danach neu her.', 'wp-lit-mcp')
. '</p></div>';
echo '<dl class="lit-mcp-legend">';
$legend = [
__('Direkt', 'wp-lit-mcp') => __('Der Assistent sieht das Tool sofort.', 'wp-lit-mcp'),
__('Indirekt', 'wp-lit-mcp') => __('Der Assistent 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>';
}
/** Search, "only changed" and open/close for the many tools; needs the admin script, so it hides without JS. */
private function renderToolbar(): void
{
printf(
'<div class="lit-mcp-toolbar hide-if-no-js">'
. '<label class="screen-reader-text" for="lit-mcp-tool-search">%1$s</label>'
. '<input type="search" id="lit-mcp-tool-search" class="lit-mcp-tool-search" placeholder="%1$s">'
. '<label><input type="checkbox" class="lit-mcp-only-changed"> %2$s</label>'
. '<button type="button" class="button lit-mcp-collapse-all">%3$s</button>'
. '<button type="button" class="button lit-mcp-expand-all">%4$s</button>'
. '<span class="lit-mcp-filter-status" role="status"></span></div>',
esc_attr__('Tool suchen', 'wp-lit-mcp'),
esc_html__('Nur Tools mit eigener Einstellung', 'wp-lit-mcp'),
esc_html__('Alle einklappen', 'wp-lit-mcp'),
esc_html__('Alle aufklappen', 'wp-lit-mcp')
);
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in kindLegend().
echo '<p class="description lit-mcp-kind-legend">' . $this->kindLegend() . '</p>';
}
/** Only when the direct tools get many: then the count is worth a warning, otherwise it is noise. */
private function renderCounter(int $direct): void
{
if ($direct <= self::DIRECT_WARNING_THRESHOLD) {
return;
}
$counter = sprintf(
/* translators: %d: number of direct tools */
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
$direct
);
printf('<div class="notice notice-warning inline"><p><strong>%s</strong>', esc_html($counter));
echo ' – '
. esc_html__('Viele direkte Tools kosten den Assistenten 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,
string $label,
array $abilities,
?array $closedBoxes
): void {
$count = count($abilities);
$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" data-group="%s">',
esc_attr($boxId),
$closed ? ' closed' : '',
esc_attr($group)
);
echo '<div class="postbox-header">';
printf(
'<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)))
);
// Title, toggle, controls: the same order for the eye and for Tab, so the stylesheet needs no "order".
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
))
);
$this->renderGroupControls($policy, $group, $label, $mixed);
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, string $label, 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]" class="lit-mcp-group-mode" id="%1$s">',
esc_attr($selectId),
esc_html__('Ganze Gruppe', 'wp-lit-mcp'),
esc_attr(ExposurePolicy::OPTION),
esc_attr(ExposurePolicy::GROUP_PREFIX . $group)
);
printf(
'<option value=""%s>%s</option>',
selected($groupMode === null, true, false),
esc_html__('Standard der Tools', 'wp-lit-mcp')
);
foreach (ExposurePolicy::MODES as $mode) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr($mode),
selected($groupMode === $mode, true, false),
esc_html($this->modeLabel($mode))
);
}
echo '</select></span>';
printf(
'<span class="lit-mcp-control"><label>'
. '<input type="checkbox" class="lit-mcp-group-readonly" 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', 'wp-lit-mcp')
);
printf(
'<button type="button" class="button-link lit-mcp-group-reset hide-if-no-js" aria-label="%s">%s</button>',
esc_attr(sprintf(
/* translators: %s: group label */
__('Auf Standard zurücksetzen: %s', 'wp-lit-mcp'),
$label
)),
esc_html__('Auf Standard zurücksetzen', 'wp-lit-mcp')
);
if ($mixed) {
echo '<span class="description">'
. esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp')
. '</span>';
}
echo '</div>';
}
private function renderTool(ExposurePolicy $policy, Ability $ability, bool $groupOverride): void
{
$name = $ability->name();
$own = $policy->toolOverride($name);
$locked = $policy->disabledByReadonly($ability);
$required = in_array($name, ExposurePolicy::REQUIRED, true);
$writes = ($ability->annotations()['readonly'] ?? false) !== true && ! $required;
$selectId = 'lit-mcp-tool-' . self::idPart($name);
/* translators: %s: default mode */
$defaultLabel = sprintf(__('Standard (%s)', 'wp-lit-mcp'), $this->modeLabel($policy->defaultMode($ability)));
printf(
'<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]" class="lit-mcp-tool-mode" data-default-label="%9$s"%10$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' : '',
esc_attr($defaultLabel),
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed attribute names only.
self::scriptFlags($name, $writes, $required)
);
$inherited = $this->modeLabel($policy->inheritedMode($ability));
printf(
'<option value=""%s>%s</option>',
selected($own === null, true, false),
esc_html($groupOverride && ! $required
/* translators: %s: mode inherited from the group */
? sprintf(__('wie Gruppe (%s)', 'wp-lit-mcp'), $inherited)
: $defaultLabel)
);
foreach (ExposurePolicy::MODES as $mode) {
if (! ExposurePolicy::isAllowed($name, $mode)) {
continue;
}
printf(
'<option value="%s"%s>%s</option>',
esc_attr($mode),
selected($own === $mode, true, false),
esc_html($this->modeLabel($mode))
);
}
echo '</select>';
if ($locked && $own !== null) {
// A disabled select is not submitted – keep the tool's own setting for later.
printf(
'<input type="hidden" name="%s[%s]" value="%s">',
esc_attr(ExposurePolicy::OPTION),
esc_attr($name),
esc_attr($own)
);
}
if ($writes) {
// Always in the row so the admin script can show it as soon as "nur lesen" is switched on.
printf(
'<p class="description lit-mcp-readonly-note"%s>%s</p>',
$locked ? '' : ' hidden',
esc_html__('Durch „Nur lesen“ aus', 'wp-lit-mcp')
);
}
if ($own === null && $policy->groupModeAdjusted($ability)) {
echo '<p class="description">'
. esc_html__('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp')
. '</p>';
}
echo '</td></tr>';
}
/** Data attributes telling the admin script how a row reacts to its group's controls. */
private static function scriptFlags(string $name, bool $writes, bool $required): string
{
$flags = '';
if ($writes) {
$flags .= ' data-writes="1"';
}
if ($required) {
$flags .= ' data-required="1"';
}
if (in_array($name, ExposurePolicy::DIRECT_ONLY, true)) {
$flags .= ' data-direct-only="1"';
}
return $flags;
}
/**
* "liest" / "schreibt" / "überschreibt" / "löscht" / "Papierkorb" with a dashicon. The
* "destructive" annotation is a broad hint (deleting, replacing, swapping, ruling S47),
* so only the tools in DELETING say "löscht"; other destructive tools "überschreiben".
*/
private function kind(Ability $ability): string
{
$annotations = $ability->annotations();
$styles = self::kindStyles();
[$icon, $text] = match (true) {
isset(self::DELETING[$ability->name()]) => $styles[self::DELETING[$ability->name()]],
($annotations['destructive'] ?? false) === true => $styles['overwrite'],
($annotations['readonly'] ?? false) === true => $styles['read'],
default => $styles['write'],
};
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)
);
}
/** @return array<string, array{string, string}> Kind → [dashicon, word]. */
private static function kindStyles(): array
{
return [
'read' => ['visibility', __('liest', 'wp-lit-mcp')],
'write' => ['edit', __('schreibt', 'wp-lit-mcp')],
'overwrite' => ['update', __('überschreibt', 'wp-lit-mcp')],
'delete' => ['trash', __('löscht', 'wp-lit-mcp')],
'trash' => ['trash', __('Papierkorb', 'wp-lit-mcp')],
];
}
/** On phones the "Art" column shows icons only; this line says what they mean (touch has no tooltips). */
private function kindLegend(): string
{
$styles = self::kindStyles();
$parts = [];
foreach (['read', 'write', 'overwrite', 'delete'] as $key) {
[$icon, $text] = $styles[$key];
$parts[] = sprintf(
'<span class="dashicons dashicons-%s" aria-hidden="true"></span> %s',
esc_attr($icon),
esc_html($key === 'delete' ? $text . ' / ' . $styles['trash'][1] : $text)
);
}
return implode(' · ', $parts);
}
/** 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 modeLabel(string $mode): string
{
return match ($mode) {
ExposurePolicy::DIRECT => __('direkt', 'wp-lit-mcp'),
ExposurePolicy::INDIRECT => __('indirekt', 'wp-lit-mcp'),
default => __('aus', 'wp-lit-mcp'),
};
}
}