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 / site-scanner.php

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

845 lines 26.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Site Scanner — thin data collector for ZIP AI memory enrichment.
4 *
5 * Collects raw WordPress data and sends it directly to the server.
6 * All extraction logic, analysis, and fact storage lives server-side.
7 * The plugin is a dumb data pipe — easy to maintain, no intelligence to update.
8 *
9 * Triggers:
10 * 1. After first successful auth (day-zero scan)
11 * 2. When a post/page/CPT is published or updated (debounced)
12 * 3. On plugin activation/deactivation or theme switch
13 *
14 * @package zip-ai
15 * @since 1.0.0
16 */
17
18 namespace ZipAI\MCP\Classes\Core;
19
20 // Exit if accessed directly.
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit;
23 }
24
25 class Site_Scanner {
26
27 /** Debounce interval — don't scan more than once per 5 minutes. */
28 private const DEBOUNCE_SECONDS = 300;
29
30 /**
31 * Register event-driven hooks.
32 */
33 public static function register_hooks(): void {
34 add_action( 'transition_post_status', array( __CLASS__, 'on_post_status_change' ), 10, 3 );
35 add_action( 'activated_plugin', array( __CLASS__, 'trigger_debounced_scan' ) );
36 add_action( 'deactivated_plugin', array( __CLASS__, 'trigger_debounced_scan' ) );
37 add_action( 'switch_theme', array( __CLASS__, 'trigger_debounced_scan' ) );
38 }
39
40 /**
41 * No-op — cron no longer used. Kept for backward compat if old events exist.
42 */
43 public static function unschedule(): void {
44 wp_clear_scheduled_hook( 'zip_ai_site_scan' );
45 }
46
47 /**
48 * Trigger scan when a post transitions to published.
49 *
50 * @param string $new_status New post status.
51 * @param string $old_status Old post status.
52 * @param \WP_Post $post Post object being transitioned.
53 * @return void
54 */
55 public static function on_post_status_change( $new_status, $old_status, $post ) {
56 if ( 'publish' !== $new_status ) {
57 return;
58 }
59
60 if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) {
61 return;
62 }
63
64 self::trigger_debounced_scan();
65 }
66
67 /**
68 * Trigger a scan with debouncing via non-blocking REST call.
69 * No WP cron dependency — fires immediately.
70 */
71 public static function trigger_debounced_scan(): void {
72 $auth_token = Helper::get_decrypted_auth_token();
73
74 if ( empty( $auth_token ) ) {
75 return;
76 }
77
78 $last_scan = get_transient( 'zip_ai_last_scan_time' );
79
80 if ( $last_scan && ( time() - Utils::to_int( $last_scan ) ) < self::DEBOUNCE_SECONDS ) {
81 return;
82 }
83
84 self::fire_scan_request();
85 }
86
87 /**
88 * Fire non-blocking REST request to trigger site scan.
89 *
90 * Authenticates with the plugin's own Application Password: the route takes
91 * Basic, never a Bearer, so the server token sent here before could only 401 —
92 * and rode the wire with TLS verification off to do it.
93 */
94 public static function fire_scan_request(): void {
95 $authorization = Helper::get_decrypted_app_password_authorization();
96
97 if ( '' === $authorization ) {
98 return;
99 }
100
101 // Claim the debounce window here — after the credential check, before the
102 // non-blocking send. `run_scan()` re-claims it once the loopback lands,
103 // which is too late to stop a bulk publish firing one scan per post; and
104 // claiming any earlier would burn the window every 5 minutes forever on
105 // installs where no App Password is provisioned.
106 set_transient( 'zip_ai_last_scan_time', time(), self::DEBOUNCE_SECONDS );
107
108 wp_remote_post(
109 rest_url( 'zip-ai/v1/site-scan' ),
110 array(
111 'headers' => array(
112 'Authorization' => $authorization,
113 ),
114 'timeout' => 0.01,
115 'blocking' => false,
116 'sslverify' => Helper::should_verify_ssl(),
117 )
118 );
119 }
120
121 /**
122 * Run the scan and send raw data straight to the server, which owns fact
123 * extraction + memory storage. The server authenticates the plugin's Sanctum
124 * token itself (dual-mode /site-scan) and resolves the account.
125 */
126 public static function run_scan(): void {
127 $auth_token = Helper::get_decrypted_auth_token();
128
129 if ( empty( $auth_token ) ) {
130 return;
131 }
132
133 set_transient( 'zip_ai_last_scan_time', time(), self::DEBOUNCE_SECONDS );
134
135 $scan_data = self::collect();
136
137 if ( empty( $scan_data ) ) {
138 return;
139 }
140
141 $domain = wp_parse_url( home_url(), PHP_URL_HOST );
142
143 wp_remote_post(
144 untrailingslashit( ZIPAI_BRAIN_URL ) . '/site-scan',
145 array(
146 'headers' => array(
147 'Content-Type' => 'application/json',
148 'Accept' => 'application/json',
149 'Authorization' => 'Bearer ' . $auth_token,
150 ),
151 'body' => (string) wp_json_encode(
152 array(
153 'domain' => $domain,
154 'scan_data' => $scan_data,
155 )
156 ),
157 'timeout' => 15,
158 'sslverify' => Helper::should_verify_ssl(),
159 )
160 );
161 }
162
163 /**
164 * Collect raw WordPress data. No formatting, no intelligence — just data.
165 *
166 * @return array<string, mixed> Raw site data.
167 */
168 public static function collect() {
169 $data = array();
170
171 // ── Site basics ──────────────────────────────────────
172 $data['site_title'] = get_bloginfo( 'name' );
173 $data['site_tagline'] = get_bloginfo( 'description' );
174 $data['language'] = get_bloginfo( 'language' );
175
176 // ── Theme ────────────────────────────────────────────
177 $theme = wp_get_theme();
178 $data['theme'] = $theme->get( 'Name' );
179
180 $data['color_palette'] = self::get_color_palette();
181
182 // ── Spectra Style Guide (GBS) ─────────────────────────
183 // Map of slug → shade → hex, so downstream memory stores the site's
184 // real palette hex values. Empty when Spectra is not active.
185 $data['spectra_style_guide'] = self::get_spectra_style_guide();
186
187 // ── Active plugins (names only) ──────────────────────
188 $data['active_plugins'] = self::get_active_plugin_names();
189
190 // ── Pages (latest 15 with raw content) ───────────────
191 $data['pages'] = self::get_pages_raw();
192
193 // ── Posts (latest 10 with raw content) ───────────────
194 $data['posts'] = self::get_posts_raw();
195
196 // ── Navigation menus ─────────────────────────────────
197 $data['menus'] = self::get_menus_raw();
198
199 // ── Custom post types ────────────────────────────────
200 $data['custom_post_types'] = self::get_custom_post_types();
201
202 // ── Sidebar widgets ──────────────────────────────────
203 $data['sidebars'] = self::get_sidebars_raw();
204
205 // ── E-commerce ───────────────────────────────────────
206 $ecommerce = self::get_ecommerce_raw();
207 if ( $ecommerce ) {
208 $data['ecommerce'] = $ecommerce;
209 }
210
211 // ── Membership ───────────────────────────────────────
212 $membership = self::get_membership_data();
213 if ( $membership ) {
214 $data['membership'] = $membership;
215 }
216
217 // ── LMS ──────────────────────────────────────────────
218 $lms = self::get_lms_data();
219 if ( $lms ) {
220 $data['lms'] = $lms;
221 }
222
223 // ── Events ───────────────────────────────────────────
224 $events = self::get_events_data();
225 if ( $events ) {
226 $data['events'] = $events;
227 }
228
229 // ── Forms ────────────────────────────────────────────
230 $forms = self::get_forms_data();
231 if ( $forms ) {
232 $data['forms'] = $forms;
233 }
234
235 // ── SEO plugin metadata ──────────────────────────────
236 $seo = self::get_seo_raw();
237 if ( $seo ) {
238 $data['seo'] = $seo;
239 }
240
241 return $data;
242 }
243
244 // ══════════════════════════════════════════════════════════
245 // Raw data collectors — minimal processing, just fetch data
246 // ══════════════════════════════════════════════════════════
247
248 /**
249 * Collect the display names of all active plugins.
250 *
251 * @return array<int,string> Active plugin names.
252 */
253 private static function get_active_plugin_names() {
254 $active = get_option( 'active_plugins', array() );
255 $names = array();
256
257 if ( ! is_array( $active ) ) {
258 return $names;
259 }
260
261 foreach ( $active as $plugin_file ) {
262 if ( ! is_string( $plugin_file ) ) {
263 continue;
264 }
265 $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, false, false );
266 if ( ! empty( $plugin_data['Name'] ) ) {
267 $names[] = $plugin_data['Name'];
268 }
269 }
270
271 return $names;
272 }
273
274 /**
275 * Fetch the latest published pages with raw content.
276 *
277 * @return array{count:int,items:array<int,array{title:string,slug:string,excerpt:string,word_count:int,raw_html:string}>} Page count and page items.
278 */
279 private static function get_pages_raw() {
280 $pages = get_posts(
281 array(
282 'post_type' => 'page',
283 'post_status' => 'publish',
284 'posts_per_page' => 15,
285 'orderby' => 'modified',
286 'order' => 'DESC',
287 )
288 );
289
290 $items = array();
291 foreach ( $pages as $page ) {
292 $raw_content = $page->post_content;
293 $text = wp_strip_all_tags( $raw_content );
294
295 $items[] = array(
296 'title' => $page->post_title,
297 'slug' => $page->post_name,
298 'excerpt' => mb_substr( $text, 0, 300 ),
299 'word_count' => str_word_count( $text ),
300 'raw_html' => mb_substr( $raw_content, 0, 2000 ), // The server extracts headings from this.
301 );
302 }
303
304 $total = wp_count_posts( 'page' );
305
306 return array(
307 'count' => isset( $total->publish ) ? Utils::to_int( $total->publish ) : 0,
308 'items' => $items,
309 );
310 }
311
312 /**
313 * Fetch the latest published posts with top categories.
314 *
315 * @return array{count:int,categories:array<int,string>,items:array<int,array{title:string,excerpt:string}>} Post count, categories, and post items.
316 */
317 private static function get_posts_raw() {
318 $posts = get_posts(
319 array(
320 'post_type' => 'post',
321 'post_status' => 'publish',
322 'posts_per_page' => 10,
323 'orderby' => 'date',
324 'order' => 'DESC',
325 )
326 );
327
328 $items = array();
329 foreach ( $posts as $post ) {
330 $text = wp_strip_all_tags( $post->post_content );
331
332 $items[] = array(
333 'title' => $post->post_title,
334 'excerpt' => mb_substr( $text, 0, 300 ),
335 );
336 }
337
338 $count = wp_count_posts( 'post' );
339
340 $categories = get_categories(
341 array(
342 'hide_empty' => true,
343 'number' => 15,
344 'orderby' => 'count',
345 'order' => 'DESC',
346 )
347 );
348
349 $cat_names = array();
350 foreach ( $categories as $cat ) {
351 $cat_names[] = $cat->name . ' (' . $cat->count . ')';
352 }
353
354 return array(
355 'count' => isset( $count->publish ) ? Utils::to_int( $count->publish ) : 0,
356 'categories' => $cat_names,
357 'items' => $items,
358 );
359 }
360
361 /**
362 * Collect navigation menus with their items.
363 *
364 * @return array<int,array{name:string,items:array<int,array{title:string,url:string}>}> Menus with their items.
365 */
366 private static function get_menus_raw() {
367 $menus = wp_get_nav_menus();
368 $results = array();
369
370 foreach ( $menus as $menu ) {
371 $items = wp_get_nav_menu_items( $menu->term_id );
372 $menu_items = array();
373
374 if ( $items ) {
375 foreach ( array_slice( $items, 0, 20 ) as $item ) {
376 if ( ! is_object( $item ) ) {
377 continue;
378 }
379 $vars = get_object_vars( $item );
380 $menu_items[] = array(
381 'title' => isset( $vars['title'] ) && is_string( $vars['title'] ) ? $vars['title'] : '',
382 'url' => isset( $vars['url'] ) && is_string( $vars['url'] ) ? $vars['url'] : '',
383 );
384 }
385 }
386
387 if ( ! empty( $menu_items ) ) {
388 $results[] = array(
389 'name' => $menu->name,
390 'items' => $menu_items,
391 );
392 }
393 }
394
395 return $results;
396 }
397
398 /**
399 * Collect footer/bottom sidebar widget counts.
400 *
401 * @return array<string,int>|null Map of sidebar id to widget count, or null when none.
402 */
403 private static function get_sidebars_raw() {
404 $sidebars = wp_get_sidebars_widgets();
405 $footer_widgets = array();
406
407 foreach ( $sidebars as $sidebar_id => $widgets ) {
408 if ( empty( $widgets ) || ! is_array( $widgets ) ) {
409 continue;
410 }
411
412 if ( str_contains( $sidebar_id, 'footer' ) || str_contains( $sidebar_id, 'bottom' ) ) {
413 $footer_widgets[ (string) $sidebar_id ] = count( $widgets );
414 }
415 }
416
417 return ! empty( $footer_widgets ) ? $footer_widgets : null;
418 }
419
420 /**
421 * Collect public custom post types that have published entries.
422 *
423 * @return array<int,array{name:string,label:string,count:int}> Custom post type details.
424 */
425 private static function get_custom_post_types() {
426 $cpts = get_post_types(
427 array(
428 '_builtin' => false,
429 'public' => true,
430 ),
431 'objects'
432 );
433 $results = array();
434 $exclude = array( 'spectra-popup', 'elementor_library', 'wp_template', 'wp_template_part', 'wp_block' );
435
436 foreach ( $cpts as $cpt ) {
437 if ( in_array( $cpt->name, $exclude, true ) ) {
438 continue;
439 }
440
441 $count = wp_count_posts( $cpt->name );
442 $published = isset( $count->publish ) ? Utils::to_int( $count->publish ) : 0;
443
444 if ( $published > 0 ) {
445 $results[] = array(
446 'name' => $cpt->name,
447 'label' => $cpt->label,
448 'count' => $published,
449 );
450 }
451 }
452
453 return $results;
454 }
455
456 /**
457 * Read the site's color palette from Astra settings or theme.json.
458 *
459 * @return string Comma-separated palette colors, or empty string when none.
460 */
461 private static function get_color_palette() {
462 $palette = array();
463
464 $astra_settings = get_option( 'astra-settings', array() );
465 if ( is_array( $astra_settings ) ) {
466 $gcp = $astra_settings['global-color-palette'] ?? null;
467 if ( is_array( $gcp ) && ! empty( $gcp['palette'] ) && is_array( $gcp['palette'] ) ) {
468 $palette = array_filter( array_map( static fn ( $v ): string => is_scalar( $v ) ? (string) $v : '', $gcp['palette'] ) );
469 }
470 }
471
472 if ( ! empty( $palette ) ) {
473 return implode( ', ', array_slice( $palette, 0, 6 ) );
474 }
475
476 if ( function_exists( 'wp_get_global_settings' ) ) {
477 $settings = wp_get_global_settings( array( 'color', 'palette', 'theme' ) );
478 if ( is_array( $settings ) ) {
479 foreach ( array_slice( $settings, 0, 6 ) as $color ) {
480 if ( ! is_array( $color ) ) {
481 continue;
482 }
483 $name = $color['name'] ?? '';
484 $value = $color['color'] ?? '';
485 $palette[] = ( is_string( $name ) ? $name : '' ) . ': ' . ( is_string( $value ) ? $value : '' );
486 }
487 }
488 }
489
490 return ! empty( $palette ) ? implode( ', ', $palette ) : '';
491 }
492
493 /**
494 * Read the Spectra GBS Style Guide palette.
495 *
496 * Returns `{ slug: { shade: "#hex", ... }, ... }` for every palette slug
497 * defined in the site's Style Guide (primary / secondary / base / neutral
498 * / chromatic1..N). Shades come from `ClassRegistry::get_all_classes()`
499 * filtered to `bg-{slug}-{shade}`; each entry's CSS body is parsed for
500 * the `var(--spectra-...)` reference which is then resolved against the
501 * style-guide-tokens stylesheet to yield the actual hex.
502 *
503 * Empty array when Spectra is not active.
504 *
505 * @return array<string, array<int, string>>
506 */
507 private static function get_spectra_style_guide() {
508 if ( ! class_exists( '\Spectra\GlobalStyles\ClassRegistry' ) ) {
509 return array();
510 }
511
512 // 1. Collect the site's resolved CSS custom properties from the
513 // Style-Guide TokenRegistry (single source of truth for slug→hex).
514 $vars = array();
515 if ( class_exists( '\\Spectra\\StyleGuide\\Engine' ) ) {
516 $engine = \Spectra\StyleGuide\Engine::get_instance();
517 $registry = is_object( $engine ) && method_exists( $engine, 'get_token_registry' ) ? $engine->get_token_registry() : null;
518 if ( is_object( $registry ) && method_exists( $registry, 'get_css_string' ) ) {
519 $token_css = $registry->get_css_string();
520 if ( is_string( $token_css ) && preg_match_all( '/--spectra-([a-z0-9-]+)\s*:\s*([^;]+);/i', $token_css, $m ) ) {
521 foreach ( $m[1] as $idx => $var_name ) {
522 $vars[ $var_name ] = trim( $m[2][ $idx ] );
523 }
524 }
525 }
526 }
527
528 // 2. Walk the ClassRegistry and pick out `bg-{slug}-{shade}` rules.
529 // Each rule's CSS body looks like `background: var(--spectra-SLUG-SHADE)`.
530 // Resolve the var name against the table built above and emit the hex.
531 $palette = array();
532 $all = \Spectra\GlobalStyles\ClassRegistry::get_all_classes();
533 if ( ! is_array( $all ) ) {
534 return array();
535 }
536
537 foreach ( $all as $class_name => $entry ) {
538 if ( ! is_string( $class_name ) || ! is_array( $entry ) ) {
539 continue;
540 }
541 if ( ! preg_match( '/^bg-([a-z][a-z0-9]*)-(50|100|200|300|400|500|600|700|800|900|950)$/', $class_name, $m ) ) {
542 continue;
543 }
544 $slug = $m[1];
545 $shade = $m[2];
546
547 $css_val = $entry['css'] ?? '';
548 $declaration = is_string( $css_val ) ? $css_val : '';
549 if ( ! preg_match( '/var\(\s*--spectra-([a-z0-9-]+)\s*\)/i', $declaration, $vm ) ) {
550 continue;
551 }
552 $var_ref = $vm[1];
553 $hex = $vars[ $var_ref ] ?? null;
554 if ( null !== $hex && preg_match( '/^#[0-9a-fA-F]{3,8}$/', $hex ) ) {
555 $palette[ $slug ][ $shade ] = $hex;
556 }
557 }
558
559 ksort( $palette );
560 foreach ( $palette as $slug => $shades ) {
561 ksort( $palette[ $slug ] );
562 }
563
564 return $palette;
565 }
566
567 // ══════════════════════════════════════════════════════════
568 // E-commerce — raw product data
569 // ══════════════════════════════════════════════════════════
570
571 /**
572 * Collect raw e-commerce data for the active store plugin.
573 *
574 * @return array<string,mixed>|null WooCommerce or SureCart data, or null when no store plugin is active.
575 */
576 private static function get_ecommerce_raw() {
577 if ( class_exists( 'WooCommerce' ) ) {
578 $product_count = wp_count_posts( 'product' );
579
580 $categories = get_terms(
581 array(
582 'taxonomy' => 'product_cat',
583 'hide_empty' => true,
584 'number' => 10,
585 'orderby' => 'count',
586 'order' => 'DESC',
587 )
588 );
589
590 $cat_names = array();
591 if ( ! is_wp_error( $categories ) ) {
592 foreach ( $categories as $cat ) {
593 $cat_names[] = $cat->name;
594 }
595 }
596
597 // Latest 15 products with names, prices, and short descriptions.
598 $products = get_posts(
599 array(
600 'post_type' => 'product',
601 'post_status' => 'publish',
602 'posts_per_page' => 15,
603 'orderby' => 'date',
604 'order' => 'DESC',
605 )
606 );
607
608 $product_items = array();
609 foreach ( $products as $product_post ) {
610 $wc = wc_get_product( $product_post->ID );
611 if ( ! $wc ) {
612 continue;
613 }
614
615 $product_items[] = array(
616 'name' => $product_post->post_title,
617 'price' => (float) $wc->get_price(),
618 'short_description' => wp_strip_all_tags( mb_substr( $wc->get_short_description(), 0, 150 ) ),
619 );
620 }
621
622 // Bestsellers (top 5 by sales).
623 $bestsellers = get_posts(
624 array(
625 'post_type' => 'product',
626 'post_status' => 'publish',
627 'posts_per_page' => 5,
628 'meta_key' => 'total_sales',
629 'orderby' => 'meta_value_num',
630 'order' => 'DESC',
631 )
632 );
633 $bestseller_data = array();
634 foreach ( $bestsellers as $bs ) {
635 $wc = wc_get_product( $bs->ID );
636 $sales = $wc ? (int) $wc->get_total_sales() : 0;
637 if ( $sales > 0 ) {
638 $bestseller_data[] = array(
639 'name' => $bs->post_title,
640 'sales' => $sales,
641 );
642 }
643 }
644
645 return array(
646 'platform' => 'WooCommerce',
647 'product_count' => isset( $product_count->publish ) ? Utils::to_int( $product_count->publish ) : 0,
648 'product_categories' => $cat_names,
649 'products' => $product_items,
650 'bestsellers' => $bestseller_data,
651 'currency' => get_woocommerce_currency(),
652 'has_reviews' => 'yes' === get_option( 'woocommerce_enable_reviews', 'yes' ),
653 );
654 }
655
656 if ( defined( 'SURECART_PLUGIN_FILE' ) || class_exists( 'SureCart' ) ) {
657 return array( 'platform' => 'SureCart' );
658 }
659
660 return null;
661 }
662
663 // ══════════════════════════════════════════════════════════
664 // SEO — raw meta data from Yoast / RankMath
665 // ══════════════════════════════════════════════════════════
666
667 /**
668 * Collect SEO metadata from Yoast or RankMath.
669 *
670 * @return array{plugin:string,pages:array<int,array<string,string>>}|null SEO plugin name and per-page meta, or null when no SEO plugin is active.
671 */
672 private static function get_seo_raw() {
673 $seo_plugin = null;
674
675 if ( defined( 'WPSEO_VERSION' ) ) {
676 $seo_plugin = 'Yoast SEO';
677 } elseif ( class_exists( 'RankMath' ) ) {
678 $seo_plugin = 'RankMath';
679 }
680
681 if ( ! $seo_plugin ) {
682 return null;
683 }
684
685 $pages = get_posts(
686 array(
687 'post_type' => array( 'page', 'post' ),
688 'post_status' => 'publish',
689 'posts_per_page' => 10,
690 'orderby' => 'modified',
691 'order' => 'DESC',
692 )
693 );
694
695 $meta = array();
696 foreach ( $pages as $page ) {
697 $entry = array( 'title' => $page->post_title );
698
699 $fk = '';
700 $md = '';
701 if ( 'Yoast SEO' === $seo_plugin ) {
702 $fk = get_post_meta( $page->ID, '_yoast_wpseo_focuskw', true );
703 $md = get_post_meta( $page->ID, '_yoast_wpseo_metadesc', true );
704 } elseif ( 'RankMath' === $seo_plugin ) {
705 $fk = get_post_meta( $page->ID, 'rank_math_focus_keyword', true );
706 $md = get_post_meta( $page->ID, 'rank_math_description', true );
707 }
708
709 $entry['focus_keyword'] = is_string( $fk ) ? $fk : '';
710 $entry['meta_description'] = is_string( $md ) ? $md : '';
711
712 // Only include if there's actual SEO data.
713 if ( ! empty( $entry['focus_keyword'] ) || ! empty( $entry['meta_description'] ) ) {
714 $meta[] = $entry;
715 }
716 }
717
718 return array(
719 'plugin' => $seo_plugin,
720 'pages' => $meta,
721 );
722 }
723
724 // ══════════════════════════════════════════════════════════
725 // Platform detectors — just check if active, no formatting
726 // ══════════════════════════════════════════════════════════
727
728 /**
729 * Detect the active membership plugin.
730 *
731 * @return array{plugin:string}|null Membership plugin name, or null when none is active.
732 */
733 private static function get_membership_data() {
734 if ( defined( 'MEPR_PLUGIN_NAME' ) ) {
735 return array( 'plugin' => 'MemberPress' );
736 }
737 if ( class_exists( 'Restrict_Content_Pro' ) ) {
738 return array( 'plugin' => 'Restrict Content Pro' );
739 }
740 if ( class_exists( 'WC_Memberships' ) ) {
741 return array( 'plugin' => 'WooCommerce Memberships' );
742 }
743
744 return null;
745 }
746
747 /**
748 * Collect LMS course/lesson counts for the active LMS plugin.
749 *
750 * @return array{plugin:string,course_count:int,lesson_count:int}|null LMS plugin name and counts, or null when no LMS is active.
751 */
752 private static function get_lms_data() {
753 if ( defined( 'LEARNDASH_VERSION' ) ) {
754 $courses = wp_count_posts( 'sfwd-courses' );
755 $lessons = wp_count_posts( 'sfwd-lessons' );
756
757 return array(
758 'plugin' => 'LearnDash',
759 'course_count' => isset( $courses->publish ) ? Utils::to_int( $courses->publish ) : 0,
760 'lesson_count' => isset( $lessons->publish ) ? Utils::to_int( $lessons->publish ) : 0,
761 );
762 }
763
764 if ( defined( 'TUTOR_VERSION' ) ) {
765 $courses = wp_count_posts( 'courses' );
766 $lessons = wp_count_posts( 'lesson' );
767
768 return array(
769 'plugin' => 'Tutor LMS',
770 'course_count' => isset( $courses->publish ) ? Utils::to_int( $courses->publish ) : 0,
771 'lesson_count' => isset( $lessons->publish ) ? Utils::to_int( $lessons->publish ) : 0,
772 );
773 }
774
775 return null;
776 }
777
778 /**
779 * Collect upcoming event count for The Events Calendar.
780 *
781 * @return array{plugin:string,upcoming_count:int}|null Events plugin name and upcoming count, or null when not active.
782 */
783 private static function get_events_data() {
784 if ( defined( 'TRIBE_EVENTS_FILE' ) ) {
785 $upcoming = get_posts(
786 array(
787 'post_type' => 'tribe_events',
788 'post_status' => 'publish',
789 'posts_per_page' => -1,
790 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one-off site-scan diagnostic query, not a hot path.
791 array(
792 'key' => '_EventStartDate',
793 'value' => current_time( 'mysql' ),
794 'compare' => '>=',
795 'type' => 'DATETIME',
796 ),
797 ),
798 'fields' => 'ids',
799 )
800 );
801
802 return array(
803 'plugin' => 'The Events Calendar',
804 'upcoming_count' => count( $upcoming ),
805 );
806 }
807
808 return null;
809 }
810
811 /**
812 * Detect the active forms plugin and its form count.
813 *
814 * @return array{plugin:string,count?:int}|null Forms plugin name and form count, or null when none is active.
815 */
816 private static function get_forms_data() {
817 if ( defined( 'JESUSFN_SUREFORMS_VER' ) || defined( 'JESUSFN_SUREFORMS_PLUGIN_FILE' ) || class_exists( 'JESUSFN_SureForms' ) ) {
818 $forms = wp_count_posts( 'sureforms_form' );
819 return array(
820 'plugin' => 'SureForms',
821 'count' => isset( $forms->publish ) ? Utils::to_int( $forms->publish ) : 0,
822 );
823 }
824 if ( defined( 'WPFORMS_VERSION' ) ) {
825 $forms = wp_count_posts( 'wpforms' );
826 return array(
827 'plugin' => 'WPForms',
828 'count' => isset( $forms->publish ) ? Utils::to_int( $forms->publish ) : 0,
829 );
830 }
831 if ( defined( 'WPCF7_VERSION' ) ) {
832 $forms = wp_count_posts( 'wpcf7_contact_form' );
833 return array(
834 'plugin' => 'Contact Form 7',
835 'count' => isset( $forms->publish ) ? Utils::to_int( $forms->publish ) : 0,
836 );
837 }
838 if ( class_exists( 'GFAPI' ) ) {
839 return array( 'plugin' => 'Gravity Forms' );
840 }
841
842 return null;
843 }
844 }
845