Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.61% covered (danger)
21.61%
75 / 347
6.90% covered (danger)
6.90%
2 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
21.61% covered (danger)
21.61%
75 / 347
6.90% covered (danger)
6.90%
2 / 29
5517.65
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
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 / 6
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 / 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 / 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 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                    return;
230                }
231
232                $wasRedirectBecameArticle = !$wikiPage->isRedirect() &&
233                    $prevRevRecord->getContent( SlotRecord::MAIN )->isRedirect();
234                $wasArticleBecameRedirect = $wikiPage->isRedirect() &&
235                    !$prevRevRecord->getContent( SlotRecord::MAIN )->isRedirect();
236                if ( $wasRedirectBecameArticle || $wasArticleBecameRedirect ) {
237                    // Add item to queue, if it's not already there.
238                    self::addToPageTriageQueue(
239                        $wikiPage->getId(),
240                        $wikiPage->getTitle(),
241                        $user
242                    );
243                }
244            } );
245        }
246    }
247
248    /** @inheritDoc */
249    public function onPageSaveComplete(
250        $wikiPage,
251        $user,
252        $summary,
253        $flags,
254        $revisionRecord,
255        $editResult
256    ) {
257        // When a new article is created, insert it into PageTriage Queue and compile metadata.
258        // Page saved, flush cache
259        self::flushUserStatusCache( $wikiPage );
260
261        if ( !( $flags & EDIT_NEW ) ) {
262            // Don't add to queue if it is not a new page
263            return;
264        }
265
266        // Don't add to queue if not in a namespace of interest.
267        if ( !in_array( $wikiPage->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
268            return;
269        }
270
271        // Add item to queue. Metadata compilation will get triggered in the LinksUpdate hook.
272        self::addToPageTriageQueue(
273            $wikiPage->getId(),
274            $wikiPage->getTitle(),
275            $user
276        );
277    }
278
279    /** @inheritDoc */
280    public function onLinksUpdateComplete( $linksUpdate, $ticket ) {
281        if ( !in_array( $linksUpdate->getTitle()->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
282            return;
283        }
284
285        // Update metadata when link information is updated.
286        // This is also run after every page save.
287        // Note that this hook can be triggered by a GET request (rollback action, until T88044 is
288        // sorted out), in which case master DB connections and writes on GET request can occur.
289        DeferredUpdates::addCallableUpdate( static function () use ( $linksUpdate ) {
290            // Validate the page ID from DB_PRIMARY, compile metadata from DB_PRIMARY and return.
291            $acp = ArticleCompileProcessor::newFromPageId(
292                [ $linksUpdate->getTitle()->getArticleID() ],
293                false,
294                DB_PRIMARY
295            );
296            if ( $acp ) {
297                $acp->registerLinksUpdate( $linksUpdate );
298                $acp->compileMetadata();
299            }
300        } );
301    }
302
303    /**
304     * Add page to page triage queue, check for autopatrol right if reviewed is not set
305     *
306     * This method should only be called from this class and its closures
307     *
308     * @param int $pageId
309     * @param Title $title
310     * @param UserIdentity|null $userIdentity
311     * @return bool
312     * @throws MWPageTriageMissingRevisionException
313     */
314    public static function addToPageTriageQueue( $pageId, $title, $userIdentity = null ): bool {
315        $config = MediaWikiServices::getInstance()->getMainConfig();
316
317        // Get draft information.
318        $draftNsId = $config->get( 'PageTriageDraftNamespaceId' );
319        $isDraft = $draftNsId !== false && $title->inNamespace( $draftNsId );
320
321        // Draft redirects are not patrolled or reviewed.
322        if ( $isDraft && $title->isRedirect() ) {
323            return false;
324        }
325
326        $pageTriage = new PageTriage( $pageId );
327
328        // action taken by system
329        if ( $userIdentity === null ) {
330            return $pageTriage->addToPageTriageQueue();
331        // action taken by a user
332        } else {
333            // set reviewed if it's not set yet
334            $user = MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $userIdentity );
335            $permissionErrors = MediaWikiServices::getInstance()->getPermissionManager()
336                ->getPermissionErrors( 'autopatrol', $user, $title );
337            $useRCPatrol = $config->get( 'UseRCPatrol' );
338            $useNPPatrol = $config->get( 'UseNPPatrol' );
339            $isAutopatrolled = ( $useRCPatrol || $useNPPatrol ) &&
340                !count( $permissionErrors );
341            if ( $isAutopatrolled && !$isDraft ) {
342                // Set as reviewed if the user has the autopatrol right,
343                // and they're not creating a Draft.
344                return $pageTriage->addToPageTriageQueue(
345                    QueueRecord::REVIEW_STATUS_AUTOPATROLLED,
346                    $userIdentity
347                );
348            }
349            // If they have no autopatrol right and are not making an explicit review,
350            // set to unreviewed (as the system would, in this situation).
351            return $pageTriage->addToPageTriageQueue();
352        }
353    }
354
355    /**
356     * Flush user page/user talk page existence status, this function should
357     * be called when a page gets created/deleted/moved/restored
358     *
359     * @param PageIdentity $pageIdentity
360     */
361    private static function flushUserStatusCache( PageIdentity $pageIdentity ): void {
362        if ( in_array( $pageIdentity->getNamespace(), [ NS_USER, NS_USER_TALK ] ) ) {
363            $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
364            $cache->delete( PageTriageUtil::userStatusKey( $pageIdentity->getDBkey() ) );
365        }
366    }
367
368    /**
369     * Determines whether to set noindex for the article specified
370     *
371     * The NOINDEX logic is explained at:
372     * https://www.mediawiki.org/wiki/Extension:PageTriage#NOINDEX
373     *
374     * @param Article $article
375     * @return bool
376     */
377    private static function shouldShowNoIndex( Article $article ) {
378        $page = $article->getPage();
379
380        if ( self::shouldNoIndexForNewArticleReasons( $page ) ) {
381            return true;
382        }
383
384        $wikitextHasNoIndexMagicWord = $article->mParserOutput instanceof ParserOutput
385            && $article->mParserOutput->getPageProperty( 'noindex' ) !== null;
386
387        return $wikitextHasNoIndexMagicWord && self::shouldNoIndexForMagicWordReasons( $page );
388    }
389
390    /**
391     * Calculate whether we should show NOINDEX, based on criteria related to whether
392     * the page is reviewed.
393     *
394     * The NOINDEX logic is explained at:
395     * https://www.mediawiki.org/wiki/Extension:PageTriage#NOINDEX
396     *
397     * Note that we always check the age of the page last since that is potentially the
398     * most expensive check (if the data isn't cached). Performance is important because
399     * this code is run on every page.
400     *
401     * @param WikiPage $page
402     * @return bool
403     */
404    private static function shouldNoIndexForNewArticleReasons( WikiPage $page ) {
405        $config = MediaWikiServices::getInstance()->getMainConfig();
406
407        if ( !$config->get( 'PageTriageNoIndexUnreviewedNewArticles' ) ) {
408            return false;
409        } elseif ( !PageTriageUtil::isPageUnreviewed( $page ) ) {
410            return false;
411        } elseif ( !self::isNewEnoughToNoIndex( $page, $config->get( 'PageTriageMaxAge' ) ) ) {
412            return false;
413        } else {
414            return true;
415        }
416    }
417
418    /**
419     * Calculate whether we should show NOINDEX, based on criteria related to whether the
420     * page contains a __NOINDEX__ magic word.
421     *
422     * The NOINDEX logic is explained at:
423     * https://www.mediawiki.org/wiki/Extension:PageTriage#NOINDEX
424     *
425     * @param WikiPage $page
426     * @return bool
427     */
428    private static function shouldNoIndexForMagicWordReasons( WikiPage $page ) {
429        $config = MediaWikiServices::getInstance()->getMainConfig();
430
431        return self::isNewEnoughToNoIndex( $page, $config->get( 'PageTriageMaxNoIndexAge' ) );
432    }
433
434    /**
435     * Checks to see if an article is new, i.e. less than the supplied $maxAgeInDays
436     *
437     * Look in cache for the creation date. If not found, query the replica for the value
438     * of ptrp_created.
439     *
440     * @param WikiPage $wikiPage WikiPage to check
441     * @param int|null|false $maxAgeInDays How many days old an article has to be to be
442     * considered "not new".
443     * @return bool
444     */
445    private static function isNewEnoughToNoIndex( WikiPage $wikiPage, $maxAgeInDays ) {
446        $pageId = $wikiPage->getId();
447        if ( !$pageId ) {
448            return false;
449        }
450
451        // Allow disabling the age threshold for noindex by setting maxAge to null, 0, or false
452        if ( !$maxAgeInDays ) {
453            return true;
454        }
455
456        // Check cache for creation date
457        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
458        $pageCreationDateTime = $cache->getWithSetCallback(
459            $cache->makeKey( 'pagetriage-page-created', $pageId ),
460            $cache::TTL_DAY,
461            static function ( $oldValue, &$ttl, array &$setOpts ) use ( $pageId ) {
462                // The ptrp_created field is equivalent to creation_date
463                // property set during article metadata compilation.
464                $dbr = PageTriageUtil::getReplicaConnection();
465                $setOpts += Database::getCacheSetOptions( $dbr );
466                $queueLookup = PageTriageServices::wrap( MediaWikiServices::getInstance() )
467                    ->getQueueLookup();
468                $queueRecord = $queueLookup->getByPageId( $pageId );
469                return $queueRecord instanceof QueueRecord ? $queueRecord->getCreatedTimestamp() : false;
470            },
471            [ 'version' => PageTriage::CACHE_VERSION ]
472        );
473
474        // If still not found, return false.
475        if ( !$pageCreationDateTime ) {
476            return false;
477        }
478
479        // Get the age of the article in days
480        $timestamp = new MWTimestamp( $pageCreationDateTime );
481        $dateInterval = $timestamp->diff( new MWTimestamp() );
482        $articleDaysOld = $dateInterval->format( '%a' );
483
484        // If it's younger than the maximum age, return true.
485        return $articleDaysOld < $maxAgeInDays;
486    }
487
488    /** @inheritDoc */
489    public function onArticleViewFooter( $article, $patrolFooterShown ) {
490        // Handler for hook ArticleViewFooter. This will...
491        //   1) determine whether to turn on noindex for new, unreviewed articles,
492        //   2) determine whether to load a link for autopatrolled users to unpatrol their article,
493        //   3) determine whether to load the Page Curation toolbar, and/or
494        //   4) determine whether to load the "Add to New Pages Feed" link
495
496        $wikiPage = $article->getPage();
497        $title = $wikiPage->getTitle();
498        $context = $article->getContext();
499        $user = $context->getUser();
500        $outputPage = $context->getOutput();
501        $request = $context->getRequest();
502
503        // 1) Determine whether to turn on noindex for new, unreviewed articles.
504        // Overwrite the noindex rule defined in Article::view(), this also affects main namespace
505        if ( self::shouldShowNoIndex( $article ) ) {
506            $outputPage->setRobotPolicy( 'noindex,nofollow' );
507            $this->statsdDataFactory->increment(
508                'extension.PageTriage.by_wiki.' . WikiMap::getCurrentWikiId() . '.noindex'
509            );
510        }
511
512        // onArticleViewFooter() is run every time any article is not viewed from cache, so exit
513        // early if we can, to increase performance.
514        // Only named users can review
515        if ( !$user->isNamed() ) {
516            return;
517        }
518        // Only show in defined namespaces
519        if ( !in_array( $title->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
520            return;
521        }
522        // Don't do anything if it's coming from Special:NewPages
523        if ( $request->getVal( 'patrolpage' ) ) {
524            return;
525        }
526
527        // 2) determine whether to load a link for autopatrolled users to unpatrol their article
528        $userCanPatrol = $this->permissionManager->quickUserCan( 'patrol', $user, $title );
529        $userCanAutoPatrol = $this->permissionManager->userHasRight( $user, 'autopatrol' );
530        $outputPage->addJsConfigVars( [
531            'wgPageTriageUserCanPatrol' => $userCanPatrol,
532            'wgPageTriageUserCanAutoPatrol' => $userCanAutoPatrol
533        ] );
534        if ( !$userCanPatrol ) {
535            $this->maybeShowUnpatrolLink( $wikiPage, $user, $outputPage );
536            return;
537        }
538
539        // 3) determine whether to load the Page Curation toolbar.
540        // 4) determine whether to load the "Add to New Pages Feed" link.
541        // See if the page is in the PageTriage page queue
542        // If it isn't, $needsReview will be null
543        // Also, users without the autopatrol right can't review their own pages
544        $needsReview = PageTriageUtil::isPageUnreviewed( $wikiPage );
545        if ( $needsReview !== null
546            && (
547                !$user->equals( $this->revisionStore->getFirstRevision( $title )->getUser( RevisionRecord::RAW ) )
548                || $userCanAutoPatrol
549            )
550        ) {
551            if ( $this->config->get( 'PageTriageEnableCurationToolbar' ) ||
552                $request->getVal( 'curationtoolbar' ) === 'true' ) {
553                // Load the JavaScript for the curation toolbar
554                $outputPage->addModules( 'ext.pageTriage.toolbarStartup' );
555                $outputPage->addModuleStyles( [ 'mediawiki.interface.helpers.styles' ] );
556            } else {
557                if ( $needsReview ) {
558                    // show 'Mark as reviewed' link
559                    $msg = $context->msg( 'pagetriage-markpatrolled' )->text();
560                    $msg = Html::element(
561                        'a',
562                        [ 'href' => '#', 'class' => 'mw-pagetriage-markpatrolled-link' ],
563                        $msg
564                    );
565                } else {
566                    // show 'Reviewed' text
567                    $msg = $context->msg( 'pagetriage-reviewed' )->escaped();
568                }
569                $outputPage->addModules( [ 'ext.pageTriage.articleLink' ] );
570                $html = Html::rawElement( 'div', [ 'class' => 'mw-pagetriage-markpatrolled' ], $msg );
571                $outputPage->addHTML( $html );
572            }
573        } elseif ( $needsReview === null && !$title->isMainPage() ) {
574            // Page is potentially usable, but not in the queue, allow users to add it manually
575            // Option is not shown if the article is the main page
576            $outputPage->addModules( 'ext.pageTriage.sidebarLink' );
577        }
578    }
579
580    /**
581     * Show a link to autopatrolled users without the 'patrol'
582     * userright that allows them to unreview a specific page iff
583     * the page is autopatrolled && they are the page's creator
584     *
585     * @param WikiPage $wikiPage Wikipage being viewed
586     * @param User $user Current user
587     * @param OutputPage $out Output of current page
588     */
589    private function maybeShowUnpatrolLink( WikiPage $wikiPage, User $user, OutputPage $out ): void {
590        $reviewStatus = PageTriageUtil::getStatus( $wikiPage );
591        $articleIsNotAutoPatrolled = $reviewStatus !== QueueRecord::REVIEW_STATUS_AUTOPATROLLED;
592        if ( $articleIsNotAutoPatrolled ) {
593            return;
594        }
595
596        $isAutopatrolled = $this->permissionManager->userHasRight( $user, 'autopatrol' );
597
598        if ( !$isAutopatrolled ) {
599            return;
600        }
601
602        $pageCreator = $this->revisionStore->getFirstRevision( $wikiPage )->getUser( RevisionRecord::RAW );
603        $isPageCreator = $pageCreator->equals( $user );
604
605        if ( $isPageCreator ) {
606            $out->addModules( 'ext.pageTriage.sidebarLink' );
607        }
608    }
609
610    /** @inheritDoc */
611    public function onMarkPatrolledComplete( $rcid, $user, $wcOnlySysopsCanPatrol, $auto ) {
612        // Sync records from patrol queue to triage queue
613        $rc = RecentChange::newFromId( $rcid );
614        if ( !$rc ) {
615            return;
616        }
617
618        // Run for PageTriage namespaces and for draftspace
619        if ( !in_array( $rc->getPage()->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
620            return;
621        }
622
623        $pt = new PageTriage( $rc->getAttribute( 'rc_cur_id' ) );
624        if ( $pt->addToPageTriageQueue( QueueRecord::REVIEW_STATUS_PATROLLED, $user, true ) ) {
625            // Compile metadata for new page triage record.
626            $acp = ArticleCompileProcessor::newFromPageId( [ $rc->getAttribute( 'rc_cur_id' ) ] );
627            if ( $acp ) {
628                // Page was just inserted into PageTriage queue, so we need to compile BasicData
629                // from DB_PRIMARY, since that component accesses the pagetriage_page table.
630                $acp->configComponentDb(
631                    ArticleCompileProcessor::getSafeComponentDbConfigForCompilation()
632                );
633                $acp->compileMetadata();
634            }
635        }
636
637        // Only notify for PageTriage namespaces, not for draftspace
638        $title = $this->titleFactory->newFromID( $rc->getAttribute( 'rc_cur_id' ) );
639        $isInPageTriageNamespaces = in_array(
640            $title->getNamespace(),
641            $this->config->get( 'PageTriageNamespaces' )
642        );
643        if ( $title && $isInPageTriageNamespaces ) {
644            PageTriageUtil::createNotificationEvent(
645                $title,
646                $user,
647                'pagetriage-mark-as-reviewed'
648            );
649        }
650    }
651
652    /** @inheritDoc */
653    public function onBlockIpComplete( $block, $performer, $priorBlock ) {
654        // Update Article metadata when a user gets blocked.
655        PageTriageUtil::updateMetadataOnBlockChange( $block, (int)$block->isSitewide() );
656    }
657
658    /** @inheritDoc */
659    public function onUnblockUserComplete( $block, $performer ) {
660        // Update Article metadata when a user gets unblocked.
661        PageTriageUtil::updateMetadataOnBlockChange( $block, 0 );
662    }
663
664    /** @inheritDoc */
665    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
666        $pageTriageDraftNamespaceId = $config->get( 'PageTriageDraftNamespaceId' );
667        $vars['pageTriageNamespaces'] = PageTriageUtil::getNamespaces( $config );
668        $vars['wgPageTriageDraftNamespaceId'] = $pageTriageDraftNamespaceId;
669    }
670
671    /**
672     * Generates messages for toolbar
673     *
674     * @param Context $context
675     * @param Config $config
676     * @return array
677     */
678    public static function toolbarContentLanguageMessages( Context $context, Config $config ) {
679        $keys = array_merge(
680            [
681                'pagetriage-mark-mark-talk-page-notify-topic-title',
682                'pagetriage-mark-unmark-talk-page-notify-topic-title',
683                'pagetriage-feedback-from-new-page-review-process-title',
684                'pagetriage-feedback-from-new-page-review-process-message',
685                'pagetriage-note-sent-talk-page-notify-topic-title',
686                'pagetriage-note-sent-talk-page-notify-topic-title-reviewer',
687                'pagetriage-tags-talk-page-notify-topic-title'
688            ],
689            $config->get( 'PageTriageDeletionTagsOptionsContentLanguageMessages' )
690        );
691        $messages = [];
692        foreach ( $keys as $key ) {
693            $messages[$key] = $context->msg( $key )->inContentLanguage()->plain();
694        }
695        return $messages;
696    }
697
698    /**
699     * Generates messages for toolbar
700     *
701     * @param Context $context
702     * @param Config $config
703     * @return array
704     */
705    public static function toolbarConfig( Context $context, Config $config ) {
706        $pageTriageCurationModules = $config->get( 'PageTriageCurationModules' );
707        $pageTriageCurationDependencies = [];
708        if ( ExtensionRegistry::getInstance()->isLoaded( 'WikiLove' ) ) {
709            $pageTriageCurationModules['wikiLove'] = [
710                // depends on WikiLove extension
711                'helplink' => '//en.wikipedia.org/wiki/Wikipedia:Page_Curation/Help#WikiLove',
712                'namespace' => [ NS_MAIN, NS_USER ],
713            ];
714            $pageTriageCurationDependencies[] = 'ext.wikiLove.init';
715        }
716        return [
717            'PageTriageCurationDependencies' => $pageTriageCurationDependencies,
718            'PageTriageCurationModules' => $pageTriageCurationModules,
719            'PageTriageEnableCopyvio' => $config->get( 'PageTriageEnableCopyvio' ),
720            'PageTriageEnableOresFilters' => $config->get( 'PageTriageEnableOresFilters' ),
721            'PageTriageEnableExtendedFeatures' =>
722                $config->get( 'PageTriageEnableExtendedFeatures' ),
723            'TalkPageNoteTemplate' => $config->get( 'TalkPageNoteTemplate' ),
724        ];
725    }
726
727    /**
728     * Add PageTriage events to Echo
729     *
730     * @param array &$notifications array a list of enabled echo events
731     * @param array &$notificationCategories array details for echo events
732     * @param array &$icons array of icon details
733     * @return bool
734     */
735    public static function onBeforeCreateEchoEvent(
736        &$notifications, &$notificationCategories, &$icons
737    ) {
738        $config = MediaWikiServices::getInstance()->getMainConfig();
739        $enabledEchoEvents = $config->get( 'PageTriageEnabledEchoEvents' );
740
741        if ( $enabledEchoEvents ) {
742            $notificationCategories['page-review'] = [
743                'priority' => 8,
744                'tooltip' => 'echo-pref-tooltip-page-review',
745            ];
746        }
747
748        if ( in_array( 'pagetriage-mark-as-reviewed', $enabledEchoEvents ) ) {
749            $notifications['pagetriage-mark-as-reviewed'] = [
750                'presentation-model' => PageTriageMarkAsReviewedPresentationModel::class,
751                'category' => 'page-review',
752                'group' => 'neutral',
753                'section' => 'message',
754                'user-locators' => [ [ self::class . '::locateUsersForNotification' ] ],
755            ];
756        }
757        if ( in_array( 'pagetriage-add-maintenance-tag', $enabledEchoEvents ) ) {
758            $notifications['pagetriage-add-maintenance-tag'] = [
759                'presentation-model' => PageTriageAddMaintenanceTagPresentationModel::class,
760                'category' => 'page-review',
761                'group' => 'neutral',
762                'section' => 'alert',
763                'user-locators' => [ [ self::class . '::locateUsersForNotification' ] ],
764            ];
765        }
766        if ( in_array( 'pagetriage-add-deletion-tag', $enabledEchoEvents ) ) {
767            $notifications['pagetriage-add-deletion-tag'] = [
768                'presentation-model' => PageTriageAddDeletionTagPresentationModel::class,
769                'category' => 'page-review',
770                'group' => 'negative',
771                'section' => 'alert',
772                'user-locators' => [ [ self::class . '::locateUsersForNotification' ] ],
773            ];
774            $icons['trash'] = [
775                'path' => 'PageTriage/echo-icons/trash.svg'
776            ];
777        }
778
779        return true;
780    }
781
782    /**
783     * For locating users to be notifies of an Echo Event.
784     * @param Event $event
785     * @return array
786     */
787    public static function locateUsersForNotification( Event $event ) {
788        if ( !$event->getTitle() ) {
789            return [];
790        }
791
792        $pageId = $event->getTitle()->getArticleID();
793
794        $articleMetadata = new ArticleMetadata( [ $pageId ], false, DB_REPLICA );
795        $metaData = $articleMetadata->getMetadata();
796
797        if ( !$metaData ) {
798            return [];
799        }
800
801        $users = [];
802        if ( $metaData[$pageId]['user_id'] ) {
803            $users[$metaData[$pageId]['user_id']] = User::newFromId( $metaData[$pageId]['user_id'] );
804        }
805        return $users;
806    }
807
808    /** @inheritDoc */
809    public function onLocalUserCreated( $user, $autocreated ) {
810        // New users get echo preferences set that are not the default settings for existing users.
811        // Specifically, new users are opted into email notifications for page reviews.
812        if ( !$autocreated ) {
813            $this->userOptionsManager->setOption( $user, 'echo-subscriptions-email-page-review', true );
814        }
815    }
816
817    /**
818     * @param RecentChange $rc
819     * @param array &$models Models names to score
820     */
821    public static function onORESCheckModels( RecentChange $rc, &$models ) {
822        if ( !in_array( $rc->getAttribute( 'rc_type' ), [ RC_NEW, RC_EDIT ] ) ) {
823            return;
824        }
825
826        if ( !ArticleMetadata::validatePageIds(
827            [ (int)$rc->getPage()->getDBkey() ], DB_REPLICA
828        ) ) {
829            return;
830        }
831
832        // Ensure all pages in the PageTriage queue
833        // are scored for both models regardless of namespace.
834        foreach ( [ 'articlequality', 'draftquality' ] as $model ) {
835            if ( !in_array( $model, $models ) ) {
836                $models[] = $model;
837            }
838        }
839    }
840
841    /** @inheritDoc */
842    public function onListDefinedTags( &$tags ) {
843        $tags[] = self::TAG_NAME;
844    }
845
846    /** @inheritDoc */
847    public function onChangeTagsAllowedAdd( &$allowedTags, $addTags, $user ) {
848        $allowedTags[] = self::TAG_NAME;
849    }
850
851    /** @inheritDoc */
852    public function onChangeTagsListActive( &$tags ) {
853        $tags[] = self::TAG_NAME;
854    }
855
856    // phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
857
858    /** @inheritDoc */
859    public function onApiMain__moduleManager( $moduleManager ) {
860        // phpcs:enable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
861        if ( !$this->config->get( 'PageTriageEnableExtendedFeatures' ) ) {
862            $moduleManager->addModule(
863                'pagetriagetagging',
864                'action',
865                ApiDisabled::class
866            );
867        }
868    }
869
870    /** @inheritDoc */
871    public function onPageDeleteComplete(
872        ProperPageIdentity $page,
873        Authority $deleter,
874        string $reason,
875        int $pageID,
876        RevisionRecord $deletedRev,
877        ManualLogEntry $logEntry,
878        int $archivedRevisionCount
879    ) {
880        if ( $this->queueManager->isPageTriageNamespace( $page->getNamespace() ) ) {
881            // TODO: Factor the user status cache into another service.
882            self::flushUserStatusCache( $page );
883            $this->queueManager->deleteByPageId( $pageID );
884        }
885    }
886
887    /** @inheritDoc */
888    public function onPageUndeleteComplete(
889        ProperPageIdentity $page,
890        Authority $restorer,
891        string $reason,
892        RevisionRecord $restoredRev,
893        ManualLogEntry $logEntry,
894        int $restoredRevisionCount,
895        bool $created,
896        array $restoredPageIds
897    ): void {
898        if ( !$created ) {
899            // not interested in revdel actions
900            return;
901        }
902
903        if ( !in_array( $page->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
904            // don't queue pages in namespaces where PageTriage is disabled
905            return;
906        }
907
908        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
909        self::addToPageTriageQueue( $wikiPage->getId(), $wikiPage->getTitle() );
910    }
911}