PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / core / plugin-abilities-toggler.php

plugin-abilities-toggler.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at inc/core/plugin-abilities-toggler.php

806 lines 23.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin abilities toggler.
4 *
5 * Data-driven post-install MCP ability toggles per plugin slug.
6 *
7 * @package zip-ai
8 */
9
10 namespace ZipAI\MCP\Classes\Core;
11
12 defined( 'ABSPATH' ) || exit;
13
14 class Plugin_Abilities_Toggler {
15
16 /**
17 * Wire the `activated_plugin` core action so any activation path
18 * (server-side ability, browser-proxied `POST /wp/v2/plugins`, admin
19 * UI, WP-CLI) triggers MCP-ability toggles for mapped slugs.
20 *
21 * Idempotent — `enable_for_slug` is safe to call multiple times and
22 * returns `handled=false` for unmapped slugs, so this hook adds no
23 * cost for plugins outside MAP.
24 */
25 public static function init(): void {
26 add_action( 'activated_plugin', array( __CLASS__, 'on_activated_plugin' ), 20, 2 );
27 }
28
29 /**
30 * Map the WP-core plugin file ("folder/file.php") to a wp.org slug
31 * (folder) and dispatch to `enable_for_slug`. Non-wp.org plugins
32 * (single-file or non-conforming folder layout) fall through to a
33 * no-op since the slug won't appear in MAP.
34 *
35 * @param string $plugin_file Relative plugin file path as stored in
36 * the `active_plugins` option.
37 * @param bool $network_wide Whether activation was network-wide.
38 */
39 public static function on_activated_plugin( $plugin_file, $network_wide = false ): void {
40 if ( '' === $plugin_file ) {
41 return;
42 }
43 if ( false === strpos( $plugin_file, '/' ) ) {
44 return;
45 }
46 $slug = strtolower( explode( '/', $plugin_file )[0] );
47 if ( '' === $slug ) {
48 return;
49 }
50
51 // ZIP AI itself (re)activated: a mapped plugin that was ALREADY active
52 // won't fire its own `activated_plugin`, so sweep every active mapped
53 // slug rather than the (unmapped) zip-ai slug.
54 $sweep_self = ( self::self_slug() === $slug );
55
56 if ( is_multisite() && $network_wide ) {
57 $site_ids = get_sites(
58 array(
59 'fields' => 'ids',
60 'number' => 0,
61 )
62 );
63 foreach ( $site_ids as $site_id ) {
64 switch_to_blog( (int) $site_id );
65 try {
66 if ( $sweep_self ) {
67 self::sweep_active_mapped_plugins();
68 } else {
69 self::enable_for_slug( $slug );
70 }
71 } finally {
72 restore_current_blog();
73 }
74 }
75 return;
76 }
77
78 if ( $sweep_self ) {
79 self::sweep_active_mapped_plugins();
80 return;
81 }
82
83 self::enable_for_slug( $slug );
84 }
85
86 /**
87 * This plugin's own folder slug, derived from its real install path rather
88 * than assumed to be `zip-ai`. A branch zip from GitHub unpacks as
89 * `zip-ai-<branch>/` and the legacy folder was `zipwp-mcp/` — hardcoding
90 * the wp.org slug makes the self-activation sweep silently no-op on exactly
91 * the install shapes QA uses.
92 *
93 * @param string $plugin_file Absolute plugin-file path to derive from.
94 * Defaults to this plugin's entry file.
95 */
96 public static function self_slug( string $plugin_file = '' ): string {
97 $file = '' !== $plugin_file ? $plugin_file : ZIPAI_MCP_FILE;
98 return strtolower( dirname( plugin_basename( $file ) ) );
99 }
100
101 /**
102 * Apply toggles for every currently-active MAPped plugin on the current
103 * site. Covers activation orderings the `activated_plugin` hook misses — a
104 * mapped plugin already active before ZIP AI, or before the site connected
105 * to ERA. Idempotent: `enable_for_slug` re-applies harmlessly.
106 *
107 * @return void
108 */
109 public static function sweep_active_mapped_plugins(): void {
110 foreach ( self::active_plugin_slugs() as $slug ) {
111 if ( array_key_exists( $slug, self::MAP ) ) {
112 self::enable_for_slug( $slug );
113 }
114 }
115 }
116
117 /**
118 * Folder slugs of plugins active on the current site (plus network-active
119 * plugins on multisite).
120 *
121 * @return array<int,string>
122 */
123 private static function active_plugin_slugs(): array {
124 $files = (array) get_option( 'active_plugins', array() );
125 if ( is_multisite() ) {
126 $files = array_merge( $files, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) );
127 }
128
129 $slugs = array();
130 foreach ( $files as $file ) {
131 if ( ! is_string( $file ) || false === strpos( $file, '/' ) ) {
132 continue;
133 }
134 $slug = strtolower( explode( '/', $file )[0] );
135 if ( '' !== $slug ) {
136 $slugs[ $slug ] = true;
137 }
138 }
139
140 return array_keys( $slugs );
141 }
142
143 /**
144 * In-memory plugin abilities map.
145 *
146 * Add new plugins here by slug with an `operations` list.
147 */
148 private const MAP = array(
149 'sureforms' => array(
150 'operations' => array(
151 array(
152 'type' => 'set_option',
153 'name' => 'srfm_abilities_api',
154 'value' => '1',
155 ),
156 array(
157 'type' => 'set_option',
158 'name' => 'srfm_abilities_api_edit',
159 'value' => '1',
160 ),
161 array(
162 'type' => 'set_option',
163 'name' => 'srfm_abilities_api_delete',
164 'value' => '1',
165 ),
166 array(
167 'type' => 'set_option',
168 'name' => 'srfm_mcp_server',
169 'value' => '1',
170 ),
171 array(
172 // Merge, never whole-value replace: a future SureForms
173 // release adding a fifth key to this option would lose it
174 // on any ZIP-AI-driven activation otherwise (the same
175 // reason the surerank/astra entries below use this type).
176 'type' => 'set_option_array_keys',
177 'name' => 'srfm_mcp_settings_options',
178 'values' => array(
179 'srfm_abilities_api' => true,
180 'srfm_abilities_api_edit' => true,
181 'srfm_abilities_api_delete' => true,
182 'srfm_mcp_server' => true,
183 ),
184 ),
185 array(
186 'type' => 'call_function_if_exists',
187 'name' => 'srfm_save_mcp_settings',
188 'args' => array(
189 array(
190 'srfm_abilities_api' => true,
191 'srfm_abilities_api_edit' => true,
192 'srfm_abilities_api_delete' => true,
193 'srfm_mcp_server' => true,
194 ),
195 ),
196 ),
197 // Suppress post-activation redirect to onboarding wizard.
198 array(
199 'type' => 'delete_option',
200 'name' => '__srfm_do_redirect',
201 ),
202 ),
203 ),
204 'suremails' => array(
205 'operations' => array(
206 array(
207 'type' => 'set_option',
208 'name' => 'suremails_abilities_api_edit',
209 'value' => '1',
210 ),
211 // Suppress post-activation redirect to onboarding wizard.
212 array(
213 'type' => 'delete_option',
214 'name' => 'suremails_do_redirect',
215 ),
216 ),
217 ),
218 'spectra-blocks' => array(
219 'operations' => array(
220 // Unlock Spectra Blocks' MCP abilities so the ZIP AI chat can use
221 // them. All three gates default to 'disabled' (string) — read
222 // abilities + wp_abilities_api_init, write abilities, and the
223 // dedicated MCP server respectively.
224 array(
225 'type' => 'set_option',
226 'name' => 'spectra_blocks_enable_abilities',
227 'value' => 'enabled',
228 ),
229 array(
230 'type' => 'set_option',
231 'name' => 'spectra_blocks_enable_edit_abilities',
232 'value' => 'enabled',
233 ),
234 array(
235 'type' => 'set_option',
236 'name' => 'spectra_blocks_enable_mcp_server',
237 'value' => 'enabled',
238 ),
239 // Suppress post-activation redirect to onboarding wizard.
240 // `__spectra_blocks_do_redirect` is set true in the plugin's
241 // activation hook and consumed once in
242 // Spectra_Blocks_Onboarding::maybe_redirect_to_onboarding().
243 array(
244 'type' => 'delete_option',
245 'name' => '__spectra_blocks_do_redirect',
246 ),
247 ),
248 ),
249 'surecart' => array(
250 'operations' => array(
251 array(
252 'type' => 'set_option',
253 'name' => 'surecart_mcp_abilities_enabled',
254 'value' => '1',
255 ),
256 array(
257 'type' => 'set_option',
258 'name' => 'surecart_mcp_edit_abilities_enabled',
259 'value' => '1',
260 ),
261 array(
262 'type' => 'set_option',
263 'name' => 'surecart_mcp_delete_abilities_enabled',
264 'value' => '1',
265 ),
266 // Mark zipwp as the SureCart referrer source for marketing attribution.
267 array(
268 'type' => 'set_option_if_absent',
269 'name' => 'surecart_source',
270 'value' => 'zipwp',
271 ),
272 ),
273 ),
274 'surerank' => array(
275 'operations' => array(
276 // Suppress post-activation redirect + mark onboarding complete.
277 array(
278 'type' => 'delete_option',
279 'name' => 'surerank_redirect_on_activation',
280 ),
281 array(
282 'type' => 'set_option',
283 'name' => 'surerank_onboarding_completed',
284 'value' => true,
285 ),
286 // Enable SureRank's MCP integration on activation (SureForms-style):
287 // flip its existing `enable_mcp` setting, which registers SureRank's
288 // abilities into the shared WP Abilities registry (and its dedicated
289 // MCP server) so the server can use them — notably
290 // surerank/update-post-seo for page-level SEO. Merged into
291 // surerank_settings so other keys are preserved.
292 array(
293 'type' => 'set_option_array_keys',
294 'name' => 'surerank_settings',
295 'values' => array( 'enable_mcp' => true ),
296 ),
297 ),
298 ),
299 'suredonation' => array(
300 'operations' => array(
301 // Enable SureDonation's Abilities API (read + edit + delete) plus its
302 // dedicated MCP server so the ZIP AI chat can read AND modify
303 // campaigns/donations on the built site — a strict mirror of the
304 // SureForms entry above. These toggles live NESTED under
305 // `suredonation_options['ai_settings']` (the plugin's
306 // `SureDonation\Inc\Helper::OPTION_NAME`) and are read at plugin boot
307 // in `suredonation.php`; SureForms uses flat `srfm_*` options instead.
308 array(
309 'type' => 'set_nested_option_keys',
310 'name' => 'suredonation_options',
311 'path' => 'ai_settings',
312 'values' => array(
313 'enable_abilities' => true,
314 'allow_updates' => true,
315 'allow_delete' => true,
316 'mcp_server' => true,
317 ),
318 ),
319 // Suppress the post-activation redirect to the onboarding wizard.
320 // `__suredonation_do_redirect` is set on activation and consumed once
321 // in `inc/onboarding.php` maybe_redirect_to_onboarding().
322 array(
323 'type' => 'delete_option',
324 'name' => '__suredonation_do_redirect',
325 ),
326 ),
327 ),
328 'surecookie' => array(
329 'operations' => array(
330 // Unlock SureCookie's MCP abilities. `enable_mcp` lives in the
331 // nested `surecookie_settings` option and gates both the Abilities
332 // API integration and the dedicated MCP server (mcp/server.php).
333 array(
334 'type' => 'set_option_array_keys',
335 'name' => 'surecookie_settings',
336 'values' => array(
337 'enable_mcp' => true,
338 ),
339 ),
340 // Suppress post-activation redirect to onboarding wizard.
341 // `surecookie_do_activation_redirect` is set true on activation
342 // and consumed once in loader.php (redirects to
343 // admin.php?page=surecookie-onboarding).
344 array(
345 'type' => 'delete_option',
346 'name' => 'surecookie_do_activation_redirect',
347 ),
348 ),
349 ),
350 'suremembers' => array(
351 'operations' => array(
352 // Unlock SureMembers' MCP abilities (read + edit + delete + MCP
353 // server). These keys live nested in the `suremembers_abilities_settings`
354 // option (inc/services/abilities/abilities-settings.php) and gate the
355 // membership read/grant/revoke/delete abilities exposed to ZIP AI.
356 array(
357 'type' => 'set_option_array_keys',
358 'name' => 'suremembers_abilities_settings',
359 'values' => array(
360 'suremembers_abilities_api' => true,
361 'suremembers_abilities_api_edit' => true,
362 'suremembers_abilities_api_delete' => true,
363 'suremembers_mcp_server' => true,
364 ),
365 ),
366 // Suppress post-activation redirect to onboarding wizard.
367 // `__suremembers_do_redirect` is set true in inc/activator.php and
368 // consumed once in plugin-loader.php (redirects to
369 // admin.php?page=suremembers-onboarding).
370 array(
371 'type' => 'delete_option',
372 'name' => '__suremembers_do_redirect',
373 ),
374 ),
375 ),
376 'astra-sites' => array(
377 'operations' => array(
378 array(
379 'type' => 'delete_option',
380 'name' => 'st_start_onboarding',
381 ),
382 array(
383 'type' => 'set_option',
384 'name' => 'astra_sites_import_complete',
385 'value' => '1',
386 ),
387 ),
388 ),
389 'latepoint' => array(
390 'operations' => array(
391 array(
392 'type' => 'delete_option',
393 'name' => 'latepoint_redirect_to_wizard',
394 ),
395 ),
396 ),
397 'suretriggers' => array(
398 'operations' => array(
399 array(
400 'type' => 'delete_transient',
401 'name' => 'st-redirect-after-activation',
402 ),
403 array(
404 'type' => 'set_option_if_absent',
405 'name' => 'suretriggers_source',
406 'value' => 'zipwp',
407 ),
408 ),
409 ),
410 'cartflows' => array(
411 'operations' => array(
412 array(
413 'type' => 'set_option',
414 'name' => 'wcf_start_onboarding',
415 'value' => 'false',
416 ),
417 array(
418 'type' => 'set_option',
419 'name' => 'wcf_setup_skipped',
420 'value' => 'true',
421 ),
422 ),
423 ),
424 'modern-cart' => array(
425 'operations' => array(
426 array(
427 'type' => 'set_option',
428 'name' => 'moderncart_is_onboarding_complete',
429 'value' => 'yes',
430 ),
431 array(
432 'type' => 'delete_transient',
433 'name' => 'moderncart_redirect_to_onboarding',
434 ),
435 ),
436 ),
437 'suredash' => array(
438 'operations' => array(
439 // Canonical API — `SureDashboard\Inc\Modules\MCP\Module::save_settings`
440 // merges the four toggles into `portal_settings` and updates the
441 // SUREDASHBOARD_SETTINGS option in one call. Plugin namespace is
442 // `SureDashboard` (not `SureDash`); class lives at
443 // inc/modules/mcp/module.php.
444 array(
445 'type' => 'call_static_method_if_exists',
446 'class' => '\\SureDashboard\\Inc\\Modules\\MCP\\Module',
447 'method' => 'save_settings',
448 'args' => array(
449 array(
450 'suredash_abilities_api' => true,
451 'suredash_abilities_api_edit' => true,
452 'suredash_abilities_api_delete' => true,
453 'suredash_mcp_server' => true,
454 ),
455 ),
456 ),
457 // Fallback — write `portal_settings` keys directly when the
458 // canonical class isn't loaded (e.g. autoloader race during
459 // activation). Same key names as the canonical settings shape.
460 array(
461 'type' => 'set_portal_settings_true_if_exists',
462 'keys' => array(
463 'suredash_abilities_api',
464 'suredash_abilities_api_edit',
465 'suredash_abilities_api_delete',
466 'suredash_mcp_server',
467 ),
468 ),
469 // Suppress post-activation redirect to onboarding wizard.
470 // `__suredash_do_redirect` is set true in activation_actions
471 // and consumed in activation_redirect (loader.php).
472 array(
473 'type' => 'delete_option',
474 'name' => '__suredash_do_redirect',
475 ),
476 array(
477 'type' => 'set_option',
478 'name' => 'suredash_onboarding_completed',
479 'value' => true,
480 ),
481 ),
482 ),
483 'astra' => array(
484 'operations' => array(
485 array(
486 'type' => 'set_option_array_keys_if_exists',
487 'name' => 'astra_admin_settings',
488 'values' => array(
489 'enable_abilities' => true,
490 'enable_edit_abilities' => true,
491 'enable_mcp_server' => true,
492 ),
493 ),
494 ),
495 ),
496 );
497
498 /**
499 * Enable MCP abilities for a plugin slug when mapped.
500 *
501 * @param string $slug Plugin slug.
502 * @return array<string,mixed>
503 */
504 public static function enable_for_slug( string $slug ): array {
505 $slug = sanitize_key( $slug );
506 $map = self::MAP;
507
508 if ( ! isset( $map[ $slug ] ) ) {
509 return array(
510 'handled' => false,
511 'slug' => $slug,
512 'mode' => 'none',
513 'applied' => 0,
514 );
515 }
516
517 $operations = $map[ $slug ]['operations'];
518
519 $applied = 0;
520 $mode = 'operations';
521
522 foreach ( $operations as $operation ) {
523 if ( self::run_operation( $operation ) ) {
524 ++$applied;
525 }
526 if ( 'set_portal_settings_true_if_exists' === $operation['type'] ) {
527 $mode = 'portal_settings';
528 }
529 }
530
531 return array(
532 'handled' => true,
533 'slug' => $slug,
534 'mode' => $mode,
535 'applied' => $applied,
536 );
537 }
538
539 /**
540 * Read a string value from an operation array, defaulting to ''.
541 *
542 * @param array<string,mixed> $operation Operation config.
543 * @param string $key Key to read.
544 */
545 private static function str_value( array $operation, string $key ): string {
546 $value = $operation[ $key ] ?? '';
547 return is_string( $value ) ? $value : '';
548 }
549
550 /**
551 * Coerce an unknown value into a string-keyed array.
552 *
553 * @param mixed $value Raw value from an operation descriptor.
554 * @return array<string,mixed>
555 */
556 private static function assoc( $value ): array {
557 if ( ! is_array( $value ) ) {
558 return array();
559 }
560 $out = array();
561 foreach ( $value as $key => $item ) {
562 $out[ (string) $key ] = $item;
563 }
564 return $out;
565 }
566
567 /**
568 * Execute one operation.
569 *
570 * @param array<string,mixed> $operation Operation config.
571 * @return bool Whether it applied a change/action.
572 */
573 private static function run_operation( array $operation ): bool {
574 $type = self::str_value( $operation, 'type' );
575
576 switch ( $type ) {
577 case 'set_option':
578 $name = self::str_value( $operation, 'name' );
579 if ( '' === $name ) {
580 return false;
581 }
582 $value = $operation['value'] ?? null;
583 update_option( $name, $value );
584 return true;
585
586 case 'set_option_if_absent':
587 $name = self::str_value( $operation, 'name' );
588 if ( '' === $name ) {
589 return false;
590 }
591 $value = $operation['value'] ?? null;
592 return add_option( $name, $value, '', false );
593
594 case 'delete_option':
595 $name = self::str_value( $operation, 'name' );
596 if ( '' === $name ) {
597 return false;
598 }
599 delete_option( $name );
600 return true;
601
602 case 'delete_transient':
603 $name = self::str_value( $operation, 'name' );
604 if ( '' === $name ) {
605 return false;
606 }
607 delete_transient( $name );
608 return true;
609
610 case 'call_function_if_exists':
611 $name = self::str_value( $operation, 'name' );
612 $args = isset( $operation['args'] ) && is_array( $operation['args'] ) ? $operation['args'] : array();
613 if ( '' === $name || ! function_exists( $name ) ) {
614 return false;
615 }
616 call_user_func_array( $name, $args );
617 return true;
618
619 case 'call_static_method_if_exists':
620 $class = self::str_value( $operation, 'class' );
621 $method = self::str_value( $operation, 'method' );
622 $args = isset( $operation['args'] ) && is_array( $operation['args'] ) ? $operation['args'] : array();
623 if ( '' === $class || '' === $method || ! class_exists( $class ) || ! method_exists( $class, $method ) ) {
624 return false;
625 }
626 $callback = array( $class, $method );
627 if ( ! is_callable( $callback ) ) {
628 return false;
629 }
630 call_user_func_array( $callback, $args );
631 return true;
632
633 case 'call_first_supported':
634 $calls = isset( $operation['calls'] ) && is_array( $operation['calls'] ) ? $operation['calls'] : array();
635 foreach ( $calls as $call_op ) {
636 if ( ! is_array( $call_op ) ) {
637 continue;
638 }
639 if ( self::run_operation( self::assoc( $call_op ) ) ) {
640 return true;
641 }
642 }
643 return false;
644
645 case 'set_portal_settings_true_if_exists':
646 $keys = isset( $operation['keys'] ) && is_array( $operation['keys'] ) ? array_values( $operation['keys'] ) : array();
647 return self::set_portal_settings_true_if_exists( $keys );
648
649 case 'set_option_array_keys':
650 $name = self::str_value( $operation, 'name' );
651 $values = self::assoc( $operation['values'] ?? null );
652 if ( '' === $name || empty( $values ) ) {
653 return false;
654 }
655 return self::set_option_array_keys( $name, $values );
656
657 case 'set_option_array_keys_if_exists':
658 $name = self::str_value( $operation, 'name' );
659 $values = self::assoc( $operation['values'] ?? null );
660 if ( '' === $name || empty( $values ) ) {
661 return false;
662 }
663 return self::set_option_array_keys_if_exists( $name, $values );
664
665 case 'set_nested_option_keys':
666 $name = self::str_value( $operation, 'name' );
667 $path = self::str_value( $operation, 'path' );
668 $values = self::assoc( $operation['values'] ?? null );
669 if ( '' === $name || '' === $path || empty( $values ) ) {
670 return false;
671 }
672 return self::set_nested_option_keys( $name, $path, $values );
673
674 default:
675 return false;
676 }
677 }
678
679 /**
680 * Set known keys to true only when `portal_settings` exists and keys exist.
681 *
682 * @param array<int,mixed> $keys Candidate keys.
683 * @return bool Whether any value was updated.
684 */
685 private static function set_portal_settings_true_if_exists( array $keys ): bool {
686 $settings = get_option( 'portal_settings', null );
687 if ( ! is_array( $settings ) ) {
688 return false;
689 }
690
691 $changed = false;
692 $truthy = array( 1, true, '1', 'true' );
693
694 foreach ( $keys as $raw_key ) {
695 $key = is_string( $raw_key ) ? $raw_key : '';
696 if ( '' === $key || ! array_key_exists( $key, $settings ) ) {
697 continue;
698 }
699 if ( in_array( $settings[ $key ], $truthy, true ) ) {
700 continue;
701 }
702 $settings[ $key ] = true;
703 $changed = true;
704 }
705
706 if ( $changed ) {
707 update_option( 'portal_settings', $settings );
708 }
709
710 return $changed;
711 }
712
713 /**
714 * Merge key/value pairs into an option array.
715 *
716 * @param string $option_name Option key.
717 * @param array<string,mixed> $values Keys to upsert.
718 * @return bool Whether changes were applied.
719 */
720 private static function set_option_array_keys( string $option_name, array $values ): bool {
721 $current = get_option( $option_name, array() );
722 if ( ! is_array( $current ) ) {
723 $current = array();
724 }
725
726 $changed = false;
727 foreach ( $values as $key => $value ) {
728 if ( '' === $key ) {
729 continue;
730 }
731 if ( array_key_exists( $key, $current ) && $current[ $key ] === $value ) {
732 continue;
733 }
734 $current[ $key ] = $value;
735 $changed = true;
736 }
737
738 if ( $changed ) {
739 update_option( $option_name, $current );
740 }
741
742 return $changed;
743 }
744
745 /**
746 * Merge key/value pairs into an option array when the option already exists.
747 *
748 * Prevents pre-emptively creating option stubs before the plugin's own
749 * bootstrap writes defaults.
750 *
751 * @param string $option_name Option key.
752 * @param array<string,mixed> $values Keys to upsert.
753 * @return bool Whether changes were applied.
754 */
755 private static function set_option_array_keys_if_exists( string $option_name, array $values ): bool {
756 $current = get_option( $option_name, null );
757 if ( null === $current || ! is_array( $current ) ) {
758 return false;
759 }
760 return self::set_option_array_keys( $option_name, $values );
761 }
762
763 /**
764 * Merge key/value pairs into a sub-array nested one level inside an option.
765 *
766 * For settings stored as `option[ $path ][ $key ] = $value` — e.g.
767 * SureDonation keeps its Abilities/MCP toggles at
768 * `suredonation_options['ai_settings']`. Read-modify-write so sibling keys
769 * the plugin or the site owner already set are preserved; the option and
770 * the sub-array are created when absent (the toggler runs on
771 * `activated_plugin`, AFTER the plugin's own activation defaults, so in
772 * practice this only ever merges).
773 *
774 * @param string $option_name Top-level option key.
775 * @param string $path Sub-array key inside the option.
776 * @param array<string,mixed> $values Keys to upsert into the sub-array.
777 * @return bool Whether changes were applied.
778 */
779 private static function set_nested_option_keys( string $option_name, string $path, array $values ): bool {
780 $current = get_option( $option_name, array() );
781 if ( ! is_array( $current ) ) {
782 $current = array();
783 }
784 $sub = isset( $current[ $path ] ) && is_array( $current[ $path ] ) ? $current[ $path ] : array();
785
786 $changed = false;
787 foreach ( $values as $key => $value ) {
788 if ( '' === $key ) {
789 continue;
790 }
791 if ( array_key_exists( $key, $sub ) && $sub[ $key ] === $value ) {
792 continue;
793 }
794 $sub[ $key ] = $value;
795 $changed = true;
796 }
797
798 if ( $changed ) {
799 $current[ $path ] = $sub;
800 update_option( $option_name, $current );
801 }
802
803 return $changed;
804 }
805 }
806