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
5621.24
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 / 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 / 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
 onMarkPatrolledComplete
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
72
 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\Extension\Notifications\Model\Event;
14use MediaWiki\Extension\PageTriage\ArticleCompile\ArticleCompileProcessor;
15use MediaWiki\Extension\PageTriage\Notifications\PageTriageAddDeletionTagPresentationModel;
16use MediaWiki\Extension\PageTriage\Notifications\PageTriageAddMaintenanceTagPresentationModel;
17use MediaWiki\Extension\PageTriage\Notifications\PageTriageMarkAsReviewedPresentationModel;
18use MediaWiki\Hook\BlockIpCompleteHook;
19use MediaWiki\Hook\LinksUpdateCompleteHook;
20use MediaWiki\Hook\MarkPatrolledCompleteHook;
21use MediaWiki\Hook\PageMoveCompleteHook;
22use MediaWiki\Hook\UnblockUserCompleteHook;
23use MediaWiki\Html\Html;
24use MediaWiki\Logging\ManualLogEntry;
25use MediaWiki\MediaWikiServices;
26use MediaWiki\Output\OutputPage;
27use MediaWiki\Page\Article;
28use MediaWiki\Page\Hook\ArticleViewFooterHook;
29use MediaWiki\Page\Hook\PageDeleteCompleteHook;
30use MediaWiki\Page\Hook\PageUndeleteCompleteHook;
31use MediaWiki\Page\Hook\RevisionFromEditCompleteHook;
32use MediaWiki\Page\PageIdentity;
33use MediaWiki\Page\ProperPageIdentity;
34use MediaWiki\Page\WikiPage;
35use MediaWiki\Page\WikiPageFactory;
36use MediaWiki\Parser\ParserOutput;
37use MediaWiki\Permissions\Authority;
38use MediaWiki\Permissions\PermissionManager;
39use MediaWiki\RecentChanges\RecentChange;
40use MediaWiki\Registration\ExtensionRegistry;
41use MediaWiki\ResourceLoader\Context;
42use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
43use MediaWiki\Revision\RevisionLookup;
44use MediaWiki\Revision\RevisionRecord;
45use MediaWiki\Revision\RevisionStore;
46use MediaWiki\Revision\SlotRecord;
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    MarkPatrolledCompleteHook,
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        $wikitextHasNoIndexMagicWord = $article->mParserOutput instanceof ParserOutput
342            && $article->mParserOutput->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 ( $oldValue, &$ttl, array &$setOpts ) use ( $pageId ) {
419                // The ptrp_created field is equivalent to creation_date
420                // property set during article metadata compilation.
421                $dbr = PageTriageUtil::getReplicaConnection();
422                $setOpts += Database::getCacheSetOptions( $dbr );
423                $queueLookup = PageTriageServices::wrap( MediaWikiServices::getInstance() )
424                    ->getQueueLookup();
425                $queueRecord = $queueLookup->getByPageId( $pageId );
426                return $queueRecord instanceof QueueRecord ? $queueRecord->getCreatedTimestamp() : false;
427            },
428            [ 'version' => PageTriage::CACHE_VERSION ]
429        );
430
431        // If still not found, return false.
432        if ( !$pageCreationDateTime ) {
433            return false;
434        }
435
436        // Get the age of the article in days
437        $timestamp = new MWTimestamp( $pageCreationDateTime );
438        $dateInterval = $timestamp->diff( new MWTimestamp() );
439        $articleDaysOld = $dateInterval->format( '%a' );
440
441        // If it's younger than the maximum age, return true.
442        return $articleDaysOld < $maxAgeInDays;
443    }
444
445    /** @inheritDoc */
446    public function onArticleViewFooter( $article, $patrolFooterShown ) {
447        // Handler for hook ArticleViewFooter. This will...
448        //   1) determine whether to turn on noindex for new, unreviewed articles,
449        //   2) determine whether to load a link for autopatrolled users to unpatrol their article,
450        //   3) determine whether to load the Page Curation toolbar, and/or
451        //   4) determine whether to load the "Add to New Pages Feed" link
452
453        $wikiPage = $article->getPage();
454        $title = $wikiPage->getTitle();
455        $context = $article->getContext();
456        $user = $context->getUser();
457        $outputPage = $context->getOutput();
458        $request = $context->getRequest();
459
460        // 1) Determine whether to turn on noindex for new, unreviewed articles.
461        // Overwrite the noindex rule defined in Article::view(), this also affects main namespace
462        if ( self::shouldShowNoIndex( $article ) ) {
463            $outputPage->setRobotPolicy( 'noindex,nofollow' );
464            $this->statsFactory->getCounter( 'noindex_total' )
465                ->setLabel( 'wiki', WikiMap::getCurrentWikiId() )
466                ->copyToStatsdAt( 'extension.PageTriage.by_wiki.' . WikiMap::getCurrentWikiId() . '.noindex' )
467                ->increment();
468        }
469
470        // onArticleViewFooter() is run every time any article is not viewed from cache, so exit
471        // early if we can, to increase performance.
472        // Only named users can review
473        if ( !$user->isNamed() ) {
474            return;
475        }
476        // Only show in defined namespaces
477        if ( !in_array( $title->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
478            return;
479        }
480        // Don't do anything if it's coming from Special:NewPages
481        if ( $request->getVal( 'patrolpage' ) ) {
482            return;
483        }
484
485        // 2) determine whether to load a link for autopatrolled users to unpatrol their article
486        $userCanPatrol = $this->permissionManager->quickUserCan( 'patrol', $user, $title );
487        $userCanAutoPatrol = $this->permissionManager->userHasRight( $user, 'autopatrol' );
488        $outputPage->addJsConfigVars( [
489            'wgPageTriageUserCanPatrol' => $userCanPatrol,
490            'wgPageTriageUserCanAutoPatrol' => $userCanAutoPatrol
491        ] );
492        if ( !$userCanPatrol ) {
493            $this->maybeShowUnpatrolLink( $wikiPage, $user, $outputPage );
494            return;
495        }
496
497        // 3) determine whether to load the Page Curation toolbar.
498        // 4) determine whether to load the "Add to New Pages Feed" link.
499        // See if the page is in the PageTriage page queue
500        // If it isn't, $needsReview will be null
501        // Also, users without the autopatrol right can't review their own pages
502        $needsReview = PageTriageUtil::isPageUnreviewed( $wikiPage );
503        if ( $needsReview !== null
504            && (
505                !$user->equals( $this->revisionStore->getFirstRevision( $title )->getUser( RevisionRecord::RAW ) )
506                || $userCanAutoPatrol
507            )
508        ) {
509            if ( $this->config->get( 'PageTriageEnableCurationToolbar' ) ||
510                $request->getVal( 'curationtoolbar' ) === 'true' ) {
511                // Load the JavaScript for the curation toolbar
512                $outputPage->addModules( 'ext.pageTriage.toolbarStartup' );
513                $outputPage->addModuleStyles( [ 'mediawiki.interface.helpers.styles' ] );
514            } else {
515                if ( $needsReview ) {
516                    // show 'Mark as reviewed' link
517                    $msg = $context->msg( 'pagetriage-markpatrolled' )->text();
518                    $msg = Html::element(
519                        'a',
520                        [ 'href' => '#', 'class' => 'mw-pagetriage-markpatrolled-link' ],
521                        $msg
522                    );
523                } else {
524                    // show 'Reviewed' text
525                    $msg = $context->msg( 'pagetriage-reviewed' )->escaped();
526                }
527                $outputPage->addModules( [ 'ext.pageTriage.articleLink' ] );
528                $html = Html::rawElement( 'div', [ 'class' => 'mw-pagetriage-markpatrolled' ], $msg );
529                $outputPage->addHTML( $html );
530            }
531        } elseif ( $needsReview === null && !$title->isMainPage() ) {
532            // Page is potentially usable, but not in the queue, allow users to add it manually
533            // Option is not shown if the article is the main page
534            $outputPage->addModules( 'ext.pageTriage.sidebarLink' );
535        }
536    }
537
538    /**
539     * Show a link to autopatrolled users without the 'patrol'
540     * userright that allows them to unreview a specific page iff
541     * the page is autopatrolled && they are the page's creator
542     *
543     * @param WikiPage $wikiPage Wikipage being viewed
544     * @param User $user Current user
545     * @param OutputPage $out Output of current page
546     */
547    private function maybeShowUnpatrolLink( WikiPage $wikiPage, User $user, OutputPage $out ): void {
548        $reviewStatus = PageTriageUtil::getStatus( $wikiPage );
549        $articleIsNotAutoPatrolled = $reviewStatus !== QueueRecord::REVIEW_STATUS_AUTOPATROLLED;
550        if ( $articleIsNotAutoPatrolled ) {
551            return;
552        }
553
554        $isAutopatrolled = $this->permissionManager->userHasRight( $user, 'autopatrol' );
555
556        if ( !$isAutopatrolled ) {
557            return;
558        }
559
560        $pageCreator = $this->revisionStore->getFirstRevision( $wikiPage )->getUser( RevisionRecord::RAW );
561        $isPageCreator = $pageCreator->equals( $user );
562
563        if ( $isPageCreator ) {
564            $out->addModules( 'ext.pageTriage.sidebarLink' );
565        }
566    }
567
568    /** @inheritDoc */
569    public function onMarkPatrolledComplete( $rcid, $user, $wcOnlySysopsCanPatrol, $auto ) {
570        // Sync records from patrol queue to triage queue
571        $rc = RecentChange::newFromId( $rcid );
572        if ( !$rc ) {
573            return;
574        }
575
576        // Run for PageTriage namespaces and for draftspace
577        if ( !in_array( $rc->getPage()->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
578            return;
579        }
580
581        $pt = new PageTriage( $rc->getAttribute( 'rc_cur_id' ) );
582        if ( $pt->addToPageTriageQueue( QueueRecord::REVIEW_STATUS_PATROLLED, $user, true ) ) {
583            // Compile metadata for new page triage record.
584            $acp = ArticleCompileProcessor::newFromPageId( [ $rc->getAttribute( 'rc_cur_id' ) ] );
585            if ( $acp ) {
586                // Page was just inserted into PageTriage queue, so we need to compile BasicData
587                // from DB_PRIMARY, since that component accesses the pagetriage_page table.
588                $acp->configComponentDb(
589                    ArticleCompileProcessor::getSafeComponentDbConfigForCompilation()
590                );
591                $acp->compileMetadata();
592            }
593        }
594
595        // Only notify for PageTriage namespaces, not for draftspace
596        $title = $this->titleFactory->newFromID( $rc->getAttribute( 'rc_cur_id' ) );
597        $isInPageTriageNamespaces = in_array(
598            $title->getNamespace(),
599            $this->config->get( 'PageTriageNamespaces' )
600        );
601        if ( $title && $isInPageTriageNamespaces ) {
602            PageTriageUtil::createNotificationEvent(
603                $title,
604                $user,
605                'pagetriage-mark-as-reviewed'
606            );
607
608            $logEntry = new ManualLogEntry(
609                'pagetriage-curation',
610                'reviewed-' . ( $title->isRedirect() ? 'redirect' : 'article' )
611            );
612
613            $logEntry->setPerformer( $user );
614            $logEntry->setTarget( $title );
615            // Explicitly not including the #PageTriage tag
616            // since the action came from a non-PageTriage component
617            $logEntry->publish( $logEntry->insert() );
618        }
619    }
620
621    /** @inheritDoc */
622    public function onBlockIpComplete( $block, $performer, $priorBlock ) {
623        // Update Article metadata when a user gets blocked.
624        PageTriageUtil::updateMetadataOnBlockChange( $block, (int)$block->isSitewide() );
625    }
626
627    /** @inheritDoc */
628    public function onUnblockUserComplete( $block, $performer ) {
629        // Update Article metadata when a user gets unblocked.
630        PageTriageUtil::updateMetadataOnBlockChange( $block, 0 );
631    }
632
633    /** @inheritDoc */
634    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
635        $pageTriageDraftNamespaceId = $config->get( 'PageTriageDraftNamespaceId' );
636        $vars['pageTriageNamespaces'] = PageTriageUtil::getNamespaces( $config );
637        $vars['wgPageTriageDraftNamespaceId'] = $pageTriageDraftNamespaceId;
638    }
639
640    /**
641     * Generates messages for toolbar
642     *
643     * @param Context $context
644     * @param Config $config
645     * @return array
646     */
647    public static function toolbarContentLanguageMessages( Context $context, Config $config ) {
648        $keys = array_merge(
649            [
650                'pagetriage-mark-mark-talk-page-notify-topic-title',
651                'pagetriage-mark-unmark-talk-page-notify-topic-title',
652                'pagetriage-feedback-from-new-page-review-process-title',
653                'pagetriage-feedback-from-new-page-review-process-message',
654                'pagetriage-note-sent-talk-page-notify-topic-title',
655                'pagetriage-note-sent-talk-page-notify-topic-title-reviewer',
656                'pagetriage-tags-talk-page-notify-topic-title'
657            ],
658            $config->get( 'PageTriageDeletionTagsOptionsContentLanguageMessages' )
659        );
660        $messages = [];
661        foreach ( $keys as $key ) {
662            $messages[$key] = $context->msg( $key )->inContentLanguage()->plain();
663        }
664        return $messages;
665    }
666
667    /**
668     * Generates messages for toolbar
669     *
670     * @param Context $context
671     * @param Config $config
672     * @return array
673     */
674    public static function toolbarConfig( Context $context, Config $config ) {
675        $pageTriageCurationModules = $config->get( 'PageTriageCurationModules' );
676        $pageTriageCurationDependencies = [];
677        if ( ExtensionRegistry::getInstance()->isLoaded( 'WikiLove' ) ) {
678            $pageTriageCurationModules['wikiLove'] = [
679                // depends on WikiLove extension
680                'helplink' => '//en.wikipedia.org/wiki/Wikipedia:Page_Curation/Help#WikiLove',
681                'namespace' => [ NS_MAIN, NS_USER ],
682            ];
683            $pageTriageCurationDependencies[] = 'ext.wikiLove.init';
684        }
685        return [
686            'PageTriageCurationDependencies' => $pageTriageCurationDependencies,
687            'PageTriageCurationModules' => $pageTriageCurationModules,
688            'PageTriageEnableCopyvio' => $config->get( 'PageTriageEnableCopyvio' ),
689            'PageTriageEnableOresFilters' => $config->get( 'PageTriageEnableOresFilters' ),
690            'PageTriageEnableExtendedFeatures' =>
691                $config->get( 'PageTriageEnableExtendedFeatures' ),
692            'TalkPageNoteTemplate' => $config->get( 'TalkPageNoteTemplate' ),
693        ];
694    }
695
696    /**
697     * Add PageTriage events to Echo
698     *
699     * @param array &$notifications array a list of enabled echo events
700     * @param array &$notificationCategories array details for echo events
701     * @param array &$icons array of icon details
702     * @return bool
703     */
704    public static function onBeforeCreateEchoEvent(
705        &$notifications, &$notificationCategories, &$icons
706    ) {
707        $config = MediaWikiServices::getInstance()->getMainConfig();
708        $enabledEchoEvents = $config->get( 'PageTriageEnabledEchoEvents' );
709
710        if ( $enabledEchoEvents ) {
711            $notificationCategories['page-review'] = [
712                'priority' => 8,
713                'tooltip' => 'echo-pref-tooltip-page-review',
714            ];
715        }
716
717        if ( in_array( 'pagetriage-mark-as-reviewed', $enabledEchoEvents ) ) {
718            $notifications['pagetriage-mark-as-reviewed'] = [
719                'presentation-model' => PageTriageMarkAsReviewedPresentationModel::class,
720                'category' => 'page-review',
721                'group' => 'neutral',
722                'section' => 'message',
723                'user-locators' => [ [ self::class . '::locateUsersForNotification' ] ],
724            ];
725        }
726        if ( in_array( 'pagetriage-add-maintenance-tag', $enabledEchoEvents ) ) {
727            $notifications['pagetriage-add-maintenance-tag'] = [
728                'presentation-model' => PageTriageAddMaintenanceTagPresentationModel::class,
729                'category' => 'page-review',
730                'group' => 'neutral',
731                'section' => 'alert',
732                'user-locators' => [ [ self::class . '::locateUsersForNotification' ] ],
733            ];
734        }
735        if ( in_array( 'pagetriage-add-deletion-tag', $enabledEchoEvents ) ) {
736            $notifications['pagetriage-add-deletion-tag'] = [
737                'presentation-model' => PageTriageAddDeletionTagPresentationModel::class,
738                'category' => 'page-review',
739                'group' => 'negative',
740                'section' => 'alert',
741                'user-locators' => [ [ self::class . '::locateUsersForNotification' ] ],
742            ];
743            $icons['trash'] = [
744                'path' => 'PageTriage/echo-icons/trash.svg'
745            ];
746        }
747
748        return true;
749    }
750
751    /**
752     * For locating users to be notifies of an Echo Event.
753     * @param Event $event
754     * @return array
755     */
756    public static function locateUsersForNotification( Event $event ) {
757        if ( !$event->getTitle() ) {
758            return [];
759        }
760
761        $pageId = $event->getTitle()->getArticleID();
762
763        $articleMetadata = new ArticleMetadata( [ $pageId ], false, DB_REPLICA );
764        $metaData = $articleMetadata->getMetadata();
765
766        if ( !$metaData ) {
767            return [];
768        }
769
770        $users = [];
771        if ( $metaData[$pageId]['user_id'] ) {
772            $users[$metaData[$pageId]['user_id']] = User::newFromId( $metaData[$pageId]['user_id'] );
773        }
774        return $users;
775    }
776
777    /** @inheritDoc */
778    public function onLocalUserCreated( $user, $autocreated ) {
779        // New users get echo preferences set that are not the default settings for existing users.
780        // Specifically, new users are opted into email notifications for page reviews.
781        if ( !$autocreated ) {
782            $this->userOptionsManager->setOption( $user, 'echo-subscriptions-email-page-review', true );
783        }
784    }
785
786    /**
787     * @param RecentChange $rc
788     * @param array &$models Models names to score
789     */
790    public static function onORESCheckModels( RecentChange $rc, &$models ) {
791        if ( !in_array( $rc->getAttribute( 'rc_source' ), [ RecentChange::SRC_NEW, RecentChange::SRC_EDIT ] ) ) {
792            return;
793        }
794
795        if ( !ArticleMetadata::validatePageIds(
796            [ (int)$rc->getPage()->getDBkey() ], DB_REPLICA
797        ) ) {
798            return;
799        }
800
801        // Ensure all pages in the PageTriage queue
802        // are scored for both models regardless of namespace.
803        foreach ( [ 'articlequality', 'draftquality' ] as $model ) {
804            if ( !in_array( $model, $models ) ) {
805                $models[] = $model;
806            }
807        }
808    }
809
810    /** @inheritDoc */
811    public function onListDefinedTags( &$tags ) {
812        $tags[] = self::TAG_NAME;
813    }
814
815    /** @inheritDoc */
816    public function onChangeTagsAllowedAdd( &$allowedTags, $addTags, $user ) {
817        $allowedTags[] = self::TAG_NAME;
818    }
819
820    /** @inheritDoc */
821    public function onChangeTagsListActive( &$tags ) {
822        $tags[] = self::TAG_NAME;
823    }
824
825    /** @inheritDoc */
826    public function onApiMain__moduleManager( $moduleManager ) {
827        if ( !$this->config->get( 'PageTriageEnableExtendedFeatures' ) ) {
828            $moduleManager->addModule(
829                'pagetriagetagging',
830                'action',
831                ApiDisabled::class
832            );
833        }
834    }
835
836    /** @inheritDoc */
837    public function onPageDeleteComplete(
838        ProperPageIdentity $page,
839        Authority $deleter,
840        string $reason,
841        int $pageID,
842        RevisionRecord $deletedRev,
843        ManualLogEntry $logEntry,
844        int $archivedRevisionCount
845    ) {
846        if ( $this->queueManager->isPageTriageNamespace( $page->getNamespace() ) ) {
847            // TODO: Factor the user status cache into another service.
848            self::flushUserStatusCache( $page );
849            $this->queueManager->deleteByPageId( $pageID );
850        }
851    }
852
853    /** @inheritDoc */
854    public function onPageUndeleteComplete(
855        ProperPageIdentity $page,
856        Authority $restorer,
857        string $reason,
858        RevisionRecord $restoredRev,
859        ManualLogEntry $logEntry,
860        int $restoredRevisionCount,
861        bool $created,
862        array $restoredPageIds
863    ): void {
864        if ( !$created ) {
865            // not interested in revdel actions
866            return;
867        }
868
869        if ( !in_array( $page->getNamespace(), PageTriageUtil::getNamespaces() ) ) {
870            // don't queue pages in namespaces where PageTriage is disabled
871            return;
872        }
873
874        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
875        self::addToPageTriageQueue( $wikiPage->getId(), $wikiPage->getTitle() );
876    }
877}