Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchExactMatchRescorer
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 5
380
0.00% covered (danger)
0.00%
0 / 1
 rescore
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
110
 getReplacedRedirect
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 redirectTargetsToRedirect
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 pullFront
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getRedirectTarget
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Rescores results from a prefix search/opensearch to make sure the
4 * exact match is the first result.
5 *
6 * @license GPL-2.0-or-later
7 * @file
8 */
9
10use MediaWiki\MediaWikiServices;
11use MediaWiki\Page\PageIdentity;
12use MediaWiki\Title\Title;
13
14/**
15 * An utility class to rescore search results by looking for an exact match
16 * in the db and add the page found to the first position.
17 *
18 * NOTE: extracted from TitlePrefixSearch
19 * @ingroup Search
20 */
21class SearchExactMatchRescorer {
22    /**
23     * @var ?string set when a redirect returned from the engine is replaced by the exact match
24     */
25    private ?string $replacedRedirect;
26
27    /**
28     * Default search backend does proper prefix searching, but custom backends
29     * may sort based on other algorithms that may cause the exact title match
30     * to not be in the results or be lower down the list.
31     * @param string $search the query
32     * @param int[] $namespaces
33     * @param string[] $srchres results
34     * @param int $limit the max number of results to return
35     * @return string[] munged results
36     */
37    public function rescore( $search, $namespaces, $srchres, $limit ) {
38        $this->replacedRedirect = null;
39        // Pick namespace (based on PrefixSearch::defaultSearchBackend)
40        $ns = in_array( NS_MAIN, $namespaces ) ? NS_MAIN : reset( $namespaces );
41        $t = Title::newFromText( $search, $ns );
42        if ( !$t || !$t->exists() ) {
43            // No exact match so just return the search results
44            return $srchres;
45        }
46        $string = $t->getPrefixedText();
47        $key = array_search( $string, $srchres );
48        if ( $key !== false ) {
49            // Exact match was in the results so just move it to the front
50            return $this->pullFront( $key, $srchres );
51        }
52        // Exact match not in the search results so check for some redirect handling cases
53        if ( $t->isRedirect() ) {
54            $target = $this->getRedirectTarget( $t );
55            $key = array_search( $target, $srchres );
56            if ( $key !== false ) {
57                // Exact match is a redirect to one of the returned matches so pull the
58                // returned match to the front.  This might look odd but the alternative
59                // is to put the redirect in front and drop the match.  The name of the
60                // found match is often more descriptive/better formed than the name of
61                // the redirect AND by definition they share a prefix.  Hopefully this
62                // choice is less confusing and more helpful.  But it might not be.  But
63                // it is the choice we're going with for now.
64                return $this->pullFront( $key, $srchres );
65            }
66            $redirectTargetsToRedirect = $this->redirectTargetsToRedirect( $srchres );
67            if ( isset( $redirectTargetsToRedirect[$target] ) ) {
68                // The exact match and something in the results list are both redirects
69                // to the same thing! In this case we prefer the match the user typed.
70                $this->replacedRedirect = array_splice( $srchres, $redirectTargetsToRedirect[$target], 1 )[0];
71                array_unshift( $srchres, $string );
72                return $srchres;
73            }
74        } else {
75            $redirectTargetsToRedirect = $this->redirectTargetsToRedirect( $srchres );
76            if ( isset( $redirectTargetsToRedirect[$string] ) ) {
77                // The exact match is the target of a redirect already in the results list so remove
78                // the redirect from the results list and push the exact match to the front
79                array_splice( $srchres, $redirectTargetsToRedirect[$string], 1 );
80                array_unshift( $srchres, $string );
81                return $srchres;
82            }
83        }
84
85        // Exact match is totally unique from the other results so just add it to the front
86        array_unshift( $srchres, $string );
87        // And roll one off the end if the results are too long
88        if ( count( $srchres ) > $limit ) {
89            array_pop( $srchres );
90        }
91        return $srchres;
92    }
93
94    /**
95     * Redirect initially returned by the search engine that got replaced by a better match:
96     * - exact match to a redirect to the same page
97     * - exact match to the target page
98     * @return string|null the replaced redirect or null if nothing was replaced
99     */
100    public function getReplacedRedirect(): ?string {
101        return $this->replacedRedirect;
102    }
103
104    /**
105     * @param string[] $titles
106     * @return array redirect target prefixedText to index of title in titles
107     *   that is a redirect to it.
108     */
109    private function redirectTargetsToRedirect( array $titles ) {
110        $result = [];
111        foreach ( $titles as $key => $titleText ) {
112            $title = Title::newFromText( $titleText );
113            if ( !$title || !$title->isRedirect() ) {
114                continue;
115            }
116            $target = $this->getRedirectTarget( $title );
117            if ( !$target ) {
118                continue;
119            }
120            $result[$target] = $key;
121        }
122        return $result;
123    }
124
125    /**
126     * Returns an array where the element of $array at index $key becomes
127     * the first element.
128     * @param int $key key to pull to the front
129     * @param array $array
130     * @return array $array with the item at $key pulled to the front
131     */
132    private function pullFront( $key, array $array ) {
133        $cut = array_splice( $array, $key, 1 );
134        array_unshift( $array, $cut[0] );
135        return $array;
136    }
137
138    /**
139     * Get a redirect's destination from a title
140     * @param PageIdentity $page A page to redirect. It may not redirect or even exist
141     * @return null|string If title exists and redirects, get the destination's prefixed name
142     */
143    private function getRedirectTarget( PageIdentity $page ) {
144        $redirectStore = MediaWikiServices::getInstance()->getRedirectStore();
145        $redir = $redirectStore->getRedirectTarget( $page );
146
147        // Needed to get the text needed for display.
148        $redir = Title::castFromLinkTarget( $redir );
149        return $redir ? $redir->getPrefixedText() : null;
150    }
151}