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