1*a1a3b679SAndreas Boehler<?php 2*a1a3b679SAndreas Boehler 3*a1a3b679SAndreas Boehlernamespace Sabre\CalDAV; 4*a1a3b679SAndreas Boehler 5*a1a3b679SAndreas Boehleruse DateTimeZone; 6*a1a3b679SAndreas Boehleruse Sabre\DAV; 7*a1a3b679SAndreas Boehleruse Sabre\VObject; 8*a1a3b679SAndreas Boehleruse Sabre\HTTP\RequestInterface; 9*a1a3b679SAndreas Boehleruse Sabre\HTTP\ResponseInterface; 10*a1a3b679SAndreas Boehleruse Sabre\DAV\Exception\BadRequest; 11*a1a3b679SAndreas Boehleruse DateTime; 12*a1a3b679SAndreas Boehler 13*a1a3b679SAndreas Boehler/** 14*a1a3b679SAndreas Boehler * ICS Exporter 15*a1a3b679SAndreas Boehler * 16*a1a3b679SAndreas Boehler * This plugin adds the ability to export entire calendars as .ics files. 17*a1a3b679SAndreas Boehler * This is useful for clients that don't support CalDAV yet. They often do 18*a1a3b679SAndreas Boehler * support ics files. 19*a1a3b679SAndreas Boehler * 20*a1a3b679SAndreas Boehler * To use this, point a http client to a caldav calendar, and add ?expand to 21*a1a3b679SAndreas Boehler * the url. 22*a1a3b679SAndreas Boehler * 23*a1a3b679SAndreas Boehler * Further options that can be added to the url: 24*a1a3b679SAndreas Boehler * start=123456789 - Only return events after the given unix timestamp 25*a1a3b679SAndreas Boehler * end=123245679 - Only return events from before the given unix timestamp 26*a1a3b679SAndreas Boehler * expand=1 - Strip timezone information and expand recurring events. 27*a1a3b679SAndreas Boehler * If you'd like to expand, you _must_ also specify start 28*a1a3b679SAndreas Boehler * and end. 29*a1a3b679SAndreas Boehler * 30*a1a3b679SAndreas Boehler * By default this plugin returns data in the text/calendar format (iCalendar 31*a1a3b679SAndreas Boehler * 2.0). If you'd like to receive jCal data instead, you can use an Accept 32*a1a3b679SAndreas Boehler * header: 33*a1a3b679SAndreas Boehler * 34*a1a3b679SAndreas Boehler * Accept: application/calendar+json 35*a1a3b679SAndreas Boehler * 36*a1a3b679SAndreas Boehler * Alternatively, you can also specify this in the url using 37*a1a3b679SAndreas Boehler * accept=application/calendar+json, or accept=jcal for short. If the url 38*a1a3b679SAndreas Boehler * parameter and Accept header is specified, the url parameter wins. 39*a1a3b679SAndreas Boehler * 40*a1a3b679SAndreas Boehler * Note that specifying a start or end data implies that only events will be 41*a1a3b679SAndreas Boehler * returned. VTODO and VJOURNAL will be stripped. 42*a1a3b679SAndreas Boehler * 43*a1a3b679SAndreas Boehler * @copyright Copyright (C) 2007-2015 fruux GmbH (https://fruux.com/). 44*a1a3b679SAndreas Boehler * @author Evert Pot (http://evertpot.com/) 45*a1a3b679SAndreas Boehler * @license http://sabre.io/license/ Modified BSD License 46*a1a3b679SAndreas Boehler */ 47*a1a3b679SAndreas Boehlerclass ICSExportPlugin extends DAV\ServerPlugin { 48*a1a3b679SAndreas Boehler 49*a1a3b679SAndreas Boehler /** 50*a1a3b679SAndreas Boehler * Reference to Server class 51*a1a3b679SAndreas Boehler * 52*a1a3b679SAndreas Boehler * @var \Sabre\DAV\Server 53*a1a3b679SAndreas Boehler */ 54*a1a3b679SAndreas Boehler protected $server; 55*a1a3b679SAndreas Boehler 56*a1a3b679SAndreas Boehler /** 57*a1a3b679SAndreas Boehler * Initializes the plugin and registers event handlers 58*a1a3b679SAndreas Boehler * 59*a1a3b679SAndreas Boehler * @param \Sabre\DAV\Server $server 60*a1a3b679SAndreas Boehler * @return void 61*a1a3b679SAndreas Boehler */ 62*a1a3b679SAndreas Boehler function initialize(DAV\Server $server) { 63*a1a3b679SAndreas Boehler 64*a1a3b679SAndreas Boehler $this->server = $server; 65*a1a3b679SAndreas Boehler $server->on('method:GET', [$this, 'httpGet'], 90); 66*a1a3b679SAndreas Boehler $server->on('browserButtonActions', function($path, $node, &$actions) { 67*a1a3b679SAndreas Boehler if ($node instanceof ICalendar) { 68*a1a3b679SAndreas Boehler $actions .= '<a href="' . htmlspecialchars($path, ENT_QUOTES, 'UTF-8') . '?export"><span class="oi" data-glyph="calendar"></span></a>'; 69*a1a3b679SAndreas Boehler } 70*a1a3b679SAndreas Boehler }); 71*a1a3b679SAndreas Boehler 72*a1a3b679SAndreas Boehler } 73*a1a3b679SAndreas Boehler 74*a1a3b679SAndreas Boehler /** 75*a1a3b679SAndreas Boehler * Intercepts GET requests on calendar urls ending with ?export. 76*a1a3b679SAndreas Boehler * 77*a1a3b679SAndreas Boehler * @param RequestInterface $request 78*a1a3b679SAndreas Boehler * @param ResponseInterface $response 79*a1a3b679SAndreas Boehler * @return bool 80*a1a3b679SAndreas Boehler */ 81*a1a3b679SAndreas Boehler function httpGet(RequestInterface $request, ResponseInterface $response) { 82*a1a3b679SAndreas Boehler 83*a1a3b679SAndreas Boehler $queryParams = $request->getQueryParameters(); 84*a1a3b679SAndreas Boehler if (!array_key_exists('export', $queryParams)) return; 85*a1a3b679SAndreas Boehler 86*a1a3b679SAndreas Boehler $path = $request->getPath(); 87*a1a3b679SAndreas Boehler 88*a1a3b679SAndreas Boehler $node = $this->server->getProperties($path, [ 89*a1a3b679SAndreas Boehler '{DAV:}resourcetype', 90*a1a3b679SAndreas Boehler '{DAV:}displayname', 91*a1a3b679SAndreas Boehler '{http://sabredav.org/ns}sync-token', 92*a1a3b679SAndreas Boehler '{DAV:}sync-token', 93*a1a3b679SAndreas Boehler '{http://apple.com/ns/ical/}calendar-color', 94*a1a3b679SAndreas Boehler ]); 95*a1a3b679SAndreas Boehler 96*a1a3b679SAndreas Boehler if (!isset($node['{DAV:}resourcetype']) || !$node['{DAV:}resourcetype']->is('{' . Plugin::NS_CALDAV . '}calendar')) { 97*a1a3b679SAndreas Boehler return; 98*a1a3b679SAndreas Boehler } 99*a1a3b679SAndreas Boehler // Marking the transactionType, for logging purposes. 100*a1a3b679SAndreas Boehler $this->server->transactionType = 'get-calendar-export'; 101*a1a3b679SAndreas Boehler 102*a1a3b679SAndreas Boehler $properties = $node; 103*a1a3b679SAndreas Boehler 104*a1a3b679SAndreas Boehler $start = null; 105*a1a3b679SAndreas Boehler $end = null; 106*a1a3b679SAndreas Boehler $expand = false; 107*a1a3b679SAndreas Boehler $componentType = false; 108*a1a3b679SAndreas Boehler if (isset($queryParams['start'])) { 109*a1a3b679SAndreas Boehler if (!ctype_digit($queryParams['start'])) { 110*a1a3b679SAndreas Boehler throw new BadRequest('The start= parameter must contain a unix timestamp'); 111*a1a3b679SAndreas Boehler } 112*a1a3b679SAndreas Boehler $start = DateTime::createFromFormat('U', $queryParams['start']); 113*a1a3b679SAndreas Boehler } 114*a1a3b679SAndreas Boehler if (isset($queryParams['end'])) { 115*a1a3b679SAndreas Boehler if (!ctype_digit($queryParams['end'])) { 116*a1a3b679SAndreas Boehler throw new BadRequest('The end= parameter must contain a unix timestamp'); 117*a1a3b679SAndreas Boehler } 118*a1a3b679SAndreas Boehler $end = DateTime::createFromFormat('U', $queryParams['end']); 119*a1a3b679SAndreas Boehler } 120*a1a3b679SAndreas Boehler if (isset($queryParams['expand']) && !!$queryParams['expand']) { 121*a1a3b679SAndreas Boehler if (!$start || !$end) { 122*a1a3b679SAndreas Boehler throw new BadRequest('If you\'d like to expand recurrences, you must specify both a start= and end= parameter.'); 123*a1a3b679SAndreas Boehler } 124*a1a3b679SAndreas Boehler $expand = true; 125*a1a3b679SAndreas Boehler $componentType = 'VEVENT'; 126*a1a3b679SAndreas Boehler } 127*a1a3b679SAndreas Boehler if (isset($queryParams['componentType'])) { 128*a1a3b679SAndreas Boehler if (!in_array($queryParams['componentType'], ['VEVENT', 'VTODO', 'VJOURNAL'])) { 129*a1a3b679SAndreas Boehler throw new BadRequest('You are not allowed to search for components of type: ' . $queryParams['componentType'] . ' here'); 130*a1a3b679SAndreas Boehler } 131*a1a3b679SAndreas Boehler $componentType = $queryParams['componentType']; 132*a1a3b679SAndreas Boehler } 133*a1a3b679SAndreas Boehler 134*a1a3b679SAndreas Boehler $format = \Sabre\HTTP\Util::Negotiate( 135*a1a3b679SAndreas Boehler $request->getHeader('Accept'), 136*a1a3b679SAndreas Boehler [ 137*a1a3b679SAndreas Boehler 'text/calendar', 138*a1a3b679SAndreas Boehler 'application/calendar+json', 139*a1a3b679SAndreas Boehler ] 140*a1a3b679SAndreas Boehler ); 141*a1a3b679SAndreas Boehler 142*a1a3b679SAndreas Boehler if (isset($queryParams['accept'])) { 143*a1a3b679SAndreas Boehler if ($queryParams['accept'] === 'application/calendar+json' || $queryParams['accept'] === 'jcal') { 144*a1a3b679SAndreas Boehler $format = 'application/calendar+json'; 145*a1a3b679SAndreas Boehler } 146*a1a3b679SAndreas Boehler } 147*a1a3b679SAndreas Boehler if (!$format) { 148*a1a3b679SAndreas Boehler $format = 'text/calendar'; 149*a1a3b679SAndreas Boehler } 150*a1a3b679SAndreas Boehler 151*a1a3b679SAndreas Boehler $this->generateResponse($path, $start, $end, $expand, $componentType, $format, $properties, $response); 152*a1a3b679SAndreas Boehler 153*a1a3b679SAndreas Boehler // Returning false to break the event chain 154*a1a3b679SAndreas Boehler return false; 155*a1a3b679SAndreas Boehler 156*a1a3b679SAndreas Boehler } 157*a1a3b679SAndreas Boehler 158*a1a3b679SAndreas Boehler /** 159*a1a3b679SAndreas Boehler * This method is responsible for generating the actual, full response. 160*a1a3b679SAndreas Boehler * 161*a1a3b679SAndreas Boehler * @param string $path 162*a1a3b679SAndreas Boehler * @param DateTime|null $start 163*a1a3b679SAndreas Boehler * @param DateTime|null $end 164*a1a3b679SAndreas Boehler * @param bool $expand 165*a1a3b679SAndreas Boehler * @param string $componentType 166*a1a3b679SAndreas Boehler * @param string $format 167*a1a3b679SAndreas Boehler * @param array $properties 168*a1a3b679SAndreas Boehler * @param ResponseInterface $response 169*a1a3b679SAndreas Boehler */ 170*a1a3b679SAndreas Boehler protected function generateResponse($path, $start, $end, $expand, $componentType, $format, $properties, ResponseInterface $response) { 171*a1a3b679SAndreas Boehler 172*a1a3b679SAndreas Boehler $calDataProp = '{' . Plugin::NS_CALDAV . '}calendar-data'; 173*a1a3b679SAndreas Boehler 174*a1a3b679SAndreas Boehler $blobs = []; 175*a1a3b679SAndreas Boehler if ($start || $end || $componentType) { 176*a1a3b679SAndreas Boehler 177*a1a3b679SAndreas Boehler // If there was a start or end filter, we need to enlist 178*a1a3b679SAndreas Boehler // calendarQuery for speed. 179*a1a3b679SAndreas Boehler $calendarNode = $this->server->tree->getNodeForPath($path); 180*a1a3b679SAndreas Boehler $queryResult = $calendarNode->calendarQuery([ 181*a1a3b679SAndreas Boehler 'name' => 'VCALENDAR', 182*a1a3b679SAndreas Boehler 'comp-filters' => [ 183*a1a3b679SAndreas Boehler [ 184*a1a3b679SAndreas Boehler 'name' => $componentType, 185*a1a3b679SAndreas Boehler 'comp-filters' => [], 186*a1a3b679SAndreas Boehler 'prop-filters' => [], 187*a1a3b679SAndreas Boehler 'is-not-defined' => false, 188*a1a3b679SAndreas Boehler 'time-range' => [ 189*a1a3b679SAndreas Boehler 'start' => $start, 190*a1a3b679SAndreas Boehler 'end' => $end, 191*a1a3b679SAndreas Boehler ], 192*a1a3b679SAndreas Boehler ], 193*a1a3b679SAndreas Boehler ], 194*a1a3b679SAndreas Boehler 'prop-filters' => [], 195*a1a3b679SAndreas Boehler 'is-not-defined' => false, 196*a1a3b679SAndreas Boehler 'time-range' => null, 197*a1a3b679SAndreas Boehler ]); 198*a1a3b679SAndreas Boehler 199*a1a3b679SAndreas Boehler // queryResult is just a list of base urls. We need to prefix the 200*a1a3b679SAndreas Boehler // calendar path. 201*a1a3b679SAndreas Boehler $queryResult = array_map( 202*a1a3b679SAndreas Boehler function($item) use ($path) { 203*a1a3b679SAndreas Boehler return $path . '/' . $item; 204*a1a3b679SAndreas Boehler }, 205*a1a3b679SAndreas Boehler $queryResult 206*a1a3b679SAndreas Boehler ); 207*a1a3b679SAndreas Boehler $nodes = $this->server->getPropertiesForMultiplePaths($queryResult, [$calDataProp]); 208*a1a3b679SAndreas Boehler unset($queryResult); 209*a1a3b679SAndreas Boehler 210*a1a3b679SAndreas Boehler } else { 211*a1a3b679SAndreas Boehler $nodes = $this->server->getPropertiesForPath($path, [$calDataProp], 1); 212*a1a3b679SAndreas Boehler } 213*a1a3b679SAndreas Boehler 214*a1a3b679SAndreas Boehler // Flattening the arrays 215*a1a3b679SAndreas Boehler foreach ($nodes as $node) { 216*a1a3b679SAndreas Boehler if (isset($node[200][$calDataProp])) { 217*a1a3b679SAndreas Boehler $blobs[$node['href']] = $node[200][$calDataProp]; 218*a1a3b679SAndreas Boehler } 219*a1a3b679SAndreas Boehler } 220*a1a3b679SAndreas Boehler unset($nodes); 221*a1a3b679SAndreas Boehler 222*a1a3b679SAndreas Boehler $mergedCalendar = $this->mergeObjects( 223*a1a3b679SAndreas Boehler $properties, 224*a1a3b679SAndreas Boehler $blobs 225*a1a3b679SAndreas Boehler ); 226*a1a3b679SAndreas Boehler 227*a1a3b679SAndreas Boehler if ($expand) { 228*a1a3b679SAndreas Boehler $calendarTimeZone = null; 229*a1a3b679SAndreas Boehler // We're expanding, and for that we need to figure out the 230*a1a3b679SAndreas Boehler // calendar's timezone. 231*a1a3b679SAndreas Boehler $tzProp = '{' . Plugin::NS_CALDAV . '}calendar-timezone'; 232*a1a3b679SAndreas Boehler $tzResult = $this->server->getProperties($path, [$tzProp]); 233*a1a3b679SAndreas Boehler if (isset($tzResult[$tzProp])) { 234*a1a3b679SAndreas Boehler // This property contains a VCALENDAR with a single 235*a1a3b679SAndreas Boehler // VTIMEZONE. 236*a1a3b679SAndreas Boehler $vtimezoneObj = VObject\Reader::read($tzResult[$tzProp]); 237*a1a3b679SAndreas Boehler $calendarTimeZone = $vtimezoneObj->VTIMEZONE->getTimeZone(); 238*a1a3b679SAndreas Boehler unset($vtimezoneObj); 239*a1a3b679SAndreas Boehler } else { 240*a1a3b679SAndreas Boehler // Defaulting to UTC. 241*a1a3b679SAndreas Boehler $calendarTimeZone = new DateTimeZone('UTC'); 242*a1a3b679SAndreas Boehler } 243*a1a3b679SAndreas Boehler 244*a1a3b679SAndreas Boehler $mergedCalendar->expand($start, $end, $calendarTimeZone); 245*a1a3b679SAndreas Boehler } 246*a1a3b679SAndreas Boehler 247*a1a3b679SAndreas Boehler $response->setHeader('Content-Type', $format); 248*a1a3b679SAndreas Boehler 249*a1a3b679SAndreas Boehler switch ($format) { 250*a1a3b679SAndreas Boehler case 'text/calendar' : 251*a1a3b679SAndreas Boehler $mergedCalendar = $mergedCalendar->serialize(); 252*a1a3b679SAndreas Boehler break; 253*a1a3b679SAndreas Boehler case 'application/calendar+json' : 254*a1a3b679SAndreas Boehler $mergedCalendar = json_encode($mergedCalendar->jsonSerialize()); 255*a1a3b679SAndreas Boehler break; 256*a1a3b679SAndreas Boehler } 257*a1a3b679SAndreas Boehler 258*a1a3b679SAndreas Boehler $response->setStatus(200); 259*a1a3b679SAndreas Boehler $response->setBody($mergedCalendar); 260*a1a3b679SAndreas Boehler 261*a1a3b679SAndreas Boehler } 262*a1a3b679SAndreas Boehler 263*a1a3b679SAndreas Boehler /** 264*a1a3b679SAndreas Boehler * Merges all calendar objects, and builds one big iCalendar blob. 265*a1a3b679SAndreas Boehler * 266*a1a3b679SAndreas Boehler * @param array $properties Some CalDAV properties 267*a1a3b679SAndreas Boehler * @param array $inputObjects 268*a1a3b679SAndreas Boehler * @return VObject\Component\VCalendar 269*a1a3b679SAndreas Boehler */ 270*a1a3b679SAndreas Boehler function mergeObjects(array $properties, array $inputObjects) { 271*a1a3b679SAndreas Boehler 272*a1a3b679SAndreas Boehler $calendar = new VObject\Component\VCalendar(); 273*a1a3b679SAndreas Boehler $calendar->version = '2.0'; 274*a1a3b679SAndreas Boehler if (DAV\Server::$exposeVersion) { 275*a1a3b679SAndreas Boehler $calendar->prodid = '-//SabreDAV//SabreDAV ' . DAV\Version::VERSION . '//EN'; 276*a1a3b679SAndreas Boehler } else { 277*a1a3b679SAndreas Boehler $calendar->prodid = '-//SabreDAV//SabreDAV//EN'; 278*a1a3b679SAndreas Boehler } 279*a1a3b679SAndreas Boehler if (isset($properties['{DAV:}displayname'])) { 280*a1a3b679SAndreas Boehler $calendar->{'X-WR-CALNAME'} = $properties['{DAV:}displayname']; 281*a1a3b679SAndreas Boehler } 282*a1a3b679SAndreas Boehler if (isset($properties['{http://apple.com/ns/ical/}calendar-color'])) { 283*a1a3b679SAndreas Boehler $calendar->{'X-APPLE-CALENDAR-COLOR'} = $properties['{http://apple.com/ns/ical/}calendar-color']; 284*a1a3b679SAndreas Boehler } 285*a1a3b679SAndreas Boehler 286*a1a3b679SAndreas Boehler $collectedTimezones = []; 287*a1a3b679SAndreas Boehler 288*a1a3b679SAndreas Boehler $timezones = []; 289*a1a3b679SAndreas Boehler $objects = []; 290*a1a3b679SAndreas Boehler 291*a1a3b679SAndreas Boehler foreach ($inputObjects as $href => $inputObject) { 292*a1a3b679SAndreas Boehler 293*a1a3b679SAndreas Boehler $nodeComp = VObject\Reader::read($inputObject); 294*a1a3b679SAndreas Boehler 295*a1a3b679SAndreas Boehler foreach ($nodeComp->children() as $child) { 296*a1a3b679SAndreas Boehler 297*a1a3b679SAndreas Boehler switch ($child->name) { 298*a1a3b679SAndreas Boehler case 'VEVENT' : 299*a1a3b679SAndreas Boehler case 'VTODO' : 300*a1a3b679SAndreas Boehler case 'VJOURNAL' : 301*a1a3b679SAndreas Boehler $objects[] = $child; 302*a1a3b679SAndreas Boehler break; 303*a1a3b679SAndreas Boehler 304*a1a3b679SAndreas Boehler // VTIMEZONE is special, because we need to filter out the duplicates 305*a1a3b679SAndreas Boehler case 'VTIMEZONE' : 306*a1a3b679SAndreas Boehler // Naively just checking tzid. 307*a1a3b679SAndreas Boehler if (in_array((string)$child->TZID, $collectedTimezones)) continue; 308*a1a3b679SAndreas Boehler 309*a1a3b679SAndreas Boehler $timezones[] = $child; 310*a1a3b679SAndreas Boehler $collectedTimezones[] = $child->TZID; 311*a1a3b679SAndreas Boehler break; 312*a1a3b679SAndreas Boehler 313*a1a3b679SAndreas Boehler } 314*a1a3b679SAndreas Boehler 315*a1a3b679SAndreas Boehler } 316*a1a3b679SAndreas Boehler 317*a1a3b679SAndreas Boehler } 318*a1a3b679SAndreas Boehler 319*a1a3b679SAndreas Boehler foreach ($timezones as $tz) $calendar->add($tz); 320*a1a3b679SAndreas Boehler foreach ($objects as $obj) $calendar->add($obj); 321*a1a3b679SAndreas Boehler 322*a1a3b679SAndreas Boehler return $calendar; 323*a1a3b679SAndreas Boehler 324*a1a3b679SAndreas Boehler } 325*a1a3b679SAndreas Boehler 326*a1a3b679SAndreas Boehler /** 327*a1a3b679SAndreas Boehler * Returns a plugin name. 328*a1a3b679SAndreas Boehler * 329*a1a3b679SAndreas Boehler * Using this name other plugins will be able to access other plugins 330*a1a3b679SAndreas Boehler * using \Sabre\DAV\Server::getPlugin 331*a1a3b679SAndreas Boehler * 332*a1a3b679SAndreas Boehler * @return string 333*a1a3b679SAndreas Boehler */ 334*a1a3b679SAndreas Boehler function getPluginName() { 335*a1a3b679SAndreas Boehler 336*a1a3b679SAndreas Boehler return 'ics-export'; 337*a1a3b679SAndreas Boehler 338*a1a3b679SAndreas Boehler } 339*a1a3b679SAndreas Boehler 340*a1a3b679SAndreas Boehler /** 341*a1a3b679SAndreas Boehler * Returns a bunch of meta-data about the plugin. 342*a1a3b679SAndreas Boehler * 343*a1a3b679SAndreas Boehler * Providing this information is optional, and is mainly displayed by the 344*a1a3b679SAndreas Boehler * Browser plugin. 345*a1a3b679SAndreas Boehler * 346*a1a3b679SAndreas Boehler * The description key in the returned array may contain html and will not 347*a1a3b679SAndreas Boehler * be sanitized. 348*a1a3b679SAndreas Boehler * 349*a1a3b679SAndreas Boehler * @return array 350*a1a3b679SAndreas Boehler */ 351*a1a3b679SAndreas Boehler function getPluginInfo() { 352*a1a3b679SAndreas Boehler 353*a1a3b679SAndreas Boehler return [ 354*a1a3b679SAndreas Boehler 'name' => $this->getPluginName(), 355*a1a3b679SAndreas Boehler 'description' => 'Adds the ability to export CalDAV calendars as a single iCalendar file.', 356*a1a3b679SAndreas Boehler 'link' => 'http://sabre.io/dav/ics-export-plugin/', 357*a1a3b679SAndreas Boehler ]; 358*a1a3b679SAndreas Boehler 359*a1a3b679SAndreas Boehler } 360*a1a3b679SAndreas Boehler 361*a1a3b679SAndreas Boehler} 362