From b4400a68e5c2a2e7780a4ad662bca0f22d1b1389 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 31 Jul 2026 05:06:36 +0800 Subject: [PATCH] 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 --- .gitignore | 16 + 2meet-data-optimizer-hivepress-addon.php | 37 + CHANGELOG.md | 22 + CLAUDE.md | 74 + DEPLOY.md | 43 + DESIGN.md | 44 + PLAN.md | 43 + README.md | 104 + SECURITY.md | 36 + admin/class-tmdo-admin-hivepress.php | 164 ++ cli/class-tmdo-cli-hivepress.php | 343 +++ composer.json | 25 + composer.lock | 2106 +++++++++++++++++ includes/class-tmdo-hivepress-bootstrap.php | 177 ++ ...ass-tmdo-hivepress-term-comment-fields.php | 133 ++ .../class-tmdo-hivepress-transient-filter.php | 307 +++ includes/class-tmdo-hivepress.php | 118 + includes/class-tmdo-hpct-import.php | 248 ++ includes/class-tmdo-listing-stats.php | 264 +++ .../class-tmdo-hivepress-blocks-adapter.php | 73 + .../class-tmdo-hivepress-bookings-adapter.php | 159 ++ .../class-tmdo-hivepress-core-adapter.php | 407 ++++ ...class-tmdo-hivepress-favorites-adapter.php | 234 ++ ...ass-tmdo-hivepress-marketplace-adapter.php | 135 ++ ...ass-tmdo-hivepress-memberships-adapter.php | 169 ++ .../class-tmdo-hivepress-messages-adapter.php | 263 ++ .../class-tmdo-hivepress-requests-adapter.php | 267 +++ .../class-tmdo-hivepress-reviews-adapter.php | 146 ++ .../class-tmdo-hivepress-seo-adapter.php | 76 + ...ss-tmdo-hivepress-social-links-adapter.php | 115 + ...lass-tmdo-hivepress-statistics-adapter.php | 130 + .../class-tmdo-hivepress-tags-adapter.php | 84 + .../class-tmdo-hivepress-attribute-bridge.php | 174 ++ .../class-tmdo-hivepress-benchmark.php | 128 + .../class-tmdo-hivepress-bootstrap.php | 242 ++ .../class-tmdo-hivepress-comment-router.php | 166 ++ .../class-tmdo-hivepress-conflict-guard.php | 266 +++ .../class-tmdo-hivepress-cron-optimizer.php | 146 ++ .../class-tmdo-hivepress-detector.php | 260 ++ .../hivepress/class-tmdo-hivepress-rest.php | 168 ++ ...lass-tmdo-hivepress-suitability-scorer.php | 141 ++ .../hivepress/interface-tmdo-hp-adapter.php | 172 ++ includes/hivepress/trait-tmdo-hp-adapter.php | 272 +++ .../class-tmdo-favorites-interceptor.php | 117 + .../class-tmdo-listing-meta-interceptor.php | 339 +++ .../class-tmdo-memberships-interceptor.php | 133 ++ .../class-tmdo-messages-interceptor.php | 135 ++ .../class-tmdo-requests-interceptor.php | 131 + .../class-tmdo-reviews-interceptor.php | 174 ++ .../class-tmdo-statistics-interceptor.php | 139 ++ .../query/class-tmdo-listing-meta-query.php | 236 ++ .../query/class-tmdo-memberships-query.php | 67 + includes/query/class-tmdo-messages-query.php | 63 + includes/query/class-tmdo-requests-query.php | 61 + includes/query/class-tmdo-reviews-query.php | 64 + uninstall.php | 39 + 56 files changed, 10395 insertions(+) create mode 100644 .gitignore create mode 100644 2meet-data-optimizer-hivepress-addon.php create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 DEPLOY.md create mode 100644 DESIGN.md create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 admin/class-tmdo-admin-hivepress.php create mode 100644 cli/class-tmdo-cli-hivepress.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 includes/class-tmdo-hivepress-bootstrap.php create mode 100644 includes/class-tmdo-hivepress-term-comment-fields.php create mode 100644 includes/class-tmdo-hivepress-transient-filter.php create mode 100644 includes/class-tmdo-hivepress.php create mode 100644 includes/class-tmdo-hpct-import.php create mode 100644 includes/class-tmdo-listing-stats.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-blocks-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-bookings-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-core-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-favorites-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-marketplace-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-memberships-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-messages-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-requests-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-reviews-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-seo-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-social-links-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-statistics-adapter.php create mode 100644 includes/hivepress/adapters/class-tmdo-hivepress-tags-adapter.php create mode 100644 includes/hivepress/class-tmdo-hivepress-attribute-bridge.php create mode 100644 includes/hivepress/class-tmdo-hivepress-benchmark.php create mode 100644 includes/hivepress/class-tmdo-hivepress-bootstrap.php create mode 100644 includes/hivepress/class-tmdo-hivepress-comment-router.php create mode 100644 includes/hivepress/class-tmdo-hivepress-conflict-guard.php create mode 100644 includes/hivepress/class-tmdo-hivepress-cron-optimizer.php create mode 100644 includes/hivepress/class-tmdo-hivepress-detector.php create mode 100644 includes/hivepress/class-tmdo-hivepress-rest.php create mode 100644 includes/hivepress/class-tmdo-hivepress-suitability-scorer.php create mode 100644 includes/hivepress/interface-tmdo-hp-adapter.php create mode 100644 includes/hivepress/trait-tmdo-hp-adapter.php create mode 100644 includes/interceptors/class-tmdo-favorites-interceptor.php create mode 100644 includes/interceptors/class-tmdo-listing-meta-interceptor.php create mode 100644 includes/interceptors/class-tmdo-memberships-interceptor.php create mode 100644 includes/interceptors/class-tmdo-messages-interceptor.php create mode 100644 includes/interceptors/class-tmdo-requests-interceptor.php create mode 100644 includes/interceptors/class-tmdo-reviews-interceptor.php create mode 100644 includes/interceptors/class-tmdo-statistics-interceptor.php create mode 100644 includes/query/class-tmdo-listing-meta-query.php create mode 100644 includes/query/class-tmdo-memberships-query.php create mode 100644 includes/query/class-tmdo-messages-query.php create mode 100644 includes/query/class-tmdo-requests-query.php create mode 100644 includes/query/class-tmdo-reviews-query.php create mode 100644 uninstall.php 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 自有)