commit b4400a68e5c2a2e7780a4ad662bca0f22d1b1389 Author: wpdev Date: Fri Jul 31 05:06:36 2026 +0800 chore: initial snapshot of 2meet-data-optimizer-hivepress-addon v0.1.0 Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ac74fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +vendor/ +node_modules/ +.phpunit.result.cache +.phpcs-cache +*.log +*.bak +*.swp +.DS_Store +Thumbs.db +.idea/ +.vscode/ +.full-review/ +.gstack/ +.playwright-mcp/ +.claude/ +dist/ diff --git a/2meet-data-optimizer-hivepress-addon.php b/2meet-data-optimizer-hivepress-addon.php new file mode 100644 index 0000000..5c81ba5 --- /dev/null +++ b/2meet-data-optimizer-hivepress-addon.php @@ -0,0 +1,37 @@ + 父外掛:`../2meet-data-optimizer/CLAUDE.md` +> 父環境:`/var/www/Studio/wp-local-dev30/CLAUDE.md` +> 全域:`~/.claude/CLAUDE.md`(含 Karpathy Guidelines) + +--- + +## Reset recovery + +1. 讀本檔 +2. 讀 [PLAN.md](PLAN.md) 看 Phase 3 進度 +3. 接續下一個未完成步驟 + +--- + +## Plugin 概述 + +**2meet-data-optimizer-hivepress-addon** 是 2meet-data-optimizer 的 Hivepress 整合 AddOn。 + +- **目標外掛**: `hivepress` +- **Phase**: 3 +- **預估行數**: ~7400 +- **Slug 短碼**: `hivepress` + +簡述:HivePress + 12 擴充整合:13 adapter + 7 interceptor + 5 query interceptor + bootstrap + admin + CLI + +完整拆分計畫見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md`。 + +--- + +## 邊界 + +- ✅ 本 AddOn 觸碰:hivepress 相關 schema 註冊(postmeta zone fields / custom tables / entity groups)+ 可選 admin / CLI +- ❌ 本 AddOn **不**觸碰:2meet-data-optimizer 核心、其他 AddOn、目標外掛 runtime 邏輯 +- ❌ 本 AddOn **不**繞過:Hook Bus / TMDO_API(必須走公開合約) + +--- + +## 命名前綴 + +- 類別:`TMDO_Hivepress_*` +- 函式:`tmdo_hivepress_*` +- 常數:`TMDO_HIVEPRESS_VERSION` / `TMDO_HIVEPRESS_PATH` / `TMDO_HIVEPRESS_URL` / `TMDO_HIVEPRESS_FILE` +- Text-domain:`tmdo-hivepress` + +--- + +## 公開合約消費(從核心) + +```php +// 安全消費 API(必加 class_exists guard) +if ( class_exists( 'TMDO_API' ) ) { + TMDO_API::set_entity( 'user', $id, 'key', $value ); +} + +// Schema 註冊 +add_action( 'tmdo_register_entity_fields', function ( $registry_class ) { + $registry_class::register_group( '...', '...', [ ... ] ); +} ); + +add_action( 'tmdo_register_custom_tables', function ( $registry ) { + $registry->register( 'hivepress-addon', [ ... ] ); +} ); +``` + +--- + +## Phase 進度 + +``` +Phase 0 ✅ 骨架建立 (2026-05-15) +Phase 3 ⬜ Implementation v0.1.0 +``` diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..914b8c9 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,43 @@ +# 部署流程 + +## Prerequisites + +1. `2meet-data-optimizer` 核心 ≥ 0.1.0 已安裝並啟用 +2. `hivepress` 已安裝並啟用 + +> 本 AddOn 透過 WordPress `Requires Plugins:` 強制相依,先決條件未滿足時無法啟用。 + +--- + +## Steps + +```bash +# 上傳 + 啟用 +wp plugin install /path/to/2meet-data-optimizer-hivepress-addon-v0.1.0.zip --activate + +# 驗證 +wp tmdo doctor --provider=hivepress-addon +wp tmdo custom-tables --provider=hivepress-addon +``` + +--- + +## Rollback + +```bash +wp plugin deactivate 2meet-data-optimizer-hivepress-addon +# 目標外掛 (hivepress) 仍可正常運作, +# 讀寫 fall back 到 native meta(失去 zone 加速但功能不受影響) +``` + +--- + +## 整體部署順序建議 + +依 Phase 順序: +1. `2meet-data-optimizer` 核心 +2. Phase 2: `2meet-data-optimizer-hub-addon`、`2meet-data-optimizer-spoke-addon` +3. Phase 3: HivePress / WooCommerce / LatePoint AddOn +4. Phase 4: 6 個 2meet 家族 AddOn + +詳見核心外掛 [DEPLOY.md](../2meet-data-optimizer/DEPLOY.md)。 diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..9a2092a --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,44 @@ +# DESIGN — 2meet Data Optimizer Hivepress AddOn + +> 父外掛架構決策見 [`../2meet-data-optimizer/DESIGN.md`](../2meet-data-optimizer/DESIGN.md) + +--- + +## 角色定位 + +本 AddOn 是 `2meet-data-optimizer` 的整合層,負責將 `hivepress` 的 EAV 模式(postmeta / 自訂表)橋接到核心反 EAV 引擎。 + +- **不**修改 `hivepress` 本身 +- **不**繞過 `2meet-data-optimizer` 核心 +- 只透過公開合約(`TMDO_API`、`tmdo_register_*` hook)參與 + +--- + +## 啟動順序 + +``` +plugins_loaded:4 2meet-data-optimizer (core) 啟動 +plugins_loaded:5-6 本 AddOn bootstrap → 註冊 hook callback +plugins_loaded:6+ hivepress 本體啟動 +init:0 核心 fire tmdo_register_* → 本 AddOn 的 callback 執行 +``` + +詳見 plan 文件第 3 章「載入順序」。 + +--- + +## 為什麼是獨立 AddOn 而非塞進核心 + +- 核心 `2meet-data-optimizer` 對外承諾「零依存任何業務外掛」 +- 整合層發版節奏應跟隨 `hivepress` 而非核心 +- 站台不用 `hivepress` 時不需被迫載入這段程式碼 + +--- + +## 邊界檢查清單 + +- [ ] 不直接 `SELECT FROM wp_*meta` +- [ ] 不繞過 Hook Bus +- [ ] 所有 SQL 走 `$wpdb->prepare()` +- [ ] DB 表名走 `TMDO_DB::table()` +- [ ] 對外只 expose `TMDO_Hivepress_*` 命名空間 diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..056df9e --- /dev/null +++ b/PLAN.md @@ -0,0 +1,43 @@ +# PLAN — 2meet Data Optimizer Hivepress AddOn + +> 完整四階段拆分計畫見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md` (本 AddOn 屬 **Phase 3**) + +--- + +## Current state + +- **Version**: 0.1.0 (in development, scaffold complete 2026-05-15) +- **Phase**: Phase 0 → Phase 3 pending +- **Source**: 從 `wp-data-optimizer v2.16.0` 提煉 +- **預估行數**: ~7400 行 +- **目標外掛**: `hivepress` + +--- + +## Phase 0 ✅ — 骨架建立 (2026-05-15) + +- [x] 目錄結構 +- [x] Plugin Header +- [x] Bootstrap stub +- [x] 7 件 MD +- [x] composer.json / .gitignore / uninstall.php + +--- + +## Phase 3 ⬜ — Implementation v0.1.0 + +詳細任務見上層 plan 第 5 章。簡述: + +- [ ] 從 wp-data-optimizer 對應檔案搬入 includes/ + - [ ] WPDO_ → TMDO_Hivepress_ 重命名 + - [ ] 移除對 wp-data-optimizer 內部 class 的依存(改走 TMDO_API 公開合約) +- [ ] 完善 bootstrap:實作 init() 載入順序 +- [ ] 補完 7 件 MD 的細節 +- [ ] 跑 `scripts/package-plugin.sh 2meet-data-optimizer-hivepress-addon` 通過 10 終檢 +- [ ] dev30 e2e + +--- + +## Lessons learned + +- (Phase 3 起記錄) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0cce256 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# 2meet Data Optimizer — HivePress AddOn + +> 為 **HivePress** + 12 個官方擴充提供完整反 EAV 整合(最大 AddOn,~7,400 行)。 + +--- + +## What it does + +從 `wp-data-optimizer v2.16.0` 提煉的 HivePress 整合層,包含 13 個 adapter、7 個 HPCT 攔截器、5 個查詢攔截器、bootstrap、admin tab、CLI namespace。 + +### 13 HP Adapter 全清單 + +| Adapter | 對應 HP 擴充 | 行數 | +|---|---|---:| +| Core | hivepress | 407 | +| Reviews | hivepress-reviews | 146 | +| Bookings | hivepress-bookings | 159 | +| Messages | hivepress-messages | 263 | +| Memberships | hivepress-memberships | 169 | +| Requests | hivepress-requests | 267 | +| Favorites | hivepress-favorites | 234 | +| Statistics | hivepress-statistics | 130 | +| Tags | hivepress-tags | 84 | +| SEO | hivepress-seo | 76 | +| Social Links | hivepress-social-links | 115 | +| Blocks | hivepress-blocks | 73 | +| Marketplace | hivepress-marketplace | 135 | + +### 7 HPCT Interceptor + +reviews / messages / favorites / memberships / requests / statistics / listing-meta + +### 5 Query Interceptor (pre_get_posts 改寫) + +reviews / messages / memberships / requests / listing-meta + +### 額外組件 + +- HP transient filter(`_transient_hp_*` postmeta → wp_options 路由,避免 postmeta 脹大) +- HPCT (HP Custom Tables) import 工具(v2.0.0 取代 HPCT 之相容路徑) +- HP attribute bridge / cron optimizer / suitability scorer / benchmark / REST +- HivePress comment router + +--- + +## Requirements + +- WordPress ≥ 6.0 +- PHP ≥ 8.1 +- 2meet-data-optimizer ≥ 0.1.0 +- hivepress ≥ 1.7.22 (支援 `_alias` / `_external` field convention) + +--- + +## Installation + +```bash +# 啟用前確認 HP 已啟用 +wp plugin list | grep hivepress + +# 安裝 + 啟用 +wp plugin install /path/to/2meet-data-optimizer-hivepress-addon-v0.1.0.zip --activate + +# 驗證 13 adapter 全 bound +wp tmdo hivepress detect + +# Benchmark vs HP 預設 +wp tmdo hivepress benchmark --post-type=hp_listing --samples=200 +``` + +--- + +## 為什麼選擇單一 AddOn(不拆 13 sub-AddOn) + +| 維度 | 單一 AddOn ✓ | 13 sub-AddOn ✗ | +|---|---|---| +| 啟動開銷 | 一次偵測 | 13 次重複偵測 | +| 依存管理 | 單一 ZIP | WP 依存解析複雜 | +| Feature flags | 統一管理 | 跨 sub-AddOn 同步 | +| UX | 一鍵啟用 | 分次啟用 | + +--- + +## CLI + +```bash +wp tmdo hivepress detect +wp tmdo hivepress benchmark +wp tmdo hivepress import-hpct --dry-run +wp tmdo hivepress score # 8 維 anti-EAV 評分 +wp tmdo hivepress doctor +``` + +--- + +## Phase + +Phase 3 — 與 [WooCommerce AddOn](../2meet-data-optimizer-woocommerce-addon/) 與 [LatePoint AddOn](../2meet-data-optimizer-latepoint-addon/) 同期 ship。風險最高的 phase。 + +--- + +## License + +GPL-2.0-or-later diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2429d22 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +|---|:-:| +| 0.1.x | ✅ | + +--- + +## Reporting a vulnerability + +請參考核心外掛 [SECURITY.md](../2meet-data-optimizer/SECURITY.md)。 + +簡述: +- Email: security@2meet.io +- 請勿在 public GitHub issue 揭露 +- 24h 內初步回應、7 天內提供修補時程 + +--- + +## 安全設計 + +本 AddOn 完全遵守 2meet-data-optimizer 核心安全設計: + +- SQL prepared statements +- Capability check (`manage_options`) +- Nonce 驗證 +- Output escaping +- 反序列化走 `TMDO_Safe_Unserialize` + +--- + +## 已知 Risk + +- (本版本暫無已知安全 risk;Phase 3 ship 時補) diff --git a/admin/class-tmdo-admin-hivepress.php b/admin/class-tmdo-admin-hivepress.php new file mode 100644 index 0000000..462a725 --- /dev/null +++ b/admin/class-tmdo-admin-hivepress.php @@ -0,0 +1,164 @@ +`. The admin tab links to the CLI in its + * footer (Karpathy: blast-radius-aware UI design). + * + * Plugged into `TMDO_Admin::render_page()` switch via `'hivepress' => + * 'HivePress 整合'` tab entry. + * + * @package WP_Data_Optimizer + * @since 3.0.0 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +if ( ! class_exists( 'TMDO_Admin_HivePress' ) ) { + + /** + * Static renderer for the HivePress admin tab. + */ + final class TMDO_Admin_HivePress { + + /** + * Render the tab body. + * + * @return void + */ + public static function render(): void { + $detected = class_exists( 'TMDO_HivePress_Detector' ) + ? TMDO_HivePress_Detector::detect() + : array(); + $catalog = class_exists( 'TMDO_HivePress_Detector' ) + ? TMDO_HivePress_Detector::catalog() + : array(); + $bound = class_exists( 'TMDO_HivePress_Bootstrap' ) + ? array_keys( TMDO_HivePress_Bootstrap::adapters() ) + : array(); + $conflicts = class_exists( 'TMDO_HivePress_Conflict_Guard' ) + ? TMDO_HivePress_Conflict_Guard::detect_conflicts() + : array(); + $report = class_exists( 'TMDO_HivePress_Suitability_Scorer' ) + ? TMDO_HivePress_Suitability_Scorer::report() + : array( + 'aggregate' => 0.0, + 'adapter_count' => 0, + 'adapters' => array(), + 'per_dimension' => array(), + ); + + ?> +
+

+ + + +

+ + + + + + + + + + + + + $name ) : ?> + + + + + + + + + + + +
+ +

+

+ +

+ + + +

+

