php_Search.php

<?php

namespace FindStr;

use Exception;
use Meilisearch\Search\SearchResult;
use WP_Post;
use WP_Query;

class Search {

  private static ?array $instances = array();

  public string $group;
  public array $default_query_args;
  public array $query;

    /**
     * Get Search instances
     *
     * @param string $group
     *
     * @return Search
     */
  public static function get_instance( string $group = 'default' ) : Search {

    if ( empty( self::$instances[ $group ] ) ) {
      self::$instances[ $group ] = new Search( $group );
    }

    return self::$instances[ $group ];
  }


  private function __construct( $group ) {

    $this->group = $group;

    $this->default_query_args = $this->get_default_query_args();
    $this->query              = $this->default_query_args; // set default query args

    // add filter to WP main search, only once.
    // This code is executed only once, because the $instance array is empty.
    if ( empty( self::$instances ) ) {
      //todo:should we add findstr results to WP the search query?
       add_filter( 'the_posts', array( $this, 'the_posts_filter' ), 10, 2 );
    }

  }

  /**
   * This function add the formatted content to the post object
   *
   * @param $posts
   * @param WP_Query $query
   *
   * @return WP_Post[] $posts
   */
  public function the_posts_filter( $posts, WP_Query $query ): array {
    if ( $query->is_search() && $query->is_main_query() && ! is_admin() ) {
      $results = $query->get( 'findstr_results' );

      if ( $results ) {
        $hits = $results->getHits();

        foreach ( $posts as $k => $post ) {
          $post->post_title   = $hits[ $k ]['_formatted']['post_title'];
          $post->post_content = $hits[ $k ]['_formatted']['post_content'];
        }
      }

      return $posts;
    }

    return $posts;
  }





  /**
   * Get default query args
   *
   * @return array
   */
  public function get_default_query_args(): array {
    /**
     * Filter the default search query args.
     *
     * Use this filter to modify the default search query args.
     * See Meilisearch documentation for more information.
     * @see https://www.meilisearch.com/docs/reference/api/search#body
     *
     * @hook findstr_default_search_query_args
     *
     * @param {array} $args default search query args
     * @param {string} $group search group
     *
     * @return {array} $defaults_args
     */
    $query = apply_filters(
      'findstr_default_search_query_args',
      array(
        'q'                     => '',
        'offset'                => 0,
        'hitsPerPage'           => 12,
        'page'                  => 1,
        'filter'                => array(
          'clauses' => array(
            'language' => array(
              'value' => Helpers::get_language_code(),
            ),
          ),
        ),
        'facets'                => $this->get_filterable_attributes(),
        'attributesToRetrieve'  => array( '*' ),
        'attributesToHighlight' => array( 'post_content' ),
        'attributesToCrop'      => array( 'post_content' ),
        'attributesToSearchOn'  => $this->get_searchable_attributes(),
        'cropLength'            => 25,
        'highlightPreTag'       => '<span class="findstr-result-highlight">',
        'highlightPostTag'      => '</span>',
        'sort'                  => array(
          'sticky'    => 'desc', //set the default sort with sticky posts first
          'post_date' => 'desc',
        ),
      ),
      $this->group
    );

    $server_version = ( new ServerSettings() )->get_meilisearch_version();
    if ( empty( $server_version ) || is_wp_error( $server_version ) ) {
      return $query;
    }

    if ( version_compare( $server_version['pkgVersion'], '1.3.0', '<' ) ) {
      unset( $query['attributesToSearchOn'] );
    }

    return $query;
  }


  public function update_query( $args = array() ): array {

    $this->query = Helpers::merge_args( $args, $this->query );

    return $this->query;
  }

  public function set_query_arg( $parameter, $value ) {
    $this->query[ $parameter ] = $value;
  }

  private function get_searchable_attributes(): array {

    $indexable_fields = (array) ( new SettingsIndexableFields() )->get();

    //get all sortable fields based on indexable fields properties
    $searchable_attributes = array_filter(
      $indexable_fields,
      function ( $field ) {
        return isset( $field->searchable ) && $field->searchable;
      }
    );

    $searchable_attributes = array_keys( $searchable_attributes );

    /**
     * Filter the searchable attributes.
     * Use this filter to modify the searchable attributes.
     *
     * @hook findstr_searchable_attributes
     * @param {array} $searchable_attributes searchable attributes
     *
     * @return {array} $searchable_attributes
     */
    $searchable_attributes = apply_filters( 'findstr_searchable_attributes', $searchable_attributes );

    if ( empty( $searchable_attributes ) ) {
      $searchable_attributes = array( '*' );
    }

    return $searchable_attributes;
  }


  /**
   * Get filterable attributes
   *
   * @return array
   */
  private function get_filterable_attributes(): array {

    $filterable_attributes = Indexer::get_instance()->get_filterable_attributes();

    //remove ID facetDistribution from frontend, because we will not need it anyway.
    foreach ( array_keys( $filterable_attributes, 'ID', true ) as $key ) {
      unset( $filterable_attributes[ $key ] );
    }

    return array_values( $filterable_attributes );
  }


  /**
   * Add filter
   *
   * @param $key
   * @param $value
   * @param string $compare
   *
   * @return void
   */
  public function add_filter( $key, $value, string $compare = '=' ) {
    $this->query['filter']['clauses'][ $key ]['value'] = $value;
    if ( '=' !== $compare ) {
      $this->query['filter']['clauses'][ $key ]['compare'] = $compare;
    }

  }

