Files
2meet-data-optimizer-hivepr…/includes/hivepress/class-tmdo-hivepress-detector.php
T
wpdev b96b041445 fix(hivepress): detector active-plugin 偵測 + 4 個 interceptor 改繼承核心基底(F2/F3/F4)
F3 detector(A v3.4.5):HivePress addon 不暴露任何 per-addon class/const,
   它們是透過 add_filter('hivepress/v1/extensions') 註冊目錄,所以原本的
   class_exists / defined 偵測永遠只認得 core → 反 EAV 覆蓋率卡在 1/13。
   改以 get_option('active_plugins') 為第一級訊號(新增 active_plugin_files()),
   version_for() 對 addon 改讀其主檔 Version: header。覆蓋率回到 7/13。

F2 interceptor:reviews / messages / memberships / requests 改繼承核心的
   TMDO_Standard_Post_Interceptor,573 → 344 行。FIELD_MAP 由 private
   提升為 public(late static binding 從基底讀取)。
   bootstrap 的 require 迴圈加核心版本守衛:這 4 個檔案在舊核心下會於
   parse 階段就 fatal,class_exists 守衛來不及。

F4 listing-stats:view 計數改用 Zone_Warm::increment() 原子遞增,
   flush 改原子 UPDATE,消除 get()+set() 的 lost-update race。

F5:主檔補 TablePrefix header(打包終檢 §10 schema drift 主路徑)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:07:39 +08:00

310 lines
9.9 KiB
PHP

