Module slug → result. */ public static function detect_all( bool $force_refresh = false ): array { if ( ! $force_refresh ) { $cached = get_transient( self::TRANSIENT ); if ( is_array( $cached ) ) { return $cached; } } $out = array(); foreach ( TMDO_Module_Rules::known_modules() as $module ) { $out[ $module ] = self::detect_one( $module ); } set_transient( self::TRANSIENT, $out, self::TRANSIENT_TTL ); // Persist to wp_options for the dashboard widget (no autoload). update_option( self::OPTION_CACHE, array( 'results' => $out, 'generated_at' => gmdate( 'Y-m-d H:i:s' ), ), false ); return $out; } /** * Detect a single module. * * @param string $module Module slug. * @return array See class doc for shape. */ public static function detect_one( string $module ): array { $rule = TMDO_Module_Rules::for_module( $module ); if ( null === $rule ) { return self::stub( $module, 'no_rule' ); } $reasons = array(); $blockers = array(); $confidence = 0.0; $current_state = class_exists( 'TMDO_Feature_Flags' ) ? TMDO_Feature_Flags::get( $module ) : 'idle'; // 0. Already non-idle? Block recommendation. if ( 'idle' !== $current_state ) { $blockers[] = sprintf( '⚠️ Module 已在 %s 狀態(不需重複推薦)', $current_state ); return self::build( $module, false, 0.0, 'skip', $reasons, $blockers, $current_state ); } // 1. Plugin compatibility check. $compat_required = (array) ( $rule['compat_required'] ?? array() ); if ( ! empty( $compat_required ) ) { $missing = self::check_compat( $compat_required ); if ( ! empty( $missing ) ) { $blockers[] = '⚠️ 需要 plugin 啟用:' . implode( ', ', $missing ); return self::build( $module, false, 0.0, 'skip', $reasons, $blockers, $current_state ); } $reasons[] = '✅ 必要 plugin 已啟用:' . implode( ', ', $compat_required ); $confidence += 0.3; } // 2. Required post_type + row count. $post_type = (string) ( $rule['post_type_required'] ?? '' ); $min_count = (int) ( $rule['min_post_count'] ?? 0 ); if ( '' !== $post_type ) { $count = self::post_type_count( $post_type ); if ( $count < $min_count ) { $blockers[] = sprintf( '⚠️ %s post_type 只有 %s 行(需要 ≥ %s)', $post_type, number_format_i18n( $count ), number_format_i18n( $min_count ) ); return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state ); } $reasons[] = sprintf( '✅ %s post_type 有 %s 行(門檻 %s)', $post_type, number_format_i18n( $count ), number_format_i18n( $min_count ) ); $confidence += 0.4; } // 3. Archive-specific: trashed count. if ( 'archive' === $module ) { $min_trash = (int) ( $rule['min_trash_count'] ?? 0 ); $trash_count = self::trashed_post_count(); if ( $trash_count < $min_trash ) { $blockers[] = sprintf( '⚠️ trashed posts 只有 %d 個(需要 ≥ %d)', $trash_count, $min_trash ); return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state ); } $reasons[] = sprintf( '✅ trashed posts 有 %d 個', $trash_count ); $confidence += 0.3; } // 4. Classifier consultation (zone modules). if ( ! empty( $rule['consult_classifier'] ) && '' !== $post_type && class_exists( 'TMDO_Zone_Classifier' ) ) { $min_conf = (float) ( $rule['min_classifier_confidence'] ?? 0.6 ); $cls_score = self::classifier_score( $post_type, $module ); if ( $cls_score < $min_conf ) { $blockers[] = sprintf( '⚠️ Classifier 對 %s 的 %s zone confidence 僅 %.2f(需 ≥ %.2f)', $post_type, self::module_to_zone( $module ), $cls_score, $min_conf ); return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state ); } $reasons[] = sprintf( '✅ Classifier confidence %.2f(門檻 %.2f)', $cls_score, $min_conf ); $confidence = min( 1.0, $confidence + $cls_score * 0.3 ); } // 5. Priority bonus: warm/archive recommended_first. // v2.5.0 M16 polish: bump from +0.2 → +0.5 so a low-risk module like // `warm` (no compat / no post_type gate) crosses the actionable // threshold (0.5) on a fresh install — Setup Wizard / Dashboard widget // can surface it without admin chasing config. if ( 'recommended_first' === ( $rule['priority'] ?? '' ) ) { $confidence = min( 1.0, $confidence + 0.5 ); $reasons[] = '⭐ 入門首選(低風險)'; } // Cap confidence. $confidence = min( 1.0, max( 0.0, $confidence ) ); return self::build( $module, true, $confidence, 'enable', $reasons, $blockers, $current_state ); } /** * Filter detect_all() down to actionable enable-recommendations * above a confidence threshold. * * @param float $min_confidence Threshold (0..1, default 0.5). * @return array */ public static function get_actionable( float $min_confidence = 0.5 ): array { $all = self::detect_all(); $out = array(); foreach ( $all as $module => $r ) { if ( ! empty( $r['available'] ) && 'enable' === ( $r['recommendation'] ?? '' ) && (float) ( $r['confidence'] ?? 0 ) >= $min_confidence ) { $out[ $module ] = $r; } } // Sort by confidence desc. uasort( $out, static fn( $a, $b ) => (float) $b['confidence'] <=> (float) $a['confidence'] ); return $out; } /** * Get the cached results from wp_options (for dashboard widget — no live query). * * @return array {results:array, generated_at:string}|null */ public static function get_cached(): ?array { $v = get_option( self::OPTION_CACHE ); return is_array( $v ) ? $v : null; } // ─── private helpers ───────────────────────────────────────────────── /** * Build a result envelope. * * @param string $module Module slug. * @param bool $available Whether module passes all checks. * @param float $confidence 0..1. * @param string $recommendation 'enable'|'wait'|'skip'. * @param array $reasons Positive findings. * @param array $blockers Negative findings. * @param string $current_state Current FSM state. * @return array */ private static function build( string $module, bool $available, float $confidence, string $recommendation, array $reasons, array $blockers, string $current_state ): array { $rule = TMDO_Module_Rules::for_module( $module ) ?? array(); return array( 'module' => $module, 'available' => $available, 'confidence' => round( $confidence, 2 ), 'recommendation' => $recommendation, 'reasons' => $reasons, 'blockers' => $blockers, 'description' => (string) ( $rule['description'] ?? '' ), 'current_state' => $current_state, 'suggested_action' => $available && 'enable' === $recommendation ? array( 'type' => 'set_state', 'module' => $module, 'to_state' => 'dual_write', 'cli' => "wp wpdo mode-set {$module} dual_write", ) : null, ); } /** * Build a stub result envelope for modules that cannot be evaluated. * * @param string $module Module slug. * @param string $reason Reason code or human message. * @return array */ private static function stub( string $module, string $reason ): array { return array( 'module' => $module, 'available' => false, 'confidence' => 0.0, 'recommendation' => 'skip', 'reasons' => array(), 'blockers' => array( $reason ), 'description' => '', 'current_state' => 'idle', 'suggested_action' => null, ); } /** * Find which required plugins are missing. * * @param array $required Plugins required. * @return array Missing plugin slugs. */ private static function check_compat( array $required ): array { if ( ! class_exists( 'TMDO_Compatibility' ) ) { return $required; } $missing = array(); foreach ( $required as $plugin ) { $active = match ( $plugin ) { 'hivepress' => TMDO_Compatibility::is_hivepress_active(), 'woocommerce' => TMDO_Compatibility::is_woocommerce_active(), 'hpct' => TMDO_Compatibility::is_hpct_active(), 'latepoint' => TMDO_Compatibility::is_latepoint_active(), default => false, }; if ( ! $active ) { $missing[] = $plugin; } } return $missing; } /** * Return the published post count for a given post type. * * @param string $post_type Post type slug. * @return int Row count. */ private static function post_type_count( string $post_type ): int { global $wpdb; $cached_key = 'wpdo_pt_count_' . md5( $post_type ); $cached = get_transient( $cached_key ); if ( false !== $cached ) { return (int) $cached; } $count = (int) $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB "SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_type = %s AND post_status NOT IN ('trash','auto-draft')", $post_type ) ); set_transient( $cached_key, $count, HOUR_IN_SECONDS ); return $count; } /** * Total trashed posts (all post types). * * @return int */ private static function trashed_post_count(): int { global $wpdb; $cached = get_transient( 'wpdo_trash_count' ); if ( false !== $cached ) { return (int) $cached; } $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_status = 'trash'" ); // phpcs:ignore WordPress.DB set_transient( 'wpdo_trash_count', $count, HOUR_IN_SECONDS ); return $count; } /** * Map module → target zone for Classifier consultation. * * @param string $module Module slug (e.g. hot_hp_listing). * @return string Zone (hot|cold|warm|archive). */ private static function module_to_zone( string $module ): string { if ( str_starts_with( $module, 'hot_' ) ) { return 'hot'; } if ( str_starts_with( $module, 'cold_' ) ) { return 'cold'; } return $module; // 'warm' / 'archive'. } /** * Classifier confidence aggregate for a (post_type, target_zone). * * @param string $post_type Post type. * @param string $module Module slug → mapped to zone. * @return float 0..1 average confidence of meta_keys whose suggested_zone matches. */ private static function classifier_score( string $post_type, string $module ): float { if ( ! class_exists( 'TMDO_Zone_Classifier' ) ) { return 0.0; } $zone = self::module_to_zone( $module ); try { $results = TMDO_Zone_Classifier::analyze( $post_type, 50 ); } catch ( Throwable $e ) { return 0.0; } if ( ! is_array( $results ) || empty( $results ) ) { return 0.0; } $total = 0.0; $count = 0; foreach ( $results as $field ) { if ( ( $field['suggested_zone'] ?? '' ) === $zone ) { $total += (float) ( $field['confidence'] ?? 0 ); ++$count; } } return $count > 0 ? $total / $count : 0.0; } }