Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
12.05% covered (danger)
12.05%
47 / 390
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangesListHooksHandler
12.05% covered (danger)
12.05%
47 / 390
0.00% covered (danger)
0.00%
0 / 15
4544.33
0.00% covered (danger)
0.00%
0 / 1
 onChangesListSpecialPageStructuredFilters
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
8.30
 handleDamaging
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 1
272
 handleGoodFaith
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
110
 handleRevertrisk
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
20
 shouldStraightJoin
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDamagingStructuredFiltersOnChangesList
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
42
 getGoodFaithStructuredFiltersOnChangesList
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
42
 getRevertriskStructuredFiltersOnChangesList
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 onChangesListSpecialPageQuery
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
6.44
 onEnhancedChangesListModifyLineData
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 onEnhancedChangesListModifyBlockLineData
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 processRecentChangesList
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 onOldChangesListRecentChangesLine
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 getScoreRecentChangesList
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
8.81
 makeApplicableCallback
0.00% covered (danger)
0.00%
0 / 7
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 3 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
14 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 */
16
17namespace ORES\Hooks;
18
19use ChangesList;
20use ChangesListBooleanFilterGroup;
21use ChangesListFilter;
22use ChangesListStringOptionsFilterGroup;
23use EnhancedChangesList;
24use Exception;
25use MediaWiki\Context\IContextSource;
26use MediaWiki\Hook\EnhancedChangesListModifyBlockLineDataHook;
27use MediaWiki\Hook\EnhancedChangesListModifyLineDataHook;
28use MediaWiki\Hook\OldChangesListRecentChangesLineHook;
29use MediaWiki\MediaWikiServices;
30use MediaWiki\SpecialPage\ChangesListSpecialPage;
31use MediaWiki\SpecialPage\Hook\ChangesListSpecialPageQueryHook;
32use MediaWiki\SpecialPage\Hook\ChangesListSpecialPageStructuredFiltersHook;
33use MediaWiki\Specials\SpecialRecentChanges;
34use MediaWiki\Specials\SpecialWatchlist;
35use ORES\Services\ORESServices;
36use ORES\Storage\ThresholdLookup;
37use RecentChange;
38use Wikimedia\Rdbms\IReadableDatabase;
39
40class ChangesListHooksHandler implements
41    ChangesListSpecialPageStructuredFiltersHook,
42    ChangesListSpecialPageQueryHook,
43    EnhancedChangesListModifyBlockLineDataHook,
44    EnhancedChangesListModifyLineDataHook,
45    OldChangesListRecentChangesLineHook
46{
47
48    public function onChangesListSpecialPageStructuredFilters(
49        $clsp
50    ) {
51        if ( !Helpers::oresUiEnabled() ) {
52            return;
53        }
54
55        $thresholdLookup = ORESServices::getThresholdLookup();
56        $changeTypeGroup = $clsp->getFilterGroup( 'changeType' );
57        $logFilter = $changeTypeGroup->getFilter( 'hidelog' );
58        try {
59            if ( Helpers::isModelEnabled( 'revertrisklanguageagnostic' ) ) {
60                self::handleRevertrisk( $clsp, $thresholdLookup, $logFilter );
61            }
62            if ( Helpers::isModelEnabled( 'damaging' ) ) {
63                self::handleDamaging( $clsp, $thresholdLookup, $logFilter );
64            }
65            if ( Helpers::isModelEnabled( 'goodfaith' ) ) {
66                self::handleGoodFaith( $clsp, $thresholdLookup, $logFilter );
67            }
68        } catch ( Exception $exception ) {
69            ORESServices::getLogger()->error(
70                'Error in ChangesListHookHandler: ' . $exception->getMessage()
71            );
72        }
73    }
74
75    /**
76     * @param ChangesListSpecialPage $clsp
77     * @param ThresholdLookup $thresholdLookup
78     * @param ChangesListFilter $logFilter
79     */
80    private static function handleDamaging(
81        ChangesListSpecialPage $clsp,
82        ThresholdLookup $thresholdLookup,
83        ChangesListFilter $logFilter
84
85    ) {
86        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
87        if ( $clsp instanceof SpecialRecentChanges ) {
88            $damagingDefault = $userOptionsLookup->getOption( $clsp->getUser(), 'oresRCHideNonDamaging' );
89            $highlightDefault = $userOptionsLookup->getBoolOption( $clsp->getUser(), 'ores-damaging-flag-rc' );
90        } elseif ( $clsp instanceof SpecialWatchlist ) {
91            $damagingDefault = $userOptionsLookup->getOption( $clsp->getUser(), 'oresWatchlistHideNonDamaging' );
92            $highlightDefault = $userOptionsLookup->getBoolOption( $clsp->getUser(), 'oresHighlight' );
93        } else {
94            $damagingDefault = false;
95            $highlightDefault = false;
96        }
97
98        $filters = self::getDamagingStructuredFiltersOnChangesList(
99            $thresholdLookup->getThresholds( 'damaging' )
100        );
101
102        if ( $filters ) {
103            $newDamagingGroup = new ChangesListStringOptionsFilterGroup( [
104                'name' => 'damaging',
105                'title' => 'ores-rcfilters-damaging-title',
106                'whatsThisHeader' => 'ores-rcfilters-damaging-whats-this-header',
107                'whatsThisBody' => 'ores-rcfilters-damaging-whats-this-body',
108                'whatsThisUrl' => 'https://www.mediawiki.org/wiki/' .
109                    'Special:MyLanguage/Help:New_filters_for_edit_review/Quality_and_Intent_Filters',
110                'whatsThisLinkText' => 'ores-rcfilters-whats-this-link-text',
111                'priority' => 2,
112                'filters' => array_values( $filters ),
113                'default' => ChangesListStringOptionsFilterGroup::NONE,
114                'isFullCoverage' => false,
115                'queryCallable' => function ( $specialClassName, $ctx,
116                    IReadableDatabase $dbr,
117                    &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues
118                ) {
119                    $databaseQueryBuilder = ORESServices::getDatabaseQueryBuilder();
120                    $condition = $databaseQueryBuilder->buildQuery(
121                        'damaging',
122                        $selectedValues
123                    );
124                    if ( $condition ) {
125                        $conds[] = $condition;
126
127                        // Filter out incompatible types; log actions and external rows are not scorable
128                        $conds[] = $dbr->expr( 'rc_type', '!=', [ RC_LOG, RC_EXTERNAL ] );
129                        // Make the joins INNER JOINs instead of LEFT JOINs
130                        $join_conds['ores_damaging_mdl'][0] = 'INNER JOIN';
131                        $join_conds['ores_damaging_cls'][0] = 'INNER JOIN';
132                        if ( self::shouldStraightJoin( $specialClassName ) ) {
133                            $query_options[] = 'STRAIGHT_JOIN';
134                        }
135                    }
136                },
137            ] );
138
139            $newDamagingGroup->conflictsWith(
140                $logFilter,
141                'ores-rcfilters-ores-conflicts-logactions-global',
142                'ores-rcfilters-damaging-conflicts-logactions',
143                'ores-rcfilters-logactions-conflicts-ores'
144            );
145
146            if ( isset( $filters[ 'maybebad' ] ) && isset( $filters[ 'likelybad' ] ) ) {
147                $newDamagingGroup->getFilter( 'maybebad' )->setAsSupersetOf(
148                    $newDamagingGroup->getFilter( 'likelybad' )
149                );
150            }
151
152            if ( isset( $filters[ 'likelybad' ] ) && isset( $filters[ 'verylikelybad' ] ) ) {
153                $newDamagingGroup->getFilter( 'likelybad' )->setAsSupersetOf(
154                    $newDamagingGroup->getFilter( 'verylikelybad' )
155                );
156            }
157
158            // Transitive closure
159            if ( isset( $filters[ 'maybebad' ] ) && isset( $filters[ 'verylikelybad' ] ) ) {
160                $newDamagingGroup->getFilter( 'maybebad' )->setAsSupersetOf(
161                    $newDamagingGroup->getFilter( 'verylikelybad' )
162                );
163            }
164
165            if ( $damagingDefault ) {
166                $newDamagingGroup->setDefault( Helpers::getDamagingLevelPreference( $clsp->getUser(),
167                    $clsp->getPageTitle() ) );
168            }
169
170            if ( $highlightDefault ) {
171                $levelsColors = [
172                    'maybebad' => 'c3',
173                    'likelybad' => 'c4',
174                    'verylikelybad' => 'c5',
175                ];
176
177                $prefLevel = Helpers::getDamagingLevelPreference(
178                    $clsp->getUser(),
179                    $clsp->getPageTitle()
180                );
181                $allLevels = array_keys( $levelsColors );
182                $applicableLevels = array_slice( $allLevels, array_search( $prefLevel, $allLevels ) );
183                $applicableLevels = array_intersect( $applicableLevels, array_keys( $filters ) );
184
185                foreach ( $applicableLevels as $level ) {
186                    $newDamagingGroup
187                        ->getFilter( $level )
188                        ->setDefaultHighlightColor( $levelsColors[$level] );
189                }
190            }
191
192            $clsp->registerFilterGroup( $newDamagingGroup );
193        }
194
195        // I don't think we need to register a conflict here, since
196        // if we're showing non-damaging, that won't conflict with
197        // anything.
198        $legacyDamagingGroup = new ChangesListBooleanFilterGroup( [
199            'name' => 'ores',
200            'filters' => [
201                [
202                    'name' => 'hidenondamaging',
203                    'showHide' => 'ores-damaging-filter',
204                    'isReplacedInStructuredUi' => true,
205                    'default' => $damagingDefault,
206                    'queryCallable' => function ( $specialClassName, IContextSource $ctx,
207                        IReadableDatabase $dbr,
208                        &$tables, &$fields, &$conds, &$query_options, &$join_conds
209                    ) {
210                        Helpers::hideNonDamagingFilter( $fields, $conds, true, $ctx->getUser(),
211                            $ctx->getTitle() );
212                        // Filter out incompatible types; log actions and external rows are not scorable
213                        $conds[] = $dbr->expr( 'rc_type', '!=', [ RC_LOG, RC_EXTERNAL ] );
214                        // Filter out patrolled edits: the 'r' doesn't appear for them
215                        $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
216                        // Make the joins INNER JOINs instead of LEFT JOINs
217                        $join_conds['ores_damaging_mdl'][0] = 'INNER JOIN';
218                        $join_conds['ores_damaging_cls'][0] = 'INNER JOIN';
219                        if ( self::shouldStraightJoin( $specialClassName ) ) {
220                            $query_options[] = 'STRAIGHT_JOIN';
221                        }
222                    },
223                ]
224            ],
225
226        ] );
227
228        $clsp->registerFilterGroup( $legacyDamagingGroup );
229    }
230
231    /**
232     * @param ChangesListSpecialPage $clsp
233     * @param ThresholdLookup $thresholdLookup
234     * @param ChangesListFilter $logFilter
235     */
236    private static function handleGoodFaith(
237        ChangesListSpecialPage $clsp,
238        ThresholdLookup $thresholdLookup,
239        ChangesListFilter $logFilter
240    ) {
241        $filters = self::getGoodFaithStructuredFiltersOnChangesList(
242            $thresholdLookup->getThresholds( 'goodfaith' )
243        );
244
245        if ( !$filters ) {
246            return;
247        }
248        $goodfaithGroup = new ChangesListStringOptionsFilterGroup( [
249            'name' => 'goodfaith',
250            'title' => 'ores-rcfilters-goodfaith-title',
251            'whatsThisHeader' => 'ores-rcfilters-goodfaith-whats-this-header',
252            'whatsThisBody' => 'ores-rcfilters-goodfaith-whats-this-body',
253            'whatsThisUrl' => 'https://www.mediawiki.org/wiki/' .
254                'Special:MyLanguage/Help:New_filters_for_edit_review/Quality_and_Intent_Filters',
255            'whatsThisLinkText' => 'ores-rcfilters-whats-this-link-text',
256            'priority' => 1,
257            'filters' => array_values( $filters ),
258            'default' => ChangesListStringOptionsFilterGroup::NONE,
259            'isFullCoverage' => false,
260            'queryCallable' => function ( $specialClassName, $ctx,
261                IReadableDatabase $dbr,
262                &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues
263            ) {
264                $databaseQueryBuilder = ORESServices::getDatabaseQueryBuilder();
265                $condition = $databaseQueryBuilder->buildQuery(
266                    'goodfaith',
267                    $selectedValues
268                );
269                if ( $condition ) {
270                    $conds[] = $condition;
271
272                    // Filter out incompatible types; log actions and external rows are not scorable
273                    $conds[] = $dbr->expr( 'rc_type', '!=', [ RC_LOG, RC_EXTERNAL ] );
274                    // Make the joins INNER JOINs instead of LEFT JOINs
275                    $join_conds['ores_goodfaith_mdl'][0] = 'INNER JOIN';
276                    $join_conds['ores_goodfaith_cls'][0] = 'INNER JOIN';
277                    if ( self::shouldStraightJoin( $specialClassName ) ) {
278                        $query_options[] = 'STRAIGHT_JOIN';
279                    }
280                }
281            },
282        ] );
283
284        if ( isset( $filters['maybebad'] ) && isset( $filters['likelybad'] ) ) {
285            $goodfaithGroup->getFilter( 'maybebad' )->setAsSupersetOf(
286                $goodfaithGroup->getFilter( 'likelybad' )
287            );
288        }
289
290        if ( isset( $filters['likelybad'] ) && isset( $filters['verylikelybad'] ) ) {
291            $goodfaithGroup->getFilter( 'likelybad' )->setAsSupersetOf(
292                $goodfaithGroup->getFilter( 'verylikelybad' )
293            );
294        }
295
296        if ( isset( $filters['maybebad'] ) && isset( $filters['verylikelybad'] ) ) {
297            $goodfaithGroup->getFilter( 'maybebad' )->setAsSupersetOf(
298                $goodfaithGroup->getFilter( 'verylikelybad' )
299            );
300        }
301
302        $goodfaithGroup->conflictsWith(
303            $logFilter,
304            'ores-rcfilters-ores-conflicts-logactions-global',
305            'ores-rcfilters-goodfaith-conflicts-logactions',
306            'ores-rcfilters-logactions-conflicts-ores'
307        );
308
309        $clsp->registerFilterGroup( $goodfaithGroup );
310    }
311
312    private static function handleRevertrisk(
313        ChangesListSpecialPage $clsp,
314        ThresholdLookup $thresholdLookup,
315        ChangesListFilter $logFilter
316    ) {
317        $filters = self::getRevertriskStructuredFiltersOnChangesList(
318            $thresholdLookup->getThresholds( 'revertrisklanguageagnostic' )
319        );
320
321        if ( !$filters ) {
322            return;
323        }
324        $revertriskGroup = new ChangesListStringOptionsFilterGroup( [
325            'name' => 'revertrisklanguageagnostic',
326            'title' => 'ores-rcfilters-revertrisklanguageagnostic-title',
327            'whatsThisHeader' => 'ores-rcfilters-revertrisklanguageagnostic-whats-this-header',
328            'whatsThisBody' => 'ores-rcfilters-revertrisklanguageagnostic-whats-this-body',
329            'whatsThisUrl' => 'https://www.mediawiki.org/wiki/' .
330                'Special:MyLanguage/Help:New_filters_for_edit_review/Quality_and_Intent_Filters#Revert_risk',
331            'whatsThisLinkText' => 'ores-rcfilters-whats-this-link-text',
332            'priority' => 1,
333            'filters' => array_values( $filters ),
334            'default' => ChangesListStringOptionsFilterGroup::NONE,
335            'isFullCoverage' => false,
336            'queryCallable' => function ( $specialClassName, $ctx,
337                IReadableDatabase $dbr,
338                &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues
339            ) {
340                $databaseQueryBuilder = ORESServices::getDatabaseQueryBuilder();
341                $condition = $databaseQueryBuilder->buildQuery(
342                    'revertrisklanguageagnostic',
343                    $selectedValues
344                );
345                if ( $condition ) {
346                    $conds[] = $condition;
347
348                    // Filter out incompatible types; log actions and external rows are not scorable
349                    $conds[] = $dbr->expr( 'rc_type', '!=', [ RC_LOG, RC_EXTERNAL ] );
350                    // Make the joins INNER JOINs instead of LEFT JOINs
351                    $join_conds['ores_revertrisklanguageagnostic_mdl'][0] = 'INNER JOIN';
352                    $join_conds['ores_revertrisklanguageagnostic_cls'][0] = 'INNER JOIN';
353                    if ( self::shouldStraightJoin( $specialClassName ) ) {
354                        $query_options[] = 'STRAIGHT_JOIN';
355                    }
356                }
357            },
358        ] );
359
360        $revertriskGroup->conflictsWith(
361            $logFilter,
362            'ores-rcfilters-ores-conflicts-logactions-global',
363            'ores-rcfilters-revertrisklanguageagnosticconflicts-logactions',
364            'ores-rcfilters-logactions-conflicts-ores'
365        );
366
367        $clsp->registerFilterGroup( $revertriskGroup );
368    }
369
370    private static function shouldStraightJoin( $specialClassName ) {
371        // Performance hack: add STRAIGHT_JOIN (T146111) but not for Watchlist (T176456 / T164796)
372        // New theory is that STRAIGHT JOIN should be used for unfiltered queries (RecentChanges)
373        // but not for filtered queries (Watchlist and RecentChangesLinked) (T179718)
374        return $specialClassName === 'SpecialRecentChanges';
375    }
376
377    private static function getDamagingStructuredFiltersOnChangesList( array $damagingLevels ): array {
378        $filters = [];
379        if ( isset( $damagingLevels[ 'likelygood' ] ) ) {
380            $filters[ 'likelygood' ] = [
381                'name' => 'likelygood',
382                'label' => 'ores-rcfilters-damaging-likelygood-label',
383                'description' => 'ores-rcfilters-damaging-likelygood-desc',
384                'cssClassSuffix' => 'damaging-likelygood',
385                'isRowApplicableCallable' => self::makeApplicableCallback(
386                    'damaging',
387                    $damagingLevels['likelygood']
388                ),
389            ];
390        }
391        if ( isset( $damagingLevels[ 'maybebad' ] ) ) {
392            $filters[ 'maybebad' ] = [
393                'name' => 'maybebad',
394                'label' => 'ores-rcfilters-damaging-maybebad-label',
395                'description' => 'ores-rcfilters-damaging-maybebad-desc',
396                'cssClassSuffix' => 'damaging-maybebad',
397                'isRowApplicableCallable' => self::makeApplicableCallback(
398                    'damaging',
399                    $damagingLevels['maybebad']
400                ),
401            ];
402        }
403        if ( isset( $damagingLevels[ 'likelybad' ] ) ) {
404            $descMsg = isset( $filters[ 'maybebad' ] ) ?
405                'ores-rcfilters-damaging-likelybad-desc-low' :
406                'ores-rcfilters-damaging-likelybad-desc-high';
407            $filters[ 'likelybad' ] = [
408                'name' => 'likelybad',
409                'label' => 'ores-rcfilters-damaging-likelybad-label',
410                'description' => $descMsg,
411                'cssClassSuffix' => 'damaging-likelybad',
412                'isRowApplicableCallable' => self::makeApplicableCallback(
413                    'damaging',
414                    $damagingLevels['likelybad']
415                ),
416            ];
417        }
418        if ( isset( $damagingLevels[ 'verylikelybad' ] ) ) {
419            $filters[ 'verylikelybad' ] = [
420                'name' => 'verylikelybad',
421                'label' => 'ores-rcfilters-damaging-verylikelybad-label',
422                'description' => 'ores-rcfilters-damaging-verylikelybad-desc',
423                'cssClassSuffix' => 'damaging-verylikelybad',
424                'isRowApplicableCallable' => self::makeApplicableCallback(
425                    'damaging',
426                    $damagingLevels['verylikelybad']
427                ),
428            ];
429        }
430        return $filters;
431    }
432
433    private static function getGoodFaithStructuredFiltersOnChangesList( array $goodfaithLevels ): array {
434        $filters = [];
435        if ( isset( $goodfaithLevels['likelygood'] ) ) {
436            $filters[ 'likelygood' ] = [
437                'name' => 'likelygood',
438                'label' => 'ores-rcfilters-goodfaith-good-label',
439                'description' => 'ores-rcfilters-goodfaith-good-desc',
440                'cssClassSuffix' => 'goodfaith-good',
441                'isRowApplicableCallable' => self::makeApplicableCallback(
442                    'goodfaith',
443                    $goodfaithLevels['likelygood']
444                ),
445            ];
446        }
447        if ( isset( $goodfaithLevels['maybebad'] ) ) {
448            $filters[ 'maybebad' ] = [
449                'name' => 'maybebad',
450                'label' => 'ores-rcfilters-goodfaith-maybebad-label',
451                'description' => 'ores-rcfilters-goodfaith-maybebad-desc',
452                'cssClassSuffix' => 'goodfaith-maybebad',
453                'isRowApplicableCallable' => self::makeApplicableCallback(
454                    'goodfaith',
455                    $goodfaithLevels['maybebad']
456                ),
457            ];
458        }
459        if ( isset( $goodfaithLevels['likelybad'] ) ) {
460            $descMsg = isset( $filters[ 'maybebad' ] ) ?
461                'ores-rcfilters-goodfaith-bad-desc-low' :
462                'ores-rcfilters-goodfaith-bad-desc-high';
463            $filters[ 'likelybad' ] = [
464                'name' => 'likelybad',
465                'label' => 'ores-rcfilters-goodfaith-bad-label',
466                'description' => $descMsg,
467                'cssClassSuffix' => 'goodfaith-bad',
468                'isRowApplicableCallable' => self::makeApplicableCallback(
469                    'goodfaith',
470                    $goodfaithLevels['likelybad']
471                ),
472            ];
473        }
474        if ( isset( $goodfaithLevels['verylikelybad'] ) ) {
475            $filters[ 'verylikelybad' ] = [
476                'name' => 'verylikelybad',
477                'label' => 'ores-rcfilters-goodfaith-verylikelybad-label',
478                'description' => 'ores-rcfilters-goodfaith-verylikelybad-desc',
479                'cssClassSuffix' => 'goodfaith-verylikelybad',
480                'isRowApplicableCallable' => self::makeApplicableCallback(
481                    'goodfaith',
482                    $goodfaithLevels['verylikelybad']
483                ),
484            ];
485        }
486
487        return $filters;
488    }
489
490    private static function getRevertriskStructuredFiltersOnChangesList( array $revertriskLevels ): array {
491        $filters = [];
492        if ( isset( $revertriskLevels['revertrisk'] ) ) {
493            $filters[ 'revertrisk' ] = [
494                'name' => 'revertrisk',
495                'label' => 'ores-rcfilters-revertrisklanguageagnostic-revertrisk-label',
496                'description' => 'ores-rcfilters-revertrisklanguageagnostic-revertrisk-desc',
497                'cssClassSuffix' => 'revertrisklanguageagnostic-revertrisk',
498                'isRowApplicableCallable' => self::makeApplicableCallback(
499                    'revertrisk',
500                    $revertriskLevels['revertrisk']
501                ),
502            ];
503        }
504
505        return $filters;
506    }
507
508    public function onChangesListSpecialPageQuery(
509        $name, &$tables, &$fields,
510        &$conds, &$query_options, &$join_conds, $opts
511    ) {
512        if ( !Helpers::oresUiEnabled() ) {
513            return;
514        }
515        try {
516            if ( Helpers::isModelEnabled( 'damaging' ) ) {
517                Helpers::joinWithOresTables( 'damaging', 'rc_this_oldid', $tables, $fields,
518                    $join_conds );
519            }
520            if ( Helpers::isModelEnabled( 'goodfaith' ) ) {
521                Helpers::joinWithOresTables( 'goodfaith', 'rc_this_oldid', $tables, $fields,
522                    $join_conds );
523            }
524            if ( Helpers::isModelEnabled( 'revertrisklanguageagnostic' ) ) {
525                Helpers::joinWithOresTables( 'revertrisklanguageagnostic', 'rc_this_oldid', $tables, $fields,
526                    $join_conds );
527            }
528        } catch ( Exception $exception ) {
529            return;
530        }
531    }
532
533    /**
534     * Label recent changes with ORES scores (for each change in an expanded group)
535     *
536     * @param EnhancedChangesList $ecl
537     * @param array &$data
538     * @param RecentChange[] $block
539     * @param RecentChange $rcObj
540     * @param string[] &$classes
541     * @param string[] &$attribs
542     */
543    public function onEnhancedChangesListModifyLineData(
544        $ecl,
545        &$data,
546        $block,
547        $rcObj,
548        &$classes,
549        &$attribs
550    ) {
551        if ( !Helpers::oresUiEnabled() ) {
552            return;
553        }
554
555        self::processRecentChangesList( $rcObj, $data, $classes, $ecl->getContext() );
556    }
557
558    /**
559     * Label recent changes with ORES scores (for top-level ungrouped lines)
560     *
561     * @param EnhancedChangesList $ecl
562     * @param array &$data
563     * @param RecentChange $rcObj
564     */
565    public function onEnhancedChangesListModifyBlockLineData(
566        $ecl,
567        &$data,
568        $rcObj
569    ) {
570        if ( !Helpers::oresUiEnabled() ) {
571            return;
572        }
573
574        $classes = [];
575        self::processRecentChangesList( $rcObj, $data, $classes, $ecl->getContext() );
576        $data['attribs']['class'] = array_merge( $data['attribs']['class'], $classes );
577    }
578
579    /**
580     * Internal helper to label matching rows
581     *
582     * @param RecentChange $rcObj
583     * @param string[] &$data
584     * @param string[] &$classes
585     * @param IContextSource $context
586     */
587    protected static function processRecentChangesList(
588        RecentChange $rcObj,
589        array &$data,
590        array &$classes,
591        IContextSource $context
592    ) {
593        $damaging = self::getScoreRecentChangesList( $rcObj, $context );
594
595        if ( $damaging && Helpers::isDamagingFlagEnabled( $context ) ) {
596            $classes[] = 'damaging';
597            $data['recentChangesFlags']['damaging'] = true;
598        }
599    }
600
601    /**
602     * Hook for formatting recent changes links
603     * @see https://www.mediawiki.org/wiki/Manual:Hooks/OldChangesListRecentChangesLine
604     *
605     * @param ChangesList $changesList
606     * @param string &$s
607     * @param RecentChange $rc
608     * @param string[] &$classes
609     * @param string[] &$attribs
610     * @return bool|void
611     */
612    public function onOldChangesListRecentChangesLine(
613        $changesList,
614        &$s,
615        $rc,
616        &$classes,
617        &$attribs
618    ) {
619        if ( !Helpers::oresUiEnabled() ) {
620            return;
621        }
622
623        $damaging = self::getScoreRecentChangesList( $rc, $changesList->getContext() );
624        if ( $damaging ) {
625            // Add highlight class
626            if ( Helpers::isHighlightEnabled( $changesList ) ) {
627                $classes[] = 'ores-highlight';
628            }
629
630            // Add damaging class and flag
631            if ( Helpers::isDamagingFlagEnabled( $changesList ) ) {
632                $classes[] = 'damaging';
633
634                $separator = ' <span class="mw-changeslist-separator"></span> ';
635                $pos = strpos( $s, $separator );
636                if ( $pos !== false ) {
637                    $pos += strlen( $separator );
638                    $s = substr_replace( $s, ChangesList::flag( 'damaging' ), $pos, 0 );
639                }
640            }
641        }
642    }
643
644    /**
645     * Check if we should flag a row. As a side effect, also adds score data for this row.
646     * @param RecentChange $rcObj
647     * @param IContextSource $context
648     * @return bool
649     */
650    public static function getScoreRecentChangesList( RecentChange $rcObj, IContextSource $context ) {
651        $threshold = $rcObj->getAttribute( 'ores_damaging_threshold' );
652        if ( $threshold === null ) {
653            try {
654                $threshold =
655                    Helpers::getThreshold( 'damaging', $context->getUser(), $context->getTitle() );
656            } catch ( Exception $exception ) {
657                return false;
658            }
659
660        }
661        $score = $rcObj->getAttribute( 'ores_damaging_score' );
662        $patrolled = $rcObj->getAttribute( 'rc_patrolled' );
663        $type = $rcObj->getAttribute( 'rc_type' );
664
665        // Log actions and external rows are not scorable; if such a row does have a score, ignore it
666        if ( !$score || $threshold === null || in_array( $type, [ RC_LOG, RC_EXTERNAL ] ) ) {
667            // Shorten out
668            return false;
669        }
670
671        $score = (float)$score;
672        Helpers::addRowData( $context, $rcObj->getAttribute( 'rc_this_oldid' ), $score,
673            'damaging' );
674
675        return $score >= $threshold && !$patrolled;
676    }
677
678    private static function makeApplicableCallback( string $model, array $levelData ) {
679        return static function ( $ctx, RecentChange $rc ) use ( $model, $levelData ) {
680            $score = $rc->getAttribute( "ores_{$model}_score" );
681            $type = $rc->getAttribute( 'rc_type' );
682            // Log actions and external rows are not scorable; if such a row does have a score, ignore it
683            if ( $score === null || in_array( $type, [ RC_LOG, RC_EXTERNAL ] ) ) {
684                return false;
685            }
686            return $levelData['min'] <= $score && $score <= $levelData['max'];
687        };
688    }
689
690}