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