> */ private $captured_search_columns = array(); /** * Prepared SQL identifiers cached by column. * * @var array */ private $column_sql = array(); /** * Normalized SQL expressions cached by column. * * @var array */ private $normalized_column_sql = array(); /** * Exclusion prefix observed while WordPress parses the current search. * * @var string */ private $exclusion_prefix = '-'; /** * Boot the singleton. * * @return Aetos_Arabic_Search_Normalizer */ public static function init() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Register search filters before WordPress builds the main query. */ private function __construct() { add_filter( 'wp_query_search_exclusion_prefix', array( $this, 'capture_exclusion_prefix' ), PHP_INT_MAX ); add_filter( 'post_search_columns', array( $this, 'capture_search_columns' ), PHP_INT_MAX, 3 ); add_filter( 'posts_search', array( $this, 'enhance_search_sql' ), 9, 2 ); } /** * Observe the exclusion prefix selected by WordPress and other plugins. * * @param string $prefix Search exclusion prefix. * @return string */ public function capture_exclusion_prefix( $prefix ) { $this->exclusion_prefix = (string) $prefix; return $prefix; } /** * Capture the search-column allowlist after other filters have run. * * @param array $search_columns Searchable wp_posts columns. * @param string $search Raw search text. * @param WP_Query $query Current query. * @return array */ public function capture_search_columns( $search_columns, $search, $query ) { unset( $search ); if ( ! $query instanceof WP_Query ) { return $search_columns; } $columns = $this->sanitize_search_columns( $search_columns ); if ( empty( $columns ) ) { $columns = $this->get_default_search_columns(); } $this->captured_search_columns[ spl_object_hash( $query ) ] = $columns; return $search_columns; } /** * Replace core search SQL with a compatible, bounded Arabic-aware form. * * @param string $search Core-generated search SQL. * @param WP_Query $query Current query. * @return string */ public function enhance_search_sql( $search, $query ) { if ( ! $query instanceof WP_Query ) { return $search; } $search_columns = $this->consume_search_columns( $query ); if ( ! $this->should_enhance_query( $search, $query ) ) { return $search; } $terms = $query->get( 'search_terms' ); if ( ! is_array( $terms ) || empty( $terms ) ) { $terms = array( (string) $query->get( 's' ) ); } $term_count = (int) $query->get( 'search_terms_count' ); if ( $term_count < 1 ) { $term_count = count( $terms ); } if ( $term_count > self::MAX_TERMS || $this->contains_excluded_term( $terms ) ) { return $search; } if ( empty( array_intersect( array( 'post_title', 'post_excerpt' ), $search_columns ) ) ) { return $search; } /** * Filters whether one otherwise-eligible query should be enhanced. * * Returning false preserves the exact SQL generated by WordPress core. * * @param bool $enhance Whether to enhance this query. * @param WP_Query $query Current query. */ $enhance = (bool) apply_filters( 'aetos_arabic_search_normalizer_should_enhance', true, $query ); if ( ! $enhance ) { return $search; } $wildcard = $query->get( 'exact' ) ? '' : '%'; $term_groups = array(); foreach ( $terms as $term ) { $term = (string) $term; if ( '' === $term ) { continue; } $term_group = $this->build_term_group( $term, $wildcard, $search_columns ); if ( '' !== $term_group ) { $term_groups[] = $term_group; } } if ( empty( $term_groups ) ) { return $search; } $search = ' AND (' . implode( ' AND ', $term_groups ) . ') '; if ( ! is_user_logged_in() ) { $search .= ' AND (' . $this->get_column_sql( 'post_password' ) . " = '') "; } return $search; } /** * Normalize Arabic text using the same transformations as the SQL fallback. * * @param string $text Input text. * @return string */ public function normalize_text( $text ) { $normalized = strtr( (string) $text, array( 'أ' => 'ا', 'إ' => 'ا', 'آ' => 'ا', 'ٱ' => 'ا', 'ؤ' => 'و', 'ئ' => 'ي', 'ى' => 'ي', 'ة' => 'ه', 'ک' => 'ك', 'ی' => 'ي', ) ); $without_marks = preg_replace( '/[\x{0640}\x{064B}-\x{0655}\x{0670}]/u', '', $normalized ); return is_string( $without_marks ) ? $without_marks : $normalized; } /** * Decide whether the current request and query are safe to enhance. * * @param string $search Core search SQL. * @param WP_Query $query Current query. * @return bool */ private function should_enhance_query( $search, $query ) { if ( '' === $search || ! $query->is_search() || is_admin() || wp_doing_ajax() ) { return false; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return false; } $search_text = (string) $query->get( 's' ); if ( '' === $search_text || strlen( $search_text ) > self::MAX_QUERY_BYTES ) { return false; } if ( 1 !== preg_match( '/[\x{0600}-\x{06FF}]/u', $search_text ) ) { return false; } $post_type = $query->get( 'post_type' ); if ( 'attachment' === $post_type || ( is_array( $post_type ) && in_array( 'attachment', $post_type, true ) ) ) { return false; } return true; } /** * Detect search terms that use WordPress's exclusion prefix. * * Exclusion semantics stay entirely with core in version 1.0.0. * * @param array $terms Parsed search terms. * @return bool */ private function contains_excluded_term( $terms ) { if ( '' === $this->exclusion_prefix ) { return false; } foreach ( $terms as $term ) { if ( 0 === strpos( (string) $term, $this->exclusion_prefix ) ) { return true; } } return false; } /** * Build one AND group containing core and normalized OR conditions. * * @param string $term Parsed search term. * @param string $wildcard Empty for exact search, otherwise percent. * @param array $search_columns Core-approved columns. * @return string */ private function build_term_group( $term, $wildcard, $search_columns ) { global $wpdb; $raw_like = $wildcard . $wpdb->esc_like( $term ) . $wildcard; $normalized = $this->normalize_text( $term ); $normalized_like = $wildcard . $wpdb->esc_like( $normalized ) . $wildcard; $has_arabic = 1 === preg_match( '/[\x{0600}-\x{06FF}]/u', $term ); $parts = array(); foreach ( $search_columns as $column ) { $parts[] = '(' . $this->get_column_sql( $column ) . ' LIKE ' . $wpdb->prepare( '%s', $raw_like ) . ')'; if ( $has_arabic && in_array( $column, array( 'post_title', 'post_excerpt' ), true ) ) { $parts[] = '(' . $this->get_normalized_column_sql( $column ) . ' LIKE ' . $wpdb->prepare( '%s', $normalized_like ) . ')'; } } return empty( $parts ) ? '' : '(' . implode( ' OR ', $parts ) . ')'; } /** * Return a safely prepared wp_posts column identifier. * * @param string $column Allowed column name. * @return string */ private function get_column_sql( $column ) { global $wpdb; $allowed_columns = array_merge( $this->get_default_search_columns(), array( 'post_password' ) ); if ( ! in_array( $column, $allowed_columns, true ) ) { return ''; } if ( ! isset( $this->column_sql[ $column ] ) ) { $this->column_sql[ $column ] = $wpdb->prepare( '%i.%i', $wpdb->posts, $column ); } return $this->column_sql[ $column ]; } /** * Build the nested, fixed-replacement expression for one short post column. * * @param string $column Either post_title or post_excerpt. * @return string */ private function get_normalized_column_sql( $column ) { global $wpdb; if ( ! in_array( $column, array( 'post_title', 'post_excerpt' ), true ) ) { return ''; } if ( isset( $this->normalized_column_sql[ $column ] ) ) { return $this->normalized_column_sql[ $column ]; } $expression = $this->get_column_sql( $column ); foreach ( $this->get_sql_replacements() as $replacement ) { $expression = 'REPLACE(' . $expression . ', ' . $wpdb->prepare( '%s', $replacement[0] ) . ', ' . $wpdb->prepare( '%s', $replacement[1] ) . ')'; } $this->normalized_column_sql[ $column ] = $expression; return $expression; } /** * Fixed replacements shared conceptually with normalize_text(). * * @return array */ private function get_sql_replacements() { return array( array( 'أ', 'ا' ), array( 'إ', 'ا' ), array( 'آ', 'ا' ), array( 'ٱ', 'ا' ), array( 'ؤ', 'و' ), array( 'ئ', 'ي' ), array( 'ى', 'ي' ), array( 'ة', 'ه' ), array( 'ک', 'ك' ), array( 'ی', 'ي' ), array( 'ـ', '' ), array( 'ً', '' ), array( 'ٌ', '' ), array( 'ٍ', '' ), array( 'َ', '' ), array( 'ُ', '' ), array( 'ِ', '' ), array( 'ّ', '' ), array( 'ْ', '' ), array( 'ٓ', '' ), array( 'ٔ', '' ), array( 'ٕ', '' ), array( 'ٰ', '' ), ); } /** * Return core's supported search columns in core order. * * @return array */ private function get_default_search_columns() { return array( 'post_title', 'post_excerpt', 'post_content' ); } /** * Intersect arbitrary values with core's supported column allowlist. * * @param mixed $columns Candidate columns. * @return array */ private function sanitize_search_columns( $columns ) { if ( ! is_array( $columns ) ) { $columns = array( $columns ); } $columns = array_filter( $columns, 'is_string' ); $columns = array_map( 'sanitize_key', $columns ); return array_values( array_intersect( $columns, $this->get_default_search_columns() ) ); } /** * Consume captured columns or reconstruct core's default-safe choice. * * @param WP_Query $query Current query. * @return array */ private function consume_search_columns( $query ) { $query_hash = spl_object_hash( $query ); if ( isset( $this->captured_search_columns[ $query_hash ] ) ) { $columns = $this->captured_search_columns[ $query_hash ]; unset( $this->captured_search_columns[ $query_hash ] ); return $columns; } $columns = $this->sanitize_search_columns( $query->get( 'search_columns' ) ); return empty( $columns ) ? $this->get_default_search_columns() : $columns; } }