Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 96
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialRandomInCategory
0.00% covered (danger)
0.00%
0 / 95
0.00% covered (danger)
0.00%
0 / 14
1332
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setCategory
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getFormFields
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 requiresPost
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDisplayFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 alterForm
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSubpageField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onSubmit
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 getRandomTitle
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 getQueryBuilder
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 getTimestampOffset
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getMinAndMaxForCat
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 selectRandomPageFromDB
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Specials;
22
23use BadMethodCallException;
24use MediaWiki\HTMLForm\HTMLForm;
25use MediaWiki\SpecialPage\FormSpecialPage;
26use MediaWiki\Status\Status;
27use MediaWiki\Title\Title;
28use stdClass;
29use Wikimedia\Rdbms\IConnectionProvider;
30use Wikimedia\Rdbms\SelectQueryBuilder;
31
32/**
33 * Redirect to a random page in a category
34 *
35 * @note The method used here is rather biased. It is assumed that
36 * the use of this page will be people wanting to get a random page
37 * out of a maintenance category, to fix it up. The method used by
38 * this page should return different pages in an unpredictable fashion
39 * which is hoped to be sufficient, even if some pages are selected
40 * more often than others.
41 *
42 * A more unbiased method could be achieved by adding a cl_random field
43 * to the categorylinks table.
44 *
45 * The method used here is as follows:
46 *  * Find the smallest and largest timestamp in the category
47 *  * Pick a random timestamp in between
48 *  * Pick an offset between 0 and 30
49 *  * Get the offset'ed page that is newer than the timestamp selected
50 * The offset is meant to counter the fact the timestamps aren't usually
51 * uniformly distributed, so if things are very non-uniform at least we
52 * won't have the same page selected 99% of the time.
53 *
54 * @ingroup SpecialPage
55 * @author Brian Wolff
56 */
57class SpecialRandomInCategory extends FormSpecialPage {
58    /** @var string[] Extra SQL statements */
59    protected $extra = [];
60    /** @var Title|false Title object of category */
61    protected $category = false;
62    /** @var int Max amount to fudge randomness by */
63    protected $maxOffset = 30;
64    /** @var int|null */
65    private $maxTimestamp = null;
66    /** @var int|null */
67    private $minTimestamp = null;
68
69    private IConnectionProvider $dbProvider;
70
71    /**
72     * @param IConnectionProvider $dbProvider
73     */
74    public function __construct( IConnectionProvider $dbProvider ) {
75        parent::__construct( 'RandomInCategory' );
76        $this->dbProvider = $dbProvider;
77    }
78
79    /**
80     * Set which category to use.
81     * @param Title $cat
82     */
83    public function setCategory( Title $cat ) {
84        $this->category = $cat;
85        $this->maxTimestamp = null;
86        $this->minTimestamp = null;
87    }
88
89    protected function getFormFields() {
90        $this->addHelpLink( 'Help:RandomInCategory' );
91
92        return [
93            'category' => [
94                'type' => 'title',
95                'namespace' => NS_CATEGORY,
96                'relative' => true,
97                'label-message' => 'randomincategory-category',
98                'required' => true,
99            ]
100        ];
101    }
102
103    public function requiresPost() {
104        return false;
105    }
106
107    protected function getDisplayFormat() {
108        return 'ooui';
109    }
110
111    protected function alterForm( HTMLForm $form ) {
112        $form->setSubmitTextMsg( 'randomincategory-submit' );
113    }
114
115    protected function getSubpageField() {
116        return 'category';
117    }
118
119    public function onSubmit( array $data ) {
120        $cat = false;
121
122        $categoryStr = $data['category'];
123
124        if ( $categoryStr ) {
125            $cat = Title::newFromText( $categoryStr, NS_CATEGORY );
126        }
127
128        if ( $cat && $cat->getNamespace() !== NS_CATEGORY ) {
129            // Someone searching for something like "Wikipedia:Foo"
130            $cat = Title::makeTitleSafe( NS_CATEGORY, $categoryStr );
131        }
132
133        if ( $cat ) {
134            $this->setCategory( $cat );
135        }
136
137        if ( !$this->category && $categoryStr ) {
138            $msg = $this->msg( 'randomincategory-invalidcategory',
139                wfEscapeWikiText( $categoryStr ) );
140
141            return Status::newFatal( $msg );
142
143        } elseif ( !$this->category ) {
144            return false; // no data sent
145        }
146
147        $title = $this->getRandomTitle();
148
149        if ( $title === null ) {
150            $msg = $this->msg( 'randomincategory-nopages',
151                $this->category->getText() );
152
153            return Status::newFatal( $msg );
154        }
155
156        $query = $this->getRequest()->getQueryValues();
157        unset( $query['title'] );
158        $this->getOutput()->redirect( $title->getFullURL( $query ) );
159    }
160
161    /**
162     * Choose a random title.
163     * @return Title|null Title object or null if nothing to choose from
164     */
165    public function getRandomTitle() {
166        // Convert to float, since we do math with the random number.
167        $rand = (float)wfRandom();
168
169        // Given that timestamps are rather unevenly distributed, we also
170        // use an offset between 0 and 30 to make any biases less noticeable.
171        $offset = mt_rand( 0, $this->maxOffset );
172
173        if ( mt_rand( 0, 1 ) ) {
174            $up = true;
175        } else {
176            $up = false;
177        }
178
179        $row = $this->selectRandomPageFromDB( $rand, $offset, $up, __METHOD__ );
180
181        // Try again without the timestamp offset (wrap around the end)
182        if ( !$row ) {
183            $row = $this->selectRandomPageFromDB( false, $offset, $up, __METHOD__ );
184        }
185
186        // Maybe the category is really small and offset too high
187        if ( !$row ) {
188            $row = $this->selectRandomPageFromDB( $rand, 0, $up, __METHOD__ );
189        }
190
191        // Just get the first entry.
192        if ( !$row ) {
193            $row = $this->selectRandomPageFromDB( false, 0, true, __METHOD__ );
194        }
195
196        if ( $row ) {
197            return Title::makeTitle( $row->page_namespace, $row->page_title );
198        }
199
200        return null;
201    }
202
203    /**
204     * @note The $up parameter is supposed to counteract what would happen if there
205     *   was a large gap in the distribution of cl_timestamp values. This way instead
206     *   of things to the right of the gap being favoured, both sides of the gap
207     *   are favoured.
208     *
209     * @param float|false $rand Random number between 0 and 1
210     * @param int $offset Extra offset to fudge randomness
211     * @param bool $up True to get the result above the random number, false for below
212     * @return SelectQueryBuilder
213     */
214    protected function getQueryBuilder( $rand, $offset, $up ) {
215        if ( !$this->category instanceof Title ) {
216            throw new BadMethodCallException( 'No category set' );
217        }
218        $dbr = $this->dbProvider->getReplicaDatabase();
219        $queryBuilder = $dbr->newSelectQueryBuilder()
220            ->select( [ 'page_title', 'page_namespace' ] )
221            ->from( 'categorylinks' )
222            ->join( 'page', null, 'cl_from = page_id' )
223            ->where( [ 'cl_to' => $this->category->getDBkey() ] )
224            ->andWhere( $this->extra )
225            ->orderBy( 'cl_timestamp', $up ? SelectQueryBuilder::SORT_ASC : SelectQueryBuilder::SORT_DESC )
226            ->limit( 1 )
227            ->offset( $offset );
228
229        $minClTime = $this->getTimestampOffset( $rand );
230        if ( $minClTime ) {
231            $op = $up ? '>=' : '<=';
232            $queryBuilder->andWhere(
233                $dbr->expr( 'cl_timestamp', $op, $dbr->timestamp( $minClTime ) )
234            );
235        }
236
237        return $queryBuilder;
238    }
239
240    /**
241     * @param float|false $rand Random number between 0 and 1
242     * @return int|false A random (unix) timestamp from the range of the category or false on failure
243     */
244    protected function getTimestampOffset( $rand ) {
245        if ( $rand === false ) {
246            return false;
247        }
248        if ( !$this->minTimestamp || !$this->maxTimestamp ) {
249            $minAndMax = $this->getMinAndMaxForCat();
250            if ( $minAndMax === null ) {
251                // No entries in this category.
252                return false;
253            }
254            [ $this->minTimestamp, $this->maxTimestamp ] = $minAndMax;
255        }
256
257        $ts = ( $this->maxTimestamp - $this->minTimestamp ) * $rand + $this->minTimestamp;
258
259        return intval( $ts );
260    }
261
262    /**
263     * Get the lowest and highest timestamp for a category.
264     *
265     * @return array|null The lowest and highest timestamp, or null if the category has no entries.
266     */
267    protected function getMinAndMaxForCat() {
268        $dbr = $this->dbProvider->getReplicaDatabase();
269        $res = $dbr->newSelectQueryBuilder()
270            ->select( [ 'low' => 'MIN( cl_timestamp )', 'high' => 'MAX( cl_timestamp )' ] )
271            ->from( 'categorylinks' )
272            ->where( [ 'cl_to' => $this->category->getDBkey(), ] )
273            ->caller( __METHOD__ )->fetchRow();
274        if ( !$res ) {
275            return null;
276        }
277
278        return [ (int)wfTimestamp( TS_UNIX, $res->low ), (int)wfTimestamp( TS_UNIX, $res->high ) ];
279    }
280
281    /**
282     * @param float|false $rand A random number that is converted to a random timestamp
283     * @param int $offset A small offset to make the result seem more "random"
284     * @param bool $up Get the result above the random value
285     * @param string $fname The name of the calling method
286     * @return stdClass|false Info for the title selected.
287     */
288    private function selectRandomPageFromDB( $rand, $offset, $up, $fname = __METHOD__ ) {
289        return $this->getQueryBuilder( $rand, $offset, $up )->caller( $fname )->fetchRow();
290    }
291
292    protected function getGroupName() {
293        return 'redirects';
294    }
295}
296
297/**
298 * Retain the old class name for backwards compatibility.
299 * @deprecated since 1.41
300 */
301class_alias( SpecialRandomInCategory::class, 'SpecialRandomInCategory' );