Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
8.06% covered (danger)
8.06%
20 / 248
5.26% covered (danger)
5.26%
1 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventsListPager
8.06% covered (danger)
8.06%
20 / 248
5.26% covered (danger)
5.26%
1 / 19
2763.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 doExtraPreprocessing
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 getRow
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getLimitSelectList
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 isHeaderRowNeeded
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 formatRow
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
6
 getOrganizersText
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
42
 getIndexField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNavigationBar
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getMeetingTypeMsg
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 getYearFromTimestamp
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getMonthHeader
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDayFromTimestamp
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 offsetTimestamp
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSubqueryInfo
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 buildQueryInfo
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 getDateRangeCond
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getWikiList
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getWikiListWidget
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\Pager;
6
7use MediaWiki\Cache\LinkBatchFactory;
8use MediaWiki\Extension\CampaignEvents\Database\CampaignsDatabaseHelper;
9use MediaWiki\Extension\CampaignEvents\Event\EventRegistration;
10use MediaWiki\Extension\CampaignEvents\Event\Store\EventStore;
11use MediaWiki\Extension\CampaignEvents\Event\Store\EventWikisStore;
12use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup;
13use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsPageFactory;
14use MediaWiki\Extension\CampaignEvents\MWEntity\PageURLResolver;
15use MediaWiki\Extension\CampaignEvents\MWEntity\UserLinker;
16use MediaWiki\Extension\CampaignEvents\MWEntity\WikiLookup;
17use MediaWiki\Extension\CampaignEvents\Organizers\Organizer;
18use MediaWiki\Extension\CampaignEvents\Organizers\OrganizersStore;
19use MediaWiki\Extension\CampaignEvents\Organizers\Roles;
20use MediaWiki\Extension\CampaignEvents\Special\SpecialEventDetails;
21use MediaWiki\Extension\CampaignEvents\Widget\TextWithIconWidget;
22use MediaWiki\Html\Html;
23use MediaWiki\Pager\IndexPager;
24use MediaWiki\Pager\ReverseChronologicalPager;
25use MediaWiki\SpecialPage\SpecialPage;
26use MediaWiki\User\Options\UserOptionsLookup;
27use MediaWiki\Utils\MWTimestamp;
28use MediaWiki\WikiMap\WikiMap;
29use OOUI\Exception;
30use OOUI\HtmlSnippet;
31use OOUI\Tag;
32use stdClass;
33use UnexpectedValueException;
34use Wikimedia\Rdbms\IResultWrapper;
35use Wikimedia\Timestamp\TimestampException;
36
37class EventsListPager extends ReverseChronologicalPager {
38    use EventPagerTrait {
39        EventPagerTrait::getSubqueryInfo as getDefaultSubqueryInfo;
40    }
41
42    private const DISPLAYED_WIKI_COUNT = 3;
43    private UserLinker $userLinker;
44    private CampaignsPageFactory $campaignsPageFactory;
45    private PageURLResolver $pageURLResolver;
46    private OrganizersStore $organizerStore;
47    private LinkBatchFactory $linkBatchFactory;
48    private UserOptionsLookup $userOptionsLookup;
49    private CampaignsCentralUserLookup $centralUserLookup;
50    private WikiLookup $wikiLookup;
51    private EventWikisStore $eventWikisStore;
52
53    private string $search;
54    /** One of the EventRegistration::MEETING_TYPE_* constants */
55    private ?int $meetingType;
56    /** dbnames of the wikis chosen */
57    private array $filterWiki;
58    private string $startDate;
59    private string $endDate;
60    private bool $showOngoing;
61
62    private string $lastHeaderTimestamp;
63    /** @var array<int,Organizer|null> Maps event ID to the event creator, if available, else to null. */
64    private array $creators = [];
65    /**
66     * @var array<int,Organizer[]> Maps event ID to a list of additional event organizers,
67     * NOT including the event creator.
68     */
69    private array $extraOrganizers = [];
70    /** @var array<int,int> Maps event ID to the total number of organizers of that event. */
71    private array $organizerCounts = [];
72    /** @var array<int,string[]|true> Maps event ID to all wikis assigned to the event. */
73    private array $eventWikis = [];
74
75    public function __construct(
76        UserLinker $userLinker,
77        CampaignsPageFactory $pageFactory,
78        PageURLResolver $pageURLResolver,
79        OrganizersStore $organizerStore,
80        LinkBatchFactory $linkBatchFactory,
81        UserOptionsLookup $userOptionsLookup,
82        CampaignsDatabaseHelper $databaseHelper,
83        CampaignsCentralUserLookup $centralUserLookup,
84        WikiLookup $wikiLookup,
85        EventWikisStore $eventWikisStore,
86        string $search,
87        ?int $meetingType,
88        string $startDate,
89        string $endDate,
90        bool $showOngoing,
91        array $filterWiki
92    ) {
93        // Set the database before calling the parent constructor, otherwise it'll use the local one.
94        $this->mDb = $databaseHelper->getDBConnection( DB_REPLICA );
95        parent::__construct( $this->getContext(), $this->getLinkRenderer() );
96
97        $this->userLinker = $userLinker;
98        $this->campaignsPageFactory = $pageFactory;
99        $this->pageURLResolver = $pageURLResolver;
100        $this->organizerStore = $organizerStore;
101        $this->linkBatchFactory = $linkBatchFactory;
102        $this->userOptionsLookup = $userOptionsLookup;
103        $this->centralUserLookup = $centralUserLookup;
104
105        $this->search = $search;
106        $this->meetingType = $meetingType;
107        $this->startDate = $startDate;
108        $this->endDate = $endDate;
109        $this->showOngoing = $showOngoing;
110
111        $this->getDateRangeCond( $startDate, $endDate );
112        $this->mDefaultDirection = IndexPager::DIR_ASCENDING;
113        $this->lastHeaderTimestamp = '';
114        $this->filterWiki = $filterWiki;
115        $this->wikiLookup = $wikiLookup;
116        $this->eventWikisStore = $eventWikisStore;
117    }
118
119    /**
120     * @see EventPagerTrait::doExtraPreprocessing
121     */
122    private function doExtraPreprocessing( IResultWrapper $result ): void {
123        $eventIDs = [];
124        foreach ( $result as $row ) {
125            $eventIDs[] = (int)$row->event_id;
126        }
127        $result->seek( 0 );
128
129        $this->eventWikis = $this->eventWikisStore->getEventWikisMulti( $eventIDs );
130
131        $this->creators = $this->organizerStore->getEventCreators(
132            $eventIDs,
133            OrganizersStore::GET_CREATOR_EXCLUDE_DELETED
134        );
135        $this->extraOrganizers = $this->organizerStore->getOrganizersForEvents( $eventIDs, 2 );
136        $this->organizerCounts = $this->organizerStore->getOrganizerCountForEvents( $eventIDs );
137
138        $organizerUserIDsMap = [];
139        foreach ( $this->creators as $creator ) {
140            if ( $creator ) {
141                $organizerUserIDsMap[$creator->getUser()->getCentralID()] = null;
142            }
143        }
144        foreach ( $this->extraOrganizers as $eventExtraOrganizer ) {
145            foreach ( $eventExtraOrganizer as $organizer ) {
146                $organizerUserIDsMap[ $organizer->getUser()->getCentralID() ] = null;
147            }
148        }
149
150        // Run a single query to get all the organizer names at once, and also check for user page existence.
151        $organizerNames = $this->centralUserLookup->getNamesIncludingDeletedAndSuppressed( $organizerUserIDsMap );
152        $this->userLinker->preloadUserLinks( $organizerNames );
153    }
154
155    /**
156     * @inheritDoc
157     */
158    public function getRow( $row ): string {
159        $s = '';
160
161        $timestampField = $this->getTimestampField();
162        $timestamp = $row->$timestampField;
163        $closeList = $this->isHeaderRowNeeded( $timestamp );
164        if ( $closeList ) {
165            $s .= $this->getEndGroup();
166        }
167        if ( $this->isHeaderRowNeeded( $timestamp ) ) {
168            $s .= $this->getHeaderRow( $this->getMonthHeader( $timestamp ) );
169            $this->lastHeaderTimestamp = $timestamp;
170        }
171        $s .= $this->formatRow( $row );
172
173        return $s;
174    }
175
176    /**
177     * Copied from {@see TablePager::getLimitSelectList()}.
178     * XXX This should probably live elsewhere in core and be easier to reuse.
179     *
180     * @return array
181     */
182    public function getLimitSelectList() {
183        # Add the current limit from the query string
184        # to avoid that the limit is lost after clicking Go next time
185        if ( !in_array( $this->mLimit, $this->mLimitsShown, true ) ) {
186            $this->mLimitsShown[] = $this->mLimit;
187            sort( $this->mLimitsShown );
188        }
189        $ret = [];
190        foreach ( $this->mLimitsShown as $key => $value ) {
191            # The pair is either $index => $limit, in which case the $value
192            # will be numeric, or $limit => $text, in which case the $value
193            # will be a string.
194            if ( is_int( $value ) ) {
195                $limit = $value;
196                $text = $this->getLanguage()->formatNum( $limit );
197            } else {
198                $limit = $key;
199                $text = $value;
200            }
201            $ret[$text] = $limit;
202        }
203        return $ret;
204    }
205
206    protected function isHeaderRowNeeded( string $date ): bool {
207        if ( !$this->lastHeaderTimestamp ) {
208            return true;
209        }
210        $month = $this->getMonthHeader( $date );
211        $prevMonth = $this->getMonthHeader( $this->lastHeaderTimestamp );
212        $year = $this->getYearFromTimestamp( $date );
213        $prevYear = $this->getYearFromTimestamp( $this->lastHeaderTimestamp );
214        return $month !== $prevMonth || $year !== $prevYear;
215    }
216
217    /**
218     * @inheritDoc
219     */
220    public function formatRow( $row ) {
221        $htmlRow = ( new Tag( 'li' ) )
222            ->addClasses( [ 'ext-campaignevents-events-list-row' ] );
223        $page = $this->getEventPageFromRow( $row );
224        $pageUrlResolver = $this->pageURLResolver;
225        $timestampField = $this->getTimestampField();
226        $timestamp = $row->$timestampField;
227        $htmlRow->appendContent( ( new Tag() )
228            ->addClasses( [ 'ext-campaignevents-events-list-day' ] )
229            ->appendContent( $this->getDayFromTimestamp( $timestamp ) ) );
230        $detailContainer = ( new Tag() )
231            ->addClasses( [ 'ext-campaignevents-events-list-details' ] );
232        $eventPageLinkElement = ( new Tag( 'a' ) )
233            ->setAttributes( [
234                "href" => $pageUrlResolver->getUrl( $page ),
235                "class" => 'ext-campaignevents-events-list-link'
236            ] )
237            ->appendContent( $row->event_name );
238        $detailContainer->appendContent(
239            ( new Tag( 'h4' ) )->appendContent( $eventPageLinkElement )
240        );
241        $datesText = Html::element(
242            'strong',
243            [],
244            $this->msg(
245                'campaignevents-eventslist-date-separator',
246                $this->getLanguage()->userDate( $row->event_start_utc, $this->getUser() ),
247                $this->getLanguage()->userDate( $row->event_end_utc, $this->getUser() )
248            )->text()
249        );
250        $detailContainer->appendContent( new HtmlSnippet( Html::rawElement( 'div', [], $datesText ) ) );
251        $detailContainer->appendContent(
252            new TextWithIconWidget( [
253                'icon' => 'mapPin',
254                'content' => $this->msg( $this->getMeetingTypeMsg( $row ) )->text(),
255                'label' => $this->msg( 'campaignevents-eventslist-meeting-type-label' )->text(),
256                'icon_classes' => [ 'ext-campaignevents-events-list-icon' ],
257            ] )
258        );
259        if ( $this->eventWikis[$row->event_id] ) {
260            $detailContainer->appendContent(
261                $this->getWikiList( $row->event_id )
262            );
263        }
264        $detailContainer->appendContent(
265            new TextWithIconWidget( [
266                'icon' => 'userRights',
267                'content' => $this->getOrganizersText( $row ),
268                'label' => $this->msg( 'campaignevents-eventslist-organizer-label' )->text(),
269                'icon_classes' => [ 'ext-campaignevents-events-list-icon' ],
270                'classes' => [ 'ext-campaignevents-events-list-organizers' ],
271            ] )
272        );
273        return $htmlRow->appendContent( $detailContainer );
274    }
275
276    private function getOrganizersText( stdClass $row ): HtmlSnippet {
277        $eventID = (int)$row->event_id;
278        $organizersToShow = [];
279        $creator = $this->creators[$eventID];
280        if ( $creator ) {
281            $organizersToShow[] = $creator;
282        }
283        foreach ( $this->extraOrganizers[$eventID] as $organizer ) {
284            if ( !$organizer->hasRole( Roles::ROLE_CREATOR ) ) {
285                $organizersToShow[] = $organizer;
286            }
287            if ( count( $organizersToShow ) === 2 ) {
288                break;
289            }
290        }
291
292        $language = $this->getLanguage();
293        $organizerLinks = array_map(
294            fn ( Organizer $organizer ) => $this->userLinker->generateUserLinkWithFallback(
295                $organizer->getUser(),
296                $language->getCode()
297            ),
298            $organizersToShow
299        );
300
301        $organizerCount = $this->organizerCounts[$eventID];
302        if ( $organizerCount > 2 ) {
303            $organizerLinks[] = $this->getLinkRenderer()->makeKnownLink(
304                SpecialPage::getTitleFor( SpecialEventDetails::PAGE_NAME, (string)$eventID ),
305                $this->msg( 'campaignevents-eventslist-organizers-more' )
306                    ->numParams( $organizerCount - 2 )
307                    ->text()
308            );
309        }
310
311        return new HtmlSnippet( $language->listToText( $organizerLinks ) );
312    }
313
314    /**
315     * @inheritDoc
316     */
317    public function getIndexField() {
318        return [ [ 'event_start_utc', 'event_id' ] ];
319    }
320
321    public function getNavigationBar(): string {
322        if ( !$this->isNavigationBarShown() ) {
323            return '';
324        }
325
326        if ( $this->mNavigationBar !== null ) {
327            return $this->mNavigationBar;
328        }
329
330        $navBuilder = $this->getNavigationBuilder()
331            ->setPrevMsg( 'prevn' )
332            ->setNextMsg( 'nextn' )
333            ->setFirstMsg( 'page_first' )
334            ->setLastMsg( 'page_last' );
335
336        $this->mNavigationBar = $navBuilder->getHtml();
337
338        return $this->mNavigationBar;
339    }
340
341    /**
342     * @param stdClass $row
343     * @return string
344     */
345    private function getMeetingTypeMsg( stdClass $row ): string {
346        $meetingType = EventStore::getMeetingTypeFromDBVal( $row->event_meeting_type );
347        switch ( $meetingType ) {
348            case EventRegistration::MEETING_TYPE_IN_PERSON:
349                return 'campaignevents-eventslist-location-in-person';
350            case EventRegistration::MEETING_TYPE_ONLINE:
351                return 'campaignevents-eventslist-location-online';
352            case EventRegistration::MEETING_TYPE_ONLINE_AND_IN_PERSON:
353                return 'campaignevents-eventslist-location-online-and-in-person';
354            default:
355                throw new UnexpectedValueException( "Unexpected meeting type $meetingType" );
356        }
357    }
358
359    /**
360     * @param string $timestamp
361     * @return string
362     */
363    private function getYearFromTimestamp( string $timestamp ): string {
364        $timestamp = $this->offsetTimestamp( $timestamp );
365        return $this->getLanguage()->sprintfDate( 'Y', $timestamp );
366    }
367
368    /**
369     * @param string $timestamp
370     * @return string
371     */
372    private function getMonthHeader( string $timestamp ): string {
373        $timestamp = $this->offsetTimestamp( $timestamp );
374        // TODO This is not guaranteed to return the month name in a format suitable for section headings (e.g.,
375        // it may need to be capitalized).
376        return $this->getLanguage()->sprintfDate( 'F Y', $timestamp );
377    }
378
379    /**
380     * @param string $timestamp
381     * @return string
382     */
383    private function getDayFromTimestamp( string $timestamp ): string {
384        $timestamp = $this->offsetTimestamp( $timestamp );
385        return $this->getLanguage()->sprintfDate( 'j', $timestamp );
386    }
387
388    /**
389     * @param string $timestamp
390     * @return string
391     */
392    private function offsetTimestamp( string $timestamp ): string {
393        $offset = $this->userOptionsLookup
394            ->getOption( $this->getUser(), 'timecorrection' );
395
396        return $this->getLanguage()->userAdjust( $timestamp, $offset );
397    }
398
399    /**
400     * @return array
401     */
402    public function getSubqueryInfo(): array {
403        $query = $this->getDefaultSubqueryInfo();
404        if ( $this->meetingType !== null ) {
405            $query['conds']['event_meeting_type'] = EventStore::meetingTypeToDBVal( $this->meetingType );
406        }
407        if ( $this->filterWiki && $this->getConfig()->get( 'CampaignEventsEnableEventWikis' ) ) {
408            $query['tables'][] = 'ce_event_wikis';
409            array_push( $query['fields'], 'ceew_wiki', 'ceew_event_id' );
410            $query['join_conds']['ce_event_wikis'] = [
411                'JOIN',
412                [
413                    'event_id=ceew_event_id',
414                    'ceew_wiki' => [ ...$this->filterWiki, EventWikisStore::ALL_WIKIS_DB_VALUE ]
415                ]
416            ];
417        }
418        return $query;
419    }
420
421    /**
422     * @param int|null|string $offset
423     * @param int $limit
424     * @param bool $order
425     * @return array
426     */
427    public function buildQueryInfo( $offset, $limit, $order ): array {
428        [ $tables, $fields, $conds, $fname, $options, $join_conds ] = parent::buildQueryInfo( $offset, $limit, $order );
429        // this is required to set the offsets correctly
430        $offsets = $this->getDateRangeCond( $this->startDate, $this->endDate );
431        if ( $offsets ) {
432            [ $startOffset, $endOffset ] = $offsets;
433
434            if ( $this->showOngoing ) {
435                if ( $startOffset ) {
436                    $conds[] = $this->mDb->expr( 'event_end_utc', '>=', $startOffset );
437                }
438                if ( $endOffset ) {
439                    $conds[] = $this->mDb->expr( 'event_start_utc', '<=', $endOffset );
440                }
441            } else {
442                if ( $startOffset ) {
443                    $conds[] = $this->mDb->expr( 'event_start_utc', '>=', $startOffset );
444                }
445                if ( $endOffset ) {
446                    $conds[] = $this->mDb->expr( 'event_start_utc', '<=', $endOffset );
447                }
448            }
449        }
450        return [ $tables, $fields, $conds, $fname, $options, $join_conds ];
451    }
452
453    /**
454     * @param string $startDate
455     * @param string $endDate
456     * @return array|null
457     */
458    private function getDateRangeCond( string $startDate, string $endDate ): ?array {
459        try {
460            $startOffset = null;
461            if ( $startDate !== '' ) {
462                $startTimestamp = MWTimestamp::getInstance( $startDate );
463                $startOffset = $this->mDb->timestamp( $startTimestamp->getTimestamp() );
464            }
465
466            if ( $endDate !== '' ) {
467                $endTimestamp = MWTimestamp::getInstance( $endDate );
468                // Turned to use '<' for consistency with the parent class,
469                // add one second for compatibility with existing use cases
470                $endTimestamp->timestamp = $endTimestamp->timestamp->modify( '+1 second' );
471                $this->endOffset = $this->mDb->timestamp( $endTimestamp->getTimestamp() );
472
473                // populate existing variables for compatibility with parent
474                $this->mYear = (int)$endTimestamp->format( 'Y' );
475                $this->mMonth = (int)$endTimestamp->format( 'm' );
476                $this->mDay = (int)$endTimestamp->format( 'd' );
477            }
478
479            return [ $startOffset, $this->endOffset ];
480        } catch ( TimestampException $ex ) {
481            return null;
482        }
483    }
484
485    private function getWikiList( string $eventID ): TextWithIconWidget {
486        $eventWikis = $this->eventWikis[(int)$eventID];
487
488        if ( $eventWikis === EventRegistration::ALL_WIKIS ) {
489            $wikiName = [ $this->msg( 'campaignevents-eventslist-all-wikis' )->text() ];
490            return $this->getWikiListWidget( $eventID, $wikiName );
491        }
492        $currentWikiId = WikiMap::getCurrentWikiId();
493        $curWikiKey = array_search( $currentWikiId, $eventWikis, true );
494        if ( $curWikiKey !== false ) {
495            unset( $eventWikis[$curWikiKey] );
496            array_unshift( $eventWikis, $currentWikiId );
497        }
498        return $this->getWikiListWidget( $eventID, $eventWikis );
499    }
500
501    /**
502     * @param string $eventID
503     * @param string[] $eventWikis
504     * @return TextWithIconWidget
505     * @throws Exception
506     */
507    public function getWikiListWidget( string $eventID, array $eventWikis ): TextWithIconWidget {
508        $language = $this->getLanguage();
509        $displayedWikiNames = $this->wikiLookup->getLocalizedNames(
510            array_slice( $eventWikis, 0, self::DISPLAYED_WIKI_COUNT )
511        );
512        $wikiCount = count( $eventWikis );
513        $escapedWikiNames = [];
514        foreach ( $displayedWikiNames as $name ) {
515            $escapedWikiNames[] = htmlspecialchars( $name );
516        }
517
518        if ( $wikiCount > self::DISPLAYED_WIKI_COUNT ) {
519            $escapedWikiNames[] = $this->getLinkRenderer()->makeKnownLink(
520                SpecialPage::getTitleFor( SpecialEventDetails::PAGE_NAME, $eventID ),
521                $this->msg( 'campaignevents-eventslist-wikis-more' )
522                    ->numParams( $wikiCount - self::DISPLAYED_WIKI_COUNT )
523                    ->text()
524            );
525        }
526        return new TextWithIconWidget( [
527            'icon' => $this->wikiLookup->getWikiIcon( $eventWikis ),
528            'content' => new HtmlSnippet( $language->listToText( $escapedWikiNames ) ),
529            'label' => $this->msg( 'campaignevents-eventslist-wiki-label' )->text(),
530            'icon_classes' => [ 'ext-campaignevents-events-list-icon' ],
531        ] );
532    }
533}