get( self::QV_ACTIVE ) || $query->get( self::QV_SKIP ) ) {
return '';
}
$s = $query->get( 's' );
if ( ! is_string( $s ) || '' === trim( $s ) ) {
return '';
}
$post_types = array_values( array_filter( (array) $query->get( 'post_type' ) ) );
if ( array( 'product' ) !== $post_types ) {
return '';
}
if ( $query->is_feed() || ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) ) {
return '';
}
$context = '';
if ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( function_exists( 'wp_is_serving_rest_request' ) && wp_is_serving_rest_request() ) ) {
$context = '';
} elseif ( wp_doing_ajax() ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- μόνο ανάγνωση του ονόματος action (δημόσιο live search του θέματος).
$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '';
$context = in_array( $action, self::ajax_actions(), true ) ? 'ajax' : '';
} elseif ( ! is_admin() ) {
$context = $query->is_main_query() ? 'page' : 'query';
}
/**
* Filters whether a product search query uses our engine in «theme» mode.
*
* Return a context string ('page', 'ajax' or 'query') to handle it, '' to leave it to WordPress.
*
* @param string $context Detected context or ''.
* @param WP_Query $query The query.
*/
$context = apply_filters( 'addsynapse_search_theme_handle_query', $context, $query );
return in_array( $context, array( 'page', 'ajax', 'query' ), true ) ? $context : '';
}
/**
* pre_get_posts: αντικαθιστά τη LIKE αναζήτηση με τα ids της μηχανής μας.
*
* @param WP_Query $query Το ερώτημα.
* @return void
*/
public static function pre_get_posts( $query ) {
$context = self::context_for( $query );
if ( '' === $context ) {
return;
}
$raw = trim( preg_replace( '/\s+/u', ' ', wp_unslash( (string) $query->get( 's' ) ) ) );
$per_page = (int) $query->get( 'posts_per_page' );
if ( 'ajax' === $context ) {
// Live search: ό,τι χωράει στο dropdown (+4 για το «sink», όπως το JS).
$limit = min( 100, max( 1, $per_page > 0 ? $per_page : 10 ) + 4 );
} else {
$limit = self::MAX_IDS;
}
/**
* Filters how many product ids are requested from the engine for a theme search.
*
* @param int $limit Number of ids.
* @param string $context page|ajax|query.
*/
$limit = max( 1, min( self::MAX_IDS, (int) apply_filters( 'addsynapse_search_theme_limit', $limit, $context ) ) );
$result = self::search( $raw, $limit );
self::$last = array(
'context' => $context,
'raw' => $raw,
'fallback' => null === $result,
'result' => $result,
);
if ( null === $result ) {
return; // Σιωπηλή επιστροφή στη native αναζήτηση του WordPress.
}
$ids = $result['ids'];
$existing = $query->get( 'post__in' );
if ( is_array( $existing ) && ! empty( $existing ) ) {
$allowed = array_flip( array_map( 'intval', $existing ) );
$ids = array_values(
array_filter(
$ids,
function ( $id ) use ( $allowed ) {
return isset( $allowed[ $id ] );
}
)
);
}
// 🔴 Μερική κάλυψη καταλόγου (0.8.0, Addsynapse_Search_Coverage): τα προϊόντα
// εκτός δείκτη ΔΕΝ εξαφανίζονται — η κανονική αναζήτηση του WordPress συμπληρώνει
// έως N ΜΕΤΑ τα δικά μας. Μόνο τότε· σε πλήρη κάλυψη το «0 = 0» παρακάτω ισχύει.
$extra = self::native_extras( $context, $limit, $ids, $result, $existing );
if ( ! empty( $extra ) ) {
if ( ! empty( $result['semantic'] ) ) {
// Κυριολεκτικό ταίριασμα εκτός δείκτη > «Σχετικά αποτελέσματα» του νοήματος.
$ids = $extra;
$result['semantic'] = false;
} else {
$ids = array_merge( $ids, $extra );
}
self::$last['native'] = $extra;
}
// 0 αποτελέσματα από τη μηχανή = 0 (όχι native LIKE): «λάμπα» σε
// κατάστημα κοσμημάτων ΔΕΝ πρέπει να ξαναβρεί «λαμπερό» από την πίσω πόρτα.
$query->set( 'post__in', empty( $ids ) ? array( 0 ) : $ids );
$query->set( self::QV_ACTIVE, 1 );
if ( self::wants_relevance_order( $query ) ) {
$query->set( 'orderby', 'post__in' );
}
if ( $query->get( 'suppress_filters' ) ) {
// Το posts_search δεν θα τρέξει — η LIKE θα έκοβε τα greeklish/typos.
$query->set( 's', '' );
}
if ( 'page' === $context ) {
self::maybe_track( $query, $raw, $result );
if ( ! empty( $result['semantic'] ) && $query->is_main_query() ) {
add_action( 'woocommerce_before_shop_loop', array( __CLASS__, 'related_notice' ), 5 );
// Builders (Woodmart/Elementor layouts, κ.ά.) δεν καλούν το woocommerce_before_shop_loop —
// το loop-start του κύριου καταλόγου όμως το καλούν σχεδόν όλοι.
add_filter( 'woocommerce_product_loop_start', array( __CLASS__, 'related_notice_loop_start' ), 5 );
}
}
}
/**
* Native συμπλήρωμα σε μερική κάλυψη (0.8.0): ids της κανονικής αναζήτησης του
* WordPress που δεν έδωσε ήδη η μηχανή μας. Live search (ajax): μόνο όσα χωράνε.
*
* @param string $context page|ajax|query.
* @param int $limit Πόσα ζητήθηκαν από τη μηχανή.
* @param int[] $ids Ids της μηχανής (μετά το post__in του ερωτήματος).
* @param array $result Αποτέλεσμα search().
* @param array|null $existing post__in του ερωτήματος (περιορισμός).
* @return int[]
*/
private static function native_extras( $context, $limit, array $ids, array $result, $existing ) {
if ( ! class_exists( 'Addsynapse_Search_Coverage' ) || ! Addsynapse_Search_Coverage::is_partial() ) {
return array();
}
if ( 'ajax' === $context ) {
$max = min( Addsynapse_Search_Coverage::native_max(), max( 0, $limit - count( $ids ) ) );
} else {
$max = Addsynapse_Search_Coverage::native_page_max();
}
if ( $max <= 0 ) {
return array();
}
$query = isset( $result['query'] ) && '' !== (string) $result['query'] ? (string) $result['query'] : '';
$extra = Addsynapse_Search_Coverage::native_extras( $query, empty( $result['semantic'] ) ? $ids : array(), $max );
if ( is_array( $existing ) && ! empty( $existing ) ) {
$allowed = array_flip( array_map( 'intval', $existing ) );
$extra = array_values(
array_filter(
$extra,
function ( $id ) use ( $allowed ) {
return isset( $allowed[ $id ] );
}
)
);
}
return $extra;
}
/** Τυπώθηκε ήδη η ετικέτα «Related results» σε αυτό το αίτημα; */
private static $notice_printed = false;
/**
* Ετικέτα πάνω από τη λίστα προϊόντων του θέματος όταν τα αποτελέσματα
* ήρθαν από το σημασιολογικό fallback (ειλικρίνεια: δεν ταίριαξαν οι λέξεις).
*
* @return void
*/
public static function related_notice() {
if ( self::$notice_printed ) {
return;
}
self::$notice_printed = true;
echo '
' . esc_html__( 'Related results', 'addsynapse-search-filter' ) . '
';
}
/**
* Ίδια ετικέτα πριν από το `` του ΚΥΡΙΟΥ καταλόγου (όχι widgets/shortcodes
* προϊόντων της ίδιας σελίδας: εκείνα έχουν όνομα loop ή δεν σελιδοποιούνται).
*
* @param string $html Το άνοιγμα της λίστας προϊόντων.
* @return string
*/
public static function related_notice_loop_start( $html ) {
if ( self::$notice_printed || ! function_exists( 'wc_get_loop_prop' ) ) {
return $html;
}
if ( '' !== (string) wc_get_loop_prop( 'name' ) || ! wc_get_loop_prop( 'is_paginated' ) ) {
return $html;
}
self::$notice_printed = true;
return '' . esc_html__( 'Related results', 'addsynapse-search-filter' ) . '
' . $html;
}
/**
* Σειρά σχετικότητας μόνο όταν ο επισκέπτης/θέμα δεν ζήτησε άλλη
* (π.χ. «τιμή αύξουσα» από το dropdown ταξινόμησης του WooCommerce).
*
* @param WP_Query $query Το ερώτημα.
* @return bool
*/
private static function wants_relevance_order( $query ) {
if ( $query->is_main_query() ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- δημόσια παράμετρος ταξινόμησης του WooCommerce, μόνο ανάγνωση.
$requested = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : '';
if ( '' !== $requested && 'relevance' !== $requested ) {
return false;
}
}
$orderby = $query->get( 'orderby' );
if ( ! is_string( $orderby ) ) {
return false;
}
return in_array( strtolower( trim( $orderby ) ), array( '', 'relevance', 'menu_order title', 'menu_order', 'post__in' ), true );
}
/**
* Εξουδετερώνει τη SQL αναζήτηση (και ό,τι πρόσθεσε το θέμα σε αυτήν).
*
* @param string $search SQL.
* @param WP_Query $query Το ερώτημα.
* @return string
*/
public static function posts_search( $search, $query ) {
return ( $query instanceof WP_Query && $query->get( self::QV_ACTIVE ) ) ? '' : $search;
}
/**
* Καμία «σειρά σχετικότητας» SQL πάνω από τη δική μας.
*
* @param string $orderby SQL.
* @param WP_Query $query Το ερώτημα.
* @return string
*/
public static function posts_search_orderby( $orderby, $query ) {
return ( $query instanceof WP_Query && $query->get( self::QV_ACTIVE ) ) ? '' : $orderby;
}
/**
* Αναζήτηση στη μηχανή μας από την PHP.
*
* @param string $raw Ό,τι έγραψε ο επισκέπτης.
* @param int $limit Πόσα ids.
* @return array|null { ids: int[], total: int, rescued: bool, semantic: bool, query: string } ή null = native.
*/
public static function search( $raw, $limit ) {
$raw = trim( preg_replace( '/\s+/u', ' ', (string) $raw ) );
$lang = Addsynapse_Search_Frontend::lang_code( Addsynapse_Search_Frontend::page_locale() );
$query = self::clean_query( $raw, $lang );
if ( self::mb_len( $query ) < self::MIN_CHARS ) {
return null;
}
if ( false !== get_transient( self::DOWN_KEY ) ) {
return null;
}
$endpoint = untrailingslashit( (string) Addsynapse_Search_Frontend::search_endpoint() );
$index = (string) Addsynapse_Search_Frontend::index_uid();
if ( '' === $endpoint || ! Addsynapse_Search_Settings::is_allowed_service_url( $endpoint ) || '' === $index || 'sups_' === $index ) {
return null;
}
$sink = 'hide' !== Addsynapse_Search_Settings::get( 'oos_behavior', 'sink' );
$body = array(
'q' => $query,
'limit' => (int) $limit,
'attributesToRetrieve' => array( 'id', 'parent_id', 'in_stock' ),
'rankingScoreThreshold' => self::threshold(),
);
$filter = self::filter_expression();
if ( '' !== $filter ) {
$body['filter'] = $filter;
}
$semantic = Addsynapse_Search_Settings::semantic_runtime();
$key = self::CACHE_PREFIX . md5( (string) wp_json_encode( array( $endpoint, $index, $body, $sink, $semantic ) ) );
if ( isset( self::$memo[ $key ] ) ) {
return self::$memo[ $key ];
}
$cached = get_transient( $key );
if ( is_array( $cached ) && isset( $cached['ids'] ) && is_array( $cached['ids'] ) ) {
self::$memo[ $key ] = $cached;
return $cached;
}
$token = class_exists( 'Addsynapse_Search_License' ) ? Addsynapse_Search_License::tenant_token() : '';
if ( ! is_string( $token ) || '' === $token ) {
return null;
}
$data = self::request( $endpoint, $index, $token, $body );
// Παλιός δείκτης χωρίς filterable catalog_visibility → χωρίς τη συνθήκη (όπως το JS).
if ( is_wp_error( $data ) && 400 === (int) $data->get_error_data() && false !== strpos( $data->get_error_message(), 'catalog_visibility' ) && isset( $body['filter'] ) ) {
set_transient( self::NOVIS_KEY, 1, DAY_IN_SECONDS );
$filter = self::filter_expression();
if ( '' !== $filter ) {
$body['filter'] = $filter;
} else {
unset( $body['filter'] );
}
$data = self::request( $endpoint, $index, $token, $body );
}
if ( is_wp_error( $data ) ) {
set_transient( self::DOWN_KEY, 1, self::DOWN_TTL );
return null;
}
$hits = $data['hits'];
$rescued = false;
if ( empty( $hits ) && self::rescue_worth( $query ) ) {
$fb = array(
'q' => $query,
'limit' => (int) $limit,
'attributesToSearchOn' => self::RESCUE_FIELDS,
'showRankingScore' => true,
'showRankingScoreDetails' => true,
'attributesToRetrieve' => array( 'id', 'parent_id', 'in_stock', 'title', 'brand', 'categories' ),
);
if ( isset( $body['filter'] ) ) {
$fb['filter'] = $body['filter'];
}
$res = self::request( $endpoint, $index, $token, $fb );
if ( ! is_wp_error( $res ) ) {
foreach ( $res['hits'] as $hit ) {
if ( self::rescue_accept( $hit, $query ) ) {
$hits[] = $hit;
}
}
$rescued = ! empty( $hits );
}
}
// Σημασιολογική αναζήτηση (beta, 0.7.0) — ΜΟΝΟ fallback, ίδιος κανόνας με
// το Semantic του JS: 0 μετά το Rescue + ρύθμιση ανοιχτή + server «ready».
// ΕΝΑ hybrid αίτημα· σφάλμα (π.χ. 400 vector_embedding_error) = 0, ΧΩΡΙΣ
// να ανοίξει ο «διακόπτης» DOWN_KEY (η κανονική αναζήτηση δούλεψε).
$is_semantic = false;
if ( empty( $hits ) && null !== $semantic ) {
// 0.7.1: κατώφλι/Δ ανά ζεύγος γλωσσών (Settings::semantic_cut).
$cut = Addsynapse_Search_Settings::semantic_cut( $semantic, $query );
$sb = array(
'q' => $query,
'hybrid' => array(
'embedder' => $semantic['embedder'],
'semanticRatio' => $semantic['ratio'],
),
'rankingScoreThreshold' => $cut['threshold'],
'limit' => max( 1, min( (int) $limit, (int) $semantic['limit'] ) ),
'attributesToRetrieve' => array( 'id', 'parent_id', 'in_stock' ),
'showRankingScore' => true,
);
if ( isset( $body['filter'] ) ) {
$sb['filter'] = $body['filter'];
}
$res = self::request( $endpoint, $index, $token, $sb );
if ( ! is_wp_error( $res ) && ! empty( $res['hits'] ) ) {
$hits = Addsynapse_Search_Settings::semantic_relative_cut( $res['hits'], $cut['delta'] );
$is_semantic = ! empty( $hits );
}
}
$ids = self::hits_to_ids( $hits, $sink );
$result = array(
'ids' => $ids,
'total' => count( $ids ),
'rescued' => $rescued,
'semantic' => $is_semantic && ! empty( $ids ),
'query' => $query,
);
set_transient( $key, $result, self::CACHE_TTL );
self::$memo[ $key ] = $result;
return $result;
}
/**
* Το κατώφλι σχετικότητας (ρύθμιση από τον server, default 0,6).
*
* @return float
*/
private static function threshold() {
$t = Addsynapse_Search_Settings::get( 'ranking_score_threshold', 0.6 );
$t = is_numeric( $t ) ? (float) $t : 0.6;
return ( $t < 0 || $t > 1 ) ? 0.6 : $t;
}
/**
* Ίδια φίλτρα με το buildFilterExpression() του JS (χωρίς facets).
*
* @return string
*/
private static function filter_expression() {
$parts = array();
if ( false === get_transient( self::NOVIS_KEY ) ) {
$parts[] = 'NOT catalog_visibility = "catalog"';
}
if ( 'hide' === Addsynapse_Search_Settings::get( 'oos_behavior', 'sink' ) ) {
$parts[] = 'in_stock = true';
}
return implode( ' AND ', $parts );
}
/**
* POST {endpoint}/indexes/{index}/search με το tenant token.
*
* @param string $endpoint Host αναζήτησης.
* @param string $index Δείκτης.
* @param string $token Tenant token.
* @param array $body Σώμα.
* @return array|WP_Error Το JSON (με `hits` πίνακα) ή σφάλμα (data = HTTP status).
*/
private static function request( $endpoint, $index, $token, array $body ) {
$url = $endpoint . '/indexes/' . rawurlencode( $index ) . '/search';
$timeout = (float) apply_filters( 'addsynapse_search_theme_timeout', self::TIMEOUT );
$args = array(
'timeout' => max( 0.5, min( 10.0, $timeout ) ),
'redirection' => 0,
'headers' => array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $token,
),
'body' => wp_json_encode( $body ),
);
$response = wp_remote_post( $url, $args );
$code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
// Token που ο server ανακάλεσε/έληξε: ΜΙΑ ανανέωση (single-flight, με backoff στο License).
if ( ( 401 === $code || 403 === $code ) && class_exists( 'Addsynapse_Search_License' ) ) {
$fresh = Addsynapse_Search_License::tenant_token_force_refresh( $token );
if ( is_string( $fresh ) && '' !== $fresh && $fresh !== $token ) {
$args['headers']['Authorization'] = 'Bearer ' . $fresh;
$response = wp_remote_post( $url, $args );
$code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
}
}
if ( is_wp_error( $response ) ) {
return new WP_Error( 'addsynapse_search_theme_http', $response->get_error_message(), 0 );
}
$json = json_decode( (string) wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code ) {
$message = is_array( $json ) && isset( $json['message'] ) && is_string( $json['message'] ) ? $json['message'] : '';
return new WP_Error( 'addsynapse_search_theme_http', $message, $code );
}
if ( ! is_array( $json ) || ! isset( $json['hits'] ) || ! is_array( $json['hits'] ) ) {
return new WP_Error( 'addsynapse_search_theme_shape', 'bad response', 0 );
}
return $json;
}
/**
* Hits → μοναδικά ids γονικών προϊόντων, με τη σειρά της μηχανής·
* «sink»: τα εξαντλημένα στο τέλος (σταθερά, όπως το JS).
*
* @param array $hits Hits.
* @param bool $sink Εξαντλημένα στο τέλος.
* @return int[]
*/
public static function hits_to_ids( array $hits, $sink ) {
$in = array();
$out = array();
$seen = array();
foreach ( $hits as $hit ) {
if ( ! is_array( $hit ) ) {
continue;
}
$id = isset( $hit['parent_id'] ) && '' !== (string) $hit['parent_id'] ? $hit['parent_id'] : ( isset( $hit['id'] ) ? $hit['id'] : 0 );
$id = is_numeric( $id ) ? (int) $id : 0;
if ( $id <= 0 || isset( $seen[ $id ] ) ) {
continue;
}
$seen[ $id ] = true;
if ( $sink && isset( $hit['in_stock'] ) && false === $hit['in_stock'] ) {
$out[] = $id;
} else {
$in[] = $id;
}
}
return array_merge( $in, $out );
}
/**
* Μία μέτρηση «search» ανά σελίδα αποτελεσμάτων (ΟΧΙ ανά πλήκτρο του live
* search) — μόνο αν ο ιδιοκτήτης έχει ανάψει τα στατιστικά (opt-in).
* Non-blocking: το αίτημα φεύγει χωρίς να περιμένουμε απάντηση.
*
* @param WP_Query $query Το ερώτημα.
* @param string $raw Αρχική φράση.
* @param array $result Αποτέλεσμα της search().
* @return void
*/
private static function maybe_track( $query, $raw, array $result ) {
if ( self::$tracked || ! $query->is_main_query() || (int) $query->get( 'paged' ) > 1 ) {
return;
}
if ( ! (bool) Addsynapse_Search_Settings::get( 'send_search_stats', false ) ) {
return;
}
$endpoint = (string) Addsynapse_Search_Settings::get( 'analytics_endpoint', '' );
if ( '' === $endpoint ) {
$endpoint = Addsynapse_Search_Settings::DEFAULT_API_BASE . '/v1/track';
}
$shop_id = class_exists( 'Addsynapse_Search_License' ) ? (int) Addsynapse_Search_License::shop_id() : 0;
if ( $shop_id < 1 || ! Addsynapse_Search_Settings::is_allowed_service_url( $endpoint ) ) {
return;
}
self::$tracked = true;
$body = array(
'type' => 'search',
'shop_id' => $shop_id,
'ts' => (int) round( microtime( true ) * 1000 ),
'query' => self::mb_sub( $result['query'], 255 ),
'hits' => (int) $result['total'],
);
// Σημασιολογικό fallback: δεν μετρά ως «χωρίς αποτέλεσμα» (hits > 0) αλλά σημαδεύεται.
if ( ! empty( $result['semantic'] ) && (int) $result['total'] > 0 ) {
$body['semantic'] = 1;
}
if ( $raw !== $result['query'] ) {
$body['raw'] = self::mb_sub( $raw, 200 );
}
wp_remote_post(
$endpoint,
array(
'blocking' => false,
'timeout' => 1,
'headers' => array( 'Content-Type' => 'text/plain;charset=UTF-8' ),
'body' => wp_json_encode( $body ),
)
);
}
// -----------------------------------------------------------------
// Καθαρισμός φράσεων-γεμίσματος — port του QueryCleaner του JS.
// -----------------------------------------------------------------
/**
* «ψάχνω για lego παρακαλώ» → «lego». Ίδιοι κανόνες με το JS: μόνο αρχή
* (lead + προαιρετικό after) και τέλος (trail), ολόκληρες λέξεις, χωρίς
* τόνους/πεζά· αν μένουν <2 χαρακτήρες → το αρχικό.
*
* @param string $raw Ό,τι έγραψε ο επισκέπτης.
* @param string $lang Κωδικός γλώσσας.
* @return string
*/
public static function clean_query( $raw, $lang ) {
$text = trim( preg_replace( '/\s+/u', ' ', (string) $raw ) );
$data = '' !== $text ? self::fillers( $lang ) : null;
if ( null === $data ) {
return $text;
}
$chars = self::chars( $text );
$n = array();
foreach ( $chars as $ch ) {
$n[] = self::norm_char( $ch );
}
$start = 0;
$end = count( $chars );
$stripped = false;
for ( $guard = 0; $guard < 8; $guard++ ) {
$s = $start;
while ( $s < $end && self::is_sep( $n[ $s ] ) ) {
$s++;
}
$len = self::match_at( $n, $s, $end, $data['lead'] );
if ( ! $len ) {
break;
}
$start = $s + $len;
$stripped = true;
$s = $start;
while ( $s < $end && preg_match( '/^\s$/u', $n[ $s ] ) ) {
$s++;
}
$len = $data['after'] ? self::match_at( $n, $s, $end, $data['after'] ) : 0;
if ( $len ) {
$start = $s + $len;
}
}
for ( $guard = 0; $guard < 4; $guard++ ) {
$s = $end;
while ( $s > $start && self::is_sep( $n[ $s - 1 ] ) ) {
$s--;
}
$len = self::match_end( $n, $start, $s, $data['trail'] );
if ( ! $len ) {
break;
}
$end = $s - $len;
$stripped = true;
}
if ( ! $stripped ) {
return $text;
}
while ( $start < $end && self::is_sep( $n[ $start ] ) ) {
$start++;
}
while ( $end > $start && self::is_sep( $n[ $end - 1 ] ) ) {
$end--;
}
$out = implode( '', array_slice( $chars, $start, $end - $start ) );
return self::mb_len( $out ) < 2 ? $text : $out;
}
/**
* Λίστες της γλώσσας, προετοιμασμένες (κανονικοποιημένες, μεγαλύτερη πρώτα).
*
* @param string $lang Κωδικός.
* @return array|null
*/
private static function fillers( $lang ) {
static $prepared = array();
$lang = substr( strtolower( (string) $lang ), 0, 2 );
if ( array_key_exists( $lang, $prepared ) ) {
return $prepared[ $lang ];
}
$all = '' !== $lang ? Addsynapse_Search_Frontend::fillers_config( $lang, $lang ) : array();
$src = isset( $all[ $lang ] ) && is_array( $all[ $lang ] ) ? $all[ $lang ] : null;
$data = null;
if ( $src ) {
$data = array();
foreach ( array( 'lead', 'after', 'trail' ) as $k ) {
$data[ $k ] = self::prepare_list( isset( $src[ $k ] ) ? $src[ $k ] : array() );
}
if ( ! $data['lead'] && ! $data['trail'] ) {
$data = null;
}
}
$prepared[ $lang ] = $data;
return $data;
}
/**
* @param mixed $list Φράσεις.
* @return array[] Πίνακες χαρακτήρων, μεγαλύτερη πρώτα.
*/
private static function prepare_list( $list ) {
$out = array();
$seen = array();
if ( ! is_array( $list ) ) {
return $out;
}
foreach ( array_slice( $list, 0, 500 ) as $p ) {
if ( ! is_string( $p ) ) {
continue;
}
$p = trim( preg_replace( '/\s+/u', ' ', $p ) );
if ( '' === $p ) {
continue;
}
$chars = array();
foreach ( self::chars( $p ) as $ch ) {
$chars[] = self::norm_char( $ch );
}
$k = implode( '', $chars );
if ( isset( $seen[ $k ] ) ) {
continue;
}
$seen[ $k ] = true;
$out[] = $chars;
}
usort(
$out,
function ( $a, $b ) {
return count( $b ) - count( $a );
}
);
return $out;
}
/**
* @param string[] $n Κανονικοποιημένοι χαρακτήρες.
* @param int $pos Θέση.
* @param int $end Τέλος.
* @param array $phrases Φράσεις.
* @return int
*/
private static function match_at( array $n, $pos, $end, array $phrases ) {
foreach ( $phrases as $p ) {
$len = count( $p );
$stop = $pos + $len;
if ( $stop > $end || array_slice( $n, $pos, $len ) !== $p ) {
continue;
}
if ( $stop === $end || self::is_sep( $n[ $stop ] ) || "'" === $p[ $len - 1 ] ) {
return $len;
}
}
return 0;
}
/**
* @param string[] $n Κανονικοποιημένοι χαρακτήρες.
* @param int $start Αρχή.
* @param int $end Τέλος.
* @param array $phrases Φράσεις.
* @return int
*/
private static function match_end( array $n, $start, $end, array $phrases ) {
foreach ( $phrases as $p ) {
$len = count( $p );
$from = $end - $len;
if ( $from < $start || array_slice( $n, $from, $len ) !== $p ) {
continue;
}
if ( $from === $start || self::is_sep( $n[ $from - 1 ] ) ) {
return $len;
}
}
return 0;
}
/**
* Διαχωριστικό (ίδια κλάση με το SEP του JS).
*
* @param string $ch Χαρακτήρας.
* @return bool
*/
private static function is_sep( $ch ) {
return '' !== $ch && 1 === preg_match( '/^[\s,.;:!?¿¡·;\-–—…"\'«»“”„()\[\]]$/u', $ch );
}
/**
* Ένας χαρακτήρας → χωρίς τόνους, πεζός, ς→σ, ’‘`´→' (1 προς 1, όπως buildNormalizedMap).
*
* @param string $ch Χαρακτήρας.
* @return string
*/
private static function norm_char( $ch ) {
$n = self::strip_marks( $ch );
if ( '' === $n ) {
$n = $ch;
}
if ( function_exists( 'mb_substr' ) ) {
$n = mb_substr( $n, 0, 1, 'UTF-8' );
}
$n = function_exists( 'mb_strtolower' ) ? mb_strtolower( $n, 'UTF-8' ) : strtolower( $n );
if ( 'ς' === $n ) {
$n = 'σ';
}
if ( in_array( $n, array( '’', '‘', '`', '´' ), true ) ) {
$n = "'";
}
return $n;
}
/**
* Αφαίρεση διακριτικών (NFD + \p{Mn}), με εφεδρικό χάρτη ελληνικών τόνων χωρίς intl.
*
* @param string $s Κείμενο.
* @return string
*/
private static function strip_marks( $s ) {
if ( class_exists( 'Normalizer' ) ) {
$d = Normalizer::normalize( $s, Normalizer::FORM_D );
if ( is_string( $d ) ) {
return (string) preg_replace( '/\p{Mn}+/u', '', $d );
}
}
$map = array(
'ά' => 'α', 'έ' => 'ε', 'ή' => 'η', 'ί' => 'ι', 'ό' => 'ο', 'ύ' => 'υ', 'ώ' => 'ω',
'ϊ' => 'ι', 'ϋ' => 'υ', 'ΐ' => 'ι', 'ΰ' => 'υ',
'Ά' => 'Α', 'Έ' => 'Ε', 'Ή' => 'Η', 'Ί' => 'Ι', 'Ό' => 'Ο', 'Ύ' => 'Υ', 'Ώ' => 'Ω', 'Ϊ' => 'Ι', 'Ϋ' => 'Υ',
);
return remove_accents( strtr( $s, $map ) );
}
// -----------------------------------------------------------------
// «Διάσωση» λάθους τίτλου — port του Rescue του JS (βλ. docs/RELEVANCE.md).
// -----------------------------------------------------------------
/**
* @param string $s Κείμενο.
* @return string Πεζό, χωρίς τόνους, ς→σ.
*/
private static function rescue_norm( $s ) {
$s = self::strip_marks( (string) $s );
$s = function_exists( 'mb_strtolower' ) ? mb_strtolower( $s, 'UTF-8' ) : strtolower( $s );
return str_replace( 'ς', 'σ', $s );
}
/**
* @param string $s Κείμενο.
* @return string[]
*/
private static function rescue_words( $s ) {
$parts = preg_split( '/[\s\-_.,;:!?()\[\]{}"\'\/\\\\|&+*#@%<>=~`^$]+/u', self::rescue_norm( $s ) );
return array_values( array_filter( is_array( $parts ) ? $parts : array(), 'strlen' ) );
}
/**
* Levenshtein ≤ 1 (με αντιμετάθεση γειτονικών), σε χαρακτήρες UTF-8.
*
* @param string $a Λέξη.
* @param string $b Λέξη.
* @return bool
*/
public static function within1( $a, $b ) {
if ( $a === $b ) {
return true;
}
$a = self::chars( $a );
$b = self::chars( $b );
$la = count( $a );
$lb = count( $b );
if ( abs( $la - $lb ) > 1 ) {
return false;
}
$i = 0;
$j = 0;
$edits = 0;
while ( $i < $la && $j < $lb ) {
if ( $a[ $i ] === $b[ $j ] ) {
$i++;
$j++;
continue;
}
if ( ++$edits > 1 ) {
return false;
}
if ( $la > $lb ) {
$i++;
} elseif ( $lb > $la ) {
$j++;
} else {
if ( isset( $a[ $i + 1 ], $b[ $j + 1 ] ) && $a[ $i + 1 ] === $b[ $j ] && $a[ $i ] === $b[ $j + 1 ] && array_slice( $a, $i + 2 ) === array_slice( $b, $j + 2 ) ) {
return true;
}
$i++;
$j++;
}
}
return $edits + ( $la - $i ) + ( $lb - $j ) <= 1;
}
/**
* @param string $w Λέξη query.
* @param string[] $tokens Λέξεις τίτλου/μάρκας/κατηγοριών.
* @return bool
*/
private static function rescue_near( $w, array $tokens ) {
foreach ( $tokens as $t ) {
if ( $t === $w || 0 === strpos( $t, $w ) ) {
return true;
}
if ( self::mb_len( $w ) >= self::RESCUE_MIN_WORD && self::within1( $w, $t ) ) {
return true;
}
}
return false;
}
/**
* Αξίζει διάσωση: τουλάχιστον μία λέξη ≥5 χαρακτήρων.
*
* @param string $query Query.
* @return bool
*/
public static function rescue_worth( $query ) {
foreach ( self::rescue_words( $query ) as $w ) {
if ( self::mb_len( $w ) >= self::RESCUE_MIN_WORD ) {
return true;
}
}
return false;
}
/**
* Δεκτό hit του fallback (ίδιοι κανόνες με το accept() του JS).
*
* @param array $hit Hit με _rankingScoreDetails.
* @param string $query Query.
* @return bool
*/
public static function rescue_accept( $hit, $query ) {
$d = is_array( $hit ) && isset( $hit['_rankingScoreDetails'] ) && is_array( $hit['_rankingScoreDetails'] ) ? $hit['_rankingScoreDetails'] : null;
if ( ! $d ) {
return false;
}
$qw = self::rescue_words( $query );
$need = count( $qw );
if ( isset( $d['words'] ) && is_array( $d['words'] ) ) {
$w = $d['words'];
if ( ! isset( $w['maxMatchingWords'], $w['matchingWords'] ) || ! is_numeric( $w['maxMatchingWords'] ) || (int) $w['matchingWords'] !== (int) $w['maxMatchingWords'] || (int) $w['maxMatchingWords'] < 1 ) {
return false;
}
$need = (int) $w['maxMatchingWords'];
}
if ( ! $need || ( isset( $d['typo']['typoCount'] ) && is_numeric( $d['typo']['typoCount'] ) && (int) $d['typo']['typoCount'] > $need ) ) {
return false;
}
$tokens = array();
foreach ( self::RESCUE_FIELDS as $f ) {
$v = isset( $hit[ $f ] ) ? $hit[ $f ] : '';
$v = is_array( $v ) ? implode( ' ', array_filter( $v, 'is_string' ) ) : ( is_string( $v ) ? $v : '' );
$tokens = array_merge( $tokens, self::rescue_words( $v ) );
}
$verified = 0;
foreach ( $qw as $w ) {
if ( self::rescue_near( $w, $tokens ) ) {
$verified++;
}
}
return $verified >= 1 && $verified >= $need;
}
// -----------------------------------------------------------------
// UTF-8 βοηθητικά (PHP 7.4, mbstring προαιρετικό).
// -----------------------------------------------------------------
/**
* @param string $s Κείμενο.
* @return string[]
*/
private static function chars( $s ) {
$out = preg_split( '//u', (string) $s, -1, PREG_SPLIT_NO_EMPTY );
return is_array( $out ) ? $out : array();
}
/**
* @param string $s Κείμενο.
* @return int
*/
private static function mb_len( $s ) {
return count( self::chars( $s ) );
}
/**
* @param string $s Κείμενο.
* @param int $max Χαρακτήρες.
* @return string
*/
private static function mb_sub( $s, $max ) {
return implode( '', array_slice( self::chars( $s ), 0, $max ) );
}
/**
* Γνωστά θέματα με ΔΙΚΟ τους πάνελ αναζήτησης (για την υπόδειξη στις Ρυθμίσεις).
*
* @return string Όνομα θέματος ή ''.
*/
public static function theme_with_own_panel() {
$known = array(
'woodmart' => 'Woodmart',
'basel' => 'Basel',
'flatsome' => 'Flatsome',
'Divi' => 'Divi',
'Avada' => 'Avada',
'porto' => 'Porto',
'kadence' => 'Kadence',
'oceanwp' => 'OceanWP',
'blocksy' => 'Blocksy',
'generatepress' => 'GeneratePress',
);
$template = (string) get_template();
foreach ( $known as $slug => $name ) {
if ( 0 === strcasecmp( $slug, $template ) ) {
return $name;
}
}
return '';
}
}