Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.01% covered (danger)
21.01%
71 / 338
6.90% covered (danger)
6.90%
2 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
21.01% covered (danger)
21.01%
71 / 338
6.90% covered (danger)
6.90%
2 / 29
5435.51
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 onPageMoveComplete
63.16% covered (warning)
63.16%
24 / 38
0.00% covered (danger)
0.00%
0 / 1
9.45
 onRevisionFromEditComplete
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 onPageSaveComplete
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 onLinksUpdateComplete
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 addToPageTriageQueue
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
9.08
 flushUserStatusCache
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 shouldShowNoIndex
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 shouldNoIndexForNewArticleReasons
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 shouldNoIndexForMagicWordReasons
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isNewEnoughToNoIndex
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
30
 onArticleViewFooter
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
210
 maybeShowUnpatrolLink
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 onMarkPatrolledComplete
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 onBlockIpComplete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onUnblockUserComplete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onResourceLoaderGetConfigVars
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 toolbarContentLanguageMessages
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 toolbarConfig
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 onBeforeCreateEchoEvent
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
30
 locateUsersForNotification
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 onLocalUserCreated
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onORESCheckModels
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 onListDefinedTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onChangeTagsAllowedAdd
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onChangeTagsListActive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onApiMain__moduleManager
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 onPageDeleteComplete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 onPageUndeleteComplete
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2
3namespace MediaWiki\Extension\PageTriage;
4
5use ApiDisabled;
6use Article;
7use ExtensionRegistry;
8use IBufferingStatsdDataFactory;
9use ManualLogEntry;
10use MediaWiki\Api\Hook\ApiMain__moduleManagerHook;
11use MediaWiki\Auth\Hook\LocalUserCreatedHook;
12use MediaWiki\ChangeTags\Hook\ChangeTagsAllowedAddHook;
13use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook;
14use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook;
15use MediaWiki\Config\Config;
16use MediaWiki\Deferred\DeferredUpdates;
17use MediaWiki\Extension\Notifications\Model\Event;
18use MediaWiki\Extension\PageTriage\ArticleCompile\ArticleCompileProcessor;
19use MediaWiki\Extension\PageTriage\Notifications\PageTriageAddDeletionTagPresentationModel;
20use MediaWiki\Extension\PageTriage\Notifications\PageTriageAddMaintenanceTagPresentationModel;
21use MediaWiki\Extension\PageTriage\Notifications\PageTriageMarkAsReviewedPresentationModel;
22use MediaWiki\Hook\BlockIpCompleteHook;
23use MediaWiki\Hook\LinksUpdateCompleteHook;
24use MediaWiki\Hook\MarkPatrolledCompleteHook;
25use MediaWiki\Hook\PageMoveCompleteHook;
26use MediaWiki\Hook\UnblockUserCompleteHook;
27use MediaWiki\Html\Html;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Output\OutputPage;
30use MediaWiki\Page\Hook\ArticleViewFooterHook;
31use MediaWiki\Page\Hook\PageDeleteCompleteHook;
32use MediaWiki\Page\Hook\PageUndeleteCompleteHook;
33use MediaWiki\Page\Hook\RevisionFromEditCompleteHook;
34use MediaWiki\Page\PageIdentity;
35use MediaWiki\Page\ProperPageIdentity;
36use MediaWiki\Page\WikiPageFactory;
37use MediaWiki\Parser\ParserOutput;
38use MediaWiki\Permissions\Authority;
39use MediaWiki\Permissions\PermissionManager;
40use MediaWiki\ResourceLoader\Context;
41use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
42use MediaWiki\Revision\RevisionLookup;
43use MediaWiki\Revision\RevisionRecord;
44use MediaWiki\Revision\RevisionStore;
45use MediaWiki\Revision\SlotRecord;
46use MediaWiki\Storage\Hook\PageSaveCompleteHook;
47use MediaWiki\Title\Title;
48use MediaWiki\Title\TitleFactory;
49use MediaWiki\User\Options\UserOptionsManager;
50use MediaWiki\User\User;
51use MediaWiki\User\UserIdentity;
52use MediaWiki\Utils\MWTimestamp;
53use MediaWiki\WikiMap\WikiMap;
54use RecentChange;
55use Wikimedia\Rdbms\Database;
56use WikiPage;
57
58class Hooks implements
59    ApiMain__moduleManagerHook,
60    ListDefinedTagsHook,
61    ChangeTagsListActiveHook,
62    ChangeTagsAllowedAddHook,
63    PageMoveCompleteHook,
64    RevisionFromEditCompleteHook,
65    PageSaveCompleteHook,
66    LinksUpdateCompleteHook,
67    ArticleViewFooterHook,
68    PageDeleteCompleteHook,
69    MarkPatrolledCompleteHook,
70    BlockIpCompleteHook,
71    UnblockUserCompleteHook,
72    ResourceLoaderGetConfigVarsHook,
73    LocalUserCreatedHook,
74    PageUndeleteCompleteHook
75{
76
77    private const TAG_NAME = 'pagetriage';
78
79    /** @var Config */
80    private Config $config;
81    /** @var QueueManager */
82    private QueueManager $queueManager;
83
84    /** @var RevisionLookup */
85    private RevisionLookup $revisionLookup;
86
87    /** @var IBufferingStatsdDataFactory */
88    private IBufferingStatsdDataFactory $statsdDataFactory;
89
90    /** @var PermissionManager */
91    private PermissionManager $permissionManager;
92
93    /** @var RevisionStore */
94    private RevisionStore $revisionStore;
95
96    /** @var TitleFactory */
97    private TitleFactory $titleFactory;
98
99    /** @var UserOptionsManager */
100    private UserOptionsManager $userOptionsManager;
101
102    /** @var WikiPageFactory */
103    private WikiPageFactory $wikiPageFactory;
104
105    /**
106     * @param Config $config
107     * @param RevisionLookup $revisionLookup
108     * @param IBufferingStatsdDataFactory $statsdDataFactory
109     * @param PermissionManager $permissionManager
110     * @param RevisionStore $revisionStore
111     * @param TitleFactory $titleFactory
112     * @param UserOptionsManager $userOptionsManager
113     * @param QueueManager $queueManager
114     * @param WikiPageFactory $wikiPageFactory
115     */
116    public function __construct(
117        Config $config,
118        RevisionLookup $revisionLookup,
119        IBufferingStatsdDataFactory $statsdDataFactory,
120        PermissionManager $permissionManager,
121        RevisionStore $revisionStore,
122        TitleFactory $titleFactory,
123        UserOptionsManager $userOptionsManager,
124        QueueManager $queueManager,
125        WikiPageFactory $wikiPageFactory
126    ) {
127        $this->config = $config;
128        $this->revisionLookup = $revisionLookup;
129        $this->statsdDataFactory = $statsdDataFactory;
130        $this->permissionManager = $permissionManager;
131        $this->revisionStore = $revisionStore;
132        $this->titleFactory = $titleFactory;
133        $this->userOptionsManager = $userOptionsManager;
134        $this->queueManager = $queueManager;
135        $this->wikiPageFactory = $wikiPageFactory;
136    }
137
138    /** @inheritDoc */
139    public function onPageMoveComplete(
140        $oldTitle,
141        $newTitle,
142        $user,
143        $oldid,
144        $newid,
145        $reason,
146        $revisionRecord
147    ) {
148        // Mark a page as unreviewed after moving the page from non-main(article) namespace to
149        // main(article) namespace
150        // Delete cache for record if it's in pagetriage queue
151        $articleMetadata = new ArticleMetadata( [ $oldid ] );
152        $articleMetadata->flushMetadataFromCache();
153
154        $oldTitle = $this->titleFactory->newFromLinkTarget( $oldTitle );
155        $newTitle = $this->titleFactory->newFromLinkTarget( $newTitle );
156
157        // Delete user status cache
158        self::flushUserStatusCache( $oldTitle->toPageIdentity() );
159        self::flushUserStatusCache( $newTitle->toPageIdentity() );
160
161        $oldNamespace = $oldTitle->getNamespace();
162        $newNamespace = $newTitle->getNamespace();
163
164        $draftNsId = $this->config->get( 'PageTriageDraftNamespaceId' );
165
166        // If the page is in a namespace we don't care about, abort
167        if ( !in_array( $newNamespace, [ NS_MAIN, $draftNsId ], true ) ) {
168            return;
169        }
170
171        // Else if the page is moved around in the same namespace we only care about updating
172        // the recreated attribute
173        if ( $oldNamespace === $newNamespace ) {
174            // Check if the page currently exists in the feed
175            $pageTriage = new PageTriage( $oldid );
176            if ( $pageTriage->retrieve() ) {
177                DeferredUpdates::addCallableUpdate( static function () use ( $oldid ) {
178                    $acp = ArticleCompileProcessor::newFromPageId(
179                        [ $oldid ],
180                        false
181                    );
182
183                    if ( $acp ) {
184                        $acp->registerComponent( 'Recreated' );
185                        $acp->compileMetadata();
186                    }
187                } );
188            }
189            return;
190        }
191
192        // else it was moved from one namespace to another, we might need a full recompile
193        $newAdditionToFeed = self::addToPageTriageQueue( $oldid, $newTitle, $user );
194
195        // The page was already in the feed so a recompile is not needed
196        if ( !$newAdditionToFeed ) {
197            return;
198        }
199
200        DeferredUpdates::addCallableUpdate( static function () use ( $oldid, $newAdditionToFeed ) {
201            $acp = ArticleCompileProcessor::newFromPageId(
202                [ $oldid ],
203                false
204            );
205            if ( $acp ) {
206                // Since this is a title move, the only component requiring DB_PRIMARY will be
207                // BasicData.
208                $acp->configComponentDb(
209                    ArticleCompileProcessor::getSafeComponentDbConfigForCompilation()
210                );
211                $acp->compileMetadata();
212            }
213        } );
214    }
215
216    /** @inheritDoc */
217    public function onRevisionFromEditComplete( $wikiPage, $rev, $baseID, $user, &$tags ) {
218        // Check if a page is created from a redirect page, then insert into it PageTriage Queue
219        // Note: Page will be automatically marked as triaged for users with autopatrol right
220        if ( !in_array( $wikiPage->getTitle()->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
221            return;
222        }
223
224        if ( $rev && $rev->getParentId() ) {
225            // Make sure $prev->getContent() is done post-send if possible
226            DeferredUpdates::addCallableUpdate( function () use ( $rev, $wikiPage, $user ) {
227                $prevRevRecord = $this->revisionLookup->getRevisionById( $rev->getParentId() );
228                if ( $prevRevRecord &&
229                    !$wikiPage->isRedirect() &&
230                    $prevRevRecord->getContent( SlotRecord::MAIN )->isRedirect()
231                ) {
232                    // Add item to queue, if it's not already there.
233                    self::addToPageTriageQueue(
234                        $wikiPage->getId(),
235                        $wikiPage->getTitle(),
236                        $user
237                    );
238                }
239            } );
240        }
241    }
242
243    /** @inheritDoc */
244    public function onPageSaveComplete(
245        $wikiPage,
246        $user,
247        $summary,
248        $flags,
249        $revisionRecord,
250        $editResult
251    ) {
252        // When a new article is created, insert it into PageTriage Queue and compile metadata.
253        // Page saved, flush cache
254        self::flushUserStatusCache( $wikiPage );
255
256        if ( !( $flags & EDIT_NEW ) ) {
257            // Don't add to queue if it is not a new page
258            return;
259        }
260
261        // Don't add to queue if not in a namespace of interest.
262        if ( !in_array( $wikiPage->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
263            return;
264        }
265
266        // Add item to queue. Metadata compilation will get triggered in the LinksUpdate hook.
267        self::addToPageTriageQueue(
268            $wikiPage->getId(),
269            $wikiPage->getTitle(),
270            $user
271        );
272    }
273
274    /** @inheritDoc */
275    public function onLinksUpdateComplete( $linksUpdate, $ticket ) {
276        if ( !in_array( $linksUpdate->getTitle()->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
277            return;
278        }
279
280        // Update metadata when link information is updated.
281        // This is also run after every page save.
282        // Note that this hook can be triggered by a GET request (rollback action, until T88044 is
283        // sorted out), in which case master DB connections and writes on GET request can occur.
284        DeferredUpdates::addCallableUpdate( static function () use ( $linksUpdate ) {
285            // Validate the page ID from DB_PRIMARY, compile metadata from DB_PRIMARY and return.
286            $acp = ArticleCompileProcessor::newFromPageId(
287                [ $linksUpdate->getTitle()->getArticleID() ],
288                false,
289                DB_PRIMARY
290            );
291            if ( $acp ) {
292                $acp->registerLinksUpdate( $linksUpdate );
293                $acp->compileMetadata();
294            }
295        } );
296    }
297
298    /**
299     * Add page to page triage queue, check for autopatrol right if reviewed is not set
300     *
301     * This method should only be called from this class and its closures
302     *
303     * @param int $pageId
304     * @param Title $title
305     * @param UserIdentity|null $userIdentity
306     * @return bool
307     * @throws MWPageTriageMissingRevisionException
308     */
309    public static function addToPageTriageQueue( $pageId, $title, $userIdentity = null ): bool {
310        global $wgUseRCPatrol, $wgUseNPPatrol;
311
312        // Get draft information.
313        $draftNsId = MediaWikiServices::getInstance()
314            ->getMainConfig()
315            ->get( 'PageTriageDraftNamespaceId' );
316        $isDraft = $draftNsId !== false && $title->inNamespace( $draftNsId );
317
318        // Draft redirects are not patrolled or reviewed.
319        if ( $isDraft && $title->isRedirect() ) {
320            return false;
321        }
322
323        $pageTriage = new PageTriage( $pageId );
324
325        // action taken by system
326        if ( $userIdentity === null ) {
327            return $pageTriage->addToPageTriageQueue();
328        // action taken by a user
329        } else {
330            // set reviewed if it's not set yet
331            $user = MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $userIdentity );
332            $permissionErrors = MediaWikiServices::getInstance()->getPermissionManager()
333                ->getPermissionErrors( 'autopatrol', $user, $title );
334            $isAutopatrolled = ( $wgUseRCPatrol || $wgUseNPPatrol ) &&
335                !count( $permissionErrors );
336            if ( $isAutopatrolled && !$isDraft ) {
337                // Set as reviewed if the user has the autopatrol right,
338                // and they're not creating a Draft.
339                return $pageTriage->addToPageTriageQueue(
340                    QueueRecord::REVIEW_STATUS_AUTOPATROLLED,
341                    $userIdentity
342                );
343            }
344            // If they have no autopatrol right and are not making an explicit review,
345            // set to unreviewed (as the system would, in this situation).
346            return $pageTriage->addToPageTriageQueue();
347        }
348    }
349
350    /**
351     * Flush user page/user talk page existence status, this function should
352     * be called when a page gets created/deleted/moved/restored
353     *
354     * @param PageIdentity $pageIdentity
355     */
356    private static function flushUserStatusCache( PageIdentity $pageIdentity ): void {
357        if ( in_array( $pageIdentity->getNamespace(), [ NS_USER, NS_USER_TALK ] ) ) {
358            $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
359            $cache->delete( PageTriageUtil::userStatusKey( $pageIdentity->getDBkey() ) );
360        }
361    }
362
363    /**
364     * Determines whether to set noindex for the article specified
365     *
366     * The NOINDEX logic is explained at:
367     * https://www.mediawiki.org/wiki/Extension:PageTriage#NOINDEX
368     *
369     * @param Article $article
370     * @return bool
371     */
372    private static function shouldShowNoIndex( Article $article ) {
373        $page = $article->getPage();
374
375        if ( self::shouldNoIndexForNewArticleReasons( $page ) ) {
376            return true;
377        }
378
379        $wikitextHasNoIndexMagicWord = $article->mParserOutput instanceof ParserOutput
380            && $article->mParserOutput->getPageProperty( 'noindex' ) !== null;
381
382        return $wikitextHasNoIndexMagicWord && self::shouldNoIndexForMagicWordReasons( $page );
383    }
384
385    /**
386     * Calculate whether we should show NOINDEX, based on criteria related to whether
387     * the page is reviewed.
388     *
389     * The NOINDEX logic is explained at:
390     * https://www.mediawiki.org/wiki/Extension:PageTriage#NOINDEX
391     *
392     * Note that we always check the age of the page last since that is potentially the
393     * most expensive check (if the data isn't cached). Performance is important because
394     * this code is run on every page.
395     *
396     * @param WikiPage $page
397     * @return bool
398     */
399    private static function shouldNoIndexForNewArticleReasons( WikiPage $page ) {
400        global $wgPageTriageNoIndexUnreviewedNewArticles, $wgPageTriageMaxAge;
401
402        if ( !$wgPageTriageNoIndexUnreviewedNewArticles ) {
403            return false;
404        } elseif ( !PageTriageUtil::isPageUnreviewed( $page ) ) {
405            return false;
406        } elseif ( !self::isNewEnoughToNoIndex( $page, $wgPageTriageMaxAge ) ) {
407            return false;
408        } else {
409            return true;
410        }
411    }
412
413    /**
414     * Calculate whether we should show NOINDEX, based on criteria related to whether the
415     * page contains a __NOINDEX__ magic word.
416     *
417     * The NOINDEX logic is explained at:
418     * https://www.mediawiki.org/wiki/Extension:PageTriage#NOINDEX
419     *
420     * @param WikiPage $page
421     * @return bool
422     */
423    private static function shouldNoIndexForMagicWordReasons( WikiPage $page ) {
424        global $wgPageTriageMaxNoIndexAge;
425
426        return self::isNewEnoughToNoIndex( $page, $wgPageTriageMaxNoIndexAge );
427    }
428
429    /**
430     * Checks to see if an article is new, i.e. less than the supplied $maxAgeInDays
431     *
432     * Look in cache for the creation date. If not found, query the replica for the value
433     * of ptrp_created.
434     *
435     * @param WikiPage $wikiPage WikiPage to check
436     * @param int|null|false $maxAgeInDays How many days old an article has to be to be
437     * considered "not new".
438     * @return bool
439     */
440    private static function isNewEnoughToNoIndex( WikiPage $wikiPage, $maxAgeInDays ) {
441        $pageId = $wikiPage->getId();
442        if ( !$pageId ) {
443            return false;
444        }
445
446        // Allow disabling the age threshold for noindex by setting maxAge to null, 0, or false
447        if ( !$maxAgeInDays ) {
448            return true;
449        }
450
451        // Check cache for creation date
452        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
453        $pageCreationDateTime = $cache->getWithSetCallback(
454            $cache->makeKey( 'pagetriage-page-created', $pageId ),
455            $cache::TTL_DAY,
456            static function ( $oldValue, &$ttl, array &$setOpts ) use ( $pageId ) {
457                // The ptrp_created field is equivalent to creation_date
458                // property set during article metadata compilation.
459                $dbr = PageTriageUtil::getReplicaConnection();
460                $setOpts += Database::getCacheSetOptions( $dbr );
461                $queueLookup = PageTriageServices::wrap( MediaWikiServices::getInstance() )
462                    ->getQueueLookup();
463                $queueRecord = $queueLookup->getByPageId( $pageId );
464                return $queueRecord instanceof QueueRecord ? $queueRecord->getCreatedTimestamp() : false;
465            },
466            [ 'version' => PageTriage::CACHE_VERSION ]
467        );
468
469        // If still not found, return false.
470        if ( !$pageCreationDateTime ) {
471            return false;
472        }
473
474        // Get the age of the article in days
475        $timestamp = new MWTimestamp( $pageCreationDateTime );
476        $dateInterval = $timestamp->diff( new MWTimestamp() );
477        $articleDaysOld = $dateInterval->format( '%a' );
478
479        // If it's younger than the maximum age, return true.
480        return $articleDaysOld < $maxAgeInDays;
481    }
482
483    /** @inheritDoc */
484    public function onArticleViewFooter( $article, $patrolFooterShown ) {
485        // Handler for hook ArticleViewFooter. This will...
486        //   1) determine whether to turn on noindex for new, unreviewed articles,
487        //   2) determine whether to load a link for autopatrolled users to unpatrol their article,
488        //   3) determine whether to load the Page Curation toolbar, and/or
489        //   4) determine whether to load the "Add to New Pages Feed" link
490
491        $wikiPage = $article->getPage();
492        $title = $wikiPage->getTitle();
493        $context = $article->getContext();
494        $user = $context->getUser();
495        $outputPage = $context->getOutput();
496        $request = $context->getRequest();
497
498        // 1) Determine whether to turn on noindex for new, unreviewed articles.
499        // Overwrite the noindex rule defined in Article::view(), this also affects main namespace
500        if ( self::shouldShowNoIndex( $article ) ) {
501            $outputPage->setRobotPolicy( 'noindex,nofollow' );
502            $this->statsdDataFactory->increment(
503                'extension.PageTriage.by_wiki.' . WikiMap::getCurrentWikiId() . '.noindex'
504            );
505        }
506
507        // onArticleViewFooter() is run every time any article is not viewed from cache, so exit
508        // early if we can, to increase performance.
509        // Only named users can review
510        if ( !$user->isNamed() ) {
511            return;
512        }
513        // Only show in defined namespaces
514        if ( !in_array( $title->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
515            return;
516        }
517        // Don't do anything if it's coming from Special:NewPages
518        if ( $request->getVal( 'patrolpage' ) ) {
519            return;
520        }
521
522        // 2) determine whether to load a link for autopatrolled users to unpatrol their article
523        $userCanPatrol = $this->permissionManager->quickUserCan( 'patrol', $user, $title );
524        $userCanAutoPatrol = $this->permissionManager->userHasRight( $user, 'autopatrol' );
525        $outputPage->addJsConfigVars( [
526            'wgPageTriageUserCanPatrol' => $userCanPatrol,
527            'wgPageTriageUserCanAutoPatrol' => $userCanAutoPatrol
528        ] );
529        if ( !$userCanPatrol ) {
530            $this->maybeShowUnpatrolLink( $wikiPage, $user, $outputPage );
531            return;
532        }
533
534        // 3) determine whether to load the Page Curation toolbar.
535        // 4) determine whether to load the "Add to New Pages Feed" link.
536        // See if the page is in the PageTriage page queue
537        // If it isn't, $needsReview will be null
538        // Also, users without the autopatrol right can't review their own pages
539        $needsReview = PageTriageUtil::isPageUnreviewed( $wikiPage );
540        if ( $needsReview !== null
541            && (
542                !$user->equals( $this->revisionStore->getFirstRevision( $title )->getUser( RevisionRecord::RAW ) )
543                || $userCanAutoPatrol
544            )
545        ) {
546            if ( $this->config->get( 'PageTriageEnableCurationToolbar' ) ||
547                $request->getVal( 'curationtoolbar' ) === 'true' ) {
548                // Load the JavaScript for the curation toolbar
549                $outputPage->addModules( 'ext.pageTriage.toolbarStartup' );
550                $outputPage->addModuleStyles( [ 'mediawiki.interface.helpers.styles' ] );
551            } else {
552                if ( $needsReview ) {
553                    // show 'Mark as reviewed' link
554                    $msg = $context->msg( 'pagetriage-markpatrolled' )->text();
555                    $msg = Html::element(
556                        'a',
557                        [ 'href' => '#', 'class' => 'mw-pagetriage-markpatrolled-link' ],
558                        $msg
559                    );
560                } else {
561                    // show 'Reviewed' text
562                    $msg = $context->msg( 'pagetriage-reviewed' )->escaped();
563                }
564                $outputPage->addModules( [ 'ext.pageTriage.articleLink' ] );
565                $html = Html::rawElement( 'div', [ 'class' => 'mw-pagetriage-markpatrolled' ], $msg );
566                $outputPage->addHTML( $html );
567            }
568        } elseif ( $needsReview === null && !$title->isMainPage() ) {
569            // Page is potentially usable, but not in the queue, allow users to add it manually
570            // Option is not shown if the article is the main page
571            $outputPage->addModules( 'ext.pageTriage.sidebarLink' );
572        }
573    }
574
575    /**
576     * Show a link to autopatrolled users without the 'patrol'
577     * userright that allows them to unreview a specific page iff
578     * the page is autopatrolled && they are the page's creator
579     *
580     * @param WikiPage $wikiPage Wikipage being viewed
581     * @param User $user Current user
582     * @param OutputPage $out Output of current page
583     */
584    private function maybeShowUnpatrolLink( WikiPage $wikiPage, User $user, OutputPage $out ): void {
585        $reviewStatus = PageTriageUtil::getStatus( $wikiPage );
586        $articleIsNotAutoPatrolled = $reviewStatus !== QueueRecord::REVIEW_STATUS_AUTOPATROLLED;
587        if ( $articleIsNotAutoPatrolled ) {
588            return;
589        }
590
591        $isAutopatrolled = $this->permissionManager->userHasRight( $user, 'autopatrol' );
592
593        if ( !$isAutopatrolled ) {
594            return;
595        }
596
597        $pageCreator = $this->revisionStore->getFirstRevision( $wikiPage )->getUser( RevisionRecord::RAW );
598        $isPageCreator = $pageCreator->equals( $user );
599
600        if ( $isPageCreator ) {
601            $out->addModules( 'ext.pageTriage.sidebarLink' );
602        }
603    }
604
605    /** @inheritDoc */
606    public function onMarkPatrolledComplete( $rcid, $user, $wcOnlySysopsCanPatrol, $auto ) {
607        // Sync records from patrol queue to triage queue
608        $rc = RecentChange::newFromId( $rcid );
609        if ( !$rc ) {
610            return;
611        }
612
613        // Run for PageTriage namespaces and for draftspace
614        if ( !in_array( $rc->getPage()->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
615            return;
616        }
617
618        $pt = new PageTriage( $rc->getAttribute( 'rc_cur_id' ) );
619        if ( $pt->addToPageTriageQueue( QueueRecord::REVIEW_STATUS_PATROLLED, $user, true ) ) {
620            // Compile metadata for new page triage record.
621            $acp = ArticleCompileProcessor::newFromPageId( [ $rc->getAttribute( 'rc_cur_id' ) ] );
622            if ( $acp ) {
623                // Page was just inserted into PageTriage queue, so we need to compile BasicData
624                // from DB_PRIMARY, since that component accesses the pagetriage_page table.
625                $acp->configComponentDb(
626                    ArticleCompileProcessor::getSafeComponentDbConfigForCompilation()
627                );
628                $acp->compileMetadata();
629            }
630        }
631
632        // Only notify for PageTriage namespaces, not for draftspace
633        $title = $this->titleFactory->newFromID( $rc->getAttribute( 'rc_cur_id' ) );
634        $isInPageTriageNamespaces = in_array(
635            $title->getNamespace(),
636            $this->config->get( 'PageTriageNamespaces' )
637        );
638        if ( $title && $isInPageTriageNamespaces ) {
639            PageTriageUtil::createNotificationEvent(
640                $title,
641                $user,
642                'pagetriage-mark-as-reviewed'
643            );
644        }
645    }
646
647    /** @inheritDoc */
648    public function onBlockIpComplete( $block, $performer, $priorBlock ) {
649        // Update Article metadata when a user gets blocked.
650        PageTriageUtil::updateMetadataOnBlockChange( $block, (int)$block->isSitewide() );
651    }
652
653    /** @inheritDoc */
654    public function onUnblockUserComplete( $block, $performer ) {
655        // Update Article metadata when a user gets unblocked.
656        PageTriageUtil::updateMetadataOnBlockChange( $block, 0 );
657    }
658
659    /** @inheritDoc */
660    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
661        $pageTriageDraftNamespaceId = $config->get( 'PageTriageDraftNamespaceId' );
662        $vars['pageTriageNamespaces'] = PageTriageUtil::getNamespaces( $config );
663        $vars['wgPageTriageDraftNamespaceId'] = $pageTriageDraftNamespaceId;
664    }
665
666    /**
667     * Generates messages for toolbar
668     *
669     * @param Context $context
670     * @param Config $config
671     * @return array
672     */
673    public static function toolbarContentLanguageMessages( Context $context, Config $config ) {
674        $keys = array_merge(
675            [
676                'pagetriage-mark-mark-talk-page-notify-topic-title',
677                'pagetriage-mark-unmark-talk-page-notify-topic-title',
678                'pagetriage-feedback-from-new-page-review-process-title',
679                'pagetriage-feedback-from-new-page-review-process-message',
680                'pagetriage-note-sent-talk-page-notify-topic-title',
681                'pagetriage-note-sent-talk-page-notify-topic-title-reviewer',
682                'pagetriage-tags-talk-page-notify-topic-title'
683            ],
684            $config->get( 'PageTriageDeletionTagsOptionsContentLanguageMessages' )
685        );
686        $messages = [];
687        foreach ( $keys as $key ) {
688            $messages[$key] = $context->msg( $key )->inContentLanguage()->plain();
689        }
690        return $messages;
691    }
692
693    /**
694     * Generates messages for toolbar
695     *
696     * @param Context $context
697     * @param Config $config
698     * @return array
699     */
700    public static function toolbarConfig( Context $context, Config $config ) {
701        $pageTriageCurationModules = $config->get( 'PageTriageCurationModules' );
702        $pageTriageCurationDependencies = [];
703        if ( ExtensionRegistry::getInstance()->isLoaded( 'WikiLove' ) ) {
704            $pageTriageCurationModules['wikiLove'] = [
705                // depends on WikiLove extension
706                'helplink' => '//en.wikipedia.org/wiki/Wikipedia:Page_Curation/Help#WikiLove',
707                'namespace' => [ NS_MAIN, NS_USER ],
708            ];
709            $pageTriageCurationDependencies[] = 'ext.wikiLove.init';
710        }
711        return [
712            'PageTriageCurationDependencies' => $pageTriageCurationDependencies,
713            'PageTriageCurationModules' => $pageTriageCurationModules,
714            'PageTriageEnableCopyvio' => $config->get( 'PageTriageEnableCopyvio' ),
715            'PageTriageEnableOresFilters' => $config->get( 'PageTriageEnableOresFilters' ),
716            'PageTriageEnableExtendedFeatures' =>
717                $config->get( 'PageTriageEnableExtendedFeatures' ),
718            'TalkPageNoteTemplate' => $config->get( 'TalkPageNoteTemplate' ),
719        ];
720    }
721
722    /**
723     * Add PageTriage events to Echo
724     *
725     * @param array &$notifications array a list of enabled echo events
726     * @param array &$notificationCategories array details for echo events
727     * @param array &$icons array of icon details
728     * @return bool
729     */
730    public static function onBeforeCreateEchoEvent(
731        &$notifications, &$notificationCategories, &$icons
732    ) {
733        global $wgPageTriageEnabledEchoEvents;
734
735        if ( $wgPageTriageEnabledEchoEvents ) {
736            $notificationCategories['page-review'] = [
737                'priority' => 8,
738                'tooltip' => 'echo-pref-tooltip-page-review',
739            ];
740        }
741
742        if ( in_array( 'pagetriage-mark-as-reviewed', $wgPageTriageEnabledEchoEvents ) ) {
743            $notifications['pagetriage-mark-as-reviewed'] = [
744                'presentation-model' => PageTriageMarkAsReviewedPresentationModel::class,
745                'category' => 'page-review',
746                'group' => 'neutral',
747                'section' => 'message',
748                'user-locators' => [ [ self::class . '::locateUsersForNotification' ] ],
749            ];
750        }
751        if ( in_array( 'pagetriage-add-maintenance-tag', $wgPageTriageEnabledEchoEvents ) ) {
752            $notifications['pagetriage-add-maintenance-tag'] = [
753                'presentation-model' => PageTriageAddMaintenanceTagPresentationModel::class,
754                'category' => 'page-review',
755                'group' => 'neutral',
756                'section' => 'alert',
757                'user-locators' => [ [ self::class . '::locateUsersForNotification' ] ],
758            ];
759        }
760        if ( in_array( 'pagetriage-add-deletion-tag', $wgPageTriageEnabledEchoEvents ) ) {
761            $notifications['pagetriage-add-deletion-tag'] = [
762                'presentation-model' => PageTriageAddDeletionTagPresentationModel::class,
763                'category' => 'page-review',
764                'group' => 'negative',
765                'section' => 'alert',
766                'user-locators' => [ [ self::class . '::locateUsersForNotification' ] ],
767            ];
768            $icons['trash'] = [
769                'path' => 'PageTriage/echo-icons/trash.svg'
770            ];
771        }
772
773        return true;
774    }
775
776    /**
777     * For locating users to be notifies of an Echo Event.
778     * @param Event $event
779     * @return array
780     */
781    public static function locateUsersForNotification( Event $event ) {
782        if ( !$event->getTitle() ) {
783            return [];
784        }
785
786        $pageId = $event->getTitle()->getArticleID();
787
788        $articleMetadata = new ArticleMetadata( [ $pageId ], false, DB_REPLICA );
789        $metaData = $articleMetadata->getMetadata();
790
791        if ( !$metaData ) {
792            return [];
793        }
794
795        $users = [];
796        if ( $metaData[$pageId]['user_id'] ) {
797            $users[$metaData[$pageId]['user_id']] = User::newFromId( $metaData[$pageId]['user_id'] );
798        }
799        return $users;
800    }
801
802    /** @inheritDoc */
803    public function onLocalUserCreated( $user, $autocreated ) {
804        // New users get echo preferences set that are not the default settings for existing users.
805        // Specifically, new users are opted into email notifications for page reviews.
806        if ( !$autocreated ) {
807            $this->userOptionsManager->setOption( $user, 'echo-subscriptions-email-page-review', true );
808        }
809    }
810
811    /**
812     * @param RecentChange $rc
813     * @param array &$models Models names to score
814     */
815    public static function onORESCheckModels( RecentChange $rc, &$models ) {
816        if ( !in_array( $rc->getAttribute( 'rc_type' ), [ RC_NEW, RC_EDIT ] ) ) {
817            return;
818        }
819
820        if ( !ArticleMetadata::validatePageIds(
821            [ (int)$rc->getPage()->getDBkey() ], DB_REPLICA
822        ) ) {
823            return;
824        }
825
826        // Ensure all pages in the PageTriage queue
827        // are scored for both models regardless of namespace.
828        foreach ( [ 'articlequality', 'draftquality' ] as $model ) {
829            if ( !in_array( $model, $models ) ) {
830                $models[] = $model;
831            }
832        }
833    }
834
835    /** @inheritDoc */
836    public function onListDefinedTags( &$tags ) {
837        $tags[] = self::TAG_NAME;
838    }
839
840    /** @inheritDoc */
841    public function onChangeTagsAllowedAdd( &$allowedTags, $addTags, $user ) {
842        $allowedTags[] = self::TAG_NAME;
843    }
844
845    /** @inheritDoc */
846    public function onChangeTagsListActive( &$tags ) {
847        $tags[] = self::TAG_NAME;
848    }
849
850    // phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
851
852    /** @inheritDoc */
853    public function onApiMain__moduleManager( $moduleManager ) {
854        // phpcs:enable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
855        if ( !$this->config->get( 'PageTriageEnableExtendedFeatures' ) ) {
856            $moduleManager->addModule(
857                'pagetriagetagging',
858                'action',
859                ApiDisabled::class
860            );
861        }
862    }
863
864    /** @inheritDoc */
865    public function onPageDeleteComplete(
866        ProperPageIdentity $page,
867        Authority $deleter,
868        string $reason,
869        int $pageID,
870        RevisionRecord $deletedRev,
871        ManualLogEntry $logEntry,
872        int $archivedRevisionCount
873    ) {
874        if ( $this->queueManager->isPageTriageNamespace( $page->getNamespace() ) ) {
875            // TODO: Factor the user status cache into another service.
876            self::flushUserStatusCache( $page );
877            $this->queueManager->deleteByPageId( $pageID );
878        }
879    }
880
881    /** @inheritDoc */
882    public function onPageUndeleteComplete(
883        ProperPageIdentity $page,
884        Authority $restorer,
885        string $reason,
886        RevisionRecord $restoredRev,
887        ManualLogEntry $logEntry,
888        int $restoredRevisionCount,
889        bool $created,
890        array $restoredPageIds
891    ): void {
892        if ( !$created ) {
893            // not interested in revdel actions
894            return;
895        }
896
897        if ( !in_array( $page->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
898            // don't queue pages in namespaces where PageTriage is disabled
899            return;
900        }
901
902        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
903        self::addToPageTriageQueue( $wikiPage->getId(), $wikiPage->getTitle() );
904    }
905}