Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.03% covered (success)
97.03%
229 / 236
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ClientChangeHooks
97.03% covered (success)
97.03%
229 / 236
78.57% covered (warning)
78.57%
11 / 14
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeTitleForPossiblyRemoteZObject
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 getDiffAndHistLinks
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 getComment
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 getFlags
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getTimeStampLink
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 onEnhancedChangesListModifyLineData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 onEnhancedChangesListModifyBlockLineData
100.00% covered (success)
100.00%
60 / 60
100.00% covered (success)
100.00%
1 / 1
6
 onOldChangesListRecentChangesLine
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
4
 isJobFromWikifunctions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onChangesListSpecialPageQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 onChangesListSpecialPageStructuredFilters
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
2.00
 onGetPreferences
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getOptionName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * WikiLambda extension Parser-related ('client-mode') hooks
5 *
6 * @file
7 * @ingroup Extensions
8 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
9 * @license MIT
10 */
11
12namespace MediaWiki\Extension\WikiLambda\HookHandler;
13
14use MediaWiki\Config\Config;
15use MediaWiki\Config\ConfigException;
16use MediaWiki\Context\IContextSource;
17use MediaWiki\Extension\WikiLambda\Jobs\WikifunctionsRecentChangesInsertJob;
18use MediaWiki\Extension\WikiLambda\WikiLambdaMode;
19use MediaWiki\Html\FormOptions;
20use MediaWiki\Html\Html;
21use MediaWiki\Linker\Linker;
22use MediaWiki\Linker\LinkRenderer;
23use MediaWiki\Logger\LoggerFactory;
24use MediaWiki\RecentChanges\ChangesList;
25use MediaWiki\RecentChanges\ChangesListBooleanFilter;
26use MediaWiki\RecentChanges\EnhancedChangesList;
27use MediaWiki\RecentChanges\OldChangesList;
28use MediaWiki\RecentChanges\RecentChange;
29use MediaWiki\SpecialPage\ChangesListSpecialPage;
30use MediaWiki\Title\Title;
31use MediaWiki\User\Options\UserOptionsLookup;
32use MediaWiki\User\User;
33use Psr\Log\LoggerInterface;
34use Wikimedia\HtmlArmor\HtmlArmor;
35use Wikimedia\Rdbms\IConnectionProvider;
36
37class ClientChangeHooks implements
38    \MediaWiki\RecentChanges\Hook\EnhancedChangesListModifyLineDataHook,
39    \MediaWiki\RecentChanges\Hook\EnhancedChangesListModifyBlockLineDataHook,
40    \MediaWiki\RecentChanges\Hook\OldChangesListRecentChangesLineHook,
41    \MediaWiki\SpecialPage\Hook\ChangesListSpecialPageQueryHook,
42    \MediaWiki\SpecialPage\Hook\ChangesListSpecialPageStructuredFiltersHook,
43    \MediaWiki\Preferences\Hook\GetPreferencesHook
44{
45    private LoggerInterface $logger;
46
47    public function __construct(
48        private readonly UserOptionsLookup $userOptionsLookup,
49        private readonly IConnectionProvider $dbProvider,
50        private readonly Config $config,
51        private readonly LinkRenderer $linkRenderer,
52        private readonly WikiLambdaMode $mode
53    ) {
54        // Non-injected items
55        $this->logger = LoggerFactory::getInstance( 'WikiLambdaClient' );
56    }
57
58    private function makeTitleForPossiblyRemoteZObject( string $zObject ): Title {
59        // If we're in "repo" mode, we don't want an interwiki Title
60        if ( $this->mode->isRepo() ) {
61            $title = Title::makeTitleSafe( 0, $zObject );
62        } else {
63            $title = Title::makeTitleSafe( 0, $zObject, '', 'wikifunctionswiki' );
64        }
65
66        if ( !$title ) {
67            throw new ConfigException( 'Could not create Title for ' . $zObject );
68        }
69
70        return $title;
71    }
72
73    private function getDiffAndHistLinks( Title $target, IContextSource $context, int $newId, int $oldId ): string {
74        // The 'diff' and 'hist' links; we can't use ChangesList->insertDiffHist as our targets are remote.
75        return Html::rawElement(
76            'span',
77            [ 'class' => 'mw-changeslist-links' ],
78            Html::rawElement(
79                'span',
80                [],
81                $this->linkRenderer->makeKnownLink(
82                    $target,
83                    new HtmlArmor( $context->msg( 'diff' )->escaped() ),
84                    [ 'class' => 'mw-changeslist-diff' ],
85                    [ 'diff' => $newId, 'oldid' => $oldId ]
86                )
87            ) .
88            Html::rawElement(
89                'span',
90                [],
91                $this->linkRenderer->makeKnownLink(
92                    $target,
93                    new HtmlArmor( $context->msg( 'hist' )->escaped() ),
94                    [ 'class' => 'mw-changeslist-history' ],
95                    [ 'action' => 'history' ]
96                )
97            )
98        );
99    }
100
101    private function getComment(
102        IContextSource $context, string $message, ?array $messageParams, string $humanComment
103    ): string {
104        // Use the supplied message, including parameters, to make a message in the current user's language
105        $commentString = Html::rawElement(
106            'span',
107            [ 'class' => 'ext-wikilambda-recentchange-autocomment' ],
108            $context->msg( $message, $messageParams ?? [] )->escaped()
109        );
110
111        // If there was also a human-written message, show that as well
112        if ( $humanComment ) {
113            $commentString .= Html::rawElement(
114                'span',
115                [ 'class' => 'ext-wikilambda-recentchange-editcomment' ],
116                $context->msg( 'colon-separator' )->plain() . $humanComment
117            );
118        }
119
120        return Html::rawElement( 'span', [ 'class' => 'comment' ], $context->msg(
121            'parentheses',
122            [ $commentString ]
123        )->parse() );
124    }
125
126    private function getFlags( RecentChange $recentChange ): array {
127        return [
128            'wikifunctions-edit' => true,
129            'minor' => $recentChange->getAttribute( 'rc_minor' ),
130            'bot' => $recentChange->getAttribute( 'rc_bot' ),
131        ];
132    }
133
134    private function getTimeStampLink(
135        Title $target, string $timestamp, ChangesList $changesList, User $user, int $newId, int $oldId
136    ): string {
137        // Time timestamp of the edit; we can't use ChangesList::revDateLink() as we don't have a local RevisionRecord
138        return $this->linkRenderer->makeKnownLink(
139            $target,
140            $changesList->getLanguage()->userTime( $timestamp, $user ),
141            [ 'class' => 'mw-changeslist-date' ],
142            [
143                'title' => $target->getDBkey(),
144                'diff' => $newId,
145                'oldid' => $oldId,
146            ]
147        );
148    }
149
150    /**
151     * @see https://www.mediawiki.org/wiki/Manual:Hooks/EnhancedChangesListModifyLineData
152     *
153     * @param EnhancedChangesList $changesList
154     * @param array &$data
155     * @param RecentChange[] $block
156     * @param RecentChange $rc
157     * @param string[] &$classes
158     * @param string[] &$attribs
159     */
160    public function onEnhancedChangesListModifyLineData( $changesList, &$data, $block, $rc, &$classes, &$attribs ) {
161        if ( !$this::isJobFromWikifunctions( $rc ) ) {
162            // Not one of ours.
163            $data['recentChangesFlags']['wikifunctions-edit'] = false;
164            return;
165        }
166
167        // We don't do anything special for runs of affected updates, so we can re-use our regular method
168        $this->onEnhancedChangesListModifyBlockLineData( $changesList, $data, $rc );
169    }
170
171    /**
172     * @see https://www.mediawiki.org/wiki/Manual:Hooks/EnhancedChangesListModifyBlockLineData
173     *
174     * @param EnhancedChangesList $changesList
175     * @param array &$data
176     * @param RecentChange $rc
177     */
178    public function onEnhancedChangesListModifyBlockLineData( $changesList, &$data, $rc ) {
179        if ( !$this::isJobFromWikifunctions( $rc ) ) {
180            // Not one of ours.
181            $data['recentChangesFlags']['wikifunctions-edit'] = false;
182            return;
183        }
184
185        if ( !$rc->getPage() ) {
186            $this->logger->warning(
187                __METHOD__ . ' called for a false page somehow on rc_id "{rcId}"',
188                [
189                    'rcId' => $rc->getAttribute( 'rc_id' )
190                ]
191            );
192            return;
193        }
194
195        $context = $changesList->getContext();
196
197        $decodedParams = json_decode( $rc->getAttribute( 'rc_params' ), true );
198        if ( !$decodedParams ) {
199            $this->logger->warning(
200                __METHOD__ . ' called for {page} but couldn\'t decode rc_params: "{params}"',
201                [
202                    'page' => $rc->getPage()->getDBkey(),
203                    'params' => $rc->getAttribute( 'rc_params' ),
204                ]
205            );
206            return;
207        }
208
209        $targetItem = $decodedParams['target'];
210        if ( !$targetItem ) {
211            $this->logger->warning(
212                __METHOD__ . ' called for {page} but couldn\'t decode target: "{params}"',
213                [
214                    'page' => $rc->getPage()->getDBkey(),
215                    'params' => var_export( $decodedParams, true ),
216                ]
217            );
218            return;
219        }
220        $targetItemTitleObject = $this->makeTitleForPossiblyRemoteZObject( $targetItem );
221
222        // Note that we're +='ing the flags, as the array might have flags we don't know about from other extensions
223        $data['recentChangesFlags'] += $this->getFlags( $rc );
224
225        $newId = $decodedParams['newId'];
226        if ( !$newId ) {
227            $this->logger->warning(
228                __METHOD__ . ' called for {page} but newId was not set: "{params}"',
229                [
230                    'page' => $rc->getPage()->getDBkey(),
231                    'params' => var_export( $decodedParams, true ),
232                ]
233            );
234            return;
235        }
236
237        $oldId = $decodedParams['oldId'] ?? 0;
238
239        $data['timestampLink'] = $this->getTimeStampLink(
240            $targetItemTitleObject,
241            $rc->getAttribute( 'rc_timestamp' ),
242            $changesList,
243            $context->getUser(),
244            $newId,
245            $oldId
246        );
247
248        $data['currentAndLastLinks'] = $this->getDiffAndHistLinks( $targetItemTitleObject, $context, $newId, $oldId );
249
250        $data['comment'] = $this->getComment(
251            $context,
252            $decodedParams['message'],
253            $decodedParams['messageParams'] ?? [],
254            $rc->getAttribute( 'rc_comment' )
255        );
256
257        // Value passed by reference, so this completes our work.
258    }
259
260    /**
261     * @see https://www.mediawiki.org/wiki/Manual:Hooks/OldChangesListRecentChangesLine
262     *
263     * @param OldChangesList $changesList
264     * @param string &$s
265     * @param RecentChange $rc
266     * @param string[] &$classes
267     * @param string[] &$attribs
268     * @return bool|void
269     */
270    public function onOldChangesListRecentChangesLine( $changesList, &$s, $rc, &$classes, &$attribs ) {
271        if ( !$this::isJobFromWikifunctions( $rc ) ) {
272            // Not one of ours.
273            return;
274        }
275
276        if ( !$rc->getPage() ) {
277            $this->logger->warning(
278                __METHOD__ . ' called for a false page somehow on rc_id "{rcId}"',
279                [
280                    'rcId' => $rc->getAttribute( 'rc_id' )
281                ]
282            );
283            return;
284        }
285
286        $context = $changesList->getContext();
287
288        $decodedParams = json_decode( $rc->getAttribute( 'rc_params' ), true );
289        if ( !$decodedParams ) {
290            $this->logger->warning(
291                __METHOD__ . ' called for {page} but couldn\'t decode rc_params: "{params}"',
292                [
293                    'page' => $rc->getPage()->getDBkey(),
294                    'params' => $rc->getAttribute( 'rc_params' ),
295                ]
296            );
297            return;
298        }
299
300        $targetItem = $decodedParams['target'];
301        $targetItemTitleObject = $this->makeTitleForPossiblyRemoteZObject( $targetItem );
302
303        $targetFunction = $decodedParams['function'];
304
305        $wordSeparator = $context->msg( 'word-separator' )->plain();
306        $conceptSeparator = ' ' . Html::element( 'span', [ 'class' => 'mw-changeslist-separator' ], '' ) . ' ';
307
308        $spanContents =
309            $this->getDiffAndHistLinks(
310                $targetItemTitleObject, $context, $decodedParams['newId'], $decodedParams['oldId']
311            ) .
312            $conceptSeparator .
313            // The appropriate Flags ('F' for us, 'm' or 'b' for minor/bot)
314            $changesList->recentChangesFlags( $this->getFlags( $rc ) ) .
315            $wordSeparator .
316            // Link to the article affected
317            Html::rawElement(
318                'span',
319                [ 'class' => 'mw-title' ],
320                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable; we've checked $rc->getPage() is non-null
321                $this->linkRenderer->makeKnownLink( $rc->getPage() )
322            ) .
323            $wordSeparator .
324            // Link to the Function being used
325            Html::rawElement(
326                'span',
327                [ 'class' => 'mw-wikilambda-function' ],
328                $this->linkRenderer->makeKnownLink(
329                    $this->makeTitleForPossiblyRemoteZObject( $targetFunction ),
330                    // As a display title, we want "Function: Foo" rather than "Z12345"
331                    $context->msg( 'wikilambda-recentchanges-entry-function' )->plain()
332                        . $context->msg( 'colon-separator' )->plain()
333                        // FIXME: Change this to the name of the Function in this language, not the ZID
334                        . $targetFunction
335                )
336            ) .
337            $wordSeparator .
338            $this->getTimeStampLink(
339                $targetItemTitleObject,
340                $rc->getAttribute( 'rc_timestamp' ),
341                $changesList,
342                $context->getUser(),
343                $decodedParams['newId'], $decodedParams['oldId'] ?? 0
344            ) .
345            $conceptSeparator .
346            // Links to this user
347            Linker::userToolLinks( $rc->getPerformerIdentity()->getId(), $rc->getPerformerIdentity()->getName() ) .
348            $wordSeparator .
349            // Comments
350            $this->getComment(
351                $context,
352                $decodedParams['message'],
353                $decodedParams['messageParams'],
354                $rc->getAttribute( 'rc_comment' )
355            );
356
357        // Value passed by reference, so this completes our work.
358        $s = Html::rawElement( 'span', [ 'class' => 'mw-changeslist-line-inner' ], $spanContents );
359    }
360
361    /**
362     * Static so that it can be used in a callback.
363     *
364     * @param RecentChange $rc
365     * @return bool
366     */
367    public static function isJobFromWikifunctions( RecentChange $rc ): bool {
368        return ( $rc->getAttribute( 'rc_source' ) === WikifunctionsRecentChangesInsertJob::SRC_WIKIFUNCTIONS );
369    }
370
371    /**
372     * @see https://www.mediawiki.org/wiki/Manual:Hooks/OldChangesListRecentChangesLine
373     *
374     * @param string $name
375     * @param array &$tables
376     * @param array &$fields
377     * @param array &$conds
378     * @param array &$query_options
379     * @param array &$join_conds
380     * @param FormOptions $opts
381     */
382    public function onChangesListSpecialPageQuery(
383        $name, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $opts
384    ) {
385        // Drop server-side our changes if the user hasn't opted to see them (as set below,
386        // in onChangesListSpecialPageStructuredFilters).
387        if ( !$this->config->get( 'WikiLambdaClientDefaultShowChanges' ) ) {
388            $dbr = $this->dbProvider->getReplicaDatabase();
389            $conds[] = $dbr->expr( 'rc_source', '!=', WikifunctionsRecentChangesInsertJob::SRC_WIKIFUNCTIONS );
390        }
391    }
392
393    /**
394     * @param ChangesListSpecialPage $specialPage
395     */
396    public function onChangesListSpecialPageStructuredFilters( $specialPage ) {
397        // @phan-suppress-next-line PhanNoopNew
398        new ChangesListBooleanFilter( [
399            'name' => 'hideWikifunctions',
400            'group' => $specialPage->getFilterGroup( 'changeType' ),
401            'priority' => -5,
402            'label' => 'wikilambda-recentchanges-filter-label',
403            'description' => 'wikilambda-recentchanges-filter-description',
404            // Triggers use on the non-JavaScript RecentChanges interface of messages:
405            // - wikilambda-rc-hide-wikifunctions
406            // - wikilambda-rc-hide-wikifunctions-hide
407            // - wikilambda-rc-hide-wikifunctions-show
408            'showHide' => 'wikilambda-rc-hide-wikifunctions',
409            'default' => !(
410                // True (i.e., show these) if the default is to show them …
411                $this->config->get( 'WikiLambdaClientDefaultShowChanges' ) &&
412                // … and the user preference isn't different.
413                (bool)$this->userOptionsLookup->getOption(
414                    $specialPage->getUser(),
415                    $this->getOptionName( $specialPage->getName() )
416                )
417            ),
418            'queryCallable' => static function (
419                // All these upstream parameters, and we only use two of them!
420                $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
421            ) {
422                $conds[] = $dbr->expr( 'rc_source', '!=', WikifunctionsRecentChangesInsertJob::SRC_WIKIFUNCTIONS );
423            },
424            'cssClassSuffix' => 'src-mw-wikifunctions',
425            'isRowApplicableCallable' => static function ( $ctx, $rc ) {
426                return ClientChangeHooks::isJobFromWikifunctions( $rc );
427            },
428        ] );
429    }
430
431    /**
432     * Adds a preference for showing or hiding Wikidata entries in recent changes
433     *
434     * @param User $user
435     * @param array[] &$prefs
436     */
437    public function onGetPreferences( $user, &$prefs ) {
438        if ( !$this->mode->isClient() ) {
439            return;
440        }
441
442        $prefs['rcshowwikifunctions'] = [
443            'type' => 'toggle',
444            'label-message' => 'wikilambda-recentchanges-filter-rc-pref',
445            'section' => 'rc/advancedrc',
446        ];
447
448        $prefs['wlshowwikifunctions'] = [
449            'type' => 'toggle',
450            'label-message' => 'wikilambda-recentchanges-filter-wl-pref',
451            'section' => 'watchlist/advancedwatchlist',
452        ];
453    }
454
455    private function getOptionName( string $pageName ): string {
456        if ( $pageName === 'Watchlist' ) {
457            return 'wlshowwikifunctions';
458        }
459
460        return 'rcshowwikifunctions';
461    }
462
463}