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 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
20 *
21 * @file
22 */
23
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Title\Title;
26
27/**
28 * An utility class to rescore search results by looking for an exact match
29 * in the db and add the page found to the first position.
30 *
31 * NOTE: extracted from TitlePrefixSearch
32 * @ingroup Search
33 */
34class SearchExactMatchRescorer {
35    /**
36     * @var ?string set when a redirect returned from the engine is replaced by the exact match
37     */
38    private ?string $replacedRedirect;
39
40    /**
41     * Default search backend does proper prefix searching, but custom backends
42     * may sort based on other algorithms that may cause the exact title match
43     * to not be in the results or be lower down the list.
44     * @param string $search the query
45     * @param int[] $namespaces
46     * @param string[] $srchres results
47     * @param int $limit the max number of results to return
48     * @return string[] munged results
49     */
50    public function rescore( $search, $namespaces, $srchres, $limit ) {
51        $this->replacedRedirect = null;
52        // Pick namespace (based on PrefixSearch::defaultSearchBackend)
53        $ns = in_array( NS_MAIN, $namespaces ) ? NS_MAIN : reset( $namespaces );
54        $t = Title::newFromText( $search, $ns );
55        if ( !$t || !$t->exists() ) {
56            // No exact match so just return the search results
57            return $srchres;
58        }
59        $string = $t->getPrefixedText();
60        $key = array_search( $string, $srchres );
61        if ( $key !== false ) {
62            // Exact match was in the results so just move it to the front
63            return $this->pullFront( $key, $srchres );
64        }
65        // Exact match not in the search results so check for some redirect handling cases
66        if ( $t->isRedirect() ) {
67            $target = $this->getRedirectTarget( $t );
68            $key = array_search( $target, $srchres );
69            if ( $key !== false ) {
70                // Exact match is a redirect to one of the returned matches so pull the
71                // returned match to the front.  This might look odd but the alternative
72                // is to put the redirect in front and drop the match.  The name of the
73                // found match is often more descriptive/better formed than the name of
74                // the redirect AND by definition they share a prefix.  Hopefully this
75                // choice is less confusing and more helpful.  But it might not be.  But
76                // it is the choice we're going with for now.
77                return $this->pullFront( $key, $srchres );
78            }
79            $redirectTargetsToRedirect = $this->redirectTargetsToRedirect( $srchres );
80            if ( isset( $redirectTargetsToRedirect[$target] ) ) {
81                // The exact match and something in the results list are both redirects
82                // to the same thing! In this case we prefer the match the user typed.
83                $this->replacedRedirect = array_splice( $srchres, $redirectTargetsToRedirect[$target], 1 )[0];
84                array_unshift( $srchres, $string );
85                return $srchres;
86            }
87        } else {
88            $redirectTargetsToRedirect = $this->redirectTargetsToRedirect( $srchres );
89            if ( isset( $redirectTargetsToRedirect[$string] ) ) {
90                // The exact match is the target of a redirect already in the results list so remove
91                // the redirect from the results list and push the exact match to the front
92                array_splice( $srchres, $redirectTargetsToRedirect[$string], 1 );
93                array_unshift( $srchres, $string );
94                return $srchres;
95            }
96        }
97
98        // Exact match is totally unique from the other results so just add it to the front
99        array_unshift( $srchres, $string );
100        // And roll one off the end if the results are too long
101        if ( count( $srchres ) > $limit ) {
102            array_pop( $srchres );
103        }
104        return $srchres;
105    }
106
107    /**
108     * Redirect initially returned by the search engine that got replaced by a better match:
109     * - exact match to a redirect to the same page
110     * - exact match to the target page
111     * @return string|null the replaced redirect or null if nothing was replaced
112     */
113    public function getReplacedRedirect(): ?string {
114        return $this->replacedRedirect;
115    }
116
117    /**
118     * @param string[] $titles
119     * @return array redirect target prefixedText to index of title in titles
120     *   that is a redirect to it.
121     */
122    private function redirectTargetsToRedirect( array $titles ) {
123        $result = [];
124        foreach ( $titles as $key => $titleText ) {
125            $title = Title::newFromText( $titleText );
126            if ( !$title || !$title->isRedirect() ) {
127                continue;
128            }
129            $target = $this->getRedirectTarget( $title );
130            if ( !$target ) {
131                continue;
132            }
133            $result[$target] = $key;
134        }
135        return $result;
136    }
137
138    /**
139     * Returns an array where the element of $array at index $key becomes
140     * the first element.
141     * @param int $key key to pull to the front
142     * @param array $array
143     * @return array $array with the item at $key pulled to the front
144     */
145    private function pullFront( $key, array $array ) {
146        $cut = array_splice( $array, $key, 1 );
147        array_unshift( $array, $cut[0] );
148        return $array;
149    }
150
151    /**
152     * Get a redirect's destination from a title
153     * @param Title $title A title to redirect. It may not redirect or even exist
154     * @return null|string If title exists and redirects, get the destination's prefixed name
155     */
156    private function getRedirectTarget( $title ) {
157        $redirectStore = MediaWikiServices::getInstance()->getRedirectStore();
158        $redir = $redirectStore->getRedirectTarget( $title );
159
160        // Needed to get the text needed for display.
161        $redir = Title::castFromLinkTarget( $redir );
162        return $redir ? $redir->getPrefixedText() : null;
163    }
164}