PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.13
ZIP AI – AI Website Builder & AI Agent v0.0.13
0.0.14 0.0.13 0.0.12 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 0.0.13, at inc/core/plugin-abilities-toggler.php

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