Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.29% covered (success)
95.29%
162 / 170
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ICalendarConverter
95.27% covered (success)
95.27%
161 / 169
50.00% covered (danger)
50.00%
7 / 14
59
0.00% covered (danger)
0.00%
0 / 1
 toIcs
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
3
 toRecord
97.50% covered (success)
97.50%
39 / 40
0.00% covered (danger)
0.00%
0 / 1
5
 appendEventTiming
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 appendEventDetails
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
8
 extractEventDates
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 extractMeetingUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 extractReminderMinutes
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 extractPriority
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
6.17
 extractAttendeesJson
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 appendAttendees
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 mapStatusToIcs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 mapIcsToStatus
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 resolveStatusByDates
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 parseDateTime
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Dav\Infrastructure\Converter;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use DateTimeImmutable;
12use DateTimeZone;
13use Exception;
14use Sabre\VObject\Component\VCalendar;
15use Sabre\VObject\Component\VEvent;
16use 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 */
25final 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}