Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.86% covered (warning)
55.86%
62 / 111
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
CargoICalendarFormat
55.86% covered (warning)
55.86%
62 / 111
0.00% covered (danger)
0.00%
0 / 7
177.64
0.00% covered (danger)
0.00%
0 / 1
 allowedParameters
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 queryAndDisplay
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
90
 getCalendar
83.33% covered (warning)
83.33%
25 / 30
0.00% covered (danger)
0.00%
0 / 1
10.46
 getEvent
90.24% covered (success)
90.24%
37 / 41
0.00% covered (danger)
0.00%
0 / 1
14.18
 text
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 wrap
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 esc
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @ingroup Cargo
4 * @file
5 */
6
7use MediaWiki\Html\Html;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\Title\Title;
10
11/**
12 * Handle the iCalendar export format.
13 * @since 2.6
14 */
15class CargoICalendarFormat extends CargoDeferredFormat {
16
17    public static function allowedParameters() {
18        return [
19            'link text' => [ 'type' => 'string' ],
20            'filename' => [ 'type' => 'string' ],
21            'icalendar name' => [ 'type' => 'string' ],
22            'icalendar description' => [ 'type' => 'string' ],
23        ];
24    }
25
26    /**
27     * @param CargoSQLQuery[] $sqlQueries
28     * @param string[] $displayParams Unused
29     * @param string[]|null $querySpecificParams Unused
30     * @return string An HTML link to Special:CargoExport with the required query string.
31     */
32    public function queryAndDisplay( $sqlQueries, $displayParams, $querySpecificParams = null ) {
33        $queryParams = $this->sqlQueriesToQueryParams( $sqlQueries );
34        $queryParams['format'] = 'icalendar';
35        // Calendar name.
36        if ( isset( $displayParams['icalendar name'] ) && $displayParams['icalendar name'] ) {
37            $queryParams['icalendar name'] = $displayParams['icalendar name'];
38        }
39        // Calendar description.
40        if ( isset( $displayParams['icalendar description'] )
41            && $displayParams['icalendar description']
42        ) {
43            $queryParams['icalendar description'] = $displayParams['icalendar description'];
44        }
45        // Filename.
46        if ( isset( $displayParams['filename'] ) && $displayParams['filename'] ) {
47            $queryParams['filename'] = $displayParams['filename'];
48        }
49        // Link.
50        if ( isset( $displayParams['link text'] ) && $displayParams['link text'] ) {
51            $linkText = $displayParams['link text'];
52        } else {
53            $linkText = wfMessage( 'cargo-viewicalendar' )->parse();
54        }
55        $export = SpecialPage::getTitleFor( 'CargoExport' );
56        return Html::element( 'a', [ 'href' => $export->getFullURL( $queryParams ) ], $linkText );
57    }
58
59    /**
60     * Get the iCalendar format output.
61     * @param WebRequest $request
62     * @param CargoSQLQuery[] $sqlQueries
63     * @return string
64     */
65    public function getCalendar( WebRequest $request, $sqlQueries ) {
66        $name = $request->getText( 'icalendar_name', 'Calendar' );
67        $desc = $request->getText( 'icalendar_description', false );
68        // Merge, remove empty lines, and re-index the lines of the calendar.
69        $calLines = array_values( array_filter( array_merge(
70            [
71                'BEGIN:VCALENDAR',
72                'VERSION:2.0',
73                'PRODID:mediawiki/cargo',
74            ],
75            $this->text( 'NAME', $name ),
76            $this->text( 'X-WR-CALNAME', $name ),
77            $desc ? $this->text( 'DESCRIPTION', $desc ) : []
78        ) ) );
79        foreach ( $sqlQueries as $sqlQuery ) {
80            [ $startDateField, $endDateField ] = $sqlQuery->getMainStartAndEndDateFields();
81            // @todo - get rid of this "if" check; right now it's only needed
82            // to pass validation, for some strange reason.
83            if ( $startDateField == null ) {
84                $startDateField = 'start';
85            }
86            $queryResults = $sqlQuery->run();
87            if ( count( $queryResults ) === 0 ) {
88                continue;
89            }
90            $nameField = '_pageName';
91            // If there is no _pageName field, take the first non-date field as the event name.
92            if ( !array_key_exists( '_pageName', $queryResults[0] ) ) {
93                foreach ( array_keys( $queryResults[0] ) as $resField ) {
94                    if ( ( $resField !== $startDateField ) && ( $resField !== $endDateField ) ) {
95                        $nameField = $resField;
96                        break;
97                    }
98                }
99            }
100            foreach ( $queryResults as $result ) {
101                $eventLines = $this->getEvent( $result, $startDateField, $endDateField, $nameField );
102                $calLines = array_merge( $calLines, $eventLines );
103            }
104        }
105        $calLines[] = 'END:VCALENDAR';
106        return implode( "\r\n", $calLines );
107    }
108
109    /**
110     * Get the lines of an event.
111     * @param string[] $result
112     * @return string[]
113     */
114    public function getEvent( $result, $startDateField, $endDateField, $nameField ) {
115        $title = Title::newFromText( $result[$nameField] );
116        // Only re-query the Page if its ID or modification date are not included in the original query.
117        if ( !isset( $result['_pageID'] ) || !isset( $result['_modificationDate'] ) ) {
118            $page = CargoUtils::makeWikiPage( $title );
119        }
120        $pageId = $result['_pageID'] ?? $page->getId();
121        $permalink = SpecialPage::getTitleFor( 'Redirect', 'page/' . $pageId, $result['_ID'] ?? '' );
122        $uid = $permalink->getCanonicalURL();
123        // Page values are stored in the wiki's timezone.
124        $wikiTimezone = MediaWikiServices::getInstance()->getMainConfig()->get( 'Localtimezone' );
125        if ( isset( $result[$startDateField] ) ) {
126            $startDateTime = new DateTime( $result[$startDateField], new DateTimeZone( $wikiTimezone ) );
127            $start = wfTimestamp( TS_ISO_8601_BASIC, $startDateTime->getTimestamp() );
128        } else {
129            $start = false;
130        }
131        if ( $endDateField !== null && isset( $result[$endDateField] ) ) {
132            $endDateTime = new DateTime( $result[$endDateField], new DateTimeZone( $wikiTimezone ) );
133            $end = wfTimestamp( TS_ISO_8601_BASIC, $endDateTime->getTimestamp() );
134        } else {
135            $end = false;
136        }
137
138        // Modification date is stored in UTC.
139        $dtstamp = isset( $result['_modificationDate'] )
140            ? wfTimestamp( TS_ISO_8601_BASIC, $result['_modificationDate'] )
141            : MWTimestamp::convert( TS_ISO_8601_BASIC, $page->getTimestamp() );
142        $desc = false;
143        if ( isset( $result['description'] ) && $result['description'] ) {
144            $desc = $result['description'];
145        }
146        $location = false;
147        if ( isset( $result['location'] ) && $result['location'] ) {
148            $location = $result['location'];
149        }
150        // Merge, remove empty lines, and re-index the lines of the event.
151        return array_values( array_filter( array_merge(
152            [
153                'BEGIN:VEVENT',
154                'UID:' . $uid,
155                'DTSTAMP:' . $dtstamp,
156            ],
157            $this->text( 'SUMMARY', $title->getText() ),
158            [
159                'DTSTART:' . $start,
160                $end ? 'DTEND:' . $end : '',
161            ],
162            $desc ? $this->text( 'DESCRIPTION', $desc ) : [],
163            $location ? $this->text( 'LOCATION', $location ) : [],
164            [
165                'END:VEVENT',
166            ]
167        ) ) );
168    }
169
170    /**
171     * Get the lines of a text property (wrapped and escaped).
172     * @param string $prop The text property name.
173     * @param string $str The value of the property.
174     * @return string[]
175     */
176    public function text( $prop, $str ) {
177        $lang = CargoUtils::getContentLang()->getHtmlCode();
178        // Make sure the language conforms to RFC5646.
179        $langProp = '';
180        if ( LanguageCode::isWellFormedLanguageTag( $lang ) ) {
181            $langProp = ";LANGUAGE=$lang";
182        }
183        return $this->wrap( "$prop$langProp:" . $this->esc( $str ) );
184    }
185
186    /**
187     * Wrap a line into an array of strings with max length 75 bytes.
188     *
189     * Kudos to  spatie/icalendar-generator, MIT license.
190     * @link https://github.com/spatie/icalendar-generator/blob/00346196cf526de2ae3e4ccc562294a59a27b5b2/src/Builders/ComponentBuilder.php#L76..L92
191     *
192     * @param string $line
193     * @return string[]
194     */
195    public function wrap( $line ) {
196        $chippedLines = [];
197        while ( strlen( $line ) > 0 ) {
198            if ( strlen( $line ) > 75 ) {
199                $chippedLines[] = mb_strcut( $line, 0, 75, 'utf-8' );
200                $line = ' ' . mb_strcut( $line, 75, strlen( $line ), 'utf-8' );
201            } else {
202                $chippedLines[] = $line;
203                break;
204            }
205        }
206        return $chippedLines;
207    }
208
209    /**
210     * Escape a string according to RFC5545 (backslashes, semicolons, commas, and newlines).
211     * @link https://tools.ietf.org/html/rfc5545#section-3.3.11
212     * @param string $str
213     * @return string
214     */
215    public function esc( $str ) {
216        $replacements = [
217            '\\' => '\\\\',
218            ';' => '\\;',
219            ',' => '\\,',
220            "\n" => '\\n',
221        ];
222        return str_replace( array_keys( $replacements ), $replacements, $str );
223    }
224}