| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* The MCP settings screen, and the same section on another user's profile. |
| 5 |
*/ |
| 6 |
|
| 7 |
namespace Extendify\Mcp; |
| 8 |
|
| 9 |
defined('ABSPATH') || die('No direct access.'); |
| 10 |
|
| 11 |
use Extendify\Config; |
| 12 |
use Extendify\Mcp\OAuth\Metadata; |
| 13 |
use Extendify\PartnerData; |
| 14 |
|
| 15 |
/** |
| 16 |
* Core's profile form wraps this section, so a nested form is not an option. |
| 17 |
*/ |
| 18 |
class Profile |
| 19 |
{ |
| 20 |
// phpcs:disable PSR12.Properties.ConstantVisibility.NotFound |
| 21 |
const PAGE = 'extendify-mcp'; |
| 22 |
|
| 23 |
const STEP_MARKUP = ['strong' => [], 'code' => [], 'a' => ['href' => [], 'target' => [], 'rel' => []]]; |
| 24 |
// phpcs:enable PSR12.Properties.ConstantVisibility.NotFound |
| 25 |
|
| 26 |
/** |
| 27 |
* Settings > MCP connects an outside assistant; our own beside it reads as one of them. |
| 28 |
* |
| 29 |
* @return boolean |
| 30 |
*/ |
| 31 |
public static function isOwnScreen() |
| 32 |
{ |
| 33 |
if (!\is_admin() || !function_exists('get_current_screen')) { |
| 34 |
return false; |
| 35 |
} |
| 36 |
|
| 37 |
$screen = \get_current_screen(); |
| 38 |
|
| 39 |
return $screen && $screen->id === 'settings_page_' . self::PAGE; |
| 40 |
} |
| 41 |
|
| 42 |
/** |
| 43 |
* @return void |
| 44 |
*/ |
| 45 |
public static function register() |
| 46 |
{ |
| 47 |
\add_action('admin_menu', [self::class, 'registerPage']); |
| 48 |
\add_action('edit_user_profile', [self::class, 'render']); |
| 49 |
\add_action('admin_init', [self::class, 'handleAction']); |
| 50 |
\add_action('rest_api_init', [self::class, 'registerRoute']); |
| 51 |
} |
| 52 |
|
| 53 |
/** |
| 54 |
* @return void |
| 55 |
*/ |
| 56 |
public static function registerPage() |
| 57 |
{ |
| 58 |
if (!Availability::offered()) { |
| 59 |
return; |
| 60 |
} |
| 61 |
|
| 62 |
$hook = \add_options_page( |
| 63 |
/* translators: MCP is a protocol name; keep it in English. */ |
| 64 |
\__('MCP', 'extendify-local'), |
| 65 |
/* translators: MCP is a protocol name; keep it in English. */ |
| 66 |
\__('MCP', 'extendify-local'), |
| 67 |
'manage_options', |
| 68 |
self::PAGE, |
| 69 |
[self::class, 'renderPage'] |
| 70 |
); |
| 71 |
if ($hook) { |
| 72 |
\add_action('load-' . $hook, [self::class, 'quietNotices']); |
| 73 |
} |
| 74 |
} |
| 75 |
|
| 76 |
/** |
| 77 |
* Update nags and other plugins' notices print over the band and break the layout. |
| 78 |
* Removed just before they print, so one hooked after the page loads goes too. |
| 79 |
* |
| 80 |
* @return void |
| 81 |
*/ |
| 82 |
public static function quietNotices() |
| 83 |
{ |
| 84 |
\add_action('in_admin_header', function () { |
| 85 |
foreach (['admin_notices', 'all_admin_notices', 'user_admin_notices', 'network_admin_notices'] as $hook) { |
| 86 |
\remove_all_actions($hook); |
| 87 |
} |
| 88 |
}, 1000); |
| 89 |
} |
| 90 |
|
| 91 |
/** |
| 92 |
* @return void |
| 93 |
*/ |
| 94 |
public static function registerRoute() |
| 95 |
{ |
| 96 |
\register_rest_route(Config::$slug . '/' . Config::$apiVersion, '/mcp/connections', [ |
| 97 |
'methods' => 'GET', |
| 98 |
'callback' => [self::class, 'connections'], |
| 99 |
'permission_callback' => [self::class, 'mayList'], |
| 100 |
'show_in_index' => false, |
| 101 |
]); |
| 102 |
} |
| 103 |
|
| 104 |
/** |
| 105 |
* Anyone else asking would learn when an administrator authorized an assistant. |
| 106 |
* |
| 107 |
* @return boolean |
| 108 |
*/ |
| 109 |
public static function mayList() |
| 110 |
{ |
| 111 |
return Availability::offered() && \current_user_can('manage_options'); |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* @return \WP_REST_Response |
| 116 |
*/ |
| 117 |
public static function connections() |
| 118 |
{ |
| 119 |
return new \WP_REST_Response(Connections::state(\get_current_user_id())); |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* @return void |
| 124 |
*/ |
| 125 |
public static function renderPage() |
| 126 |
{ |
| 127 |
if (!Availability::offered() || !\current_user_can('manage_options')) { |
| 128 |
return; |
| 129 |
} |
| 130 |
|
| 131 |
echo '<div class="wrap extendify-mcp-wrap">'; |
| 132 |
/* translators: MCP is a protocol name; keep it in English. */ |
| 133 |
self::renderBand(\__('MCP', 'extendify-local'), self::intro(true), 'extendify-mcp'); |
| 134 |
echo '<div class="extendify-mcp-page">'; |
| 135 |
self::renderSection(\get_current_user_id(), false); |
| 136 |
echo '</div></div>'; |
| 137 |
} |
| 138 |
|
| 139 |
/** |
| 140 |
* @param string $title - The page's heading. |
| 141 |
* @param string $intro - A line under the heading, or none. |
| 142 |
* @param string $id - The heading's id, or none. |
| 143 |
* @return void |
| 144 |
*/ |
| 145 |
public static function renderBand($title, $intro = '', $id = '') |
| 146 |
{ |
| 147 |
echo '<div class="extendify-mcp-band">'; |
| 148 |
if (PartnerData::$logo) { |
| 149 |
printf( |
| 150 |
'<img class="extendify-mcp-partner" src="%1$s" alt="%2$s">', |
| 151 |
\esc_url(PartnerData::$logo), |
| 152 |
\esc_attr(PartnerData::$name) |
| 153 |
); |
| 154 |
} |
| 155 |
|
| 156 |
echo '<div><h1' . ($id !== '' ? ' id="' . \esc_attr($id) . '"' : '') . '>' . \esc_html($title) . '</h1>'; |
| 157 |
if ($intro !== '') { |
| 158 |
echo '<p>' . \esc_html($intro) . '</p>'; |
| 159 |
} |
| 160 |
|
| 161 |
echo '</div></div>'; |
| 162 |
} |
| 163 |
|
| 164 |
/** |
| 165 |
* @return void |
| 166 |
*/ |
| 167 |
public static function handleAction() |
| 168 |
{ |
| 169 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 170 |
$action = \sanitize_key(\wp_unslash($_GET['extendify_mcp_action'] ?? '')); |
| 171 |
if (!in_array($action, ['revoke', 'turn_off', 'turn_on'], true)) { |
| 172 |
return; |
| 173 |
} |
| 174 |
|
| 175 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 176 |
$userId = (int) \sanitize_text_field(\wp_unslash($_GET['user_id'] ?? '')) ?: \get_current_user_id(); |
| 177 |
// Without the nonce, a link on any page could revoke or switch off connections as the signed-in administrator. |
| 178 |
\check_admin_referer('extendify_mcp_' . $action . '_' . $userId); |
| 179 |
|
| 180 |
if ($action === 'revoke') { |
| 181 |
self::revokeConnection($userId, \sanitize_key(\wp_unslash($_GET['connection'] ?? ''))); |
| 182 |
} |
| 183 |
|
| 184 |
if ($action === 'turn_off') { |
| 185 |
self::turnSiteOff(); |
| 186 |
} |
| 187 |
|
| 188 |
if ($action === 'turn_on') { |
| 189 |
self::turnSiteOn(); |
| 190 |
} |
| 191 |
|
| 192 |
\wp_safe_redirect(self::screenUrl($userId) . '#extendify-mcp'); |
| 193 |
exit; |
| 194 |
} |
| 195 |
|
| 196 |
/** |
| 197 |
* @return void |
| 198 |
*/ |
| 199 |
public static function turnSiteOff() |
| 200 |
{ |
| 201 |
if (!\current_user_can('manage_options')) { |
| 202 |
return; |
| 203 |
} |
| 204 |
|
| 205 |
Availability::turnOff(\get_current_user_id()); |
| 206 |
} |
| 207 |
|
| 208 |
/** |
| 209 |
* @return void |
| 210 |
*/ |
| 211 |
public static function turnSiteOn() |
| 212 |
{ |
| 213 |
if (!\current_user_can('manage_options')) { |
| 214 |
return; |
| 215 |
} |
| 216 |
|
| 217 |
Availability::turnOn(); |
| 218 |
} |
| 219 |
|
| 220 |
/** |
| 221 |
* @param integer $userId - The user the connection belongs to. |
| 222 |
* @param string $id - The connection's id. |
| 223 |
* @return void |
| 224 |
*/ |
| 225 |
public static function revokeConnection($userId, $id) |
| 226 |
{ |
| 227 |
// Without this, any signed-in user could end another administrator's connections. |
| 228 |
if (!\current_user_can('edit_user', $userId)) { |
| 229 |
return; |
| 230 |
} |
| 231 |
|
| 232 |
Connections::revoke($userId, $id); |
| 233 |
} |
| 234 |
|
| 235 |
/** |
| 236 |
* @param \WP_User $user - The user whose profile is on screen. |
| 237 |
* @return void |
| 238 |
*/ |
| 239 |
public static function render($user) |
| 240 |
{ |
| 241 |
if (!\current_user_can('edit_user', $user->ID) || !Availability::offered()) { |
| 242 |
return; |
| 243 |
} |
| 244 |
|
| 245 |
if (!\user_can($user->ID, 'manage_options')) { |
| 246 |
return; |
| 247 |
} |
| 248 |
|
| 249 |
/* translators: heading over the assistants another user has authorized. */ |
| 250 |
echo '<h2 id="extendify-mcp">' . \esc_html__('AI assistant connections', 'extendify-local') . '</h2>'; |
| 251 |
self::renderSection($user->ID); |
| 252 |
} |
| 253 |
|
| 254 |
/** |
| 255 |
* @param integer $userId - The user whose connections to show. |
| 256 |
* @param boolean $withIntro - Whether to print the intro, which the settings screen puts in its heading. |
| 257 |
* @return void |
| 258 |
*/ |
| 259 |
private static function renderSection($userId, $withIntro = true) |
| 260 |
{ |
| 261 |
self::enqueueStyles(); |
| 262 |
|
| 263 |
if (Availability::turnedOff()) { |
| 264 |
self::renderTurnedOff($userId); |
| 265 |
return; |
| 266 |
} |
| 267 |
|
| 268 |
$isSelf = (int) $userId === \get_current_user_id(); |
| 269 |
|
| 270 |
if ($withIntro) { |
| 271 |
echo '<p class="description">' . \esc_html(self::intro($isSelf)) . '</p>'; |
| 272 |
} |
| 273 |
|
| 274 |
if (!$isSelf) { |
| 275 |
self::renderList($userId); |
| 276 |
return; |
| 277 |
} |
| 278 |
|
| 279 |
\wp_register_script('extendify-mcp-profile', false, [], false, true); |
| 280 |
\wp_enqueue_script('extendify-mcp-profile'); |
| 281 |
\wp_add_inline_script('extendify-mcp-profile', self::script(\get_current_user_id())); |
| 282 |
|
| 283 |
/* translators: heading over the steps that connect the person's AI assistant to this site; not a sign-in. */ |
| 284 |
self::openSection(\__('Connect your AI assistant', 'extendify-local'), 'extendify-mcp'); |
| 285 |
self::renderReachability(); |
| 286 |
self::renderPicker(); |
| 287 |
echo '</div></details>'; |
| 288 |
|
| 289 |
/* translators: heading over the assistants this person has authorized. */ |
| 290 |
self::openSection(\__('Your authorized assistants', 'extendify-local')); |
| 291 |
self::renderList($userId); |
| 292 |
echo '</div></details>'; |
| 293 |
} |
| 294 |
|
| 295 |
/** |
| 296 |
* @param string $title - The heading that collapses the section. |
| 297 |
* @param string $class - An extra class for the section's body. |
| 298 |
* @return void |
| 299 |
*/ |
| 300 |
private static function openSection($title, $class = '') |
| 301 |
{ |
| 302 |
printf( |
| 303 |
'<details class="extendify-mcp-section" open><summary>%1$s</summary>' |
| 304 |
. '<div class="%2$s">', |
| 305 |
\esc_html($title), |
| 306 |
\esc_attr(trim('extendify-mcp-section-body ' . $class)) |
| 307 |
); |
| 308 |
} |
| 309 |
|
| 310 |
/** |
| 311 |
* @return void |
| 312 |
*/ |
| 313 |
private static function enqueueStyles() |
| 314 |
{ |
| 315 |
\wp_register_style('extendify-mcp-profile', false, [], false); |
| 316 |
\wp_enqueue_style('extendify-mcp-profile'); |
| 317 |
\wp_add_inline_style('extendify-mcp-profile', self::styles()); |
| 318 |
} |
| 319 |
|
| 320 |
/** |
| 321 |
* @param boolean $isSelf - Whether this is the screen the viewer connects from. |
| 322 |
* @return string |
| 323 |
*/ |
| 324 |
private static function intro($isSelf) |
| 325 |
{ |
| 326 |
if (!$isSelf) { |
| 327 |
/* translators: shown to an administrator looking at someone else's profile. */ |
| 328 |
return \__( |
| 329 |
'AI assistants this user has authorized. Only they can add new ones, but you can revoke any of them.', |
| 330 |
'extendify-local' |
| 331 |
); |
| 332 |
} |
| 333 |
|
| 334 |
if (!Allowed::writable()) { |
| 335 |
/* translators: the assistant acts with this person's permissions but cannot change anything. */ |
| 336 |
return \__( |
| 337 |
'Connect an AI assistant to your site so it can look things up for you. It cannot change anything.', |
| 338 |
'extendify-local' |
| 339 |
); |
| 340 |
} |
| 341 |
|
| 342 |
/* translators: the assistant acts with this person's own permissions. */ |
| 343 |
return \__('Connect an AI assistant to work on your site.', 'extendify-local'); |
| 344 |
} |
| 345 |
|
| 346 |
/** |
| 347 |
* @return void |
| 348 |
*/ |
| 349 |
private static function renderReachability() |
| 350 |
{ |
| 351 |
$obstacle = Reachability::obstacle(); |
| 352 |
if (!$obstacle) { |
| 353 |
return; |
| 354 |
} |
| 355 |
|
| 356 |
$reasons = [ |
| 357 |
/* translators: shown when the site is reachable only inside its own network. */ |
| 358 |
'local' => \__( |
| 359 |
'This site needs to be publicly accessible for an AI assistant to connect to it.', |
| 360 |
'extendify-local' |
| 361 |
), |
| 362 |
/* translators: HTTPS is a protocol name; keep it in English. */ |
| 363 |
'http' => \__( |
| 364 |
'This site needs to be served over HTTPS for an AI assistant to connect to it.', |
| 365 |
'extendify-local' |
| 366 |
), |
| 367 |
/* translators: shown when the whole site sits behind a password prompt. */ |
| 368 |
'auth' => \__( |
| 369 |
'Remove the site\'s password protection so an AI assistant can connect to it.', |
| 370 |
'extendify-local' |
| 371 |
), |
| 372 |
/* translators: names the likely causes when requests from outside never arrive. */ |
| 373 |
'blocked' => \__( |
| 374 |
'Outside requests can\'t reach this site. A coming-soon page or security plugin may be blocking them.', |
| 375 |
'extendify-local' |
| 376 |
), |
| 377 |
]; |
| 378 |
|
| 379 |
printf('<div class="notice notice-warning inline"><p>%s</p></div>', \esc_html($reasons[$obstacle])); |
| 380 |
} |
| 381 |
|
| 382 |
/** |
| 383 |
* @return void |
| 384 |
*/ |
| 385 |
private static function renderPicker() |
| 386 |
{ |
| 387 |
$clients = Clients::all(); |
| 388 |
$selected = self::selected($clients); |
| 389 |
|
| 390 |
echo '<div class="extendify-mcp-card"><h2>' |
| 391 |
/* translators: heading over a picker of AI assistants. */ |
| 392 |
. \esc_html__('Which assistant are you using?', 'extendify-local') . '</h2>' |
| 393 |
/* translators: under the heading of the AI assistant picker. */ |
| 394 |
. '<p class="description">' . \esc_html__( |
| 395 |
'Pick your AI assistant to see how to connect it.', |
| 396 |
'extendify-local' |
| 397 |
) . '</p>'; |
| 398 |
|
| 399 |
echo '<div class="extendify-mcp-tiles">'; |
| 400 |
foreach ($clients as $client) { |
| 401 |
printf( |
| 402 |
'<a class="extendify-mcp-tile" href="%1$s" data-client="%2$s" aria-pressed="%3$s">' |
| 403 |
. '<span class="extendify-mcp-logo" aria-hidden="true">', |
| 404 |
\esc_url(\add_query_arg('assistant', $client['id'], self::screenUrl(\get_current_user_id()))), |
| 405 |
\esc_attr($client['id']), |
| 406 |
$client['id'] === $selected ? 'true' : 'false' |
| 407 |
); |
| 408 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed SVG markup, no input. |
| 409 |
echo Clients::logo($client); |
| 410 |
echo '</span><span>' . \esc_html($client['name']) . '</span></a>'; |
| 411 |
} |
| 412 |
|
| 413 |
echo '</div>'; |
| 414 |
|
| 415 |
foreach ($clients as $client) { |
| 416 |
self::renderPanel($client, $client['id'] === $selected); |
| 417 |
} |
| 418 |
|
| 419 |
echo '</div>'; |
| 420 |
} |
| 421 |
|
| 422 |
/** |
| 423 |
* @param array $client - One of the Clients entries. |
| 424 |
* @param boolean $open - Whether this is the one the picker is showing. |
| 425 |
* @return void |
| 426 |
*/ |
| 427 |
private static function renderPanel(array $client, $open) |
| 428 |
{ |
| 429 |
printf( |
| 430 |
'<div class="extendify-mcp-panel" id="extendify-mcp-panel-%1$s" data-client="%1$s"%2$s>', |
| 431 |
\esc_attr($client['id']), |
| 432 |
$open ? '' : ' hidden' |
| 433 |
); |
| 434 |
|
| 435 |
if (isset($client['gate'])) { |
| 436 |
self::renderGate($client['gate']); |
| 437 |
} |
| 438 |
|
| 439 |
self::renderSteps($client); |
| 440 |
echo '</div>'; |
| 441 |
} |
| 442 |
|
| 443 |
/** |
| 444 |
* @param array $gate - Its tone and its line. |
| 445 |
* @return void |
| 446 |
*/ |
| 447 |
private static function renderGate(array $gate) |
| 448 |
{ |
| 449 |
printf( |
| 450 |
'<div class="extendify-mcp-gate is-%1$s"><p>%2$s</p></div>', |
| 451 |
\esc_attr($gate['tone']), |
| 452 |
\wp_kses($gate['text'], self::STEP_MARKUP) |
| 453 |
); |
| 454 |
} |
| 455 |
|
| 456 |
/** |
| 457 |
* @param array $links - The client's buttons, each opening the assistant in a new tab. |
| 458 |
* @return void |
| 459 |
*/ |
| 460 |
private static function renderLinks(array $links) |
| 461 |
{ |
| 462 |
echo '<p class="extendify-mcp-actions">'; |
| 463 |
foreach ($links as $link) { |
| 464 |
printf( |
| 465 |
'<a class="button button-compact %1$s" href="%2$s" target="_blank" rel="noopener noreferrer">%3$s' |
| 466 |
. '<span class="screen-reader-text"> %4$s</span></a>', |
| 467 |
$link['primary'] ? 'button-primary' : '', |
| 468 |
\esc_url($link['url']), |
| 469 |
\esc_html($link['label']), |
| 470 |
/* translators: screen-reader text appended to a link that opens a new tab. */ |
| 471 |
\esc_html__('(opens in a new tab)', 'extendify-local') |
| 472 |
); |
| 473 |
} |
| 474 |
|
| 475 |
echo '</p>'; |
| 476 |
} |
| 477 |
|
| 478 |
/** |
| 479 |
* @param array $client - One of the Clients entries. |
| 480 |
* @return void |
| 481 |
*/ |
| 482 |
private static function renderSteps(array $client) |
| 483 |
{ |
| 484 |
echo '<ol class="extendify-mcp-steps">'; |
| 485 |
foreach ($client['steps'] as $step) { |
| 486 |
echo '<li><h4>' . \esc_html($step['title']) . '</h4>'; |
| 487 |
self::renderInstructions($step, $client['id']); |
| 488 |
echo '</li>'; |
| 489 |
} |
| 490 |
|
| 491 |
echo '</ol>'; |
| 492 |
} |
| 493 |
|
| 494 |
/** |
| 495 |
* @param array $block - A step, or a part of one, with optional text, substeps, links, fields and asides. |
| 496 |
* @param string $clientId - The client the fields are for. |
| 497 |
* @return void |
| 498 |
*/ |
| 499 |
private static function renderInstructions(array $block, $clientId) |
| 500 |
{ |
| 501 |
foreach ((array) ($block['text'] ?? []) as $paragraph) { |
| 502 |
echo '<p class="description">' . \wp_kses($paragraph, self::STEP_MARKUP) . '</p>'; |
| 503 |
} |
| 504 |
|
| 505 |
if (isset($block['substeps'])) { |
| 506 |
echo '<ol class="extendify-mcp-substeps">'; |
| 507 |
foreach ($block['substeps'] as $substep) { |
| 508 |
$substep = is_array($substep) ? $substep : ['text' => $substep]; |
| 509 |
echo '<li>' . \wp_kses($substep['text'], self::STEP_MARKUP); |
| 510 |
self::renderFields($clientId, $substep['fields'] ?? []); |
| 511 |
echo '</li>'; |
| 512 |
} |
| 513 |
|
| 514 |
echo '</ol>'; |
| 515 |
} |
| 516 |
|
| 517 |
if (isset($block['links'])) { |
| 518 |
self::renderLinks($block['links']); |
| 519 |
} |
| 520 |
|
| 521 |
self::renderFields($clientId, $block['fields'] ?? []); |
| 522 |
|
| 523 |
foreach ($block['asides'] ?? [] as $aside) { |
| 524 |
printf('<details class="extendify-mcp-manual"><summary>%s</summary>', \esc_html($aside['summary'])); |
| 525 |
self::renderInstructions($aside, $clientId); |
| 526 |
echo '</details>'; |
| 527 |
} |
| 528 |
} |
| 529 |
|
| 530 |
/** |
| 531 |
* @param string $clientId - The client the fields are for. |
| 532 |
* @param array $fields - Labels keyed by the value they carry: name, url or prompt. |
| 533 |
* @return void |
| 534 |
*/ |
| 535 |
private static function renderFields($clientId, array $fields) |
| 536 |
{ |
| 537 |
$values = [ |
| 538 |
'url' => Metadata::resource(), |
| 539 |
'prompt' => Clients::prompt(), |
| 540 |
]; |
| 541 |
|
| 542 |
foreach ($fields as $field => $label) { |
| 543 |
if ($label !== '') { |
| 544 |
echo '<p class="extendify-mcp-field">' . \esc_html($label) . '</p>'; |
| 545 |
} |
| 546 |
|
| 547 |
if ($field === 'name') { |
| 548 |
printf( |
| 549 |
'<p class="description">%s</p>', |
| 550 |
sprintf( |
| 551 |
/* translators: %s: this site's name, offered as an example name for the connection. */ |
| 552 |
\esc_html__('Anything you\'ll recognize, like %s.', 'extendify-local'), |
| 553 |
'<code>' . \esc_html(Clients::siteName()) . '</code>' |
| 554 |
) |
| 555 |
); |
| 556 |
continue; |
| 557 |
} |
| 558 |
|
| 559 |
self::renderCopy('extendify-mcp-' . $field . '-' . $clientId, $values[$field], $field === 'prompt'); |
| 560 |
} |
| 561 |
} |
| 562 |
|
| 563 |
/** |
| 564 |
* @param string $id - The id the copy button reads the text from. |
| 565 |
* @param string $text - The text to copy. |
| 566 |
* @param boolean $multiline - Whether the text is a prompt that wraps rather than a one-line value. |
| 567 |
* @return void |
| 568 |
*/ |
| 569 |
private static function renderCopy($id, $text, $multiline = false) |
| 570 |
{ |
| 571 |
printf( |
| 572 |
$multiline |
| 573 |
? '<div class="extendify-mcp-copyable"><pre id="%1$s">%2$s</pre>' |
| 574 |
: '<p class="extendify-mcp-address"><code id="%1$s">%2$s</code>', |
| 575 |
\esc_attr($id), |
| 576 |
\esc_html($text) |
| 577 |
); |
| 578 |
printf( |
| 579 |
'<button type="button" class="button button-compact" data-copy="%1$s" data-copied="%2$s">%3$s</button>', |
| 580 |
\esc_attr($id), |
| 581 |
/* translators: button label for two seconds after the text was copied. */ |
| 582 |
\esc_attr__('Copied', 'extendify-local'), |
| 583 |
/* translators: button that copies the text beside it. */ |
| 584 |
\esc_html__('Copy', 'extendify-local') |
| 585 |
); |
| 586 |
echo $multiline ? '</div>' : '</p>'; |
| 587 |
} |
| 588 |
|
| 589 |
/** |
| 590 |
* @param array $clients - The clients the picker lists. |
| 591 |
* @return string |
| 592 |
*/ |
| 593 |
private static function selected(array $clients) |
| 594 |
{ |
| 595 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 596 |
$asked = \sanitize_key(\wp_unslash($_GET['assistant'] ?? '')); |
| 597 |
|
| 598 |
return in_array($asked, array_column($clients, 'id'), true) ? $asked : $clients[0]['id']; |
| 599 |
} |
| 600 |
|
| 601 |
/** |
| 602 |
* @param integer $userId - The user whose profile screen this is. |
| 603 |
* @return void |
| 604 |
*/ |
| 605 |
private static function renderTurnedOff($userId) |
| 606 |
{ |
| 607 |
$off = Availability::turnedOff(); |
| 608 |
$who = \get_userdata($off['by']); |
| 609 |
$when = self::when($off['at']); |
| 610 |
|
| 611 |
$notice = $who |
| 612 |
? sprintf( |
| 613 |
/* translators: 1: a person's name. 2: a date and time. No assistant works until this is undone. */ |
| 614 |
\__( |
| 615 |
'%1$s turned connections off for the whole site on %2$s. None work until they\'re turned back on.', |
| 616 |
'extendify-local' |
| 617 |
), |
| 618 |
$who->display_name, |
| 619 |
$when |
| 620 |
) |
| 621 |
: sprintf( |
| 622 |
/* translators: %s: a date and time. No assistant works until this is undone. */ |
| 623 |
\__( |
| 624 |
'Connections were turned off for the whole site on %s. None work until they\'re turned back on.', |
| 625 |
'extendify-local' |
| 626 |
), |
| 627 |
$when |
| 628 |
); |
| 629 |
|
| 630 |
printf('<p class="description">%s</p>', \esc_html($notice)); |
| 631 |
|
| 632 |
if (!\current_user_can('manage_options')) { |
| 633 |
return; |
| 634 |
} |
| 635 |
|
| 636 |
printf( |
| 637 |
'<p><a class="button" href="%1$s">%2$s</a></p>', |
| 638 |
\esc_url(self::actionUrl('turn_on', $userId)), |
| 639 |
/* translators: button that re-enables connections for the whole site. */ |
| 640 |
\esc_html__('Turn connections back on', 'extendify-local') |
| 641 |
); |
| 642 |
} |
| 643 |
|
| 644 |
/** |
| 645 |
* @param integer $userId - The user whose connections to list. |
| 646 |
* @return void |
| 647 |
*/ |
| 648 |
private static function renderList($userId) |
| 649 |
{ |
| 650 |
$connections = Connections::all($userId); |
| 651 |
if (!$connections) { |
| 652 |
echo '<p class="extendify-mcp-empty">' |
| 653 |
/* translators: empty state under that heading. */ |
| 654 |
. \esc_html__('No assistants have been authorized yet.', 'extendify-local') . '</p>'; |
| 655 |
return; |
| 656 |
} |
| 657 |
|
| 658 |
echo '<div class="extendify-mcp-connections">'; |
| 659 |
foreach ($connections as $connection) { |
| 660 |
self::renderConnection($userId, $connection); |
| 661 |
} |
| 662 |
|
| 663 |
echo '</div>'; |
| 664 |
} |
| 665 |
|
| 666 |
/** |
| 667 |
* @param integer $userId - The user the connection belongs to. |
| 668 |
* @param array $connection - The connection being listed. |
| 669 |
* @return void |
| 670 |
*/ |
| 671 |
private static function renderConnection($userId, array $connection) |
| 672 |
{ |
| 673 |
/* translators: fallback when an assistant gave no name. */ |
| 674 |
$name = $connection['label'] ?: \__('Unnamed assistant', 'extendify-local'); |
| 675 |
$stale = !empty($connection['invalidated']); |
| 676 |
$calls = Log::recent($userId, $connection['id']); |
| 677 |
|
| 678 |
printf('<div class="extendify-mcp-connection%s">', $stale ? ' is-stale' : ''); |
| 679 |
printf( |
| 680 |
'<div class="extendify-mcp-who"><span class="extendify-mcp-name">%1$s</span>' |
| 681 |
. '<span class="extendify-mcp-meta">%2$s</span></div>', |
| 682 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built. |
| 683 |
self::assistantName($name, $connection['client'] ?? ''), |
| 684 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built. |
| 685 |
implode(' · ', self::summary($connection, (bool) $calls)) |
| 686 |
); |
| 687 |
|
| 688 |
printf( |
| 689 |
'<div class="extendify-mcp-status"><span class="extendify-mcp-pill%1$s">%2$s</span>' |
| 690 |
. '<a class="button button-compact extendify-mcp-revoke" href="%3$s" aria-label="%4$s">%5$s</a></div>', |
| 691 |
$stale ? ' is-stale' : '', |
| 692 |
\esc_html($stale |
| 693 |
/* translators: badge on a connection that no longer works. */ |
| 694 |
? \__('Needs reconnecting', 'extendify-local') |
| 695 |
: Grants::label($connection['grants'])), |
| 696 |
\esc_url(self::actionUrl('revoke', $userId, $connection['id'])), |
| 697 |
/* translators: %s: the assistant's name. */ |
| 698 |
\esc_attr(sprintf(\__('Revoke "%s"', 'extendify-local'), $name)), |
| 699 |
/* translators: button that ends an assistant's access. */ |
| 700 |
\esc_html__('Revoke', 'extendify-local') |
| 701 |
); |
| 702 |
|
| 703 |
if ($stale) { |
| 704 |
echo '<p class="extendify-mcp-why">' . \esc_html__( |
| 705 |
/* translators: why a connection stopped working, and the fix. |
| 706 |
Revoke is the button beside this message; use its label. */ |
| 707 |
'This site\'s address or security keys changed, so this connection stopped working. Revoke it and connect the assistant again.', // phpcs:ignore Generic.Files.LineLength.TooLong |
| 708 |
'extendify-local' |
| 709 |
) . '</p>'; |
| 710 |
} |
| 711 |
|
| 712 |
self::renderActivity($connection, $calls); |
| 713 |
|
| 714 |
echo '</div>'; |
| 715 |
} |
| 716 |
|
| 717 |
/** |
| 718 |
* @param array $connection - The connection being listed. |
| 719 |
* @param array $calls - Its newest calls, newest first. |
| 720 |
* @return void |
| 721 |
*/ |
| 722 |
private static function renderActivity(array $connection, array $calls) |
| 723 |
{ |
| 724 |
if (!$calls) { |
| 725 |
return; |
| 726 |
} |
| 727 |
|
| 728 |
$used = $connection['lastUsed'] ?: strtotime($calls[0]['created_at'] . ' UTC'); |
| 729 |
printf( |
| 730 |
'<details class="extendify-mcp-aside extendify-mcp-activity"><summary title="%1$s">%2$s</summary><ul>', |
| 731 |
\esc_attr(self::exactly($used)), |
| 732 |
/* translators: %s: how long ago, such as "2 hours". */ |
| 733 |
\esc_html(sprintf(\__('Last used %s ago', 'extendify-local'), \human_time_diff($used))) |
| 734 |
); |
| 735 |
|
| 736 |
foreach ($calls as $call) { |
| 737 |
$at = strtotime($call['created_at'] . ' UTC'); |
| 738 |
printf( |
| 739 |
'<li><span title="%1$s">%2$s</span><code>%3$s</code>%4$s</li>', |
| 740 |
\esc_attr(self::exactly($at)), |
| 741 |
/* translators: %s: how long ago, such as "2 hours". */ |
| 742 |
\esc_html(sprintf(\__('%s ago', 'extendify-local'), \human_time_diff($at))), |
| 743 |
\esc_html($call['tool']), |
| 744 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built. |
| 745 |
self::refusal($call) |
| 746 |
); |
| 747 |
} |
| 748 |
|
| 749 |
echo '</ul></details>'; |
| 750 |
} |
| 751 |
|
| 752 |
/** |
| 753 |
* @param array $call - One row of the log. |
| 754 |
* @return string |
| 755 |
*/ |
| 756 |
private static function refusal(array $call) |
| 757 |
{ |
| 758 |
if ($call['outcome'] === 'ok') { |
| 759 |
return ''; |
| 760 |
} |
| 761 |
|
| 762 |
$why = $call['error'] |
| 763 |
? $call['error'] |
| 764 |
: ($call['outcome'] === 'refused' |
| 765 |
/* translators: a tool call this site would not allow. */ |
| 766 |
? \__('Refused', 'extendify-local') |
| 767 |
/* translators: a tool call that ended in an error. */ |
| 768 |
: \__('Failed', 'extendify-local')); |
| 769 |
|
| 770 |
return '<span class="extendify-mcp-refused">' . \esc_html($why) . '</span>'; |
| 771 |
} |
| 772 |
|
| 773 |
/** |
| 774 |
* @param array $connection - The connection being listed. |
| 775 |
* @param boolean $expandable - Whether a toggle already carries the last-used date. |
| 776 |
* @return array - The metadata line, already escaped, in order. |
| 777 |
*/ |
| 778 |
private static function summary(array $connection, $expandable) |
| 779 |
{ |
| 780 |
$parts = [ |
| 781 |
sprintf( |
| 782 |
/* translators: %s: a date. */ |
| 783 |
\esc_html__('Authorized %s', 'extendify-local'), |
| 784 |
\esc_html(self::when($connection['created'])) |
| 785 |
), |
| 786 |
]; |
| 787 |
|
| 788 |
if ($expandable) { |
| 789 |
return $parts; |
| 790 |
} |
| 791 |
|
| 792 |
$parts[] = $connection['lastUsed'] |
| 793 |
? sprintf( |
| 794 |
'<span title="%1$s">%2$s</span>', |
| 795 |
\esc_attr(self::exactly($connection['lastUsed'])), |
| 796 |
sprintf( |
| 797 |
/* translators: %s: how long ago, such as "2 hours". */ |
| 798 |
\esc_html__('Last used %s ago', 'extendify-local'), |
| 799 |
\esc_html(\human_time_diff($connection['lastUsed'])) |
| 800 |
) |
| 801 |
) |
| 802 |
/* translators: shown when an assistant has never called this site. */ |
| 803 |
: \esc_html__('Never used', 'extendify-local'); |
| 804 |
|
| 805 |
return $parts; |
| 806 |
} |
| 807 |
|
| 808 |
/** |
| 809 |
* Two connections a person has named the same are told apart by where the client is. |
| 810 |
* |
| 811 |
* @param string $name - The label the client gave itself. |
| 812 |
* @param string $client - The client id, which is a URL when the client published one. |
| 813 |
* @return string |
| 814 |
*/ |
| 815 |
private static function assistantName($name, $client) |
| 816 |
{ |
| 817 |
if (\wp_parse_url((string) $client, PHP_URL_SCHEME) !== 'https') { |
| 818 |
return \esc_html($name); |
| 819 |
} |
| 820 |
|
| 821 |
return sprintf( |
| 822 |
'<a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a>', |
| 823 |
\esc_url($client), |
| 824 |
\esc_html($name) |
| 825 |
); |
| 826 |
} |
| 827 |
|
| 828 |
/** |
| 829 |
* @param integer $timestamp - A UTC timestamp. |
| 830 |
* @return string |
| 831 |
*/ |
| 832 |
private static function when($timestamp) |
| 833 |
{ |
| 834 |
return (string) \wp_date(\get_option('date_format'), (int) $timestamp); |
| 835 |
} |
| 836 |
|
| 837 |
/** |
| 838 |
* Two connections used on the same day read as one without the time. |
| 839 |
* |
| 840 |
* @param integer $timestamp - A UTC timestamp. |
| 841 |
* @return string |
| 842 |
*/ |
| 843 |
private static function exactly($timestamp) |
| 844 |
{ |
| 845 |
$format = \get_option('date_format') . ' ' . \get_option('time_format'); |
| 846 |
|
| 847 |
return (string) \wp_date($format, (int) $timestamp); |
| 848 |
} |
| 849 |
|
| 850 |
/** |
| 851 |
* @param string $action - One of the handleAction actions. |
| 852 |
* @param integer $userId - The user the connection belongs to. |
| 853 |
* @param string $connection - The connection's id. |
| 854 |
* @return string |
| 855 |
*/ |
| 856 |
private static function actionUrl($action, $userId, $connection = '') |
| 857 |
{ |
| 858 |
$args = ['extendify_mcp_action' => $action, 'user_id' => (int) $userId]; |
| 859 |
if ($connection) { |
| 860 |
$args['connection'] = $connection; |
| 861 |
} |
| 862 |
|
| 863 |
return \wp_nonce_url( |
| 864 |
\add_query_arg($args, self::screenUrl($userId)), |
| 865 |
'extendify_mcp_' . $action . '_' . (int) $userId |
| 866 |
); |
| 867 |
} |
| 868 |
|
| 869 |
/** |
| 870 |
* @param integer $userId - The user whose profile screen to point at. |
| 871 |
* @return string |
| 872 |
*/ |
| 873 |
private static function screenUrl($userId) |
| 874 |
{ |
| 875 |
return (int) $userId === \get_current_user_id() |
| 876 |
? \admin_url('options-general.php?page=' . self::PAGE) |
| 877 |
: \add_query_arg('user_id', (int) $userId, \admin_url('user-edit.php')); |
| 878 |
} |
| 879 |
|
| 880 |
/** |
| 881 |
* @param string $screen - The admin screen's hook suffix, which WordPress puts on the body. |
| 882 |
* @return string |
| 883 |
*/ |
| 884 |
public static function frameStyles($screen) |
| 885 |
{ |
| 886 |
$page = 'body.' . $screen . ' '; |
| 887 |
|
| 888 |
// Core has no token for the ground its own stage sits on. |
| 889 |
return $page . '{ background: #1e1e1e; } |
| 890 |
' . $page . '#wpcontent { padding-left: 0; } |
| 891 |
' . $page . '#wpbody-content { padding-bottom: 0; } |
| 892 |
' . $page . '#wpfooter { display: none; } |
| 893 |
.extendify-mcp-wrap { margin: 0 8px 8px 0; overflow: hidden; |
| 894 |
background: var(--wpds-color-background-surface-neutral-strong, #fff); |
| 895 |
border-radius: var(--wpds-border-radius-xl, 12px); |
| 896 |
min-height: calc(100vh - var(--wp-admin--admin-bar--height, 32px) - 8px); } |
| 897 |
/* Partner logos are drawn for their banner colour, and a light one vanishes on white. */ |
| 898 |
.extendify-mcp-band { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; |
| 899 |
padding: var(--wpds-dimension-padding-lg, 16px) var(--wpds-dimension-padding-2xl, 24px); |
| 900 |
background: var(--ext-banner-main, transparent); |
| 901 |
color: var(--ext-banner-text, var(--wpds-color-foreground-content-neutral, #1e1e1e)); |
| 902 |
border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral-weak, #f0f0f1); } |
| 903 |
.extendify-mcp-partner { display: block; flex: none; width: auto; min-width: 156px; max-width: min(208px, 100%); |
| 904 |
height: 40px; object-fit: contain; object-position: left center; } |
| 905 |
@media (min-width: 768px) { .extendify-mcp-partner { max-width: min(288px, 100%); } } |
| 906 |
.extendify-mcp-band h1 { margin: 0; padding: 0; font-size: var(--wpds-typography-font-size-lg, 15px); |
| 907 |
font-weight: var(--wpds-typography-font-weight-emphasis, 600); |
| 908 |
line-height: var(--wpds-typography-line-height-sm, 20px); color: inherit; } |
| 909 |
.extendify-mcp-band p { margin: var(--wpds-dimension-gap-xs, 4px) 0 0; max-width: 62ch; |
| 910 |
font-size: var(--wpds-typography-font-size-md, 13px); color: inherit; opacity: .8; } |
| 911 |
.extendify-mcp-band .notice { margin: var(--wpds-dimension-gap-md, 12px) 0 0; }'; |
| 912 |
} |
| 913 |
|
| 914 |
/** |
| 915 |
* @return string |
| 916 |
*/ |
| 917 |
private static function styles() |
| 918 |
{ |
| 919 |
return self::frameStyles('settings_page_' . self::PAGE) . ' |
| 920 |
.extendify-mcp-page { box-sizing: border-box; max-width: 680px; margin: 0 auto; |
| 921 |
padding: var(--wpds-dimension-padding-2xl, 24px); padding-bottom: 200px; } |
| 922 |
.extendify-mcp-section { background: var(--wpds-color-background-surface-neutral-strong, #fff); |
| 923 |
border: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde); |
| 924 |
border-radius: var(--wpds-border-radius-lg, 8px); margin: 0 0 16px; } |
| 925 |
.extendify-mcp-section > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; |
| 926 |
padding: 20px 24px; cursor: pointer; list-style: none; font-size: 14px; font-weight: 600; color: #1e1e1e; } |
| 927 |
.extendify-mcp-section > summary::-webkit-details-marker { display: none; } |
| 928 |
.extendify-mcp-section > summary::after { content: ""; flex: none; width: 7px; height: 7px; margin: -4px 4px 0 0; |
| 929 |
border: solid currentColor; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); } |
| 930 |
.extendify-mcp-section[open] > summary::after { margin-top: 4px; transform: rotate(-135deg); } |
| 931 |
.extendify-mcp-section > summary:focus-visible { outline: 2px solid var(--wp-admin-theme-color, #3858e9); |
| 932 |
outline-offset: -2px; border-radius: var(--wpds-border-radius-lg, 8px); } |
| 933 |
.extendify-mcp-section-body { padding: 0 24px 24px; } |
| 934 |
.extendify-mcp-card { margin: 0 0 20px; } |
| 935 |
.extendify-mcp-card h2 { font-size: var(--wpds-typography-font-size-md, 14px); |
| 936 |
font-weight: var(--wpds-typography-font-weight-emphasis, 600); margin: 0; } |
| 937 |
.extendify-mcp-card > .description { margin: var(--wpds-dimension-gap-xs, 4px) 0 var(--wpds-dimension-gap-md, 12px); } |
| 938 |
.extendify-mcp-address { display: flex; margin: 0; } |
| 939 |
.extendify-mcp-address code { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; |
| 940 |
white-space: nowrap; padding: 6px 10px; border: 1px solid #dcdcde; border-right: 0; |
| 941 |
border-radius: 4px 0 0 4px; background: #f6f7f7; } |
| 942 |
.extendify-mcp-address .button { border-radius: 0 4px 4px 0; } |
| 943 |
.extendify-mcp-tiles { display: flex; flex-wrap: wrap; gap: 4px; |
| 944 |
padding: 4px; background: var(--wpds-color-background-surface-neutral-weak, #f0f0f1); |
| 945 |
border-radius: var(--wpds-border-radius-lg, 8px); } |
| 946 |
.extendify-mcp-tile { display: flex; flex: 1 1 124px; box-sizing: border-box; align-items: center; |
| 947 |
justify-content: center; gap: 8px; padding: 8px 12px; border-radius: 6px; color: #50575e; |
| 948 |
text-decoration: none; font-size: 13px; font-weight: 500; white-space: nowrap; } |
| 949 |
.extendify-mcp-tile:hover, .extendify-mcp-tile:focus { background: rgba(255, 255, 255, .6); color: #1e1e1e; } |
| 950 |
/* Core sets border-radius: 2px on a:focus, which squares the ring off the tile. */ |
| 951 |
.extendify-mcp-tile:focus { border-radius: 6px; } |
| 952 |
.extendify-mcp-tile[aria-pressed="true"] { background: #fff; color: var(--wp-admin-theme-color, #3858e9); |
| 953 |
font-weight: 600; box-shadow: 0 1px 2px rgba(0, 0, 0, .08), 0 0 0 1px rgba(0, 0, 0, .04); } |
| 954 |
.extendify-mcp-logo { display: inline-flex; flex: none; width: 18px; height: 18px; color: #1e1e1e; } |
| 955 |
.extendify-mcp-logo svg { width: 100%; height: 100%; } |
| 956 |
.extendify-mcp-panel { margin-top: 16px; } |
| 957 |
.extendify-mcp-gate { margin: 0; } |
| 958 |
.extendify-mcp-gate p { margin: 0; } |
| 959 |
.extendify-mcp-gate.is-ok { position: relative; padding-left: 22px; color: #1e1e1e; } |
| 960 |
.extendify-mcp-gate.is-ok::before { content: ""; position: absolute; left: 4px; top: 3px; width: 5px; height: 9px; |
| 961 |
border: solid #008a20; border-width: 0 2px 2px 0; transform: rotate(45deg); } |
| 962 |
.extendify-mcp-gate.is-warn { border-radius: 6px; padding: 8px 10px; |
| 963 |
background: var(--wpds-color-background-surface-caution-weak, #fcf5e6); |
| 964 |
border: 1px solid var(--wpds-color-stroke-surface-caution, #e3cf9a); |
| 965 |
color: var(--wpds-color-foreground-content-caution-weak, #7a5600); } |
| 966 |
.extendify-mcp-actions { display: flex; flex-wrap: wrap; gap: var(--wpds-dimension-gap-sm, 8px); |
| 967 |
margin: var(--wpds-dimension-gap-md, 12px) 0 0; } |
| 968 |
.extendify-mcp-copyable { display: flex; gap: var(--wpds-dimension-gap-sm, 8px); align-items: flex-start; |
| 969 |
margin-top: var(--wpds-dimension-padding-md, 10px); } |
| 970 |
.extendify-mcp-copyable pre { flex: 1 1 auto; min-width: 0; margin: 0; overflow-x: auto; |
| 971 |
background: var(--wpds-color-background-surface-neutral-strong, #fff); |
| 972 |
border: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde); |
| 973 |
border-radius: var(--wpds-border-radius-md, 6px); |
| 974 |
padding: var(--wpds-dimension-gap-sm, 8px) var(--wpds-dimension-padding-md, 10px); |
| 975 |
font-family: var(--wpds-typography-font-family-mono, monospace); white-space: pre-wrap; word-break: break-word; } |
| 976 |
.extendify-mcp-aside { border-top: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde); |
| 977 |
margin-top: var(--wpds-dimension-gap-md, 12px); padding-top: var(--wpds-dimension-padding-md, 10px); } |
| 978 |
.extendify-mcp-aside summary, .extendify-mcp-manual summary { cursor: var(--wpds-cursor-control, pointer); |
| 979 |
color: var(--wpds-color-foreground-interactive-brand, #2271b1); } |
| 980 |
.extendify-mcp-steps { list-style: none; margin: 16px 0 0; padding: 0; counter-reset: extendify-mcp-step; } |
| 981 |
.extendify-mcp-steps > li { position: relative; margin: 0; padding: 16px 0 16px 32px; |
| 982 |
border-top: 1px solid #f0f0f1; counter-increment: extendify-mcp-step; } |
| 983 |
.extendify-mcp-steps > li:first-child { padding-top: 0; border-top: 0; } |
| 984 |
.extendify-mcp-steps > li::before { content: counter(extendify-mcp-step); position: absolute; left: 0; top: 16px; |
| 985 |
width: 22px; height: 22px; border-radius: 50%; background: #f0f0f1; color: #1e1e1e; |
| 986 |
font-size: 12px; font-weight: 600; line-height: 22px; text-align: center; } |
| 987 |
.extendify-mcp-steps > li:first-child::before { top: 0; } |
| 988 |
.extendify-mcp-steps h4 { margin: 2px 0 4px; font-size: 14px; font-weight: 600; } |
| 989 |
.extendify-mcp-steps p { margin: 4px 0 0; } |
| 990 |
.extendify-mcp-manual { margin-top: 10px; } |
| 991 |
.extendify-mcp-manual ol, .extendify-mcp-substeps { margin: 8px 0 0 18px; } |
| 992 |
.extendify-mcp-substeps li { margin-bottom: 6px; } |
| 993 |
.extendify-mcp-steps .extendify-mcp-field { margin: 12px 0 4px; font-size: 12px; font-weight: 500; color: #50575e; } |
| 994 |
.extendify-mcp-connections { display: flex; flex-direction: column; gap: var(--wpds-dimension-gap-sm, 8px); } |
| 995 |
.extendify-mcp-connection { display: flex; flex-wrap: wrap; |
| 996 |
gap: var(--wpds-dimension-gap-sm, 8px) var(--wpds-dimension-gap-lg, 14px); align-items: center; |
| 997 |
justify-content: space-between; background: var(--wpds-color-background-surface-neutral-strong, #fff); |
| 998 |
border: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde); |
| 999 |
border-radius: var(--wpds-border-radius-lg, 8px); |
| 1000 |
padding: var(--wpds-dimension-padding-md, 12px) var(--wpds-dimension-padding-lg, 16px); } |
| 1001 |
.extendify-mcp-connection.is-stale { border-color: var(--wpds-color-stroke-surface-caution, #e3cf9a); } |
| 1002 |
.extendify-mcp-who { flex: 1 1 320px; min-width: 0; } |
| 1003 |
.extendify-mcp-name { display: block; font-weight: var(--wpds-typography-font-weight-emphasis, 600); } |
| 1004 |
.extendify-mcp-meta { display: block; color: var(--wpds-color-foreground-content-neutral-weak, #646970); } |
| 1005 |
.extendify-mcp-status { display: flex; align-items: center; gap: var(--wpds-dimension-padding-md, 10px); } |
| 1006 |
.extendify-mcp-pill { background: var(--wpds-color-background-surface-brand, #eef4fa); |
| 1007 |
color: var(--wpds-color-foreground-interactive-brand, #135e96); border-radius: 999px; |
| 1008 |
padding: 3px var(--wpds-dimension-padding-md, 10px); |
| 1009 |
font-size: var(--wpds-typography-font-size-sm, 12px); white-space: nowrap; } |
| 1010 |
.extendify-mcp-pill.is-stale { background: var(--wpds-color-background-surface-caution-weak, #fcf5e6); |
| 1011 |
color: var(--wpds-color-foreground-content-caution-weak, #7a5600); } |
| 1012 |
.extendify-mcp-revoke { color: var(--wpds-color-foreground-interactive-error, #b32d2e); } |
| 1013 |
.extendify-mcp-why { flex: 1 1 100%; margin: 0; |
| 1014 |
color: var(--wpds-color-foreground-content-caution-weak, #7a5600); } |
| 1015 |
.extendify-mcp-activity { flex: 1 1 100%; |
| 1016 |
border-top-color: var(--wpds-color-stroke-surface-neutral-weak, #f0f0f1); |
| 1017 |
margin-top: var(--wpds-dimension-padding-md, 10px); padding-top: var(--wpds-dimension-gap-sm, 8px); } |
| 1018 |
.extendify-mcp-activity ul { margin: var(--wpds-dimension-gap-sm, 8px) 0 0; } |
| 1019 |
.extendify-mcp-activity li { display: flex; flex-wrap: wrap; |
| 1020 |
gap: var(--wpds-dimension-gap-xs, 4px) var(--wpds-dimension-padding-md, 10px); align-items: baseline; |
| 1021 |
padding: var(--wpds-dimension-gap-xs, 4px) 0; |
| 1022 |
border-top: 1px solid var(--wpds-color-stroke-surface-neutral-weak, #f0f0f1); } |
| 1023 |
.extendify-mcp-activity li > span:first-child { flex: 0 0 auto; min-width: 92px; |
| 1024 |
color: var(--wpds-color-foreground-content-neutral-weak, #646970); } |
| 1025 |
.extendify-mcp-refused { min-width: 0; color: var(--wpds-color-foreground-interactive-error, #b32d2e); } |
| 1026 |
.extendify-mcp-empty { color: var(--wpds-color-foreground-content-neutral-weak, #646970); } |
| 1027 |
/* Arabic letters render unjoined in the mono font. */ |
| 1028 |
.rtl .extendify-mcp-steps code, .rtl .extendify-mcp-copyable pre { font-family: inherit; }'; |
| 1029 |
} |
| 1030 |
|
| 1031 |
/** |
| 1032 |
* Without this, a user who approved on the assistant comes back to an empty list. |
| 1033 |
* |
| 1034 |
* @param integer $userId - The user whose connections the screen is showing. |
| 1035 |
* @return string |
| 1036 |
*/ |
| 1037 |
private static function script($userId) |
| 1038 |
{ |
| 1039 |
$state = array_merge(Connections::state($userId), [ |
| 1040 |
'url' => \rest_url(Config::$slug . '/' . Config::$apiVersion . '/mcp/connections'), |
| 1041 |
'nonce' => \wp_create_nonce('wp_rest'), |
| 1042 |
]); |
| 1043 |
$state['watching'] = $state['count'] === 0; |
| 1044 |
|
| 1045 |
return 'window.extendifyMcpWatch = ' . \wp_json_encode($state) . "; |
| 1046 |
var watch = window.extendifyMcpWatch; |
| 1047 |
var deadline = 0; |
| 1048 |
var timer = null; |
| 1049 |
var stopWatching = function () { |
| 1050 |
if (timer) { |
| 1051 |
clearInterval(timer); |
| 1052 |
timer = null; |
| 1053 |
} |
| 1054 |
}; |
| 1055 |
var check = function () { |
| 1056 |
if (Date.now() > deadline) { |
| 1057 |
stopWatching(); |
| 1058 |
return; |
| 1059 |
} |
| 1060 |
if (document.hidden) { |
| 1061 |
return; |
| 1062 |
} |
| 1063 |
fetch(watch.url, { headers: { 'X-WP-Nonce': watch.nonce }, credentials: 'same-origin' }) |
| 1064 |
.then(function (response) { |
| 1065 |
return response.ok ? response.json() : null; |
| 1066 |
}) |
| 1067 |
.then(function (state) { |
| 1068 |
if (!state || (state.count === watch.count && state.newest === watch.newest)) { |
| 1069 |
return; |
| 1070 |
} |
| 1071 |
stopWatching(); |
| 1072 |
window.location.reload(); |
| 1073 |
}) |
| 1074 |
.catch(function () {}); |
| 1075 |
}; |
| 1076 |
var armed = false; |
| 1077 |
var startWatching = function () { |
| 1078 |
armed = true; |
| 1079 |
deadline = Date.now() + 120000; |
| 1080 |
if (!timer) { |
| 1081 |
timer = setInterval(check, 3000); |
| 1082 |
} |
| 1083 |
}; |
| 1084 |
if (watch.watching) { |
| 1085 |
startWatching(); |
| 1086 |
} |
| 1087 |
document.addEventListener('visibilitychange', function () { |
| 1088 |
if (document.hidden || !armed) { |
| 1089 |
return; |
| 1090 |
} |
| 1091 |
startWatching(); |
| 1092 |
check(); |
| 1093 |
}); |
| 1094 |
var copyFrom = function (button) { |
| 1095 |
var source = document.getElementById(button.dataset.copy); |
| 1096 |
if (!source) { |
| 1097 |
return; |
| 1098 |
} |
| 1099 |
var range = document.createRange(); |
| 1100 |
range.selectNodeContents(source); |
| 1101 |
window.getSelection().removeAllRanges(); |
| 1102 |
window.getSelection().addRange(range); |
| 1103 |
if (navigator.clipboard) { |
| 1104 |
navigator.clipboard.writeText(source.textContent); |
| 1105 |
} else { |
| 1106 |
document.execCommand('copy'); |
| 1107 |
} |
| 1108 |
var label = button.textContent; |
| 1109 |
button.textContent = button.dataset.copied; |
| 1110 |
setTimeout(function () { |
| 1111 |
button.textContent = label; |
| 1112 |
}, 2000); |
| 1113 |
startWatching(); |
| 1114 |
}; |
| 1115 |
var show = function (id) { |
| 1116 |
var tiles = document.querySelectorAll('.extendify-mcp-tile'); |
| 1117 |
for (var t = 0; t < tiles.length; t++) { |
| 1118 |
tiles[t].setAttribute('aria-pressed', String(tiles[t].dataset.client === id)); |
| 1119 |
} |
| 1120 |
var panels = document.querySelectorAll('.extendify-mcp-panel'); |
| 1121 |
for (var p = 0; p < panels.length; p++) { |
| 1122 |
panels[p].hidden = panels[p].dataset.client !== id; |
| 1123 |
} |
| 1124 |
}; |
| 1125 |
document.addEventListener('click', function (event) { |
| 1126 |
var copy = event.target.closest('[data-copy]'); |
| 1127 |
if (copy) { |
| 1128 |
copyFrom(copy); |
| 1129 |
return; |
| 1130 |
} |
| 1131 |
var tile = event.target.closest('.extendify-mcp-tile'); |
| 1132 |
if (tile) { |
| 1133 |
event.preventDefault(); |
| 1134 |
show(tile.dataset.client); |
| 1135 |
window.history.replaceState({}, '', tile.href); |
| 1136 |
} |
| 1137 |
});"; |
| 1138 |
} |
| 1139 |
} |
| 1140 |
|