chore: initial snapshot of 2meet-data-optimizer v0.1.0

Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
@@ -0,0 +1,381 @@
<?php
/**
* TMDO_Module_Detector — Smart module availability detection (v2.5.0 M16).
*
* For each registered module (see TMDO_Module_Rules), evaluates
* 1. Are required plugins active (Compatibility::is_*_active)?
* 2. Does the required post_type exist + meet min_post_count?
* 3. (zone modules) Does Classifier confidence exceed min_classifier_confidence?
* 4. Is the module already in a non-idle state (no point recommending)?
*
* Output per module: {available, confidence, recommendation, reasons,
* blockers, current_state, suggested_action}.
*
* Caching: detect_all() is heavy (queries postmeta, runs classifier).
* Daily health-cron writes to wp_options.wpdo_module_suggestions; admin UI
* reads from there for fast widget rendering.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Detection engine — stateless static API.
*/
class TMDO_Module_Detector {
/** WP options key holding cached results. */
public const OPTION_CACHE = 'wpdo_module_suggestions';
/** Transient key for short-term cache (1 hour). */
private const TRANSIENT = 'wpdo_module_detector_results';
/** TTL for transient cache. */
private const TRANSIENT_TTL = 3600;
/**
* Detect every registered module. Cached for 1 hour via transient.
*
* @param bool $force_refresh Bypass transient cache.
* @return array<string,array> 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<string,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<int,string> 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;
}
}