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>
484 lines
20 KiB
PHP
484 lines
20 KiB
PHP
<?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'),
|
||
};
|
||
}
|
||
}
|