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