PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.12
ZIP AI – AI Website Builder & AI Agent v0.0.12
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 / site-scanner.php

site-scanner.php in ZIP AI – AI Website Builder & AI Agent 0.0.12, at inc/core/site-scanner.php

843 lines 26.0 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,excerpt:string,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 'excerpt' => mb_substr( $text, 0, 300 ),
298 'raw_html' => mb_substr( $raw_content, 0, 2000 ), // The server extracts headings from this.
299 );
300 }
301
302 $total = wp_count_posts( 'page' );
303
304 return array(
305 'count' => isset( $total->publish ) ? Utils::to_int( $total->publish ) : 0,
306 'items' => $items,
307 );
308 }
309
310 /**
311 * Fetch the latest published posts with top categories.
312 *
313 * @return array{count:int,categories:array<int,string>,items:array<int,array{title:string,excerpt:string}>} Post count, categories, and post items.
314 */
315 private static function get_posts_raw() {
316 $posts = get_posts(
317 array(
318 'post_type' => 'post',
319 'post_status' => 'publish',
320 'posts_per_page' => 10,
321 'orderby' => 'date',
322 'order' => 'DESC',
323 )
324 );
325
326 $items = array();
327 foreach ( $posts as $post ) {
328 $text = wp_strip_all_tags( $post->post_content );
329
330 $items[] = array(
331 'title' => $post->post_title,
332 'excerpt' => mb_substr( $text, 0, 300 ),
333 );
334 }
335
336 $count = wp_count_posts( 'post' );
337
338 $categories = get_categories(
339 array(
340 'hide_empty' => true,
341 'number' => 15,
342 'orderby' => 'count',
343 'order' => 'DESC',
344 )
345 );
346
347 $cat_names = array();
348 foreach ( $categories as $cat ) {
349 $cat_names[] = $cat->name . ' (' . $cat->count . ')';
350 }
351
352 return array(
353 'count' => isset( $count->publish ) ? Utils::to_int( $count->publish ) : 0,
354 'categories' => $cat_names,
355 'items' => $items,
356 );
357 }
358
359 /**
360 * Collect navigation menus with their items.
361 *
362 * @return array<int,array{name:string,items:array<int,array{title:string,url:string}>}> Menus with their items.
363 */
364 private static function get_menus_raw() {
365 $menus = wp_get_nav_menus();
366 $results = array();
367
368 foreach ( $menus as $menu ) {
369 $items = wp_get_nav_menu_items( $menu->term_id );
370 $menu_items = array();
371
372 if ( $items ) {
373 foreach ( array_slice( $items, 0, 20 ) as $item ) {
374 if ( ! is_object( $item ) ) {
375 continue;
376 }
377 $vars = get_object_vars( $item );
378 $menu_items[] = array(
379 'title' => isset( $vars['title'] ) && is_string( $vars['title'] ) ? $vars['title'] : '',
380 'url' => isset( $vars['url'] ) && is_string( $vars['url'] ) ? $vars['url'] : '',
381 );
382 }
383 }
384
385 if ( ! empty( $menu_items ) ) {
386 $results[] = array(
387 'name' => $menu->name,
388 'items' => $menu_items,
389 );
390 }
391 }
392
393 return $results;
394 }
395
396 /**
397 * Collect footer/bottom sidebar widget counts.
398 *
399 * @return array<string,int>|null Map of sidebar id to widget count, or null when none.
400 */
401 private static function get_sidebars_raw() {
402 $sidebars = wp_get_sidebars_widgets();
403 $footer_widgets = array();
404
405 foreach ( $sidebars as $sidebar_id => $widgets ) {
406 if ( empty( $widgets ) || ! is_array( $widgets ) ) {
407 continue;
408 }
409
410 if ( str_contains( $sidebar_id, 'footer' ) || str_contains( $sidebar_id, 'bottom' ) ) {
411 $footer_widgets[ (string) $sidebar_id ] = count( $widgets );
412 }
413 }
414
415 return ! empty( $footer_widgets ) ? $footer_widgets : null;
416 }
417
418 /**
419 * Collect public custom post types that have published entries.
420 *
421 * @return array<int,array{name:string,label:string,count:int}> Custom post type details.
422 */
423 private static function get_custom_post_types() {
424 $cpts = get_post_types(
425 array(
426 '_builtin' => false,
427 'public' => true,
428 ),
429 'objects'
430 );
431 $results = array();
432 $exclude = array( 'spectra-popup', 'elementor_library', 'wp_template', 'wp_template_part', 'wp_block' );
433
434 foreach ( $cpts as $cpt ) {
435 if ( in_array( $cpt->name, $exclude, true ) ) {
436 continue;
437 }
438
439 $count = wp_count_posts( $cpt->name );
440 $published = isset( $count->publish ) ? Utils::to_int( $count->publish ) : 0;
441
442 if ( $published > 0 ) {
443 $results[] = array(
444 'name' => $cpt->name,
445 'label' => $cpt->label,
446 'count' => $published,
447 );
448 }
449 }
450
451 return $results;
452 }
453
454 /**
455 * Read the site's color palette from Astra settings or theme.json.
456 *
457 * @return string Comma-separated palette colors, or empty string when none.
458 */
459 private static function get_color_palette() {
460 $palette = array();
461
462 $astra_settings = get_option( 'astra-settings', array() );
463 if ( is_array( $astra_settings ) ) {
464 $gcp = $astra_settings['global-color-palette'] ?? null;
465 if ( is_array( $gcp ) && ! empty( $gcp['palette'] ) && is_array( $gcp['palette'] ) ) {
466 $palette = array_filter( array_map( static fn ( $v ): string => is_scalar( $v ) ? (string) $v : '', $gcp['palette'] ) );
467 }
468 }
469
470 if ( ! empty( $palette ) ) {
471 return implode( ', ', array_slice( $palette, 0, 6 ) );
472 }
473
474 if ( function_exists( 'wp_get_global_settings' ) ) {
475 $settings = wp_get_global_settings( array( 'color', 'palette', 'theme' ) );
476 if ( is_array( $settings ) ) {
477 foreach ( array_slice( $settings, 0, 6 ) as $color ) {
478 if ( ! is_array( $color ) ) {
479 continue;
480 }
481 $name = $color['name'] ?? '';
482 $value = $color['color'] ?? '';
483 $palette[] = ( is_string( $name ) ? $name : '' ) . ': ' . ( is_string( $value ) ? $value : '' );
484 }
485 }
486 }
487
488 return ! empty( $palette ) ? implode( ', ', $palette ) : '';
489 }
490
491 /**
492 * Read the Spectra GBS Style Guide palette.
493 *
494 * Returns `{ slug: { shade: "#hex", ... }, ... }` for every palette slug
495 * defined in the site's Style Guide (primary / secondary / base / neutral
496 * / chromatic1..N). Shades come from `ClassRegistry::get_all_classes()`
497 * filtered to `bg-{slug}-{shade}`; each entry's CSS body is parsed for
498 * the `var(--spectra-...)` reference which is then resolved against the
499 * style-guide-tokens stylesheet to yield the actual hex.
500 *
501 * Empty array when Spectra is not active.
502 *
503 * @return array<string, array<int, string>>
504 */
505 private static function get_spectra_style_guide() {
506 if ( ! class_exists( '\Spectra\GlobalStyles\ClassRegistry' ) ) {
507 return array();
508 }
509
510 // 1. Collect the site's resolved CSS custom properties from the
511 // Style-Guide TokenRegistry (single source of truth for slug→hex).
512 $vars = array();
513 if ( class_exists( '\\Spectra\\StyleGuide\\Engine' ) ) {
514 $engine = \Spectra\StyleGuide\Engine::get_instance();
515 $registry = is_object( $engine ) && method_exists( $engine, 'get_token_registry' ) ? $engine->get_token_registry() : null;
516 if ( is_object( $registry ) && method_exists( $registry, 'get_css_string' ) ) {
517 $token_css = $registry->get_css_string();
518 if ( is_string( $token_css ) && preg_match_all( '/--spectra-([a-z0-9-]+)\s*:\s*([^;]+);/i', $token_css, $m ) ) {
519 foreach ( $m[1] as $idx => $var_name ) {
520 $vars[ $var_name ] = trim( $m[2][ $idx ] );
521 }
522 }
523 }
524 }
525
526 // 2. Walk the ClassRegistry and pick out `bg-{slug}-{shade}` rules.
527 // Each rule's CSS body looks like `background: var(--spectra-SLUG-SHADE)`.
528 // Resolve the var name against the table built above and emit the hex.
529 $palette = array();
530 $all = \Spectra\GlobalStyles\ClassRegistry::get_all_classes();
531 if ( ! is_array( $all ) ) {
532 return array();
533 }
534
535 foreach ( $all as $class_name => $entry ) {
536 if ( ! is_string( $class_name ) || ! is_array( $entry ) ) {
537 continue;
538 }
539 if ( ! preg_match( '/^bg-([a-z][a-z0-9]*)-(50|100|200|300|400|500|600|700|800|900|950)$/', $class_name, $m ) ) {
540 continue;
541 }
542 $slug = $m[1];
543 $shade = $m[2];
544
545 $css_val = $entry['css'] ?? '';
546 $declaration = is_string( $css_val ) ? $css_val : '';
547 if ( ! preg_match( '/var\(\s*--spectra-([a-z0-9-]+)\s*\)/i', $declaration, $vm ) ) {
548 continue;
549 }
550 $var_ref = $vm[1];
551 $hex = $vars[ $var_ref ] ?? null;
552 if ( null !== $hex && preg_match( '/^#[0-9a-fA-F]{3,8}$/', $hex ) ) {
553 $palette[ $slug ][ $shade ] = $hex;
554 }
555 }
556
557 ksort( $palette );
558 foreach ( $palette as $slug => $shades ) {
559 ksort( $palette[ $slug ] );
560 }
561
562 return $palette;
563 }
564
565 // ══════════════════════════════════════════════════════════
566 // E-commerce — raw product data
567 // ══════════════════════════════════════════════════════════
568
569 /**
570 * Collect raw e-commerce data for the active store plugin.
571 *
572 * @return array<string,mixed>|null WooCommerce or SureCart data, or null when no store plugin is active.
573 */
574 private static function get_ecommerce_raw() {
575 if ( class_exists( 'WooCommerce' ) ) {
576 $product_count = wp_count_posts( 'product' );
577
578 $categories = get_terms(
579 array(
580 'taxonomy' => 'product_cat',
581 'hide_empty' => true,
582 'number' => 10,
583 'orderby' => 'count',
584 'order' => 'DESC',
585 )
586 );
587
588 $cat_names = array();
589 if ( ! is_wp_error( $categories ) ) {
590 foreach ( $categories as $cat ) {
591 $cat_names[] = $cat->name;
592 }
593 }
594
595 // Latest 15 products with names, prices, and short descriptions.
596 $products = get_posts(
597 array(
598 'post_type' => 'product',
599 'post_status' => 'publish',
600 'posts_per_page' => 15,
601 'orderby' => 'date',
602 'order' => 'DESC',
603 )
604 );
605
606 $product_items = array();
607 foreach ( $products as $product_post ) {
608 $wc = wc_get_product( $product_post->ID );
609 if ( ! $wc ) {
610 continue;
611 }
612
613 $product_items[] = array(
614 'name' => $product_post->post_title,
615 'price' => (float) $wc->get_price(),
616 'short_description' => wp_strip_all_tags( mb_substr( $wc->get_short_description(), 0, 150 ) ),
617 );
618 }
619
620 // Bestsellers (top 5 by sales).
621 $bestsellers = get_posts(
622 array(
623 'post_type' => 'product',
624 'post_status' => 'publish',
625 'posts_per_page' => 5,
626 'meta_key' => 'total_sales',
627 'orderby' => 'meta_value_num',
628 'order' => 'DESC',
629 )
630 );
631 $bestseller_data = array();
632 foreach ( $bestsellers as $bs ) {
633 $wc = wc_get_product( $bs->ID );
634 $sales = $wc ? (int) $wc->get_total_sales() : 0;
635 if ( $sales > 0 ) {
636 $bestseller_data[] = array(
637 'name' => $bs->post_title,
638 'sales' => $sales,
639 );
640 }
641 }
642
643 return array(
644 'platform' => 'WooCommerce',
645 'product_count' => isset( $product_count->publish ) ? Utils::to_int( $product_count->publish ) : 0,
646 'product_categories' => $cat_names,
647 'products' => $product_items,
648 'bestsellers' => $bestseller_data,
649 'currency' => get_woocommerce_currency(),
650 'has_reviews' => 'yes' === get_option( 'woocommerce_enable_reviews', 'yes' ),
651 );
652 }
653
654 if ( defined( 'SURECART_PLUGIN_FILE' ) || class_exists( 'SureCart' ) ) {
655 return array( 'platform' => 'SureCart' );
656 }
657
658 return null;
659 }
660
661 // ══════════════════════════════════════════════════════════
662 // SEO — raw meta data from Yoast / RankMath
663 // ══════════════════════════════════════════════════════════
664
665 /**
666 * Collect SEO metadata from Yoast or RankMath.
667 *
668 * @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.
669 */
670 private static function get_seo_raw() {
671 $seo_plugin = null;
672
673 if ( defined( 'WPSEO_VERSION' ) ) {
674 $seo_plugin = 'Yoast SEO';
675 } elseif ( class_exists( 'RankMath' ) ) {
676 $seo_plugin = 'RankMath';
677 }
678
679 if ( ! $seo_plugin ) {
680 return null;
681 }
682
683 $pages = get_posts(
684 array(
685 'post_type' => array( 'page', 'post' ),
686 'post_status' => 'publish',
687 'posts_per_page' => 10,
688 'orderby' => 'modified',
689 'order' => 'DESC',
690 )
691 );
692
693 $meta = array();
694 foreach ( $pages as $page ) {
695 $entry = array( 'title' => $page->post_title );
696
697 $fk = '';
698 $md = '';
699 if ( 'Yoast SEO' === $seo_plugin ) {
700 $fk = get_post_meta( $page->ID, '_yoast_wpseo_focuskw', true );
701 $md = get_post_meta( $page->ID, '_yoast_wpseo_metadesc', true );
702 } elseif ( 'RankMath' === $seo_plugin ) {
703 $fk = get_post_meta( $page->ID, 'rank_math_focus_keyword', true );
704 $md = get_post_meta( $page->ID, 'rank_math_description', true );
705 }
706
707 $entry['focus_keyword'] = is_string( $fk ) ? $fk : '';
708 $entry['meta_description'] = is_string( $md ) ? $md : '';
709
710 // Only include if there's actual SEO data.
711 if ( ! empty( $entry['focus_keyword'] ) || ! empty( $entry['meta_description'] ) ) {
712 $meta[] = $entry;
713 }
714 }
715
716 return array(
717 'plugin' => $seo_plugin,
718 'pages' => $meta,
719 );
720 }
721
722 // ══════════════════════════════════════════════════════════
723 // Platform detectors — just check if active, no formatting
724 // ══════════════════════════════════════════════════════════
725
726 /**
727 * Detect the active membership plugin.
728 *
729 * @return array{plugin:string}|null Membership plugin name, or null when none is active.
730 */
731 private static function get_membership_data() {
732 if ( defined( 'MEPR_PLUGIN_NAME' ) ) {
733 return array( 'plugin' => 'MemberPress' );
734 }
735 if ( class_exists( 'Restrict_Content_Pro' ) ) {
736 return array( 'plugin' => 'Restrict Content Pro' );
737 }
738 if ( class_exists( 'WC_Memberships' ) ) {
739 return array( 'plugin' => 'WooCommerce Memberships' );
740 }
741
742 return null;
743 }
744
745 /**
746 * Collect LMS course/lesson counts for the active LMS plugin.
747 *
748 * @return array{plugin:string,course_count:int,lesson_count:int}|null LMS plugin name and counts, or null when no LMS is active.
749 */
750 private static function get_lms_data() {
751 if ( defined( 'LEARNDASH_VERSION' ) ) {
752 $courses = wp_count_posts( 'sfwd-courses' );
753 $lessons = wp_count_posts( 'sfwd-lessons' );
754
755 return array(
756 'plugin' => 'LearnDash',
757 'course_count' => isset( $courses->publish ) ? Utils::to_int( $courses->publish ) : 0,
758 'lesson_count' => isset( $lessons->publish ) ? Utils::to_int( $lessons->publish ) : 0,
759 );
760 }
761
762 if ( defined( 'TUTOR_VERSION' ) ) {
763 $courses = wp_count_posts( 'courses' );
764 $lessons = wp_count_posts( 'lesson' );
765
766 return array(
767 'plugin' => 'Tutor LMS',
768 'course_count' => isset( $courses->publish ) ? Utils::to_int( $courses->publish ) : 0,
769 'lesson_count' => isset( $lessons->publish ) ? Utils::to_int( $lessons->publish ) : 0,
770 );
771 }
772
773 return null;
774 }
775
776 /**
777 * Collect upcoming event count for The Events Calendar.
778 *
779 * @return array{plugin:string,upcoming_count:int}|null Events plugin name and upcoming count, or null when not active.
780 */
781 private static function get_events_data() {
782 if ( defined( 'TRIBE_EVENTS_FILE' ) ) {
783 $upcoming = get_posts(
784 array(
785 'post_type' => 'tribe_events',
786 'post_status' => 'publish',
787 'posts_per_page' => -1,
788 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one-off site-scan diagnostic query, not a hot path.
789 array(
790 'key' => '_EventStartDate',
791 'value' => current_time( 'mysql' ),
792 'compare' => '>=',
793 'type' => 'DATETIME',
794 ),
795 ),
796 'fields' => 'ids',
797 )
798 );
799
800 return array(
801 'plugin' => 'The Events Calendar',
802 'upcoming_count' => count( $upcoming ),
803 );
804 }
805
806 return null;
807 }
808
809 /**
810 * Detect the active forms plugin and its form count.
811 *
812 * @return array{plugin:string,count?:int}|null Forms plugin name and form count, or null when none is active.
813 */
814 private static function get_forms_data() {
815 if ( defined( 'JESUSFN_SUREFORMS_VER' ) || defined( 'JESUSFN_SUREFORMS_PLUGIN_FILE' ) || class_exists( 'JESUSFN_SureForms' ) ) {
816 $forms = wp_count_posts( 'sureforms_form' );
817 return array(
818 'plugin' => 'SureForms',
819 'count' => isset( $forms->publish ) ? Utils::to_int( $forms->publish ) : 0,
820 );
821 }
822 if ( defined( 'WPFORMS_VERSION' ) ) {
823 $forms = wp_count_posts( 'wpforms' );
824 return array(
825 'plugin' => 'WPForms',
826 'count' => isset( $forms->publish ) ? Utils::to_int( $forms->publish ) : 0,
827 );
828 }
829 if ( defined( 'WPCF7_VERSION' ) ) {
830 $forms = wp_count_posts( 'wpcf7_contact_form' );
831 return array(
832 'plugin' => 'Contact Form 7',
833 'count' => isset( $forms->publish ) ? Utils::to_int( $forms->publish ) : 0,
834 );
835 }
836 if ( class_exists( 'GFAPI' ) ) {
837 return array( 'plugin' => 'Gravity Forms' );
838 }
839
840 return null;
841 }
842 }
843