Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
55.86% |
62 / 111 |
|
0.00% |
0 / 7 |
CRAP | |
0.00% |
0 / 1 |
CargoICalendarFormat | |
55.86% |
62 / 111 |
|
0.00% |
0 / 7 |
177.64 | |
0.00% |
0 / 1 |
allowedParameters | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
queryAndDisplay | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
90 | |||
getCalendar | |
83.33% |
25 / 30 |
|
0.00% |
0 / 1 |
10.46 | |||
getEvent | |
90.24% |
37 / 41 |
|
0.00% |
0 / 1 |
14.18 | |||
text | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
wrap | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
esc | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * @ingroup Cargo |
4 | * @file |
5 | */ |
6 | |
7 | use MediaWiki\Html\Html; |
8 | use MediaWiki\MediaWikiServices; |
9 | use MediaWiki\Title\Title; |
10 | |
11 | /** |
12 | * Handle the iCalendar export format. |
13 | * @since 2.6 |
14 | */ |
15 | class 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 | } |