Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
55.36% |
62 / 112 |
|
0.00% |
0 / 7 |
CRAP | |
0.00% |
0 / 1 |
CargoICalendarFormat | |
55.36% |
62 / 112 |
|
0.00% |
0 / 7 |
190.56 | |
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 | |
85.71% |
24 / 28 |
|
0.00% |
0 / 1 |
9.24 | |||
getEvent | |
92.68% |
38 / 41 |
|
0.00% |
0 / 1 |
14.08 | |||
text | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
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\MediaWikiServices; |
8 | |
9 | /** |
10 | * Handle the iCalendar export format. |
11 | * @since 2.6 |
12 | */ |
13 | class 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 | list( $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 | } |