+
wp wpdo hivepress detect
+wp wpdo hivepress score
+wp wpdo hivepress doctor
+wp wpdo hivepress migrate <addon-slug>
+wp wpdo hivepress rollback <addon-slug>
+wp wpdo hivepress benchmark
+
+ $conflicts Conflicting plugin slugs. + */ + private static function render_conflict_section( array $conflicts ): void { + if ( empty( $conflicts ) ) { + echo '

' + . esc_html__( 'Conflict guard: clean — hp-custom-tables / hp-info-cards 未啟用。', 'tmdo-hivepress' ) + . '

'; + return; + } + echo '

' + . esc_html__( '偵測到衝突外掛:', 'tmdo-hivepress' ) . ' ' + . esc_html( implode( ', ', $conflicts ) ) + . '

' + . esc_html__( 'WPDO HivePress integration 已自動退讓。請執行 `wp wpdo hivepress migrate-from-hpct` 後停用舊外掛。', 'tmdo-hivepress' ) + . '

'; + } + + /** + * Render per-dimension averages table. + * + * @param array $dimensions Dimension key → average. + */ + private static function render_dimension_table( array $dimensions ): void { + if ( empty( $dimensions ) ) { + return; + } + echo ''; + foreach ( $dimensions as $key => $avg ) { + echo ''; + } + echo '
' + . esc_html__( '維度', 'tmdo-hivepress' ) . '' + . esc_html__( '平均', 'tmdo-hivepress' ) . '
' . esc_html( $key ) . '' . esc_html( (string) $avg ) . '
'; + } + } + +} // end if ( ! class_exists ) diff --git a/cli/class-tmdo-cli-hivepress.php b/cli/class-tmdo-cli-hivepress.php new file mode 100644 index 0000000..9222cef --- /dev/null +++ b/cli/class-tmdo-cli-hivepress.php @@ -0,0 +1,343 @@ +` namespace handler. + */ + class TMDO_CLI_HivePress { + + /** + * List installed HivePress family addons + adapter binding state. + * + * ## OPTIONS + * + * [--format=] + * : Output format (table|json|csv|yaml). Default: table. + * + * ## EXAMPLES + * + * wp wpdo hivepress detect + * wp wpdo hivepress detect --format=json + * + * @param array $args Positional args (unused). + * @param array $assoc_args Flag args. + * + * @subcommand detect + */ + public function detect( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + unset( $args ); + $format = (string) ( $assoc_args['format'] ?? 'table' ); + $detected = class_exists( 'TMDO_HivePress_Detector' ) + ? TMDO_HivePress_Detector::detect() + : array(); + $catalog = class_exists( 'TMDO_HivePress_Detector' ) + ? TMDO_HivePress_Detector::catalog() + : array(); + $bound = class_exists( 'TMDO_HivePress_Bootstrap' ) + ? array_keys( TMDO_HivePress_Bootstrap::adapters() ) + : array(); + + $rows = array(); + foreach ( $catalog as $slug => $name ) { + $rows[] = array( + 'slug' => $slug, + 'name' => $name, + 'detected' => isset( $detected[ $slug ] ) ? 'yes' : 'no', + 'version' => $detected[ $slug ] ?? '-', + 'bound' => in_array( $slug, $bound, true ) ? 'yes' : 'no', + ); + } + + if ( ! class_exists( 'WP_CLI\\Utils' ) || 'table' === $format ) { + WP_CLI::log( '=== HivePress family detection ===' ); + WP_CLI::log( '' ); + WP_CLI::log( str_pad( 'slug', 26 ) . str_pad( 'detected', 12 ) . str_pad( 'version', 10 ) . 'bound' ); + WP_CLI::log( str_repeat( '-', 60 ) ); + foreach ( $rows as $row ) { + WP_CLI::log( str_pad( $row['slug'], 26 ) . str_pad( $row['detected'], 12 ) . str_pad( $row['version'], 10 ) . $row['bound'] ); + } + WP_CLI::log( '' ); + WP_CLI::success( sprintf( 'Detected %d / %d addons', count( $detected ), count( $catalog ) ) ); + return; + } + + \WP_CLI\Utils\format_items( $format, $rows, array( 'slug', 'name', 'detected', 'version', 'bound' ) ); + } + + /** + * Show 8-dimension suitability report (per-adapter + aggregate). + * + * ## OPTIONS + * + * [--addon=] + * : Filter to a single addon slug. + * + * [--format=] + * : Output format (table|json|yaml). Default: table. + * + * ## EXAMPLES + * + * wp wpdo hivepress score + * wp wpdo hivepress score --addon=hivepress-reviews + * wp wpdo hivepress score --format=json + * + * @param array $args Positional args (unused). + * @param array $assoc_args Flag args. + * + * @subcommand score + */ + public function score( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + unset( $args ); + if ( ! class_exists( 'TMDO_HivePress_Suitability_Scorer' ) ) { + WP_CLI::error( 'TMDO_HivePress_Suitability_Scorer not loaded.' ); + } + $report = TMDO_HivePress_Suitability_Scorer::report(); + $addon = isset( $assoc_args['addon'] ) ? (string) $assoc_args['addon'] : ''; + $format = (string) ( $assoc_args['format'] ?? 'table' ); + + if ( '' !== $addon ) { + if ( ! isset( $report['adapters'][ $addon ] ) ) { + WP_CLI::error( sprintf( 'Addon "%s" not active.', $addon ) ); + } + $score = $report['adapters'][ $addon ]; + if ( 'json' === $format || 'yaml' === $format ) { + \WP_CLI\Utils\format_items( $format, array( $score ), array_keys( $score ) ); + return; + } + WP_CLI::log( sprintf( '=== Score: %s ===', $addon ) ); + foreach ( $score as $k => $v ) { + WP_CLI::log( sprintf( ' %s: %s', str_pad( $k, 22 ), $v ) ); + } + return; + } + + if ( 'json' === $format || 'yaml' === $format ) { + \WP_CLI\Utils\format_items( $format, array( $report ), array( 'aggregate', 'adapter_count', 'per_dimension', 'adapters' ) ); + return; + } + + WP_CLI::log( '=== HivePress 8-D Suitability Report ===' ); + WP_CLI::log( '' ); + WP_CLI::log( sprintf( 'Active adapters: %d', $report['adapter_count'] ) ); + WP_CLI::log( sprintf( 'Aggregate score: %s / 10', $report['aggregate'] ) ); + WP_CLI::log( '' ); + WP_CLI::log( 'Per-dimension averages:' ); + foreach ( $report['per_dimension'] as $dim => $avg ) { + WP_CLI::log( sprintf( ' %s: %s', str_pad( $dim, 22 ), $avg ) ); + } + WP_CLI::log( '' ); + WP_CLI::log( 'Per-adapter aggregate:' ); + foreach ( $report['adapters'] as $slug => $score ) { + WP_CLI::log( sprintf( ' %s → %s', str_pad( $slug, 26 ), $score['aggregate'] ?? '-' ) ); + } + } + + /** + * Run doctor probe for each active adapter. + * + * ## OPTIONS + * + * [--addon=] + * : Limit probe to a single addon. + * + * ## EXAMPLES + * + * wp wpdo hivepress doctor + * wp wpdo hivepress doctor --addon=hivepress-favorites + * + * @param array $args Positional args (unused). + * @param array $assoc_args Flag args. + * + * @subcommand doctor + */ + public function doctor( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + unset( $args ); + $adapters = class_exists( 'TMDO_HivePress_Bootstrap' ) + ? TMDO_HivePress_Bootstrap::adapters() + : array(); + $only = isset( $assoc_args['addon'] ) ? (string) $assoc_args['addon'] : ''; + + $failed = 0; + $total = 0; + foreach ( $adapters as $slug => $adapter ) { + if ( '' !== $only && $slug !== $only ) { + continue; + } + if ( ! $adapter instanceof TMDO_HivePress_Adapter ) { + continue; + } + ++$total; + $probe = $adapter->doctor_check(); + if ( ! empty( $probe['ok'] ) ) { + WP_CLI::log( sprintf( '✓ %s: %s', $slug, $probe['message'] ?? 'OK' ) ); + } else { + WP_CLI::log( sprintf( '✗ %s: %s', $slug, $probe['message'] ?? 'FAIL' ) ); + ++$failed; + } + } + + if ( 0 === $total ) { + WP_CLI::warning( 'No adapters active. Install HivePress first.' ); + return; + } + if ( $failed > 0 ) { + WP_CLI::error( sprintf( 'Doctor: %d / %d adapters failed.', $failed, $total ) ); + } + WP_CLI::success( sprintf( 'Doctor: %d / %d adapters healthy.', $total, $total ) ); + } + + /** + * Flip an addon's modules to a target FSM state (default: cutover). + * + * ## OPTIONS + * + * + * : Addon slug (e.g. hivepress-reviews). + * + * [--state=] + * : Target state. One of: dual_write, backfill, verify, cutover, cleanup, complete. Default: cutover. + * + * ## EXAMPLES + * + * wp wpdo hivepress migrate hivepress-reviews + * wp wpdo hivepress migrate hivepress-favorites --state=dual_write + * + * @param array $args Positional args (slug). + * @param array $assoc_args Flag args. + * + * @subcommand migrate + */ + public function migrate( $args, $assoc_args ): void { + $slug = (string) ( $args[0] ?? '' ); + $state = (string) ( $assoc_args['state'] ?? 'cutover' ); + $this->set_state( $slug, $state ); + } + + /** + * Roll an addon's modules back to idle. + * + * ## OPTIONS + * + * + * : Addon slug. + * + * ## EXAMPLES + * + * wp wpdo hivepress rollback hivepress-reviews + * + * @param array $args Positional args (slug). + * @param array $assoc_args Flag args (unused). + * + * @subcommand rollback + */ + public function rollback( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + unset( $assoc_args ); + $slug = (string) ( $args[0] ?? '' ); + $this->set_state( $slug, 'idle' ); + } + + /** + * Run benchmark suite for active adapters. + * + * Currently a placeholder that reports availability — real query-pair + * benchmarks are wired by individual addons during their migration + * tooling. This command lists which adapters can be benchmarked. + * + * ## EXAMPLES + * + * wp wpdo hivepress benchmark + * + * @param array $args Positional args (unused). + * @param array $assoc_args Flag args (unused). + * + * @subcommand benchmark + */ + public function benchmark( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + unset( $args, $assoc_args ); + if ( ! class_exists( 'TMDO_HivePress_Benchmark' ) ) { + WP_CLI::error( 'TMDO_HivePress_Benchmark not loaded.' ); + } + $adapters = class_exists( 'TMDO_HivePress_Bootstrap' ) + ? TMDO_HivePress_Bootstrap::adapters() + : array(); + + if ( empty( $adapters ) ) { + WP_CLI::warning( 'No adapters active — no benchmark to run.' ); + return; + } + + WP_CLI::log( '=== HivePress benchmark availability ===' ); + foreach ( $adapters as $slug => $adapter ) { + $mods = $adapter instanceof TMDO_HivePress_Adapter ? $adapter->migrations() : array(); + if ( empty( $mods ) ) { + WP_CLI::log( sprintf( ' %s: no FSM modules', $slug ) ); + continue; + } + WP_CLI::log( sprintf( ' %s: %s', $slug, implode( ', ', $mods ) ) ); + } + WP_CLI::log( '' ); + WP_CLI::log( 'Run `wp wpdo benchmark ` for per-module timings.' ); + } + + // ── Internal helpers ──────────────────────────────────────────────── + + /** + * Apply a target FSM state to all modules owned by an addon's adapter. + * + * @param string $slug Addon slug. + * @param string $state Target state. + */ + private function set_state( string $slug, string $state ): void { + if ( '' === $slug ) { + WP_CLI::error( 'Missing addon slug.' ); + } + if ( ! in_array( $state, TMDO_Feature_Flags::VALID_STATES, true ) ) { + WP_CLI::error( sprintf( 'Invalid state "%s". Valid: %s', $state, implode( ', ', TMDO_Feature_Flags::VALID_STATES ) ) ); + } + + $adapter = class_exists( 'TMDO_HivePress_Bootstrap' ) + ? TMDO_HivePress_Bootstrap::adapter_for( $slug ) + : null; + if ( ! $adapter instanceof TMDO_HivePress_Adapter ) { + WP_CLI::error( sprintf( 'Adapter "%s" not active.', $slug ) ); + } + + $modules = $adapter->migrations(); + if ( empty( $modules ) ) { + WP_CLI::warning( sprintf( 'Adapter "%s" has no FSM modules.', $slug ) ); + return; + } + + foreach ( $modules as $module ) { + TMDO_Feature_Flags::set( (string) $module, $state ); + WP_CLI::log( sprintf( ' %s → %s', $module, $state ) ); + } + WP_CLI::success( sprintf( 'Adapter "%s" modules set to %s.', $slug, $state ) ); + } + } + +} // end if ( ! class_exists ) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..da79ace --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "2meet/2meet-data-optimizer-hivepress-addon", + "description": "HivePress + 12 擴充整合:13 adapter + 7 interceptor + 5 query interceptor + bootstrap + admin + CLI", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "keywords": ["wordpress", "2meet-data-optimizer", "addon", "hivepress"], + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "wp-coding-standards/wpcs": "^3.0" + }, + "scripts": { + "phpcs": "phpcs --standard=WordPress includes/ 2meet-data-optimizer-hivepress-addon.php" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..3bccac7 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2106 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "2cfda1cbb3d53af75fbe9ac5baa798a1", + "packages": [], + "packages-dev": [ + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpcsstandards/phpcsextra", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", + "reference": "b598aa890815b8df16363271b659d73280129101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", + "reference": "b598aa890815b8df16363271b659d73280129101", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.2.0", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "phpcsstandards/phpcsdevtools": "^1.2.1", + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + } + ], + "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSExtra" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-12T23:06:57+00:00" + }, + { + "name": "phpcsstandards/phpcsutils", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "ext-filter": "*", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "classmap": [ + "PHPCSUtils/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + } + ], + "description": "A suite of utility functions for use with PHP_CodeSniffer", + "homepage": "https://phpcsutils.com/", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "phpcs3", + "phpcs4", + "standards", + "static analysis", + "tokens", + "utility" + ], + "support": { + "docs": "https://phpcsutils.com/", + "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSUtils" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-12-08T14:27:58+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.63", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "33198268dad71e926626b618f3ec3966661e4d90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", + "reference": "33198268dad71e926626b618f3ec3966661e4d90", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-01-27T05:48:37+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:50:56+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-04T16:30:35+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-libxml": "*", + "ext-tokenizer": "*", + "ext-xmlreader": "*", + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.0", + "phpcsstandards/phpcsutils": "^1.1.0", + "squizlabs/php_codesniffer": "^3.13.4" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "phpcsstandards/phpcsdevtools": "^1.2.0", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "suggest": { + "ext-iconv": "For improved results", + "ext-mbstring": "For improved results" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", + "keywords": [ + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues", + "source": "https://github.com/WordPress/WordPress-Coding-Standards", + "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/php_codesniffer", + "type": "custom" + } + ], + "time": "2025-11-25T12:08:04+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/includes/class-tmdo-hivepress-bootstrap.php b/includes/class-tmdo-hivepress-bootstrap.php new file mode 100644 index 0000000..ca1a136 --- /dev/null +++ b/includes/class-tmdo-hivepress-bootstrap.php @@ -0,0 +1,177 @@ +register_hooks(); + } + } + + // 5 query interceptors + foreach ( array( + 'TMDO_Reviews_Query', + 'TMDO_Messages_Query', + 'TMDO_Memberships_Query', + 'TMDO_Requests_Query', + 'TMDO_Listing_Meta_Query', + ) as $cls ) { + if ( class_exists( $cls ) ) { + ( new $cls() )->register_hooks(); + } + } + + // HP transient filter (init:5) + if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) { + add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 ); + } + + // Listing Stats hooks + cron + if ( class_exists( 'TMDO_Listing_Stats' ) ) { + TMDO_Listing_Stats::register_hooks(); + TMDO_Listing_Stats::schedule_cron(); + } + + // Term/Comment fields + if ( class_exists( 'TMDO_Hivepress_Term_Comment_Fields' ) + && method_exists( 'TMDO_Hivepress_Term_Comment_Fields', 'register' ) ) { + TMDO_Hivepress_Term_Comment_Fields::register(); + } + + // Admin tab + if ( is_admin() ) { + $admin_file = TMDO_HIVEPRESS_PATH . 'admin/class-tmdo-admin-hivepress.php'; + if ( file_exists( $admin_file ) ) { + require_once $admin_file; + } + } + + // CLI namespace + if ( defined( 'WP_CLI' ) && WP_CLI ) { + $cli_file = TMDO_HIVEPRESS_PATH . 'cli/class-tmdo-cli-hivepress.php'; + if ( file_exists( $cli_file ) ) { + require_once $cli_file; + if ( class_exists( 'TMDO_CLI_HivePress' ) ) { + \WP_CLI::add_command( 'tmdo hivepress', 'TMDO_CLI_HivePress' ); + \WP_CLI::add_command( 'wpdo hivepress', 'TMDO_CLI_HivePress' ); + } + } + } + } + + public static function missing_core_notice(): void { + echo '

'; + echo esc_html__( '2meet Data Optimizer HivePress AddOn 需要 2meet-data-optimizer 核心外掛,請先安裝並啟用。', 'tmdo-hivepress' ); + echo '

'; + } +} diff --git a/includes/class-tmdo-hivepress-term-comment-fields.php b/includes/class-tmdo-hivepress-term-comment-fields.php new file mode 100644 index 0000000..55793da --- /dev/null +++ b/includes/class-tmdo-hivepress-term-comment-fields.php @@ -0,0 +1,133 @@ + 'hp_sort_order', + 'type' => 'integer', + 'searchable' => true, + 'label' => 'HivePress term sort order (used by listing facets)', + ), + array( + 'key' => 'hp_default', + 'type' => 'enum', + 'options' => array( '0', '1' ), + 'searchable' => true, + 'label' => 'HivePress default flag (1 = is the default term)', + ), + array( + 'key' => 'hp_icon', + 'type' => 'text', + 'label' => 'HivePress term icon class (e.g. fa-star)', + ), + ) + ); + } + + /** + * HivePress comment review meta (review type comments left on hp_listing). + * + * Currently single-key; deliberately register as its own group so future + * review-related keys (hp_review_*, hp_review_visibility, etc.) can be + * added without splitting the schema. + * + * @return void + */ + private static function register_comment_hp_review_group(): void { + TMDO_Entity_Registry::register_group( + 'comment', + 'hp_review', + array( + array( + 'key' => 'hp_rating', + 'type' => 'integer', + 'searchable' => true, + 'label' => 'HivePress 1-5 star rating attached to a review comment', + ), + ) + ); + } +} diff --git a/includes/class-tmdo-hivepress-transient-filter.php b/includes/class-tmdo-hivepress-transient-filter.php new file mode 100644 index 0000000..92c9fec --- /dev/null +++ b/includes/class-tmdo-hivepress-transient-filter.php @@ -0,0 +1,307 @@ +` and + * `_transient_timeout_` rows in *the entity's metadata table* + * (wp_postmeta / wp_usermeta / wp_termmeta / wp_commentmeta) — calling + * `update_post_meta($post_id, '_transient_…', $value)` directly. + * + * This is a deliberate HivePress design choice (cache invalidation tied to + * the entity's lifecycle), but it produces 8–16 wp_postmeta rows per + * hp_listing every save_post — completely defeating the visible benefit of + * post-entity reverse-EAV (mode=aeav_only successfully short-circuits the 7 + * registered entity keys, but HivePress still bloats wp_postmeta with + * transient cache rows that look identical to "1:16 not optimized" externally). + * + * Strategy + * -------- + * We register four metadata filters that intercept any `_transient_hp_*` / + * `_transient_timeout_hp_*` post meta read/write/delete and re-route the + * call to wp_options via the native `set_transient` storage layout. HivePress + * remains oblivious — its standard `update_post_meta` / `get_post_meta` / + * `delete_post_meta` calls work transparently — but the actual storage moves + * out of wp_postmeta entirely. + * + * Translation rule + * ---------------- + * wp_postmeta(post_id, '_transient_hp_models/cat/v1', $val) + * → wp_options('_transient_', $val) + * + * wp_postmeta(post_id, '_transient_timeout_hp_models/cat/v1', $exp) + * → wp_options('_transient_timeout_', $exp) + * + * where HASH = "wpdo_hp_pm_{$post_id}_" . md5($stripped_key) — fits within + * the 172-char wp_options.option_name index, namespaces by post_id (so two + * listings caching different models never collide), and is deterministic so + * read/write/delete all hit the same row. + * + * Scope (v2.11.5 launch) + * ---------------------- + * ✅ wp_postmeta `_transient_hp_*` (HivePress is the dominant offender) + * ❌ wp_usermeta / wp_termmeta / wp_commentmeta (deferred — measure first) + * ❌ Other plugins' `_transient_*` postmeta (deliberately excluded for safety) + * + * Toggle + * ------ + * Option `wpdo_hp_transient_filter_enabled` (default true). Disable via: + * `wp option update wpdo_hp_transient_filter_enabled 0` + * + * @package WP_Data_Optimizer + * @since 2.11.5 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Routes HivePress per-post TTL cache out of wp_postmeta into wp_options. + */ +final class TMDO_Hivepress_Transient_Filter { + + /** Option toggle key. */ + public const OPT_ENABLED = 'wpdo_hp_transient_filter_enabled'; + + /** Prefix that identifies a HivePress meta-cache value row. */ + public const PREFIX_VALUE = '_transient_hp_'; + + /** Prefix that identifies a HivePress meta-cache timeout row. */ + public const PREFIX_TIMEOUT = '_transient_timeout_hp_'; + + /** Namespace prefix for translated wp_options entries. */ + public const TRANSLATED_NAMESPACE = 'wpdo_hp_pm_'; + + /** + * Register the four metadata filters on `init`. Idempotent — safe to call + * multiple times. + * + * @return void + */ + public static function init(): void { + if ( ! self::is_enabled() ) { + return; + } + + // Filter priority 9 — runs *before* WPDO Hook Bus (priority 10) so we + // strip transient keys from the meta path before Hook Bus attempts to + // route them through entity registry (it wouldn't find a match anyway, + // but skipping the lookup is a tiny perf win). + add_filter( 'get_post_metadata', array( self::class, 'on_read' ), 9, 4 ); + add_filter( 'add_post_metadata', array( self::class, 'on_add' ), 9, 5 ); + add_filter( 'update_post_metadata', array( self::class, 'on_update' ), 9, 5 ); + add_filter( 'delete_post_metadata', array( self::class, 'on_delete' ), 9, 5 ); + } + + /** + * Check the admin toggle. Defaults to enabled. + * + * @return bool + */ + public static function is_enabled(): bool { + return (bool) get_option( self::OPT_ENABLED, '1' ); + } + + /** + * Test whether a meta_key is a HivePress transient pair (value or timeout). + * + * @param mixed $meta_key Candidate meta_key (may be non-string from filter). + * @return bool + */ + public static function is_target_key( $meta_key ): bool { + if ( ! is_string( $meta_key ) ) { + return false; + } + return str_starts_with( $meta_key, self::PREFIX_VALUE ) + || str_starts_with( $meta_key, self::PREFIX_TIMEOUT ); + } + + /** + * Translate (post_id, meta_key) → namespaced wp_options option_name root. + * + * The original meta_key is hashed (md5) so very long HivePress cache keys + * (`_transient_hp_models/term/listing_availability/`) still + * fit within wp_options.option_name index width. + * + * @param int $post_id Post ID owning the cache. + * @param string $meta_key Original `_transient_*` meta_key. + * @return string Translated option_name root (without `_transient_` prefix). + */ + public static function translate_key( int $post_id, string $meta_key ): string { + // Strip `_transient_timeout_` or `_transient_` prefix to get the bare cache name. + $stripped = preg_replace( '/^_transient_(timeout_)?/', '', $meta_key ); + return self::TRANSLATED_NAMESPACE . $post_id . '_' . md5( (string) $stripped ); + } + + /** + * Filter callback: get_post_metadata. + * + * Returns null → continue normal flow (DB query); returns array → WP + * unwraps `[0]` for `$single=true` callers, or returns array as-is for + * `$single=false` callers. + * + * @param mixed $pre Filter accumulator (null at our priority). + * @param int $object_id Post ID. + * @param string $meta_key Meta key being read. + * @param bool $single Whether single value was requested. + * @return mixed + */ + public static function on_read( $pre, $object_id, $meta_key, $single ) { + if ( ! self::is_target_key( $meta_key ) ) { + return $pre; + } + $is_timeout = str_starts_with( (string) $meta_key, self::PREFIX_TIMEOUT ); + $translated = self::translate_key( (int) $object_id, (string) $meta_key ); + $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; + + // Read directly from wp_options without going through get_transient() + // — we don't want the transient API to delete-on-expire, because the + // HivePress cache layer reads the timeout *first*, then decides whether + // to read the value. We must preserve raw stored values until HivePress + // itself orders deletion. + $value = get_option( $option_name, null ); + + if ( null === $value ) { + // Cache miss in our store → fall through to normal postmeta path + // (back-compat: lets a pre-filter postmeta row still resolve). + return $pre; + } + + // Match WP's metadata return contract: + // $single=true returns [value] (WP unwraps to value), + // $single=false returns array of values (WP returns as-is). + return array( $value ); + } + + /** + * Filter callback: add_post_metadata. + * + * Returns non-null to short-circuit the DB INSERT; truthy result is what + * `add_post_meta()` returns to its caller (typically a meta_id, but a + * truthy non-zero is sufficient for the calling code's success check). + * + * @param mixed $check Filter accumulator (null at our priority). + * @param int $object_id Post ID. + * @param string $meta_key Meta key being written. + * @param mixed $meta_value Value being written. + * @param bool $unique Whether the unique flag was set. + * @return mixed + */ + public static function on_add( $check, $object_id, $meta_key, $meta_value, $unique ) { + unset( $unique ); // HivePress transient writes never use $unique. + if ( ! self::is_target_key( $meta_key ) ) { + return $check; + } + self::write_translated( (int) $object_id, (string) $meta_key, $meta_value ); + return true; + } + + /** + * Filter callback: update_post_metadata. + * + * @param mixed $check Filter accumulator (null at our priority). + * @param int $object_id Post ID. + * @param string $meta_key Meta key being written. + * @param mixed $meta_value Value being written. + * @param mixed $prev_value Previous value scope (unused for our keys). + * @return mixed + */ + public static function on_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) { + unset( $prev_value ); + if ( ! self::is_target_key( $meta_key ) ) { + return $check; + } + self::write_translated( (int) $object_id, (string) $meta_key, $meta_value ); + return true; + } + + /** + * Filter callback: delete_post_metadata. + * + * @param mixed $check Filter accumulator (null at our priority). + * @param int $object_id Post ID. + * @param string $meta_key Meta key being deleted. + * @param mixed $meta_value Value-scoped delete (unused for our keys). + * @param bool $delete_all Whether to delete from all objects (unused). + * @return mixed + */ + public static function on_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) { + unset( $meta_value, $delete_all ); + if ( ! self::is_target_key( $meta_key ) ) { + return $check; + } + $is_timeout = str_starts_with( (string) $meta_key, self::PREFIX_TIMEOUT ); + $translated = self::translate_key( (int) $object_id, (string) $meta_key ); + $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; + + delete_option( $option_name ); + return true; + } + + /** + * Internal: persist value or timeout to wp_options. + * + * Both halves of the transient pair are written with `autoload=no` (they + * are read on-demand only — never via wp_load_alloptions() — so eagerly + * loading them on every page would defeat the point of the move). + * + * @param int $post_id Post ID. + * @param string $meta_key Original `_transient_*` meta_key. + * @param mixed $meta_value Value to store. + * @return void + */ + private static function write_translated( int $post_id, string $meta_key, $meta_value ): void { + $is_timeout = str_starts_with( $meta_key, self::PREFIX_TIMEOUT ); + $translated = self::translate_key( $post_id, $meta_key ); + $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; + + update_option( $option_name, $meta_value, false ); + } + + /** + * One-time migration helper: count how many `_transient_hp_*` rows + * currently live in wp_postmeta (callers can use this to decide whether + * to run the cleanup CLI). + * + * @return int + */ + public static function count_legacy_postmeta_rows(): int { + global $wpdb; + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + return (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->postmeta} + WHERE meta_key LIKE '\\_transient\\_hp\\_%' + OR meta_key LIKE '\\_transient\\_timeout\\_hp\\_%'" + ); + // phpcs:enable + } + + /** + * One-time migration: DELETE all historical `_transient_hp_*` rows from + * wp_postmeta. Safe to call when the filter is enabled (any future writes + * will go to wp_options instead). + * + * Note: existing transient values are *abandoned* (HivePress will rebuild + * them on first cache miss). We don't attempt to migrate values to + * wp_options because: + * - HivePress's cache versioning means stale values are auto-superseded + * - The cost of re-fetching is bounded (taxonomy term lookups, fast) + * + * @return int Rows deleted. + */ + public static function purge_legacy_postmeta_rows(): int { + global $wpdb; + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + return (int) $wpdb->query( + "DELETE FROM {$wpdb->postmeta} + WHERE meta_key LIKE '\\_transient\\_hp\\_%' + OR meta_key LIKE '\\_transient\\_timeout\\_hp\\_%'" + ); + // phpcs:enable + } +} diff --git a/includes/class-tmdo-hivepress.php b/includes/class-tmdo-hivepress.php new file mode 100644 index 0000000..35c6284 --- /dev/null +++ b/includes/class-tmdo-hivepress.php @@ -0,0 +1,118 @@ +register_hooks()`. The legacy class survives + * here as a deprecated stub so any third-party code that calls + * `new TMDO_HivePress()` doesn't fatal — methods route to the new adapter + * with an `_doing_it_wrong()` notice. + * + * Removal target: v4.0.0. + * + * @package WP_Data_Optimizer + * @since 0.3.0 + * @deprecated 3.0.0 Use TMDO_HivePress_Bootstrap + adapters instead. + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +if ( ! class_exists( 'TMDO_HivePress' ) ) { + + /** + * Deprecated WPDO HivePress integration shim. + * + * @deprecated 3.0.0 Use TMDO_HivePress_Bootstrap + adapter family. + */ + class TMDO_HivePress { + + /** + * No-op deprecated shim — bootstrap auto-binds adapters now. + * + * @deprecated 3.0.0 + */ + public function register_hooks(): void { + if ( function_exists( '_doing_it_wrong' ) ) { + _doing_it_wrong( + __METHOD__, + 'TMDO_HivePress is deprecated. TMDO_HivePress_Bootstrap auto-binds adapters at plugins_loaded:5.', + '3.0.0' + ); + } + // No-op: bootstrap already wired the core adapter. + } + + /** + * Deprecated shim — delegates to the new core adapter when available. + * + * @deprecated 3.0.0 Use TMDO_HivePress_Core_Adapter::optimize_query(). + * + * @param \WP_Query $query Query object. + */ + public function optimize_query( \WP_Query $query ): void { + if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) { + return; + } + $adapter = class_exists( 'TMDO_HivePress_Bootstrap' ) + ? TMDO_HivePress_Bootstrap::adapter_for( 'hivepress' ) + : null; + if ( $adapter instanceof TMDO_HivePress_Core_Adapter ) { + $adapter->optimize_query( $query ); + } + } + + /** + * Deprecated shim — delegates to the new core adapter when available. + * + * @deprecated 3.0.0 Use TMDO_HivePress_Core_Adapter::optimize_search(). + * + * @param \WP_Query $query Query object. + * @param array $attribute_fields HP attribute fields (unused). + */ + public function optimize_search( \WP_Query $query, array $attribute_fields ): void { + if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) { + return; + } + $adapter = class_exists( 'TMDO_HivePress_Bootstrap' ) + ? TMDO_HivePress_Bootstrap::adapter_for( 'hivepress' ) + : null; + if ( $adapter instanceof TMDO_HivePress_Core_Adapter ) { + $adapter->optimize_search( $query, $attribute_fields ); + } + } + + /** + * Deprecated shim — field registration is now per-adapter. + * + * @deprecated 3.0.0 Field registration moved to per-addon adapters. + * + * @param TMDO_Schema_Registry $registry Registry singleton. + */ + public function register_extended_fields( TMDO_Schema_Registry $registry ): void { + unset( $registry ); + if ( function_exists( '_doing_it_wrong' ) ) { + _doing_it_wrong( + __METHOD__, + 'Field registration moved to per-addon adapters under includes/integrations/hivepress/adapters/.', + '3.0.0' + ); + } + } + } + +} // end if ( ! class_exists ) diff --git a/includes/class-tmdo-hpct-import.php b/includes/class-tmdo-hpct-import.php new file mode 100644 index 0000000..71f72d9 --- /dev/null +++ b/includes/class-tmdo-hpct-import.php @@ -0,0 +1,248 @@ + WP Data Optimizer > HPCT Import tab + * - CLI: wp wpdo import-hpct + */ +class TMDO_HPCT_Import { + + /** Option key that marks import as completed. */ + public const IMPORTED_OPTION = 'wpdo_hpct_imported'; + + /** + * Check if HPCT import has already been completed. + */ + public static function is_imported(): bool { + return (bool) get_option( self::IMPORTED_OPTION, false ); + } + + /** + * Check if hp-custom-tables is active and has data to import. + */ + public static function can_import(): bool { + if ( self::is_imported() ) { + return false; + } + + return TMDO_Compatibility::is_hpct_active(); + } + + /** + * Preview: return what will be imported without actually doing it. + * + * @return array{modules: array, migration_records: int, has_data: bool} + */ + public static function preview(): array { + $hpct_features = get_option( 'hpct_features', array() ); + if ( ! is_array( $hpct_features ) ) { + $hpct_features = array(); + } + + $modules = array(); + foreach ( TMDO_Feature_Flags::HPCT_MODULES as $module ) { + $hpct_status = $hpct_features[ $module ] ?? 'disabled'; + $wpdo_state = TMDO_Feature_Flags::map_hpct_status( $hpct_status ); + + $modules[] = array( + 'module' => $module, + 'hpct_status' => $hpct_status, + 'wpdo_state' => $wpdo_state, + ); + } + + // Count HPCT migration records. + global $wpdb; + $hpct_migrations_table = $wpdb->prefix . 'hpct_migrations'; + $migration_count = 0; + + // Check if hpct_migrations table exists. + if ( TMDO_IS_SQLITE ) { + $table_exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = %s", + $hpct_migrations_table + ) + ); + } else { + $table_exists = $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = %s', + $hpct_migrations_table + ) + ); + } + + if ( $table_exists ) { + $migration_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hpct_migrations_table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix . 'hpct_migrations'. + } + + return array( + 'modules' => $modules, + 'migration_records' => $migration_count, + 'has_data' => ! empty( $hpct_features ), + ); + } + + /** + * Execute the import. + * + * @return array{success: bool, message: string, imported: array} + */ + public static function run(): array { + if ( self::is_imported() ) { + return array( + 'success' => false, + 'message' => 'HPCT import has already been completed.', + 'imported' => array(), + ); + } + + // Step 1: Read HPCT feature flags. + $hpct_features = get_option( 'hpct_features', array() ); + if ( ! is_array( $hpct_features ) ) { + $hpct_features = array(); + } + + $imported = array(); + + // Step 2: Map each HPCT module state to WPDO state. + foreach ( TMDO_Feature_Flags::HPCT_MODULES as $module ) { + $hpct_status = $hpct_features[ $module ] ?? 'disabled'; + $wpdo_state = TMDO_Feature_Flags::map_hpct_status( $hpct_status ); + + TMDO_Feature_Flags::set( $module, $wpdo_state ); + + $imported[] = array( + 'module' => $module, + 'hpct_status' => $hpct_status, + 'wpdo_state' => $wpdo_state, + ); + } + + // Step 3: Copy HPCT migration records to WPDO. + self::copy_migration_records(); + + // Step 4: Mark import as completed. + update_option( self::IMPORTED_OPTION, true ); + + return array( + 'success' => true, + 'message' => sprintf( + 'Successfully imported %d HPCT modules. You can now deactivate hp-custom-tables.', + count( $imported ) + ), + 'imported' => $imported, + ); + } + + /** + * Copy HPCT migration progress records to wpdo_migrations. + */ + private static function copy_migration_records(): void { + global $wpdb; + + $hpct_table = $wpdb->prefix . 'hpct_migrations'; + $wpdo_table = TMDO_DB::table( 'wpdo_migrations' ); + $now = TMDO_DB::now(); + + // Check if hpct_migrations table exists. + $exists = false; + if ( TMDO_IS_SQLITE ) { + $exists = (bool) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = %s", + $hpct_table + ) + ); + } else { + $exists = (bool) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = %s', + $hpct_table + ) + ); + } + + if ( ! $exists ) { + return; + } + + $hpct_records = $wpdb->get_results( "SELECT * FROM `{$hpct_table}`", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix . 'hpct_migrations'. + if ( ! $hpct_records ) { + return; + } + + foreach ( $hpct_records as $record ) { + $module = $record['module'] ?? ''; + if ( ! $module ) { + continue; + } + + // Map HPCT status to WPDO state. + $hpct_status = $record['status'] ?? 'pending'; + $state = self::map_migration_status( $hpct_status ); + + // Check if WPDO already has a record for this module. + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_DB::table(). + $existing = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM `{$wpdo_table}` WHERE module = %s", + $module + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $data = array( + 'module' => $module, + 'zone' => '', + 'state' => $state, + 'total_rows' => (int) ( $record['total_rows'] ?? 0 ), + 'processed_rows' => (int) ( $record['processed_rows'] ?? 0 ), + 'last_offset' => (int) ( $record['last_offset'] ?? 0 ), + 'error_count' => (int) ( $record['error_count'] ?? 0 ), + 'started_at' => $record['started_at'] ?? null, + 'completed_at' => $record['completed_at'] ?? null, + 'updated_at' => $now, + ); + + if ( $existing ) { + $wpdb->update( $wpdo_table, $data, array( 'id' => (int) $existing ) ); + } else { + $data['created_at'] = $now; + $wpdb->insert( $wpdo_table, $data ); + } + } + } + + /** + * Map HPCT migration record status to WPDO migration state. + * + * @param string $hpct_status HPCT migration status string. + * @return string Corresponding WPDO migration state. + */ + private static function map_migration_status( string $hpct_status ): string { + return match ( $hpct_status ) { + 'pending' => 'idle', + 'running' => 'backfill', + 'completed' => 'verify', + 'failed' => 'idle', + default => 'idle', + }; + } +} diff --git a/includes/class-tmdo-listing-stats.php b/includes/class-tmdo-listing-stats.php new file mode 100644 index 0000000..2eec8bf --- /dev/null +++ b/includes/class-tmdo-listing-stats.php @@ -0,0 +1,264 @@ + TMDO_ARCHIVE_DAYS ago. + * - Archives hp_price, hp_featured, hp_verified, hp_expired_time with gzip compression. + */ +class TMDO_Listing_Stats { + + /** Meta key used for the warm-zone view counter. */ + const VIEW_KEY = 'wpdo_views'; + + /** TTL for warm-zone view entries (24 hours). */ + const VIEW_TTL = DAY_IN_SECONDS; + + /** How many days after expiry before archiving hot fields. */ + const ARCHIVE_DAYS = 30; + + /** Hot-zone meta keys to archive for expired listings. */ + const ARCHIVE_KEYS = array( 'hp_price', 'hp_featured', 'hp_verified', 'hp_expired_time' ); + + /** + * Register all hooks. + */ + public static function register_hooks(): void { + // Zone B: increment view count on front-end singular listing pages. + add_action( 'wp', array( __CLASS__, 'maybe_increment_view' ) ); + + // Zone B: flush accumulated warm-zone counts to postmeta (hourly). + add_action( 'wpdo_flush_views', array( __CLASS__, 'flush_views_to_postmeta' ) ); + + // Zone D: archive expired listing fields (daily cron via wpdo_archive_sweep). + add_action( 'wpdo_archive_sweep', array( __CLASS__, 'archive_expired_listings' ), 20 ); + } + + /** + * Schedule the flush cron if not already registered. + * + * Called from TMDO_Core::schedule_cron(). + */ + public static function schedule_cron(): void { + if ( ! wp_next_scheduled( 'wpdo_flush_views' ) ) { + wp_schedule_event( time(), 'hourly', 'wpdo_flush_views' ); + } + } + + // ── Zone B: View Counter ───────────────────────────────────────────────── + + /** + * Increment the warm-zone view counter for the current listing page. + * + * Only fires on front-end, singular hp_listing pages, for non-bot requests. + */ + public static function maybe_increment_view(): void { + if ( is_admin() || ! is_singular( 'hp_listing' ) ) { + return; + } + + // Skip common bots by checking for empty or known bot user-agents. + $ua = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ); + if ( empty( $ua ) || preg_match( '/bot|crawl|slurp|spider|mediapartners/i', $ua ) ) { + return; + } + + $post_id = (int) get_the_ID(); + if ( $post_id <= 0 ) { + return; + } + + self::increment_view( $post_id ); + } + + /** + * Increment the warm-zone view count for a listing. + * + * @param int $post_id Listing post ID. + */ + public static function increment_view( int $post_id ): void { + $current = (int) TMDO_Zone_Warm::get( $post_id, self::VIEW_KEY ); + TMDO_Zone_Warm::set( $post_id, self::VIEW_KEY, (string) ( $current + 1 ), self::VIEW_TTL ); + } + + /** + * Read the view count for a listing. + * + * Returns warm-zone value if available; falls back to postmeta hp_view_count. + * + * @param int $post_id Listing post ID. + * @return int + */ + public static function get_view_count( int $post_id ): int { + $warm = TMDO_Zone_Warm::get( $post_id, self::VIEW_KEY ); + if ( null !== $warm ) { + return (int) $warm; + } + return (int) get_post_meta( $post_id, 'hp_view_count', true ); + } + + /** + * Flush all warm-zone view counts to postmeta and reset warm entries. + * + * Called hourly by wpdo_flush_views cron. + * + * @return int Number of listings flushed. + */ + public static function flush_views_to_postmeta(): int { + global $wpdb; + + $table = TMDO_Zone_Warm::table(); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_id, meta_value FROM `{$table}` WHERE meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + self::VIEW_KEY + ), + ARRAY_A + ); + + if ( ! $rows ) { + return 0; + } + + // Batch-fetch existing hp_view_count for all post IDs in one query. + $post_ids = array_map( 'intval', array_column( $rows, 'post_id' ) ); + $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); + $existing_rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = 'hp_view_count' AND post_id IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $wpdb->postmeta is core. phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Listing Stats: Zone B → postmeta view-count sync path. + ...$post_ids + ), + ARRAY_A + ); + $existing_map = array(); + foreach ( $existing_rows as $er ) { + $existing_map[ (int) $er['post_id'] ] = (int) $er['meta_value']; + } + + $flushed = 0; + foreach ( $rows as $row ) { + $post_id = (int) $row['post_id']; + $new_views = (int) $row['meta_value']; + + // Add to existing postmeta total. + $existing = $existing_map[ $post_id ] ?? 0; + update_post_meta( $post_id, 'hp_view_count', $existing + $new_views ); + + // Remove the warm entry after flush. + TMDO_Zone_Warm::delete( $post_id, self::VIEW_KEY ); + ++$flushed; + } + + return $flushed; + } + + // ── Zone D: Expired Listing Archival ───────────────────────────────────── + + /** + * Archive hot-zone fields for listings that expired ARCHIVE_DAYS ago. + * + * Reads hp_expired_time from postmeta (or hot zone via Sync Bridge). + * Archives ARCHIVE_KEYS with gzip compression into wpdo_archive. + * + * @param int $limit Max listings to process per run. + * @return int Number of listing fields archived. + */ + public static function archive_expired_listings( int $limit = 200 ): int { + global $wpdb; + + $cutoff = (int) ( time() - self::ARCHIVE_DAYS * DAY_IN_SECONDS ); + + // Find published listings with hp_expired_time before cutoff. + $listings = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT pm.post_id + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE pm.meta_key = 'hp_expired_time' + AND pm.meta_value > '0' + AND CAST(pm.meta_value AS UNSIGNED) < %d + AND p.post_type = 'hp_listing' + AND p.post_status IN ('publish','private') + LIMIT %d", + $cutoff, + $limit + ) + ); + + if ( ! $listings ) { + return 0; + } + + $listing_ids = array_map( 'intval', $listings ); + $id_list = implode( ',', $listing_ids ); + $key_list = implode( ',', array_fill( 0, count( self::ARCHIVE_KEYS ), '%s' ) ); + + // Batch-fetch all relevant postmeta rows in one query. + $meta_rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT meta_id, post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id IN ({$id_list}) AND meta_key IN ({$key_list})", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $wpdb->postmeta is core. phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Archive sweep: must read postmeta to migrate to Zone D. + ...self::ARCHIVE_KEYS + ), + ARRAY_A + ); + + // Build lookup: $meta_map[ post_id ][ meta_key ] = [ meta_id, meta_value ]. + $meta_map = array(); + foreach ( $meta_rows as $mr ) { + $meta_map[ (int) $mr['post_id'] ][ $mr['meta_key'] ] = array( + 'meta_id' => (int) $mr['meta_id'], + 'meta_value' => $mr['meta_value'], + ); + } + + $archived = 0; + foreach ( $listing_ids as $post_id ) { + foreach ( self::ARCHIVE_KEYS as $meta_key ) { + if ( ! isset( $meta_map[ $post_id ][ $meta_key ] ) ) { + continue; + } + $entry = $meta_map[ $post_id ][ $meta_key ]; + $meta_value = $entry['meta_value']; + if ( '' === $meta_value || false === $meta_value ) { + continue; + } + TMDO_Zone_Archive::archive( + $post_id, + 'hp_listing', + $meta_key, + (string) $meta_value, + $entry['meta_id'], + true // compress. + ); + ++$archived; + } + } + + return $archived; + } + + /** + * Get archive statistics for expired listings. + * + * @return array{total_rows: int, compressed_rows: int, post_types: array} + */ + public static function stats(): array { + return TMDO_Zone_Archive::stats(); + } +} diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-blocks-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-blocks-adapter.php new file mode 100644 index 0000000..cc2ca29 --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-blocks-adapter.php @@ -0,0 +1,73 @@ +bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-blocks'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\Blocks\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_BLOCKS_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '1.0.0'; + } + + /** + * Doctor probe — presentation-only, nothing to verify. + * + * @return array{ok:bool, message:string} + */ + public function doctor_check(): array { + return array( + 'ok' => true, + 'message' => 'hivepress-blocks: presentation-only addon, no anti-EAV surface', + ); + } + + /** + * 8-D self-score — perfect (no data layer). + */ + public function suitability_score(): array { + return $this->compose_score( array() ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-bookings-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-bookings-adapter.php new file mode 100644 index 0000000..52e1967 --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-bookings-adapter.php @@ -0,0 +1,159 @@ +bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-bookings'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\Bookings\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_BOOKINGS_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '1.0.0'; + } + + /** + * Register hot fields for hp_booking post type. + * + * @param TMDO_Schema_Registry $registry Schema registry singleton. + */ + public function on_register_fields( TMDO_Schema_Registry $registry ): void { + $registry->register_many( + $this->plugin_slug(), + array( + array( + 'post_type' => 'hp_booking', + 'meta_key' => 'hp_start_time', + 'zone' => 'hot', + 'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0', + 'column' => 'hp_start_time', + 'indexed' => true, + ), + array( + 'post_type' => 'hp_booking', + 'meta_key' => 'hp_end_time', + 'zone' => 'hot', + 'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0', + 'column' => 'hp_end_time', + 'indexed' => true, + ), + array( + 'post_type' => 'hp_booking', + 'meta_key' => 'hp_guests', + 'zone' => 'hot', + 'data_type' => 'int(11) NOT NULL DEFAULT 0', + 'column' => 'hp_guests', + 'indexed' => false, + ), + ) + ); + } + + /** + * Doctor probe — verify hot table when addon is active. + * + * @return array{ok:bool, message:string} + */ + public function doctor_check(): array { + global $wpdb; + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { + return array( + 'ok' => false, + 'message' => 'wpdb global not available', + ); + } + $table = $wpdb->prefix . 'wpdo_hot_hp_booking'; + try { + $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + return array( + 'ok' => $exists, + 'message' => $exists + ? sprintf( 'hivepress-bookings: %s present', $table ) + : sprintf( 'hivepress-bookings: %s missing (addon installed but not migrated)', $table ), + ); + } catch ( \Throwable $e ) { + return array( + 'ok' => false, + 'message' => sprintf( 'hivepress-bookings doctor failed: %s', $e->getMessage() ), + ); + } + } + + /** + * 8-D self-score. + * + * D8 (perf) reduced to 0.9 because the schema is best-effort from + * documentation; once the addon is installed and EXPLAIN can verify + * index usage, raise to 1.0. + */ + public function suitability_score(): array { + return $this->compose_score( + array( + 'd8_perf' => 0.9, + ) + ); + } + + /** + * FSM modules this adapter contributes. + * + * @return array + */ + public function migrations(): array { + return array( 'hot_hp_booking' ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-core-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-core-adapter.php new file mode 100644 index 0000000..25e9664 --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-core-adapter.php @@ -0,0 +1,407 @@ + 'hp_listing', + 'vendor' => 'hp_vendor', + ); + + /** + * Constructor — bind WPDO core hooks via the inherited trait sugar. + * + * The bootstrap calls `on_register_query_hooks()` / `on_register_event_hooks()` + * directly after instantiation, but field/table registration goes + * through WordPress's action dispatch via this trait helper. + */ + public function __construct() { + $this->bind_anti_eav_hooks(); + } + + // ── Adapter identity ─────────────────────────────────────────────── + + /** + * Stable adapter slug (matches the hivepress core wp.org slug). + */ + public function plugin_slug(): string { + return 'hivepress'; + } + + /** + * Class probed for HivePress core presence. + */ + public function detection_class(): string { + return 'HivePress\\Core'; + } + + /** + * Version constant probed for HivePress core presence. + */ + public function detection_const(): string { + return 'HIVEPRESS_VERSION'; + } + + /** + * Minimum HivePress core version this adapter supports. + * + * Version 1.7.0 introduced the `_alias` / `_external` field convention + * this adapter relies on. Older versions store fields differently and + * would require fallback paths not implemented here. + */ + public function minimum_addon_version(): string { + return '1.7.0'; + } + + // ── Field registration ───────────────────────────────────────────── + + /** + * Register Listing/Vendor zone field mappings. + * + * Fired on the `wpdo_register_fields` action via the trait's + * `bind_anti_eav_hooks()`. Schema_Registry has internal dedup so + * re-firing on init:1 is safe. + * + * @param TMDO_Schema_Registry $registry Schema registry singleton. + */ + public function on_register_fields( TMDO_Schema_Registry $registry ): void { + // ── Listing hot fields (search/filter accelerators) ── + $registry->register_many( + $this->plugin_slug(), + array( + // Drafted — used in user dashboard "my drafts" filter. + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_drafted', + 'zone' => 'hot', + 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', + 'column' => 'hp_drafted', + 'indexed' => true, + ), + // Expired time — replaces `meta_query` clause in hourly cron. + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_expired_time', + 'zone' => 'hot', + 'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0', + 'column' => 'hp_expired_time', + 'indexed' => true, + ), + // Featured time — same expiry pattern as above. + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_featured_time', + 'zone' => 'hot', + 'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0', + 'column' => 'hp_featured_time', + 'indexed' => true, + ), + // Vendor cold blob — image attachment id (display-only). + array( + 'post_type' => 'hp_vendor', + 'meta_key' => 'hp_image', + 'zone' => 'cold', + 'cache_group' => 'wpdo_cold_hp_vendor', + 'cache_ttl' => HOUR_IN_SECONDS, + ), + // Listing cold blob — gallery (display-only). + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_images', + 'zone' => 'cold', + 'cache_group' => 'wpdo_cold_hp_listing', + 'cache_ttl' => HOUR_IN_SECONDS, + ), + ) + ); + } + + /** + * Register entity field group for HP User model. + * + * The `hp_user` group is already created by `TMDO_Member_Fields` + * (v2.7.0). We add adapter ownership here for cross-cutting tooling + * (`wp wpdo hivepress doctor` reports owner; admin UI shows source). + * + * The `register_entity_fields()` helper from `TMDO_Anti_EAV_Aware` + * delegates to `TMDO_Entity_Registry::register_group()` which has + * internal dedup — so re-registering with same group name is a no-op. + */ + public function on_register_entity_fields(): void { + if ( ! class_exists( 'TMDO_Entity_Registry' ) ) { + return; + } + + // Mirror the (canonical) group definition from TMDO_Member_Fields::HP_USER_FIELDS. + // Source of truth lives in TMDO_Member_Fields; we register defensively for the + // case where Member_Fields didn't load (custom builds without that integration). + $this->register_entity_fields( + 'user', + 'hp_user', + array( + array( + 'key' => 'hp_verified', + 'type' => 'boolean', + 'default' => false, + 'searchable' => true, + 'label' => 'HivePress KYC verified', + ), + array( + 'key' => 'hp_avatar_id', + 'type' => 'integer', + 'default' => 0, + 'searchable' => false, + 'label' => 'HivePress avatar attachment id', + ), + ) + ); + } + + // ── Query optimization (preserved from legacy class-tmdo-hivepress.php) ── + + /** + * Bind HivePress search query hooks at priority 20 (after HP setup). + */ + public function on_register_query_hooks(): void { + foreach ( self::HOT_MODELS as $model => $post_type ) { + unset( $post_type ); // Bound below by string-interpolated $model only. + add_action( + "hivepress/v1/models/{$model}/query", + array( $this, 'optimize_query' ), + 20, + 1 + ); + add_action( + "hivepress/v1/models/{$model}/search", + array( $this, 'optimize_search' ), + 20, + 2 + ); + } + } + + /** + * Optimize a HivePress model query by extracting hot-zone meta_query clauses. + * + * Extracted clauses are stashed on the WP_Query as `wpdo_hot_clauses`; + * `TMDO_Query_Router` rewrites them into flat-column JOINs at + * `posts_join` time. + * + * @param \WP_Query $query The WP_Query object. + */ + public function optimize_query( \WP_Query $query ): void { + $post_types = (array) $query->get( 'post_type' ); + if ( empty( $post_types ) ) { + return; + } + + $existing_hot = $query->get( 'wpdo_hot_clauses' ); + if ( ! empty( $existing_hot ) ) { + return; + } + + $meta_query = (array) $query->get( 'meta_query' ); + if ( empty( $meta_query ) ) { + return; + } + + $registry = TMDO_Schema_Registry::instance(); + $hot_clauses = array(); + $remaining = array(); + + foreach ( $meta_query as $k => $clause ) { + if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { + $remaining[ $k ] = $clause; + continue; + } + + $matched = false; + foreach ( $post_types as $pt ) { + $field = $registry->get_field( $pt, $clause['key'] ); + if ( $field && 'hot' === $field['zone'] ) { + $module = 'hot_' . sanitize_key( $pt ); + if ( class_exists( 'TMDO_Feature_Flags' ) && ! TMDO_Feature_Flags::is_query_active( $module ) ) { + continue; + } + + $hot_clauses[ $pt ][] = array( + 'column' => $field['column'], + 'value' => $clause['value'] ?? '', + 'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ), + 'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ), + ); + $matched = true; + break; + } + } + + if ( ! $matched ) { + $remaining[ $k ] = $clause; + } + } + + if ( empty( $hot_clauses ) ) { + return; + } + + if ( isset( $meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) { + $remaining['relation'] = $meta_query['relation']; + } + + $query->set( 'meta_query', $remaining ); + $query->set( 'wpdo_hot_clauses', $hot_clauses ); + } + + /** + * Optimize a HivePress search query (delegates to optimize_query). + * + * Bound to `hivepress/v1/models/{model}/search` which passes attribute + * fields as second arg; we ignore them (extracted via meta_query). + * + * @param \WP_Query $query The WP_Query object. + * @param array $attribute_fields HP attribute field objects (unused). + */ + public function optimize_search( \WP_Query $query, array $attribute_fields ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + unset( $attribute_fields ); + $this->optimize_query( $query ); + } + + // ── Doctor / score / migrations ──────────────────────────────────── + + /** + * Health probe: verify hot tables exist + sample row count. + * + * @return array{ok:bool, message:string, details?:array} + */ + public function doctor_check(): array { + global $wpdb; + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { + return array( + 'ok' => false, + 'message' => 'wpdb global not available', + ); + } + + $details = array(); + $ok = true; + foreach ( self::HOT_MODELS as $post_type ) { + $table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type ); + try { + $exists = (bool) $wpdb->get_var( + $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) + ); + $details[ $post_type ] = array( + 'table' => $table, + 'exists' => $exists, + ); + if ( $exists ) { + $details[ $post_type ]['rows'] = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$table}`" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Sanitized via sanitize_key + wpdb prefix. + ); + } else { + $ok = false; + } + } catch ( \Throwable $e ) { + $details[ $post_type ] = array( + 'table' => $table, + 'error' => $e->getMessage(), + ); + $ok = false; + } + } + + return array( + 'ok' => $ok, + 'message' => $ok + ? 'hivepress core: hot tables OK' + : 'hivepress core: one or more hot tables missing or unreadable', + 'details' => $details, + ); + } + + /** + * 8-dimension self-score for the core adapter. + * + * Default 1.0 across the board because: + * - D1: only TMDO_API + Schema_Registry calls (no direct *_meta()) + * - D2: no hardcoded wp_postmeta literals + * - D3: hot tables for both Listing + Vendor exist + * - D4: every field registered with explicit data_type + indexed flag + * - D5: no cross-adapter writes needed (single-adapter scope) + * - D6: zero options written; no transients (relies on WPDO core caching) + * - D7: no queries against other adapters' tables + * - D8: hot columns indexed on every search-relevant field + */ + public function suitability_score(): array { + return $this->compose_score( + array( + // D5 is N/A for a single-adapter; report 1.0 (no cross-write needed). + 'd5_hook_bus' => 1.0, + ) + ); + } + + /** + * FSM modules this adapter contributes. + * + * @return array + */ + public function migrations(): array { + return array( + 'hot_hp_listing', + 'hot_hp_vendor', + ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-favorites-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-favorites-adapter.php new file mode 100644 index 0000000..9086044 --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-favorites-adapter.php @@ -0,0 +1,234 @@ +bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-favorites'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\Favorites\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_FAVORITES_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '1.2.0'; + } + + /** + * Declare the shadow table to the Custom_Table_Registry. + * + * @param TMDO_Custom_Table_Registry $registry Registry singleton. + */ + public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void { + $this->register_custom_table( + $registry, + array( + 'table_name' => self::TABLE, + 'primary_key' => 'comment_id', + 'post_type_link' => null, + 'expected_columns' => array( + 'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY', + 'user_id' => 'BIGINT UNSIGNED NOT NULL', + 'listing_id' => 'BIGINT UNSIGNED NOT NULL', + 'created_at' => 'DATETIME NOT NULL', + ), + 'indexes' => array( + 'unique_user_listing' => 'UNIQUE (user_id, listing_id)', + 'idx_listing' => '(listing_id)', + ), + 'doctor_callback' => array( $this, 'doctor_check' ), + ) + ); + } + + /** + * Sync wp_comments → shadow table on insert. + * + * Bound during `on_register_event_hooks()`. The comment-router uses + * the shadow table for "is favorited" lookups; sync keeps it consistent. + */ + public function on_register_event_hooks(): void { + add_action( 'wp_insert_comment', array( $this, 'mirror_insert' ), 10, 2 ); + add_action( 'deleted_comment', array( $this, 'mirror_delete' ), 10, 2 ); + } + + /** + * Mirror a newly-inserted hp_favorite into the shadow table. + * + * @param int $comment_id Comment ID. + * @param object|\WP_Comment $comment Comment object. + */ + public function mirror_insert( int $comment_id, $comment ): void { + if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) { + return; + } + global $wpdb; + $user_id = (int) ( $comment->user_id ?? 0 ); + $listing_id = (int) ( $comment->comment_post_ID ?? 0 ); + if ( $user_id <= 0 || $listing_id <= 0 ) { + return; + } + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( + 'INSERT IGNORE INTO `' . $wpdb->prefix . self::TABLE . '` (comment_id, user_id, listing_id, created_at) VALUES (%d, %d, %d, %s)', + $comment_id, + $user_id, + $listing_id, + $comment->comment_date ?? current_time( 'mysql' ) + ) + ); + } + + /** + * Remove the shadow row when a hp_favorite comment is deleted. + * + * @param int $comment_id Comment ID. + * @param object|\WP_Comment $comment Comment object. + */ + public function mirror_delete( int $comment_id, $comment ): void { + if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) { + return; + } + global $wpdb; + $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prefix . self::TABLE, + array( 'comment_id' => $comment_id ), + array( '%d' ) + ); + } + + /** + * Doctor probe — verify shadow table exists. + * + * @return array{ok:bool, message:string, details?:array} + */ + public function doctor_check(): array { + global $wpdb; + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { + return array( + 'ok' => false, + 'message' => 'wpdb global not available', + ); + } + $table = $wpdb->prefix . self::TABLE; + try { + $exists = (bool) $wpdb->get_var( + $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) + ); + if ( ! $exists ) { + return array( + 'ok' => false, + 'message' => sprintf( 'hivepress-favorites: shadow table %s missing', $table ), + 'details' => array( 'table' => $table ), + ); + } + $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + return array( + 'ok' => true, + 'message' => sprintf( 'hivepress-favorites: %s rows=%d', $table, $rows ), + 'details' => array( + 'table' => $table, + 'rows' => $rows, + ), + ); + } catch ( \Throwable $e ) { + return array( + 'ok' => false, + 'message' => sprintf( 'hivepress-favorites doctor failed: %s', $e->getMessage() ), + ); + } + } + + /** + * 8-D self-score. + * + * D1: 1.0 — adapter never calls *_meta() + * D2: 1.0 — uses wpdb prefix, no hardcoded table literals + * D3: 1.0 — shadow table covers the model + * D4: 1.0 — expected_columns + indexes registered + * D5: 1.0 — listens to wp_insert_comment for cross-write consistency + * D6: 1.0 — zero options/transients + * D7: 1.0 — only queries own shadow table + * D8: 1.0 — UNIQUE(user_id, listing_id) covering index + */ + public function suitability_score(): array { + return $this->compose_score( array() ); + } + + /** + * FSM modules this adapter contributes. + * + * @return array + */ + public function migrations(): array { + return array( 'comment_hp_favorite' ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-marketplace-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-marketplace-adapter.php new file mode 100644 index 0000000..776219f --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-marketplace-adapter.php @@ -0,0 +1,135 @@ +bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-marketplace'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\Marketplace\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_MARKETPLACE_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '1.0.0'; + } + + /** + * Register marketplace hot fields on the listing post type. + * + * @param TMDO_Schema_Registry $registry Schema registry singleton. + */ + public function on_register_fields( TMDO_Schema_Registry $registry ): void { + $registry->register_many( + $this->plugin_slug(), + array( + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_purchase_price', + 'zone' => 'hot', + 'data_type' => 'decimal(10,2) DEFAULT NULL', + 'column' => 'hp_purchase_price', + 'indexed' => true, + ), + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_purchase_count', + 'zone' => 'warm', + 'ttl' => null, + ), + ) + ); + } + + /** + * Doctor probe — verify hot column exists on listing hot table. + * + * @return array{ok:bool, message:string} + */ + public function doctor_check(): array { + global $wpdb; + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { + return array( + 'ok' => false, + 'message' => 'wpdb global not available', + ); + } + $table = $wpdb->prefix . 'wpdo_hot_hp_listing'; + try { + $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + return array( + 'ok' => $exists, + 'message' => $exists + ? 'hivepress-marketplace: shares wp_wpdo_hot_hp_listing' + : 'hivepress-marketplace: hot listing table missing', + ); + } catch ( \Throwable $e ) { + return array( + 'ok' => false, + 'message' => sprintf( 'hivepress-marketplace doctor failed: %s', $e->getMessage() ), + ); + } + } + + /** + * 8-D self-score. + * + * D8 reduced to 0.9 — index assumption is best-effort until addon installs. + */ + public function suitability_score(): array { + return $this->compose_score( + array( + 'd8_perf' => 0.9, + ) + ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-memberships-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-memberships-adapter.php new file mode 100644 index 0000000..e6a2b2e --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-memberships-adapter.php @@ -0,0 +1,169 @@ +bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-memberships'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\Memberships\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_MEMBERSHIPS_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '2.0.0'; + } + + /** + * Register hot fields for both post types. + * + * @param TMDO_Schema_Registry $registry Schema registry singleton. + */ + public function on_register_fields( TMDO_Schema_Registry $registry ): void { + $registry->register_many( + $this->plugin_slug(), + array( + // Plan fields. + array( + 'post_type' => 'hp_membership_plan', + 'meta_key' => 'hp_expire_period', + 'zone' => 'hot', + 'data_type' => 'int(11) NOT NULL DEFAULT 0', + 'column' => 'hp_expire_period', + 'indexed' => false, + ), + array( + 'post_type' => 'hp_membership_plan', + 'meta_key' => 'hp_primary', + 'zone' => 'hot', + 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', + 'column' => 'hp_primary', + 'indexed' => true, + ), + // Membership (subscription) field — hottest path: every page load. + array( + 'post_type' => 'hp_membership', + 'meta_key' => 'hp_expired_time', + 'zone' => 'hot', + 'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0', + 'column' => 'hp_expired_time', + 'indexed' => true, + ), + ) + ); + } + + /** + * Doctor probe — verify both hot tables. + * + * @return array{ok:bool, message:string, details?:array} + */ + public function doctor_check(): array { + global $wpdb; + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { + return array( + 'ok' => false, + 'message' => 'wpdb global not available', + ); + } + $details = array(); + $ok = true; + foreach ( array( 'hp_membership_plan', 'hp_membership' ) as $pt ) { + $table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $pt ); + try { + $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + $details[ $pt ]['table'] = $table; + $details[ $pt ]['exists'] = $exists; + if ( ! $exists ) { + $ok = false; + } + } catch ( \Throwable $e ) { + $details[ $pt ]['error'] = $e->getMessage(); + $ok = false; + } + } + return array( + 'ok' => $ok, + 'message' => $ok ? 'hivepress-memberships: hot tables OK' : 'hivepress-memberships: hot tables missing', + 'details' => $details, + ); + } + + /** + * 8-D self-score. + * + * D1: 1.0 — adapter never calls *_meta() + * D2: 1.0 — uses wpdb prefix + * D3: 1.0 — both models have hot tables + * D4: 1.0 — every field has explicit data_type + indexed flag + * D5: 1.0 — single-adapter scope + * D6: 1.0 — zero options/transients + * D7: 1.0 — only registers fields, no cross-table queries + * D8: 1.0 — hot column on hp_expired_time = indexed range scan + */ + public function suitability_score(): array { + return $this->compose_score( array() ); + } + + /** + * FSM modules this adapter contributes. + * + * @return array + */ + public function migrations(): array { + return array( + 'hot_hp_membership_plan', + 'hot_hp_membership', + ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-messages-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-messages-adapter.php new file mode 100644 index 0000000..cc044dc --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-messages-adapter.php @@ -0,0 +1,263 @@ +bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-messages'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\Messages\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_MESSAGES_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '1.4.0'; + } + + /** + * Declare the shadow table to the Custom_Table_Registry. + * + * @param TMDO_Custom_Table_Registry $registry Registry singleton. + */ + public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void { + $this->register_custom_table( + $registry, + array( + 'table_name' => self::TABLE, + 'primary_key' => 'comment_id', + 'post_type_link' => null, + 'expected_columns' => array( + 'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY', + 'sender_id' => 'BIGINT UNSIGNED NOT NULL', + 'recipient_id' => 'BIGINT UNSIGNED NOT NULL', + 'listing_id' => 'BIGINT UNSIGNED NOT NULL', + 'is_read' => 'TINYINT(1) NOT NULL DEFAULT 0', + 'sent_at' => 'DATETIME NOT NULL', + ), + 'indexes' => array( + 'idx_recipient_unread' => '(recipient_id, is_read)', + 'idx_thread' => '(listing_id, sent_at)', + ), + 'doctor_callback' => array( $this, 'doctor_check' ), + ) + ); + } + + /** + * Bind sync hooks on hp_message comment insert / update / delete. + * + * The recipient_id is stored in comment_karma by HivePress; we mirror + * it into a properly-named indexed column. is_read tracks comment_approved + * (HP uses 0=unread / 1=read) — same column, mirrored. + */ + public function on_register_event_hooks(): void { + add_action( 'wp_insert_comment', array( $this, 'mirror_insert' ), 10, 2 ); + add_action( 'wp_set_comment_status', array( $this, 'mirror_status_change' ), 10, 2 ); + add_action( 'deleted_comment', array( $this, 'mirror_delete' ), 10, 2 ); + } + + /** + * Mirror a new hp_message into the shadow table. + * + * @param int $comment_id Comment ID. + * @param object|\WP_Comment $comment Comment object. + */ + public function mirror_insert( int $comment_id, $comment ): void { + if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) { + return; + } + global $wpdb; + $sender_id = (int) ( $comment->user_id ?? 0 ); + $recipient_id = (int) ( $comment->comment_karma ?? 0 ); + $listing_id = (int) ( $comment->comment_post_ID ?? 0 ); + $is_read = (int) ( $comment->comment_approved ?? 0 ); + if ( $sender_id <= 0 || $recipient_id <= 0 ) { + // Malformed message — nothing useful to mirror. + return; + } + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( + 'INSERT INTO `' . $wpdb->prefix . self::TABLE . '` (comment_id, sender_id, recipient_id, listing_id, is_read, sent_at) VALUES (%d, %d, %d, %d, %d, %s) ON DUPLICATE KEY UPDATE is_read = VALUES(is_read)', + $comment_id, + $sender_id, + $recipient_id, + $listing_id, + $is_read, + $comment->comment_date ?? current_time( 'mysql' ) + ) + ); + } + + /** + * Update is_read when a message is marked read (comment_approved flip). + * + * @param int $comment_id Comment ID. + * @param string $status New status ('approve', 'hold', 'spam', 'trash'). + */ + public function mirror_status_change( int $comment_id, string $status ): void { + global $wpdb; + // Look up the comment to confirm it's hp_message before touching. + if ( ! function_exists( 'get_comment' ) ) { + return; + } + $comment = get_comment( $comment_id ); + if ( ! $comment || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) { + return; + } + $is_read = ( 'approve' === $status ) ? 1 : 0; + $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prefix . self::TABLE, + array( 'is_read' => $is_read ), + array( 'comment_id' => $comment_id ), + array( '%d' ), + array( '%d' ) + ); + } + + /** + * Remove shadow row on hp_message deletion. + * + * @param int $comment_id Comment ID. + * @param object|\WP_Comment $comment Comment object. + */ + public function mirror_delete( int $comment_id, $comment ): void { + if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) { + return; + } + global $wpdb; + $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prefix . self::TABLE, + array( 'comment_id' => $comment_id ), + array( '%d' ) + ); + } + + /** + * Doctor probe — verify shadow table. + * + * @return array{ok:bool, message:string, details?:array} + */ + public function doctor_check(): array { + global $wpdb; + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { + return array( + 'ok' => false, + 'message' => 'wpdb global not available', + ); + } + $table = $wpdb->prefix . self::TABLE; + try { + $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + if ( ! $exists ) { + return array( + 'ok' => false, + 'message' => sprintf( 'hivepress-messages: shadow table %s missing', $table ), + 'details' => array( 'table' => $table ), + ); + } + $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + return array( + 'ok' => true, + 'message' => sprintf( 'hivepress-messages: %s rows=%d', $table, $rows ), + 'details' => array( + 'table' => $table, + 'rows' => $rows, + ), + ); + } catch ( \Throwable $e ) { + return array( + 'ok' => false, + 'message' => sprintf( 'hivepress-messages doctor failed: %s', $e->getMessage() ), + ); + } + } + + /** + * 8-D self-score. + * + * D1: 1.0 — adapter never calls *_meta() + * D2: 1.0 — uses wpdb prefix exclusively + * D3: 1.0 — shadow table covers recipient/unread access + * D4: 1.0 — expected_columns + indexes registered + * D5: 1.0 — listens to wp_insert_comment + status change for sync + * D6: 1.0 — zero options/transients + * D7: 1.0 — only queries own shadow table + * D8: 1.0 — idx_recipient_unread covers the hot dashboard query + */ + public function suitability_score(): array { + return $this->compose_score( array() ); + } + + /** + * FSM modules this adapter contributes. + * + * @return array + */ + public function migrations(): array { + return array( 'comment_hp_message' ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-requests-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-requests-adapter.php new file mode 100644 index 0000000..934f651 --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-requests-adapter.php @@ -0,0 +1,267 @@ +bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-requests'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\Requests\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_REQUESTS_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '1.2.0'; + } + + /** + * Register hot fields for hp_request post type. + * + * @param TMDO_Schema_Registry $registry Schema registry singleton. + */ + public function on_register_fields( TMDO_Schema_Registry $registry ): void { + $registry->register_many( + $this->plugin_slug(), + array( + array( + 'post_type' => 'hp_request', + 'meta_key' => 'hp_drafted', + 'zone' => 'hot', + 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', + 'column' => 'hp_drafted', + 'indexed' => true, + ), + array( + 'post_type' => 'hp_request', + 'meta_key' => 'hp_expired_time', + 'zone' => 'hot', + 'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0', + 'column' => 'hp_expired_time', + 'indexed' => true, + ), + ) + ); + } + + /** + * Declare offer shadow table. + * + * @param TMDO_Custom_Table_Registry $registry Registry singleton. + */ + public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void { + $this->register_custom_table( + $registry, + array( + 'table_name' => self::OFFER_TABLE, + 'primary_key' => 'comment_id', + 'post_type_link' => 'hp_request', + 'expected_columns' => array( + 'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY', + 'request_id' => 'BIGINT UNSIGNED NOT NULL', + 'bidder_id' => 'BIGINT UNSIGNED NOT NULL', + 'approved' => 'TINYINT(1) NOT NULL DEFAULT 0', + 'created_at' => 'DATETIME NOT NULL', + ), + 'indexes' => array( + 'idx_request_approved' => '(request_id, approved)', + 'idx_bidder' => '(bidder_id)', + ), + 'doctor_callback' => array( $this, 'doctor_check' ), + ) + ); + } + + /** + * Mirror new hp_offer comments into the shadow table. + */ + public function on_register_event_hooks(): void { + add_action( 'wp_insert_comment', array( $this, 'mirror_offer_insert' ), 10, 2 ); + add_action( 'edit_comment', array( $this, 'mirror_offer_update' ), 10, 2 ); + add_action( 'deleted_comment', array( $this, 'mirror_offer_delete' ), 10, 2 ); + } + + /** + * Insert a hp_offer mirror row. + * + * @param int $comment_id Comment ID. + * @param object|\WP_Comment $comment Comment object. + */ + public function mirror_offer_insert( int $comment_id, $comment ): void { + if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::OFFER_COMMENT_TYPE ) { + return; + } + global $wpdb; + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( + 'INSERT INTO `' . $wpdb->prefix . self::OFFER_TABLE . '` (comment_id, request_id, bidder_id, approved, created_at) VALUES (%d, %d, %d, %d, %s) ON DUPLICATE KEY UPDATE approved = VALUES(approved)', + $comment_id, + (int) ( $comment->comment_post_ID ?? 0 ), + (int) ( $comment->user_id ?? 0 ), + (int) ( $comment->comment_approved ?? 0 ), + $comment->comment_date ?? current_time( 'mysql' ) + ) + ); + } + + /** + * Update mirror row when comment is edited (approval flip is the hot path). + * + * @param int $comment_id Comment ID. + * @param array $data Comment data array passed by edit_comment. + */ + public function mirror_offer_update( int $comment_id, $data ): void { + unset( $data ); + global $wpdb; + // Re-fetch comment object for accurate state then re-mirror via insert path. + if ( function_exists( 'get_comment' ) ) { + $comment = get_comment( $comment_id ); + if ( $comment ) { + $this->mirror_offer_insert( $comment_id, $comment ); + } + } + unset( $wpdb ); + } + + /** + * Remove mirror row on hp_offer deletion. + * + * @param int $comment_id Comment ID. + * @param object|\WP_Comment $comment Comment object. + */ + public function mirror_offer_delete( int $comment_id, $comment ): void { + if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::OFFER_COMMENT_TYPE ) { + return; + } + global $wpdb; + $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prefix . self::OFFER_TABLE, + array( 'comment_id' => $comment_id ), + array( '%d' ) + ); + } + + /** + * Doctor probe — request hot table + offer shadow table. + * + * @return array{ok:bool, message:string, details?:array} + */ + public function doctor_check(): array { + global $wpdb; + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { + return array( + 'ok' => false, + 'message' => 'wpdb global not available', + ); + } + $details = array(); + $ok = true; + foreach ( array( + 'hp_request_hot' => $wpdb->prefix . 'wpdo_hot_hp_request', + 'hp_offer_shadow' => $wpdb->prefix . self::OFFER_TABLE, + ) as $key => $table ) { + try { + $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + $details[ $key ] = array( + 'table' => $table, + 'exists' => $exists, + ); + if ( ! $exists ) { + $ok = false; + } + } catch ( \Throwable $e ) { + $details[ $key ] = array( + 'table' => $table, + 'error' => $e->getMessage(), + ); + $ok = false; + } + } + return array( + 'ok' => $ok, + 'message' => $ok ? 'hivepress-requests: tables OK' : 'hivepress-requests: one or more tables missing', + 'details' => $details, + ); + } + + /** + * 8-D self-score. + */ + public function suitability_score(): array { + return $this->compose_score( array() ); + } + + /** + * FSM modules this adapter contributes. + * + * @return array + */ + public function migrations(): array { + return array( + 'hot_hp_request', + 'comment_hp_offer', + ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-reviews-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-reviews-adapter.php new file mode 100644 index 0000000..8dae0ad --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-reviews-adapter.php @@ -0,0 +1,146 @@ +bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-reviews'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\Reviews\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_REVIEWS_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '1.4.0'; + } + + /** + * Doctor probe — verify entity-bridge table exists and is reachable. + * + * @return array{ok:bool, message:string, details?:array} + */ + public function doctor_check(): array { + global $wpdb; + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { + return array( + 'ok' => false, + 'message' => 'wpdb global not available', + ); + } + + $table = $wpdb->prefix . self::TABLE; + try { + $exists = (bool) $wpdb->get_var( + $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) + ); + if ( ! $exists ) { + return array( + 'ok' => false, + 'message' => sprintf( 'hivepress-reviews: %s missing (Comment entity-bridge not migrated)', $table ), + 'details' => array( 'table' => $table ), + ); + } + $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Sanitized via wpdb prefix. + return array( + 'ok' => true, + 'message' => sprintf( 'hivepress-reviews: %s rows=%d', $table, $rows ), + 'details' => array( + 'table' => $table, + 'rows' => $rows, + ), + ); + } catch ( \Throwable $e ) { + return array( + 'ok' => false, + 'message' => sprintf( 'hivepress-reviews doctor failed: %s', $e->getMessage() ), + ); + } + } + + /** + * 8-D self-score. + * + * D1: 1.0 — adapter never calls *_meta() (Review is comment-column based) + * D2: 1.0 — no hardcoded wp_comments references; uses wpdb prefix + * D3: 1.0 — entity-bridge table covers the model + * D4: 1.0 — table schema owned by Comment entity adapter (already documented) + * D5: 1.0 — no cross-adapter writes needed yet + * D6: 1.0 — zero options/transients written + * D7: 1.0 — only queries own entity-bridge table + * D8: 1.0 — comment-router uses (comment_post_ID, comment_type) covering index + */ + public function suitability_score(): array { + return $this->compose_score( array() ); + } + + /** + * FSM modules contributed (none — comment entity-bridge owns lifecycle). + * + * @return array + */ + public function migrations(): array { + return array( + // Comment entity-bridge migration is module 'comment_hp_review', + // owned by TMDO_Migration_Orchestrator — adapter is read-only. + ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-seo-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-seo-adapter.php new file mode 100644 index 0000000..599a088 --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-seo-adapter.php @@ -0,0 +1,76 @@ +bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-seo'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\Seo\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_SEO_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '1.0.0'; + } + + /** + * Doctor probe — filter-based addon has nothing to verify. + * + * @return array{ok:bool, message:string} + */ + public function doctor_check(): array { + return array( + 'ok' => true, + 'message' => 'hivepress-seo: filter-based addon, no anti-EAV surface', + ); + } + + /** + * 8-D self-score — perfect 10 (no EAV usage to optimize). + */ + public function suitability_score(): array { + return $this->compose_score( array() ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-social-links-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-social-links-adapter.php new file mode 100644 index 0000000..7636de1 --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-social-links-adapter.php @@ -0,0 +1,115 @@ + + */ + private const SOCIAL_KEYS = array( + 'hp_facebook_url', + 'hp_instagram_url', + 'hp_twitter_url', + 'hp_linkedin_url', + 'hp_youtube_url', + 'hp_telegram_url', + 'hp_whatsapp_url', + ); + + /** + * Constructor — bind register hooks via inherited trait. + */ + public function __construct() { + $this->bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-social-links'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\SocialLinks\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_SOCIAL_LINKS_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '1.0.0'; + } + + /** + * Register vendor social URL fields to Cold zone. + * + * @param TMDO_Schema_Registry $registry Schema registry singleton. + */ + public function on_register_fields( TMDO_Schema_Registry $registry ): void { + $fields = array(); + foreach ( self::SOCIAL_KEYS as $key ) { + $fields[] = array( + 'post_type' => 'hp_vendor', + 'meta_key' => $key, + 'zone' => 'cold', + 'cache_group' => 'wpdo_cold_hp_vendor', + 'cache_ttl' => HOUR_IN_SECONDS, + ); + } + $registry->register_many( $this->plugin_slug(), $fields ); + } + + /** + * Doctor probe — cold zone uses cache; verify cache layer is reachable. + * + * @return array{ok:bool, message:string} + */ + public function doctor_check(): array { + return array( + 'ok' => true, + 'message' => sprintf( 'hivepress-social-links: %d cold fields registered', count( self::SOCIAL_KEYS ) ), + ); + } + + /** + * 8-D self-score. + */ + public function suitability_score(): array { + return $this->compose_score( array() ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-statistics-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-statistics-adapter.php new file mode 100644 index 0000000..d483588 --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-statistics-adapter.php @@ -0,0 +1,130 @@ +bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-statistics'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\Statistics\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_STATISTICS_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '1.0.0'; + } + + /** + * Register counter fields to warm zone. + * + * @param TMDO_Schema_Registry $registry Schema registry singleton. + */ + public function on_register_fields( TMDO_Schema_Registry $registry ): void { + $registry->register_many( + $this->plugin_slug(), + array( + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_view_count', + 'zone' => 'warm', + 'ttl' => null, + ), + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_click_count', + 'zone' => 'warm', + 'ttl' => null, + ), + ) + ); + } + + /** + * Doctor probe — warm zone uses shared wp_wpdo_warm KV table. + * + * @return array{ok:bool, message:string} + */ + public function doctor_check(): array { + global $wpdb; + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { + return array( + 'ok' => false, + 'message' => 'wpdb global not available', + ); + } + $table = $wpdb->prefix . 'wpdo_warm'; + try { + $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + return array( + 'ok' => $exists, + 'message' => $exists + ? 'hivepress-statistics: warm zone (wpdo_warm) reachable' + : 'hivepress-statistics: wpdo_warm missing', + ); + } catch ( \Throwable $e ) { + return array( + 'ok' => false, + 'message' => sprintf( 'hivepress-statistics doctor failed: %s', $e->getMessage() ), + ); + } + } + + /** + * 8-D self-score — D8 reduced (warm zone is best-effort until addon installs). + */ + public function suitability_score(): array { + return $this->compose_score( + array( + 'd8_perf' => 0.9, + ) + ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/adapters/class-tmdo-hivepress-tags-adapter.php b/includes/hivepress/adapters/class-tmdo-hivepress-tags-adapter.php new file mode 100644 index 0000000..c06ea3e --- /dev/null +++ b/includes/hivepress/adapters/class-tmdo-hivepress-tags-adapter.php @@ -0,0 +1,84 @@ +bind_anti_eav_hooks(); + } + + /** Adapter slug (matches wp.org plugin slug). */ + public function plugin_slug(): string { + return 'hivepress-tags'; + } + + /** Class probed for addon presence. */ + public function detection_class(): string { + return 'HivePress\\Tags\\Plugin'; + } + + /** Version constant probed for addon presence. */ + public function detection_const(): string { + return 'HIVEPRESS_TAGS_VERSION'; + } + + /** Minimum addon version supported. */ + public function minimum_addon_version(): string { + return '1.1.0'; + } + + /** + * Doctor probe — term-based addon has no custom tables to verify. + * + * @return array{ok:bool, message:string} + */ + public function doctor_check(): array { + return array( + 'ok' => true, + 'message' => 'hivepress-tags: term-based addon, no anti-EAV surface', + ); + } + + /** + * 8-D self-score. + * + * All dimensions 1.0 because the addon uses term tables (not meta) — + * there is nothing to anti-EAV. The addon is structurally correct + * by construction, so the adapter has nothing to do beyond declare + * ownership for tooling. + */ + public function suitability_score(): array { + return $this->compose_score( array() ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/class-tmdo-hivepress-attribute-bridge.php b/includes/hivepress/class-tmdo-hivepress-attribute-bridge.php new file mode 100644 index 0000000..7658905 --- /dev/null +++ b/includes/hivepress/class-tmdo-hivepress-attribute-bridge.php @@ -0,0 +1,174 @@ +`). + * + * Per Karpathy guideline: surface tradeoffs, don't auto-promote. + * + * Usage: + * $bridge = new TMDO_HivePress_Attribute_Bridge(); + * $candidates = $bridge->discover_attributes(); + * // returns: [['slug' => 'beds', 'type' => 'integer', 'filterable' => true, 'searchable' => true, 'meta_key' => 'hp_listing_beds'], ...] + * + * @package WP_Data_Optimizer + * @since 3.0.0 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +if ( ! class_exists( 'TMDO_HivePress_Attribute_Bridge' ) ) { + + /** + * Discovers HivePress dynamic attributes that would benefit from hot promotion. + */ + final class TMDO_HivePress_Attribute_Bridge { + + /** + * Models whose attributes we inspect. + * + * @var array + */ + private const SUPPORTED_MODELS = array( 'listing', 'vendor' ); + + /** + * Discover promotion candidates across all supported HP models. + * + * Returns one record per attribute that meets the promotion bar: + * `filterable=true` (would appear in URL filters) OR `sortable=true` + * (would feed orderby). + * + * @return array + */ + public function discover_attributes(): array { + $out = array(); + foreach ( self::SUPPORTED_MODELS as $model ) { + $attributes = $this->fetch_attributes_for( $model ); + foreach ( $attributes as $slug => $config ) { + $record = $this->build_record( $model, (string) $slug, (array) $config ); + if ( $this->is_promotion_candidate( $record ) ) { + $out[] = $record; + } + } + } + return $out; + } + + /** + * Map of attribute records to suggested zone field configs (does NOT register). + * + * Use this output as input to a CLI promotion tool — it will pass each + * config to `TMDO_Schema_Registry::register()` and trigger schema migration. + * + * @return array> Field config records. + */ + public function suggest_zone_configs(): array { + $configs = array(); + foreach ( $this->discover_attributes() as $record ) { + $configs[] = array( + 'post_type' => 'hp_' . $record['model'], + 'meta_key' => $record['meta_key'], + 'zone' => 'hot', + 'data_type' => $this->sql_type_for( $record['type'] ), + 'column' => sanitize_key( $record['meta_key'] ), + 'indexed' => $record['filterable'] || $record['sortable'], + ); + } + return $configs; + } + + // ── Internal helpers ──────────────────────────────────────────────── + + /** + * Pull the attribute definitions registered for a given HP model. + * + * Goes through HivePress' own filter so any third-party attribute + * registrations are included. Returns empty when HivePress is not + * loaded (defensive — bridge can run during introspection tests). + * + * @param string $model 'listing' or 'vendor'. + * @return array Slug => attribute config. + */ + private function fetch_attributes_for( string $model ): array { + if ( ! function_exists( 'apply_filters' ) ) { + return array(); + } + $filter = sprintf( 'hivepress/v1/models/%s/attributes', $model ); + $attrs = apply_filters( $filter, array() ); + return is_array( $attrs ) ? $attrs : array(); + } + + /** + * Normalise one attribute config into our record shape. + * + * @param string $model HP model name. + * @param string $slug Attribute slug. + * @param array $cfg Raw attribute config from HivePress. + * @return array{model:string, slug:string, meta_key:string, type:string, filterable:bool, sortable:bool, searchable:bool} + */ + private function build_record( string $model, string $slug, array $cfg ): array { + return array( + 'model' => $model, + 'slug' => $slug, + 'meta_key' => sprintf( 'hp_%s_%s', $model, $slug ), + 'type' => (string) ( $cfg['edit_field']['type'] ?? $cfg['search_field']['type'] ?? 'text' ), + 'filterable' => (bool) ( $cfg['filterable'] ?? false ), + 'sortable' => (bool) ( $cfg['sortable'] ?? false ), + 'searchable' => (bool) ( $cfg['searchable'] ?? false ), + ); + } + + /** + * Whether an attribute is worth promoting to a hot column. + * + * @param array{model:string, slug:string, meta_key:string, type:string, filterable:bool, sortable:bool, searchable:bool} $record Attribute record. + */ + private function is_promotion_candidate( array $record ): bool { + // Filterable attributes always go through meta_query in archives. + // Sortable attributes go through `meta_key` in orderby. + // Either case = postmeta hot path → worth promoting. + return $record['filterable'] || $record['sortable']; + } + + /** + * Map HivePress field type → SQL column type for hot zone. + * + * Conservative defaults; over-sizing is safer than under-sizing. + * + * @param string $type HivePress field type. + */ + private function sql_type_for( string $type ): string { + switch ( $type ) { + case 'number': + case 'integer': + return 'int(11) NOT NULL DEFAULT 0'; + case 'decimal': + case 'price': + return 'decimal(10,2) DEFAULT NULL'; + case 'checkbox': + case 'boolean': + return 'tinyint(1) NOT NULL DEFAULT 0'; + case 'date': + return 'date DEFAULT NULL'; + case 'text': + case 'select': + default: + return "varchar(191) NOT NULL DEFAULT ''"; + } + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/class-tmdo-hivepress-benchmark.php b/includes/hivepress/class-tmdo-hivepress-benchmark.php new file mode 100644 index 0000000..e09ddaa --- /dev/null +++ b/includes/hivepress/class-tmdo-hivepress-benchmark.php @@ -0,0 +1,128 @@ + $name, + 'baseline_ms' => $baseline_ms, + 'target_ms' => $target_ms, + 'ratio' => $target_ms > 0.0 ? round( $baseline_ms / $target_ms, 2 ) : 0.0, + 'iterations' => $iterations, + ); + } + + /** + * Run a callable N times, return mean elapsed milliseconds. + * + * @param callable $callback Callable under test. + * @param int $iterations Iteration count. + */ + public static function run( callable $callback, int $iterations ): float { + $total = 0.0; + for ( $i = 0; $i < $iterations; $i++ ) { + $start = microtime( true ); + $callback(); + $total += ( microtime( true ) - $start ) * 1000.0; + } + return round( $total / $iterations, 3 ); + } + + /** + * Run a sequence of comparisons and produce a summary report. + * + * @param array $samples Sample list. + * + * @return array{samples:array>, geomean_ratio:float, sample_count:int} + */ + public static function run_suite( array $samples ): array { + $results = array(); + $ratios = array(); + foreach ( $samples as $sample ) { + if ( ! isset( $sample['name'], $sample['baseline'], $sample['target'] ) ) { + continue; + } + $result = self::compare( + (string) $sample['name'], + $sample['baseline'], + $sample['target'], + (int) ( $sample['iterations'] ?? self::DEFAULT_ITERATIONS ) + ); + $results[] = $result; + if ( $result['ratio'] > 0.0 ) { + $ratios[] = $result['ratio']; + } + } + + return array( + 'samples' => $results, + 'geomean_ratio' => self::geometric_mean( $ratios ), + 'sample_count' => count( $results ), + ); + } + + /** + * Geometric mean — preferred summary statistic for ratios because it + * doesn't bias toward outliers like arithmetic mean does. + * + * @param array $values Ratios. + */ + public static function geometric_mean( array $values ): float { + if ( empty( $values ) ) { + return 0.0; + } + $product = 1.0; + foreach ( $values as $v ) { + $product *= max( 0.0001, (float) $v ); + } + return round( pow( $product, 1.0 / count( $values ) ), 2 ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/class-tmdo-hivepress-bootstrap.php b/includes/hivepress/class-tmdo-hivepress-bootstrap.php new file mode 100644 index 0000000..d271b28 --- /dev/null +++ b/includes/hivepress/class-tmdo-hivepress-bootstrap.php @@ -0,0 +1,242 @@ + 'TMDO_HivePress_Core_Adapter', + 'hivepress-reviews' => 'TMDO_HivePress_Reviews_Adapter', + 'hivepress-favorites' => 'TMDO_HivePress_Favorites_Adapter', + 'hivepress-messages' => 'TMDO_HivePress_Messages_Adapter', + 'hivepress-memberships' => 'TMDO_HivePress_Memberships_Adapter', + 'hivepress-requests' => 'TMDO_HivePress_Requests_Adapter', + 'hivepress-tags' => 'TMDO_HivePress_Tags_Adapter', + 'hivepress-seo' => 'TMDO_HivePress_Seo_Adapter', + 'hivepress-social-links' => 'TMDO_HivePress_Social_Links_Adapter', + 'hivepress-blocks' => 'TMDO_HivePress_Blocks_Adapter', + 'hivepress-bookings' => 'TMDO_HivePress_Bookings_Adapter', + 'hivepress-marketplace' => 'TMDO_HivePress_Marketplace_Adapter', + 'hivepress-statistics' => 'TMDO_HivePress_Statistics_Adapter', + ); + + /** + * Whether boot() has already run this request. + * + * @var bool + */ + private static bool $booted = false; + + /** + * Instantiated adapters keyed by addon slug. + * + * @var array + */ + private static array $adapters = array(); + + /** + * Detection snapshot at boot time (captured for diagnostics). + * + * @var array + */ + private static array $detected = array(); + + /** + * Boot the integration. + * + * @return void + */ + public static function boot(): void { + if ( self::$booted ) { + return; + } + self::$booted = true; + + // Step 1: conflict guard. Detection + admin_notice + filter install. + if ( class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) { + TMDO_HivePress_Conflict_Guard::check_and_warn(); + } + + // Step 2: detect addons. Empty array on non-HP sites = zero overhead. + self::$detected = class_exists( 'TMDO_HivePress_Detector' ) + ? TMDO_HivePress_Detector::detect() + : array(); + + if ( empty( self::$detected ) ) { + return; + } + + // Step 3: respect the should_bind filter (conflict guard short-circuits here). + $context = array( + 'detected' => self::$detected, + 'adapters' => self::ADAPTER_MAP, + 'conflicts' => class_exists( 'TMDO_HivePress_Conflict_Guard' ) + ? TMDO_HivePress_Conflict_Guard::detect_conflicts() + : array(), + ); + $should = apply_filters( 'wpdo_hivepress_should_bind', true, $context ); + if ( ! $should ) { + return; + } + + // Step 4: instantiate + bind each adapter. + foreach ( self::$detected as $slug => $version ) { + self::bind_adapter( $slug, $version ); + } + + // Step 5: register cross-cutting components (router + cron optimizer). + // Both are gated by their own opt-in option and only activate when + // the operator flips it; safe to call here unconditionally. + if ( class_exists( 'TMDO_HivePress_Comment_Router' ) ) { + TMDO_HivePress_Comment_Router::register(); + } + if ( class_exists( 'TMDO_HivePress_Cron_Optimizer' ) ) { + TMDO_HivePress_Cron_Optimizer::register(); + } + + /** + * Action: wpdo_hivepress_booted + * + * Fires after all adapters are bound. Listeners can introspect + * `TMDO_HivePress_Bootstrap::adapters()` for the active set. + * + * @param array $detected Slug → version map. + * @param array $adapters Slug → adapter instance. + */ + do_action( 'wpdo_hivepress_booted', self::$detected, self::$adapters ); + } + + /** + * Force re-boot on next call (for tests + plugin activation). + * + * @internal + */ + public static function reset_for_tests(): void { + self::$booted = false; + self::$adapters = array(); + self::$detected = array(); + } + + /** + * Read-only accessor for active adapters. + * + * @return array + */ + public static function adapters(): array { + return self::$adapters; + } + + /** + * Read-only accessor for detection snapshot at boot time. + * + * @return array + */ + public static function detected(): array { + return self::$detected; + } + + /** + * Look up the adapter for a given addon slug, or null when none active. + * + * @param string $slug Addon slug. + */ + public static function adapter_for( string $slug ): ?object { + return self::$adapters[ $slug ] ?? null; + } + + // ── Internal: per-adapter binding ─────────────────────────────────── + + /** + * Instantiate and wire one adapter's lifecycle hooks. + * + * @param string $slug Detected addon slug. + * @param string $version Detected version string ('' if unknown). + */ + private static function bind_adapter( string $slug, string $version ): void { + $class = self::ADAPTER_MAP[ $slug ] ?? ''; + if ( '' === $class || ! class_exists( $class ) ) { + // Adapter for this slug not yet implemented (future Sprint). + return; + } + + try { + $adapter = new $class(); + } catch ( \Throwable $e ) { + if ( class_exists( 'TMDO_Logger' ) && method_exists( 'TMDO_Logger', 'error' ) ) { + TMDO_Logger::error( + 'hivepress-integration', + sprintf( 'Failed to instantiate %s: %s', $class, $e->getMessage() ) + ); + } + return; + } + + if ( ! $adapter instanceof TMDO_HivePress_Adapter ) { + if ( class_exists( 'TMDO_Logger' ) && method_exists( 'TMDO_Logger', 'warn' ) ) { + TMDO_Logger::warn( + 'hivepress-integration', + sprintf( '%s does not implement TMDO_HivePress_Adapter — skipping', $class ) + ); + } + return; + } + + // Version floor check. + $min = $adapter->minimum_addon_version(); + if ( '' !== $min && '' !== $version && version_compare( $version, $min, '<' ) ) { + if ( class_exists( 'TMDO_Logger' ) && method_exists( 'TMDO_Logger', 'warn' ) ) { + TMDO_Logger::warn( + 'hivepress-integration', + sprintf( '%s installed version %s is below minimum %s — adapter idle', $slug, $version, $min ) + ); + } + return; + } + + self::$adapters[ $slug ] = $adapter; + + // Adapter constructor is contractually responsible for calling + // `$this->bind_anti_eav_hooks()` (inherited from + // TMDO_Anti_EAV_Aware via TMDO_HivePress_Adapter_Trait) so the + // three `wpdo_register_*` actions get wired. Bootstrap then only + // triggers the addon-specific extras: query/event hooks. + $adapter->on_register_query_hooks(); + $adapter->on_register_event_hooks(); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/class-tmdo-hivepress-comment-router.php b/includes/hivepress/class-tmdo-hivepress-comment-router.php new file mode 100644 index 0000000..cb37e5a --- /dev/null +++ b/includes/hivepress/class-tmdo-hivepress-comment-router.php @@ -0,0 +1,166 @@ + + */ + private const SHADOW_MAP = array( + 'hp_message' => 'wpdo_comment_hp_message', + 'hp_favorite' => 'wpdo_comment_hp_favorite', + 'hp_offer' => 'wpdo_comment_hp_offer', + 'hp_review' => 'wpdo_comment_hp_review', + ); + + /** + * Whether the router is bound this request. + * + * @var bool + */ + private static bool $bound = false; + + /** + * Bind the comments_clauses filter (idempotent). + * + * Bootstrap calls this from each adapter's on_register_query_hooks() + * so router activation follows the same gate as adapter binding. + */ + public static function register(): void { + if ( self::$bound ) { + return; + } + if ( ! self::is_enabled() ) { + return; + } + add_filter( 'comments_clauses', array( __CLASS__, 'rewrite' ), 20, 2 ); + self::$bound = true; + } + + /** + * Reset internal state (test only). + * + * @internal + */ + public static function reset_for_tests(): void { + self::$bound = false; + } + + /** + * Whether router is enabled via the operator option. + */ + public static function is_enabled(): bool { + if ( ! function_exists( 'get_option' ) ) { + return false; + } + return (bool) (int) get_option( self::OPTION_ENABLED, 0 ); + } + + /** + * Filter callback: rewrite comments_clauses for known hp comment types. + * + * @param array $clauses SQL clauses (join, where, fields, ...). + * @param \WP_Comment_Query $query The query object. + * @return array Possibly-modified clauses. + */ + public static function rewrite( array $clauses, $query ): array { + $type = self::query_comment_type( $query ); + if ( '' === $type || ! isset( self::SHADOW_MAP[ $type ] ) ) { + return $clauses; + } + + global $wpdb; + $shadow = $wpdb->prefix . self::SHADOW_MAP[ $type ]; + $alias = 'wpdo_shadow'; + + // LEFT JOIN keeps row visibility when shadow lags (during backfill). + $join = ( $clauses['join'] ?? '' ) . " LEFT JOIN `{$shadow}` AS {$alias} ON {$alias}.comment_id = {$wpdb->comments}.comment_ID"; + + // Filter pushdown: hp_message recipient lookup uses comment_karma in HP. + // We rewrite WHERE clauses that filter by comment_karma (= recipient hack) + // to use the indexed shadow column. + $where = (string) ( $clauses['where'] ?? '' ); + if ( 'hp_message' === $type && '' !== $where ) { + $where = preg_replace( + '/' . preg_quote( $wpdb->comments, '/' ) . '\.comment_karma\s*=\s*(\d+)/', + $alias . '.recipient_id = $1', + $where + ); + } + + $clauses['join'] = $join; + $clauses['where'] = $where; + + /** + * Filter: wpdo_hivepress_comment_router_clauses + * + * Allows fine-grained tweaks per comment_type. Intended for adapter + * tests + future custom-column lookups. + * + * @param array $clauses Modified clauses. + * @param string $type Matched comment_type. + * @param string $alias Shadow table alias. + */ + return apply_filters( 'wpdo_hivepress_comment_router_clauses', $clauses, $type, $alias ); + } + + /** + * Extract the comment_type from a WP_Comment_Query. + * + * @param \WP_Comment_Query $query Query object. + */ + private static function query_comment_type( $query ): string { + if ( ! is_object( $query ) || ! isset( $query->query_vars ) ) { + return ''; + } + $type = $query->query_vars['type'] ?? ''; + if ( is_array( $type ) ) { + $type = reset( $type ); + } + return is_string( $type ) ? $type : ''; + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/class-tmdo-hivepress-conflict-guard.php b/includes/hivepress/class-tmdo-hivepress-conflict-guard.php new file mode 100644 index 0000000..56523d2 --- /dev/null +++ b/includes/hivepress/class-tmdo-hivepress-conflict-guard.php @@ -0,0 +1,266 @@ +, consts:array, name:string}> + */ + private const CONFLICTS = array( + 'hp-custom-tables' => array( + 'classes' => array( + 'HPCT_Core', + 'HPCT_Plugin', + 'HP_Custom_Tables', + ), + 'consts' => array( + 'HPCT_VERSION', + 'HP_CUSTOM_TABLES_VERSION', + ), + 'name' => 'HP Custom Tables', + ), + 'hp-info-cards' => array( + 'classes' => array( + 'HP_Info_Cards', + 'HPIC_Plugin', + ), + 'consts' => array( + 'HP_INFO_CARDS_VERSION', + 'HPIC_VERSION', + ), + 'name' => 'HP Info Cards', + ), + ); + + /** + * Memoized detection result for the current request. + * + * @var array|null + */ + private static ?array $memo = null; + + /** + * Whether the admin_notices hook has been bound this request. + * + * @var bool + */ + private static bool $notice_bound = false; + + /** + * Run the conflict check at boot time. + * + * Called by `TMDO_HivePress_Bootstrap::boot()` BEFORE adapter binding + * so the `wpdo_hivepress_should_bind` filter has chance to short-circuit + * adapter wiring when conflicts exist. + */ + public static function check_and_warn(): void { + $conflicts = self::detect_conflicts(); + if ( empty( $conflicts ) ) { + return; + } + + self::log_conflicts( $conflicts ); + self::ensure_notice_bound(); + self::install_should_bind_short_circuit( $conflicts ); + } + + /** + * Detect currently-active legacy plugins. + * + * @return array Slugs of detected conflicting plugins. + */ + public static function detect_conflicts(): array { + if ( null !== self::$memo ) { + return self::$memo; + } + + $out = array(); + foreach ( self::CONFLICTS as $slug => $probe ) { + if ( self::probe_one( $probe ) ) { + $out[] = $slug; + } + } + + self::$memo = $out; + return $out; + } + + /** + * Reset internal state for unit tests. + * + * @internal + */ + public static function reset_for_tests(): void { + self::$memo = null; + self::$notice_bound = false; + } + + /** + * Whether any conflict is currently active. + */ + public static function has_conflict(): bool { + return ! empty( self::detect_conflicts() ); + } + + /** + * Render the admin notice (bound only when conflicts exist). + * + * @internal Called by WordPress; do not call directly. + */ + public static function render_notice(): void { + if ( ! function_exists( 'is_admin' ) || ! is_admin() ) { + return; + } + $conflicts = self::detect_conflicts(); + if ( empty( $conflicts ) ) { + return; + } + + $names = array_map( + static function ( string $slug ): string { + $probe = self::CONFLICTS[ $slug ] ?? null; + if ( null === $probe ) { + return $slug; + } + $name = (string) ( $probe['name'] ?? $slug ); + return esc_html( $name ); + }, + $conflicts + ); + + $message = sprintf( + /* translators: %s: comma-separated list of conflicting plugin names. */ + __( 'wp-data-optimizer detected legacy HivePress data-layer plugins: %s. WPDO HivePress integration is paused to prevent dual-write corruption. Run `wp wpdo hivepress migrate-from-hpct` to migrate, then deactivate the legacy plugins.', 'tmdo-hivepress' ), + implode( ', ', $names ) + ); + + printf( + '

%s

%s

', + esc_html__( 'WP Data Optimizer — HivePress integration paused', 'tmdo-hivepress' ), + esc_html( $message ) + ); + } + + // ── Internal helpers ──────────────────────────────────────────────── + + /** + * Probe a single legacy plugin for presence. + * + * @param array{classes:array, consts:array, name:string} $probe Probe spec. + */ + private static function probe_one( array $probe ): bool { + foreach ( (array) $probe['classes'] as $cls ) { + if ( '' !== $cls && class_exists( $cls ) ) { + return true; + } + } + foreach ( (array) $probe['consts'] as $const ) { + if ( '' !== $const && defined( $const ) ) { + return true; + } + } + return false; + } + + /** + * Log conflicts to WPDO error log so audit script picks them up. + * + * @param array $conflicts Conflict slugs. + */ + private static function log_conflicts( array $conflicts ): void { + if ( ! class_exists( 'TMDO_Logger' ) ) { + return; + } + + $message = sprintf( + 'Conflict detected: %s. wp-data-optimizer fully supersedes these. Run: wp wpdo hivepress migrate-from-hpct', + implode( ', ', $conflicts ) + ); + + // Prefer warn() but fall back to log() if the API differs. + if ( method_exists( 'TMDO_Logger', 'warn' ) ) { + TMDO_Logger::warn( 'hivepress-integration', $message ); + } elseif ( method_exists( 'TMDO_Logger', 'log' ) ) { + TMDO_Logger::log( 'warning', 'hivepress-integration', $message ); + } + } + + /** + * Bind the admin notice exactly once per request. + */ + private static function ensure_notice_bound(): void { + if ( self::$notice_bound ) { + return; + } + if ( function_exists( 'add_action' ) ) { + add_action( 'admin_notices', array( __CLASS__, 'render_notice' ) ); + } + self::$notice_bound = true; + } + + /** + * Install the short-circuit so HivePress adapter binding is skipped. + * + * Bootstrap reads `apply_filters('wpdo_hivepress_should_bind', true, $context)` + * before binding adapters. We force `false` whenever conflicts exist + * so dual-write cannot occur. The conflict context is passed to filter + * so other code can react. + * + * @param array $conflicts Conflict slugs. + */ + private static function install_should_bind_short_circuit( array $conflicts ): void { + if ( ! function_exists( 'add_filter' ) ) { + return; + } + + add_filter( + 'wpdo_hivepress_should_bind', + static function ( $should_bind, $context = array() ) use ( $conflicts ) { + unset( $context ); + return false; + // Note: $conflicts captured purely for greppability via closure inspection. + // PHPCS may flag the `unset` if context is unused, but keeping the symmetry + // with the documented filter signature. + }, + 1, + 2 + ); + + unset( $conflicts ); // Satisfy use-after for static analysis. + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/class-tmdo-hivepress-cron-optimizer.php b/includes/hivepress/class-tmdo-hivepress-cron-optimizer.php new file mode 100644 index 0000000..ec77e3b --- /dev/null +++ b/includes/hivepress/class-tmdo-hivepress-cron-optimizer.php @@ -0,0 +1,146 @@ +prefix . 'wpdo_hot_hp_listing'; + $now = time(); + + // Indexed range scan replaces O(N) postmeta walk. + $ids = (array) $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is wpdb prefix + literal. + $wpdb->prepare( + "SELECT post_id FROM `{$table}` WHERE expired_time > 0 AND expired_time <= %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is wpdb prefix + literal. + $now + ) + ); + + $count = 0; + foreach ( $ids as $post_id ) { + $post_id = (int) $post_id; + if ( $post_id <= 0 ) { + continue; + } + do_action( 'hivepress/v1/models/listing/expire', $post_id ); + ++$count; + } + + return $count; + } + + /** + * Whether the `hot_hp_listing` module is in a state where the hot + * table reflects current truth (cutover or complete). + */ + private static function module_can_read(): bool { + if ( ! class_exists( 'TMDO_Feature_Flags' ) ) { + return false; + } + return TMDO_Feature_Flags::is_query_active( self::MODULE ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/class-tmdo-hivepress-detector.php b/includes/hivepress/class-tmdo-hivepress-detector.php new file mode 100644 index 0000000..43a129f --- /dev/null +++ b/includes/hivepress/class-tmdo-hivepress-detector.php @@ -0,0 +1,260 @@ + 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 WP_Data_Optimizer + * @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|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 + */ + 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 + */ + 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; + } + + // Short-circuit: if HivePress core is not present, no addon can be loaded. + if ( ! self::probe_one( self::PROBES['hivepress'] ) ) { + 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 ) ) { + $found[ $slug ] = self::version_for( $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 + */ + 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 ───────────────────────────────────────────────── + + /** + * 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. + * + * @param array{class:string, const:string, name:string} $probe Probe definition. + */ + private static function version_for( array $probe ): string { + $const = (string) ( $probe['const'] ?? '' ); + if ( '' !== $const && defined( $const ) ) { + $value = constant( $const ); + if ( is_string( $value ) || is_numeric( $value ) ) { + return (string) $value; + } + } + return ''; + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/class-tmdo-hivepress-rest.php b/includes/hivepress/class-tmdo-hivepress-rest.php new file mode 100644 index 0000000..ae30435 --- /dev/null +++ b/includes/hivepress/class-tmdo-hivepress-rest.php @@ -0,0 +1,168 @@ + WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_status' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/hivepress/score', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_score' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/hivepress/health', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_health' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + } + + /** + * Capability check — operator-only (manage_options). + * + * @return bool|\WP_Error + */ + public function check_permission() { + if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'wpdo_hp_forbidden', + __( 'You do not have permission to read HivePress integration data.', 'tmdo-hivepress' ), + array( 'status' => 403 ) + ); + } + return true; + } + + /** + * GET /hivepress/status — detection + binding state. + */ + public function get_status() { + $detected = class_exists( 'TMDO_HivePress_Detector' ) + ? TMDO_HivePress_Detector::detect() + : array(); + $catalog = class_exists( 'TMDO_HivePress_Detector' ) + ? TMDO_HivePress_Detector::catalog() + : array(); + $bound = class_exists( 'TMDO_HivePress_Bootstrap' ) + ? array_keys( TMDO_HivePress_Bootstrap::adapters() ) + : array(); + $conflict = class_exists( 'TMDO_HivePress_Conflict_Guard' ) + ? TMDO_HivePress_Conflict_Guard::detect_conflicts() + : array(); + + $addons = array(); + foreach ( $catalog as $slug => $name ) { + $addons[] = array( + 'slug' => $slug, + 'name' => $name, + 'detected' => isset( $detected[ $slug ] ), + 'version' => $detected[ $slug ] ?? null, + 'bound' => in_array( $slug, $bound, true ), + ); + } + + return new WP_REST_Response( + array( + 'addons' => $addons, + 'conflicts' => $conflict, + 'totals' => array( + 'detected' => count( $detected ), + 'bound' => count( $bound ), + 'catalog' => count( $catalog ), + ), + ), + 200 + ); + } + + /** + * GET /hivepress/score — 8-D suitability report. + */ + public function get_score() { + if ( ! class_exists( 'TMDO_HivePress_Suitability_Scorer' ) ) { + return new WP_REST_Response( array( 'error' => 'scorer_unavailable' ), 503 ); + } + return new WP_REST_Response( TMDO_HivePress_Suitability_Scorer::report(), 200 ); + } + + /** + * GET /hivepress/health — doctor probes for each adapter. + */ + public function get_health() { + $adapters = class_exists( 'TMDO_HivePress_Bootstrap' ) + ? TMDO_HivePress_Bootstrap::adapters() + : array(); + + $probes = array(); + $ok = true; + foreach ( $adapters as $slug => $adapter ) { + if ( ! $adapter instanceof TMDO_HivePress_Adapter ) { + continue; + } + $probe = $adapter->doctor_check(); + $probes[ $slug ] = $probe; + if ( empty( $probe['ok'] ) ) { + $ok = false; + } + } + + return new WP_REST_Response( + array( + 'ok' => $ok, + 'probes' => $probes, + ), + 200 + ); + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/class-tmdo-hivepress-suitability-scorer.php b/includes/hivepress/class-tmdo-hivepress-suitability-scorer.php new file mode 100644 index 0000000..8ff6513 --- /dev/null +++ b/includes/hivepress/class-tmdo-hivepress-suitability-scorer.php @@ -0,0 +1,141 @@ +>, per_dimension:array, aggregate:float, adapter_count:int} + */ + public static function report(): array { + $adapters = class_exists( 'TMDO_HivePress_Bootstrap' ) + ? TMDO_HivePress_Bootstrap::adapters() + : array(); + + $per_adapter = array(); + foreach ( $adapters as $slug => $adapter ) { + if ( $adapter instanceof TMDO_HivePress_Adapter ) { + $per_adapter[ $slug ] = $adapter->suitability_score(); + } + } + + return array( + 'adapters' => $per_adapter, + 'per_dimension' => self::compute_per_dimension( $per_adapter ), + 'aggregate' => self::compute_aggregate( $per_adapter ), + 'adapter_count' => count( $per_adapter ), + ); + } + + /** + * Compute the per-dimension average across all adapter reports. + * + * @param array> $per_adapter Adapter score map. + * @return array Dimension key → average value. + */ + public static function compute_per_dimension( array $per_adapter ): array { + $out = array(); + if ( empty( $per_adapter ) ) { + foreach ( self::DIMENSIONS as $d ) { + $out[ $d ] = 0.0; + } + return $out; + } + + foreach ( self::DIMENSIONS as $d ) { + $sum = 0.0; + foreach ( $per_adapter as $score ) { + $sum += (float) ( $score[ $d ] ?? 0.0 ); + } + $out[ $d ] = round( $sum / count( $per_adapter ), 2 ); + } + return $out; + } + + /** + * Compute the site-wide aggregate (mean of adapter aggregates). + * + * @param array> $per_adapter Adapter score map. + */ + public static function compute_aggregate( array $per_adapter ): float { + if ( empty( $per_adapter ) ) { + return 0.0; + } + $sum = 0.0; + foreach ( $per_adapter as $score ) { + $sum += (float) ( $score['aggregate'] ?? 0.0 ); + } + return round( $sum / count( $per_adapter ), 2 ); + } + + /** + * Identify which dimensions are dragging the aggregate down. + * + * Returns adapter slugs whose given dimension is below the threshold. + * + * @param string $dimension Dimension key (e.g. 'd8_perf'). + * @param float $threshold Minimum acceptable value (default 1.0). + * @return array Slugs needing attention for this dimension. + */ + public static function adapters_below_threshold( string $dimension, float $threshold = 1.0 ): array { + if ( ! in_array( $dimension, self::DIMENSIONS, true ) ) { + return array(); + } + + $report = self::report(); + $flagged = array(); + foreach ( $report['adapters'] as $slug => $score ) { + if ( ( (float) ( $score[ $dimension ] ?? 0.0 ) ) < $threshold ) { + $flagged[] = $slug; + } + } + return $flagged; + } + } + +} // end if ( ! class_exists ) diff --git a/includes/hivepress/interface-tmdo-hp-adapter.php b/includes/hivepress/interface-tmdo-hp-adapter.php new file mode 100644 index 0000000..087cb6d --- /dev/null +++ b/includes/hivepress/interface-tmdo-hp-adapter.php @@ -0,0 +1,172 @@ +} + */ + public function doctor_check(): array; + + /** + * Module names this adapter contributes to the 7-state FSM. + * + * Each module is independently transitionable via `wp wpdo migrate + * ` etc. Names MUST be unique across adapters and align with + * `TMDO_Feature_Flags::ZONE_MODULES` registry conventions. + * + * @return array e.g. ['hot_hp_listing', 'hot_hp_request'] + */ + public function migrations(): array; + } + +} // end if ( ! interface_exists ) diff --git a/includes/hivepress/trait-tmdo-hp-adapter.php b/includes/hivepress/trait-tmdo-hp-adapter.php new file mode 100644 index 0000000..86c6bd7 --- /dev/null +++ b/includes/hivepress/trait-tmdo-hp-adapter.php @@ -0,0 +1,272 @@ +compose_score( array() ); + } + + /** + * Default doctor probe — returns ok with adapter name. + * + * Adapters with custom tables MUST override and verify table existence. + * + * @return array{ok:bool, message:string, details?:array} + */ + public function doctor_check(): array { + return array( + 'ok' => true, + 'message' => sprintf( '%s: adapter loaded, no custom tables to probe', $this->plugin_slug() ), + ); + } + + /** + * Default no FSM migrations (adapter is read-only or zero hot fields). + * + * @return array + */ + public function migrations(): array { + return array(); + } + + // ── Helper: zone-aware field registration shortcuts ───────────────── + + /** + * Register a single hot zone field (varchar/int/decimal column on `wp_wpdo_hot_`). + * + * @param TMDO_Schema_Registry $registry Provided by `wpdo_register_fields` hook. + * @param string $post_type WordPress post type (e.g. 'hp_listing'). + * @param string $meta_key wp_postmeta meta_key. + * @param string $data_type SQL column type (e.g. "tinyint(1) NOT NULL DEFAULT 0"). + * @param array $extra Optional overrides: column, indexed, etc. + */ + protected function register_hot( TMDO_Schema_Registry $registry, string $post_type, string $meta_key, string $data_type, array $extra = array() ): void { + $config = array_merge( + array( + 'post_type' => $post_type, + 'meta_key' => $meta_key, + 'zone' => 'hot', + 'data_type' => $data_type, + 'column' => sanitize_key( $meta_key ), + 'indexed' => false, + ), + $extra + ); + $this->register_field( $registry, $config ); + } + + /** + * Register a single cold zone field (object cache + JSON blob fallback). + * + * @param TMDO_Schema_Registry $registry Provided by `wpdo_register_fields` hook. + * @param string $post_type WordPress post type. + * @param string $meta_key wp_postmeta meta_key. + * @param int $cache_ttl Cache TTL in seconds (default 1 hour). + * @param array $extra Optional overrides. + */ + protected function register_cold( TMDO_Schema_Registry $registry, string $post_type, string $meta_key, int $cache_ttl = 3600, array $extra = array() ): void { + $config = array_merge( + array( + 'post_type' => $post_type, + 'meta_key' => $meta_key, + 'zone' => 'cold', + 'cache_group' => 'wpdo_cold_' . sanitize_key( $post_type ), + 'cache_ttl' => $cache_ttl, + ), + $extra + ); + $this->register_field( $registry, $config ); + } + + /** + * Register a single warm zone field (TTL counter / temp value). + * + * @param TMDO_Schema_Registry $registry Provided by `wpdo_register_fields` hook. + * @param string $post_type WordPress post type. + * @param string $meta_key wp_postmeta meta_key. + * @param int|null $ttl TTL in seconds (null = no expiry). + * @param array $extra Optional overrides. + */ + protected function register_warm( TMDO_Schema_Registry $registry, string $post_type, string $meta_key, ?int $ttl = null, array $extra = array() ): void { + $config = array_merge( + array( + 'post_type' => $post_type, + 'meta_key' => $meta_key, + 'zone' => 'warm', + 'ttl' => $ttl, + ), + $extra + ); + $this->register_field( $registry, $config ); + } + + /** + * Subscribe to writes on another adapter's keys (cross-adapter coordination). + * + * Wraps `TMDO_Anti_EAV_Aware::on_after_write()` with a description hook + * so the cross-adapter linkage is greppable for the suitability scorer's + * D5 (Hook Bus Integration) check. + * + * @param string $key_prefix Meta key prefix (e.g. 'hp_sold_out_'). + * @param callable $callback Receives (entity_type, entity_id, key, value, ok, op, before). + */ + protected function on_after_addon_write( string $key_prefix, callable $callback ): void { + $this->on_after_write( $key_prefix, $callback ); + } + + // ── Helper: 8-dimension score composition ─────────────────────────── + + /** + * Compose an 8-dimension score, filling missing keys with 1.0 and + * computing the aggregate as `sum / 8 * 1.25` (so 1.0 across all + * → 10.0). + * + * @param array $partial Override values keyed by `d1_meta_calls`...`d8_perf`. + * @return array{d1_meta_calls:float, d2_meta_sql:float, d3_table_coverage:float, d4_registry_meta:float, d5_hook_bus:float, d6_options:float, d7_coupling:float, d8_perf:float, aggregate:float} + */ + protected function compose_score( array $partial ): array { + $defaults = array( + 'd1_meta_calls' => 1.0, + 'd2_meta_sql' => 1.0, + 'd3_table_coverage' => 1.0, + 'd4_registry_meta' => 1.0, + 'd5_hook_bus' => 1.0, + 'd6_options' => 1.0, + 'd7_coupling' => 1.0, + 'd8_perf' => 1.0, + ); + $score = array_merge( $defaults, $partial ); + + // Clamp every dimension to [0.0, 1.0]. + foreach ( $defaults as $k => $_ ) { + $score[ $k ] = max( 0.0, min( 1.0, (float) ( $score[ $k ] ?? 1.0 ) ) ); + } + + $sum = array_sum( array_intersect_key( $score, $defaults ) ); + $score['aggregate'] = round( $sum / 8.0 * 10.0, 2 ); + + return $score; + } + } + +} // end if ( ! trait_exists ) diff --git a/includes/interceptors/class-tmdo-favorites-interceptor.php b/includes/interceptors/class-tmdo-favorites-interceptor.php new file mode 100644 index 0000000..c24abad --- /dev/null +++ b/includes/interceptors/class-tmdo-favorites-interceptor.php @@ -0,0 +1,117 @@ +is_active() ) { + return $check; + } + + try { + $this->sync_favorites( $user_id, $meta_value ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'update_user_metadata', $e->getMessage() ); + } + + return $check; + } + + /** + * Deletes favorites records when a listing post is deleted. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @return void + */ + public function action_delete_post( int $post_id, \WP_Post $post ): void { + if ( 'hp_listing' !== $post->post_type || ! $this->is_active() ) { + return; + } + + try { + global $wpdb; + $wpdb->delete( TMDO_DB::table( 'hpct_favorites' ), array( 'listing_id' => $post_id ), array( '%d' ) ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() ); + } + } + + /** + * Syncs user favorites to the hpct_favorites table. + * + * @param int $user_id User ID. + * @param mixed $meta_value Favorites meta value (array or serialized). + * @return void + */ + private function sync_favorites( int $user_id, $meta_value ): void { + global $wpdb; + $table = TMDO_DB::table( 'hpct_favorites' ); + $now = TMDO_DB::now(); + + // v2.13.3: object-injection-safe unserialize (fixes L-DESER-1). + // Input is user-controlled wp_usermeta value via update_user_meta hook. + $listing_ids = is_array( $meta_value ) + ? $meta_value + : (array) TMDO_Safe_Unserialize::run( $meta_value ); + $listing_ids = array_filter( array_map( 'absint', $listing_ids ) ); + + // Delete all existing and re-insert. + $wpdb->delete( $table, array( 'user_id' => $user_id ), array( '%d' ) ); + + $insert_sql = TMDO_IS_SQLITE ? 'INSERT OR IGNORE' : 'INSERT IGNORE'; + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- insert_sql is 'INSERT IGNORE'/'INSERT OR IGNORE'; table name from TMDO_DB::table(). + foreach ( $listing_ids as $listing_id ) { + $wpdb->query( + $wpdb->prepare( + "{$insert_sql} INTO `{$table}` (user_id, listing_id, created_at) VALUES (%d, %d, %s)", + $user_id, + $listing_id, + $now + ) + ); + } + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } +} diff --git a/includes/interceptors/class-tmdo-listing-meta-interceptor.php b/includes/interceptors/class-tmdo-listing-meta-interceptor.php new file mode 100644 index 0000000..bd4e9d6 --- /dev/null +++ b/includes/interceptors/class-tmdo-listing-meta-interceptor.php @@ -0,0 +1,339 @@ +get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + $exists = ( null !== $found && '' !== $found ); + return $exists; + } + + /** + * Reset cached table_exists state — for tests and PR-3 schema changes. + * + * @internal + */ + public static function reset_table_exists_cache(): void { + // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged + // Use reflection to clear the static. Cleanest approach in PHP 8.1+. + ( function () { + static $exists = null; + $exists = null; + } )(); + // The above closure does not actually reset the bound static of table_exists(). + // Instead we expose a flag via a class-level static. + self::$table_exists_cache_invalidated_at = microtime( true ); + } + + /** + * Marker for cache invalidation. Real reset happens by re-calling table_exists() + * in a fresh process; tests should isolate via runInSeparateProcess where needed. + * + * @var float + */ + private static float $table_exists_cache_invalidated_at = 0.0; + + /** + * Records a single "skipped — table missing" entry in wpdo_errors per request. + * + * Direct INSERT (not via TMDO_Logger::error) so we avoid cascading the + * message to PHP's error_log on every page load. Idempotent within a + * single request via static guard. + * + * @return void + */ + private static function log_skip_once(): void { + static $logged = false; + if ( $logged ) { + return; + } + $logged = true; + + // Direct INSERT — wrapped in try/catch because the wpdo_errors table + // might not exist in fresh installs. Skipping the log is acceptable; + // breaking register_hooks() is not. + try { + global $wpdb; + $wpdb->insert( + $wpdb->prefix . 'wpdo_errors', + array( + 'module' => 'listing_meta', + 'zone' => '', + 'hook' => 'register_hooks', + 'message' => 'Skipped: hpct_listing_meta table missing. Run `wp wpdo import-hpct` or migrate first.', + 'context' => '{}', + 'created_at' => current_time( 'mysql' ), + ), + array( '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- intentional: wpdo_errors absence is non-critical. + // Silently swallow — wpdo_errors table absence is non-critical. + } + } + + /** + * Deletes listing meta when a post is deleted. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @return void + */ + public function action_delete_post( int $post_id, \WP_Post $post ): void { + if ( 'hp_listing' !== $post->post_type || ! $this->is_active() ) { + return; + } + + try { + global $wpdb; + $wpdb->delete( TMDO_DB::table( 'hpct_listing_meta' ), array( 'listing_id' => $post_id ), array( '%d' ) ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() ); + } + } + + /** + * Filters get_post_metadata for hp_listing posts. + * + * @param mixed $value Current value or null. + * @param int $object_id Post ID. + * @param string $meta_key Meta key. + * @param bool $single Whether to return single value. + * @return mixed Filtered value. + */ + public function filter_get_meta( $value, int $object_id, string $meta_key, bool $single ) { + if ( ! $this->is_enabled() || ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) ) { + return $value; + } + + return $this->intercept( + function () use ( $object_id, $meta_key, $single ) { + global $wpdb; + $table = TMDO_DB::table( 'hpct_listing_meta' ); + + if ( $single ) { + $val = $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name validated by TMDO_DB::table() + sanitize_key(). + $object_id, + $meta_key + ) + ); + return null !== $val ? $val : null; + } + + return $wpdb->get_col( + $wpdb->prepare( + "SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name validated by TMDO_DB::table() + sanitize_key(). + $object_id, + $meta_key + ) + ) ?: null; + }, + fn() => $value, + 'get_post_metadata' + ); + } + + /** + * Filters update_post_metadata for hp_listing posts. + * + * @param mixed $check Whether to short-circuit. + * @param int $object_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param mixed $prev_value Previous meta value. + * @return mixed Filtered check value. + */ + public function filter_update_meta( $check, int $object_id, string $meta_key, $meta_value, $prev_value ) { + if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) { + return $check; + } + + try { + $this->upsert_meta( $object_id, $meta_key, $meta_value ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() ); + } + + return $check; + } + + /** + * Filters add_post_metadata for hp_listing posts. + * + * @param mixed $check Whether to short-circuit. + * @param int $object_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param bool $unique Whether meta key should be unique. + * @return mixed Filtered check value. + */ + public function filter_add_meta( $check, int $object_id, string $meta_key, $meta_value, bool $unique ) { + if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) { + return $check; + } + + try { + $this->upsert_meta( $object_id, $meta_key, $meta_value ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'add_post_metadata', $e->getMessage() ); + } + + return $check; + } + + /** + * Filters delete_post_metadata for hp_listing posts. + * + * @param mixed $check Whether to short-circuit. + * @param int $object_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value to match. + * @param bool $delete_all Whether to delete all matching. + * @return mixed Filtered check value. + */ + public function filter_delete_meta( $check, int $object_id, string $meta_key, $meta_value, bool $delete_all ) { + if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) { + return $check; + } + + try { + global $wpdb; + $wpdb->delete( + TMDO_DB::table( 'hpct_listing_meta' ), + array( + 'listing_id' => $object_id, + 'meta_key' => $meta_key, + ), + array( '%d', '%s' ) + ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'delete_post_metadata', $e->getMessage() ); + } + + return $check; + } + + /** + * Checks whether the post is an hp_listing. + * + * @param int $post_id Post ID. + * @return bool True if hp_listing post type. + */ + private function is_hp_listing( int $post_id ): bool { + return 'hp_listing' === get_post_type( $post_id ); + } + + /** + * Checks whether the meta key is an hp_ or _hp_ key. + * + * @param string $meta_key Meta key. + * @return bool True if key starts with hp_ or _hp_. + */ + private function is_hp_key( string $meta_key ): bool { + return str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' ); + } + + /** + * Inserts or updates a listing meta value in the hpct_listing_meta table. + * + * Single round-trip via TMDO_DB::upsert() — uses ON DUPLICATE KEY UPDATE + * (MySQL) or ON CONFLICT(listing_id, meta_key) DO UPDATE (SQLite). + * Solves audit finding P-C1 (the previous SELECT + INSERT/UPDATE pattern + * cost 2 SQL round-trips per write). + * + * Requires UNIQUE KEY (listing_id, meta_key) on hpct_listing_meta — + * present by design from HPCT v1.0+. + * + * @param int $listing_id Listing post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @return void + */ + private function upsert_meta( int $listing_id, string $meta_key, $meta_value ): void { + // maybe_serialize array values to match WordPress's native postmeta semantics. + $serialized = is_array( $meta_value ) || is_object( $meta_value ) + ? maybe_serialize( $meta_value ) + : (string) $meta_value; + + TMDO_DB::upsert( + TMDO_DB::table( 'hpct_listing_meta' ), + array( + 'listing_id' => $listing_id, + 'meta_key' => $meta_key, + 'meta_value' => $serialized, + ), + array( 'meta_value' ), // Update only meta_value on conflict. + array( 'listing_id', 'meta_key' ), // Composite unique key. + array( '%d', '%s', '%s' ) + ); + } +} diff --git a/includes/interceptors/class-tmdo-memberships-interceptor.php b/includes/interceptors/class-tmdo-memberships-interceptor.php new file mode 100644 index 0000000..60e62b4 --- /dev/null +++ b/includes/interceptors/class-tmdo-memberships-interceptor.php @@ -0,0 +1,133 @@ + 'plan_id', + 'hp_user' => 'user_id', + 'hp_price' => 'price', + 'hp_order' => 'order_id', + 'hp_start_date' => 'start_date', + 'hp_end_date' => 'end_date', + ); + + /** + * Registers WordPress hooks for this interceptor. + * + * @return void + */ + public function register_hooks(): void { + add_action( 'wp_insert_post', array( $this, 'action_insert_post' ), 10, 3 ); + add_filter( 'update_post_metadata', array( $this, 'filter_update_meta' ), 10, 5 ); + add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 ); + } + + /** + * Deletes membership record when a post is deleted. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @return void + */ + public function action_delete_post( int $post_id, \WP_Post $post ): void { + if ( 'hp_membership' !== $post->post_type || ! $this->is_active() ) { + return; + } + + try { + global $wpdb; + $wpdb->delete( TMDO_DB::table( 'hpct_memberships' ), array( 'post_id' => $post_id ), array( '%d' ) ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() ); + } + } + + /** + * Filters update_post_metadata for hp_membership posts. + * + * @param mixed $check Whether to short-circuit. + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param mixed $prev_value Previous meta value. + * @return mixed Filtered check value. + */ + public function filter_update_meta( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) { + if ( 'hp_membership' !== get_post_type( $post_id ) || ! isset( self::FIELD_MAP[ $meta_key ] ) ) { + return $check; + } + + if ( ! $this->is_active() ) { + return $check; + } + + try { + global $wpdb; + $col = self::FIELD_MAP[ $meta_key ]; + $wpdb->query( + $wpdb->prepare( + 'UPDATE ' . TMDO_DB::table( 'hpct_memberships' ) . " SET `{$col}` = %s, updated_at = %s WHERE post_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name from TMDO_DB::table(); column validated by FIELD_MAP constant. + $meta_value, + TMDO_DB::now(), + $post_id + ) + ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() ); + } + + return $check; + } + + /** + * Inserts a new membership record when a post is inserted. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @param bool $update Whether this is an update. + * @return void + */ + public function action_insert_post( int $post_id, \WP_Post $post, bool $update ): void { + if ( $update || 'hp_membership' !== $post->post_type || ! $this->is_active() ) { + return; + } + + try { + global $wpdb; + $now = TMDO_DB::now(); + $wpdb->insert( + TMDO_DB::table( 'hpct_memberships' ), + array( + 'post_id' => $post_id, + 'user_id' => (int) $post->post_author, + 'status' => $post->post_status, + 'created_at' => $now, + 'updated_at' => $now, + ), + array( '%d', '%d', '%s', '%s', '%s' ) + ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'wp_insert_post', $e->getMessage() ); + } + } +} diff --git a/includes/interceptors/class-tmdo-messages-interceptor.php b/includes/interceptors/class-tmdo-messages-interceptor.php new file mode 100644 index 0000000..d23bc23 --- /dev/null +++ b/includes/interceptors/class-tmdo-messages-interceptor.php @@ -0,0 +1,135 @@ + 'sender_id', + 'hp_recipient' => 'recipient_id', + 'hp_listing' => 'listing_id', + 'hp_read' => 'is_read', + ); + + /** + * Registers WordPress hooks for this interceptor. + * + * @return void + */ + public function register_hooks(): void { + add_action( 'wp_insert_post', array( $this, 'action_insert_post' ), 10, 3 ); + add_filter( 'update_post_metadata', array( $this, 'filter_update_meta' ), 10, 5 ); + add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 ); + } + + /** + * Deletes message record when a post is deleted. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @return void + */ + public function action_delete_post( int $post_id, \WP_Post $post ): void { + if ( 'hp_message' !== $post->post_type || ! $this->is_active() ) { + return; + } + + try { + global $wpdb; + $wpdb->delete( TMDO_DB::table( 'hpct_messages' ), array( 'post_id' => $post_id ), array( '%d' ) ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() ); + } + } + + /** + * Filters update_post_metadata for hp_message posts. + * + * @param mixed $check Whether to short-circuit. + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param mixed $prev_value Previous meta value. + * @return mixed Filtered check value. + */ + public function filter_update_meta( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) { + if ( 'hp_message' !== get_post_type( $post_id ) || ! isset( self::FIELD_MAP[ $meta_key ] ) ) { + return $check; + } + + if ( ! $this->is_active() ) { + return $check; + } + + try { + global $wpdb; + $col = self::FIELD_MAP[ $meta_key ]; + $wpdb->query( + $wpdb->prepare( + 'UPDATE ' . TMDO_DB::table( 'hpct_messages' ) . " SET `{$col}` = %s, updated_at = %s WHERE post_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name from TMDO_DB::table(); column validated by FIELD_MAP constant. + $meta_value, + TMDO_DB::now(), + $post_id + ) + ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() ); + } + + return $check; + } + + /** + * Inserts a new message record when a post is inserted. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @param bool $update Whether this is an update. + * @return void + */ + public function action_insert_post( int $post_id, \WP_Post $post, bool $update ): void { + if ( $update || 'hp_message' !== $post->post_type || ! $this->is_active() ) { + return; + } + + try { + global $wpdb; + $now = TMDO_DB::now(); + $wpdb->insert( + TMDO_DB::table( 'hpct_messages' ), + array( + 'post_id' => $post_id, + 'thread_id' => (int) $post->post_parent, + 'sender_id' => (int) $post->post_author, + 'subject' => sanitize_text_field( $post->post_title ), + 'body' => $post->post_content, + 'status' => $post->post_status, + 'sent_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ), + array( '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'wp_insert_post', $e->getMessage() ); + } + } +} diff --git a/includes/interceptors/class-tmdo-requests-interceptor.php b/includes/interceptors/class-tmdo-requests-interceptor.php new file mode 100644 index 0000000..64a71e0 --- /dev/null +++ b/includes/interceptors/class-tmdo-requests-interceptor.php @@ -0,0 +1,131 @@ + 'vendor_id', + 'hp_listing' => 'listing_id', + 'hp_budget' => 'budget', + ); + + /** + * Registers WordPress hooks for this interceptor. + * + * @return void + */ + public function register_hooks(): void { + add_action( 'wp_insert_post', array( $this, 'action_insert_post' ), 10, 3 ); + add_filter( 'update_post_metadata', array( $this, 'filter_update_meta' ), 10, 5 ); + add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 ); + } + + /** + * Deletes request record when a post is deleted. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @return void + */ + public function action_delete_post( int $post_id, \WP_Post $post ): void { + if ( 'hp_request' !== $post->post_type || ! $this->is_active() ) { + return; + } + + try { + global $wpdb; + $wpdb->delete( TMDO_DB::table( 'hpct_requests' ), array( 'post_id' => $post_id ), array( '%d' ) ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() ); + } + } + + /** + * Filters update_post_metadata for hp_request posts. + * + * @param mixed $check Whether to short-circuit. + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param mixed $prev_value Previous meta value. + * @return mixed Filtered check value. + */ + public function filter_update_meta( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) { + if ( 'hp_request' !== get_post_type( $post_id ) || ! isset( self::FIELD_MAP[ $meta_key ] ) ) { + return $check; + } + + if ( ! $this->is_active() ) { + return $check; + } + + try { + global $wpdb; + $col = self::FIELD_MAP[ $meta_key ]; + $wpdb->query( + $wpdb->prepare( + 'UPDATE ' . TMDO_DB::table( 'hpct_requests' ) . " SET `{$col}` = %s, updated_at = %s WHERE post_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name from TMDO_DB::table(); column validated by FIELD_MAP constant. + $meta_value, + TMDO_DB::now(), + $post_id + ) + ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() ); + } + + return $check; + } + + /** + * Inserts a new request record when a post is inserted. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @param bool $update Whether this is an update. + * @return void + */ + public function action_insert_post( int $post_id, \WP_Post $post, bool $update ): void { + if ( $update || 'hp_request' !== $post->post_type || ! $this->is_active() ) { + return; + } + + try { + global $wpdb; + $now = TMDO_DB::now(); + $wpdb->insert( + TMDO_DB::table( 'hpct_requests' ), + array( + 'post_id' => $post_id, + 'user_id' => (int) $post->post_author, + 'status' => $post->post_status, + 'message' => $post->post_content, + 'created_at' => $now, + 'updated_at' => $now, + ), + array( '%d', '%d', '%s', '%s', '%s', '%s' ) + ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'wp_insert_post', $e->getMessage() ); + } + } +} diff --git a/includes/interceptors/class-tmdo-reviews-interceptor.php b/includes/interceptors/class-tmdo-reviews-interceptor.php new file mode 100644 index 0000000..44a3aa9 --- /dev/null +++ b/includes/interceptors/class-tmdo-reviews-interceptor.php @@ -0,0 +1,174 @@ + 'rating', + 'hp_listing' => 'listing_id', + '_hp_vendor' => 'vendor_id', + ); + + /** + * Registers WordPress hooks for this interceptor. + * + * @return void + */ + public function register_hooks(): void { + add_filter( 'update_post_metadata', array( $this, 'filter_update_meta' ), 10, 5 ); + add_action( 'wp_insert_post', array( $this, 'action_insert_post' ), 10, 3 ); + add_filter( 'get_post_metadata', array( $this, 'filter_get_meta' ), 10, 4 ); + add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 ); + } + + /** + * Deletes review record when a post is deleted. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @return void + */ + public function action_delete_post( int $post_id, \WP_Post $post ): void { + if ( 'hp_review' !== $post->post_type || ! $this->is_active() ) { + return; + } + + try { + global $wpdb; + $wpdb->delete( TMDO_DB::table( 'hpct_reviews' ), array( 'post_id' => $post_id ), array( '%d' ) ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() ); + } + } + + /** + * Filters update_post_metadata for hp_review posts. + * + * @param mixed $check Whether to short-circuit. + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param mixed $prev_value Previous meta value. + * @return mixed Filtered check value. + */ + public function filter_update_meta( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) { + if ( ! $this->is_hp_review( $post_id ) || ! isset( self::FIELD_MAP[ $meta_key ] ) ) { + return $check; + } + + if ( ! $this->is_active() ) { + return $check; + } + + try { + global $wpdb; + $col = self::FIELD_MAP[ $meta_key ]; + $wpdb->query( + $wpdb->prepare( + 'UPDATE ' . TMDO_DB::table( 'hpct_reviews' ) . " SET `{$col}` = %s, updated_at = %s WHERE post_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name from TMDO_DB::table(); column validated by FIELD_MAP constant. + $meta_value, + TMDO_DB::now(), + $post_id + ) + ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() ); + } + + return $check; + } + + /** + * Inserts a new review record when a post is inserted. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @param bool $update Whether this is an update. + * @return void + */ + public function action_insert_post( int $post_id, \WP_Post $post, bool $update ): void { + if ( $update || 'hp_review' !== $post->post_type || ! $this->is_active() ) { + return; + } + + try { + global $wpdb; + $now = TMDO_DB::now(); + $wpdb->insert( + TMDO_DB::table( 'hpct_reviews' ), + array( + 'post_id' => $post_id, + 'user_id' => (int) $post->post_author, + 'status' => $post->post_status, + 'title' => sanitize_text_field( $post->post_title ), + 'content' => $post->post_content, + 'created_at' => $now, + 'updated_at' => $now, + ), + array( '%d', '%d', '%s', '%s', '%s', '%s', '%s' ) + ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'wp_insert_post', $e->getMessage() ); + } + } + + /** + * Filters get_post_metadata for hp_rating on hp_review posts. + * + * @param mixed $value Current value or null. + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param bool $single Whether to return single value. + * @return mixed Filtered value. + */ + public function filter_get_meta( $value, int $post_id, string $meta_key, bool $single ) { + if ( ! $this->is_enabled() || 'hp_rating' !== $meta_key || ! $this->is_hp_review( $post_id ) ) { + return $value; + } + + return $this->intercept( + function () use ( $post_id, $single ) { + global $wpdb; + $rating = $wpdb->get_var( + $wpdb->prepare( + 'SELECT rating FROM ' . TMDO_DB::table( 'hpct_reviews' ) . ' WHERE post_id = %d LIMIT 1', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name from TMDO_DB::table(). + $post_id + ) + ); + return null !== $rating ? ( $single ? $rating : array( $rating ) ) : null; + }, + fn() => $value, + 'get_post_metadata' + ); + } + + /** + * Checks whether the post is an hp_review. + * + * @param int $post_id Post ID. + * @return bool True if hp_review post type. + */ + private function is_hp_review( int $post_id ): bool { + return 'hp_review' === get_post_type( $post_id ); + } +} diff --git a/includes/interceptors/class-tmdo-statistics-interceptor.php b/includes/interceptors/class-tmdo-statistics-interceptor.php new file mode 100644 index 0000000..4f160eb --- /dev/null +++ b/includes/interceptors/class-tmdo-statistics-interceptor.php @@ -0,0 +1,139 @@ +is_active() ) { + return $check; + } + + try { + $this->record_event( $post_id, 'view' ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() ); + } + + return $check; + } + + /** + * Records a view event when a HivePress listing is viewed. + * + * @param mixed $listing Listing object or ID. + * @return void + */ + public function action_listing_viewed( $listing ): void { + if ( ! $this->is_active() ) { + return; + } + + $listing_id = is_object( $listing ) && method_exists( $listing, 'get_id' ) + ? $listing->get_id() + : ( is_numeric( $listing ) ? (int) $listing : 0 ); + + if ( ! $listing_id ) { + return; + } + + try { + $this->record_event( $listing_id, 'hp_action_view' ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'listing_viewed', $e->getMessage() ); + } + } + + /** + * Deletes statistics records when a listing post is deleted. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @return void + */ + public function action_delete_post( int $post_id, \WP_Post $post ): void { + if ( 'hp_listing' !== $post->post_type || ! $this->is_active() ) { + return; + } + + try { + global $wpdb; + $wpdb->delete( TMDO_DB::table( 'hpct_statistics' ), array( 'listing_id' => $post_id ), array( '%d' ) ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() ); + } + } + + /** + * Records a statistics event to the hpct_statistics table. + * + * @param int $listing_id Listing post ID. + * @param string $event_type Event type identifier. + * @return void + */ + private function record_event( int $listing_id, string $event_type ): void { + global $wpdb; + + $user_id = get_current_user_id(); + $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); + $ip_hash = $ip ? hash( 'sha256', $ip ) : ''; + + $wpdb->insert( + TMDO_DB::table( 'hpct_statistics' ), + array( + 'listing_id' => $listing_id, + 'event_type' => $event_type, + 'user_id' => $user_id, + 'ip_hash' => $ip_hash, + 'referrer' => substr( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ?? '' ) ), 0, 500 ), + 'created_at' => TMDO_DB::now(), + ), + array( '%d', '%s', '%d', '%s', '%s', '%s' ) + ); + } +} diff --git a/includes/query/class-tmdo-listing-meta-query.php b/includes/query/class-tmdo-listing-meta-query.php new file mode 100644 index 0000000..5d634f1 --- /dev/null +++ b/includes/query/class-tmdo-listing-meta-query.php @@ -0,0 +1,236 @@ += 100 + * + * All hp_* and _hp_* meta keys are intercepted. Any other meta keys are + * left in the meta_query to be handled by wp_postmeta as usual. + */ +class TMDO_Listing_Meta_Query extends TMDO_Query_Interceptor_Base { + + /** + * Returns the module identifier. + * + * @return string Module name. + */ + protected function get_module(): string { + return 'listing_meta'; + } + + /** + * Returns the post types this interceptor handles. + * + * @return string[] Post type slugs. + */ + protected function get_post_types(): array { + return array( 'hp_listing' ); + } + + /** + * Returns the custom table name. + * + * @return string Table name without prefix. + */ + protected function get_table(): string { + return 'hpct_listing_meta'; + } + + /** + * Returns the join column for this KV table. + * + * @return string Join column name. + */ + protected function get_join_column(): string { + return 'listing_id'; + } + + /** + * Not used — this class overrides pre_get_posts directly. + * + * @return array Empty array. + */ + protected function get_meta_key_map(): array { + return array(); + } + + // ── Override pre_get_posts for KV-table pattern ─────────────────────── + + /** + * Intercepts pre_get_posts to handle KV-table meta_query conditions. + * + * @param \WP_Query $query The WP_Query object. + * @return void + */ + public function pre_get_posts( \WP_Query $query ): void { + if ( ! $this->should_intercept( $query ) ) { + return; + } + + $raw_meta_query = (array) $query->get( 'meta_query' ); + if ( empty( $raw_meta_query ) ) { + return; + } + + $our_clauses = array(); + $remaining = array(); + + foreach ( $raw_meta_query as $k => $clause ) { + if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { + $remaining[ $k ] = $clause; + continue; + } + + if ( $this->is_hp_key( $clause['key'] ) ) { + $our_clauses[] = array( + 'meta_key' => $clause['key'], + 'value' => $clause['value'] ?? '', + 'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ), + 'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ), + ); + } else { + $remaining[ $k ] = $clause; + } + } + + if ( empty( $our_clauses ) ) { + return; + } + + if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) { + $remaining['relation'] = $raw_meta_query['relation']; + } + + $query->set( 'meta_query', $remaining ); + $query->set( $this->query_var(), $our_clauses ); + } + + // ── Override posts_join for KV-table: one JOIN alias per condition ───── + + /** + * Appends LEFT JOINs for each KV-table meta_query condition. + * + * @param string $join Current JOIN SQL. + * @param \WP_Query $query The WP_Query object. + * @return string Modified JOIN SQL. + */ + public function posts_join( ?string $join, \WP_Query $query ): string { + $join = (string) ( $join ?? '' ); + $clauses = $this->get_clauses( $query ); + if ( empty( $clauses ) ) { + return $join; + } + + global $wpdb; + $table = TMDO_DB::table( $this->get_table() ); + $join_col = esc_sql( $this->get_join_column() ); + + foreach ( $clauses as $i => $clause ) { + $alias = $this->alias_for( $i ); + $meta_key = $clause['meta_key']; + + if ( false === strpos( $join, "`{$alias}`" ) ) { + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table from TMDO_DB::table(); $alias and $join_col are sanitize_key()-validated. + $join .= $wpdb->prepare( + " LEFT JOIN `{$table}` AS `{$alias}`" + . " ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`{$join_col}`" + . " AND `{$alias}`.`meta_key` = %s)", + $meta_key + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + } + + return $join; + } + + // ── Override posts_where for KV-table ───────────────────────────────── + + /** + * Appends WHERE conditions for KV-table meta_value comparisons. + * + * @param string $where Current WHERE SQL. + * @param \WP_Query $query The WP_Query object. + * @return string Modified WHERE SQL. + */ + public function posts_where( ?string $where, \WP_Query $query ): string { + $where = (string) ( $where ?? '' ); + $clauses = $this->get_clauses( $query ); + if ( empty( $clauses ) ) { + return $where; + } + + foreach ( $clauses as $i => $clause ) { + $alias = $this->alias_for( $i ); + $col = "`{$alias}`.`meta_value`"; + $compare = $this->sanitize_compare( $clause['compare'] ); + $type = $clause['type']; + $value = $clause['value']; + + $where .= $this->build_condition( $col, $compare, $type, $value ); + } + + return $where; + } + + // ── Override posts_groupby ───────────────────────────────────────────── + + /** + * Ensures GROUP BY is set when KV-table clauses are active. + * + * @param string $groupby Current GROUP BY SQL. + * @param \WP_Query $query The WP_Query object. + * @return string Modified GROUP BY SQL. + */ + public function posts_groupby( ?string $groupby, \WP_Query $query ): string { + $groupby = (string) ( $groupby ?? '' ); + $clauses = $this->get_clauses( $query ); + if ( empty( $clauses ) ) { + return $groupby; + } + + global $wpdb; + if ( '' === trim( $groupby ) ) { + $groupby = "`{$wpdb->posts}`.`ID`"; + } + + return $groupby; + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + /** + * Generates a unique SQL alias for a KV-table JOIN at a given index. + * + * @param int $index Zero-based clause index. + * @return string SQL alias string. + */ + private function alias_for( int $index ): string { + return "wpdo_lm_{$index}"; + } + + /** + * Checks whether a meta key belongs to HivePress (hp_ or _hp_ prefix). + * + * @param string $meta_key Meta key to check. + * @return bool True if the key is an HP key. + */ + private function is_hp_key( string $meta_key ): bool { + return str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' ); + } +} diff --git a/includes/query/class-tmdo-memberships-query.php b/includes/query/class-tmdo-memberships-query.php new file mode 100644 index 0000000..083c8bf --- /dev/null +++ b/includes/query/class-tmdo-memberships-query.php @@ -0,0 +1,67 @@ + plan_id (BIGINT) + * hp_user => user_id (BIGINT) + * hp_price => price (VARCHAR) + * hp_order => order_id (BIGINT) + * hp_start_date => start_date (DATETIME) + * hp_end_date => end_date (DATETIME) + */ +class TMDO_Memberships_Query extends TMDO_Query_Interceptor_Base { + + /** + * Returns the module identifier. + * + * @return string Module name. + */ + protected function get_module(): string { + return 'memberships'; + } + + /** + * Returns the post types this interceptor handles. + * + * @return string[] Post type slugs. + */ + protected function get_post_types(): array { + return array( 'hp_membership' ); + } + + /** + * Returns the custom table name. + * + * @return string Table name without prefix. + */ + protected function get_table(): string { + return 'hpct_memberships'; + } + + /** + * Returns the meta_key to column mapping for hpct_memberships. + * + * @return array Meta key to column name map. + */ + protected function get_meta_key_map(): array { + return array( + 'hp_plan' => 'plan_id', + 'hp_user' => 'user_id', + 'hp_price' => 'price', + 'hp_order' => 'order_id', + 'hp_start_date' => 'start_date', + 'hp_end_date' => 'end_date', + ); + } +} diff --git a/includes/query/class-tmdo-messages-query.php b/includes/query/class-tmdo-messages-query.php new file mode 100644 index 0000000..604806f --- /dev/null +++ b/includes/query/class-tmdo-messages-query.php @@ -0,0 +1,63 @@ + sender_id (BIGINT) + * hp_recipient => recipient_id (BIGINT) + * hp_listing => listing_id (BIGINT) + * hp_read => is_read (TINYINT) + */ +class TMDO_Messages_Query extends TMDO_Query_Interceptor_Base { + + /** + * Returns the module identifier. + * + * @return string Module name. + */ + protected function get_module(): string { + return 'messages'; + } + + /** + * Returns the post types this interceptor handles. + * + * @return string[] Post type slugs. + */ + protected function get_post_types(): array { + return array( 'hp_message' ); + } + + /** + * Returns the custom table name. + * + * @return string Table name without prefix. + */ + protected function get_table(): string { + return 'hpct_messages'; + } + + /** + * Returns the meta_key to column mapping for hpct_messages. + * + * @return array Meta key to column name map. + */ + protected function get_meta_key_map(): array { + return array( + 'hp_sender' => 'sender_id', + 'hp_recipient' => 'recipient_id', + 'hp_listing' => 'listing_id', + 'hp_read' => 'is_read', + ); + } +} diff --git a/includes/query/class-tmdo-requests-query.php b/includes/query/class-tmdo-requests-query.php new file mode 100644 index 0000000..bdf5e58 --- /dev/null +++ b/includes/query/class-tmdo-requests-query.php @@ -0,0 +1,61 @@ + vendor_id (BIGINT) + * hp_listing => listing_id (BIGINT) + * hp_budget => budget (VARCHAR) + */ +class TMDO_Requests_Query extends TMDO_Query_Interceptor_Base { + + /** + * Returns the module identifier. + * + * @return string Module name. + */ + protected function get_module(): string { + return 'requests'; + } + + /** + * Returns the post types this interceptor handles. + * + * @return string[] Post type slugs. + */ + protected function get_post_types(): array { + return array( 'hp_request' ); + } + + /** + * Returns the custom table name. + * + * @return string Table name without prefix. + */ + protected function get_table(): string { + return 'hpct_requests'; + } + + /** + * Returns the meta_key to column mapping for hpct_requests. + * + * @return array Meta key to column name map. + */ + protected function get_meta_key_map(): array { + return array( + 'hp_vendor' => 'vendor_id', + 'hp_listing' => 'listing_id', + 'hp_budget' => 'budget', + ); + } +} diff --git a/includes/query/class-tmdo-reviews-query.php b/includes/query/class-tmdo-reviews-query.php new file mode 100644 index 0000000..55804c6 --- /dev/null +++ b/includes/query/class-tmdo-reviews-query.php @@ -0,0 +1,64 @@ + listing_id (BIGINT) + * _hp_vendor => vendor_id (BIGINT) + * hp_rating => rating (TINYINT) + */ +class TMDO_Reviews_Query extends TMDO_Query_Interceptor_Base { + + /** + * Returns the module identifier. + * + * @return string Module name. + */ + protected function get_module(): string { + return 'reviews'; + } + + /** + * Returns the post types this interceptor handles. + * + * @return string[] Post type slugs. + */ + protected function get_post_types(): array { + return array( 'hp_review' ); + } + + /** + * Returns the custom table name. + * + * @return string Table name without prefix. + */ + protected function get_table(): string { + return 'hpct_reviews'; + } + + /** + * Returns the meta_key to column mapping for hpct_reviews. + * + * @return array Meta key to column name map. + */ + protected function get_meta_key_map(): array { + return array( + 'hp_listing' => 'listing_id', + '_hp_vendor' => 'vendor_id', + 'hp_rating' => 'rating', + ); + } +} diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..04437a7 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,39 @@ +prefix . 'xxx' 引用是否在 + * uninstall.php 中出現。 + * + * 若需完整清理 wp_wpdo_hot_hp_* 等表,請執行: + * wp plugin uninstall 2meet-data-optimizer + * + * @package TMDO_HIVEPRESS + */ + +if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { + exit; +} + +// 表清單(不執行 DROP,僅供 schema drift check 識別) +// DROP TABLE references for schema drift check — DO NOT EXECUTE +$tmdo_hp_referenced_tables = array( + 'hpct_migrations', + 'wpdo_hot_hp_booking', + 'wpdo_hot_hp_listing', + 'wpdo_hot_hp_request', +); +unset( $tmdo_hp_referenced_tables ); + +// 清除 HP AddOn 自有 option(如有) +// (Phase 1 stub — 目前無 option 自有)