Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 187 |
|
0.00% |
0 / 9 |
CRAP | |
0.00% |
0 / 1 |
SpecialRecentChangesLinked | |
0.00% |
0 / 186 |
|
0.00% |
0 / 9 |
2070 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
getDefaultOptions | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
parseParameters | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
doMainQuery | |
0.00% |
0 / 133 |
|
0.00% |
0 / 1 |
930 | |||
setTopText | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getExtraOptions | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
getTargetTitle | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
prefixSearchSubpages | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
outputNoResults | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | /** |
3 | * Implements Special:Recentchangeslinked |
4 | * |
5 | * This program is free software; you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation; either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License along |
16 | * with this program; if not, write to the Free Software Foundation, Inc., |
17 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
18 | * http://www.gnu.org/copyleft/gpl.html |
19 | * |
20 | * @file |
21 | * @ingroup SpecialPage |
22 | */ |
23 | |
24 | namespace MediaWiki\Specials; |
25 | |
26 | use MediaWiki\ChangeTags\ChangeTagsStore; |
27 | use MediaWiki\Html\FormOptions; |
28 | use MediaWiki\Html\Html; |
29 | use MediaWiki\MainConfigNames; |
30 | use MediaWiki\Title\Title; |
31 | use MediaWiki\User\Options\UserOptionsLookup; |
32 | use MediaWiki\User\TempUser\TempUserConfig; |
33 | use MediaWiki\User\UserIdentityUtils; |
34 | use MessageCache; |
35 | use RecentChange; |
36 | use SearchEngineFactory; |
37 | use WatchedItemStoreInterface; |
38 | use Wikimedia\Rdbms\SelectQueryBuilder; |
39 | use Wikimedia\Rdbms\Subquery; |
40 | use Xml; |
41 | |
42 | /** |
43 | * This is to display changes made to all articles linked in an article. |
44 | * |
45 | * @ingroup SpecialPage |
46 | */ |
47 | class SpecialRecentChangesLinked extends SpecialRecentChanges { |
48 | /** @var bool|Title */ |
49 | protected $rclTargetTitle; |
50 | |
51 | private SearchEngineFactory $searchEngineFactory; |
52 | private ChangeTagsStore $changeTagsStore; |
53 | |
54 | /** |
55 | * @param WatchedItemStoreInterface $watchedItemStore |
56 | * @param MessageCache $messageCache |
57 | * @param UserOptionsLookup $userOptionsLookup |
58 | * @param SearchEngineFactory $searchEngineFactory |
59 | */ |
60 | public function __construct( |
61 | WatchedItemStoreInterface $watchedItemStore, |
62 | MessageCache $messageCache, |
63 | UserOptionsLookup $userOptionsLookup, |
64 | SearchEngineFactory $searchEngineFactory, |
65 | ChangeTagsStore $changeTagsStore, |
66 | UserIdentityUtils $userIdentityUtils, |
67 | TempUserConfig $tempUserConfig |
68 | ) { |
69 | parent::__construct( |
70 | $watchedItemStore, |
71 | $messageCache, |
72 | $userOptionsLookup, |
73 | $changeTagsStore, |
74 | $userIdentityUtils, |
75 | $tempUserConfig |
76 | ); |
77 | $this->mName = 'Recentchangeslinked'; |
78 | $this->searchEngineFactory = $searchEngineFactory; |
79 | $this->changeTagsStore = $changeTagsStore; |
80 | } |
81 | |
82 | public function getDefaultOptions() { |
83 | $opts = parent::getDefaultOptions(); |
84 | $opts->add( 'target', '' ); |
85 | $opts->add( 'showlinkedto', false ); |
86 | |
87 | return $opts; |
88 | } |
89 | |
90 | public function parseParameters( $par, FormOptions $opts ) { |
91 | $opts['target'] = $par; |
92 | } |
93 | |
94 | /** |
95 | * FIXME: Port useful changes from SpecialRecentChanges |
96 | * |
97 | * @inheritDoc |
98 | */ |
99 | protected function doMainQuery( $tables, $select, $conds, $query_options, |
100 | $join_conds, FormOptions $opts |
101 | ) { |
102 | $target = $opts['target']; |
103 | $showlinkedto = $opts['showlinkedto']; |
104 | $limit = $opts['limit']; |
105 | |
106 | if ( $target === '' ) { |
107 | return false; |
108 | } |
109 | $outputPage = $this->getOutput(); |
110 | $title = Title::newFromText( $target ); |
111 | if ( !$title || $title->isExternal() ) { |
112 | $outputPage->addHTML( |
113 | Html::errorBox( $this->msg( 'allpagesbadtitle' )->parse(), '', 'mw-recentchangeslinked-errorbox' ) |
114 | ); |
115 | return false; |
116 | } |
117 | |
118 | $outputPage->setPageTitleMsg( |
119 | $this->msg( 'recentchangeslinked-title' )->plaintextParams( $title->getPrefixedText() ) |
120 | ); |
121 | |
122 | /* |
123 | * Ordinary links are in the pagelinks table, while transclusions are |
124 | * in the templatelinks table, categorizations in categorylinks and |
125 | * image use in imagelinks. We need to somehow combine all these. |
126 | * Special:Whatlinkshere does this by firing multiple queries and |
127 | * merging the results, but the code we inherit from our parent class |
128 | * expects only one result set so we use UNION instead. |
129 | */ |
130 | $dbr = $this->getDB(); |
131 | $id = $title->getArticleID(); |
132 | $ns = $title->getNamespace(); |
133 | $dbkey = $title->getDBkey(); |
134 | |
135 | $rcQuery = RecentChange::getQueryInfo(); |
136 | $tables = array_unique( array_merge( $rcQuery['tables'], $tables ) ); |
137 | $select = array_unique( array_merge( $rcQuery['fields'], $select ) ); |
138 | $join_conds = array_merge( $rcQuery['joins'], $join_conds ); |
139 | |
140 | // Join with watchlist and watchlist_expiry tables to highlight watched rows. |
141 | $this->addWatchlistJoins( $dbr, $tables, $select, $join_conds, $conds ); |
142 | |
143 | // JOIN on page, used for 'last revision' filter highlight |
144 | $tables[] = 'page'; |
145 | $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ]; |
146 | $select[] = 'page_latest'; |
147 | |
148 | $tagFilter = $opts['tagfilter'] !== '' ? explode( '|', $opts['tagfilter'] ) : []; |
149 | $this->changeTagsStore->modifyDisplayQuery( |
150 | $tables, |
151 | $select, |
152 | $conds, |
153 | $join_conds, |
154 | $query_options, |
155 | $tagFilter, |
156 | $opts['inverttags'] |
157 | ); |
158 | |
159 | if ( $dbr->unionSupportsOrderAndLimit() ) { |
160 | if ( in_array( 'DISTINCT', $query_options ) ) { |
161 | // ChangeTagsStore::modifyDisplayQuery() will have added DISTINCT. |
162 | // To prevent this from causing query performance problems, we need to add |
163 | // a GROUP BY, and add rc_id to the ORDER BY. |
164 | $order = [ |
165 | 'GROUP BY' => [ 'rc_timestamp', 'rc_id' ], |
166 | 'ORDER BY' => [ 'rc_timestamp DESC', 'rc_id DESC' ] |
167 | ]; |
168 | } else { |
169 | $order = [ 'ORDER BY' => 'rc_timestamp DESC' ]; |
170 | } |
171 | } else { |
172 | $order = []; |
173 | } |
174 | |
175 | if ( !$this->runMainQueryHook( $tables, $select, $conds, $query_options, $join_conds, |
176 | $opts ) |
177 | ) { |
178 | return false; |
179 | } |
180 | |
181 | if ( $ns === NS_CATEGORY && !$showlinkedto ) { |
182 | // special handling for categories |
183 | // XXX: should try to make this less kludgy |
184 | $link_tables = [ 'categorylinks' ]; |
185 | $showlinkedto = true; |
186 | } else { |
187 | // for now, always join on these tables; really should be configurable as in whatlinkshere |
188 | $link_tables = [ 'pagelinks', 'templatelinks' ]; |
189 | // imagelinks only contains links to pages in NS_FILE |
190 | if ( $ns === NS_FILE || !$showlinkedto ) { |
191 | $link_tables[] = 'imagelinks'; |
192 | } |
193 | } |
194 | |
195 | if ( $id == 0 && !$showlinkedto ) { |
196 | return false; // nonexistent pages can't link to any pages |
197 | } |
198 | |
199 | // field name prefixes for all the various tables we might want to join with |
200 | $prefix = [ |
201 | 'pagelinks' => 'pl', |
202 | 'templatelinks' => 'tl', |
203 | 'categorylinks' => 'cl', |
204 | 'imagelinks' => 'il' |
205 | ]; |
206 | |
207 | $subsql = []; // SELECT statements to combine with UNION |
208 | |
209 | foreach ( $link_tables as $link_table ) { |
210 | $queryBuilder = $dbr->newSelectQueryBuilder(); |
211 | $linksMigration = \MediaWiki\MediaWikiServices::getInstance()->getLinksMigration(); |
212 | $queryBuilder = $queryBuilder |
213 | ->tables( $tables ) |
214 | ->fields( $select ) |
215 | ->where( $conds ) |
216 | ->caller( __METHOD__ ) |
217 | ->options( $order + $query_options ) |
218 | ->joinConds( $join_conds ); |
219 | $pfx = $prefix[$link_table]; |
220 | |
221 | // imagelinks and categorylinks tables have no xx_namespace field, |
222 | // and have xx_to instead of xx_title |
223 | if ( $link_table == 'imagelinks' ) { |
224 | $link_ns = NS_FILE; |
225 | } elseif ( $link_table == 'categorylinks' ) { |
226 | $link_ns = NS_CATEGORY; |
227 | } else { |
228 | $link_ns = 0; |
229 | } |
230 | |
231 | if ( $showlinkedto ) { |
232 | // find changes to pages linking to this page |
233 | if ( $link_ns ) { |
234 | if ( $ns != $link_ns ) { |
235 | continue; |
236 | } // should never happen, but check anyway |
237 | $queryBuilder->where( [ "{$pfx}_to" => $dbkey ] ); |
238 | } else { |
239 | if ( isset( $linksMigration::$mapping[$link_table] ) ) { |
240 | $queryBuilder->where( $linksMigration->getLinksConditions( $link_table, $title ) ); |
241 | } else { |
242 | $queryBuilder->where( [ "{$pfx}_namespace" => $ns, "{$pfx}_title" => $dbkey ] ); |
243 | } |
244 | } |
245 | $queryBuilder->join( $link_table, null, "rc_cur_id = {$pfx}_from" ); |
246 | } else { |
247 | // find changes to pages linked from this page |
248 | $queryBuilder->where( [ "{$pfx}_from" => $id ] ); |
249 | if ( $link_table == 'imagelinks' || $link_table == 'categorylinks' ) { |
250 | $queryBuilder->where( [ "rc_namespace" => $link_ns ] ); |
251 | $queryBuilder->join( $link_table, null, "rc_title = {$pfx}_to" ); |
252 | } else { |
253 | // TODO: Move this to LinksMigration |
254 | if ( isset( $linksMigration::$mapping[$link_table] ) ) { |
255 | $queryInfo = $linksMigration->getQueryInfo( $link_table, $link_table ); |
256 | [ $nsField, $titleField ] = $linksMigration->getTitleFields( $link_table ); |
257 | if ( in_array( 'linktarget', $queryInfo['tables'] ) ) { |
258 | $joinTable = 'linktarget'; |
259 | } else { |
260 | $joinTable = $link_table; |
261 | } |
262 | $queryBuilder->join( |
263 | $joinTable, |
264 | null, |
265 | [ "rc_namespace = {$nsField}", "rc_title = {$titleField}" ] |
266 | ); |
267 | if ( in_array( 'linktarget', $queryInfo['tables'] ) ) { |
268 | $queryBuilder->joinConds( $queryInfo['joins'] ); |
269 | $queryBuilder->table( $link_table ); |
270 | } |
271 | } else { |
272 | $queryBuilder->join( |
273 | $link_table, |
274 | null, |
275 | [ "rc_namespace = {$pfx}_namespace", "rc_title = {$pfx}_title" ] |
276 | ); |
277 | } |
278 | } |
279 | } |
280 | |
281 | if ( $dbr->unionSupportsOrderAndLimit() ) { |
282 | $queryBuilder->limit( $limit ); |
283 | } |
284 | |
285 | $subsql[] = $queryBuilder; |
286 | } |
287 | |
288 | if ( count( $subsql ) == 0 ) { |
289 | return false; // should never happen |
290 | } |
291 | if ( count( $subsql ) == 1 && $dbr->unionSupportsOrderAndLimit() ) { |
292 | return $subsql[0] |
293 | ->setMaxExecutionTime( $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries ) ) |
294 | ->caller( __METHOD__ )->fetchResultSet(); |
295 | } else { |
296 | $sqls = array_map( static function ( $queryBuilder ) { |
297 | return $queryBuilder->getSQL(); |
298 | }, $subsql ); |
299 | return $dbr->newSelectQueryBuilder() |
300 | ->select( '*' ) |
301 | ->from( |
302 | new Subquery( $dbr->unionQueries( $sqls, $dbr::UNION_DISTINCT ) ), |
303 | 'main' |
304 | ) |
305 | ->orderBy( 'rc_timestamp', SelectQueryBuilder::SORT_DESC ) |
306 | ->setMaxExecutionTime( $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries ) ) |
307 | ->limit( $limit ) |
308 | ->caller( __METHOD__ )->fetchResultSet(); |
309 | } |
310 | } |
311 | |
312 | public function setTopText( FormOptions $opts ) { |
313 | $target = $this->getTargetTitle(); |
314 | if ( $target ) { |
315 | $this->getOutput()->addBacklinkSubtitle( $target ); |
316 | $this->getSkin()->setRelevantTitle( $target ); |
317 | } |
318 | } |
319 | |
320 | /** |
321 | * Get options to be displayed in a form |
322 | * |
323 | * @param FormOptions $opts |
324 | * @return array |
325 | */ |
326 | public function getExtraOptions( $opts ) { |
327 | $extraOpts = parent::getExtraOptions( $opts ); |
328 | |
329 | $opts->consumeValues( [ 'showlinkedto', 'target' ] ); |
330 | |
331 | $extraOpts['target'] = [ $this->msg( 'recentchangeslinked-page' )->escaped(), |
332 | Xml::input( 'target', 40, str_replace( '_', ' ', $opts['target'] ) ) . ' ' . |
333 | Xml::check( 'showlinkedto', $opts['showlinkedto'], [ 'id' => 'showlinkedto' ] ) . ' ' . |
334 | Xml::label( $this->msg( 'recentchangeslinked-to' )->text(), 'showlinkedto' ) ]; |
335 | |
336 | $this->addHelpLink( 'Help:Related changes' ); |
337 | return $extraOpts; |
338 | } |
339 | |
340 | /** |
341 | * @return Title |
342 | */ |
343 | private function getTargetTitle() { |
344 | if ( $this->rclTargetTitle === null ) { |
345 | $opts = $this->getOptions(); |
346 | if ( isset( $opts['target'] ) && $opts['target'] !== '' ) { |
347 | $this->rclTargetTitle = Title::newFromText( $opts['target'] ); |
348 | } else { |
349 | $this->rclTargetTitle = false; |
350 | } |
351 | } |
352 | |
353 | return $this->rclTargetTitle; |
354 | } |
355 | |
356 | /** |
357 | * Return an array of subpages beginning with $search that this special page will accept. |
358 | * |
359 | * @param string $search Prefix to search for |
360 | * @param int $limit Maximum number of results to return (usually 10) |
361 | * @param int $offset Number of results to skip (usually 0) |
362 | * @return string[] Matching subpages |
363 | */ |
364 | public function prefixSearchSubpages( $search, $limit, $offset ) { |
365 | return $this->prefixSearchString( $search, $limit, $offset, $this->searchEngineFactory ); |
366 | } |
367 | |
368 | protected function outputNoResults() { |
369 | $targetTitle = $this->getTargetTitle(); |
370 | if ( $targetTitle === false ) { |
371 | $this->getOutput()->addHTML( |
372 | Html::rawElement( |
373 | 'div', |
374 | [ 'class' => 'mw-changeslist-empty mw-changeslist-notargetpage' ], |
375 | $this->msg( 'recentchanges-notargetpage' )->parse() |
376 | ) |
377 | ); |
378 | } elseif ( !$targetTitle || $targetTitle->isExternal() ) { |
379 | $this->getOutput()->addHTML( |
380 | Html::rawElement( |
381 | 'div', |
382 | [ 'class' => 'mw-changeslist-empty mw-changeslist-invalidtargetpage' ], |
383 | $this->msg( 'allpagesbadtitle' )->parse() |
384 | ) |
385 | ); |
386 | } else { |
387 | parent::outputNoResults(); |
388 | } |
389 | } |
390 | } |
391 | |
392 | /** |
393 | * Retain the old class name for backwards compatibility. |
394 | * @deprecated since 1.41 |
395 | */ |
396 | class_alias( SpecialRecentChangesLinked::class, 'SpecialRecentChangesLinked' ); |