Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 242
0.00% covered (danger)
0.00%
0 / 45
CRAP
0.00% covered (danger)
0.00%
0 / 1
Event
0.00% covered (danger)
0.00%
0 / 241
0.00% covered (danger)
0.00%
0 / 45
12432
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
 __sleep
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 __wakeup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __toString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
210
 toDbArray
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 isEnabledEvent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 insert
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 resolveTargetPages
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 loadFromRow
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
182
 loadFromID
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 newFromRow
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 newFromID
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 serializeExtra
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 userCan
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 getId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTimestamp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getVariant
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExtra
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExtraParam
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAgent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canNotifyAgent
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTitle
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
72
 getRevision
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getCategory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUseJobQueue
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setVariant
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setAgent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTitle
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 setExtra
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLinkMessage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getLinkDestination
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getBundleHash
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setBundleHash
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isDeleted
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setBundledEvents
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBundledEvents
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canBeBundled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBundlingKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setBundledElements
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSortingKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 selectFields
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Notifications\Model;
4
5use Exception;
6use IDBAccessObject;
7use InvalidArgumentException;
8use MediaWiki\Extension\Notifications\Bundleable;
9use MediaWiki\Extension\Notifications\Controller\NotificationController;
10use MediaWiki\Extension\Notifications\DbFactory;
11use MediaWiki\Extension\Notifications\Hooks\HookRunner;
12use MediaWiki\Extension\Notifications\Mapper\EventMapper;
13use MediaWiki\Extension\Notifications\Mapper\TargetPageMapper;
14use MediaWiki\Extension\Notifications\Services;
15use MediaWiki\Logger\LoggerFactory;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Revision\RevisionRecord;
18use MediaWiki\Title\Title;
19use MediaWiki\User\User;
20use MediaWiki\User\UserIdentity;
21use RuntimeException;
22use stdClass;
23
24/**
25 * Immutable class to represent an event.
26 * In Echo nomenclature, an event is a single occurrence.
27 */
28class Event extends AbstractEntity implements Bundleable {
29
30    /** @var string|null */
31    protected $type = null;
32    /** @var int|null|false */
33    protected $id = null;
34    /** @var string|null */
35    protected $variant = null;
36    /**
37     * @var User|null
38     */
39    protected $agent = null;
40
41    /**
42     * Loaded dynamically on request
43     *
44     * @var Title|null
45     */
46    protected $title = null;
47    /** @var int|null */
48    protected $pageId = null;
49
50    /**
51     * Loaded dynamically on request
52     *
53     * @var RevisionRecord|null
54     */
55    protected $revision = null;
56
57    /** @var array */
58    protected $extra = [];
59
60    /**
61     * Notification timestamp
62     * @var string|null
63     */
64    protected $timestamp = null;
65
66    /**
67     * A hash used to bundle a set of events, events that can be
68     * grouped for a user has the same bundle hash
69     * @var string|null
70     */
71    protected $bundleHash;
72
73    /**
74     * Other events bundled with this one
75     *
76     * @var Event[]
77     */
78    protected $bundledEvents;
79
80    /**
81     * Deletion flag
82     *
83     * @var int
84     */
85    protected $deleted = 0;
86
87    /**
88     * You should not call the constructor.
89     * Instead, use one of the factory functions:
90     * Event::create        To create a new event
91     * Event::newFromRow    To create an event object from a row object
92     * Event::newFromID     To create an event object from the database given its ID
93     */
94    protected function __construct() {
95    }
96
97    ## Save the id and timestamp
98    public function __sleep() {
99        if ( !$this->id ) {
100            throw new RuntimeException( "Unable to serialize an uninitialized Event" );
101        }
102
103        return [ 'id', 'timestamp' ];
104    }
105
106    public function __wakeup() {
107        $this->loadFromID( $this->id );
108    }
109
110    public function __toString() {
111        return "Event(id={$this->id}; type={$this->type})";
112    }
113
114    /**
115     * Creates an Event object
116     * @param array $info Named arguments:
117     * type (required): The event type;
118     * variant: A variant of the type;
119     * agent: The user who caused the event;
120     * title: The page on which the event was triggered;
121     * extra: Event-specific extra information (e.g. post content, delay time, root job params).
122     *
123     * Delayed jobs extra params:
124     * delay: Amount of time in seconds for the notification to be delayed
125     *
126     * Job deduplication extra params:
127     * rootJobSignature: The sha1 signature of the job
128     * rootJobTimestamp: The timestamp when the job gets submitted
129     *
130     * For example to enqueue a new `example` root job or make a parent job
131     * no-op when submitting a new notification you need to pass this extra params:
132     *
133     * [ 'extra' => Job::newRootJobParams('example') ]
134     *
135     * @return Event|false False if aborted via hook or Echo DB is read-only
136     */
137    public static function create( $info = [] ) {
138        global $wgEchoNotifications;
139
140        $services = MediaWikiServices::getInstance();
141        // Do not create event and notifications if write access is locked
142        if ( $services->getReadOnlyMode()->isReadOnly()
143            || DbFactory::newFromDefault()->getEchoDb( DB_PRIMARY )->isReadOnly()
144        ) {
145            return false;
146        }
147
148        $obj = new Event;
149        static $validFields = [ 'type', 'variant', 'agent', 'title', 'extra' ];
150
151        if ( empty( $info['type'] ) ) {
152            throw new InvalidArgumentException( "'type' parameter is mandatory" );
153        }
154
155        if ( !isset( $wgEchoNotifications[$info['type']] ) ) {
156            return false;
157        }
158
159        $obj->id = false;
160        $obj->timestamp = $info['timestamp'] ?? wfTimestampNow();
161        foreach ( $validFields as $field ) {
162            if ( isset( $info[$field] ) ) {
163                $obj->$field = $info[$field];
164            }
165        }
166
167        // If the extra size is more than 50000 bytes, that means there is
168        // probably a problem with the design of this notification type.
169        // There might be data loss if the size exceeds the DB column size of
170        // event_extra.
171        if ( strlen( $obj->serializeExtra() ) > 50000 ) {
172            wfDebugLog( __CLASS__, __FUNCTION__ . ': event extra data is too huge for ' . $info['type'] );
173
174            return false;
175        }
176
177        if ( $obj->title ) {
178            if ( !$obj->title instanceof Title ) {
179                throw new InvalidArgumentException( 'Invalid title parameter' );
180            }
181            $obj->setTitle( $obj->title );
182        }
183
184        if ( $obj->agent ) {
185            if ( !$obj->agent instanceof UserIdentity ) {
186                throw new InvalidArgumentException( "Invalid user parameter" );
187            }
188
189            // RevisionStore returns UserIdentityValue now, convert to User for passing to hooks.
190            if ( !$obj->agent instanceof User ) {
191                $obj->agent = $services->getUserFactory()->newFromUserIdentity( $obj->agent );
192            }
193        }
194
195        $hookRunner = new HookRunner( $services->getHookContainer() );
196        if ( !$hookRunner->onBeforeEchoEventInsert( $obj ) ) {
197            return false;
198        }
199
200        // @Todo - Database insert logic should not be inside the model
201        $obj->insert();
202
203        $hookRunner->onEventInsertComplete( $obj );
204
205        global $wgEchoUseJobQueue;
206
207        NotificationController::notify( $obj, $wgEchoUseJobQueue );
208
209        $stats = $services->getStatsdDataFactory();
210        $type = $info['type'];
211        $stats->increment( 'echo.event.all' );
212        $stats->increment( "echo.event.$type" );
213
214        return $obj;
215    }
216
217    /**
218     * Convert the object's database property to array
219     * @return array
220     */
221    public function toDbArray() {
222        $data = [
223            'event_type' => $this->type,
224            'event_variant' => $this->variant,
225            'event_deleted' => $this->deleted,
226            'event_extra' => $this->serializeExtra()
227        ];
228        if ( $this->id ) {
229            $data['event_id'] = $this->id;
230        }
231        if ( $this->agent ) {
232            if ( !$this->agent->isRegistered() ) {
233                $data['event_agent_ip'] = $this->agent->getName();
234            } else {
235                $data['event_agent_id'] = $this->agent->getId();
236            }
237        }
238
239        if ( $this->pageId ) {
240            $data['event_page_id'] = $this->pageId;
241        } elseif ( $this->title ) {
242            $pageId = $this->title->getArticleID();
243            // Don't need any special handling for title with no id
244            // as they are already stored in extra data array
245            if ( $pageId ) {
246                $data['event_page_id'] = $pageId;
247            }
248        }
249
250        return $data;
251    }
252
253    /**
254     * Check whether the echo event is an enabled event
255     * @return bool
256     */
257    public function isEnabledEvent(): bool {
258        global $wgEchoNotifications;
259        return isset( $wgEchoNotifications[$this->getType()] );
260    }
261
262    /**
263     * Inserts the object into the database.
264     */
265    protected function insert() {
266        $eventMapper = new EventMapper();
267        $this->id = $eventMapper->insert( $this );
268
269        $targetPages = self::resolveTargetPages( $this->getExtraParam( 'target-page' ) );
270        if ( $targetPages ) {
271            $targetMapper = new TargetPageMapper();
272            foreach ( $targetPages as $title ) {
273                $targetPage = TargetPage::create( $title, $this );
274                if ( $targetPage ) {
275                    $targetMapper->insert( $targetPage );
276                }
277            }
278        }
279    }
280
281    /**
282     * @param int[]|int|false $targetPageIds
283     * @return Title[]
284     */
285    protected static function resolveTargetPages( $targetPageIds ) {
286        if ( !$targetPageIds ) {
287            return [];
288        }
289        if ( !is_array( $targetPageIds ) ) {
290            $targetPageIds = [ $targetPageIds ];
291        }
292        $result = [];
293        foreach ( $targetPageIds as $targetPageId ) {
294            // Make sure the target-page id is a valid id
295            $title = Title::newFromID( $targetPageId );
296            // Try primary database if there is no match
297            if ( !$title ) {
298                $title = Title::newFromID( $targetPageId, IDBAccessObject::READ_LATEST );
299            }
300            if ( $title ) {
301                $result[] = $title;
302            }
303        }
304
305        return $result;
306    }
307
308    /**
309     * Loads data from the provided $row into this object.
310     *
311     * @param stdClass $row row object from echo_event
312     * @return bool Whether loading was successful
313     */
314    public function loadFromRow( $row ) {
315        $this->id = (int)$row->event_id;
316        $this->type = $row->event_type;
317
318        // If the object is loaded from __sleep(), timestamp should be already set
319        if ( !$this->timestamp ) {
320            if ( isset( $row->notification_timestamp ) ) {
321                $this->timestamp = wfTimestamp( TS_MW, $row->notification_timestamp );
322            } else {
323                $this->timestamp = wfTimestampNow();
324            }
325        }
326
327        $this->variant = $row->event_variant;
328        try {
329            $this->extra = $row->event_extra ? unserialize( $row->event_extra ) : [];
330        } catch ( Exception $e ) {
331            // T73489: unserializing can fail for old notifications
332            LoggerFactory::getInstance( 'Echo' )->warning(
333                'Failed to unserialize event {id}',
334                [
335                    'id' => $row->event_id
336                ]
337            );
338            return false;
339        }
340        $this->pageId = $row->event_page_id;
341        $this->deleted = $row->event_deleted;
342
343        if ( $row->event_agent_id ) {
344            $this->agent = User::newFromId( (int)$row->event_agent_id );
345        } elseif ( $row->event_agent_ip ) {
346            $this->agent = User::newFromName( (string)$row->event_agent_ip, false );
347        }
348
349        // Lazy load the title from getTitle() so that we can do a batch-load
350        if (
351            isset( $this->extra['page_title'] ) && isset( $this->extra['page_namespace'] )
352            && !$row->event_page_id
353        ) {
354            $this->title = Title::makeTitleSafe(
355                $this->extra['page_namespace'],
356                $this->extra['page_title']
357            );
358        }
359        if ( $row->event_page_id ) {
360            $titleCache = Services::getInstance()->getTitleLocalCache();
361            $titleCache->add( (int)$row->event_page_id );
362        }
363        if ( isset( $this->extra['revid'] ) && $this->extra['revid'] ) {
364            $revisionCache = Services::getInstance()->getRevisionLocalCache();
365            $revisionCache->add( $this->extra['revid'] );
366        }
367
368        return true;
369    }
370
371    /**
372     * Loads data from the database into this object, given the event ID.
373     * @param int $id Event ID
374     * @param bool $fromPrimary
375     * @return bool Whether it loaded successfully
376     */
377    public function loadFromID( $id, $fromPrimary = false ) {
378        $eventMapper = new EventMapper();
379        $event = $eventMapper->fetchById( $id, $fromPrimary );
380        if ( !$event ) {
381            return false;
382        }
383
384        // Copy over the attribute
385        $this->id = $event->id;
386        $this->type = $event->type;
387        $this->variant = $event->variant;
388        $this->extra = $event->extra;
389        $this->pageId = $event->pageId;
390        $this->agent = $event->agent;
391        $this->title = $event->title;
392        $this->deleted = $event->deleted;
393        // Don't overwrite timestamp if it exists already
394        if ( !$this->timestamp ) {
395            $this->timestamp = $event->timestamp;
396        }
397
398        return true;
399    }
400
401    /**
402     * Creates an Event from a row object
403     *
404     * @param stdClass $row row object from echo_event
405     * @return Event|false
406     */
407    public static function newFromRow( $row ) {
408        $obj = new Event();
409        return $obj->loadFromRow( $row )
410            ? $obj
411            : false;
412    }
413
414    /**
415     * Creates an Event from the database by ID
416     *
417     * @param int $id Event ID
418     * @return Event|false
419     */
420    public static function newFromID( $id ) {
421        $obj = new Event();
422        return $obj->loadFromID( $id )
423            ? $obj
424            : false;
425    }
426
427    /**
428     * Serialize the extra data for event
429     * @return string|null
430     */
431    public function serializeExtra() {
432        if ( is_array( $this->extra ) || is_object( $this->extra ) ) {
433            $extra = serialize( $this->extra );
434        } elseif ( $this->extra === null ) {
435            $extra = null;
436        } else {
437            $extra = serialize( [ $this->extra ] );
438        }
439
440        return $extra;
441    }
442
443    /**
444     * Determine if the current user is allowed to view a particular
445     * field of this revision, if it's marked as deleted.  When no
446     * revision is attached always returns true.
447     *
448     * @param int $field One of RevisionRecord::DELETED_TEXT,
449     *                              RevisionRecord::DELETED_COMMENT,
450     *                              RevisionRecord::DELETED_USER
451     * @param User $user User object to check
452     * @return bool
453     */
454    public function userCan( $field, User $user ) {
455        $revision = $this->getRevision();
456        // User is handled specially
457        if ( $field === RevisionRecord::DELETED_USER ) {
458            $agent = $this->getAgent();
459            if ( !$agent ) {
460                // No user associated, so they can see it.
461                return true;
462            }
463
464            if (
465                $revision
466                && $agent->getName() === $revision->getUser( RevisionRecord::RAW )->getName()
467            ) {
468                // If the agent and the revision user are the same, use rev_deleted
469                return $revision->audienceCan( $field, RevisionRecord::FOR_THIS_USER, $user );
470            } else {
471                // Use User::isHidden()
472                $permManager = MediaWikiServices::getInstance()->getPermissionManager();
473                return $permManager->userHasAnyRight( $user, 'viewsuppressed', 'hideuser' )
474                    || !$agent->isHidden();
475            }
476        } elseif ( $revision ) {
477            // A revision is set, use rev_deleted
478            return $revision->audienceCan( $field, RevisionRecord::FOR_THIS_USER, $user );
479        } else {
480            // Not a user, and there is no associated revision, so the user can see it
481            return true;
482        }
483    }
484
485    ## Accessors
486
487    /**
488     * @return int
489     */
490    public function getId() {
491        return $this->id;
492    }
493
494    /**
495     * @return string
496     */
497    public function getTimestamp() {
498        return $this->timestamp;
499    }
500
501    /**
502     * @return string
503     */
504    public function getType() {
505        return $this->type;
506    }
507
508    /**
509     * @return string|null
510     */
511    public function getVariant() {
512        return $this->variant;
513    }
514
515    /**
516     * @return array
517     */
518    public function getExtra() {
519        return $this->extra;
520    }
521
522    /**
523     * @param string $key
524     * @param mixed|null $default
525     * @return mixed|null
526     */
527    public function getExtraParam( $key, $default = null ) {
528        return $this->extra[$key] ?? $default;
529    }
530
531    /**
532     * @return User|null
533     */
534    public function getAgent() {
535        return $this->agent;
536    }
537
538    /**
539     * Check whether this event allows its agent to be notified.
540     *
541     * Notifying the agent is only allowed if the event's type allows it, or if the event extra
542     * explicitly specifies 'notifyAgent' => true.
543     *
544     * @return bool
545     */
546    public function canNotifyAgent() {
547        global $wgEchoNotifications;
548        $allowedInConfig = $wgEchoNotifications[$this->getType()]['canNotifyAgent'] ?? false;
549        $allowedInExtra = $this->getExtraParam( 'notifyAgent', false );
550        return $allowedInConfig || $allowedInExtra;
551    }
552
553    /**
554     * @param bool $fromPrimary
555     * @return null|Title
556     */
557    public function getTitle( $fromPrimary = false ) {
558        if ( $this->title ) {
559            return $this->title;
560        }
561        if ( $this->pageId ) {
562            $titleCache = Services::getInstance()->getTitleLocalCache();
563            $title = $titleCache->get( $this->pageId );
564            if ( $title ) {
565                $this->title = $title;
566                return $this->title;
567            }
568            $this->title = Title::newFromID( $this->pageId, $fromPrimary ? IDBAccessObject::READ_LATEST : 0 );
569            if ( $this->title ) {
570                return $this->title;
571            }
572        }
573        if ( isset( $this->extra['page_title'] ) && isset( $this->extra['page_namespace'] ) ) {
574            $this->title = Title::makeTitleSafe(
575                $this->extra['page_namespace'],
576                $this->extra['page_title']
577            );
578            return $this->title;
579        }
580        return null;
581    }
582
583    /**
584     * @return RevisionRecord|null
585     */
586    public function getRevision() {
587        if ( $this->revision ) {
588            return $this->revision;
589        }
590
591        if ( isset( $this->extra['revid'] ) ) {
592            $revisionCache = Services::getInstance()->getRevisionLocalCache();
593            $revision = $revisionCache->get( $this->extra['revid'] );
594            if ( $revision ) {
595                $this->revision = $revision;
596                return $this->revision;
597            }
598
599            $store = MediaWikiServices::getInstance()->getRevisionStore();
600            $this->revision = $store->getRevisionById( $this->extra['revid'] );
601            return $this->revision;
602        }
603
604        return null;
605    }
606
607    /**
608     * Get the category of the event type
609     * @return string
610     */
611    public function getCategory() {
612        return Services::getInstance()->getAttributeManager()->getNotificationCategory( $this->type );
613    }
614
615    /**
616     * Get the section of the event type
617     * @return string
618     */
619    public function getSection() {
620        return Services::getInstance()->getAttributeManager()->getNotificationSection( $this->type );
621    }
622
623    /**
624     * Determine whether an event can use the job queue, or should be immediate
625     * @return bool
626     */
627    public function getUseJobQueue() {
628        global $wgEchoNotifications;
629        if ( isset( $wgEchoNotifications[$this->type]['immediate'] ) ) {
630            return !(bool)$wgEchoNotifications[$this->type]['immediate'];
631        }
632
633        return true;
634    }
635
636    public function setType( $type ) {
637        $this->type = $type;
638    }
639
640    public function setVariant( $variant ) {
641        $this->variant = $variant;
642    }
643
644    public function setAgent( User $agent ) {
645        $this->agent = $agent;
646    }
647
648    public function setTitle( Title $title ) {
649        $this->title = $title;
650        $pageId = $title->getArticleID();
651        if ( $pageId ) {
652            $this->pageId = $pageId;
653        } else {
654            $this->extra['page_title'] = $title->getDBkey();
655            $this->extra['page_namespace'] = $title->getNamespace();
656        }
657    }
658
659    public function setExtra( $name, $value ) {
660        $this->extra[$name] = $value;
661    }
662
663    /**
664     * Get the message key of the primary or secondary link for a notification type.
665     *
666     * @param string $rank 'primary' or 'secondary'
667     * @return string i18n message key
668     */
669    public function getLinkMessage( $rank ) {
670        global $wgEchoNotifications;
671        $type = $this->getType();
672        return $wgEchoNotifications[$type][$rank . '-link']['message'] ?? '';
673    }
674
675    /**
676     * Get the link destination of the primary or secondary link for a notification type.
677     *
678     * @param string $rank 'primary' or 'secondary'
679     * @return string The link destination, e.g. 'agent'
680     */
681    public function getLinkDestination( $rank ) {
682        global $wgEchoNotifications;
683        $type = $this->getType();
684        return $wgEchoNotifications[$type][$rank . '-link']['destination'] ?? '';
685    }
686
687    /**
688     * @return string|null
689     */
690    public function getBundleHash() {
691        return $this->bundleHash;
692    }
693
694    /**
695     * @param string|null $hash
696     */
697    public function setBundleHash( $hash ) {
698        $this->bundleHash = $hash;
699    }
700
701    /**
702     * @return bool
703     */
704    public function isDeleted() {
705        return $this->deleted === 1;
706    }
707
708    public function setBundledEvents( array $events ) {
709        $this->bundledEvents = $events;
710    }
711
712    public function getBundledEvents() {
713        return $this->bundledEvents;
714    }
715
716    /**
717     * @inheritDoc
718     */
719    public function canBeBundled() {
720        return true;
721    }
722
723    /**
724     * @inheritDoc
725     */
726    public function getBundlingKey() {
727        return $this->getBundleHash();
728    }
729
730    /**
731     * @inheritDoc
732     */
733    public function setBundledElements( array $bundleables ) {
734        $this->setBundledEvents( $bundleables );
735    }
736
737    /**
738     * @inheritDoc
739     */
740    public function getSortingKey() {
741        return $this->getTimestamp();
742    }
743
744    /**
745     * Return the list of fields that should be selected to create
746     * a new event with Event::newFromRow
747     * @return string[]
748     */
749    public static function selectFields() {
750        return [
751            'event_id',
752            'event_type',
753            'event_variant',
754            'event_agent_id',
755            'event_agent_ip',
756            'event_extra',
757            'event_page_id',
758            'event_deleted',
759        ];
760    }
761
762}
763
764class_alias( Event::class, 'EchoEvent' );