Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 96 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
SpecialRandomInCategory | |
0.00% |
0 / 95 |
|
0.00% |
0 / 14 |
1332 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
setCategory | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getFormFields | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
requiresPost | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDisplayFormat | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
alterForm | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getSubpageField | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onSubmit | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
90 | |||
getRandomTitle | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
42 | |||
getQueryBuilder | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
30 | |||
getTimestampOffset | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
getMinAndMaxForCat | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
selectRandomPageFromDB | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
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 | |
21 | namespace MediaWiki\Specials; |
22 | |
23 | use BadMethodCallException; |
24 | use MediaWiki\HTMLForm\HTMLForm; |
25 | use MediaWiki\SpecialPage\FormSpecialPage; |
26 | use MediaWiki\Status\Status; |
27 | use MediaWiki\Title\Title; |
28 | use stdClass; |
29 | use Wikimedia\Rdbms\IConnectionProvider; |
30 | use 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 | */ |
57 | class 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 | */ |
301 | class_alias( SpecialRandomInCategory::class, 'SpecialRandomInCategory' ); |