Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.62% covered (warning)
68.62%
223 / 325
29.41% covered (danger)
29.41%
5 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecentChangeStore
68.62% covered (warning)
68.62%
223 / 325
29.41% covered (danger)
29.41%
5 / 17
216.18
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 newRecentChangeFromRow
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getRecentChangeById
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRecentChangeByConds
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 insertRecentChange
70.83% covered (warning)
70.83%
51 / 72
0.00% covered (danger)
0.00%
0 / 1
24.17
 isEnotifEnabled
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 createEditRecentChange
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
3
 createNewPageRecentChange
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
3
 createLogRecentChange
81.97% covered (warning)
81.97%
50 / 61
0.00% covered (danger)
0.00%
0 / 1
17.50
 createCategorizationRecentChange
95.24% covered (success)
95.24%
40 / 42
0.00% covered (danger)
0.00%
0 / 1
4
 checkIPAddress
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 addSourceForTest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrimarySources
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 isFromPrimarySource
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAllSources
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 convertTypeToSources
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 convertSourceToType
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\RecentChanges;
8
9use MediaWiki\ChangeTags\ChangeTagsStore;
10use MediaWiki\CommentStore\CommentStore;
11use MediaWiki\Config\ServiceOptions;
12use MediaWiki\Context\RequestContext;
13use MediaWiki\HookContainer\HookContainer;
14use MediaWiki\HookContainer\HookRunner;
15use MediaWiki\JobQueue\JobQueueGroup;
16use MediaWiki\Json\FormatJson;
17use MediaWiki\MainConfigNames;
18use MediaWiki\Page\PageIdentity;
19use MediaWiki\Page\PageReference;
20use MediaWiki\Page\WikiPageFactory;
21use MediaWiki\Permissions\PermissionManager;
22use MediaWiki\Storage\EditResult;
23use MediaWiki\Title\Title;
24use MediaWiki\Title\TitleFormatter;
25use MediaWiki\User\ActorStoreFactory;
26use MediaWiki\User\UserFactory;
27use MediaWiki\User\UserIdentity;
28use MediaWiki\Utils\MWTimestamp;
29use RuntimeException;
30use Wikimedia\Assert\Assert;
31use Wikimedia\IPUtils;
32use Wikimedia\Rdbms\IConnectionProvider;
33use Wikimedia\Timestamp\TimestampFormat as TS;
34
35/**
36 * @since 1.45
37 */
38class RecentChangeStore implements RecentChangeFactory, RecentChangeLookup {
39
40    public const CONSTRUCTOR_OPTIONS = [
41        MainConfigNames::LogRestrictions,
42        MainConfigNames::PutIPinRC,
43        MainConfigNames::EnotifUserTalk,
44        MainConfigNames::EnotifWatchlist,
45        MainConfigNames::ShowUpdatedMarker,
46    ];
47
48    private ActorStoreFactory $actorStoreFactory;
49    private ChangeTagsStore $changeTagsStore;
50    private IConnectionProvider $connectionProvider;
51    private CommentStore $commentStore;
52    private HookContainer $hookContainer;
53    private JobQueueGroup $jobQueueGroup;
54    private PermissionManager $permissionManager;
55    private RecentChangeRCFeedNotifier $recentChangeRCFeedNotifier;
56    private ServiceOptions $options;
57    private TitleFormatter $titleFormatter;
58    private WikiPageFactory $wikiPageFactory;
59    private UserFactory $userFactory;
60
61    /** @var array|null The merged RecentChangeSources extension attribute */
62    private ?array $extensionSourcesAttr;
63    /** @var array|null All rc_source values (lazy-initialised) */
64    private ?array $allSources = null;
65    /** @var array|null rc_source values for primary events (lazy-initialised) */
66    private ?array $primarySources = null;
67
68    public function __construct(
69        ActorStoreFactory $actorStoreFactory,
70        ChangeTagsStore $changeTagsStore,
71        IConnectionProvider $connectionProvider,
72        CommentStore $commentStore,
73        HookContainer $hookContainer,
74        JobQueueGroup $jobQueueGroup,
75        PermissionManager $permissionManager,
76        RecentChangeRCFeedNotifier $recentChangeRCFeedNotifier,
77        ServiceOptions $options,
78        TitleFormatter $titleFormatter,
79        WikiPageFactory $wikiPageFactory,
80        UserFactory $userFactory,
81        ?array $extensionSources
82    ) {
83        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
84
85        $this->actorStoreFactory = $actorStoreFactory;
86        $this->changeTagsStore = $changeTagsStore;
87        $this->connectionProvider = $connectionProvider;
88        $this->commentStore = $commentStore;
89        $this->hookContainer = $hookContainer;
90        $this->jobQueueGroup = $jobQueueGroup;
91        $this->permissionManager = $permissionManager;
92        $this->recentChangeRCFeedNotifier = $recentChangeRCFeedNotifier;
93        $this->options = $options;
94        $this->titleFormatter = $titleFormatter;
95        $this->wikiPageFactory = $wikiPageFactory;
96        $this->userFactory = $userFactory;
97        $this->extensionSourcesAttr = $extensionSources;
98    }
99
100    /**
101     * @inheritDoc
102     */
103    public function newRecentChangeFromRow( $row ): RecentChange {
104        $rc = new RecentChange;
105        $rc->loadFromRow( $row );
106        return $rc;
107    }
108
109    /**
110     * @inheritDoc
111     */
112    public function getRecentChangeById( int $rcid ): ?RecentChange {
113        return $this->getRecentChangeByConds( [ 'rc_id' => $rcid ], __METHOD__ );
114    }
115
116    /**
117     * @inheritDoc
118     */
119    public function getRecentChangeByConds(
120        array $conds,
121        string $fname = __METHOD__,
122        bool $fromPrimary = false
123    ): ?RecentChange {
124        if ( $fromPrimary ) {
125            $db = $this->connectionProvider->getPrimaryDatabase();
126        } else {
127            $db = $this->connectionProvider->getReplicaDatabase();
128        }
129
130        $row = $db->newSelectQueryBuilder()
131            ->select( '*' )
132            ->from( 'recentchanges' )
133            ->where( $conds )
134            ->caller( $fname )
135            ->fetchRow();
136
137        if ( $row ) {
138            return $this->newRecentChangeFromRow( $row );
139        }
140        return null;
141    }
142
143    /**
144     * @inheritDoc
145     */
146    public function insertRecentChange( RecentChange $recentChange, bool $send = self::SEND_FEED ) {
147        $putIPinRC = $this->options->get( MainConfigNames::PutIPinRC );
148        $dbw = $this->connectionProvider->getPrimaryDatabase();
149        if ( !is_array( $recentChange->getExtras() ) ) {
150            $recentChange->setExtra( [] );
151        }
152
153        if ( !$putIPinRC ) {
154            $recentChange->setAttribute( 'rc_ip', '' );
155        }
156
157        // Strict mode fixups (not-NULL fields)
158        foreach ( [ 'minor', 'bot', 'patrolled', 'deleted' ] as $field ) {
159            $recentChange->setAttribute( "rc_$field", (int)$recentChange->getAttribute( "rc_$field" ) );
160        }
161        // ...more fixups (NULL fields)
162        foreach ( [ 'old_len', 'new_len' ] as $field ) {
163            $recentChange->setAttribute( "rc_$field", $recentChange->getAttribute( "rc_$field" ) !== null
164                ? (int)$recentChange->getAttribute( "rc_$field" )
165                : null );
166        }
167
168        $row = $recentChange->getAttributes();
169
170        // Trim spaces on user supplied text
171        $row['rc_comment'] = trim( $row['rc_comment'] ?? '' );
172
173        // Fixup database timestamps
174        $row['rc_timestamp'] = $dbw->timestamp( $row['rc_timestamp'] );
175
176        // If we are using foreign keys, an entry of 0 for the page_id will fail, so use NULL
177        if ( $row['rc_cur_id'] == 0 ) {
178            unset( $row['rc_cur_id'] );
179        }
180
181        // Convert mAttribs['rc_comment'] for CommentStore
182        $comment = $row['rc_comment'];
183        unset( $row['rc_comment'], $row['rc_comment_text'], $row['rc_comment_data'] );
184        $row += $this->commentStore->insert( $dbw, 'rc_comment', $comment );
185
186        // Normalize UserIdentity to actor ID
187        $user = $recentChange->getPerformerIdentity();
188        if ( array_key_exists( 'forImport', $recentChange->getExtras() ) && $recentChange->getExtra( 'forImport' ) ) {
189            $actorStore = $this->actorStoreFactory->getActorStoreForImport();
190        } else {
191            $actorStore = $this->actorStoreFactory->getActorStore();
192        }
193        $row['rc_actor'] = $actorStore->acquireActorId( $user, $dbw );
194        unset( $row['rc_user'], $row['rc_user_text'] );
195
196        // Don't reuse an existing rc_id for the new row, if one happens to be
197        // set for some reason.
198        unset( $row['rc_id'] );
199
200        // Insert new row
201        $dbw->newInsertQueryBuilder()
202            ->insertInto( 'recentchanges' )
203            ->row( $row )
204            ->caller( __METHOD__ )->execute();
205
206        // Set the ID
207        $recentChange->setAttribute( 'rc_id', $dbw->insertId() );
208
209        // Notify extensions
210        $hookRunner = new HookRunner( $this->hookContainer );
211        $hookRunner->onRecentChange_save( $recentChange );
212
213        // Apply revert tags (if needed)
214        $editResult = $recentChange->getEditResult();
215        if ( $editResult !== null && count( $editResult->getRevertTags() ) ) {
216            $this->changeTagsStore->addTags(
217                $editResult->getRevertTags(),
218                $recentChange->getAttribute( 'rc_id' ),
219                $recentChange->getAttribute( 'rc_this_oldid' ),
220                $recentChange->getAttribute( 'rc_logid' ),
221                FormatJson::encode( $editResult ),
222                $recentChange
223            );
224        }
225
226        if ( count( $recentChange->getTags() ) ) {
227            // $this->tags may contain revert tags we already applied above, they will
228            // just be ignored.
229            $this->changeTagsStore->addTags(
230                $recentChange->getTags(),
231                $recentChange->getAttribute( 'rc_id' ),
232                $recentChange->getAttribute( 'rc_this_oldid' ),
233                $recentChange->getAttribute( 'rc_logid' ),
234                null,
235                $recentChange
236            );
237        }
238
239        if ( $send === self::SEND_FEED ) {
240            // Emit the change to external applications via RCFeeds.
241            $this->recentChangeRCFeedNotifier->notifyRCFeeds( $recentChange );
242        }
243
244        // E-mail notifications
245        if ( self::isEnotifEnabled( $this->options ) ) {
246            $editor = $this->userFactory->newFromUserIdentity( $recentChange->getPerformerIdentity() );
247            $title = Title::castFromPageReference( $recentChange->getPage() );
248
249            if ( $title ) {
250                // Send emails or email jobs once this row is safely committed
251                $dbw->onTransactionCommitOrIdle(
252                    static function () use ( $recentChange ) {
253                        $notifier = new RecentChangeNotifier();
254                        $notifier->notifyOnPageChange( $recentChange );
255                    },
256                    __METHOD__
257                );
258            }
259        }
260
261        $jobs = [];
262        // Flush old entries from the `recentchanges` table
263        if ( mt_rand( 0, 9 ) == 0 ) {
264            $jobs[] = RecentChangesUpdateJob::newPurgeJob();
265        }
266        // Update the cached list of active users
267        if ( $recentChange->getAttribute( 'rc_user' ) > 0 ) {
268            $jobs[] = RecentChangesUpdateJob::newCacheUpdateJob();
269        }
270        $this->jobQueueGroup->lazyPush( $jobs );
271    }
272
273    /**
274     * Whether e-mail notifications are generally enabled on this wiki.
275     *
276     * This is used for:
277     *
278     * - performance optimization in RecentChangeStore::insertRecentChange().
279     *   After an edit, whether or not we need to use the RecentChangeNotifier
280     *   to determine which RecentChangeNotifyJob to dispatch.
281     *
282     * - performance optmization in WatchlistManager.
283     *   After using reset ("Mark all pages as seen") on Special:Watchlist,
284     *   whether to only look for user talk data to reset, or whether to look
285     *   at all possible pages for timestamps to reset.
286     *
287     * TODO: Determine whether these optimizations still make sense.
288     *
289     * FIXME: The $wgShowUpdatedMarker variable was added to this condtion
290     * in 2008 (2cf12c973d, SVN r35001) because at the time the per-user
291     * "last seen" marker for watchlist and page history, was managed by
292     * the RecentChangeNotifier/UserMailer classes. As of August 2022, this
293     * appears to no longer be the case.
294     *
295     * @param ServiceOptions $options
296     * @return bool
297     */
298    public static function isEnotifEnabled( ServiceOptions $options ): bool {
299        return $options->get( MainConfigNames::EnotifUserTalk ) ||
300            $options->get( MainConfigNames::EnotifWatchlist ) ||
301            $options->get( MainConfigNames::ShowUpdatedMarker );
302    }
303
304    /**
305     * @inheritDoc
306     */
307    public function createEditRecentChange(
308        string $timestamp,
309        PageIdentity $page,
310        bool $minor,
311        UserIdentity $user,
312        string $comment,
313        int $oldId,
314        bool $bot,
315        string $ip = '',
316        ?int $oldSize = 0,
317        ?int $newSize = 0,
318        int $newId = 0,
319        int $patrol = 0,
320        array $tags = [],
321        ?EditResult $editResult = null
322    ): RecentChange {
323        Assert::parameter( $page->exists(), '$page', 'must represent an existing page' );
324
325        $rc = new RecentChange( $page, $user );
326        $rc->setAttribs( [
327            'rc_timestamp' => $timestamp,
328            'rc_namespace' => $page->getNamespace(),
329            'rc_title' => $page->getDBkey(),
330            'rc_source' => RecentChange::SRC_EDIT,
331            'rc_minor' => $minor ? 1 : 0,
332            'rc_cur_id' => $page->getId(),
333            'rc_user' => $user->getId(),
334            'rc_user_text' => $user->getName(),
335            'rc_comment' => &$comment,
336            'rc_comment_text' => &$comment,
337            'rc_comment_data' => null,
338            'rc_this_oldid' => $newId,
339            'rc_last_oldid' => $oldId,
340            'rc_bot' => $bot ? 1 : 0,
341            'rc_ip' => self::checkIPAddress( $ip ),
342            'rc_patrolled' => $patrol,
343            'rc_old_len' => $oldSize,
344            'rc_new_len' => $newSize,
345            'rc_deleted' => 0,
346            'rc_logid' => 0,
347            'rc_log_type' => null,
348            'rc_log_action' => '',
349            'rc_params' => ''
350        ] );
351
352        $rc->setExtra( [
353            // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
354            'prefixedDBkey' => $this->titleFormatter->getPrefixedDBkey( $page ),
355            'oldSize' => $oldSize,
356            'newSize' => $newSize,
357            'pageStatus' => 'changed'
358        ] );
359
360        $rc->addTags( $tags );
361        $rc->setEditResult( $editResult );
362
363        return $rc;
364    }
365
366    /**
367     * @inheritDoc
368     */
369    public function createNewPageRecentChange(
370        string $timestamp,
371        PageIdentity $page,
372        bool $minor,
373        UserIdentity $user,
374        string $comment,
375        bool $bot,
376        string $ip = '',
377        ?int $size = 0,
378        int $newId = 0,
379        int $patrol = 0,
380        array $tags = []
381    ): RecentChange {
382        Assert::parameter( $page->exists(), '$page', 'must represent an existing page' );
383
384        $rc = new RecentChange( $page, $user );
385        $rc->setAttribs( [
386            'rc_timestamp' => $timestamp,
387            'rc_namespace' => $page->getNamespace(),
388            'rc_title' => $page->getDBkey(),
389            'rc_source' => RecentChange::SRC_NEW,
390            'rc_minor' => $minor ? 1 : 0,
391            'rc_cur_id' => $page->getId(),
392            'rc_user' => $user->getId(),
393            'rc_user_text' => $user->getName(),
394            'rc_comment' => &$comment,
395            'rc_comment_text' => &$comment,
396            'rc_comment_data' => null,
397            'rc_this_oldid' => $newId,
398            'rc_last_oldid' => 0,
399            'rc_bot' => $bot ? 1 : 0,
400            'rc_ip' => self::checkIPAddress( $ip ),
401            'rc_patrolled' => $patrol,
402            'rc_old_len' => 0,
403            'rc_new_len' => $size,
404            'rc_deleted' => 0,
405            'rc_logid' => 0,
406            'rc_log_type' => null,
407            'rc_log_action' => '',
408            'rc_params' => ''
409        ] );
410
411        $rc->setExtra( [
412            // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
413            'prefixedDBkey' => $this->titleFormatter->getPrefixedDBkey( $page ),
414            'oldSize' => 0,
415            'newSize' => $size,
416            'pageStatus' => 'created'
417        ] );
418
419        $rc->addTags( $tags );
420
421        return $rc;
422    }
423
424    /**
425     * @inheritDoc
426     */
427    public function createLogRecentChange(
428        string $timestamp,
429        PageReference $logPage,
430        UserIdentity $user,
431        string $actionComment,
432        string $ip,
433        string $type,
434        string $action,
435        PageReference $target,
436        string $logComment,
437        string $params,
438        int $newId = 0,
439        string $actionCommentIRC = '',
440        int $revId = 0,
441        bool $isPatrollable = false,
442        ?bool $forceBotFlag = null,
443        int $deleted = 0
444    ): RecentChange {
445        // Get pageStatus for email notification
446        switch ( $type . '-' . $action ) {
447            case 'delete-delete':
448            case 'delete-delete_redir':
449            case 'delete-delete_redir2':
450                $pageStatus = 'deleted';
451                break;
452            case 'move-move':
453            case 'move-move_redir':
454                $pageStatus = 'moved';
455                break;
456            case 'delete-restore':
457                $pageStatus = 'restored';
458                break;
459            case 'upload-upload':
460                $pageStatus = 'created';
461                break;
462            case 'upload-overwrite':
463            default:
464                $pageStatus = 'changed';
465                break;
466        }
467
468        // Allow unpatrolled status for patrollable log entries
469        $canAutopatrol = $this->permissionManager->userHasRight( $user, 'autopatrol' );
470        $markPatrolled = $isPatrollable ? $canAutopatrol : true;
471
472        if ( $target instanceof PageIdentity && $target->canExist() ) {
473            $pageId = $target->getId();
474        } else {
475            $pageId = 0;
476        }
477
478        if ( $forceBotFlag !== null ) {
479            $bot = (int)$forceBotFlag;
480        } else {
481            $bot = $this->permissionManager->userHasRight( $user, 'bot' ) ?
482                (int)RequestContext::getMain()->getRequest()->getBool( 'bot', true ) : 0;
483        }
484
485        $rc = new RecentChange( $target, $user );
486        $rc->setAttribs( [
487            'rc_timestamp' => $timestamp,
488            'rc_namespace' => $target->getNamespace(),
489            'rc_title' => $target->getDBkey(),
490            'rc_source' => RecentChange::SRC_LOG,
491            'rc_minor' => 0,
492            'rc_cur_id' => $pageId,
493            'rc_user' => $user->getId(),
494            'rc_user_text' => $user->getName(),
495            'rc_comment' => &$logComment,
496            'rc_comment_text' => &$logComment,
497            'rc_comment_data' => null,
498            'rc_this_oldid' => $revId,
499            'rc_last_oldid' => 0,
500            'rc_bot' => $bot,
501            'rc_ip' => self::checkIPAddress( $ip ),
502            'rc_patrolled' => $markPatrolled ? RecentChange::PRC_AUTOPATROLLED : RecentChange::PRC_UNPATROLLED,
503            'rc_old_len' => null,
504            'rc_new_len' => null,
505            'rc_deleted' => $deleted,
506            'rc_logid' => $newId,
507            'rc_log_type' => $type,
508            'rc_log_action' => $action,
509            'rc_params' => $params
510        ] );
511
512        $rc->setExtra( [
513            // XXX: This does not correspond to rc_namespace/rc_title/rc_cur_id.
514            //      Is that intentional? For all other kinds of RC entries, prefixedDBkey
515            //      matches rc_namespace/rc_title. Do we even need $logPage?
516            // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
517            'prefixedDBkey' => $this->titleFormatter->getPrefixedDBkey( $logPage ),
518            'actionComment' => $actionComment, // the comment appended to the action, passed from LogPage
519            'pageStatus' => $pageStatus,
520            'actionCommentIRC' => $actionCommentIRC
521        ] );
522
523        return $rc;
524    }
525
526    /**
527     * @inheritDoc
528     */
529    public function createCategorizationRecentChange(
530        string $timestamp,
531        PageIdentity $categoryTitle,
532        ?UserIdentity $user,
533        string $comment,
534        PageIdentity $pageTitle,
535        int $oldRevId,
536        int $newRevId,
537        bool $bot,
538        string $ip = '',
539        int $deleted = 0,
540        ?bool $added = null,
541        bool $forImport = false
542    ): RecentChange {
543        // Done in a backwards compatible way.
544        $categoryWikiPage = $this->wikiPageFactory->newFromTitle( $categoryTitle );
545
546        '@phan-var \MediaWiki\Page\WikiCategoryPage $categoryWikiPage';
547        $params = [
548            'hidden-cat' => $categoryWikiPage->isHidden()
549        ];
550        if ( $added !== null ) {
551            $params['added'] = $added;
552        }
553
554        if ( !$user ) {
555            // XXX: when and why do we need this?
556            $user = $this->actorStoreFactory->getActorStore()->getUnknownActor();
557        }
558
559        $rc = new RecentChange( $categoryTitle, $user );
560        $rc->setAttribs( [
561            'rc_timestamp' => MWTimestamp::convert( TS::MW, $timestamp ),
562            'rc_namespace' => $categoryTitle->getNamespace(),
563            'rc_title' => $categoryTitle->getDBkey(),
564            'rc_source' => RecentChange::SRC_CATEGORIZE,
565            'rc_minor' => 0,
566            // XXX: rc_cur_id does not correspond to rc_namespace/rc_title.
567            // It's because when the page (rc_cur_id) is deleted, we want
568            // to delete the categorization entries, too (see LinksDeletionUpdate).
569            'rc_cur_id' => $pageTitle->getId(),
570            'rc_user' => $user->getId(),
571            'rc_user_text' => $user->getName(),
572            'rc_comment' => &$comment,
573            'rc_comment_text' => &$comment,
574            'rc_comment_data' => null,
575            'rc_this_oldid' => $newRevId,
576            'rc_last_oldid' => $oldRevId,
577            'rc_bot' => $bot ? 1 : 0,
578            'rc_ip' => self::checkIPAddress( $ip ),
579            'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED, // Always patrolled, just like log entries
580            'rc_old_len' => null,
581            'rc_new_len' => null,
582            'rc_deleted' => $deleted,
583            'rc_logid' => 0,
584            'rc_log_type' => null,
585            'rc_log_action' => '',
586            'rc_params' => serialize( $params )
587        ] );
588
589        $rc->setExtra( [
590            // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
591            'prefixedDBkey' => $this->titleFormatter->getPrefixedDBkey( $categoryTitle ),
592            'oldSize' => 0,
593            'newSize' => 0,
594            'pageStatus' => 'changed',
595            'forImport' => $forImport,
596        ] );
597
598        return $rc;
599    }
600
601    private static function checkIPAddress( string $ip ): string {
602        if ( $ip ) {
603            if ( !IPUtils::isIPAddress( $ip ) ) {
604                throw new RuntimeException( "Attempt to write \"" . $ip .
605                    "\" as an IP address into recent changes" );
606            }
607        } else {
608            $ip = RequestContext::getMain()->getRequest()->getIP();
609            if ( !$ip ) {
610                $ip = '';
611            }
612        }
613
614        return $ip;
615    }
616
617    /**
618     * Register an rc_source value
619     *
620     * @internal For testing
621     * @param string $name
622     * @param array $info
623     */
624    public function addSourceForTest( $name, $info ) {
625        $this->extensionSourcesAttr[$name] = $info;
626    }
627
628    public function getPrimarySources(): array {
629        if ( $this->primarySources === null ) {
630            $this->primarySources = array_diff(
631                RecentChange::INTERNAL_SOURCES, [ RecentChange::SRC_CATEGORIZE ] );
632            foreach ( $this->extensionSourcesAttr as $value => $info ) {
633                if ( $info['primary'] ?? false ) {
634                    $this->primarySources[] = $value;
635                }
636            }
637        }
638        return $this->primarySources;
639    }
640
641    public function isFromPrimarySource( RecentChange $rc ): bool {
642        return in_array( $rc->getAttribute( 'rc_source' ), $this->getPrimarySources(), true );
643    }
644
645    public function getAllSources(): array {
646        if ( $this->allSources === null ) {
647            $this->allSources = [
648                ...RecentChange::INTERNAL_SOURCES,
649                ...array_keys( $this->extensionSourcesAttr )
650            ];
651        }
652        return $this->allSources;
653    }
654
655    /** @inheritDoc */
656    public function convertTypeToSources( $type ): array {
657        if ( is_array( $type ) ) {
658            $retval = [];
659            foreach ( $type as $t ) {
660                foreach ( self::convertTypeToSources( $t ) as $s ) {
661                    $retval[] = $s;
662                }
663            }
664
665            return $retval;
666        }
667
668        return match ( $type ) {
669            'edit' => [ RecentChange::SRC_EDIT ],
670            'new' => [ RecentChange::SRC_NEW ],
671            'log' => [ RecentChange::SRC_LOG ],
672            'external' => array_diff(
673                $this->getAllSources(),
674                RecentChange::INTERNAL_SOURCES
675            ),
676            'categorize' => [ RecentChange::SRC_CATEGORIZE ],
677        };
678    }
679
680    /** @inheritDoc */
681    public function convertSourceToType( string $source ): string {
682        return match ( $source ) {
683            RecentChange::SRC_EDIT => 'edit',
684            RecentChange::SRC_NEW => 'new',
685            RecentChange::SRC_LOG => 'log',
686            RecentChange::SRC_CATEGORIZE => 'categorize',
687            default => 'external'
688        };
689    }
690
691}