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