  /**
   * @return mixed|null
   */
  public function get_query() {

    /**
     * Filter the search query args.
     *
     * Use this filter to modify the search query args, before the search is executed.
     *
     * @hook findstr_search_query_args
     *
     * @param {array} $args search query args
     * @param {string} $group search group
     *
     * @return {array} $args
     */
    return apply_filters( 'findstr_search_query_args', $this->query, $this->group );
  }

    /**
     * @param string $q
     * @param array $args
     *
     * @return SearchResult
     */
  public function fetch_results( string $q = '', array $args = array() ): SearchResult {

    $client = new Client();
    $index  = $client->index;

    $query_args = $this->get_query();

    $query_args['filter'] = $this->parse_filters( $query_args['filter'] );
    $query_args['sort']   = $this->parse_sort( $query_args['sort'] );
    $query_args['q']      = ! empty( $q ) ? $q : $query_args['q'];

    //take the search query from WordPress search
    if ( ! empty( get_search_query() ) ) {
      $query_args['q'] = get_search_query();
    }

    //add queried filters from url at the very last moment.
    $queried_search = get_query_var( 'findstr' );
    if ( ! empty( $queried_search ) && ! empty( $queried_search['group'] ) && $this->group === $queried_search['group'] && ! empty( $queried_search['page'] ) ) {
      $query_args['page'] = (int) $queried_search['page'];
    }

    return $index->search( $q, $query_args );
  }

  private function escape_string( $string ): string {
    return str_replace( "'", "\\'", $string );
  }

  /**
   * Parse filters
   * All the logic here has to be replicated in the javascript parser
   * @see front/findstr-search/src/helpers.js:14
   *
   * @param $filters
   *
   * @return string
   */
  public function parse_filters( $filters ): string {

    //insert queried filters from url at the very last moment.
    $queried_search = wp_unslash( get_query_var( 'findstr' ) );

    if ( ! empty( $queried_search ) && ! empty( $queried_search['group'] ) && $this->group === $queried_search['group'] && ! empty( $queried_search['filter'] ) ) {
      $filters['clauses'] = Helpers::merge_args( $queried_search['filter'], $filters['clauses'] );
    }

    $filters['relation'] = $filters['relation'] ?? 'AND';

    if ( empty( $filters['clauses'] ) || ! is_array( $filters['clauses'] ) ) {
      return '';
    }

    $clauses = array();
    foreach ( $filters['clauses'] as $key => $clause ) {
      if ( is_array( $clause ) ) {

        if ( isset( $clause['relation'] ) && isset( $clause['clauses'] ) ) {
          $sub_query = $this->parse_filters( $clause );
          $clauses[] = "( {$sub_query} )";
          continue;
        }

        //set default key
        $clause['key'] = $clause['key'] ?? $key;

        if ( isset( $clause['value'] ) && is_array( $clause['value'] ) && ( empty( $clause['compare'] ) ) ) {
          $clause['compare'] = 'IN';
        }

        //set compare defaults
        $clause['compare'] = $clause['compare'] ?? '=';

        switch ( $clause['compare'] ) {
          case 'IN':
          case 'NOT IN':
            $clause['value'] = is_array( $clause['value'] ) ? $clause['value'] : array( $clause['value'] );
            $clause['value'] = array_map(
              function( $v ) {
                $v = $this->escape_string( $v );
                return "'$v'";
              },
              $clause['value']
            );
            $clause['value'] = implode( ',', $clause['value'] );
            $clauses[]       = "{$clause['key']} {$clause['compare']} [{$clause['value']}]";

              break;

          case 'EXISTS':
          case 'NOT EXISTS':
          case 'EMPTY':
          case 'NOT EMPTY':
          case 'IS NULL':
          case 'IS NOT NULL':
            $clauses[] = "{$clause['key']} {$clause['compare']}";
              break;
          case 'BETWEEN':
            if ( is_array( $clause['value'] ) ) {
              $clauses[] = sprintf(
                "%s >='%s' AND %s < '%s'",
                $clause['key'],
                $this->escape_string( $clause['value'][0] ),
                $clause['key'],
                $this->escape_string( $clause['value'][1] )
              );
            }
              break;
          default:
            if ( is_array( $clause['value'] ) ) {
              foreach ( $clause['value'] as $k => $v ) {
                $clauses[] = sprintf( "%s %s '%s'", $clause['key'], $clause['compare'], $this->escape_string( $v ) );
              }
            } else {
              $clauses [ $key ] = sprintf( "%s %s '%s'", $clause['key'], $clause['compare'], $this->escape_string( $clause['value'] ) );
            }
              break;
        }
      }
    }

    return implode( " {$filters['relation']} ", $clauses );
  }

  private function parse_sort( $sort ): array {

    //insert queried filters from url at the very last moment.
    $queried_search = get_query_var( 'findstr' );
    if ( ! empty( $queried_search ) && ! empty( $queried_search['group'] ) && $this->group === $queried_search['group'] && ! empty( $queried_search['sort'] ) ) {
      $sort = $queried_search['sort'];
    }

    $parsed_sort = array();
    if ( empty( $sort ) || ! is_array( $sort ) ) {
      return $parsed_sort;
    }

    foreach ( $sort as $key => $value ) {
      $parsed_sort[] = sprintf( '%s:%s', $key, $value );
    }
    return $parsed_sort;
  }

}