Skip to content

Commit 7301cac

Browse files
committed
fix(#203): add iCal workaround where if ORGANIZER has no parameters
1 parent 5ca028b commit 7301cac

File tree

2 files changed

+169
-1
lines changed

2 files changed

+169
-1
lines changed

app/Services/Plugin/Parsers/IcalResponseParser.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ public function parse(Response $response): ?array
2525
}
2626

2727
try {
28-
$this->parser->parseString($body);
28+
// Workaround for om/icalparser v4.0.0 bug where it fails if ORGANIZER or ATTENDEE has no parameters.
29+
// When ORGANIZER or ATTENDEE has no parameters (no semicolon after the key),
30+
// IcalParser::parseRow returns an empty string for $middle instead of an array,
31+
// which causes a type error in a foreach loop in IcalParser::parseString.
32+
$normalizedBody = preg_replace('/^(ORGANIZER|ATTENDEE):/m', '$1;CN=Unknown:', $body);
33+
$this->parser->parseString($normalizedBody);
2934

3035
$events = $this->parser->getEvents()->sorted()->getArrayCopy();
3136
$windowStart = now()->subDays(7);
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use App\Models\Plugin;
6+
use Carbon\Carbon;
7+
use Illuminate\Support\Facades\Http;
8+
9+
test('iCal plugin parses Google Calendar invitation event', function (): void {
10+
// Set test time close to the event in the issue
11+
Carbon::setTestNow(Carbon::parse('2026-03-10 12:00:00', 'Europe/Budapest'));
12+
13+
$icalContent = <<<'ICS'
14+
BEGIN:VCALENDAR
15+
VERSION:2.0
16+
PRODID:-//Example Corp.//EN
17+
BEGIN:VEVENT
18+
DTSTART;TZID=Europe/Budapest:20260311T100000
19+
DTEND;TZID=Europe/Budapest:20260311T110000
20+
DTSTAMP:20260301T100000Z
21+
ORGANIZER:mailto:organizer@example.com
22+
UID:xxxxxxxxxxxxxxxxxxx@google.com
23+
SEQUENCE:0
24+
DESCRIPTION:-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~
25+
·:~:~:~:~:~:~:~:~::~:~::-
26+
Csatlakozás a Google Meet szolgáltatással: https://meet.google.com/xxx-xxxx-xxx
27+
28+
További információ a Meetről: https://support.google.com/a/users/answer/9282720
29+
30+
Kérjük, ne szerkeszd ezt a szakaszt.
31+
-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~
32+
·:~:~:~:~:~:~:~:~:~:~:~:~::~:~::-
33+
LOCATION:Meet XY Street, ZIP; https://meet.google.com/xxx-xxxx-xxx
34+
SUMMARY:Meeting
35+
STATUS:CONFIRMED
36+
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=ACCEPTED:mailto:participant1@example.com
37+
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=participant2@example.com;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=ACCEPTED:mailto:participant2@example.com
38+
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=participant3@example.com;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=NEEDS-ACTION:mailto:participant3@example.com
39+
END:VEVENT
40+
END:VCALENDAR
41+
ICS;
42+
43+
Http::fake([
44+
'example.com/calendar.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
45+
]);
46+
47+
$plugin = Plugin::factory()->create([
48+
'data_strategy' => 'polling',
49+
'polling_url' => 'https://example.com/calendar.ics',
50+
'polling_verb' => 'get',
51+
]);
52+
53+
$plugin->updateDataPayload();
54+
$plugin->refresh();
55+
56+
expect($plugin->data_payload)->not->toHaveKey('error');
57+
expect($plugin->data_payload)->toHaveKey('ical');
58+
expect($plugin->data_payload['ical'])->toHaveCount(1);
59+
expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Meeting');
60+
61+
Carbon::setTestNow();
62+
});
63+
64+
test('iCal plugin parses recurring events with multiple BYDAY correctly', function (): void {
65+
// Set test now to Monday 2024-03-25
66+
Carbon::setTestNow(Carbon::parse('2024-03-25 12:00:00', 'UTC'));
67+
68+
$icalContent = <<<'ICS'
69+
BEGIN:VCALENDAR
70+
VERSION:2.0
71+
PRODID:-//Example Corp.//EN
72+
BEGIN:VEVENT
73+
DESCRIPTION:XXX-REDACTED
74+
RRULE:FREQ=WEEKLY;UNTIL=20250604T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO
75+
UID:040000008200E00074C5B7101A82E00800000000E07AF34F937EDA01000000000000000
76+
01000000061F3E918C753424E8154B36E55452933
77+
SUMMARY:Recurring Meeting
78+
DTSTART;VALUE=DATE:20240326
79+
DTEND;VALUE=DATE:20240327
80+
DTSTAMP:20240605T082436Z
81+
CLASS:PUBLIC
82+
STATUS:CONFIRMED
83+
END:VEVENT
84+
END:VCALENDAR
85+
ICS;
86+
87+
Http::fake([
88+
'example.com/recurring.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
89+
]);
90+
91+
$plugin = Plugin::factory()->create([
92+
'data_strategy' => 'polling',
93+
'polling_url' => 'https://example.com/recurring.ics',
94+
'polling_verb' => 'get',
95+
]);
96+
97+
$plugin->updateDataPayload();
98+
$plugin->refresh();
99+
100+
$ical = $plugin->data_payload['ical'];
101+
102+
// Week of March 25, 2024:
103+
// Tue March 26: 2024-03-26 (DTSTART)
104+
// Thu March 28: 2024-03-28 (Recurrence)
105+
106+
// The parser window is now-7 days to now+30 days.
107+
// Window: 2024-03-18 to 2024-04-24.
108+
109+
$summaries = collect($ical)->pluck('SUMMARY');
110+
expect($summaries)->toContain('Recurring Meeting');
111+
112+
$dates = collect($ical)->map(fn ($event) => Carbon::parse($event['DTSTART'])->format('Y-m-d'))->values();
113+
114+
// Check if Tuesday March 26 is present
115+
expect($dates)->toContain('2024-03-26');
116+
117+
// Check if Thursday March 28 is present (THIS IS WHERE IT IS EXPECTED TO FAIL BASED ON THE ISSUE)
118+
expect($dates)->toContain('2024-03-28');
119+
120+
Carbon::setTestNow();
121+
});
122+
123+
test('iCal plugin parses recurring events with multiple BYDAY and specific DTSTART correctly', function (): void {
124+
// Set test now to Monday 2024-03-25
125+
Carbon::setTestNow(Carbon::parse('2024-03-25 12:00:00', 'UTC'));
126+
127+
$icalContent = <<<'ICS'
128+
BEGIN:VCALENDAR
129+
VERSION:2.0
130+
X-WR-TIMEZONE:UTC
131+
PRODID:-//Example Corp.//EN
132+
BEGIN:VEVENT
133+
RRULE:FREQ=WEEKLY;UNTIL=20250604T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO
134+
UID:recurring-event-2
135+
SUMMARY:Recurring Meeting 2
136+
DTSTART:20240326T100000
137+
DTEND:20240326T110000
138+
DTSTAMP:20240605T082436Z
139+
END:VEVENT
140+
END:VCALENDAR
141+
ICS;
142+
143+
Http::fake([
144+
'example.com/recurring2.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
145+
]);
146+
147+
$plugin = Plugin::factory()->create([
148+
'data_strategy' => 'polling',
149+
'polling_url' => 'https://example.com/recurring2.ics',
150+
'polling_verb' => 'get',
151+
]);
152+
153+
$plugin->updateDataPayload();
154+
$plugin->refresh();
155+
156+
$ical = $plugin->data_payload['ical'];
157+
$dates = collect($ical)->map(fn ($event) => Carbon::parse($event['DTSTART'])->format('Y-m-d'))->values();
158+
159+
expect($dates)->toContain('2024-03-26');
160+
expect($dates)->toContain('2024-03-28');
161+
162+
Carbon::setTestNow();
163+
});

0 commit comments

Comments
 (0)