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