<?php
/**
* HivePress family addon detector.
*
* Probes for the 13 known HivePress family plugins by checking the most
* stable `class_exists()` / `defined()` signal each addon publishes. Returns
* a `slug => version` map of detected addons.
*
* Detection happens during `TMDO_HivePress_Bootstrap::boot()` on
* `plugins_loaded:5`, AFTER WPDO core (priority 4) but BEFORE HivePress own
* boot (priority 8). The bootstrap then instantiates an adapter per detected
* addon and binds its lifecycle hooks.
*
* Result is cached in a 5-min transient (`wpdo_hivepress_detector_cache`) so
* repeated calls within a single page-load (e.g. admin tab + REST endpoint)
* don't re-probe. Cache busts on plugin activation/deactivation via the
* static `bust_cache()` method.
*
* Cost: when HivePress core is NOT installed, detector short-circuits after
* a single `class_exists('HivePress\\Core')` check — zero-cost fallback path
* for sites that don't use HivePress.
*
* @package TMDO
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Detector' ) ) {
/**
* Static detector for HivePress family addons.
*
* Stateless apart from the request-level memo cache; reset between tests
* via `reset_for_tests()`.
*/
final class TMDO_HivePress_Detector {
/** Transient key for the detection cache. */
private const CACHE_KEY = 'wpdo_hivepress_detector_cache';
/** Transient TTL (5 minutes — short enough to pick up plugin activation, long enough to avoid per-request cost). */
private const CACHE_TTL = 300;
/**
* Request-level memo cache. Survives only the current request.
*
* @var array<string,string>|null
*/
private static ?array $memo = null;
/**
* Detection probe table: slug => [class, version_const, name].
*
* Keep this list in sync with the HivePress family on wordpress.org.
* Detection prefers `class_exists()` over `defined()` because version
* constants sometimes leak to other globals; the class probe is more
* specific. Both signals are checked — either one suffices.
*
* For addons not yet installed locally (bookings, marketplace,
* statistics) the probe still runs — it just returns no match. The
* adapter file exists for future installs.
*
* @var array<string, array{class:string, const:string, name:string}>
*/
private const PROBES = array(
'hivepress' => array(
'class' => 'HivePress\\Core',
'const' => 'HIVEPRESS_VERSION',
'name' => 'HivePress',
),
'hivepress-blocks' => array(
'class' => 'HivePress\\Blocks\\Plugin',
'const' => 'HIVEPRESS_BLOCKS_VERSION',
'name' => 'HivePress Blocks',
),
'hivepress-bookings' => array(
'class' => 'HivePress\\Bookings\\Plugin',
'const' => 'HIVEPRESS_BOOKINGS_VERSION',
'name' => 'HivePress Bookings',
),
'hivepress-favorites' => array(
'class' => 'HivePress\\Favorites\\Plugin',
'const' => 'HIVEPRESS_FAVORITES_VERSION',
'name' => 'HivePress Favorites',
),
'hivepress-marketplace' => array(
'class' => 'HivePress\\Marketplace\\Plugin',
'const' => 'HIVEPRESS_MARKETPLACE_VERSION',
'name' => 'HivePress Marketplace',
),
'hivepress-memberships' => array(
'class' => 'HivePress\\Memberships\\Plugin',
'const' => 'HIVEPRESS_MEMBERSHIPS_VERSION',
'name' => 'HivePress Memberships',
),
'hivepress-messages' => array(
'class' => 'HivePress\\Messages\\Plugin',
'const' => 'HIVEPRESS_MESSAGES_VERSION',
'name' => 'HivePress Messages',
),
'hivepress-requests' => array(
'class' => 'HivePress\\Requests\\Plugin',
'const' => 'HIVEPRESS_REQUESTS_VERSION',
'name' => 'HivePress Requests',
),
'hivepress-reviews' => array(
'class' => 'HivePress\\Reviews\\Plugin',
'const' => 'HIVEPRESS_REVIEWS_VERSION',
'name' => 'HivePress Reviews',
),
'hivepress-seo' => array(
'class' => 'HivePress\\Seo\\Plugin',
'const' => 'HIVEPRESS_SEO_VERSION',
'name' => 'HivePress SEO',
),
'hivepress-social-links' => array(
'class' => 'HivePress\\SocialLinks\\Plugin',
'const' => 'HIVEPRESS_SOCIAL_LINKS_VERSION',
'name' => 'HivePress Social Links',
),
'hivepress-statistics' => array(
'class' => 'HivePress\\Statistics\\Plugin',
'const' => 'HIVEPRESS_STATISTICS_VERSION',
'name' => 'HivePress Statistics',
),
'hivepress-tags' => array(
'class' => 'HivePress\\Tags\\Plugin',
'const' => 'HIVEPRESS_TAGS_VERSION',
'name' => 'HivePress Tags',
),
);
/**
* Detect installed HivePress family addons.
*
* Returns map of slug → version-string. Empty array when HivePress
* core is not installed (zero-overhead short circuit for non-HP sites).
*
* @return array<string,string>
*/
public static function detect(): array {
if ( null !== self::$memo ) {
return self::$memo;
}
$cached = function_exists( 'get_transient' ) ? get_transient( self::CACHE_KEY ) : false;
if ( is_array( $cached ) ) {
self::$memo = $cached;
return $cached;
}
// Active-plugin set is the authoritative signal. HivePress addons
// register via the `hivepress/v1/extensions` filter and publish no
// per-addon class/const, so class_exists()/defined() alone only ever
// detects core. We treat "addon plugin is active" as a first-class
// detection signal alongside the class/const probe.
$active = self::active_plugin_files();
// Short-circuit: if HivePress core is not present, no addon can be loaded.
if ( ! self::probe_one( self::PROBES['hivepress'] ) && ! isset( $active['hivepress/hivepress.php'] ) ) {
self::$memo = array();
if ( function_exists( 'set_transient' ) ) {
set_transient( self::CACHE_KEY, self::$memo, self::CACHE_TTL );
}
return self::$memo;
}
$found = array();
foreach ( self::PROBES as $slug => $probe ) {
if ( self::probe_one( $probe ) || isset( $active[ $slug . '/' . $slug . '.php' ] ) ) {
$found[ $slug ] = self::version_for( $slug, $probe );
}
}
self::$memo = $found;
if ( function_exists( 'set_transient' ) ) {
set_transient( self::CACHE_KEY, $found, self::CACHE_TTL );
}
return $found;
}
/**
* Force re-detection on next call.
*
* Call from plugin activation / deactivation hooks if the listening
* code wants up-to-date detection state immediately.
*/
public static function bust_cache(): void {
self::$memo = null;
if ( function_exists( 'delete_transient' ) ) {
delete_transient( self::CACHE_KEY );
}
}
/**
* Reset internal state for unit tests.
*
* @internal
*/
public static function reset_for_tests(): void {
self::$memo = null;
}
/**
* Catalog of supported addons for UI display (slug => human-readable name).
*
* @return array<string,string>
*/
public static function catalog(): array {
$out = array();
foreach ( self::PROBES as $slug => $probe ) {
$out[ $slug ] = $probe['name'];
}
return $out;
}
/**
* Whether a given addon slug is in the supported catalog.
*
* @param string $slug Addon slug.
*/
public static function is_known( string $slug ): bool {
return isset( self::PROBES[ $slug ] );
}
// ── Internal probes ─────────────────────────────────────────────────
/**
* Active plugin file set (single-site + network), keyed by plugin file.
*
* Read straight from options so it works on `plugins_loaded` before
* `wp-admin/includes/plugin.php` (`is_plugin_active`) is loaded.
*
* @return array<string,bool> Map of "dir/file.php" => true.
*/
private static function active_plugin_files(): array {
$files = array();
$site = function_exists( 'get_option' ) ? get_option( 'active_plugins' ) : false;
if ( is_array( $site ) ) {
foreach ( $site as $f ) {
$files[ (string) $f ] = true;
}
}
$network = function_exists( 'get_site_option' ) ? get_site_option( 'active_sitewide_plugins' ) : false;
if ( is_array( $network ) ) {
foreach ( array_keys( $network ) as $f ) {
$files[ (string) $f ] = true;
}
}
return $files;
}
/**
* Probe a single addon. Either the class OR the const must exist.
*
* @param array{class:string, const:string, name:string} $probe Probe definition.
*/
private static function probe_one( array $probe ): bool {
$class = (string) ( $probe['class'] ?? '' );
$const = (string) ( $probe['const'] ?? '' );
if ( '' !== $class && class_exists( $class ) ) {
return true;
}
if ( '' !== $const && defined( $const ) ) {
return true;
}
return false;
}
/**
* Best-effort version extraction. Empty string when no version available.
*
* Prefers the addon's version const; falls back to the `Version:` header
* of the addon's main plugin file (HivePress addons publish no const).
*
* @param string $slug Addon slug (plugin dir name).
* @param array{class:string, const:string, name:string} $probe Probe definition.
*/
private static function version_for( string $slug, array $probe ): string {
$const = (string) ( $probe['const'] ?? '' );
if ( '' !== $const && defined( $const ) ) {
$value = constant( $const );
if ( is_string( $value ) || is_numeric( $value ) ) {
return (string) $value;
}
}
// Fallback: read the Version header from the addon's main plugin
// file. HivePress addons expose no version const, so this is the
// only version signal available for them.
if ( defined( 'WP_PLUGIN_DIR' ) && function_exists( 'get_file_data' ) ) {
$path = WP_PLUGIN_DIR . '/' . $slug . '/' . $slug . '.php';
if ( is_readable( $path ) ) {
$data = get_file_data( $path, array( 'Version' => 'Version' ) );
if ( ! empty( $data['Version'] ) ) {
return (string) $data['Version'];
}
}
}
return '';
}
}
} // end if ( ! class_exists )