'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 */ 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 '
'; 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( '
', /* 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 '
'; submit_button(); echo '
'; } private function renderIntro(): void { echo '

' . esc_html__('Lege fest, welche Tools der KI-Assistent nutzen kann.', 'wp-lit-mcp') . '

'; echo '

' . esc_html__('Änderungen gelten ab der nächsten Verbindung.', 'wp-lit-mcp') . ' ' . esc_html__('Stelle die Verbindung im KI-Assistenten danach neu her.', 'wp-lit-mcp') . '

'; echo '
'; $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('
%s
%s
', esc_html($term), esc_html($text)); } echo '
'; } /** Search, "only changed" and open/close for the many tools; needs the admin script, so it hides without JS. */ private function renderToolbar(): void { printf( '
' . '' . '' . '' . '' . '' . '
', 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 '

' . $this->kindLegend() . '

'; } /** 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('

%s', 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 '

'; } /** * Closed postbox ids the user saved via WordPress' postbox script, or null if never saved. * * @return list|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 $abilities * @param list|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( '
', esc_attr($boxId), $closed ? ' closed' : '', esc_attr($group) ); echo '
'; printf( '

%s (%s) %s

', 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( '
', $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 '
'; printf( '' . '' . '', 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 '
%s%s%s
'; } /** @param array $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 '
'; printf( '' . ' '; printf( '', esc_attr(ExposurePolicy::OPTION), esc_attr(ExposurePolicy::READONLY_PREFIX . $group), checked($policy->isReadonly($group), true, false), esc_html__('Nur lesen', 'wp-lit-mcp') ); printf( '', 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 '' . esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp') . ''; } echo '
'; } 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( '%1$s
%2$s' . '%3$s' . '' . ''; if ($locked && $own !== null) { // A disabled select is not submitted – keep the tool's own setting for later. printf( '', 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( '

%s

', $locked ? '' : ' hidden', esc_html__('Durch „Nur lesen“ aus', 'wp-lit-mcp') ); } if ($own === null && $policy->groupModeAdjusted($ability)) { echo '

' . esc_html__('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp') . '

'; } echo ''; } /** 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( '' . ' %2$s', esc_attr($icon), esc_html($text) ); } /** @return array 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( ' %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'), }; } }