Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 97
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 / 97
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 / 1
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    public function __construct(
58        private readonly IConnectionProvider $dbProvider,
59    ) {
60        parent::__construct( 'RandomInCategory' );
61    }
62
63    /**
64     * Set which category to use.
65     */
66    public function setCategory( Title $cat ) {
67        $this->category = $cat;
68        $this->maxTimestamp = null;
69        $this->minTimestamp = null;
70    }
71
72    /** @inheritDoc */
73    protected function getFormFields() {
74        $this->addHelpLink( 'Help:RandomInCategory' );
75
76        return [
77            'category' => [
78                'type' => 'title',
79                'namespace' => NS_CATEGORY,
80                'relative' => true,
81                'label-message' => 'randomincategory-category',
82                'required' => true,
83            ]
84        ];
85    }
86
87    /** @inheritDoc */
88    public function requiresPost() {
89        return false;
90    }
91
92    /** @inheritDoc */
93    protected function getDisplayFormat() {
94        return 'ooui';
95    }
96
97    protected function alterForm( HTMLForm $form ) {
98        $form->setSubmitTextMsg( 'randomincategory-submit' );
99    }
100
101    /** @inheritDoc */
102    protected function getSubpageField() {
103        return 'category';
104    }
105
106    /** @inheritDoc */
107    public function onSubmit( array $data ) {
108        $cat = false;
109
110        $categoryStr = $data['category'];
111
112        if ( $categoryStr ) {
113            $cat = Title::newFromText( $categoryStr, NS_CATEGORY );
114        }
115
116        if ( $cat && $cat->getNamespace() !== NS_CATEGORY ) {
117            // Someone searching for something like "Wikipedia:Foo"
118            $cat = Title::makeTitleSafe( NS_CATEGORY, $categoryStr );
119        }
120
121        if ( $cat ) {
122            $this->setCategory( $cat );
123        }
124
125        if ( !$this->category && $categoryStr ) {
126            $msg = $this->msg( 'randomincategory-invalidcategory',
127                wfEscapeWikiText( $categoryStr ) );
128
129            return Status::newFatal( $msg );
130
131        } elseif ( !$this->category ) {
132            return false; // no data sent
133        }
134
135        $title = $this->getRandomTitle();
136
137        if ( $title === null ) {
138            $msg = $this->msg( 'randomincategory-nopages',
139                $this->category->getText() );
140
141            return Status::newFatal( $msg );
142        }
143
144        $query = $this->getRequest()->getQueryValues();
145        unset( $query['title'] );
146        $this->getOutput()->redirect( $title->getFullURL( $query ) );
147    }
148
149    /**
150     * Choose a random title.
151     * @return Title|null Title object or null if nothing to choose from
152     */
153    public function getRandomTitle() {
154        // Convert to float, since we do math with the random number.
155        $rand = (float)wfRandom();
156
157        // Given that timestamps are rather unevenly distributed, we also
158        // use an offset between 0 and 30 to make any biases less noticeable.
159        $offset = mt_rand( 0, $this->maxOffset );
160
161        if ( mt_rand( 0, 1 ) ) {
162            $up = true;
163        } else {
164            $up = false;
165        }
166
167        $row = $this->selectRandomPageFromDB( $rand, $offset, $up, __METHOD__ );
168
169        // Try again without the timestamp offset (wrap around the end)
170        if ( !$row ) {
171            $row = $this->selectRandomPageFromDB( false, $offset, $up, __METHOD__ );
172        }
173
174        // Maybe the category is really small and offset too high
175        if ( !$row ) {
176            $row = $this->selectRandomPageFromDB( $rand, 0, $up, __METHOD__ );
177        }
178
179        // Just get the first entry.
180        if ( !$row ) {
181            $row = $this->selectRandomPageFromDB( false, 0, true, __METHOD__ );
182        }
183
184        if ( $row ) {
185            return Title::makeTitle( $row->page_namespace, $row->page_title );
186        }
187
188        return null;
189    }
190
191    /**
192     * @note The $up parameter is supposed to counteract what would happen if there
193     *   was a large gap in the distribution of cl_timestamp values. This way instead
194     *   of things to the right of the gap being favoured, both sides of the gap
195     *   are favoured.
196     *
197     * @param float|false $rand Random number between 0 and 1
198     * @param int $offset Extra offset to fudge randomness
199     * @param bool $up True to get the result above the random number, false for below
200     * @return SelectQueryBuilder
201     */
202    protected function getQueryBuilder( $rand, $offset, $up ) {
203        if ( !$this->category instanceof Title ) {
204            throw new BadMethodCallException( 'No category set' );
205        }
206        $dbr = $this->dbProvider->getReplicaDatabase( CategoryLinksTable::VIRTUAL_DOMAIN );
207        $queryBuilder = $dbr->newSelectQueryBuilder()
208            ->select( [ 'page_title', 'page_namespace' ] )
209            ->from( 'categorylinks' )
210            ->join( 'linktarget', null, 'cl_target_id = lt_id' )
211            ->join( 'page', null, 'cl_from = page_id' )
212            ->where( [ 'lt_title' => $this->category->getDBkey(), 'lt_namespace' => NS_CATEGORY ] )
213            ->andWhere( $this->extra )
214            ->orderBy( 'cl_timestamp', $up ? SelectQueryBuilder::SORT_ASC : SelectQueryBuilder::SORT_DESC )
215            ->limit( 1 )
216            ->offset( $offset );
217
218        $minClTime = $this->getTimestampOffset( $rand );
219        if ( $minClTime ) {
220            $op = $up ? '>=' : '<=';
221            $queryBuilder->andWhere(
222                $dbr->expr( 'cl_timestamp', $op, $dbr->timestamp( $minClTime ) )
223            );
224        }
225
226        return $queryBuilder;
227    }
228
229    /**
230     * @param float|false $rand Random number between 0 and 1
231     * @return int|false A random (unix) timestamp from the range of the category or false on failure
232     */
233    protected function getTimestampOffset( $rand ) {
234        if ( $rand === false ) {
235            return false;
236        }
237        if ( !$this->minTimestamp || !$this->maxTimestamp ) {
238            $minAndMax = $this->getMinAndMaxForCat();
239            if ( $minAndMax === null ) {
240                // No entries in this category.
241                return false;
242            }
243            [ $this->minTimestamp, $this->maxTimestamp ] = $minAndMax;
244        }
245
246        $ts = ( $this->maxTimestamp - $this->minTimestamp ) * $rand + $this->minTimestamp;
247
248        return intval( $ts );
249    }
250
251    /**
252     * Get the lowest and highest timestamp for a category.
253     *
254     * @return array|null The lowest and highest timestamp, or null if the category has no entries.
255     */
256    protected function getMinAndMaxForCat() {
257        $dbr = $this->dbProvider->getReplicaDatabase( CategoryLinksTable::VIRTUAL_DOMAIN );
258        $res = $dbr->newSelectQueryBuilder()
259            ->select( [ 'low' => 'MIN( cl_timestamp )', 'high' => 'MAX( cl_timestamp )' ] )
260            ->from( 'categorylinks' )
261            ->join( 'linktarget', null, 'cl_target_id = lt_id' )
262            ->where( [ 'lt_title' => $this->category->getDBkey(), 'lt_namespace' => NS_CATEGORY ] )
263            ->caller( __METHOD__ )
264            ->fetchRow();
265
266        if ( !$res ) {
267            return null;
268        }
269
270        return [ (int)wfTimestamp( TS::UNIX, $res->low ), (int)wfTimestamp( TS::UNIX, $res->high ) ];
271    }
272
273    /**
274     * @param float|false $rand A random number that is converted to a random timestamp
275     * @param int $offset A small offset to make the result seem more "random"
276     * @param bool $up Get the result above the random value
277     * @param string $fname The name of the calling method
278     * @return stdClass|false Info for the title selected.
279     */
280    private function selectRandomPageFromDB( $rand, $offset, $up, $fname ) {
281        return $this->getQueryBuilder( $rand, $offset, $up )->caller( $fname )->fetchRow();
282    }
283
284    /** @inheritDoc */
285    protected function getGroupName() {
286        return 'redirects';
287    }
288}
289
290// @codeCoverageIgnoreStart
291/**
292 * Retain the old class name for backwards compatibility.
293 * @deprecated since 1.41
294 */
295class_alias( SpecialRandomInCategory::class, 'SpecialRandomInCategory' );
296// @codeCoverageIgnoreEnd