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 | } |