Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 187
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialRecentChangesLinked
0.00% covered (danger)
0.00%
0 / 186
0.00% covered (danger)
0.00%
0 / 9
2162
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultOptions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 parseParameters
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doMainQuery
0.00% covered (danger)
0.00%
0 / 133
0.00% covered (danger)
0.00%
0 / 1
992
 setTopText
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getExtraOptions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getTargetTitle
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 prefixSearchSubpages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 outputNoResults
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
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
21namespace MediaWiki\Specials;
22
23use MediaWiki\ChangeTags\ChangeTagsStore;
24use MediaWiki\Html\FormOptions;
25use MediaWiki\Html\Html;
26use MediaWiki\MainConfigNames;
27use MediaWiki\Title\Title;
28use MediaWiki\User\Options\UserOptionsLookup;
29use MediaWiki\User\TempUser\TempUserConfig;
30use MediaWiki\User\UserIdentityUtils;
31use MediaWiki\Watchlist\WatchedItemStoreInterface;
32use MediaWiki\Xml\Xml;
33use MessageCache;
34use RecentChange;
35use SearchEngineFactory;
36use Wikimedia\Rdbms\SelectQueryBuilder;
37use 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 */
45class 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 */
395class_alias( SpecialRecentChangesLinked::class, 'SpecialRecentChangesLinked' );