PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13.1 1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 All 212 releases
charitable / includes / admin / tools / class-charitable-tools-ai-mcp.php

class-charitable-tools-ai-mcp.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.13, at includes/admin/tools/class-charitable-tools-ai-mcp.php

1,619 lines 58.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Charitable Tools > AI MCP.
4 *
5 * Surfaces the WordPress Abilities API, which has no UI of its own. Charitable
6 * registers its abilities into core and any MCP client reads them, so without a
7 * screen like this a site owner has no way to discover the feature exists, let
8 * alone what it can do.
9 *
10 * Modelled on the shape of WPForms' Tools > AI MCP tab (WPForms Lite 2.0.1.1,
11 * `src/Admin/Tools/Views/AiMcp.php` + `templates/admin/tools/ai-mcp.php`) so the
12 * two products read as siblings, with Charitable's own branding and content.
13 *
14 * The write-access toggle rendered below is Charitable's equivalent of WPForms'
15 * single "Enable MCP write access". It is saved over AJAX by
16 * charitable-ai-mcp.js and stored as `ai_mcp_write_enabled` in the shared
17 * charitable_settings option - the same key Charitable Pro reads, so the two
18 * editions agree on one value.
19 *
20 * 1.8.13 briefly split this into three toggles by consequence, behind a
21 * Charitable_Abilities_Scopes class and an `ai_mcp_write_scopes` map. Reverted
22 * before release in favour of matching Pro; see
23 * Charitable_Abilities_Registrar::write_enabled() for the reasoning and for
24 * what protects the irreversible operations now.
25 *
26 * DELIBERATE DIFFERENCE: WPForms gates its write abilities TWO ways - a 403 from
27 * `check_write_gate()`, and `'mcp' => [ 'public' => $this->write_enabled() ]`,
28 * which hides the write abilities from MCP discovery altogether. Charitable does
29 * only the first. `Charitable_Abilities_Registrar::gate_write_callback()` wraps
30 * every ability that is not annotated `readonly` and refuses it at execution
31 * time, but the ability stays visible in `/wp-abilities/v1/abilities`.
32 *
33 * That is on purpose: the operation count a site reports must not change with a
34 * setting. The changelog, System Info and this screen all say 24, and they stay
35 * true whichever way the toggle is set. The trade is that an assistant can offer
36 * a write and then be refused, which is why the refusal message names the screen
37 * to come and change.
38 *
39 * Ported from Charitable Pro (pinned commit 4f84275197) for Lite 1.8.13. Lite
40 * registers 24 abilities (3 diagnostic + 12 campaign + 4 donation + 5 report)
41 * rather than Pro's full set, and get_ability_count() below counts them live
42 * against `wp_get_abilities()` rather than asserting a fixed number, so this
43 * screen cannot drift from what the site actually exposes. The capability
44 * cards flagged `requires_pro` in get_cards() are Lite-only additions: they
45 * name the areas GATED_ABILITIES (Charitable_Abilities) leaves unregistered
46 * on this edition, reading their text LIVE from
47 * `Charitable_Diagnostic_Abilities::unavailable_capabilities()` - the same
48 * data `charitable/get-plugin-info` returns for the same boundary - rather
49 * than keeping a second copy of it. See the comment above that block, below.
50 *
51 * @package Charitable/Classes/Charitable_Tools_AI_MCP
52 * @copyright Copyright (c) 2026, WP Charitable LLC
53 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
54 * @since 1.8.13
55 */
56
57 // Exit if accessed directly.
58 if ( ! defined( 'ABSPATH' ) ) {
59 exit;
60 }
61
62 if ( ! class_exists( 'Charitable_Tools_AI_MCP' ) ) :
63
64 /**
65 * Charitable_Tools_AI_MCP
66 *
67 * @since 1.8.13
68 */
69 class Charitable_Tools_AI_MCP {
70
71 /**
72 * The single instance of this class.
73 *
74 * @since 1.8.13
75 *
76 * @var Charitable_Tools_AI_MCP|null
77 */
78 private static $instance = null;
79
80 /**
81 * The WPVibe plugin's wordpress.org slug.
82 *
83 * NOT "wpvibe" - the plugin is published as `vibe-ai`. Verified against
84 * WPForms Lite 2.0.1.1, which points at the same slug.
85 *
86 * @since 1.8.13
87 *
88 * @var string
89 */
90 const BRIDGE_SLUG = 'vibe-ai';
91
92 /**
93 * The WPVibe plugin's basename, for install/active detection.
94 *
95 * @since 1.8.13
96 *
97 * @var string
98 */
99 const BRIDGE_BASENAME = 'vibe-ai/vibe-ai.php';
100
101 /**
102 * The admin screen id of WPVibe's own top-level page.
103 *
104 * WPVibe registers its menu with the slug in BRIDGE_SLUG, so WordPress
105 * names the screen `toplevel_page_<slug>`. Derived from BRIDGE_SLUG rather
106 * than written out again so the two cannot drift, and held as a constant
107 * because both the visit tracker and its test compare against it - a typo
108 * in either would fail silently by simply never matching. WPForms watches
109 * the same screen id for the same purpose.
110 *
111 * @since 1.8.13
112 *
113 * @var string
114 */
115 const BRIDGE_PAGE_SCREEN_ID = 'toplevel_page_' . self::BRIDGE_SLUG;
116
117 /**
118 * User-meta key: this user has opened WPVibe's own admin page at least once.
119 *
120 * Per-user, not per-site: "have YOU been to set this up yet" is the
121 * question the CTA answers, and two admins on one site can be at different
122 * points. Carries the charitable_ prefix deliberately - unlike the
123 * cross-brand attribution options, this flag is Charitable's alone. Mirrors
124 * WPForms' `wpforms_ai_mcp_visited_wpvibe`; the Lite port reuses this exact
125 * key so a site running both editions never asks twice.
126 *
127 * @since 1.8.13
128 *
129 * @var string
130 */
131 const USER_META_VISITED_BRIDGE = 'charitable_ai_mcp_visited_wpvibe';
132
133 /**
134 * The YouTube id of the walkthrough shown in the "Watch video" modal.
135 *
136 * Held as the bare id rather than a URL because three things are built
137 * from it: the watch link the browser follows with JS off, the
138 * youtube-nocookie embed the modal loads, and the JS data attribute.
139 *
140 * @since 1.8.13
141 *
142 * @var string
143 */
144 const VIDEO_ID = 'zcRODJj9pAc';
145
146 /**
147 * Returns and/or create the single instance of this class.
148 *
149 * @since 1.8.13
150 *
151 * @return Charitable_Tools_AI_MCP
152 */
153 public static function get_instance() {
154
155 if ( is_null( self::$instance ) ) {
156 self::$instance = new Charitable_Tools_AI_MCP();
157 }
158
159 return self::$instance;
160 }
161
162 /**
163 * Register the tab's single content field.
164 *
165 * The whole panel is one `content` field rather than a set of settings
166 * fields, because nothing on this screen is a setting - it is a
167 * discovery surface. `content` echoes raw HTML, so everything is escaped
168 * at the point it is built below.
169 *
170 * @since 1.8.13
171 *
172 * @param array $fields Existing fields.
173 * @return array
174 */
175 public function add_fields( $fields = array() ) {
176
177 $fields['ai_mcp_panel'] = array(
178 'type' => 'content',
179 'content' => $this->render(),
180 'priority' => 10,
181 );
182
183 return $fields;
184 }
185
186 /**
187 * Whether the Abilities API is present.
188 *
189 * Charitable registers nothing below WordPress 6.9, so on an older site
190 * this screen has to say so rather than advertise a feature that cannot
191 * work.
192 *
193 * @since 1.8.13
194 *
195 * @return bool
196 */
197 public function has_abilities_api() {
198
199 return function_exists( 'wp_register_ability' ) && function_exists( 'wp_get_abilities' );
200 }
201
202 /**
203 * Count the Charitable abilities currently registered.
204 *
205 * Counted live rather than hardcoded, so the number on screen cannot
206 * drift from the number the site actually exposes - which is exactly the
207 * bug this screen exists to make visible. On Lite this reads 24 (3
208 * diagnostic + 12 campaign + 4 donation + 5 report); the six abilities in
209 * Charitable_Abilities::GATED_ABILITIES are never registered on this
210 * edition, so they are never counted here either.
211 *
212 * @since 1.8.13
213 *
214 * @return int
215 */
216 public function get_ability_count() {
217
218 if ( ! $this->has_abilities_api() ) {
219 return 0;
220 }
221
222 $count = 0;
223
224 foreach ( (array) wp_get_abilities() as $key => $ability ) {
225 $name = is_object( $ability ) && method_exists( $ability, 'get_name' )
226 ? $ability->get_name()
227 : (string) $key;
228
229 if ( 0 === strpos( $name, 'charitable/' ) ) {
230 ++$count;
231 }
232 }
233
234 return $count;
235 }
236
237 /**
238 * Resolve the bridge plugin's state.
239 *
240 * @since 1.8.13
241 *
242 * @return string One of 'not_installed', 'installed_inactive', 'active'.
243 */
244 public function get_bridge_state() {
245
246 if ( ! function_exists( 'is_plugin_active' ) ) {
247 include_once ABSPATH . 'wp-admin/includes/plugin.php';
248 }
249
250 $plugins = get_plugins();
251
252 if ( ! array_key_exists( self::BRIDGE_BASENAME, $plugins ) ) {
253 return 'not_installed';
254 }
255
256 if ( ! is_plugin_active( self::BRIDGE_BASENAME ) ) {
257 return 'installed_inactive';
258 }
259
260 return 'active';
261 }
262
263 /**
264 * Whether this user can install a plugin from the dashboard.
265 *
266 * Some hosts set DISALLOW_FILE_MODS, and the primary button has to say so
267 * instead of leading somewhere that will refuse.
268 *
269 * @since 1.8.13
270 *
271 * @return bool
272 */
273 public function can_install_plugins() {
274
275 return current_user_can( 'install_plugins' ) && ! ( defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS );
276 }
277
278 /**
279 * Record that the current user has opened WPVibe's admin page.
280 *
281 * Hooked on `current_screen` rather than on a link click, so EVERY route to
282 * the page counts - the sidebar menu WPVibe adds, our own setup button, or
283 * a bookmarked URL - matching how WPForms tracks the same visit. The meta
284 * is a write-once latch: once set, the get_user_meta guard below returns
285 * early, so the cost on every later admin screen is a single lookup.
286 *
287 * Public because it is a hook callback; core hands it the WP_Screen. The
288 * is_object()/isset() guard is not defensive theatre - a plugin can fire
289 * `current_screen` with null on an unusual request, and reading `->id` off
290 * a non-object is a fatal on PHP 8 rather than a notice on 7.
291 *
292 * @since 1.8.13
293 *
294 * @param mixed $screen The current screen, as passed by the current_screen action.
295 * @return void
296 */
297 public function maybe_mark_bridge_visited( $screen ) {
298
299 if ( ! is_object( $screen ) || ! isset( $screen->id ) || self::BRIDGE_PAGE_SCREEN_ID !== $screen->id ) {
300 return;
301 }
302
303 $user_id = get_current_user_id();
304
305 if ( ! $user_id || get_user_meta( $user_id, self::USER_META_VISITED_BRIDGE, true ) ) {
306 return;
307 }
308
309 update_user_meta( $user_id, self::USER_META_VISITED_BRIDGE, '1' );
310 }
311
312 /**
313 * Whether the current user has already opened WPVibe's admin page.
314 *
315 * Drives the one CTA copy swap in get_primary_action() below. Returns false
316 * for a logged-out request - get_current_user_id() is 0 there, and reading
317 * meta for user 0 would be a shared, meaningless row.
318 *
319 * @since 1.8.13
320 *
321 * @return bool
322 */
323 public function has_visited_bridge() {
324
325 $user_id = get_current_user_id();
326
327 return $user_id && (bool) get_user_meta( $user_id, self::USER_META_VISITED_BRIDGE, true );
328 }
329
330
331 /**
332 * The AI clients shown beside the hero copy.
333 *
334 * The marks are the vendors' own, shipped to indicate compatibility, and
335 * are the same three assets WPForms ships for the same purpose in
336 * assets/images/admin/tools/ai-mcp/. They are third-party trademarks -
337 * worth a Legal nod before release even though there is internal
338 * precedent.
339 *
340 * Each SVG carries its own colours via var(--fill-0, <default>). Because
341 * they render through <img>, page-level custom properties do NOT reach
342 * inside them, so the defaults are what you get - and one of them is
343 * black. That is why the chips are light rather than dark.
344 *
345 * @since 1.8.13
346 *
347 * @return array
348 */
349 public function get_clients() {
350
351 return array(
352 array(
353 'asset' => 'ai-claude.svg',
354 'label' => __( 'Claude', 'charitable' ),
355 ),
356 array(
357 'asset' => 'ai-chatgpt.svg',
358 'label' => __( 'ChatGPT', 'charitable' ),
359 ),
360 array(
361 'asset' => 'ai-cursor.svg',
362 'label' => __( 'Cursor', 'charitable' ),
363 ),
364 );
365 }
366
367 /**
368 * The capability cards.
369 *
370 * Charitable's abilities fall into five groups (diagnostics, campaign,
371 * donation, donor, report). These are deliberately collapsed to three
372 * "can do" cards: the layout carries three well, and the diagnostics group
373 * is left out because "report my gateway status" is a support tool rather
374 * than a reason to connect an assistant.
375 *
376 * LITE-ONLY ADDITION (fix round 1, Task 10): further cards, flagged
377 * `requires_pro`, name the areas this edition does not register at all -
378 * donor management and donation-form editing
379 * (Charitable_Abilities::GATED_ABILITIES). Their `note` text is read
380 * LIVE from Charitable_Diagnostic_Abilities::unavailable_capabilities()
381 * - the exact array `charitable/get-plugin-info` returns to an
382 * assistant for the same boundary - rather than duplicated here. That
383 * method was widened from private to public specifically so this could
384 * be a single source of truth instead of two copies behind a sync
385 * comment: a comment cannot enforce that two strings stay equal, and a
386 * duplicate very nearly drifted apart during this port. Only `icon` and
387 * `title` (presentation, not carried by the ability's schema) stay
388 * local, keyed on the same `name` the ability uses.
389 *
390 * @since 1.8.13
391 *
392 * @return array
393 */
394 public function get_cards() {
395
396 $cards = array(
397 array(
398 'slug' => 'campaigns',
399 'icon' => 'megaphone',
400 'title' => __( 'Campaigns', 'charitable' ),
401 'bullets' => array(
402 __( 'Build a whole fundraiser from a plain-English brief', 'charitable' ),
403 __( 'Add a question to a donation form, or change its colors', 'charitable' ),
404 __( 'Publish, unpublish, trash and restore a campaign', 'charitable' ),
405 ),
406 ),
407 array(
408 'slug' => 'donations',
409 'icon' => 'heart',
410 'title' => __( 'Donations & Donors', 'charitable' ),
411 'bullets' => array(
412 __( 'Record a gift that arrived by check, cash or transfer', 'charitable' ),
413 __( 'Look up a donation, or everything one donor has given', 'charitable' ),
414 __( 'Filter donations by campaign, status or date range', 'charitable' ),
415 ),
416 ),
417 array(
418 'slug' => 'reports',
419 'icon' => 'chart-bar',
420 'title' => __( 'Reports', 'charitable' ),
421 'bullets' => array(
422 __( 'Ask what you raised over any period, converted to one currency', 'charitable' ),
423 __( 'Get a thank-you list of your biggest supporters', 'charitable' ),
424 __( 'See which campaigns are performing best, day by day', 'charitable' ),
425 ),
426 ),
427 );
428
429 /*
430 * Presentation-only metadata for the gated areas, keyed on the same
431 * `name` unavailable_capabilities() uses. An area with no entry here
432 * is skipped rather than guessed at - see the isset() guard below.
433 */
434 $gated_presentation = array(
435 'donor-management' => array(
436 'icon' => 'admin-users',
437 'title' => __( 'Donor Management', 'charitable' ),
438 ),
439 'donation-form-editing' => array(
440 'icon' => 'edit',
441 'title' => __( 'Donation Form Editing', 'charitable' ),
442 ),
443 /*
444 * Added with the matching entries in
445 * unavailable_capabilities(). These two areas have no gated
446 * ability slug - they are analyses Pro reports on and Lite does
447 * not compute - but they still need an entry here, because
448 * Test_Charitable_Abilities_Pro_Gate asserts the screen renders
449 * a card for EVERY named area carrying the same note string.
450 * The screen and the assistant are meant to describe the
451 * boundary identically, so a named area with no card is a
452 * failure rather than a quiet subset.
453 */
454 'donor-retention' => array(
455 'icon' => 'chart-line',
456 'title' => __( 'Donor Retention', 'charitable' ),
457 ),
458 'recurring-donations' => array(
459 'icon' => 'update',
460 'title' => __( 'Recurring Donations', 'charitable' ),
461 ),
462 );
463
464 $unavailable = class_exists( 'Charitable_Diagnostic_Abilities' )
465 ? ( new Charitable_Diagnostic_Abilities() )->unavailable_capabilities()
466 : array();
467
468 foreach ( $unavailable as $area ) {
469 if ( ! isset( $gated_presentation[ $area['name'] ] ) ) {
470 continue;
471 }
472
473 $cards[] = array(
474 'slug' => $area['name'],
475 'icon' => $gated_presentation[ $area['name'] ]['icon'],
476 'title' => $gated_presentation[ $area['name'] ]['title'],
477 'requires_pro' => true,
478 'note' => $area['note'],
479 );
480 }
481
482 return $cards;
483 }
484
485 /**
486 * The primary button's href, text, state and what clicking it does.
487 *
488 * WPForms' equivalent tab installs and activates its bridge plugin in
489 * place, so this returns an `action` for the tab's JS to act on rather
490 * than only a URL. `install` and `activate` are served over AJAX by
491 * Charitable_Admin_Plugins_Third_Party, which resolves the download from
492 * wordpress.org via plugins_api() and so needs no registry entry for the
493 * slug. `external` is the fallback for a site that forbids dashboard
494 * installs: there is nothing to do in place, so it stays a plain link.
495 *
496 * Both AJAX endpoints check `install_plugins`, so the in-place branches
497 * are gated on the same capability rather than on `activate_plugins` -
498 * otherwise the button would render for someone the endpoint refuses.
499 *
500 * @since 1.8.13
501 *
502 * @return array {
503 * @type string $url Where the button points, or '#' when it acts in place.
504 * @type string $text Button label.
505 * @type string $target Link target.
506 * @type string $note Explanatory line beneath, or ''.
507 * @type string $action One of install|activate|setup|external.
508 * @type string $slug Plugin slug, for the install call.
509 * @type string $basename Plugin basename, for the activate call.
510 * }
511 */
512 public function get_primary_action() {
513
514 $state = $this->get_bridge_state();
515
516 if ( 'active' === $state ) {
517
518 /*
519 * Once the user has actually opened WPVibe's page, "Open WPVibe
520 * Setup" reads as a chore they have already done. Swap it for "Go
521 * to WPVibe" and drop the "to finish" tail of the note, which no
522 * longer describes where they are. Tracked per user by
523 * maybe_mark_bridge_visited(); WPForms makes the same swap for the
524 * same reason.
525 */
526 $visited = $this->has_visited_bridge();
527
528 return array(
529 'url' => admin_url( 'admin.php?page=' . self::BRIDGE_SLUG ),
530 'text' => $visited
531 ? __( 'Go to WPVibe', 'charitable' )
532 : __( 'Open WPVibe Setup', 'charitable' ),
533 'target' => '_self',
534 'note' => $visited
535 ? __( 'WPVibe is active on this site.', 'charitable' )
536 : __( 'WPVibe is active on this site. Connect it to your assistant to finish.', 'charitable' ),
537 'action' => 'setup',
538 'slug' => self::BRIDGE_SLUG,
539 'basename' => self::BRIDGE_BASENAME,
540 );
541 }
542
543 if ( 'installed_inactive' === $state ) {
544
545 if ( ! $this->can_install_plugins() ) {
546 return array(
547 'url' => admin_url( 'plugins.php' ),
548 'text' => __( 'Activate WPVibe', 'charitable' ),
549 'target' => '_self',
550 'note' => __( 'WPVibe is installed but not active.', 'charitable' ),
551 'action' => 'external',
552 'slug' => self::BRIDGE_SLUG,
553 'basename' => self::BRIDGE_BASENAME,
554 );
555 }
556
557 return array(
558 'url' => '#',
559 'text' => __( 'Activate WPVibe', 'charitable' ),
560 'target' => '_self',
561 'note' => __( 'WPVibe is installed but not active.', 'charitable' ),
562 'action' => 'activate',
563 'slug' => self::BRIDGE_SLUG,
564 'basename' => self::BRIDGE_BASENAME,
565 );
566 }
567
568 if ( ! $this->can_install_plugins() ) {
569 return array(
570 'url' => 'https://wordpress.org/plugins/' . self::BRIDGE_SLUG . '/',
571 'text' => __( 'View on WordPress.org', 'charitable' ),
572 'target' => '_blank',
573 'note' => __( 'Your site is configured to disallow plugin installation from the dashboard.', 'charitable' ),
574 'action' => 'external',
575 'slug' => self::BRIDGE_SLUG,
576 'basename' => self::BRIDGE_BASENAME,
577 );
578 }
579
580 return array(
581 'url' => '#',
582 'text' => __( 'Install & Activate WPVibe', 'charitable' ),
583 'target' => '_self',
584 'note' => '',
585 'action' => 'install',
586 'slug' => self::BRIDGE_SLUG,
587 'basename' => self::BRIDGE_BASENAME,
588 );
589 }
590
591 /**
592 * The handful of things that actually stop a client connecting.
593 *
594 * On the tab rather than behind a link to System Info, because that page is
595 * an undifferentiated text dump with no anchor to land on - a link labelled
596 * "diagnose a client that cannot connect" that opens it is a promise the
597 * destination does not keep. System Info remains the right tool for pasting
598 * into a ticket; this is the right thing for answering the question here.
599 *
600 * Every value is measured now, not cached.
601 *
602 * @since 1.8.13
603 *
604 * @return array<int,array{label:string,value:string,ok:bool}>
605 */
606 public function get_diagnostics() {
607
608 $rows = array();
609
610 $rows[] = array(
611 'label' => __( 'WordPress Abilities API', 'charitable' ),
612 'value' => $this->has_abilities_api()
613 ? __( 'Available', 'charitable' )
614 /* translators: %s: the WordPress version running on this site. */
615 : sprintf( __( 'Not available - needs WordPress 6.9 or later, this site runs %s', 'charitable' ), get_bloginfo( 'version' ) ),
616 'ok' => $this->has_abilities_api(),
617 );
618
619 $count = $this->get_ability_count();
620
621 $rows[] = array(
622 'label' => __( 'Charitable operations registered', 'charitable' ),
623 /* translators: %d: number of registered Charitable operations. */
624 'value' => sprintf( _n( '%d operation', '%d operations', $count, 'charitable' ), $count ),
625 'ok' => $count > 0,
626 );
627
628 /*
629 * Pretty permalinks. With plain permalinks /wp-json/ does not resolve and
630 * a client has to use ?rest_route= instead, which most do not - this was
631 * the actual blocker when the testing site was first connected.
632 */
633 $pretty = '' !== (string) get_option( 'permalink_structure' );
634
635 $rows[] = array(
636 'label' => __( 'Pretty permalinks', 'charitable' ),
637 'value' => $pretty
638 ? __( 'On, so /wp-json/ resolves', 'charitable' )
639 : __( 'Off - /wp-json/ will not resolve and most clients cannot connect', 'charitable' ),
640 'ok' => $pretty,
641 );
642
643 /*
644 * Application passwords. Every remote MCP client authenticates with one,
645 * and the feature is unavailable over plain HTTP and is filterable.
646 */
647 $app_passwords = function_exists( 'wp_is_application_passwords_available' ) && wp_is_application_passwords_available();
648
649 $rows[] = array(
650 'label' => __( 'Application passwords', 'charitable' ),
651 'value' => $app_passwords
652 ? __( 'Available', 'charitable' )
653 : __( 'Unavailable - they need HTTPS, and a client cannot sign in without one', 'charitable' ),
654 'ok' => $app_passwords,
655 );
656
657 if ( $this->has_abilities_api() ) {
658
659 $rows[] = array(
660 'label' => __( 'AI write access', 'charitable' ),
661 'value' => Charitable_Abilities_Registrar::write_enabled()
662 ? __( 'On - an assistant can make changes', 'charitable' )
663 : __( 'Off - an assistant can read, but changes are refused', 'charitable' ),
664 /* Off is a valid, deliberate state, so this is never a failure. */
665 'ok' => true,
666 );
667 }
668
669 /*
670 * Whether anything has ever actually called an ability, which is the
671 * question this panel could not answer. Every other row here reports
672 * whether the feature COULD work; all of them can pass while no
673 * client has ever connected.
674 *
675 * From the usage counter rather than the log, because reads are not
676 * logged by default and a session with an assistant is mostly reads -
677 * so the log shows an empty integration on a site where it is working.
678 */
679 if ( $this->has_abilities_api() && class_exists( 'Charitable_Abilities_Usage' ) ) {
680 $usage = Charitable_Abilities_Usage::get();
681
682 $rows[] = array(
683 'label' => __( 'Activity', 'charitable' ),
684 'value' => $usage['total'] > 0
685 ? sprintf(
686 /* translators: 1: total ability calls, 2: date of the most recent call. */
687 __( '%1$d operation(s) run, most recently %2$s', 'charitable' ),
688 (int) $usage['total'],
689 $this->format_usage_date( $usage['last_at'] )
690 )
691 : __( 'Nothing has used this yet - connect a client to get started', 'charitable' ),
692 /* A site that has not connected anything yet is not broken. */
693 'ok' => true,
694 );
695 }
696
697 return $rows;
698 }
699
700 /**
701 * Render a usage date in the site's own date format.
702 *
703 * `Charitable_Abilities_Usage` stores day granularity only - a bare
704 * `Y-m-d` written with `gmdate()`, deliberately, so the counter cannot be
705 * correlated back to an individual administrator's working pattern. There
706 * is therefore no time of day to show, and appending the site's
707 * `time_format` would print a midnight that never happened.
708 *
709 * The stored value is a calendar date rather than an instant, so it is
710 * formatted IN UTC. Handing the bare string to `date_i18n()` instead would
711 * parse it as UTC midnight and then apply the site's offset, moving the
712 * date back a day on every negative-offset site - the whole of the
713 * Americas would see the day before the one that was recorded.
714 *
715 * @since 1.8.13
716 *
717 * @param string $date A `Y-m-d` date, or '' when nothing is recorded.
718 * @return string The formatted date, or the input unchanged if unparseable.
719 */
720 private function format_usage_date( $date ) {
721
722 $date = (string) $date;
723
724 if ( '' === $date ) {
725 return $date;
726 }
727
728 $timestamp = strtotime( $date . ' 00:00:00+00:00' );
729
730 if ( false === $timestamp ) {
731 return $date;
732 }
733
734 return wp_date( (string) get_option( 'date_format' ), $timestamp, new DateTimeZone( 'UTC' ) );
735 }
736
737 /*
738 * ---------------------------------------------------------------------
739 * AI Activity panel.
740 *
741 * A view over the `abilities` log type Charitable_Abilities_Registrar
742 * already writes - log_write(), log_write_refused() and log_denial().
743 * NO new storage is added here: every method below reads through
744 * Charitable_Log's own query class, exactly as Tools > Logs does.
745 * ---------------------------------------------------------------------
746 */
747
748 /**
749 * Ability-slug prefix, used to tell a real ability call apart from the
750 * one other thing that logs under the `abilities` type.
751 *
752 * ajax_toggle_write() above logs the write-gate TOGGLE itself under
753 * `type => 'abilities'` with the plain-English title "AI write access
754 * changed" rather than an ability slug, so it does not carry an outcome
755 * of applied/refused/denied and must not appear as a phantom "applied"
756 * row. Every real ability call is logged with the slug as the record's
757 * title (see Charitable_Abilities_Registrar::log_write() and its
758 * siblings), and every Charitable ability slug is namespaced under this
759 * exact prefix (Charitable_Abilities_Registrar::CATEGORY), so filtering
760 * on it keeps the panel's "ability" column an actual ability, always.
761 *
762 * @since 1.8.13
763 *
764 * @var string
765 */
766 const ABILITY_LOG_PREFIX = 'charitable/';
767
768 /**
769 * The most recent ability activity: what an AI assistant actually did.
770 *
771 * Reads the log records the abstract registrar already writes. NO new
772 * storage, and NO per-row MCP client column - that is a real constraint,
773 * not a missing feature. log_write()'s context carries type, level,
774 * source, object_type, user_id and object ids, but no client identity,
775 * and there is no reliable way to attribute a single REST call to a
776 * specific MCP plugin. The acting WordPress user IS in the record and is
777 * more meaningful anyway: it says who is accountable, not which client
778 * happened to be in front of them. The site-level client (which IS known)
779 * belongs in the panel header instead - see render_activity_panel().
780 *
781 * Twenty-five rows covers a realistic assistant session without turning
782 * this into a second, paginated Tools > Logs; the full history is
783 * already one click away there.
784 *
785 * @since 1.8.13
786 *
787 * @param int $limit Maximum rows to return. Default 25.
788 * @return array<int,array{date:string,ability:string,outcome:string,user:string,message:string}>
789 */
790 public function get_activity_rows( $limit = 25 ) {
791
792 $limit = (int) $limit;
793
794 if ( $limit < 1 ) {
795 $limit = 25;
796 }
797
798 if ( ! class_exists( 'Charitable_Log' ) || ! class_exists( 'Charitable_Log_Query' ) ) {
799 return array();
800 }
801
802 $log = Charitable_Log::get_instance();
803
804 /*
805 * A write from earlier in THIS SAME request can still be sitting in
806 * the in-memory queue - charitable_log() only flushes to the DB on
807 * `shutdown` - so without this, an assistant's own most recent action
808 * would be invisible on the very screen built to show it. This is the
809 * exact seam test-abilities-system-info.php already flushes through
810 * for the same reason; it is not new storage, just reading the
811 * existing queue before the existing table.
812 */
813 $log->save_queued_records();
814
815 if ( ! $log->get_db()->table_exists() ) {
816 return array();
817 }
818
819 /*
820 * One PAGED query per level, not one fixed-size fetch per level.
821 *
822 * Two things share the `abilities` log type and are not outcomes this
823 * panel can show, and both are excluded in SQL by asking for the two
824 * levels explicitly:
825 *
826 * - level `debug`, from Charitable_Abilities_Registrar::log_read(),
827 * on any site that turns on the documented, supported
828 * `charitable_abilities_log_reads` filter - whose own docblock
829 * promises "a row per ability call", and an agentic loop reads an
830 * order of magnitude more often than it writes.
831 * - level `error`, from log_failure() (a rejected argument or a
832 * returned WP_Error).
833 *
834 * Neither can crowd a write out of the result set, because neither is
835 * ever IN it.
836 *
837 * One more row cannot be excluded in SQL: ajax_toggle_write() logs
838 * the write-gate toggle itself under this same type at the same
839 * default `info` level, with a plain-English title instead of an
840 * ability slug, so it is dropped by title below - AFTER being
841 * fetched. An earlier version of this code fetched one fixed-size
842 * window per level and argued that row was too rare to fill it. That
843 * argument was wrong: 50 consecutive toggles with no intervening
844 * ability write push an older applied write clean out of a 50-row
845 * window, which is the reads failure again through a different door.
846 * Worse, it is an argument that has to be re-made from scratch every
847 * time anything new logs at `info` under this type, and nothing
848 * fails if it is not.
849 *
850 * So the loop pages until it hits a real condition instead of
851 * trusting a margin. It stops when this level has yielded $limit
852 * SURVIVING candidates - enough that the merge below, which
853 * truncates to $limit overall, cannot need more from this level - or
854 * when a page comes back short of what was asked for, which means
855 * that level's result set is exhausted. The only way a write goes
856 * unlisted now is that no further rows exist at its level to find.
857 *
858 * Each level must be able to supply $limit on its own, since rows
859 * from either level alone could legitimately fill the whole panel;
860 * worst case is 2 * $limit candidates, truncated to $limit.
861 *
862 * The 20-page cap is a runaway guard, NOT a correctness condition: it
863 * bounds the work on a pathological log table, it does not define
864 * when the answer is complete.
865 *
866 * Charitable_Log_Query's `level` is an exact `level = %s` match with
867 * no support for a list or a negation, and its `limit`/`offset` map
868 * straight onto SQL LIMIT/OFFSET (see
869 * includes/logger/class-charitable-log-query.php) - hence one paged
870 * loop per level rather than one query for both.
871 */
872 $page_size = max( $limit * 2, 50 );
873 $max_pages = 20;
874 $candidates = array();
875
876 foreach ( array( 'info', 'warning' ) as $level ) {
877
878 $offset = 0;
879 $kept = 0;
880
881 for ( $page = 0; $page < $max_pages; $page++ ) {
882
883 $result = $log->get_query()->get(
884 array(
885 'type' => 'abilities',
886 'level' => $level,
887 'limit' => $page_size,
888 'offset' => $offset,
889 'orderby' => 'id',
890 'order' => 'DESC',
891 )
892 );
893
894 $fetched = count( $result['records'] );
895
896 foreach ( $result['records'] as $record ) {
897
898 if ( 0 !== strpos( (string) $record->title, self::ABILITY_LOG_PREFIX ) ) {
899 continue; // Not an ability call - e.g. the write-gate toggle's own log entry.
900 }
901
902 $candidates[] = $record;
903 ++$kept;
904 }
905
906 if ( $kept >= $limit || $fetched < $page_size ) {
907 break;
908 }
909
910 $offset += $page_size;
911 }
912 }
913
914 /*
915 * Two ordered result sets interleave, so re-sort. `id` is the log
916 * table's auto-increment primary key, so it is both unique and in
917 * insert order - a total ordering, which makes usort()'s lack of
918 * stability on PHP 7.4 irrelevant here.
919 */
920 usort(
921 $candidates,
922 static function ( $a, $b ) {
923 return (int) $b->id <=> (int) $a->id;
924 }
925 );
926
927 $accepted = array();
928 $user_ids = array();
929
930 foreach ( $candidates as $record ) {
931
932 $outcome = $this->outcome_from_record( $record );
933
934 if ( '' === $outcome ) {
935 continue; // A warning from an unrecognised source - not one of applied/refused/denied.
936 }
937
938 $accepted[] = array(
939 'record' => $record,
940 'outcome' => $outcome,
941 );
942
943 $user_id = (int) $record->user_id;
944
945 if ( $user_id > 0 ) {
946 $user_ids[ $user_id ] = $user_id;
947 }
948
949 if ( count( $accepted ) >= $limit ) {
950 break;
951 }
952 }
953
954 /*
955 * Prime the user cache once for the whole page rather than letting
956 * user_label()'s get_userdata() issue a query per row: up to $limit
957 * uncached queries on first render otherwise.
958 */
959 if ( ! empty( $user_ids ) ) {
960 cache_users( array_values( $user_ids ) );
961 }
962
963 $rows = array();
964
965 foreach ( $accepted as $entry ) {
966
967 $record = $entry['record'];
968
969 $rows[] = array(
970 'date' => $record->get_date( 'full' ),
971 'ability' => (string) $record->title,
972 'outcome' => $entry['outcome'],
973 'user' => $this->user_label( (int) $record->user_id ),
974 'message' => $this->row_message( $record ),
975 );
976 }
977
978 return $rows;
979 }
980
981 /**
982 * Map a log record to applied / refused / denied.
983 *
984 * `log_write()` records at level "info" and only ever runs after a write
985 * has actually happened, so "info" always means applied.
986 *
987 * Both `log_write_refused()` and `log_denial()` record at level
988 * "warning" - the log system has no third level for "blocked" - so
989 * level cannot tell them apart. `source` is the discriminator instead:
990 * `log_write_refused()` writes `source => 'write-gate'` and
991 * `log_denial()` writes `source => 'permission'`.
992 *
993 * `level` could not carry this distinction, because `charitable_log()`
994 * validates it against `Charitable_Log::get_log_levels()` and silently
995 * coerces anything outside that allowlist back to `'info'` - a fourth
996 * level would never survive to be read back. `source` has no such
997 * allowlist; `sanitize_key()` is the only transform applied to it, and
998 * both values above pass through it unchanged.
999 *
1000 * An earlier version of this method matched on whether `$record->message`
1001 * started with "Refused". That is wrong on any translated site: the
1002 * stored message is the OUTPUT of `__()`, so a non-English locale does
1003 * not start with the English word "Refused", and WordPress's admin
1004 * locale is per-user, so a record written under one locale can be read
1005 * under another. Every refusal silently rendered as a denial.
1006 *
1007 * Everything else - "error" from log_failure(), "debug" from an opted-in
1008 * log_read() - is not one of the three outcomes this panel promises and
1009 * returns ''. get_activity_rows() no longer FETCHES either of those
1010 * levels (it queries `info` and `warning` explicitly, so read logging
1011 * cannot crowd writes out of the window), so the '' that matters in
1012 * practice is the one below: a warning-level record whose `source` is
1013 * neither of the two recognised values. That returns '' rather than
1014 * guessing 'denied', which would be exactly the quiet mislabelling this
1015 * method exists to avoid.
1016 *
1017 * @since 1.8.13
1018 *
1019 * @param Charitable_Log_Record $record The record.
1020 * @return string One of 'applied', 'refused', 'denied', or '' if none apply.
1021 */
1022 private function outcome_from_record( $record ) {
1023
1024 $level = (string) $record->level;
1025
1026 if ( 'info' === $level ) {
1027 return 'applied';
1028 }
1029
1030 if ( 'warning' !== $level ) {
1031 return '';
1032 }
1033
1034 switch ( (string) $record->source ) {
1035 case 'write-gate':
1036 return 'refused';
1037
1038 case 'permission':
1039 return 'denied';
1040
1041 default:
1042 // Unrecognised warning-level source: '' (dropped by
1043 // get_activity_rows()), not a guessed 'denied'.
1044 return '';
1045 }
1046 }
1047
1048 /**
1049 * A display name for the acting WordPress user.
1050 *
1051 * User id 0 is not a hypothetical: a request that reaches an ability
1052 * unauthenticated, or from an account since deleted, logs user_id 0, and
1053 * get_userdata( 0 ) returns false rather than a user with an empty name.
1054 *
1055 * @since 1.8.13
1056 *
1057 * @param int $user_id The record's user_id column.
1058 * @return string
1059 */
1060 private function user_label( $user_id ) {
1061
1062 $user_id = (int) $user_id;
1063
1064 if ( $user_id < 1 ) {
1065 return __( 'Unknown', 'charitable' );
1066 }
1067
1068 $user = get_userdata( $user_id );
1069
1070 return $user ? $user->display_name : __( 'Unknown', 'charitable' );
1071 }
1072
1073 /**
1074 * The row's human-readable message, with the affected object named when known.
1075 *
1076 * log_write()'s free-text message rarely names WHICH campaign or
1077 * donation changed ("Campaign trashed via ability.") - the id lives in
1078 * the record's own campaign_id/donation_id/donor_id columns instead,
1079 * alongside the message rather than inside it. This stitches the two
1080 * back together so the row actually says what changed. A refusal or
1081 * denial carries no such context - the gate refuses before anything
1082 * about the target is known - so those render with no object suffix,
1083 * which is correct: "where known" excludes them on purpose.
1084 *
1085 * @since 1.8.13
1086 *
1087 * @param Charitable_Log_Record $record The record.
1088 * @return string
1089 */
1090 private function row_message( $record ) {
1091
1092 $message = (string) $record->message;
1093 $object = $this->object_label( $record );
1094
1095 if ( '' === $object ) {
1096 return $message;
1097 }
1098
1099 /* translators: 1: the log message, 2: the affected object, e.g. "Campaign #12". */
1100 return sprintf( __( '%1$s (%2$s)', 'charitable' ), $message, $object );
1101 }
1102
1103 /**
1104 * Name the affected object from the record's own correlation columns.
1105 *
1106 * Priority is most-specific first. create-manual-donation's context
1107 * carries donation_id, campaign_id AND donor_id together, and the
1108 * object actually created is the donation, not the campaign it landed
1109 * on or the donor it came from - so donation_id is checked first.
1110 *
1111 * @since 1.8.13
1112 *
1113 * @param Charitable_Log_Record $record The record.
1114 * @return string The label, or '' when nothing is known.
1115 */
1116 private function object_label( $record ) {
1117
1118 $donation_id = isset( $record->donation_id ) ? (int) $record->donation_id : 0;
1119
1120 if ( $donation_id > 0 ) {
1121 /* translators: %d: donation ID. */
1122 return sprintf( __( 'Donation #%d', 'charitable' ), $donation_id );
1123 }
1124
1125 $campaign_id = isset( $record->campaign_id ) ? (int) $record->campaign_id : 0;
1126
1127 if ( $campaign_id > 0 ) {
1128 /* translators: %d: campaign ID. */
1129 return sprintf( __( 'Campaign #%d', 'charitable' ), $campaign_id );
1130 }
1131
1132 $donor_id = isset( $record->donor_id ) ? (int) $record->donor_id : 0;
1133
1134 if ( $donor_id > 0 ) {
1135 /* translators: %d: donor ID. */
1136 return sprintf( __( 'Donor #%d', 'charitable' ), $donor_id );
1137 }
1138
1139 return '';
1140 }
1141
1142 /**
1143 * Render the AI Activity panel.
1144 *
1145 * A separate view file (includes/admin/tools/views/ai-mcp-activity.php)
1146 * rather than more inline HTML in render() below, so the panel's markup
1147 * can be read, and dumped for review, apart from the much larger method
1148 * that builds the rest of this screen.
1149 *
1150 * @since 1.8.13
1151 *
1152 * @return string
1153 */
1154 public function render_activity_panel() {
1155
1156 $rows = $this->get_activity_rows();
1157 $observation_complete = true;
1158
1159 /*
1160 * Denial logging depends on `wp_ability_invoked`, core @since 7.1.0.
1161 * Below that, writes and refusals still log - they are recorded from
1162 * inside the callbacks that run them - but a denial never fires at
1163 * all, and this floor is the only way to tell "nothing was denied"
1164 * apart from "this core cannot see denials". Charitable_Abilities_Usage
1165 * already computes it for exactly this reason (get_counts()).
1166 */
1167 if ( class_exists( 'Charitable_Abilities_Usage' ) ) {
1168 $usage = Charitable_Abilities_Usage::get_counts();
1169 $observation_complete = ! empty( $usage['observation_complete'] );
1170 }
1171
1172 /*
1173 * The site-level client, not a per-row one - see ABILITY_LOG_PREFIX's
1174 * docblock and get_activity_rows() above for why a per-row client is
1175 * not something this data can honestly show.
1176 */
1177 $clients = class_exists( 'Charitable_Abilities' ) ? Charitable_Abilities::get_active_mcp_clients() : array();
1178
1179 $logs_url = admin_url( 'admin.php?page=charitable-tools&tab=logs&log_type=abilities' );
1180
1181 $view = charitable()->get_path( 'admin' ) . 'tools/views/ai-mcp-activity.php';
1182
1183 if ( ! is_readable( $view ) ) {
1184 return '';
1185 }
1186
1187 ob_start();
1188 include $view;
1189 return ob_get_clean();
1190 }
1191
1192 /**
1193 * The settings key the write gate reads.
1194 *
1195 * Kept here beside the UI that writes it, while
1196 * Charitable_Abilities_Registrar::write_enabled() is what READS it - the
1197 * gate must not depend on an admin-only class being loaded.
1198 *
1199 * @since 1.8.13
1200 *
1201 * @var string
1202 */
1203 const WRITE_SETTING_KEY = 'ai_mcp_write_enabled';
1204
1205 /**
1206 * Turn AI write access on or off, and verify it actually stored.
1207 *
1208 * Returns whether the setting now reads what was asked for, RE-READ from
1209 * the option rather than taken from update_option()'s return value -
1210 * which is false both when the write failed and when the value was
1211 * already what we asked for.
1212 *
1213 * Verifying matters because charitable_settings is a shared row that
1214 * WP-CLI, another plugin or a filter can rewrite underneath this, and
1215 * because charitable-ai-mcp.js reverts the checkbox on a failure: a
1216 * success reported for a write that did not land shows the reader OFF
1217 * while an assistant can still make changes, and a failure reported for
1218 * one that DID land shows the reverse. Both were real defects on this
1219 * screen while it had three toggles and a five-way status enum.
1220 *
1221 * 1.8.13 briefly had set_write_scope() here plus scope_persisted(),
1222 * write_scope_response(), reassert_legacy_boolean() and three SCOPE_SAVE_*
1223 * statuses, because write access was split three ways and a legacy boolean
1224 * had to be kept in AND agreement with the scope map. One switch needs
1225 * none of that: one value, it is the value the gate reads, and it either
1226 * stored or it did not.
1227 *
1228 * @since 1.8.13
1229 *
1230 * @param bool $enabled Whether AI write access should be on.
1231 * @return bool Whether the stored setting now matches $enabled.
1232 */
1233 public function set_write_enabled( $enabled ) {
1234
1235 $settings = get_option( 'charitable_settings' );
1236 $settings = is_array( $settings ) ? $settings : array();
1237
1238 $settings[ self::WRITE_SETTING_KEY ] = $enabled ? 1 : 0;
1239
1240 update_option( 'charitable_settings', $settings );
1241
1242 /*
1243 * The RAW option, not charitable_get_option(): the generic
1244 * `charitable_option_ai_mcp_write_enabled` filter could make a value
1245 * that never stored look present and correct, and a verification that
1246 * consults a filter is not a verification.
1247 */
1248 $stored = get_option( 'charitable_settings' );
1249 $stored = is_array( $stored ) ? $stored : array();
1250
1251 return ! empty( $stored[ self::WRITE_SETTING_KEY ] ) === (bool) $enabled;
1252 }
1253
1254 /**
1255 * Flip the AI write gate over AJAX.
1256 *
1257 * ⚠️ The error path must have stored NOTHING. charitable-ai-mcp.js reverts
1258 * the checkbox on a failure, so an error returned after the setting was
1259 * written shows the reader OFF while an assistant can still make changes.
1260 *
1261 * @since 1.8.13
1262 *
1263 * @return void
1264 */
1265 public function ajax_toggle_write() {
1266
1267 check_ajax_referer( 'charitable_ai_mcp_toggle', 'nonce' );
1268
1269 if ( ! current_user_can( 'manage_charitable_settings' ) ) {
1270 wp_send_json_error( array( 'message' => __( 'You do not have permission to change this.', 'charitable' ) ) );
1271 }
1272
1273 $enabled = isset( $_POST['enabled'] ) && '1' === sanitize_key( wp_unslash( $_POST['enabled'] ) );
1274
1275 if ( ! $this->set_write_enabled( $enabled ) ) {
1276 wp_send_json_error(
1277 array(
1278 'message' => __( 'Nothing has been changed for an AI assistant. That setting could not be saved - something else on this site may be writing Charitable settings. Please try again.', 'charitable' ),
1279 )
1280 );
1281 }
1282
1283 /*
1284 * charitable_log()'s first argument is the TITLE, and
1285 * get_activity_rows() matches this exact string to exclude the
1286 * toggle's own entry from the AI Activity panel's ability rows - so it
1287 * must not change.
1288 */
1289 if ( function_exists( 'charitable_log' ) ) {
1290 charitable_log(
1291 __( 'AI write access changed', 'charitable' ),
1292 /* Not translated: a log body read by us, not UI. */
1293 $enabled
1294 ? 'AI write access was turned ON from Tools > AI MCP.'
1295 : 'AI write access was turned OFF from Tools > AI MCP.',
1296 array(
1297 'type' => 'abilities',
1298 'user_id' => get_current_user_id(),
1299 )
1300 );
1301 }
1302
1303 wp_send_json_success(
1304 array(
1305 'message' => $enabled
1306 ? __( 'An AI assistant can now make changes on this site.', 'charitable' )
1307 : __( 'An AI assistant can no longer make changes on this site.', 'charitable' ),
1308 )
1309 );
1310 }
1311
1312 /**
1313 * Build the panel markup.
1314 *
1315 * @since 1.8.13
1316 *
1317 * @return string
1318 */
1319 public function render() {
1320
1321 $action = $this->get_primary_action();
1322
1323 /*
1324 * One master switch, read through the same method the gate uses, so
1325 * the toggle and the banner below can never disagree with what an
1326 * assistant is actually allowed to do.
1327 */
1328 $write_enabled = Charitable_Abilities_Registrar::write_enabled();
1329
1330 /*
1331 * Per David 2026-09-16: the hub page rather than the deep reference.
1332 * /ai is a live redirect to /ai-info/ (verified 200), unlike the
1333 * ai-assistants-learn-more page this used to point at, which is still
1334 * an unpublished draft and would have 404'd for every customer.
1335 */
1336 $docs_url = 'https://www.wpcharitable.com/ai/';
1337 $video_url = 'https://youtu.be/' . self::VIDEO_ID;
1338
1339 if ( function_exists( 'charitable_utm_link' ) ) {
1340 $docs_url = charitable_utm_link( $docs_url, 'tools-ai-assistants', 'Charitable + AI Documentation' );
1341 }
1342
1343 ob_start();
1344 ?>
1345 <div class="charitable-ai-mcp">
1346
1347 <?php if ( ! $this->has_abilities_api() ) : ?>
1348 <div class="charitable-ai-notice">
1349 <span class="dashicons dashicons-info-outline" aria-hidden="true"></span>
1350 <p>
1351 <?php
1352 printf(
1353 /* translators: %s: the WordPress version currently running. */
1354 esc_html__( 'This feature needs the WordPress Abilities API, which arrived in WordPress 6.9. This site is running %s, so nothing is registered and nothing about your site has changed. Update WordPress to use Charitable with an AI assistant.', 'charitable' ),
1355 esc_html( get_bloginfo( 'version' ) )
1356 );
1357 ?>
1358 </p>
1359 </div>
1360 <?php endif; ?>
1361
1362 <section class="charitable-ai-hero">
1363 <div class="charitable-ai-hero-body">
1364 <p class="charitable-ai-eyebrow"><?php esc_html_e( 'WordPress Abilities API + Charitable', 'charitable' ); ?></p>
1365 <h2 class="charitable-ai-title"><?php esc_html_e( 'Use Charitable With Your Favorite AI', 'charitable' ); ?></h2>
1366 <p class="charitable-ai-desc">
1367 <?php
1368 printf(
1369 /* translators: %s: linked "WPVibe.ai" text. */
1370 esc_html__( 'Connect your site to an assistant like Claude or ChatGPT, then just say what you need. Launch a fundraiser, add a question to a donation form, log a check that came in the post, or ask who your biggest supporters are. No copy-pasting, no exports. Connect them with the free %s plugin.', 'charitable' ),
1371 '<a href="https://wpvibe.ai/" target="_blank" rel="noopener noreferrer"><strong>WPVibe.ai</strong></a>'
1372 );
1373 ?>
1374 </p>
1375
1376 <div class="charitable-ai-actions">
1377 <a
1378 class="charitable-ai-button"
1379 href="<?php echo esc_url( $action['url'] ); ?>"
1380 data-action="<?php echo esc_attr( $action['action'] ); ?>"
1381 data-slug="<?php echo esc_attr( $action['slug'] ); ?>"
1382 data-basename="<?php echo esc_attr( $action['basename'] ); ?>"
1383 <?php if ( '_blank' === $action['target'] ) : ?>
1384 target="_blank" rel="noopener noreferrer"
1385 <?php endif; ?>
1386 >
1387 <?php echo esc_html( $action['text'] ); ?>
1388 <span class="charitable-ai-button-arrow" aria-hidden="true">&rarr;</span>
1389 </a>
1390
1391 <?php
1392 /*
1393 * ONE toggle, sitting INLINE with the install button,
1394 * exactly as Charitable Pro renders it.
1395 *
1396 * The `.charitable-ai-gates` wrapper this used to sit in
1397 * is gone, and that wrapper was the only reason the
1398 * toggle dropped to its own line. It carried
1399 * `width: 100%`, which made it a full-width flex item
1400 * inside `.charitable-ai-actions` and so forced a wrap.
1401 * That was correct when there were THREE toggles - the
1402 * stylesheet note recorded that inline, the first label
1403 * rode alongside the button while the other two wrapped
1404 * to a second misaligned line - but with one toggle it
1405 * just separated a single control from the button it
1406 * belongs beside. Pro has no such wrapper and never did.
1407 *
1408 * `.charitable-ai-actions` is byte-identical in both
1409 * editions (display:flex, align-items:center, gap:16px,
1410 * flex-wrap:wrap), so removing the wrapper is all that
1411 * was needed to match Pro's layout - no new CSS.
1412 *
1413 * The `charitable-ai-mcp-write` CLASS stays on the input.
1414 * Pro gates on the id alone, but Lite's JS delegates from
1415 * document on `.charitable-ai-mcp-write`, so dropping it
1416 * to match Pro exactly would silently unbind the toggle.
1417 */
1418 ?>
1419 <?php if ( $this->has_abilities_api() && current_user_can( 'manage_charitable_settings' ) ) : ?>
1420 <div class="charitable-ai-gate">
1421 <label class="charitable-toggle">
1422 <input
1423 type="checkbox"
1424 class="charitable-ai-mcp-write"
1425 id="charitable-ai-mcp-write"
1426 value="1"
1427 <?php checked( $write_enabled ); ?>
1428 />
1429 <span class="charitable-toggle-slider"></span>
1430 </label>
1431 <label class="charitable-ai-gate-label" for="charitable-ai-mcp-write">
1432 <?php esc_html_e( 'Enable AI write access', 'charitable' ); ?>
1433 </label>
1434 <span class="charitable-ai-gate-spinner" aria-hidden="true"></span>
1435 </div>
1436 <?php endif; ?>
1437 </div>
1438
1439 <?php if ( '' !== $action['note'] ) : ?>
1440 <p class="charitable-ai-note"><?php echo esc_html( $action['note'] ); ?></p>
1441 <?php endif; ?>
1442 </div>
1443
1444 <aside class="charitable-ai-clients" aria-label="<?php esc_attr_e( 'Supported AI clients', 'charitable' ); ?>">
1445 <?php foreach ( $this->get_clients() as $client ) : ?>
1446 <div class="charitable-ai-client">
1447 <span class="charitable-ai-client-icon">
1448 <img
1449 src="<?php echo esc_url( charitable()->get_path( 'assets', false ) . 'images/admin/tools/ai-mcp/' . $client['asset'] ); ?>"
1450 alt=""
1451 role="presentation"
1452 width="16"
1453 height="16"
1454 >
1455 </span>
1456 <span class="charitable-ai-client-label"><?php echo esc_html( $client['label'] ); ?></span>
1457 </div>
1458 <?php endforeach; ?>
1459 <p class="charitable-ai-client-any"><?php esc_html_e( '+ Any MCP Client', 'charitable' ); ?></p>
1460 </aside>
1461 </section>
1462
1463 <section class="charitable-ai-panel">
1464 <?php if ( $this->has_abilities_api() && ! $write_enabled ) : ?>
1465 <p class="charitable-ai-readonly-banner">
1466 <span class="dashicons dashicons-lock" aria-hidden="true"></span>
1467 <?php esc_html_e( 'Write access is off. An assistant can read everything below, but cannot change anything.', 'charitable' ); ?>
1468 </p>
1469 <?php endif; ?>
1470
1471 <header class="charitable-ai-panel-head">
1472 <h2 class="charitable-ai-panel-title">
1473 <?php
1474 /*
1475 * The brand word is a placeholder rather than part of the
1476 * sentence so it can be painted brand orange without
1477 * handing translators a span to preserve. esc_html() on
1478 * the format string leaves %1$s intact, and $brand is our
1479 * own markup with its text already escaped.
1480 */
1481 $brand = '<span class="charitable-ai-brand">' . esc_html__( 'Charitable', 'charitable' ) . '</span>';
1482
1483 /*
1484 * NO OPERATION COUNT in this heading, deliberately.
1485 *
1486 * It used to read "(24 operations)" from a _n() pair. The
1487 * number is still reported where it is diagnostically
1488 * useful - the "Charitable abilities" row under *If a
1489 * client cannot connect* - and that is the copy people
1490 * paste into a support ticket. In the heading it was
1491 * decoration: it told a fundraiser nothing they could act
1492 * on, and it dated the sentence, because the count moves
1493 * whenever an ability is added.
1494 *
1495 * Removing it also removed the only caller of
1496 * get_ability_count() in render(), which walked the whole
1497 * registry on every page load of this tab.
1498 *
1499 * DIVERGENCE FROM PRO, and intentional: Pro 1.8.19 still
1500 * prints the count here from the same _n() pair. Per David.
1501 */
1502 printf(
1503 /* translators: %s: the product name, styled. */
1504 esc_html__( 'Everything %s Can Do With AI', 'charitable' ),
1505 $brand // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built above with esc_html__().
1506 );
1507 ?>
1508 </h2>
1509 <p class="charitable-ai-panel-links">
1510 <?php
1511 /*
1512 * A real YouTube link, not a bare button: with JS off, or
1513 * before charitable-ai-mcp.js runs, the click still
1514 * reaches the video. The handler intercepts it and opens
1515 * the same id in a modal instead.
1516 */
1517 ?>
1518 <a class="charitable-ai-video-link" href="<?php echo esc_url( $video_url ); ?>" data-video-id="<?php echo esc_attr( self::VIDEO_ID ); ?>" target="_blank" rel="noopener noreferrer">
1519 <span class="dashicons dashicons-video-alt3" aria-hidden="true"></span>
1520 <span><?php esc_html_e( 'Watch video', 'charitable' ); ?></span>
1521 </a>
1522 <span class="charitable-ai-panel-links-sep" aria-hidden="true">&middot;</span>
1523 <a class="charitable-ai-docs-link" href="<?php echo esc_url( $docs_url ); ?>" target="_blank" rel="noopener noreferrer">
1524 <span><?php esc_html_e( 'View Charitable + AI Documentation', 'charitable' ); ?></span>
1525 <span class="charitable-ai-docs-arrow" aria-hidden="true">&rarr;</span>
1526 </a>
1527 </p>
1528 </header>
1529
1530 <div class="charitable-ai-cards">
1531 <?php foreach ( $this->get_cards() as $card ) : ?>
1532 <article class="charitable-ai-card charitable-ai-card-<?php echo esc_attr( $card['slug'] ); ?>">
1533 <header class="charitable-ai-card-head">
1534 <span class="charitable-ai-card-icon dashicons dashicons-<?php echo esc_attr( $card['icon'] ); ?>" aria-hidden="true"></span>
1535 <h3 class="charitable-ai-card-title">
1536 <?php echo esc_html( $card['title'] ); ?>
1537 <?php if ( ! empty( $card['requires_pro'] ) ) : ?>
1538 <span class="charitable-pro-badge"><?php esc_html_e( 'Pro', 'charitable' ); ?></span>
1539 <?php endif; ?>
1540 </h3>
1541 </header>
1542 <?php if ( ! empty( $card['requires_pro'] ) ) : ?>
1543 <p class="charitable-ai-card-note"><?php echo esc_html( $card['note'] ); ?></p>
1544 <p class="charitable-ai-card-upgrade">
1545 <a href="<?php echo esc_url( charitable_utm_link( 'https://wpcharitable.com/lite-upgrade/', 'tools-ai-mcp-' . $card['slug'], __( 'Upgrade to Charitable Pro', 'charitable' ) ) ); ?>" target="_blank" rel="noopener noreferrer">
1546 <?php esc_html_e( 'Upgrade to Charitable Pro', 'charitable' ); ?>
1547 <span aria-hidden="true">&rarr;</span>
1548 </a>
1549 </p>
1550 <?php else : ?>
1551 <ul class="charitable-ai-card-list">
1552 <?php foreach ( $card['bullets'] as $bullet ) : ?>
1553 <li><?php echo esc_html( $bullet ); ?></li>
1554 <?php endforeach; ?>
1555 </ul>
1556 <?php endif; ?>
1557 </article>
1558 <?php endforeach; ?>
1559 </div>
1560
1561 <?php if ( $this->has_abilities_api() ) : ?>
1562 <?php
1563 /*
1564 * Below the capability cards, and gated on the same check the
1565 * WP<6.9 notice above uses: there can be no ability activity
1566 * without the Abilities API, so this panel simply does not
1567 * appear in that state rather than advertising empty data.
1568 * render_activity_panel() escapes everything itself.
1569 */
1570 echo $this->render_activity_panel(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped inside the included view.
1571 ?>
1572 <?php endif; ?>
1573
1574 <footer class="charitable-ai-panel-foot">
1575 <p>
1576 <?php
1577 esc_html_e( 'Nothing is exposed to anyone who could not already do it in this dashboard. Every operation checks a Charitable permission before it runs, donor contact details need the sensitive-data permission, and your payment gateway keys are never readable.', 'charitable' );
1578 ?>
1579 <a href="<?php echo esc_url( admin_url( 'admin.php?page=charitable-tools&tab=logs&log_type=abilities' ) ); ?>">
1580 <?php esc_html_e( 'View logs to see what an assistant changed.', 'charitable' ); ?>
1581 </a>
1582 </p>
1583
1584 <details class="charitable-ai-diag-wrap" id="charitable-ai-diagnostics">
1585 <summary class="charitable-ai-diag-summary"><?php esc_html_e( 'If a client cannot connect', 'charitable' ); ?></summary>
1586
1587 <div class="charitable-ai-diag-box">
1588 <ul class="charitable-ai-diag">
1589 <?php foreach ( $this->get_diagnostics() as $row ) : ?>
1590 <li class="charitable-ai-diag-row <?php echo $row['ok'] ? 'is-ok' : 'is-problem'; ?>">
1591 <span class="charitable-ai-diag-mark dashicons dashicons-<?php echo $row['ok'] ? 'yes-alt' : 'warning'; ?>" aria-hidden="true"></span>
1592 <span class="charitable-ai-diag-label"><?php echo esc_html( $row['label'] ); ?></span>
1593 <span class="charitable-ai-diag-value"><?php echo esc_html( $row['value'] ); ?></span>
1594 </li>
1595 <?php endforeach; ?>
1596 </ul>
1597
1598 <p class="charitable-ai-diag-more">
1599 <?php
1600 printf(
1601 /* translators: %s: link to the System Info tab, text "System Info". */
1602 esc_html__( 'Everything above, plus the REST route, the registered operation names and how many are visible to a client, is in %s.', 'charitable' ),
1603 '<a href="' . esc_url( admin_url( 'admin.php?page=charitable-tools&tab=system-info' ) ) . '">' . esc_html__( 'System Info', 'charitable' ) . '</a>'
1604 );
1605 ?>
1606 </p>
1607 </div>
1608 </details>
1609 </footer>
1610 </section>
1611
1612 </div>
1613 <?php
1614 return ob_get_clean();
1615 }
1616 }
1617
1618 endif;
1619