WP_REST_Server::READABLE, 'callback' => array( $this, 'get_listings' ), 'permission_callback' => '__return_true', 'args' => $this->listings_args(), ) ); register_rest_route( self::NAMESPACE, '/listings/(?P\d+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_listing' ), 'permission_callback' => '__return_true', 'args' => array( 'id' => array( 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0, 'sanitize_callback' => 'absint', 'required' => true, ), ), ) ); register_rest_route( self::NAMESPACE, '/stats/(?P\d+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_stats' ), 'permission_callback' => '__return_true', 'args' => array( 'id' => array( 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0, 'sanitize_callback' => 'absint', 'required' => true, ), ), ) ); register_rest_route( self::NAMESPACE, '/listings/(?P\d+)/view', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'post_view' ), 'permission_callback' => '__return_true', 'args' => array( 'id' => array( 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0, 'sanitize_callback' => 'absint', 'required' => true, ), ), ) ); register_rest_route( self::NAMESPACE, '/status', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_status' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); // ── Entity Bridge 端點 (v2.6.6) ─────────────────────────────────────── register_rest_route( self::NAMESPACE, '/entity-bridge/health', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'entity_bridge_health_all' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/entity-bridge/health/(?P[a-z]+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'entity_bridge_health_one' ), 'permission_callback' => array( $this, 'require_manage_options' ), 'args' => array( 'type' => array( 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ), 'sanitize_callback' => 'sanitize_key', 'required' => true, ), ), ) ); register_rest_route( self::NAMESPACE, '/entity-bridge/backfill', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'entity_bridge_start_backfill' ), 'permission_callback' => array( $this, 'require_manage_options' ), 'args' => array( 'entity_type' => array( 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ), 'sanitize_callback' => 'sanitize_key', 'required' => true, ), 'group_name' => array( 'sanitize_callback' => 'sanitize_key', 'required' => true, ), ), ) ); register_rest_route( self::NAMESPACE, '/entity-bridge/promote', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'entity_bridge_promote' ), 'permission_callback' => array( $this, 'require_manage_options' ), 'args' => array( 'entity_type' => array( 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ), 'sanitize_callback' => 'sanitize_key', 'required' => true, ), ), ) ); register_rest_route( self::NAMESPACE, '/entity-bridge/demote', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'entity_bridge_demote' ), 'permission_callback' => array( $this, 'require_manage_options' ), 'args' => array( 'entity_type' => array( 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ), 'sanitize_callback' => 'sanitize_key', 'required' => true, ), ), ) ); // v2.6.7: User stress-test endpoints. register_rest_route( self::NAMESPACE, '/stress-test/status', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'stress_test_status' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/stress-test/start', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'stress_test_start' ), 'permission_callback' => array( $this, 'require_manage_options' ), 'args' => array( 'target' => array( 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000000, 'sanitize_callback' => 'absint', 'required' => true, ), 'mode' => array( 'validate_callback' => fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ), 'sanitize_callback' => 'sanitize_key', 'required' => false, ), 'batch_size' => array( 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 2000, 'sanitize_callback' => 'absint', 'required' => false, ), ), ) ); register_rest_route( self::NAMESPACE, '/stress-test/cancel', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'stress_test_cancel' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/stress-test/cleanup', array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'stress_test_cleanup' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/stress-test/benchmark', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'stress_test_run_benchmark' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); // v2.11.4: Post stress-test endpoints (mirror of user-side, post_type-scoped). register_rest_route( self::NAMESPACE, '/post-stress-test/status', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'post_stress_test_status' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/post-stress-test/start', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'post_stress_test_start' ), 'permission_callback' => array( $this, 'require_manage_options' ), 'args' => array( 'post_type' => array( 'validate_callback' => fn( $v ) => in_array( $v, array( 'product', 'hp_listing', 'hp_request', 'hp_vendor', 'attachment', 'nav_menu_item', 'post' ), true ), 'sanitize_callback' => 'sanitize_key', 'required' => true, ), 'target' => array( 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000, 'sanitize_callback' => 'absint', 'required' => true, ), 'mode' => array( 'validate_callback' => fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ), 'sanitize_callback' => 'sanitize_key', 'required' => false, ), 'batch_size' => array( 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000, 'sanitize_callback' => 'absint', 'required' => false, ), ), ) ); register_rest_route( self::NAMESPACE, '/post-stress-test/cancel', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'post_stress_test_cancel' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/post-stress-test/cleanup', array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'post_stress_test_cleanup' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/post-stress-test/benchmark', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'post_stress_test_run_benchmark' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); // v2.13.0: Term stress-test endpoints (mirror post side, taxonomy-scoped). register_rest_route( self::NAMESPACE, '/term-stress-test/status', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'term_stress_test_status' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/term-stress-test/start', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'term_stress_test_start' ), 'permission_callback' => array( $this, 'require_manage_options' ), 'args' => array( 'taxonomy' => array( 'validate_callback' => static fn( $v ) => is_string( $v ) && '' !== $v && taxonomy_exists( $v ), 'sanitize_callback' => 'sanitize_key', 'required' => true, ), 'target' => array( 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000, 'sanitize_callback' => 'absint', 'required' => true, ), 'mode' => array( 'validate_callback' => static fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ), 'sanitize_callback' => 'sanitize_key', 'required' => false, ), 'batch_size' => array( 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000, 'sanitize_callback' => 'absint', 'required' => false, ), ), ) ); register_rest_route( self::NAMESPACE, '/term-stress-test/cancel', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'term_stress_test_cancel' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/term-stress-test/cleanup', array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'term_stress_test_cleanup' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/term-stress-test/benchmark', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'term_stress_test_run_benchmark' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); // v2.13.1: Comment stress-test endpoints (mirror term side, post-scoped). register_rest_route( self::NAMESPACE, '/comment-stress-test/status', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'comment_stress_test_status' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/comment-stress-test/start', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'comment_stress_test_start' ), 'permission_callback' => array( $this, 'require_manage_options' ), 'args' => array( 'post_id' => array( 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1, 'sanitize_callback' => 'absint', 'required' => true, ), 'target' => array( 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000, 'sanitize_callback' => 'absint', 'required' => true, ), 'mode' => array( 'validate_callback' => static fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ), 'sanitize_callback' => 'sanitize_key', 'required' => false, ), 'batch_size' => array( 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000, 'sanitize_callback' => 'absint', 'required' => false, ), ), ) ); register_rest_route( self::NAMESPACE, '/comment-stress-test/cancel', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'comment_stress_test_cancel' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/comment-stress-test/cleanup', array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'comment_stress_test_cleanup' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/comment-stress-test/benchmark', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'comment_stress_test_run_benchmark' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); // ── Migration Wizard endpoints (v2.8.0) ─────────────────────────── register_rest_route( self::NAMESPACE, '/migration/preflight', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'migration_preflight' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/migration/start', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'migration_start' ), 'permission_callback' => array( $this, 'require_manage_options' ), 'args' => array( 'verify_strict' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ), 'verify_24h' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ), 'auto_backup' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ), 'force_async' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ), 'dry_run' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ), ), ) ); register_rest_route( self::NAMESPACE, '/migration/status', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'migration_status' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/migration/cancel', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'migration_cancel' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/migration/resume', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'migration_resume' ), 'permission_callback' => array( $this, 'require_manage_options' ), ) ); } // ── Handlers ───────────────────────────────────────────────────────────── /** * GET /wpdo/v1/listings * * Query Zone A flat table (if cutover) or fall back to WP_Query. * Supports pagination and per-column numeric filters. * * @param WP_REST_Request $request REST request object. * @return WP_REST_Response REST response. */ public function get_listings( WP_REST_Request $request ): WP_REST_Response { $post_type = $request->get_param( 'post_type' ) ?? 'hp_listing'; // Defense-in-depth: clamp per_page even if validate_callback in listings_args() fails open. // Filter `wpdo_rest_max_per_page` allows site owners to adjust the upper bound. $max_per_page = (int) apply_filters( 'wpdo_rest_max_per_page', 100 ); $per_page = max( 1, min( $max_per_page, (int) ( $request->get_param( 'per_page' ) ?? 20 ) ) ); $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); $orderby = $request->get_param( 'orderby' ) ?? 'post_id'; $order = strtoupper( (string) ( $request->get_param( 'order' ) ?? 'DESC' ) ); $offset = ( $page - 1 ) * $per_page; $module = TMDO_Zone_Router::module_name( 'hot', $post_type ); if ( TMDO_Feature_Flags::is_read_custom( $module ) ) { return $this->listings_from_zone_a( $post_type, $per_page, $offset, $orderby, $order, $request ); } return $this->listings_from_wp_query( $post_type, $per_page, $page, $request ); } /** * GET /wpdo/v1/listings/{id} * * Returns Zone A fields + Zone C blob merged (or postmeta fallback). * * @param WP_REST_Request $request REST request object. * @return WP_REST_Response REST response. */ public function get_listing( WP_REST_Request $request ): WP_REST_Response { $post_id = (int) $request->get_param( 'id' ); // v2.13.3: post_status / read_post capability guard (fixes M-AUTH-1). $err = $this->assert_post_viewable_or_error( $post_id ); if ( null !== $err ) { return $err; } $post_type = get_post_type( $post_id ); $data = array( 'id' => $post_id, 'post_type' => $post_type, ); // Zone A. $hot_module = TMDO_Zone_Router::module_name( 'hot', $post_type ); if ( TMDO_Feature_Flags::is_read_custom( $hot_module ) ) { $hot = TMDO_Zone_Hot::get_row( $post_id, $post_type ); if ( $hot ) { unset( $hot['post_id'], $hot['updated_at'] ); $data = array_merge( $data, $hot ); } } else { foreach ( array_keys( TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ) ) as $col ) { $data[ $col ] = get_post_meta( $post_id, $col, true ); } } // Zone C. $cold_module = TMDO_Zone_Router::module_name( 'cold', $post_type ); if ( TMDO_Feature_Flags::is_read_custom( $cold_module ) ) { $cold = TMDO_Zone_Cold::get_blob( $post_id, $post_type ); $data = array_merge( $data, $cold ); } else { foreach ( TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type ) as $key ) { $data[ $key ] = get_post_meta( $post_id, $key, true ); } } // Strip fields not marked show_in_rest => true in Schema_Registry. // 'id' and 'post_type' are always retained. The filter below can add/remove // fields on top of this default restriction. $registry = TMDO_Schema_Registry::instance(); $visible_keys = array_fill_keys( array_merge( array( 'id', 'post_type' ), $registry->get_rest_visible_hot_columns( $post_type ), $registry->get_rest_visible_cold_keys( $post_type ) ), true ); $data = array_intersect_key( $data, $visible_keys ); /** * Filters the fields returned by GET /wpdo/v1/listings/{id}. * * Called after the default show_in_rest restriction. Use this hook to expose * additional private fields to authenticated users, or to remove fields for * specific consumers. * * @param array $data Field map (already restricted to show_in_rest=true fields). * @param int $post_id Post ID. * @param string $post_type Post type slug. */ $data = (array) apply_filters( 'wpdo_rest_listing_visible_fields', $data, $post_id, $post_type ); /** This filter is documented above (tmdo_* is the forward-looking name). */ $data = (array) apply_filters( 'tmdo_rest_listing_visible_fields', $data, $post_id, $post_type ); return new WP_REST_Response( $data, 200 ); } /** * GET /wpdo/v1/stats/{id} * * Returns Zone B view count (with postmeta fallback). * * @param WP_REST_Request $request REST request object. * @return WP_REST_Response REST response. */ public function get_stats( WP_REST_Request $request ): WP_REST_Response { $post_id = (int) $request->get_param( 'id' ); // v2.13.3: post_status / read_post capability guard (fixes M-AUTH-1). $err = $this->assert_post_viewable_or_error( $post_id ); if ( null !== $err ) { return $err; } $views = self::read_view_count( $post_id ); return new WP_REST_Response( array( 'post_id' => $post_id, 'view_count' => (int) $views, ), 200 ); } /** * POST /wpdo/v1/listings/{id}/view * * Increment Zone B view count for a post. * Requires a valid WP REST nonce (X-WP-Nonce header or _wpnonce query param). * Rate-limited to one increment per IP per post per hour (transient). * Returns the new view count. * * @param WP_REST_Request $request REST request object. * @return WP_REST_Response REST response. */ public function post_view( WP_REST_Request $request ): WP_REST_Response { // Verify nonce — prevents CSRF from third-party sites. $nonce = $request->get_header( 'X-WP-Nonce' ) ?? $request->get_param( '_wpnonce' ) ?? ''; if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new WP_REST_Response( array( 'code' => 'rest_forbidden', 'message' => 'Invalid or missing nonce.', ), 403 ); } $post_id = (int) $request->get_param( 'id' ); // v2.13.3: post_status / read_post capability guard (fixes M-LOGIC-1). // Prevents accumulating view counts on draft / private / trash posts. $err = $this->assert_post_viewable_or_error( $post_id ); if ( null !== $err ) { return $err; } // Cookie-based dedup — browser clients won't re-count on page reload. $cookie_key = 'wpdo_view_' . $post_id; $cookie_blocked = ! empty( $_COOKIE[ $cookie_key ] ); // IP-based rate limit — one increment per IP per post per hour. // v2.13.3: behind a reverse proxy REMOTE_ADDR collapses to the proxy IP // and the rate limit degenerates. Site owners can hook `wpdo_view_client_ip` // to resolve X-Forwarded-For (or another trusted header) per their topology. // Default keeps REMOTE_ADDR (no implicit trust of forwarded headers). $ip_default = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '127.0.0.1'; $ip = (string) apply_filters( 'wpdo_view_client_ip', $ip_default, $request ); $rl_key = 'wpdo_view_' . $post_id . '_' . substr( md5( $ip ), 0, 12 ); // Max 43 chars. $ip_blocked = (bool) get_transient( $rl_key ); if ( $cookie_blocked || $ip_blocked ) { // Track rate-limit hits per post for admin visibility, capped at 100 entries. $rl_stats = get_option( 'wpdo_rl_stats', array() ); $rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1; if ( count( $rl_stats ) > 100 ) { // Keep only the top-100 posts by hit count to bound option size. arsort( $rl_stats ); $rl_stats = array_slice( $rl_stats, 0, 100, true ); } update_option( 'wpdo_rl_stats', $rl_stats, false ); return new WP_REST_Response( array( 'code' => 'too_many_requests', 'message' => 'View already counted for this session.', ), 429 ); } // Mark as counted for this IP/session. set_transient( $rl_key, 1, HOUR_IN_SECONDS ); self::bump_view_count( $post_id ); $views = self::read_view_count( $post_id ); $response = new WP_REST_Response( array( 'post_id' => $post_id, 'view_count' => (int) $views, ), 200 ); // Tell browsers not to re-count on page reload. $response->header( 'Set-Cookie', "{$cookie_key}=1; Max-Age=3600; Path=/; SameSite=Strict" ); return $response; } /** * GET /wpdo/v1/status (requires manage_options) * * Returns zone module states and registered field counts. * * @param WP_REST_Request $request REST request object. Not used directly. * @return WP_REST_Response REST response. */ public function get_status( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by WP REST API callback signature. $registry = TMDO_Schema_Registry::instance(); $modules = TMDO_Feature_Flags::all(); $stats = $registry->get_stats(); return new WP_REST_Response( array( 'version' => TMDO_VERSION, 'engine' => TMDO_IS_SQLITE ? 'sqlite' : 'mysql', 'fields' => $stats, 'modules' => $modules, 'rate_limit_stats' => get_option( 'wpdo_rl_stats', array() ), ), 200 ); } // ── Permission ──────────────────────────────────────────────────────────── /** * Permission callback that requires plugin admin capability. * * Delegates to TMDO_Capability::current_user_can_admin() which additionally * allows super admins on Multisite (v2.14.0). Method name retained for * back-compat with the existing 30 REST endpoint registrations. * * @return bool True if the current user can manage WP Data Optimizer. */ public function require_manage_options(): bool { return TMDO_Capability::current_user_can_admin(); } /** * Visibility guard for the four public REST endpoints (v2.13.3 — fixes * M-AUTH-1 / M-LOGIC-1). * * The 4 `__return_true` endpoints (get_listings / get_listing / get_stats / * post_view) historically only checked post existence via `get_post_type()`. * That allowed anonymous reads of draft / private / trash post fields * (Zone A flat columns + Zone C JSON blob), and accumulated view counts * against unpublished posts. * * Rule: a post is viewable to the current request if either * (a) it is publicly viewable (`is_post_publicly_viewable` — typically * publish status + non-private + password-protected handled by WP), or * (b) the current user has `read_post` capability on it. * * @param int $post_id Post ID to check. * @return WP_REST_Response|null Null when allowed; 404 / 403 response otherwise. */ private function assert_post_viewable_or_error( int $post_id ): ?WP_REST_Response { if ( $post_id < 1 || ! get_post_type( $post_id ) ) { return new WP_REST_Response( array( 'code' => 'not_found', 'message' => 'Post not found.', ), 404 ); } $publicly_viewable = function_exists( 'is_post_publicly_viewable' ) ? is_post_publicly_viewable( $post_id ) : ( 'publish' === get_post_status( $post_id ) ); if ( $publicly_viewable ) { return null; } if ( current_user_can( 'read_post', $post_id ) ) { return null; } return new WP_REST_Response( array( 'code' => 'rest_forbidden', 'message' => 'Post not viewable.', ), 403 ); } // ── Entity Bridge Handlers (v2.6.6) ────────────────────────────────────── /** * GET /wpdo/v1/entity-bridge/health * * @param WP_REST_Request $request REST request. * @return WP_REST_Response */ public function entity_bridge_health_all( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found if ( ! class_exists( 'TMDO_Entity_Health' ) ) { return new WP_REST_Response( array( 'error' => 'Entity health class unavailable' ), 500 ); } return new WP_REST_Response( TMDO_Entity_Health::get_all(), 200 ); } /** * GET /wpdo/v1/entity-bridge/health/{type} * * @param WP_REST_Request $request REST request. * @return WP_REST_Response */ public function entity_bridge_health_one( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Entity_Health' ) ) { return new WP_REST_Response( array( 'error' => 'Entity health class unavailable' ), 500 ); } $type = (string) $request->get_param( 'type' ); return new WP_REST_Response( TMDO_Entity_Health::get_one( $type ), 200 ); } /** * POST /wpdo/v1/entity-bridge/backfill * * Schedules async cron-driven backfill for one (entity_type, group_name) pair. * * @param WP_REST_Request $request REST request. * @return WP_REST_Response */ public function entity_bridge_start_backfill( WP_REST_Request $request ): WP_REST_Response { $entity_type = (string) $request->get_param( 'entity_type' ); $group_name = (string) $request->get_param( 'group_name' ); if ( ! class_exists( 'TMDO_Entity_Registry' ) || ! TMDO_Entity_Registry::get_adapter( $entity_type ) ) { return new WP_REST_Response( array( 'error' => 'Unknown entity type' ), 400 ); } $groups = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_groups_for_type( $entity_type ) : array(); if ( ! in_array( $group_name, $groups, true ) ) { return new WP_REST_Response( array( 'error' => 'Unknown group for this entity type' ), 400 ); } $hook = 'wpdo_entity_backfill_batch'; $args = array( $entity_type, $group_name ); // Clear existing scheduled event for this pair before scheduling fresh. $existing = wp_next_scheduled( $hook, $args ); if ( $existing ) { wp_unschedule_event( $existing, $hook, $args ); } // Also reset the checkpoint so backfill starts from the beginning. if ( class_exists( 'TMDO_Entity_Migration_Engine' ) ) { TMDO_Entity_Migration_Engine::reset_checkpoint( $entity_type, $group_name ); } wp_schedule_single_event( time(), $hook, $args ); TMDO_Logger::info( 'entity_backfill_scheduled', array( 'entity_type' => $entity_type, 'group_name' => $group_name, 'user_id' => get_current_user_id(), ) ); return new WP_REST_Response( array( 'ok' => true, 'message' => "Backfill scheduled for {$entity_type}/{$group_name}", 'entity_type' => $entity_type, 'group_name' => $group_name, ), 200 ); } /** * POST /wpdo/v1/entity-bridge/promote * * Advance entity mode by one step (disabled→dual_write→shadow_read→aeav_only). * * @param WP_REST_Request $request REST request. * @return WP_REST_Response */ public function entity_bridge_promote( WP_REST_Request $request ): WP_REST_Response { $entity_type = (string) $request->get_param( 'entity_type' ); if ( ! class_exists( 'TMDO_Mode_Manager' ) ) { return new WP_REST_Response( array( 'error' => 'Mode manager unavailable' ), 500 ); } $current = TMDO_Mode_Manager::get( $entity_type ); $order = TMDO_Mode_Manager::ALL_MODES; $idx = array_search( $current, $order, true ); if ( false === $idx || $idx >= count( $order ) - 1 ) { return new WP_REST_Response( array( 'error' => "Already at maximum mode: {$current}" ), 400 ); } $next = $order[ $idx + 1 ]; $result = TMDO_Mode_Manager::set( $entity_type, $next ); if ( is_wp_error( $result ) ) { return new WP_REST_Response( array( 'error' => $result->get_error_message() ), 400 ); } return new WP_REST_Response( array( 'ok' => true, 'entity_type' => $entity_type, 'from' => $current, 'to' => $next, ), 200 ); } /** * POST /wpdo/v1/entity-bridge/demote * * Roll back entity mode by one step. * * @param WP_REST_Request $request REST request. * @return WP_REST_Response */ public function entity_bridge_demote( WP_REST_Request $request ): WP_REST_Response { $entity_type = (string) $request->get_param( 'entity_type' ); if ( ! class_exists( 'TMDO_Mode_Manager' ) ) { return new WP_REST_Response( array( 'error' => 'Mode manager unavailable' ), 500 ); } $current = TMDO_Mode_Manager::get( $entity_type ); $order = TMDO_Mode_Manager::ALL_MODES; $idx = array_search( $current, $order, true ); if ( false === $idx || $idx <= 0 ) { return new WP_REST_Response( array( 'error' => "Already at minimum mode: {$current}" ), 400 ); } $prev = $order[ $idx - 1 ]; $result = TMDO_Mode_Manager::set( $entity_type, $prev ); if ( is_wp_error( $result ) ) { return new WP_REST_Response( array( 'error' => $result->get_error_message() ), 400 ); } return new WP_REST_Response( array( 'ok' => true, 'entity_type' => $entity_type, 'from' => $current, 'to' => $prev, ), 200 ); } // ── v2.6.7: User Stress Test Handlers ──────────────────────────────────── /** * GET /wpdo/v1/stress-test/status * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function stress_test_status( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 ); } return new WP_REST_Response( TMDO_User_Stress_Tester::get_progress(), 200 ); } /** * POST /wpdo/v1/stress-test/start * * @param WP_REST_Request $request REST request with target / mode / batch_size. * @return WP_REST_Response REST response. */ public function stress_test_start( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 ); } $target = (int) $request->get_param( 'target' ); $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' ); $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_User_Stress_Tester::DEFAULT_BATCH_SIZE ); $result = TMDO_User_Stress_Tester::start( $target, $mode, $batch_size ); $status = ! empty( $result['ok'] ) ? 200 : 409; return new WP_REST_Response( $result, $status ); } /** * POST /wpdo/v1/stress-test/cancel * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function stress_test_cancel( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 ); } return new WP_REST_Response( TMDO_User_Stress_Tester::cancel(), 200 ); } /** * DELETE /wpdo/v1/stress-test/cleanup * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 ); } return new WP_REST_Response( TMDO_User_Stress_Tester::cleanup(), 200 ); } /** * POST /wpdo/v1/stress-test/benchmark — re-run benchmark on current dataset. * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 ); } $report = TMDO_User_Stress_Tester::run_benchmark(); return new WP_REST_Response( array( 'ok' => true, 'benchmark' => $report, ), 200 ); } // ── v2.11.4: Post Stress Test Handlers ──────────────────────────────────── /** * GET /wpdo/v1/post-stress-test/status * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function post_stress_test_status( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 ); } return new WP_REST_Response( TMDO_Post_Stress_Tester::get_progress(), 200 ); } /** * POST /wpdo/v1/post-stress-test/start * * @param WP_REST_Request $request REST request with post_type / target / mode / batch_size. * @return WP_REST_Response REST response. */ public function post_stress_test_start( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 ); } $post_type = (string) $request->get_param( 'post_type' ); $target = (int) $request->get_param( 'target' ); $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' ); $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Post_Stress_Tester::DEFAULT_BATCH_SIZE ); $result = TMDO_Post_Stress_Tester::start( $post_type, $target, $mode, $batch_size ); $status = ! empty( $result['ok'] ) ? 200 : 409; return new WP_REST_Response( $result, $status ); } /** * POST /wpdo/v1/post-stress-test/cancel * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function post_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 ); } return new WP_REST_Response( TMDO_Post_Stress_Tester::cancel(), 200 ); } /** * DELETE /wpdo/v1/post-stress-test/cleanup * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function post_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 ); } $result = TMDO_Post_Stress_Tester::cleanup(); // Also wipe the persisted run state so the UI re-renders idle after cleanup. delete_option( TMDO_Post_Stress_Tester::OPT_STATE ); delete_transient( TMDO_Post_Stress_Tester::CANCEL_FLAG ); return new WP_REST_Response( array( 'ok' => true, 'deleted' => (int) ( $result['deleted_posts'] ?? 0 ), 'detail' => $result, ), 200 ); } /** * POST /wpdo/v1/post-stress-test/benchmark — re-run benchmark on current state. * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function post_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 ); } $report = TMDO_Post_Stress_Tester::run_benchmark(); return new WP_REST_Response( array( 'ok' => true, 'benchmark' => $report, ), 200 ); } // ── v2.13.0: Term Stress Test Handlers ──────────────────────────────────── /** * GET /wpdo/v1/term-stress-test/status * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function term_stress_test_status( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 ); } return new WP_REST_Response( TMDO_Term_Stress_Tester::get_progress(), 200 ); } /** * POST /wpdo/v1/term-stress-test/start * * @param WP_REST_Request $request REST request with taxonomy / target / mode / batch_size. * @return WP_REST_Response REST response. */ public function term_stress_test_start( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 ); } $taxonomy = (string) $request->get_param( 'taxonomy' ); $target = (int) $request->get_param( 'target' ); $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' ); $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Term_Stress_Tester::DEFAULT_BATCH_SIZE ); $result = TMDO_Term_Stress_Tester::start( $taxonomy, $target, $mode, $batch_size ); $status = ! empty( $result['ok'] ) ? 200 : 409; return new WP_REST_Response( $result, $status ); } /** * POST /wpdo/v1/term-stress-test/cancel * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function term_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 ); } return new WP_REST_Response( TMDO_Term_Stress_Tester::cancel(), 200 ); } /** * DELETE /wpdo/v1/term-stress-test/cleanup * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function term_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 ); } $result = TMDO_Term_Stress_Tester::cleanup(); delete_option( TMDO_Term_Stress_Tester::OPT_STATE ); delete_transient( TMDO_Term_Stress_Tester::CANCEL_FLAG ); return new WP_REST_Response( array( 'ok' => true, 'deleted' => (int) ( $result['deleted_terms'] ?? 0 ), 'detail' => $result, ), 200 ); } /** * POST /wpdo/v1/term-stress-test/benchmark * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function term_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 ); } $report = TMDO_Term_Stress_Tester::run_benchmark(); return new WP_REST_Response( array( 'ok' => true, 'benchmark' => $report, ), 200 ); } // ── v2.13.1: Comment Stress Test Handlers ───────────────────────────────── /** * GET /wpdo/v1/comment-stress-test/status * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function comment_stress_test_status( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 ); } return new WP_REST_Response( TMDO_Comment_Stress_Tester::get_progress(), 200 ); } /** * POST /wpdo/v1/comment-stress-test/start * * @param WP_REST_Request $request REST request with post_id / target / mode / batch_size. * @return WP_REST_Response REST response. */ public function comment_stress_test_start( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 ); } $post_id = (int) $request->get_param( 'post_id' ); $target = (int) $request->get_param( 'target' ); $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' ); $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Comment_Stress_Tester::DEFAULT_BATCH_SIZE ); $result = TMDO_Comment_Stress_Tester::start( $post_id, $target, $mode, $batch_size ); $status = ! empty( $result['ok'] ) ? 200 : 409; return new WP_REST_Response( $result, $status ); } /** * POST /wpdo/v1/comment-stress-test/cancel * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function comment_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 ); } return new WP_REST_Response( TMDO_Comment_Stress_Tester::cancel(), 200 ); } /** * DELETE /wpdo/v1/comment-stress-test/cleanup * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function comment_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 ); } $result = TMDO_Comment_Stress_Tester::cleanup(); delete_option( TMDO_Comment_Stress_Tester::OPT_STATE ); delete_transient( TMDO_Comment_Stress_Tester::CANCEL_FLAG ); return new WP_REST_Response( array( 'ok' => true, 'deleted' => (int) ( $result['deleted_comments'] ?? 0 ), 'detail' => $result, ), 200 ); } /** * POST /wpdo/v1/comment-stress-test/benchmark * * @param WP_REST_Request $request REST request (unused). * @return WP_REST_Response REST response. */ public function comment_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response { if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) { return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 ); } $report = TMDO_Comment_Stress_Tester::run_benchmark(); return new WP_REST_Response( array( 'ok' => true, 'benchmark' => $report, ), 200 ); } // ── Private helpers ─────────────────────────────────────────────────────── /** * Query Zone A flat table directly. * * @param string $post_type Post type slug. * @param int $per_page Number of items per page. * @param int $offset Database offset. * @param string $orderby Column to order by. * @param string $order Order direction (ASC or DESC). * @param WP_REST_Request $request REST request object. * @return WP_REST_Response REST response. */ private function listings_from_zone_a( string $post_type, int $per_page, int $offset, string $orderby, string $order, WP_REST_Request $request ): WP_REST_Response { global $wpdb; $table = TMDO_Zone_Hot::table( $post_type ); $columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ); // Build WHERE clauses from numeric filter params. $where = array(); $params = array(); foreach ( $this->get_filter_params( $request, $columns ) as $filter ) { $where[] = $filter['sql']; $params[] = $filter['value']; } $where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : ''; // Validate orderby against known columns + post_id. $allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) ); $orderby_col = in_array( $orderby, $allowed_orderby, true ) ? sanitize_key( $orderby ) : 'post_id'; $order_dir = 'ASC' === $order ? 'ASC' : 'DESC'; // Count total. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table from TMDO_Zone_Hot::table(); where_sql/orderby_col/order_dir are sanitized/validated. if ( $params ) { $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` {$where_sql}", ...$params ) ); } else { $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); } // Fetch rows. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM `{$table}` {$where_sql} ORDER BY `{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d", ...array_merge( $params, array( $per_page, $offset ) ) ), ARRAY_A ) ?: array(); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber // Rename post_id → id, drop internal columns, and restrict to show_in_rest fields. $visible_cols = array_fill_keys( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ), true ); $items = array_map( function ( array $row ) use ( $post_type, $visible_cols ): array { $item = array( 'id' => (int) $row['post_id'], 'post_type' => $post_type, ); unset( $row['post_id'], $row['updated_at'] ); // Keep only columns with show_in_rest => true (or all if no registry entry). $row = $visible_cols ? array_intersect_key( $row, $visible_cols ) : $row; return array_merge( $item, $row ); }, $rows ); $response = new WP_REST_Response( $items, 200 ); $response->header( 'X-WP-Total', (string) $total ); $response->header( 'X-WP-TotalPages', (string) (int) ceil( $total / $per_page ) ); return $response; } /** * Fall back to WP_Query when zone is not cutover. * * @param string $post_type Post type slug. * @param int $per_page Number of items per page. * @param int $page Page number. * @param WP_REST_Request $request REST request object. Not used directly. * @return WP_REST_Response REST response. */ private function listings_from_wp_query( // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $request kept for potential future filtering. string $post_type, int $per_page, int $page, WP_REST_Request $request ): WP_REST_Response { $args = array( 'post_type' => $post_type, 'posts_per_page' => $per_page, 'paged' => $page, 'post_status' => 'publish', ); $query = new WP_Query( $args ); $items = array(); foreach ( $query->posts as $post ) { $item = array( 'id' => $post->ID, 'post_type' => $post->post_type, ); $cols = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ); foreach ( array_keys( $cols ) as $col ) { $item[ $col ] = get_post_meta( $post->ID, $col, true ); } $items[] = $item; } $response = new WP_REST_Response( $items, 200 ); $response->header( 'X-WP-Total', (string) $query->found_posts ); $response->header( 'X-WP-TotalPages', (string) $query->max_num_pages ); return $response; } /** * Extract numeric filter params (e.g. hp_price_min => hp_price >= ?) * from the request, validated against registered hot columns. * * @param WP_REST_Request $request REST request object. * @param array $columns Registered hot columns from Schema Registry. * @return array[] Each element: ['sql' => string, 'value' => mixed]. */ private function get_filter_params( WP_REST_Request $request, array $columns ): array { $filters = array(); foreach ( array_keys( $columns ) as $col ) { $col = sanitize_key( $col ); $min = $request->get_param( $col . '_min' ); if ( null !== $min && is_numeric( $min ) ) { $filters[] = array( 'sql' => "`{$col}` >= %f", 'value' => (float) $min, ); } $max = $request->get_param( $col . '_max' ); if ( null !== $max && is_numeric( $max ) ) { $filters[] = array( 'sql' => "`{$col}` <= %f", 'value' => (float) $max, ); } $exact = $request->get_param( $col ); if ( null !== $exact && '' !== $exact ) { if ( is_numeric( $exact ) ) { $filters[] = array( 'sql' => "`{$col}` = %f", 'value' => (float) $exact, ); } else { $filters[] = array( 'sql' => "`{$col}` = %s", 'value' => (string) $exact, ); } } } return $filters; } /** * Argument definitions for GET /listings. */ private function listings_args(): array { return array( 'post_type' => array( 'default' => 'hp_listing', 'sanitize_callback' => 'sanitize_key', ), 'per_page' => array( 'default' => 20, 'sanitize_callback' => 'absint', 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v >= 1 && (int) $v <= 100, ), 'page' => array( 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v >= 1, ), 'orderby' => array( 'default' => 'post_id', 'sanitize_callback' => 'sanitize_key', ), 'order' => array( 'default' => 'DESC', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => fn( $v ) => in_array( strtoupper( $v ), array( 'ASC', 'DESC' ), true ), ), ); } // ── Migration Wizard handlers (v2.8.0) ──────────────────────────────────── /** * GET /wpdo/v1/migration/preflight * * Read-only diagnostic — returns current ratio, mode, group residue, and * the strategy `start()` would pick. * * @param WP_REST_Request $request Unused (read-only endpoint takes no params). * @return WP_REST_Response */ public function migration_preflight( WP_REST_Request $request ): WP_REST_Response { unset( $request ); return new WP_REST_Response( TMDO_Migration_Orchestrator::preflight(), 200 ); } /** * POST /wpdo/v1/migration/start * * Body params: verify_strict (bool, default true), verify_24h (bool), * auto_backup (bool, default true), force_async (bool), dry_run (bool). * * @param WP_REST_Request $request Body parameters. * @return WP_REST_Response */ public function migration_start( WP_REST_Request $request ): WP_REST_Response { $options = array( 'verify_strict' => null === $request->get_param( 'verify_strict' ) ? true : (bool) $request->get_param( 'verify_strict' ), 'verify_24h' => (bool) $request->get_param( 'verify_24h' ), 'auto_backup' => null === $request->get_param( 'auto_backup' ) ? true : (bool) $request->get_param( 'auto_backup' ), 'force_async' => (bool) $request->get_param( 'force_async' ), 'dry_run' => (bool) $request->get_param( 'dry_run' ), ); $result = TMDO_Migration_Orchestrator::start( $options ); $status = ! empty( $result['ok'] ) ? 200 : ( 'nothing_to_do' === ( $result['reason'] ?? '' ) ? 200 : 409 ); return new WP_REST_Response( $result, $status ); } /** * GET /wpdo/v1/migration/status — polling target. * * @param WP_REST_Request $request Unused. * @return WP_REST_Response */ public function migration_status( WP_REST_Request $request ): WP_REST_Response { unset( $request ); return new WP_REST_Response( TMDO_Migration_Orchestrator::get_status(), 200 ); } /** * POST /wpdo/v1/migration/cancel * * @param WP_REST_Request $request Unused. * @return WP_REST_Response */ public function migration_cancel( WP_REST_Request $request ): WP_REST_Response { unset( $request ); $ok = TMDO_Migration_Orchestrator::cancel(); return new WP_REST_Response( array( 'ok' => $ok ), $ok ? 200 : 409 ); } /** * POST /wpdo/v1/migration/resume * * @param WP_REST_Request $request Unused. * @return WP_REST_Response */ public function migration_resume( WP_REST_Request $request ): WP_REST_Response { unset( $request ); $result = TMDO_Migration_Orchestrator::resume(); return new WP_REST_Response( $result, ! empty( $result['ok'] ) ? 200 : 409 ); } /** * Read a post's view counter. * * Delegates to the HivePress AddOn when it is active, because that class * adds an hp_view_count postmeta fallback for listings migrated before the * warm zone existed. Without the AddOn — the normal case for a plain * install — core reads its own warm row directly. Calling the AddOn class * unconditionally used to fatal these endpoints on any site without it. * * @param int $post_id Post ID. * @return int */ private static function read_view_count( int $post_id ): int { if ( class_exists( 'TMDO_Listing_Stats' ) ) { return (int) TMDO_Listing_Stats::get_view_count( $post_id ); } return (int) ( TMDO_Zone_Warm::get( $post_id, TMDO_Zone_Warm::VIEW_KEY ) ?? 0 ); } /** * Increment a post's view counter. * * @param int $post_id Post ID. * @return void */ private static function bump_view_count( int $post_id ): void { if ( class_exists( 'TMDO_Listing_Stats' ) ) { TMDO_Listing_Stats::increment_view( $post_id ); return; } TMDO_Zone_Warm::increment( $post_id, TMDO_Zone_Warm::VIEW_KEY, 1, TMDO_Zone_Warm::VIEW_TTL ); } }