1<?php 2/** 3 * Plugin iCalEvents: Renders an iCalendar file, e.g., as a table. 4 * 5 * Copyright (C) 2010-2012, 2015-2016 6 * Tim Ruffing, Robert Rackl, Elan Ruusamäe, Jannes Drost-Tenfelde 7 * 8 * This file is part of the DokuWiki iCalEvents plugin. 9 * 10 * The DokuWiki iCalEvents plugin program is free software: 11 * you can redistribute it and/or modify it under the terms of the 12 * GNU General Public License version 2 as published by the Free 13 * Software Foundation. 14 * 15 * This program is distributed in the hope that it will be useful, 16 * but WITHOUT ANY WARRANTY; without even the implied warranty of 17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 * GNU General Public License for more details. 19 * 20 * You should have received a copy of the GNU General Public License 21 * version 2 along with the DokuWiki iCalEvents plugin program. If 22 * not, see <http://www.gnu.org/licenses/gpl-2.0.html>. 23 * 24 * @license https://www.gnu.org/licenses/gpl-2.0.html GPL2 25 * @author Tim Ruffing <tim@timruffing.de> 26 * @author Robert Rackl <wiki@doogie.de> 27 * @author Elan Ruusamäe <glen@delfi.ee> 28 * @author Jannes Drost-Tenfelde <info@drost-tenfelde.de> 29 * 30 */ 31 32if (!defined('DOKU_INC')) 33 die(); 34 35if (!defined('DOKU_PLUGIN')) 36 define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/'); 37 38use Sabre\VObject; 39 40require_once DOKU_PLUGIN . 'syntax.php'; 41require_once __DIR__ . '/vendor/autoload.php'; 42 43class syntax_plugin_icalevents extends syntax_plugin_icalevents_base { 44 function __construct() { 45 // Unpredictable (not in a crypto sense) nonce to recognize our own 46 // strings, e.g., <nowiki> tags that we have inserted 47 $this->nonce = mt_rand(); 48 $this->localTimezone = new DateTimeZone(date_default_timezone_get()); 49 } 50 51 /** 52 * Parse parameters from the {{iCalEvents>...}} tag. 53 * @return array an array that will be passed to the render function 54 */ 55 function handle($match, $state, $pos, Doku_Handler $handler) { 56 // strip {{iCalEvents> or {{iCalendar from start and strip }} from end 57 $match = substr($match, strpos($match, '>') + 1, -2); 58 59 list($source, $flagStr) = explode('#', $match, 2); 60 61 // parse_str urldecodes valid percent-encoded byte, e.g., %dd. 62 // This is problematic, because the tformat and dformat parameters 63 // are intended to be parsed by strftime(), for which % is a 64 // special char. That is, a string %dd would not be interpreted 65 // as %d followed by a literal d. 66 // We ignore that problem, because it does not seem likely to hit 67 // a valid percecent encoding (% followed by two hex digits) in 68 // practice. In that case, % must be encoded as %25. 69 parse_str($flagStr, $params); 70 71 // maxNumberOfEntries was called numberOfEntries earlier. 72 // We support both versions for backwards compatibility. 73 $maxNumberOfEntries = (int) ($params['maxNumberOfEntries'] ?: ($params['numberOfEntries'] ?: PHP_INT_MAX)); 74 75 $showEndDates = filter_var($params['showEndDates'], FILTER_VALIDATE_BOOLEAN); 76 77 if ($params['showAs']) { 78 $showAs = $params['showAs']; 79 } else { 80 // BackwardEvent compatibility of v1.3 or earlier 81 if (filter_var($params['showAsList'], FILTER_VALIDATE_BOOLEAN)) { 82 $showAs = 'list'; 83 } else { 84 $showAs = 'default'; 85 } 86 } 87 88 // Get the template 89 $template = $this->getConf('template:' . $showAs); 90 if (!isset($template) || $template == '') { 91 $template = $this->getConf('default'); 92 } 93 94 // Sorting 95 switch (mb_strtolower($params['sort'])) { 96 case 'desc': 97 $order = -1; 98 break; 99 case 'off': 100 $order = 0; 101 break; 102 default: 103 $order = 1; 104 } 105 106 $fromString = $params['from']; 107 108 // Handle deprecated previewDays parameter 109 if (isset($params['previewDays']) && !isset($params['to'])) { 110 $toString = '+' . $params['previewDays'] . ' days'; 111 } else { 112 $toString = $params['to']; 113 } 114 115 if ($toString) { 116 $hasRelativeRange = static::isRelativeDateTimeString($fromString) 117 || static::isRelativeDateTimeString($toString); 118 } else { 119 $toString = $toString ?: '+30 days'; 120 $hasRelativeRange = true; 121 } 122 123 return array( 124 $source, 125 $fromString, 126 $toString, 127 $hasRelativeRange, 128 $maxNumberOfEntries, 129 $showEndDates, 130 $template, 131 $order, 132 hsc($params['dformat']), 133 hsc($params['tformat']) 134 ); 135 } 136 137 function render($mode, Doku_Renderer $renderer, $data) { 138 list( 139 $source, 140 $fromString, 141 $toString, 142 $hasRelativeRange, 143 $maxNumberOfEntries, 144 $showEndDates, 145 $template, 146 $order, 147 $dformat, 148 $tformat 149 ) = $data; 150 151 try { 152 $from = new DateTime($fromString); 153 $to = new DateTime($toString); 154 } catch (Exception $e) { 155 $renderer->doc .= static::ERROR_PREFIX . 'invalid date/time string: '. $e->getMessage() . '.'; 156 return false; 157 } 158 159 try { 160 $content = static::readSource($source); 161 } catch (Exception $e) { 162 $renderer->doc .= static::ERROR_PREFIX . $e->getMessage() . ' '; 163 return false; 164 } 165 166 // SECURITY 167 // Disable caching for rendered local (media) files because 168 // a user without read permission for the local file could read 169 // the cached document. 170 // Also disable caching if the rendered result depends on the 171 // current time, i.e., if the time range to display is relative. 172 if (static::isLocalFile($source) || $hasRelativeRange) { 173 $renderer->info['cache'] = false; 174 } 175 176 try { 177 $ical = VObject\Reader::read($content, VObject\Reader::OPTION_FORGIVING); 178 } catch (Exception $e) { 179 $renderer->doc .= static::ERROR_PREFIX . 'invalid iCalendar input. '; 180 return false; 181 } 182 183 // Export mode 184 if ($mode == 'icalevents') { 185 $uid = rawurldecode($_GET['uid']); 186 $recurrenceId = rawurldecode($_GET['recurrence-id']); 187 188 // Make sure the sub-event is in the expanded calendar. 189 // Also, there is no need to expand more. 190 if ($dtRecurrence = DateTimeImmutable::createFromFormat('Ymd', $recurrenceId)) { 191 try { 192 // +/- 1 day to avoid time zone weirdness 193 $ical = $ical->expand($dtRecurrence->modify('-1 day'), $dtRecurrence->modify('+1 day')); 194 } catch (Exception $e) { 195 $renderer->doc .= static::ERROR_PREFIX . 'Unable to expand recurrent events for export.'; 196 return false; 197 } 198 } 199 200 $comp = array_shift(array_filter($ical->getByUid($uid), 201 function($event) use ($recurrenceId) { 202 return ((string) $event->{'RECURRENCE-ID'}) === $recurrenceId; 203 } 204 )); 205 if ($comp) { 206 $renderer->doc .= $comp->serialize(); 207 $eid = $uid . ($recurrenceId ? '-' . $recurrenceId : ''); 208 $renderer->setEventId($eid); 209 } 210 return (bool) $comp; 211 } else { 212 // If no date/time format is requested, fall back to plugin 213 // configuration ('dformat' and 'tformat'), and then to a 214 // a value based on DokuWiki's defaults. 215 // Note: We don't fall back to DokuWiki's global dformat, because it contains 216 // date AND time, and there is no global tformat. 217 $dateFormat = $dformat ?: $this->getConf('dformat') ?: '%Y/%m/%d'; 218 $timeFormat = $tformat ?: $this->getConf('tformat') ?: '%H:%M'; 219 220 try { 221 $vevent = $ical->expand($from, $to)->VEVENT; 222 // We need an instance of ArrayIterator, which supports sorting. 223 } catch (Exception $e) { 224 $renderer->doc .= static::ERROR_PREFIX . 'unable to expand recurrent events. '; 225 return false; 226 } 227 228 if ($vevent === null) { 229 // No events, nothing to do. 230 return true; 231 } 232 233 $events = $vevent->getIterator(); 234 235 // Sort if desired 236 if ($order != 0) { 237 $events->uasort( 238 function(&$e1, &$e2) use ($order) { 239 $diff = $e1->DTSTART->getDateTime($this->localTimezone)->getTimestamp() 240 - $e2->DTSTART->getDateTime($this->localTimezone)->getTimestamp(); 241 return $order * $diff; 242 } 243 ); 244 } 245 246 // Loop over events and render template for each one. 247 $dokuwikiOutput = ''; 248 $i = 0; 249 foreach ($events as &$event) { 250 if ($i++ >= $maxNumberOfEntries) { 251 break; 252 } 253 254 list($output, $summaryLinks[]) 255 = $this->renderEvent($mode, $renderer, $event, $template, $dateFormat, $timeFormat); 256 $dokuwikiOutput .= $output; 257 } 258 259 // Replace {summary_link}s by placeholders containing our nonce and 260 // wrap them into <nowiki> to ensure that the DokuWiki renderer won't touch it. 261 $summaryLinkToken= '{summary_link:' . $this->nonce . '}'; 262 $rep = $this->nowikiStart() . $summaryLinkToken . $this->nowikiEnd(); 263 $dokuwikiOutput = str_replace('{summary_link}', $rep, $dokuwikiOutput); 264 265 // Translate DokuWiki code into instructions. 266 $instructions = p_get_instructions($dokuwikiOutput); 267 268 // Some <nowiki> tags introduced by us may not haven been parsed 269 // because <nowiki> is ignored in certain syntax elements, e.g., headings. 270 // Remove these remaining <nowiki> tags. We find them reliably because 271 // they contain our nonce. 272 static::str_remove_deep(array($this->nowikiStart(), $this->nowikiEnd(), $this->magicString()), $instructions); 273 274 // Remove document_start and document_end instructions. 275 // This avoids a reset of the TOC for example. 276 array_shift($instructions); 277 array_pop($instructions); 278 279 foreach ($instructions as &$ins) { 280 foreach ($ins[1] as &$text) { 281 $text = str_replace(array($this->nowikiStart(), $this->nowikiEnd(), $this->magicString()), '', $text); 282 } 283 // Execute the callback against the Renderer, i.e., render the instructions. 284 if (method_exists($renderer, $ins[0])){ 285 call_user_func_array(array(&$renderer, $ins[0]), $ins[1] ?: array()); 286 } 287 } 288 289 if ($mode == 'xhtml') { 290 // Replace summary link placeholders with the entries of $summaryLinks. 291 // We handle it here, because it is raw HTML generated by us, not DokuWiki syntax. 292 $linksPerEvent = substr_count($template, '{summary_link}'); 293 $renderer->doc = static::str_replace_array($summaryLinkToken , $summaryLinks, $linksPerEvent, $renderer->doc); 294 } 295 return true; 296 } 297 } 298 299 /** 300 * Render a VEVENT 301 * 302 * @param string $mode rendering mode, e.g., 'xhtml' 303 * @param Doku_Renderer $renderer 304 * @param Sabre\VObject\Component\VEvent $event 305 * @param string $template 306 * @param string $dateFormat 307 * @param string $timeFormat 308 * @return string[] an array containing the modified template at first position 309 * and the formatted summary link at second position 310 */ 311 function renderEvent($mode, Doku_Renderer $renderer, $event, $template, $dateFormat, $timeFormat) { 312 // {description} 313 $template = str_replace('{description}', $this->textAsWiki($event->DESCRIPTION), $template); 314 315 // {summary} 316 $summary = $this->textAsWiki($event->SUMMARY); 317 $template = str_replace('{summary}', $summary, $template); 318 319 // See if a location was set 320 $location = $event->LOCATION; 321 if ($location != '') { 322 // {location} 323 $template = str_replace('{location}', $location, $template); 324 325 // {location_link} 326 $locationUrl = $this->getLocationUrl($location); 327 328 $locationLink = $locationUrl ? ('[[' . $locationUrl . '|' . $location . ']]') : $location; 329 $template = str_replace('{location_link}', $locationLink, $template); 330 } else { 331 // {location} 332 $template = str_replace('{location}', 'Unknown', $template); 333 // {location_link} 334 $template = str_replace('{location_link}', 'Unknown', $template); 335 } 336 337 $dt = $this->handleDatetime($event, $dateFormat, $timeFormat); 338 339 $startString = $dt['start']['datestring'] . ' ' . $dt['start']['timestring']; 340 $endString = ''; 341 if ($dt['end']['datestring'] != $dt['start']['datestring'] || $showEndDates) { 342 $endString .= $dt['end']['datestring'] . ' '; 343 } 344 $endString .= $dt['end']['timestring']; 345 // Add dash only if there is end date or time 346 $whenString = $startString . ($endString ? ' - ' : '') . $endString; 347 348 // {date} 349 $template = str_replace('{date}', $whenString, $template); 350 $template .= "\n"; 351 352 if ($mode == 'xhtml') { 353 // Prepare summary link 354 $link = array(); 355 $link['class'] = 'mediafile plugin-icalevents-export'; 356 $link['pre'] = ''; 357 $link['suf'] = ''; 358 $link['more'] = 'rel="nofollow"'; 359 $link['target'] = ''; 360 $link['title'] = hsc($event->SUMMARY); 361 $getParams = array( 362 'uid' => rawurlencode($event->UID), 363 'recurrence-id' => rawurlencode($event->{'RECURRENCE-ID'}) 364 ); 365 $link['url'] = exportlink($GLOBALS['ID'], 'icalevents', $getParams); 366 $link['name'] = nl2br($link['title']); 367 368 // We add a span to be able to "inherit" from it CSS 369 $summaryLink = '<span>' . $renderer->_formatLink($link) . '</span>'; 370 } else { 371 $template = str_replace('{summary_link}', $event->SUMMARY, $template); 372 $summaryLink = ''; 373 } 374 return array($template, $summaryLink); 375 } 376 377 /** 378 * Read an iCalendar file from a remote server via HTTP(S) or from a local media file 379 * 380 * @param string $source URL or media id 381 * @return string 382 */ 383 static function readSource($source) { 384 if (static::isLocalFile($source)) { 385 $path = mediaFN($source); 386 $contents = @file_get_contents($path); 387 if ($contents === false) { 388 $error = 'could not read media file ' . hsc($source) . '. '; 389 throw new Exception($error); 390 } 391 return $contents; 392 } else { 393 $http = new DokuHTTPClient(); 394 if (!$http->get($source)) { 395 $error = 'could not get ' . hsc($source) . ', HTTP status ' . $http->status . '. '; 396 throw new Exception($error); 397 } 398 return $http->resp_body; 399 } 400 } 401 402 /** 403 * Determines whether a source is a local (media) file 404 */ 405 static function isLocalFile($source) { 406 // This does not work for protocol-relative URLs 407 // but they are not supported by DokuHTTPClient anyway. 408 return !preg_match('#^https?://#i', $source); 409 } 410 411 /** 412 * Computes date and time string of an event returned by vobject/sabre 413 */ 414 function handleDatetime($event, $dateFormat, $timeFormat) { 415 foreach (array('start', 'end') as $which) { 416 $dtSabre = $event->{'DT' . strtoupper($which)}; 417 $dtImmutable = $dtSabre->getDateTime($this->localTimezone); 418 $dt = &$res[$which]; 419 // Correct end date for all-day events, which formally end 420 // on 00:00 of the following day. 421 if (!$dtSabre->hasTime() && $which == 'end') { 422 $dtImmutable = $dtImmutable->modify('-1 day'); 423 } 424 $dt['datestring'] = strftime($dateFormat, $dtImmutable->getTimestamp()); 425 $dt['timestring'] = $dtSabre->hasTime() ? strftime($timeFormat, $dtImmutable->getTimestamp()) : ''; 426 } 427 return $res; 428 } 429 430 /** 431 * Determines whether a string as accepted by strtotime() 432 * is relative to a base timestamp (second argument of 433 * strtotime()). 434 */ 435 static function isRelativeDateTimeString($str) { 436 // $str is relative iff it yields the same timestamp 437 // now and more than one year ago. 438 // Reason: A year is the largest unit that is understood 439 // by strtotime(). 440 $relNow = strtotime($str); 441 $relTwoY = strtotime($str, time() - 2 * 365 * 24 * 3600); 442 return $relNow != $relTwoY; 443 } 444 445 /** 446 * Replaces all occurrences of $needle in $haystack by the elements of $replace. 447 * 448 * Each element of $replace is used $count times, i.e., the first $count occurrences of $needle in 449 * $haystack are replaced by $replace[0], the next $count occurrences by $replace[1], and so on. 450 * If $count is 0, then $haystack is returned without modification. 451 * 452 * @param string $needle substring to replace 453 * @param string[] $replace a numerically indexed array of substitutions for $needle 454 * @param int $count number of $needles to be replaced by the same element of $replace 455 * @param string $haystack string to be searched 456 * @return string $haystack with the substitution applied 457 */ 458 static function str_replace_array($needle, $replace, $count, $haystack) { 459 if ($count <= 0) { 460 return $haystack; 461 } 462 $haystackArray = explode($needle, $haystack); 463 $res = ''; 464 foreach ($haystackArray as $i => $piece) { 465 $res .= $piece; 466 // "(int) ($i / $count)" simulates integer division. 467 $replaceIndex = (int) ($i / $count); 468 // Note that $replaceIndex will be out of bounds in $replace for the last loop iteration. 469 // In that case, the array access yields NULL, which is interpreted as the empty string. 470 // This is what we need, because there was no $needle after the last $piece. 471 $res .= $replace[$replaceIndex]; 472 } 473 return $res; 474 } 475 476 /** 477 * Removes all occurrences of $needle in all strings in the array $haystack recursively. 478 * 479 * @param string $needle substring or array of substrings to remove 480 * @param array $haystack array to searched 481 */ 482 static function str_remove_deep($needle, &$haystack) { 483 array_walk_recursive($haystack, 484 function (&$h, &$k) use ($needle) { 485 $h = str_replace($needle, '', $h); 486 }); 487 } 488 489 /** 490 * Replaces line breaks by DokuWiki's \\ line breaks and inserts 491 * <nowiki> tags. 492 * 493 * @param string $text 494 * @return string 495 */ 496 function textAsWiki($text) { 497 // First, remove existing </nowiki> end tags. (We display events that contain '</nowiki>' 498 // incorrectly but this should not be a problem in practice.) 499 // Second, replace line breaks by DokuWiki line breaks. 500 $needle = array('</nowiki>', "\n"); 501 $haystack = array('', $this->nowikiEnd() . '\\\\ '. $this->nowikiStart()); 502 $text = str_ireplace($needle, $haystack, $text); 503 return $this->nowikiStart() . $text . $this->nowikiEnd(); 504 } 505 506 function magicString() { 507 return '{' . $this->nonce .' magiccc}'; 508 } 509 510 function nowikiStart() { 511 return '<nowiki>' . $this->magicString(); 512 } 513 514 function nowikiEnd() { 515 return $this->magicString() . '</nowiki>'; 516 } 517 518 function getLocationUrl($location) { 519 // Some map providers don't like line break characters. 520 $location = urlencode(str_replace("\n", ' ', $location)); 521 522 $prefix = $this->getConf('customLocationUrlPrefix') ?: $this->getConf('locationUrlPrefix'); 523 return ($prefix != '') ? ($prefix . $location) : false; 524 } 525} 526