PluginProbe
VikBooking Hotel Booking Engine & PMS / trunk
VikBooking Hotel Booking Engine & PMS vtrunk
1.8.15 1.8.14 1.8.13 1.8.12 1.8.11 1.8.10 1.8.9 1.8.6 1.8.7 1.8.8 trunk 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.7.3 All 36 releases
vikbooking / admin / helpers / src / task / operator / ical.php

ical.php in VikBooking Hotel Booking Engine & PMS trunk, at admin/helpers/src/task/operator/ical.php

617 lines 19.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package VikBooking
4 * @subpackage core
5 * @author E4J s.r.l.
6 * @copyright Copyright (C) 2025 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 * Task operator iCal implementation.
16 *
17 * @since 1.18.0 (J) - 1.8.0 (WP)
18 */
19 final class VBOTaskOperatorIcal
20 {
21 /** @var array */
22 protected $operator = [];
23
24 /** @var object|null */
25 protected $permissions;
26
27 /** @var string */
28 protected $tool = '';
29
30 /** @var string */
31 protected $toolUri = '';
32
33 /** @var string|null */
34 protected $calendarSubscriber = null;
35
36 /** @var array */
37 private $events = [];
38
39 /** @var VBOPlatformDispatcherInterface */
40 private $dispatcher;
41
42 /**
43 * Proxy for immediately accessing the object.
44 *
45 * @return self
46 */
47 public static function getInstance()
48 {
49 return new static;
50 }
51
52 /**
53 * Class constructor.
54 */
55 public function __construct()
56 {
57 $this->dispatcher = VBOFactory::getPlatform()->getDispatcher();
58 }
59
60 /**
61 * Magic method used to access protected properties.
62 * Private properties are still not accessible.
63 *
64 * @inheritDoc
65 *
66 * @since 1.18.1 (J) - 1.8.1 (WP)
67 */
68 public function __get(string $name)
69 {
70 try {
71 // obtain class property details
72 $prop = (new ReflectionClass($this))->getProperty($name);
73
74 if ($prop->isPrivate()) {
75 // cannot access a private property
76 throw new DomainException;
77 }
78 } catch (Exception $error) {
79 // the property doesn't exist or is private
80 return null;
81 }
82
83 // grant access to protected properties instead
84 return $prop->getValue($this);
85 }
86
87 /**
88 * Sets the list of event objects.
89 *
90 * @param object[] $events List of event objects.
91 *
92 * @return self
93 */
94 public function setEvents(array $events)
95 {
96 $this->events = $events;
97
98 return $this;
99 }
100
101 /**
102 * Sets the current operator record.
103 *
104 * @param array|object $operator The operator information record.
105 *
106 * @return self
107 */
108 public function setOperator($operator)
109 {
110 if (is_array($operator) || is_object($operator)) {
111 $this->operator = (array) $operator;
112 }
113
114 return $this;
115 }
116
117 /**
118 * Sets the current operator permissions object.
119 *
120 * @param object $permissions The operator permissions object.
121 *
122 * @return self
123 */
124 public function setPermissions($permissions)
125 {
126 if (is_object($permissions)) {
127 $this->permissions = $permissions;
128 }
129
130 return $this;
131 }
132
133 /**
134 * Sets the name of the current operator tool.
135 *
136 * @param string $tool The operator tool identifier.
137 *
138 * @return self
139 */
140 public function setTool(string $tool)
141 {
142 $this->tool = $tool;
143
144 return $this;
145 }
146
147 /**
148 * Sets the URI for the current operator tool.
149 *
150 * @param string $uri The operator tool URI.
151 *
152 * @return self
153 */
154 public function setToolUri(string $uri)
155 {
156 $this->toolUri = VBOFactory::getPlatform()->getUri()->route($uri);
157
158 return $this;
159 }
160
161 /**
162 * Internally sets the calendar that will subscribe to the ICS.
163 * Useful to generate different contents depending on the receiver.
164 *
165 * @param ?string $calendarId Such as google, apple and so on.
166 *
167 * @return self
168 *
169 * @since 1.18.1 (J) - 1.8.1 (WP)
170 */
171 public function setCalendarSubscriber(?string $calendarId)
172 {
173 $this->calendarSubscriber = $calendarId ? strtolower($calendarId) : null;
174
175 return $this;
176 }
177
178 /**
179 * Builds the event UID.
180 *
181 * @param VBOTaskTaskregistry $task The task registry.
182 *
183 * @return string
184 */
185 public function getEventUid(VBOTaskTaskregistry $task)
186 {
187 return md5($task->getID() ?: rand());
188 }
189
190 /**
191 * Builds up the iCal calendar file content.
192 *
193 * @return string The full iCal calendar file content.
194 */
195 public function toString()
196 {
197 /**
198 * Starts the calendar declaration and build header and events.
199 *
200 * @link https://icalendar.org/iCalendar-RFC-5545/3-4-icalendar-object.html
201 */
202 return implode('', [
203 $this->addLine('BEGIN', 'VCALENDAR'),
204 $this->buildCalendarHead(),
205 $this->buildCalendarContent(),
206 $this->addLine('END', 'VCALENDAR')
207 ]);
208 }
209
210 /**
211 * Downloads the iCal calendar file content.
212 *
213 * @param mixed $app The CMS application.
214 * @param ?string $filename The file name to use for the download.
215 *
216 * @return void
217 *
218 * @since 1.18.1 (J) - 1.8.1 (WP)
219 */
220 public function download($app = null, ?string $filename = null)
221 {
222 // use default application if not provided
223 $app = $app ?: JFactory::getApplication();
224
225 if (!$filename) {
226 // use default name format: {ID}-{FIRST_NAME}-{TODAY}
227 $filename = sprintf(
228 '%d-%s-%s',
229 $this->operator['id'],
230 (string) $this->operator['first_name'],
231 date('Y-m-d')
232 );
233 }
234
235 // remove .ics extenion from file name
236 $filename = preg_replace("/\.ics$/i", '', $filename);
237
238 // generate ICS output
239 $ics = $this->toString();
240
241 // declare headers
242 $app->setHeader('Content-Type', 'text/calendar; charset=utf-8', true);
243 $app->setHeader('Content-Disposition', 'attachment; filename=' . $filename . '.ics');
244 $app->setHeader('Content-Length', strlen($ics));
245 $app->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
246 $app->setHeader('Pragma', 'no-cache');
247 $app->setHeader('Expires', '0');
248 $app->sendHeaders();
249
250 // start the ICS download
251 echo $ics;
252 }
253
254 /**
255 * Builds and returns the iCal calendar head string section.
256 *
257 * @return string
258 */
259 private function buildCalendarHead()
260 {
261 $ics = '';
262
263 // set up default head information
264 $head = [
265 'version' => '2.0',
266 'prodid' => '-//e4j//VikBooking ' . VIKBOOKING_SOFTWARE_VERSION . '//EN',
267 'calscale' => 'GREGORIAN',
268 'calname' => JText::translate('VBO_TASK_MANAGER') . ' - ' . trim($this->operator['first_name'] . ' ' . $this->operator['last_name']),
269 ];
270
271 /**
272 * Trigger event to allow the plugins to include custom options within the
273 * head of the ICS file.
274 *
275 * @param array &$head The default head data.
276 * @param mixed $handler The current handler instance.
277 *
278 * @return string Some extra rules to include at the end of the head.
279 *
280 * @since 1.18.1 (J) - 1.8.1 (WP)
281 */
282 $extra = $this->dispatcher->filter('onBuildHeadExportICS', [&$head, $this]);
283
284 /**
285 * This property specifies the identifier corresponding to the highest version number
286 * or the minimum and maximum range of the iCalendar specification that is required
287 * in order to interpret the iCalendar object.
288 *
289 * @link https://icalendar.org/iCalendar-RFC-5545/3-7-4-version.html
290 */
291 $ics .= $this->addLine('VERSION', $head['version']);
292
293 /**
294 * This property specifies the identifier for the product that created the iCalendar object.
295 *
296 * @link https://icalendar.org/iCalendar-RFC-5545/3-7-3-product-identifier.html
297 */
298 $ics .= $this->addLine('PRODID', $head['prodid']);
299
300 /**
301 * This property defines the calendar scale used for the calendar information
302 * specified in the iCalendar object.
303 *
304 * @link https://icalendar.org/iCalendar-RFC-5545/3-7-1-calendar-scale.html
305 */
306 $ics .= $this->addLine('CALSCALE', $head['calscale']);
307
308 /**
309 * This non standard property defines the default name that will be used
310 * when creating a new subscription.
311 *
312 * @since 1.18.1 (J) - 1.8.1 (WP)
313 */
314 $ics .= $this->addLine('X-WR-CALNAME', $head['calname']);
315
316 // append also the values that have been returned by the plugins
317 $ics .= implode('', array_filter($extra));
318
319 return $ics;
320 }
321
322 /**
323 * Builds and returns the iCal calendar content string.
324 *
325 * @return string
326 */
327 private function buildCalendarContent()
328 {
329 $content = '';
330
331 foreach ($this->events as $event) {
332 $content .= $this->buildCalendarEvent((array) $event);
333 }
334
335 return $content;
336 }
337
338 /**
339 * Builds and returns the iCal content string for the given event data.
340 *
341 * @param array $event The event (task) information record.
342 *
343 * @return string
344 */
345 private function buildCalendarEvent(array $event)
346 {
347 // wrap the event (task) record into a registry
348 $task = VBOTaskTaskregistry::getInstance($event);
349
350 // check if the task is currently un-assigned
351 $assigneeIds = $task->getAssigneeIds();
352 $unassigned_label = !$assigneeIds ? sprintf(' (%s)', JText::translate('VBO_UNASSIGNED')) : '';
353
354 // fetch room name and geo details
355 $roomInfo = VikBooking::getRoomInfo($task->getListingId(), $columns = ['name', 'params']);
356 if (!empty($roomInfo['params'])) {
357 $roomInfo['params'] = json_decode($roomInfo['params']);
358 }
359
360 $uri = null;
361
362 if ($this->toolUri) {
363 // use task direct link
364 $uri = new JUri($this->toolUri);
365 $uri->setVar('filters[calendar_type]', 'taskdetails');
366 $uri->setVar('filters[task_id]', $task->getID());
367 }
368
369 // add a new line at the end of each paragraph
370 $notes = preg_replace("/<\/p></", "</p>\n<", $task->getNotes());
371
372 // event description is built through various task values separated by a safe new-line
373 $description = implode('\n', array_filter([
374 // task status
375 $task->getStatusName() . $unassigned_label,
376 // listing name
377 $roomInfo['name'] ?? '',
378 // listing notes (plain text)
379 preg_replace("/\R/", "\\n", strip_tags($notes)),
380 ]));
381
382 if ($this->calendarSubscriber === 'google') {
383 // Google Calendar doesn't support the URI rule, therefore the task URL
384 // should be included directly within the description.
385 $description .= '\n\n' . $uri;
386 }
387
388 $data = [
389 'uid' => $this->getEventUid($task),
390 'created' => $task->getCreationDate(true, 'Ymd\THis\Z'),
391 'modified' => $task->getModificationDate(true, 'Ymd\THis\Z'),
392 'start' => $task->getDueDate(true, 'Ymd\THis\Z'),
393 'end' => $task->getFinishDate(true, 'Ymd\THis\Z') ?: $task->getDurationDate(true, 'Ymd\THis\Z'),
394 'summary' => $task->getTitle(),
395 'description' => $description,
396 'location' => $roomInfo['params']->geo->address ?? null,
397 'url' => (string) $uri,
398 'status' => 'CONFIRMED',
399 ];
400
401 // adjust ics status depending on task current status
402 if (in_array($task->getStatus(), ['notstarted', 'pending'])) {
403 $data['status'] = 'TENTATIVE';
404 } else if (in_array($task->getStatus(), ['cancelled', 'archived'])) {
405 $data['status'] = 'CANCELLED';
406 }
407
408 $ics = '';
409
410 /**
411 * Trigger event to allow the plugins to manipulate the event details before being included.
412 *
413 * @param array &$data The event data.
414 * @param mixed $task The task registry.
415 * @param mixed $handler The current handler instance.
416 *
417 * @return string Some extra rules to include at the end of the event body.
418 *
419 * @since 1.18.1 (J) - 1.8.1 (WP)
420 */
421 $extra = $this->dispatcher->filter('onBeforeBuildEventICS', [&$data, $task, $this]);
422
423 /**
424 * Provide a grouping of component properties that describe an event.
425 *
426 * @link https://icalendar.org/iCalendar-RFC-5545/3-6-1-event-component.html
427 */
428 $ics .= $this->addLine('BEGIN', 'VEVENT');
429
430 /**
431 * This property specifies the persistent, globally unique identifier for the
432 * iCalendar object. This can be used, for example, to identify duplicate calendar
433 * streams that a client may have been given access to.
434 *
435 * Generate a md5 string of the order number because "UID" values MUST NOT include any
436 * data that might identify a user, host, domain, or any other private sensitive information.
437 *
438 * @link https://icalendar.org/New-Properties-for-iCalendar-RFC-7986/5-3-uid-property.html
439 */
440 $ics .= $this->addLine('UID', $data['uid']);
441
442 /**
443 * This property specifies when the calendar component begins.
444 *
445 * @link https://icalendar.org/iCalendar-RFC-5545/3-8-2-4-date-time-start.html
446 *
447 * @since 1.18.1 (J) - 1.8.1 (WP) Changed from VALUE=DATE.
448 */
449 $ics .= $this->addLine(['DTSTART', 'VALUE=DATE-TIME'], $data['start']);
450
451 /**
452 * This property specifies the date and time that a calendar component ends.
453 *
454 * @link https://icalendar.org/iCalendar-RFC-5545/3-8-2-2-date-time-end.html
455 *
456 * @since 1.18.1 (J) - 1.8.1 (WP) Changed from VALUE=DATE.
457 */
458 $ics .= $this->addLine(['DTEND', 'VALUE=DATE-TIME'], $data['end']);
459
460 /**
461 * In the case of an iCalendar object that specifies a "METHOD" property, this property
462 * specifies the date and time that the instance of the iCalendar object was created.
463 * In the case of an iCalendar object that doesn't specify a "METHOD" property, this
464 * property specifies the date and time that the information associated with the calendar
465 * component was last revised in the calendar store.
466 *
467 * @link https://icalendar.org/iCalendar-RFC-5545/3-8-7-2-date-time-stamp.html
468 */
469 $ics .= $this->addLine('DTSTAMP', $data['created']);
470
471 /**
472 * In case an event is modified through a client, it updates the Last-Modified property to the
473 * current time. When the calendar is going to refresh an event, in case the Last-Modified is
474 * not specified or it is lower than the current one, the changes will be discarded.
475 * For this reason, it is needed to specify our internal modified date in order to refresh
476 * any existing events with the updated details.
477 *
478 * @link https://icalendar.org/iCalendar-RFC-5545/3-8-7-3-last-modified.html
479 *
480 * @since 1.18.1 (J) - 1.8.1 (WP)
481 */
482 if ($data['modified']) {
483 $ics .= $this->addLine('LAST-MODIFIED', $data['modified']);
484 }
485
486 /**
487 * This property may be used to convey a location where a more dynamic
488 * rendition of the calendar information can be found.
489 *
490 * Google Calendar DOES NOT support this rule.
491 *
492 * @link https://icalendar.org/New-Properties-for-iCalendar-RFC-7986/5-5-url-property.html
493 *
494 * @since 1.18.1 (J) - 1.8.1 (WP)
495 */
496 if ($this->calendarSubscriber !== 'google') {
497 $ics .= $this->addLine(['URL', 'VALUE=URI'], $data['url']);
498 }
499
500 /**
501 * This property defines a short summary or subject for the calendar component.
502 *
503 * @link https://icalendar.org/iCalendar-RFC-5545/3-8-1-12-summary.html
504 */
505 $ics .= $this->addLine('SUMMARY', $this->safeContent($data['summary']));
506
507 /**
508 * This property provides a more complete description of the calendar component
509 * than that provided by the "SUMMARY" property.
510 *
511 * @link https://icalendar.org/iCalendar-RFC-5545/3-8-1-5-description.html
512 */
513 if ($data['description']) {
514 $ics .= $this->addLine('DESCRIPTION', $this->safeContent($data['description']));
515 }
516
517 /**
518 * This property defines the intended venue for the activity defined by a calendar component.
519 *
520 * @link https://icalendar.org/iCalendar-RFC-5545/3-8-1-7-location.html
521 */
522 if ($data['location']) {
523 $ics .= $this->addLine('LOCATION', $this->safeContent($data['location']));
524 }
525
526 /**
527 * This property defines whether or not an event is transparent to busy time searches.
528 *
529 * @link https://icalendar.org/iCalendar-RFC-5545/3-8-2-7-time-transparency.html
530 *
531 * @since 1.18.1 (J) - 1.8.1 (WP)
532 */
533 $ics .= $this->addLine('TRANSP', 'OPAQUE');
534
535 /**
536 * This property defines the overall status or confirmation for the calendar component.
537 *
538 * @link https://icalendar.org/iCalendar-RFC-5545/3-8-1-11-status.html
539 *
540 * @since 1.18.1 (J) - 1.8.1 (WP)
541 */
542 $ics .= $this->addLine('STATUS', $data['status']);
543
544 // append also the values that have been returned by the plugins
545 $ics .= implode('', array_filter($extra));
546
547 /**
548 * Closes the event properties.
549 *
550 * @see BEGIN:VEVENT
551 */
552 $ics .= $this->addLine('END', 'VEVENT');
553
554 return $ics;
555 }
556
557 /**
558 * Adds a line within the ICS buffer by caring of the iCalendar standards.
559 *
560 * @param mixed $rule Either the rule command or an array of commands to be concatenated (;).
561 * @param mixed $content Either the rule content or an array of contents to be concatenated (,).
562 *
563 * @return string The compliant ICS declaration.
564 *
565 * @since 1.18.1 (J) - 1.8.1 (WP)
566 */
567 public function addLine($rule, $content = null)
568 {
569 // concat rules in case of array
570 if (is_array($rule))
571 {
572 // rule with multiple parts, use semi-colon
573 $rule = implode(';', $rule);
574 }
575
576 // concat contents in case of array
577 if (is_array($content))
578 {
579 // multi-contents list, use comma
580 $content = implode(',', $content);
581 }
582
583 // create line
584 if (is_null($content))
585 {
586 // we had the full line within the rule
587 $line = $rule;
588 }
589 else
590 {
591 // merge rule and content
592 $line = $rule . ':' . $content;
593 }
594
595 // split string every 73 characters (reserve 2 chars to include new line and space)
596 $chunks = str_split($line, 73);
597
598 // merge lines togheter by using indentation technique,
599 // then add the line to the buffer
600 return implode("\n ", $chunks) . "\n";
601 }
602
603 /**
604 * Escapes the characters of the given content.
605 *
606 * @param string $content The content string to make safe.
607 *
608 * @return string
609 *
610 * @since 1.18.1 (J) - 1.8.1 (WP) Changed visibility from private.
611 */
612 public function safeContent(string $content)
613 {
614 return preg_replace('/([\,;])/', '\\\$1', $content);
615 }
616 }
617