ics.php
913 lines
| 1 | <?php |
| 2 | /** |
| 3 | * @package VikAppointments |
| 4 | * @subpackage core |
| 5 | * @author E4J s.r.l. |
| 6 | * @copyright Copyright (C) 2021 E4J s.r.l. All Rights Reserved. |
| 7 | * @license http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL |
| 8 | * @link https://vikwp.com |
| 9 | */ |
| 10 | |
| 11 | // No direct access |
| 12 | defined('ABSPATH') or die('No script kiddies please!'); |
| 13 | |
| 14 | /** |
| 15 | * Driver class used to export the orders/appointments in ICS format. |
| 16 | * |
| 17 | * @since 1.7 |
| 18 | */ |
| 19 | class VAPOrderExportDriverIcs extends VAPOrderExportDriver |
| 20 | { |
| 21 | /** |
| 22 | * ICS declarations buffer. |
| 23 | * |
| 24 | * @var string |
| 25 | */ |
| 26 | private $ics; |
| 27 | |
| 28 | /** |
| 29 | * A list of custom fields. |
| 30 | * |
| 31 | * @var array |
| 32 | */ |
| 33 | private $customFields; |
| 34 | |
| 35 | /** |
| 36 | * Checks whether the specified group is supported by the |
| 37 | * export driver. Children classes can override this method |
| 38 | * to drop the support for a specific group. |
| 39 | * |
| 40 | * @param string $group The group to check. |
| 41 | * |
| 42 | * @return boolean True if supported, false otherwise. |
| 43 | */ |
| 44 | public function isSupported($group) |
| 45 | { |
| 46 | // only appointments are supported here |
| 47 | return $this->isGroup('appointment'); |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * @override |
| 52 | * Builds the form parameters required to the ICS driver. |
| 53 | * |
| 54 | * @return array |
| 55 | */ |
| 56 | protected function buildForm() |
| 57 | { |
| 58 | return array( |
| 59 | /** |
| 60 | * An optional subject to be used instead of the |
| 61 | * default one. |
| 62 | * |
| 63 | * @var text |
| 64 | */ |
| 65 | 'subject' => array( |
| 66 | 'type' => 'text', |
| 67 | 'label' => JText::translate('VAP_EXPORT_DRIVER_ICS_SUBJECT_FIELD'), |
| 68 | 'help' => JText::translate('VAP_EXPORT_DRIVER_ICS_SUBJECT_FIELD_HELP'), |
| 69 | ), |
| 70 | |
| 71 | /** |
| 72 | * Include past events. |
| 73 | * If disabled, reservations older than the current |
| 74 | * month won't have to be included. |
| 75 | * |
| 76 | * @var checkbox |
| 77 | */ |
| 78 | 'pastevents' => array( |
| 79 | 'type' => 'checkbox', |
| 80 | 'label' => JText::translate('VAP_EXPORT_DRIVER_ICS_PAST_DATES_FIELD'), |
| 81 | 'help' => JText::translate('VAP_EXPORT_DRIVER_ICS_PAST_DATES_FIELD_HELP'), |
| 82 | 'default' => true, |
| 83 | ), |
| 84 | |
| 85 | /** |
| 86 | * Events default reminder. |
| 87 | * The minutes in advance since the event date time |
| 88 | * for which the alert will be triggered. |
| 89 | * |
| 90 | * @var select |
| 91 | */ |
| 92 | 'reminder' => array( |
| 93 | 'type' => 'select', |
| 94 | 'label' => JText::translate('VAP_EXPORT_DRIVER_ICS_REMINDER_FIELD'), |
| 95 | 'help' => JText::translate('VAP_EXPORT_DRIVER_ICS_REMINDER_FIELD_HELP'), |
| 96 | 'default' => -1, |
| 97 | 'options' => array( |
| 98 | -1 => JText::translate('VAP_EXPORT_DRIVER_ICS_REMINDER_OPT_NONE'), |
| 99 | 0 => JText::translate('VAP_EXPORT_DRIVER_ICS_REMINDER_OPT_EVENT_TIME'), |
| 100 | 5 => JText::sprintf('VAP_EXPORT_DRIVER_ICS_REMINDER_OPT_N_MIN', 5), |
| 101 | 10 => JText::sprintf('VAP_EXPORT_DRIVER_ICS_REMINDER_OPT_N_MIN', 10), |
| 102 | 15 => JText::sprintf('VAP_EXPORT_DRIVER_ICS_REMINDER_OPT_N_MIN', 15), |
| 103 | 30 => JText::sprintf('VAP_EXPORT_DRIVER_ICS_REMINDER_OPT_N_MIN', 30), |
| 104 | 60 => JText::plural('VAP_EXPORT_DRIVER_ICS_REMINDER_OPT_N_HOURS', 1), |
| 105 | 120 => JText::plural('VAP_EXPORT_DRIVER_ICS_REMINDER_OPT_N_HOURS', 2), |
| 106 | ), |
| 107 | ), |
| 108 | ); |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * @override |
| 113 | * Exports the orders in the given format. |
| 114 | * |
| 115 | * @return string The resulting export string. |
| 116 | */ |
| 117 | public function export() |
| 118 | { |
| 119 | $dispatcher = VAPFactory::getEventDispatcher(); |
| 120 | |
| 121 | // init buffer |
| 122 | $this->ics = ''; |
| 123 | |
| 124 | // load custom fields |
| 125 | VAPLoader::import('libraries.customfields.loader'); |
| 126 | $this->customFields = VAPCustomFieldsLoader::getInstance() |
| 127 | ->noRequiredCheckbox() |
| 128 | ->noInputFile() |
| 129 | ->noSeparator() |
| 130 | ->translate() |
| 131 | ->fetch(); |
| 132 | |
| 133 | /** |
| 134 | * Starts the calendar declaration. |
| 135 | * |
| 136 | * @link https://icalendar.org/iCalendar-RFC-5545/3-4-icalendar-object.html |
| 137 | */ |
| 138 | $this->addLine('BEGIN', 'VCALENDAR'); |
| 139 | |
| 140 | // create ICS header |
| 141 | $this->createHeader(); |
| 142 | |
| 143 | /** |
| 144 | * Trigger event to allow the plugins to include custom options before |
| 145 | * the body of the ICS file. |
| 146 | * |
| 147 | * @param mixed $handler The current handler instance. |
| 148 | * |
| 149 | * @return string The rules to include. |
| 150 | * |
| 151 | * @since 1.6.6 |
| 152 | */ |
| 153 | $res = $dispatcher->trigger('onBuildBodyExportICS', array($this)); |
| 154 | |
| 155 | // include the custom rules before the body |
| 156 | $this->ics .= implode('', array_filter($res)); |
| 157 | |
| 158 | // iterate records to export |
| 159 | foreach ($this->getRecords() as $event) |
| 160 | { |
| 161 | // use registry for ease of use |
| 162 | $event = new JRegistry($event); |
| 163 | |
| 164 | // add event properties |
| 165 | $this->addEvent($event); |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Closes the calendar |
| 170 | * |
| 171 | * @see BEGIN:VCALENDAR |
| 172 | */ |
| 173 | $this->addLine('END', 'VCALENDAR'); |
| 174 | |
| 175 | // return generated buffer |
| 176 | return $this->ics; |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * @override |
| 181 | * Downloads the orders in a file compatible with the given format. |
| 182 | * |
| 183 | * @param string $filename The name of the file that will be downloaded. |
| 184 | * |
| 185 | * @return void |
| 186 | * |
| 187 | * @uses export() |
| 188 | */ |
| 189 | public function download($filename = null) |
| 190 | { |
| 191 | // obtain export string |
| 192 | $buffer = $this->export(); |
| 193 | |
| 194 | if ($filename) |
| 195 | { |
| 196 | // strip file extension |
| 197 | $filename = preg_replace("/\.ics$/i", '', $filename); |
| 198 | } |
| 199 | else |
| 200 | { |
| 201 | // use current date time as name |
| 202 | $filename = JHtml::fetch('date', 'now', 'Y-m-d H_i_s'); |
| 203 | } |
| 204 | |
| 205 | $app = JFactory::getApplication(); |
| 206 | |
| 207 | // declare headers |
| 208 | $app->setHeader('Content-Type', 'text/calendar; charset=utf-8'); |
| 209 | $app->setHeader('Content-Disposition', 'attachment; filename=' . $filename . '.ics'); |
| 210 | $app->setHeader('Content-Length', strlen($buffer)); |
| 211 | $app->setHeader('Cache-Control', 'no-store, no-cache'); |
| 212 | |
| 213 | // send headers |
| 214 | $app->sendHeaders(); |
| 215 | |
| 216 | // output buffer for download |
| 217 | echo $buffer; |
| 218 | } |
| 219 | |
| 220 | /** |
| 221 | * Returns the list of records to export. |
| 222 | * |
| 223 | * @return array A list of records. |
| 224 | */ |
| 225 | protected function getRecords() |
| 226 | { |
| 227 | $dispatcher = VAPFactory::getEventDispatcher(); |
| 228 | |
| 229 | $dbo = JFactory::getDbo(); |
| 230 | |
| 231 | $q = $dbo->getQuery(true); |
| 232 | |
| 233 | // select all reservation columns |
| 234 | $q->select('r.*'); |
| 235 | $q->from($dbo->qn('#__vikappointments_reservation', 'r')); |
| 236 | |
| 237 | // select service name |
| 238 | $q->select($dbo->qn('s.name', 'service_name')); |
| 239 | $q->leftjoin($dbo->qn('#__vikappointments_service', 's') . ' ON ' . $dbo->qn('r.id_service') . ' = ' . $dbo->qn('s.id')); |
| 240 | |
| 241 | // select employee name and timezone |
| 242 | $q->select($dbo->qn('e.nickname', 'employee_name')); |
| 243 | $q->leftjoin($dbo->qn('#__vikappointments_employee', 'e') . ' ON ' . $dbo->qn('r.id_employee') . ' = ' . $dbo->qn('e.id')); |
| 244 | |
| 245 | // exclude all parent orders |
| 246 | $q->where($dbo->qn('r.id_parent') . ' > 0'); |
| 247 | // exclude all closures |
| 248 | $q->where($dbo->qn('r.closure') . ' = 0'); |
| 249 | |
| 250 | /** |
| 251 | * Obtain also the cancelled statuses to improve the synchronization with Google Calendar. |
| 252 | * |
| 253 | * @since 1.7.8 |
| 254 | */ |
| 255 | $statuses = array_merge( |
| 256 | // get approved statuses |
| 257 | JHtml::fetch('vaphtml.status.find', 'code', ['appointments' => 1, 'approved' => 1]), |
| 258 | // get cancelled statuses |
| 259 | JHtml::fetch('vaphtml.status.find', 'code', ['appointments' => 1, 'cancelled' => 1]), |
| 260 | ); |
| 261 | |
| 262 | if ($statuses) |
| 263 | { |
| 264 | // filter by status |
| 265 | $q->where($dbo->qn('r.status') . ' IN (' . implode(',', array_map(array($dbo, 'q'), $statuses)) . ')'); |
| 266 | } |
| 267 | |
| 268 | // include records with check-in equals or higher than |
| 269 | // the specified starting date |
| 270 | $from = $this->getOption('fromdate'); |
| 271 | |
| 272 | if (!VAPDateHelper::isNull($from)) |
| 273 | { |
| 274 | $q->where($dbo->qn('r.checkin_ts') . ' >= ' . $dbo->q($from)); |
| 275 | } |
| 276 | |
| 277 | // include records with check-in equals or lower than |
| 278 | // the specified ending date |
| 279 | $to = $this->getOption('todate'); |
| 280 | |
| 281 | if (!VAPDateHelper::isNull($to)) |
| 282 | { |
| 283 | $q->where($dbo->qn('r.checkin_ts') . ' <= ' . $dbo->q($to)); |
| 284 | } |
| 285 | |
| 286 | // retrieve only the selected records, if any |
| 287 | $ids = $this->getOption('cid'); |
| 288 | |
| 289 | if ($ids) |
| 290 | { |
| 291 | /** |
| 292 | * The export system is now able to fetch also the appointments assigned to a parent order. |
| 293 | * |
| 294 | * @since 1.7.4 |
| 295 | */ |
| 296 | $q->andWhere([ |
| 297 | $dbo->qn('r.id') . ' IN (' . implode(',', array_map('intval', $ids)) . ')', |
| 298 | $dbo->qn('r.id_parent') . ' IN (' . implode(',', array_map('intval', $ids)) . ')', |
| 299 | ], 'OR'); |
| 300 | } |
| 301 | |
| 302 | // retrieve employee filter, if any |
| 303 | $id_emp = (int) $this->getOption('id_employee'); |
| 304 | |
| 305 | if ($id_emp > 0) |
| 306 | { |
| 307 | $q->where($dbo->qn('r.id_employee') . ' = ' . $id_emp); |
| 308 | } |
| 309 | |
| 310 | // check whether the past events should be excluded |
| 311 | if (!$this->getOption('pastevents')) |
| 312 | { |
| 313 | // get current date at midnight |
| 314 | $date = JFactory::getDate('today 00:00:00', JFactory::getUser()->getTimezone()); |
| 315 | // Back to the first day of the month. |
| 316 | // Do not use "first day of" modifier because PHP 7.3 |
| 317 | // seems to experience some strange behaviors. |
| 318 | $date->modify($date->format('Y-m-01')); |
| 319 | |
| 320 | $q->where($dbo->qn('r.checkin_ts') . ' >= ' . $dbo->q($date->toSql())); |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * Check whether the imported events should be excluded or not. |
| 325 | * |
| 326 | * @since 1.7.3 |
| 327 | */ |
| 328 | if ($this->getOption('imported', true) === false) |
| 329 | { |
| 330 | $q->andWhere([ |
| 331 | $dbo->qn('r.icaluid') . ' IS NULL', |
| 332 | $dbo->qn('r.icaluid') . ' = ' . $dbo->q(''), |
| 333 | ], 'OR'); |
| 334 | } |
| 335 | |
| 336 | // order by ascending checkin |
| 337 | $q->order($dbo->qn('r.checkin_ts') . ' ASC'); |
| 338 | |
| 339 | /** |
| 340 | * Trigger event to allow the plugins to manipulate the query used to retrieve |
| 341 | * a standard list of records. |
| 342 | * |
| 343 | * @param mixed &$query The query string or a query builder object. |
| 344 | * @param mixed $options A configuration registry. |
| 345 | * |
| 346 | * @return void |
| 347 | * |
| 348 | * @since 1.6.6 |
| 349 | */ |
| 350 | $dispatcher->trigger('onBeforeListQueryExportICS', array(&$q, $this->options)); |
| 351 | |
| 352 | $dbo->setQuery($q); |
| 353 | return $dbo->loadObjectList(); |
| 354 | } |
| 355 | |
| 356 | /** |
| 357 | * Creates the header of the calendar. |
| 358 | * |
| 359 | * @return void |
| 360 | */ |
| 361 | protected function createHeader() |
| 362 | { |
| 363 | $dispatcher = VAPFactory::getEventDispatcher(); |
| 364 | |
| 365 | // set up default head information |
| 366 | $head = array( |
| 367 | 'version' => '2.0', |
| 368 | 'prodid' => '-//e4j//VikAppointments ' . VIKAPPOINTMENTS_SOFTWARE_VERSION . '//EN', |
| 369 | 'calscale' => 'GREGORIAN', |
| 370 | 'calname' => VAPFactory::getConfig()->get('agencyname'), |
| 371 | ); |
| 372 | |
| 373 | /** |
| 374 | * Trigger event to allow the plugins to include custom options within the |
| 375 | * head of the ICS file. |
| 376 | * |
| 377 | * @param array &$head The default head data. |
| 378 | * @param mixed $handler The current handler instance. |
| 379 | * |
| 380 | * @return string The rules to include. |
| 381 | * |
| 382 | * @since 1.6.6 |
| 383 | * @since 1.7 $head is now an array and it is passed by reference. |
| 384 | */ |
| 385 | $res = $dispatcher->trigger('onBuildHeadExportICS', array(&$head, $this)); |
| 386 | |
| 387 | /** |
| 388 | * This property specifies the identifier corresponding to the highest version number |
| 389 | * or the minimum and maximum range of the iCalendar specification that is required |
| 390 | * in order to interpret the iCalendar object. |
| 391 | * |
| 392 | * @link https://icalendar.org/iCalendar-RFC-5545/3-7-4-version.html |
| 393 | */ |
| 394 | $this->addLine('VERSION', $head['version']); |
| 395 | |
| 396 | /** |
| 397 | * This property specifies the identifier for the product that created the iCalendar object. |
| 398 | * |
| 399 | * @link https://icalendar.org/iCalendar-RFC-5545/3-7-3-product-identifier.html |
| 400 | */ |
| 401 | $this->addLine('PRODID', $head['prodid']); |
| 402 | |
| 403 | /** |
| 404 | * This property defines the calendar scale used for the calendar information |
| 405 | * specified in the iCalendar object. |
| 406 | * |
| 407 | * @link https://icalendar.org/iCalendar-RFC-5545/3-7-1-calendar-scale.html |
| 408 | */ |
| 409 | $this->addLine('CALSCALE', $head['calscale']); |
| 410 | |
| 411 | /** |
| 412 | * This non standard property defines the default name that will be used |
| 413 | * when creating a new subscription. |
| 414 | * |
| 415 | * @since 1.6.5 |
| 416 | */ |
| 417 | $this->addLine('X-WR-CALNAME', $head['calname']); |
| 418 | |
| 419 | // $this->addLine('X-WR-TIMEZONE', JFactory::getApplication()->get('offset', 'UTC')); |
| 420 | |
| 421 | // append also the values that have been returned by the plugins |
| 422 | $this->ics .= implode('', array_filter($res)); |
| 423 | } |
| 424 | |
| 425 | /** |
| 426 | * Adds an appointment as event within the calendar. |
| 427 | * |
| 428 | * @param JRegistry $event The event to include. |
| 429 | * |
| 430 | * @return void |
| 431 | */ |
| 432 | protected function addEvent($event) |
| 433 | { |
| 434 | $dispatcher = VAPFactory::getEventDispatcher(); |
| 435 | |
| 436 | $config = VAPFactory::getConfig(); |
| 437 | $vik = VAPApplication::getInstance(); |
| 438 | |
| 439 | // fetch URI |
| 440 | $uri = 'index.php?option=com_vikappointments&view=order&ordnum=' . $event->get('id') . '&ordkey=' . $event->get('sid'); |
| 441 | $uri = $vik->routeForExternalUse($uri); |
| 442 | |
| 443 | // fetch summary |
| 444 | $summary = $this->getOption('subject'); |
| 445 | |
| 446 | // retrieve customer name |
| 447 | $customer = $event->get('purchaser_nominative'); |
| 448 | |
| 449 | if (!$summary) |
| 450 | { |
| 451 | // use default summary built as "service" for customer or |
| 452 | // "service - customer" for administrator |
| 453 | $summary = '{service}'; |
| 454 | |
| 455 | // sets a different title for the administrator |
| 456 | if ($this->getOption('admin') && $customer) |
| 457 | { |
| 458 | $summary .= ' - {customer}'; |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | if (!$customer) |
| 463 | { |
| 464 | // use e-mail in case the name is missing |
| 465 | $customer = $event->get('purchaser_mail'); |
| 466 | } |
| 467 | |
| 468 | if (!$customer) |
| 469 | { |
| 470 | // fallback to "Guest" |
| 471 | $customer = 'Guest'; |
| 472 | } |
| 473 | |
| 474 | // retrieve service name |
| 475 | $service = $event->get('service_name'); |
| 476 | |
| 477 | // retrieve people |
| 478 | $people = (int) $event->get('people', 0); |
| 479 | |
| 480 | // replace tags with reservation values |
| 481 | $summary = preg_replace("/{customer}/", $customer, $summary); |
| 482 | $summary = preg_replace("/{service}/", $service, $summary); |
| 483 | $summary = preg_replace("/{people}/", $people, $summary); |
| 484 | |
| 485 | // fetch modified date |
| 486 | $modified = max(array($event->get('createdon'), $event->get('modifiedon'))); |
| 487 | |
| 488 | $description = ''; |
| 489 | |
| 490 | // fetch description |
| 491 | if ($this->getOption('admin')) |
| 492 | { |
| 493 | // decode custom fields and translate values |
| 494 | $cf = (array) json_decode($event->get('custom_f', '{}'), true); |
| 495 | $cf = VAPCustomFieldsLoader::translateObject($cf, $this->customFields); |
| 496 | |
| 497 | // create description containing the user custom fields |
| 498 | foreach ($this->customFields as $field) |
| 499 | { |
| 500 | $k = $field['name']; |
| 501 | |
| 502 | if (!array_key_exists($k, $cf)) |
| 503 | { |
| 504 | // field not found inside the given object, go to next one |
| 505 | continue; |
| 506 | } |
| 507 | |
| 508 | $v = $cf[$k]; |
| 509 | |
| 510 | // take only if the value is not empty |
| 511 | if ((is_scalar($v) && strlen($v)) || !empty($v)) |
| 512 | { |
| 513 | // add colon as separator only in case the label doesn't |
| 514 | // end with a punctuation |
| 515 | if (preg_match("/[.,:;?!_\-]$/", $field['langname'])) |
| 516 | { |
| 517 | // ends with a punctuation, do not use separator |
| 518 | $sep = ''; |
| 519 | } |
| 520 | else |
| 521 | { |
| 522 | $sep = ':'; |
| 523 | } |
| 524 | |
| 525 | // get a more readable label/text of the saved value |
| 526 | $description .= $field['langname'] . $sep . ' ' . preg_replace("/\R/", "\\n", $v) . "\\n"; |
| 527 | } |
| 528 | } |
| 529 | } |
| 530 | else |
| 531 | { |
| 532 | // no description for customer |
| 533 | } |
| 534 | |
| 535 | /** |
| 536 | * @todo Include location once the database will support a FK to quickly access the |
| 537 | * details of the appointment. Otherwise we risk to slow down the whole process. |
| 538 | */ |
| 539 | |
| 540 | // build EVENT |
| 541 | $data = array( |
| 542 | 'dtend' => VikAppointments::getCheckout($event->get('checkin_ts'), $event->get('duration')), |
| 543 | 'uid' => $event->get('icaluid'), |
| 544 | 'dtstamp' => $event->get('createdon'), |
| 545 | 'location' => $config->get('agencyname'), |
| 546 | 'description' => $description, |
| 547 | 'url' => $uri, |
| 548 | 'summary' => $summary, |
| 549 | 'dtstart' => $event->get('checkin_ts'), |
| 550 | 'modified' => $modified, |
| 551 | 'status' => JHtml::fetch('vaphtml.status.iscancelled', 'appointments', $event->get('status')) ? 'CANCELLED' : 'CONFIRMED', |
| 552 | 'sequence' => (int) $event->get('sequence', 0), |
| 553 | 'attendees' => array(), |
| 554 | ); |
| 555 | |
| 556 | if (!$data['uid']) |
| 557 | { |
| 558 | // iCal UID not found, generate a new one |
| 559 | $data['uid'] = md5($event->get('id') . '-' . $event->get('sid')); |
| 560 | } |
| 561 | |
| 562 | // get attendees and decode them |
| 563 | $attendees = $event->get('attendees'); |
| 564 | $attendees = $attendees ? (array) json_decode($attendees, true) : []; |
| 565 | |
| 566 | // inject customer details within attendee data |
| 567 | array_unshift($attendees, array( |
| 568 | 'purchaser_nominative' => $event->get('purchaser_nominative'), |
| 569 | 'purchaser_mail' => $event->get('purchaser_mail'), |
| 570 | )); |
| 571 | |
| 572 | // build attendees ICS data |
| 573 | foreach ($attendees as $attendee) |
| 574 | { |
| 575 | if (empty($attendee['purchaser_mail'])) |
| 576 | { |
| 577 | // go ahead, missing attendee e-mail |
| 578 | continue; |
| 579 | } |
| 580 | |
| 581 | // register attendee structure |
| 582 | $data['attendees'][] = array( |
| 583 | 'key' => array( |
| 584 | 'ATTENDEE', |
| 585 | 'CN=' . $attendee['purchaser_nominative'], |
| 586 | 'CUTYPE=INDIVIDUAL', |
| 587 | 'EMAIL=' . $attendee['purchaser_mail'], |
| 588 | ), |
| 589 | 'value' => 'mailto:' . $attendee['purchaser_mail'], |
| 590 | ); |
| 591 | } |
| 592 | |
| 593 | /** |
| 594 | * Provide a grouping of component properties that describe an event. |
| 595 | * |
| 596 | * @link https://icalendar.org/iCalendar-RFC-5545/3-6-1-event-component.html |
| 597 | */ |
| 598 | $this->addLine('BEGIN', 'VEVENT'); |
| 599 | |
| 600 | /** |
| 601 | * Trigger event to allow the plugins to manipulate the event |
| 602 | * details before being included. |
| 603 | * |
| 604 | * @param array &$event The event data. |
| 605 | * @param mixed $record The database record. |
| 606 | * @param mixed $handler The current handler instance. |
| 607 | * |
| 608 | * @return void |
| 609 | * |
| 610 | * @since 1.6.6 |
| 611 | * @since 1.7 $record is now a registry. |
| 612 | */ |
| 613 | $dispatcher->trigger('onBeforeBuildEventICS', array(&$data, $event, $this)); |
| 614 | |
| 615 | /** |
| 616 | * This property specifies the persistent, globally unique identifier for the |
| 617 | * iCalendar object. This can be used, for example, to identify duplicate calendar |
| 618 | * streams that a client may have been given access to. |
| 619 | * |
| 620 | * Generate a md5 string of the order number because "UID" values MUST NOT include any |
| 621 | * data that might identify a user, host, domain, or any other private sensitive information. |
| 622 | * |
| 623 | * @link https://icalendar.org/New-Properties-for-iCalendar-RFC-7986/5-3-uid-property.html |
| 624 | */ |
| 625 | $this->addLine('UID', $data['uid']); |
| 626 | |
| 627 | /** |
| 628 | * This property specifies when the calendar component begins. |
| 629 | * |
| 630 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-2-4-date-time-start.html |
| 631 | */ |
| 632 | $this->addLine( |
| 633 | array('DTSTART', 'VALUE=DATE-TIME'), |
| 634 | $this->tsToCal($data['dtstart']) |
| 635 | ); |
| 636 | |
| 637 | /** |
| 638 | * This property specifies the date and time that a calendar component ends. |
| 639 | * |
| 640 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-2-2-date-time-end.html |
| 641 | */ |
| 642 | $this->addLine( |
| 643 | array('DTEND', 'VALUE=DATE-TIME'), |
| 644 | $this->tsToCal($data['dtend']) |
| 645 | ); |
| 646 | |
| 647 | /** |
| 648 | * In the case of an iCalendar object that specifies a "METHOD" property, this property |
| 649 | * specifies the date and time that the instance of the iCalendar object was created. |
| 650 | * In the case of an iCalendar object that doesn't specify a "METHOD" property, this |
| 651 | * property specifies the date and time that the information associated with the calendar |
| 652 | * component was last revised in the calendar store. |
| 653 | * |
| 654 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-7-2-date-time-stamp.html |
| 655 | */ |
| 656 | $this->addLine('DTSTAMP', $this->tsToCal($data['dtstamp'])); |
| 657 | |
| 658 | /** |
| 659 | * In case an event is modified through a client, it updates the Last-Modified property to the |
| 660 | * current time. When the calendar is going to refresh an event, in case the Last-Modified is |
| 661 | * not specified or it is lower than the current one, the changes will be discarded. |
| 662 | * For this reason, it is needed to specify our internal modified date in order to refresh |
| 663 | * any existing events with the updated details. |
| 664 | * |
| 665 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-7-3-last-modified.html |
| 666 | */ |
| 667 | $this->addLine('LAST-MODIFIED', $this->tsToCal($data['modified'])); |
| 668 | |
| 669 | /** |
| 670 | * This property may be used to convey a location where a more dynamic |
| 671 | * rendition of the calendar information can be found. |
| 672 | * |
| 673 | * @link https://icalendar.org/New-Properties-for-iCalendar-RFC-7986/5-5-url-property.html |
| 674 | */ |
| 675 | $this->addLine(array('URL', 'VALUE=URI'), $data['url']); |
| 676 | |
| 677 | /** |
| 678 | * This property defines a short summary or subject for the calendar component. |
| 679 | * |
| 680 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-1-12-summary.html |
| 681 | */ |
| 682 | $this->addLine('SUMMARY', $this->escape($data['summary'])); |
| 683 | |
| 684 | /** |
| 685 | * This property provides a more complete description of the calendar component |
| 686 | * than that provided by the "SUMMARY" property. |
| 687 | * |
| 688 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-1-5-description.html |
| 689 | */ |
| 690 | if ($data['description']) |
| 691 | { |
| 692 | $this->addLine('DESCRIPTION', $this->escape($data['description'])); |
| 693 | } |
| 694 | |
| 695 | /** |
| 696 | * This property defines the intended venue for the activity defined by a calendar component. |
| 697 | * |
| 698 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-1-7-location.html |
| 699 | */ |
| 700 | $this->addLine('LOCATION', $this->escape($data['location'])); |
| 701 | |
| 702 | /** |
| 703 | * This property defines whether or not an event is transparent to busy time searches. |
| 704 | * |
| 705 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-2-7-time-transparency.html |
| 706 | */ |
| 707 | $this->addLine('TRANSP', 'OPAQUE'); |
| 708 | |
| 709 | /** |
| 710 | * This property defines the overall status or confirmation for the calendar component. |
| 711 | * |
| 712 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-1-11-status.html |
| 713 | * |
| 714 | * @since 1.7.8 |
| 715 | */ |
| 716 | $this->addLine('STATUS', $data['status']); |
| 717 | |
| 718 | /** |
| 719 | * This property defines the revision sequence number of the calendar component within a sequence of revisions. |
| 720 | * |
| 721 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-7-4-sequence-number.html |
| 722 | * |
| 723 | * @since 1.7.8 |
| 724 | */ |
| 725 | $this->addLine('SEQUENCE', max(0, (int) $data['sequence'])); |
| 726 | |
| 727 | // iterate all attendees |
| 728 | foreach ($data['attendees'] as $attendee) |
| 729 | { |
| 730 | /** |
| 731 | * This property defines an "Attendee" within a calendar component. |
| 732 | * |
| 733 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-4-1-attendee.html |
| 734 | */ |
| 735 | $this->addLine($attendee['key'], $attendee['value']); |
| 736 | } |
| 737 | |
| 738 | // check if a reminder should be included |
| 739 | $reminder = (int) $this->getOption('reminder'); |
| 740 | |
| 741 | if ($reminder >= 0) |
| 742 | { |
| 743 | // create event alarm |
| 744 | $this->createAlarm($event, $reminder); |
| 745 | } |
| 746 | |
| 747 | /** |
| 748 | * Trigger event to allow the plugins to include custom options within the |
| 749 | * current calendar event. |
| 750 | * |
| 751 | * @param array $event The event data. |
| 752 | * @param mixed $record The database record. |
| 753 | * @param mixed $handler The current handler instance. |
| 754 | * |
| 755 | * @return string The rules to include. |
| 756 | * |
| 757 | * @since 1.6.6 |
| 758 | * @since 1.7 $record is now a registry. |
| 759 | */ |
| 760 | $res = $dispatcher->trigger('onAfterBuildEventICS', array($data, $event, $this)); |
| 761 | |
| 762 | // append also the values that have been returned by the plugins |
| 763 | $this->ics .= implode('', array_filter($res)); |
| 764 | |
| 765 | /** |
| 766 | * Closes the event properties. |
| 767 | * |
| 768 | * @see BEGIN:VEVENT |
| 769 | */ |
| 770 | $this->addLine('END', 'VEVENT'); |
| 771 | } |
| 772 | |
| 773 | /** |
| 774 | * Creates an alarm for the specified event. |
| 775 | * |
| 776 | * @param JRegistry $event The event to bind. |
| 777 | * @param integer $reminder The reminder in minutes. |
| 778 | * |
| 779 | * @return void |
| 780 | */ |
| 781 | protected function createAlarm($event, $reminder = 0) |
| 782 | { |
| 783 | /** |
| 784 | * Provide a grouping of component properties that define an alarm. |
| 785 | * |
| 786 | * @link https://icalendar.org/iCalendar-RFC-5545/3-6-6-alarm-component.html |
| 787 | */ |
| 788 | $this->addLine('BEGIN', 'VALARM'); |
| 789 | |
| 790 | /** |
| 791 | * This property specifies a positive duration of time. |
| 792 | * |
| 793 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-2-5-duration.html |
| 794 | */ |
| 795 | if ($reminder == 0) |
| 796 | { |
| 797 | // trigger alert at event time |
| 798 | $duration = '-PT0S'; |
| 799 | } |
| 800 | else if ($reminder < 60) |
| 801 | { |
| 802 | // trigger alert X minutes in advance |
| 803 | $duration = '-PT' . $reminder . 'M'; |
| 804 | } |
| 805 | else |
| 806 | { |
| 807 | // trigger alert X hours in advance |
| 808 | $duration = '-PT' . floor($reminder / 60) . 'H' . ($reminder % 60) . 'M'; |
| 809 | } |
| 810 | |
| 811 | /** |
| 812 | * This property specifies when an alarm will trigger. |
| 813 | * |
| 814 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-6-3-trigger.html |
| 815 | */ |
| 816 | $this->addLine(array('TRIGGER', 'RELATED=START'), $duration); |
| 817 | |
| 818 | /** |
| 819 | * This property defines the action to be invoked when an alarm is triggered. |
| 820 | * |
| 821 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-6-1-action.html |
| 822 | */ |
| 823 | $this->addLine('ACTION', 'DISPLAY'); |
| 824 | |
| 825 | /** |
| 826 | * In a DISPLAY alarm, the intended alarm effect is for the text value of |
| 827 | * the "DESCRIPTION" property to be displayed to the user. |
| 828 | * |
| 829 | * @link https://icalendar.org/iCalendar-RFC-5545/3-8-1-5-description.html |
| 830 | */ |
| 831 | $this->addLine('DESCRIPTION', JText::translate('VAP_EXPORT_DRIVER_ICS_REMINDER_FIELD')); |
| 832 | |
| 833 | /** |
| 834 | * Closes the alarm properties. |
| 835 | * |
| 836 | * @see BEGIN:VALARM |
| 837 | */ |
| 838 | $this->addLine('END', 'VALARM'); |
| 839 | } |
| 840 | |
| 841 | /** |
| 842 | * Adds a line within the ICS buffer by caring of |
| 843 | * the iCalendar standards. |
| 844 | * |
| 845 | * @param mixed $rule Either the rule command or an array of commands to be concatenated (;). |
| 846 | * @param mixed $content Either the rule content or an array of contents to be concatenated (,). |
| 847 | * |
| 848 | * @return self This object to support chaining. |
| 849 | */ |
| 850 | protected function addLine($rule, $content = null) |
| 851 | { |
| 852 | // concat rules in case of array |
| 853 | if (is_array($rule)) |
| 854 | { |
| 855 | // rule with multiple parts, use semi-colon |
| 856 | $rule = implode(';', $rule); |
| 857 | } |
| 858 | |
| 859 | // concat contents in case of array |
| 860 | if (is_array($content)) |
| 861 | { |
| 862 | // multi-contents list, use comma |
| 863 | $content = implode(',', $content); |
| 864 | } |
| 865 | |
| 866 | // create line |
| 867 | if (is_null($content)) |
| 868 | { |
| 869 | // we had the full line within the rule |
| 870 | $line = $rule; |
| 871 | } |
| 872 | else |
| 873 | { |
| 874 | // merge rule and content |
| 875 | $line = $rule . ':' . $content; |
| 876 | } |
| 877 | |
| 878 | // split string every 73 characters (reserve 2 chars to include new line and space) |
| 879 | $chunks = str_split($line, 73); |
| 880 | |
| 881 | // merge lines togheter by using indentation technique, |
| 882 | // then add the line to the buffer |
| 883 | $this->ics .= implode("\n ", $chunks) . "\n"; |
| 884 | |
| 885 | return $this; |
| 886 | } |
| 887 | |
| 888 | /** |
| 889 | * Converts a UNIX timestamp to a valid ICS date string. |
| 890 | * |
| 891 | * @param integer $ts The timestamp to convert. |
| 892 | * |
| 893 | * @return string The formatted date. |
| 894 | */ |
| 895 | protected function tsToCal($ts) |
| 896 | { |
| 897 | return JFactory::getDate($ts)->format('Ymd\THis\Z'); |
| 898 | } |
| 899 | |
| 900 | /** |
| 901 | * Escapes a line value. |
| 902 | * |
| 903 | * @param string $str The string to escape. |
| 904 | * |
| 905 | * @return string The escaped string. |
| 906 | */ |
| 907 | protected function escape($str) |
| 908 | { |
| 909 | // escape reserved characters |
| 910 | return preg_replace('/([\,;])/','\\\$1', $str); |
| 911 | } |
| 912 | } |
| 913 |