<?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;
}
}