0.0, 'match' => 'none', ); } if ( $nq === $nc ) { return array( 'score' => 1.0, 'match' => 'exact', ); } $kq = self::key( $query ); $kc = self::key( $candidate ); if ( '' === $kq || '' === $kc ) { return array( 'score' => 0.0, 'match' => 'none', ); } if ( $kq === $kc ) { // Same letters once accents/script/spelling are ignored ("Αντιντας" = "Adidas"). return array( 'score' => 0.95, 'match' => 'equivalent', ); } $len_q = strlen( $kq ); $len_c = strlen( $kc ); $max = max( $len_q, $len_c ); $edit = 1 - ( levenshtein( $kq, $kc ) / $max ); $contain = 0.0; if ( min( $len_q, $len_c ) >= 3 && ( false !== strpos( $kc, $kq ) || false !== strpos( $kq, $kc ) ) ) { $contain = 0.6 + 0.35 * ( min( $len_q, $len_c ) / $max ); } $score = max( $edit, $contain ); return array( 'score' => round( max( 0.0, min( 0.94, $score ) ), 2 ), 'match' => $contain >= $edit && $contain > 0 ? 'partial' : 'fuzzy', ); } /** * Ranks terms for a query. * * @param string $query Query. * @param array $terms Terms. * @param int $limit Max matches. * @return array> Sorted by score (desc), then usage count. */ public static function rank( string $query, array $terms, int $limit = 5 ): array { $out = array(); foreach ( $terms as $term ) { $best = self::score( $query, $term['name'] ); $slug = self::score( $query, rawurldecode( $term['slug'] ) ); if ( $slug['score'] > $best['score'] ) { $best = $slug; } // Multi-word text: a single word of the query may match a term, or a word of // the term may match the query ("Κρεατίνη Μονοϋδρική" → "Κρεατίνες"). Weaker. $best = self::best_word_match( $query, $term['name'], $best ); if ( $best['score'] < self::MIN_SCORE ) { continue; } $out[] = $term + $best; } usort( $out, static function ( $a, $b ) { return array( $b['score'], $b['count'] ) <=> array( $a['score'], $a['count'] ); } ); return array_slice( $out, 0, max( 1, $limit ) ); } /** * Word-level partial matching (each side's words of 3+ letters), capped below whole matches. * * @param string $query Query. * @param string $name Term name. * @param array{score:float,match:string} $best Best whole-text score so far. * @return array{score:float,match:string} */ private static function best_word_match( string $query, string $name, array $best ): array { $query_words = array_filter( explode( ' ', self::normalize( $query ) ), static fn ( $w ) => mb_strlen( $w ) >= 3 ); $name_words = array_filter( explode( ' ', self::normalize( $name ) ), static fn ( $w ) => mb_strlen( $w ) >= 3 ); if ( count( $query_words ) < 2 && count( $name_words ) < 2 ) { return $best; } foreach ( $query_words ? $query_words : array( $query ) as $qw ) { foreach ( $name_words ? $name_words : array( $name ) as $nw ) { $score = self::score( $qw, $nw )['score'] * 0.85; if ( $score > $best['score'] ) { $best = array( 'score' => round( $score, 2 ), 'match' => 'partial', ); } } } return $best; } }