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