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