query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_psa_search_%' OR option_name LIKE '_transient_timeout_psa_search_%'" ); } /** Register REST API route for external/headless access. */ public static function register_rest_routes(): void { register_rest_route( 'peptides/v1', '/search', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'rest_search' ), 'permission_callback' => '__return_true', 'args' => array( 'q' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function ( $param ) { return is_string( $param ) && strlen( $param ) >= PSA_Config::MIN_QUERY_LENGTH; }, ), ), ) ); } /** * REST search callback — returns published peptides only (no generation trigger). * * Uses dual-layer rate limiting (transient + object cache) for resilience. * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response */ public static function rest_search( $request ) { $ip = psa_get_client_ip(); $rate_key = 'psa_rest_rate_' . md5( $ip ); $count_transient = (int) get_transient( $rate_key ); $count_cache = (int) wp_cache_get( $rate_key, 'psa_rate_limits' ); $count = max( $count_transient, $count_cache ); if ( $count >= PSA_Config::REST_RATE_LIMIT ) { return new WP_REST_Response( array( 'error' => 'Rate limit exceeded. Please try again later.' ), 429 ); } set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS ); wp_cache_set( $rate_key, $count + 1, 'psa_rate_limits', HOUR_IN_SECONDS ); $query = $request->get_param( 'q' ); if ( mb_strlen( $query ) > PSA_Config::MAX_QUERY_LENGTH ) { return new WP_REST_Response( array( 'error' => 'Search query must not exceed ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' ), 400 ); } return new WP_REST_Response( self::search_peptides( $query ), 200 ); } /** * Render the [peptide_search] shortcode. * * Uses class-based selectors (no IDs) to support multiple instances per page. * * @param array $atts Shortcode attributes. * @return string HTML output. */ public static function render_search_form( $atts ): string { self::$instance_count++; $instance = self::$instance_count; $atts = shortcode_atts( array( 'placeholder' => 'Search for a peptide (e.g., BPC-157, Thymosin Beta-4)...' ), $atts, 'peptide_search' ); ob_start(); ?>
esc_like( $query ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $post_id = $wpdb->get_var( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} src ON p.ID = src.post_id AND src.meta_key = 'psa_source' AND src.meta_value = 'pending' LEFT JOIN {$wpdb->postmeta} alias ON p.ID = alias.post_id AND alias.meta_key = 'psa_aliases' WHERE p.post_type = 'peptide' AND p.post_status = 'draft' AND ( p.post_title LIKE %s OR alias.meta_value LIKE %s ) ORDER BY p.post_date DESC LIMIT 1", $like, $like ) ); return $post_id ? get_post( $post_id ) : null; } /** * Core search — queries CPT by title and aliases (published only). * * Includes transient caching with generation-counter invalidation. * * @param string $query Search term. * @return array{results: array, total: int, query: string} */ public static function search_peptides( string $query ): array { global $wpdb; $cache_key = 'psa_search_' . self::get_cache_generation() . '_' . md5( sanitize_text_field( $query ) ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } $like = '%' . $wpdb->esc_like( $query ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON p.ID = m.post_id AND m.meta_key = 'psa_aliases' WHERE p.post_type = 'peptide' AND p.post_status = 'publish' AND ( p.post_title LIKE %s OR m.meta_value LIKE %s ) ORDER BY p.post_title ASC LIMIT 20", $like, $like ) ); if ( ! empty( $post_ids ) ) { _prime_post_caches( $post_ids ); update_meta_cache( 'post', $post_ids ); } $results = array(); foreach ( $post_ids as $id ) { $results[] = self::format_peptide( (int) $id ); } $response = array( 'results' => $results, 'total' => count( $results ), 'query' => $query ); set_transient( $cache_key, $response, 6 * HOUR_IN_SECONDS ); return apply_filters( 'psa_search_results', $response, $query ); } /** * Format a peptide post for JSON response. * * @param int $post_id The post ID. * @return array */ public static function format_peptide( int $post_id ): array { $post = get_post( $post_id ); if ( ! $post ) { return array(); } return array( 'id' => $post->ID, 'title' => $post->post_title, 'url' => get_permalink( $post->ID ), 'excerpt' => wp_trim_words( $post->post_content, 30 ), 'sequence' => get_post_meta( $post->ID, 'psa_sequence', true ), 'molecular_weight' => get_post_meta( $post->ID, 'psa_molecular_weight', true ), 'molecular_formula' => get_post_meta( $post->ID, 'psa_molecular_formula', true ), 'source' => get_post_meta( $post->ID, 'psa_source', true ), ); } // ── Backward-compatible proxies ───────────────────────────────────── /** @see PSA_Search_Handler::ajax_search() */ public static function ajax_search(): void { PSA_Search_Handler::ajax_search(); } /** @see PSA_Search_Handler::create_pending_placeholder() */ public static function create_pending_placeholder( string $peptide_name ) { return PSA_Search_Handler::create_pending_placeholder( $peptide_name ); } /** @see PSA_Search_Handler::schedule_background_generation() */ public static function schedule_background_generation( int $post_id, string $peptide_name ): void { PSA_Search_Handler::schedule_background_generation( $post_id, $peptide_name ); } }