Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.29% |
162 / 170 |
|
50.00% |
7 / 14 |
CRAP | |
0.00% |
0 / 1 |
| ICalendarConverter | |
95.27% |
161 / 169 |
|
50.00% |
7 / 14 |
59 | |
0.00% |
0 / 1 |
| toIcs | |
96.55% |
28 / 29 |
|
0.00% |
0 / 1 |
3 | |||
| toRecord | |
97.50% |
39 / 40 |
|
0.00% |
0 / 1 |
5 | |||
| appendEventTiming | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| appendEventDetails | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
8 | |||
| extractEventDates | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| extractMeetingUrl | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| extractReminderMinutes | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
5.02 | |||
| extractPriority | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
6.17 | |||
| extractAttendeesJson | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| appendAttendees | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
7 | |||
| mapStatusToIcs | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| mapIcsToStatus | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
| resolveStatusByDates | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| parseDateTime | |
33.33% |
1 / 3 |
|
0.00% |
0 / 1 |
3.19 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Dav\Infrastructure\Converter; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use DateTimeImmutable; |
| 12 | use DateTimeZone; |
| 13 | use Exception; |
| 14 | use Sabre\VObject\Component\VCalendar; |
| 15 | use Sabre\VObject\Component\VEvent; |
| 16 | use Sabre\VObject\Reader; |
| 17 | |
| 18 | /** |
| 19 | * iCalendar RFC 5545 Domain Model Converter. |
| 20 | * |
| 21 | * Bi-directionally maps CRM calendar records to standard iCalendar (.ics) VEVENT components. |
| 22 | * |
| 23 | * @package App\Modules\Dav\Infrastructure\Converter |
| 24 | */ |
| 25 | final class ICalendarConverter |
| 26 | { |
| 27 | private const string PRODID = '-//Ammonly//Ammonly CRM Groupware 1.0//EN'; |
| 28 | private const string DEFAULT_TIMEZONE = 'Europe/Warsaw'; |
| 29 | private const string ONE_HOUR_OFFSET = '+1 hour'; |
| 30 | private const string DATETIME_FORMAT = 'Y-m-d H:i:s'; |
| 31 | |
| 32 | /** |
| 33 | * Converts an Ammonly CRM calendar database record into an RFC 5545 iCalendar string. |
| 34 | * |
| 35 | * @param array<string, mixed> $record Calendar record database attributes. |
| 36 | * @return string Serialized VCALENDAR (.ics) content. |
| 37 | */ |
| 38 | public function toIcs(array $record): string |
| 39 | { |
| 40 | $vcal = new VCalendar([ |
| 41 | 'PRODID' => self::PRODID, |
| 42 | 'VERSION' => '2.0', |
| 43 | 'CALSCALE' => 'GREGORIAN', |
| 44 | ]); |
| 45 | |
| 46 | $uid = (string) ($record['c_uid'] ?? ''); |
| 47 | if ($uid === '') { |
| 48 | $uid = sprintf('%s@ammonly.com', bin2hex(random_bytes(16))); |
| 49 | } |
| 50 | |
| 51 | $summary = (string) ($record['subject'] ?? 'Untitled Event'); |
| 52 | $isOpaque = ((int) ($record['c_isopaque'] ?? 1)) === 1; |
| 53 | $sequence = (int) ($record['sequence'] ?? 0); |
| 54 | $rawStatus = (string) ($record['status'] ?? 'planned'); |
| 55 | $isAllDay = ((int) ($record['is_all_day'] ?? 0)) === 1; |
| 56 | |
| 57 | $tz = new DateTimeZone(self::DEFAULT_TIMEZONE); |
| 58 | $dtStart = $this->parseDateTime((string) ($record['start_date'] ?? 'now'), $tz); |
| 59 | $dtEnd = $this->parseDateTime((string) ($record['end_date'] ?? self::ONE_HOUR_OFFSET), $tz); |
| 60 | |
| 61 | /** @var VEvent $event */ |
| 62 | $event = $vcal->add('VEVENT', [ |
| 63 | 'UID' => $uid, |
| 64 | 'SEQUENCE' => $sequence, |
| 65 | 'SUMMARY' => $summary, |
| 66 | 'TRANSP' => $isOpaque ? 'OPAQUE' : 'TRANSPARENT', |
| 67 | 'STATUS' => $this->mapStatusToIcs($rawStatus), |
| 68 | 'DTSTAMP' => new DateTimeImmutable('now', new DateTimeZone('UTC')), |
| 69 | ]); |
| 70 | |
| 71 | $this->appendEventTiming($event, $dtStart, $dtEnd, $isAllDay); |
| 72 | $this->appendEventDetails($event, $record, $summary); |
| 73 | $this->appendAttendees($event, $record['attendees'] ?? null); |
| 74 | $event->add('X-AMMONLY-STATUS', $rawStatus); |
| 75 | |
| 76 | return $vcal->serialize(); |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Converts raw RFC 5545 iCalendar content into an Ammonly CRM record array. |
| 81 | * |
| 82 | * @param string $icsContent Raw iCalendar string. |
| 83 | * @param string $defaultEtag Optional ETag to assign to the record. |
| 84 | * @return array<string, mixed> Mapped calendar database columns. |
| 85 | */ |
| 86 | public function toRecord(string $icsContent, string $defaultEtag = ''): array |
| 87 | { |
| 88 | $vcal = Reader::read($icsContent); |
| 89 | if (!($vcal instanceof VCalendar)) { |
| 90 | return []; |
| 91 | } |
| 92 | /** @var VEvent|null $event */ |
| 93 | $event = $vcal->VEVENT ?? null; |
| 94 | if (!($event instanceof VEvent)) { |
| 95 | return []; |
| 96 | } |
| 97 | |
| 98 | $uid = (string) ($event->UID ?? ''); |
| 99 | $summary = (string) ($event->SUMMARY ?? 'Untitled Event'); |
| 100 | $description = (string) ($event->DESCRIPTION ?? ''); |
| 101 | $location = (string) ($event->LOCATION ?? ''); |
| 102 | $transp = strtoupper((string) ($event->TRANSP ?? 'OPAQUE')); |
| 103 | $sequence = (int) ((string) ($event->SEQUENCE ?? '0')); |
| 104 | |
| 105 | [$dtStart, $dtEnd, $isAllDay] = $this->extractEventDates($event); |
| 106 | $startDateStr = $dtStart->format(self::DATETIME_FORMAT); |
| 107 | $endDateStr = $dtEnd->format(self::DATETIME_FORMAT); |
| 108 | |
| 109 | $meetingUrl = $this->extractMeetingUrl($event); |
| 110 | $reminderMinutes = $this->extractReminderMinutes($event); |
| 111 | $priority = $this->extractPriority($event); |
| 112 | $eventType = isset($event->CATEGORIES) ? strtolower((string) $event->CATEGORIES) : 'meeting'; |
| 113 | |
| 114 | $customStatus = (string) ($event->select('X-AMMONLY-STATUS')[0] ?? ''); |
| 115 | $icsStatus = strtoupper((string) ($event->STATUS ?? 'CONFIRMED')); |
| 116 | $status = $this->mapIcsToStatus($icsStatus, $customStatus, $startDateStr, $endDateStr); |
| 117 | |
| 118 | return [ |
| 119 | 'c_uid' => $uid, |
| 120 | 'c_etag' => $defaultEtag, |
| 121 | 'sequence' => $sequence, |
| 122 | 'c_isopaque' => ($transp === 'TRANSPARENT') ? 0 : 1, |
| 123 | 'is_all_day' => $isAllDay, |
| 124 | 'subject' => $summary, |
| 125 | 'status' => $status, |
| 126 | 'event_type' => $eventType, |
| 127 | 'priority' => $priority, |
| 128 | 'meeting_url' => $meetingUrl, |
| 129 | 'reminder_minutes' => $reminderMinutes, |
| 130 | 'description' => $description, |
| 131 | 'location' => $location, |
| 132 | 'start_date' => $startDateStr, |
| 133 | 'end_date' => $endDateStr, |
| 134 | 'attendees' => $this->extractAttendeesJson($event), |
| 135 | ]; |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * Appends DTSTART and DTEND taking all-day into consideration. |
| 140 | * |
| 141 | * @param VEvent $event Target VEVENT. |
| 142 | * @param DateTimeImmutable $dtStart Event start. |
| 143 | * @param DateTimeImmutable $dtEnd Event end. |
| 144 | * @param bool $isAllDay All day event flag. |
| 145 | */ |
| 146 | private function appendEventTiming( |
| 147 | VEvent $event, |
| 148 | DateTimeImmutable $dtStart, |
| 149 | DateTimeImmutable $dtEnd, |
| 150 | bool $isAllDay |
| 151 | ): void { |
| 152 | if ($isAllDay) { |
| 153 | $event->add('DTSTART', $dtStart->format('Ymd'), ['VALUE' => 'DATE']); |
| 154 | $event->add('DTEND', $dtEnd->format('Ymd'), ['VALUE' => 'DATE']); |
| 155 | return; |
| 156 | } |
| 157 | |
| 158 | $event->add('DTSTART', $dtStart); |
| 159 | $event->add('DTEND', $dtEnd); |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Appends description, location, meeting URL, priority, alarm, and categories. |
| 164 | * |
| 165 | * @param VEvent $event Target VEVENT. |
| 166 | * @param array<string, mixed> $record Source calendar record. |
| 167 | * @param string $summary Event subject. |
| 168 | */ |
| 169 | private function appendEventDetails(VEvent $event, array $record, string $summary): void |
| 170 | { |
| 171 | $description = (string) ($record['description'] ?? ''); |
| 172 | if ($description !== '') { |
| 173 | $event->add('DESCRIPTION', $description); |
| 174 | } |
| 175 | $location = (string) ($record['location'] ?? ''); |
| 176 | if ($location !== '') { |
| 177 | $event->add('LOCATION', $location); |
| 178 | } |
| 179 | $meetingUrl = (string) ($record['meeting_url'] ?? ''); |
| 180 | if ($meetingUrl !== '') { |
| 181 | $event->add('URL', $meetingUrl); |
| 182 | $event->add('X-CONFERENCE-URL', $meetingUrl); |
| 183 | } |
| 184 | |
| 185 | $reminderMinutes = (int) ($record['reminder_minutes'] ?? 0); |
| 186 | if ($reminderMinutes > 0) { |
| 187 | $event->add('VALARM', [ |
| 188 | 'ACTION' => 'DISPLAY', |
| 189 | 'TRIGGER' => sprintf('-PT%dM', $reminderMinutes), |
| 190 | 'DESCRIPTION' => $summary, |
| 191 | ]); |
| 192 | } |
| 193 | |
| 194 | $priority = (string) ($record['priority'] ?? 'normal'); |
| 195 | $event->add('PRIORITY', match (strtolower($priority)) { |
| 196 | 'high' => 1, |
| 197 | 'low' => 9, |
| 198 | default => 5, |
| 199 | }); |
| 200 | |
| 201 | $eventType = (string) ($record['event_type'] ?? 'meeting'); |
| 202 | $event->add('CATEGORIES', strtoupper($eventType)); |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * Extracts dates and all day indicator from VEVENT. |
| 207 | * |
| 208 | * @param VEvent $event Source VEVENT. |
| 209 | * @return array{0: DateTimeImmutable, 1: DateTimeImmutable, 2: int} [start, end, isAllDay] |
| 210 | */ |
| 211 | private function extractEventDates(VEvent $event): array |
| 212 | { |
| 213 | $startProp = $event->DTSTART ?? null; |
| 214 | $endProp = $event->DTEND ?? null; |
| 215 | |
| 216 | $isAllDay = 0; |
| 217 | if ($startProp !== null && isset($startProp['VALUE']) && strtoupper((string) $startProp['VALUE']) === 'DATE') { |
| 218 | $isAllDay = 1; |
| 219 | } |
| 220 | |
| 221 | $dtStart = $startProp?->getDateTime() ?? new DateTimeImmutable('now'); |
| 222 | $dtEnd = $endProp?->getDateTime() ?? $dtStart->modify(self::ONE_HOUR_OFFSET); |
| 223 | |
| 224 | return [ |
| 225 | DateTimeImmutable::createFromInterface($dtStart), |
| 226 | DateTimeImmutable::createFromInterface($dtEnd), |
| 227 | $isAllDay, |
| 228 | ]; |
| 229 | } |
| 230 | |
| 231 | /** |
| 232 | * Extracts meeting URL from URL or X-CONFERENCE-URL property. |
| 233 | */ |
| 234 | private function extractMeetingUrl(VEvent $event): string |
| 235 | { |
| 236 | if (isset($event->URL)) { |
| 237 | return (string) $event->URL; |
| 238 | } |
| 239 | $conf = $event->select('X-CONFERENCE-URL')[0] ?? null; |
| 240 | return $conf !== null ? (string) $conf : ''; |
| 241 | } |
| 242 | |
| 243 | /** |
| 244 | * Extracts reminder minutes from VALARM component. |
| 245 | */ |
| 246 | private function extractReminderMinutes(VEvent $event): int |
| 247 | { |
| 248 | if (!isset($event->VALARM)) { |
| 249 | return 15; |
| 250 | } |
| 251 | $alarm = $event->VALARM; |
| 252 | $trigger = isset($alarm->TRIGGER) ? (string) $alarm->TRIGGER : ''; |
| 253 | if (preg_match('/-?P(?:T(?:(\d+)H)?(?:(\d+)M)?)?/i', $trigger, $matches)) { |
| 254 | $hours = (int) ($matches[1] ?? 0); |
| 255 | $mins = (int) ($matches[2] ?? 0); |
| 256 | $total = ($hours * 60) + $mins; |
| 257 | if ($total > 0) { |
| 258 | return $total; |
| 259 | } |
| 260 | } |
| 261 | return 15; |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * Extracts CRM priority from RFC 5545 PRIORITY property. |
| 266 | */ |
| 267 | private function extractPriority(VEvent $event): string |
| 268 | { |
| 269 | if (!isset($event->PRIORITY)) { |
| 270 | return 'normal'; |
| 271 | } |
| 272 | $val = (int) ((string) $event->PRIORITY); |
| 273 | return match (true) { |
| 274 | $val >= 1 && $val <= 4 => 'high', |
| 275 | $val >= 6 => 'low', |
| 276 | default => 'normal', |
| 277 | }; |
| 278 | } |
| 279 | |
| 280 | /** |
| 281 | * Extracts attendees list as JSON encoded string. |
| 282 | */ |
| 283 | private function extractAttendeesJson(VEvent $event): string |
| 284 | { |
| 285 | $attendeesList = []; |
| 286 | if (isset($event->ATTENDEE)) { |
| 287 | foreach ($event->ATTENDEE as $att) { |
| 288 | $email = str_ireplace('mailto:', '', (string) $att); |
| 289 | $name = (string) ($att['CN'] ?? $email); |
| 290 | $attendeesList[] = ['name' => $name, 'email' => $email]; |
| 291 | } |
| 292 | } |
| 293 | return (string) json_encode($attendeesList, JSON_UNESCAPED_UNICODE); |
| 294 | } |
| 295 | |
| 296 | /** |
| 297 | * Appends attendee components to the VEVENT from JSON or array metadata. |
| 298 | * |
| 299 | * @param VEvent $event Sabre VObject VEVENT component. |
| 300 | * @param mixed $rawAttendees JSON string or array of attendees. |
| 301 | * @return void |
| 302 | */ |
| 303 | private function appendAttendees(VEvent $event, mixed $rawAttendees): void |
| 304 | { |
| 305 | if (is_string($rawAttendees) && trim($rawAttendees) !== '') { |
| 306 | $rawAttendees = json_decode($rawAttendees, true); |
| 307 | } |
| 308 | if (!is_array($rawAttendees)) { |
| 309 | return; |
| 310 | } |
| 311 | |
| 312 | foreach ($rawAttendees as $att) { |
| 313 | $email = $att['email'] ?? ''; |
| 314 | if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { |
| 315 | continue; |
| 316 | } |
| 317 | $cn = $att['name'] ?? $email; |
| 318 | $event->add('ATTENDEE', 'mailto:' . $email, ['CN' => $cn, 'PARTSTAT' => 'ACCEPTED']); |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | /** |
| 323 | * Maps local CRM status to RFC 5545 iCalendar STATUS. |
| 324 | * |
| 325 | * In RFC 5545 VEVENT STATUS property allows only: TENTATIVE, CONFIRMED, CANCELLED. |
| 326 | * |
| 327 | * @param string $status Local CRM calendar status. |
| 328 | * @return string Standard iCalendar status string. |
| 329 | */ |
| 330 | private function mapStatusToIcs(string $status): string |
| 331 | { |
| 332 | return match (strtolower($status)) { |
| 333 | 'cancelled', 'postponed' => 'CANCELLED', |
| 334 | default => 'CONFIRMED', |
| 335 | }; |
| 336 | } |
| 337 | |
| 338 | /** |
| 339 | * Maps RFC 5545 iCalendar status and optional X-AMMONLY-STATUS to CRM status. |
| 340 | * |
| 341 | * @param string $icsStatus RFC 5545 status (CONFIRMED, TENTATIVE, CANCELLED). |
| 342 | * @param string $customStatus X-AMMONLY-STATUS property value if present. |
| 343 | * @param string $startDateStr Event start date string. |
| 344 | * @param string $endDateStr Event end date string. |
| 345 | * @return string Valid CRM calendar status. |
| 346 | */ |
| 347 | private function mapIcsToStatus( |
| 348 | string $icsStatus, |
| 349 | string $customStatus, |
| 350 | string $startDateStr, |
| 351 | string $endDateStr |
| 352 | ): string { |
| 353 | $allowedStatuses = ['planned', 'in_progress', 'overdue', 'completed', 'cancelled', 'postponed']; |
| 354 | $cleanCustom = strtolower(trim($customStatus)); |
| 355 | if (in_array($cleanCustom, $allowedStatuses, true)) { |
| 356 | return $cleanCustom; |
| 357 | } |
| 358 | |
| 359 | if ($icsStatus === 'CANCELLED') { |
| 360 | return 'cancelled'; |
| 361 | } |
| 362 | |
| 363 | return $this->resolveStatusByDates($startDateStr, $endDateStr); |
| 364 | } |
| 365 | |
| 366 | /** |
| 367 | * Resolves status based on chronological comparison with start and end dates. |
| 368 | * |
| 369 | * @param string $startDateStr Event start date string. |
| 370 | * @param string $endDateStr Event end date string. |
| 371 | * @return string Inferred status. |
| 372 | */ |
| 373 | private function resolveStatusByDates(string $startDateStr, string $endDateStr): string |
| 374 | { |
| 375 | $now = time(); |
| 376 | $startTs = strtotime($startDateStr) ?: $now; |
| 377 | $endTs = strtotime($endDateStr) ?: ($startTs + 3600); |
| 378 | |
| 379 | if ($now < $startTs) { |
| 380 | return 'planned'; |
| 381 | } |
| 382 | |
| 383 | return ($now < $endTs) ? 'in_progress' : 'overdue'; |
| 384 | } |
| 385 | |
| 386 | /** |
| 387 | * Parses a date string safely into DateTimeImmutable. |
| 388 | * |
| 389 | * @param string $dateStr Date string representation. |
| 390 | * @param DateTimeZone $timezone Target timezone. |
| 391 | * @return DateTimeImmutable Parsed datetime object. |
| 392 | */ |
| 393 | private function parseDateTime(string $dateStr, DateTimeZone $timezone): DateTimeImmutable |
| 394 | { |
| 395 | try { |
| 396 | return new DateTimeImmutable($dateStr, $timezone); |
| 397 | } catch (Exception) { |
| 398 | return new DateTimeImmutable('now', $timezone); |
| 399 | } |
| 400 | } |
| 401 | } |