From c43295802a0ad5efa829dd70521f46a6681d9dfb Mon Sep 17 00:00:00 2001 From: Bracken Dawson Date: Fri, 26 Jan 2024 17:42:00 +0000 Subject: [PATCH] allow merge of overlapping events --- README.md | 3 +- fixtures/calMerged.ics | 276 +++++++++++++++++++++++++++ fixtures/calUnmerged.ics | 402 +++++++++++++++++++++++++++++++++++++++ fixtures_test.go | 4 + server.go | 96 +++++++++- server_test.go | 14 ++ 6 files changed, 793 insertions(+), 2 deletions(-) create mode 100644 fixtures/calMerged.ics create mode 100644 fixtures/calUnmerged.ics diff --git a/README.md b/README.md index 78aef0d..e5954e3 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,14 @@ log level (default "info") ### Client Enter the URL into your webcal client: ``` -webcal:///?cal=[&inc= ...][&exc= ...] +webcal:///?cal=[&inc= ...][&exc= ...][&mrg=true] ``` Where: * **this_server** is the address and path hosting this program. * **cal** your upstream webcal link, including the protocol scheme (webcal, http, https) (Required). * **inc** query for events to include in the form `=` where **FIELD** is an iCal event field (eg `SUMMARY`) and **regexp** is an unbound regular expression. Multiple inc arguments are allowed, (default `SUMMARY=.*`). * **exc** query for events to exclude in the form `=` where **FIELD** is an iCal event field (eg `SUMMARY`) and **regexp** is an unbound regular expression. Multiple inc arguments are allowed. +* **mrg** optional parameter to merge overlapping events into the one event. eg: ``` diff --git a/fixtures/calMerged.ics b/fixtures/calMerged.ics new file mode 100644 index 0000000..93a6c0c --- /dev/null +++ b/fixtures/calMerged.ics @@ -0,0 +1,276 @@ +BEGIN:VCALENDAR +CALSCALE:GREGORIAN +PRODID:-//Apple Inc.//macOS 14.2.1//EN +VERSION:2.0 +X-APPLE-CALENDAR-COLOR:#1BADF8 +X-WR-CALNAME:webcalmerge +BEGIN:VTIMEZONE +TZID:Europe/London +BEGIN:DAYLIGHT +DTSTART:19810329T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:BST +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19961027T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:GMT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20240126T142833Z +DESCRIPTION:note +DTEND;TZID=Europe/London:20240122T100000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T090000 +LAST-MODIFIED:20240126T160342Z +SEQUENCE:1 +SUMMARY:not overlap 1 +TRANSP:OPAQUE +UID:2B91A4FD-54B3-43B0-ADE3-820E82BF4E88 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142844Z +DTEND;TZID=Europe/London:20240122T120000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T110000 +LAST-MODIFIED:20240126T142854Z +SEQUENCE:1 +SUMMARY:not overlap 2 +TRANSP:OPAQUE +UID:8D66C7BF-89E7-4C03-A7EB-6ABA70DFB927 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142904Z +DTEND;TZID=Europe/London:20240122T140000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T130000 +LAST-MODIFIED:20240126T142911Z +SEQUENCE:1 +SUMMARY:not overlap 3 +TRANSP:OPAQUE +UID:8C9B208F-A427-452F-A119-DD95DBC3A93B +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142913Z +DTEND;TZID=Europe/London:20240122T160000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T150000 +LAST-MODIFIED:20240126T142927Z +SEQUENCE:2 +SUMMARY:not overlap 4 +TRANSP:OPAQUE +UID:E755A482-507E-44BD-A15A-5132558E9483 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142929Z +DTEND;TZID=Europe/London:20240122T170000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T160000 +LAST-MODIFIED:20240126T142938Z +SEQUENCE:1 +SUMMARY:not overlap 5 +TRANSP:OPAQUE +UID:26BD1F01-96FD-464F-A2F2-CA73C6822E50 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142941Z +DTEND;TZID=Europe/London:20240122T190000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T180000 +LAST-MODIFIED:20240126T142949Z +SEQUENCE:1 +SUMMARY:not overlap 6 +TRANSP:OPAQUE +UID:3083DEF3-A55B-41B0-8FD0-17EC2C0ADDA1 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142953Z +DTEND;TZID=Europe/London:20240122T200000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T190000 +LAST-MODIFIED:20240126T143003Z +SEQUENCE:1 +SUMMARY:not overlap 7 +TRANSP:OPAQUE +UID:561BD7D2-46CB-4CA2-BA2C-95D5D8ABB2FF +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143007Z +DTEND;TZID=Europe/London:20240122T210000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T200000 +LAST-MODIFIED:20240126T143015Z +SEQUENCE:1 +SUMMARY:not overlap 8 +TRANSP:OPAQUE +UID:6DC6C23E-F3DB-42E3-B55A-AA7C6D99458F +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143019Z +DESCRIPTION:note 3\n\n---\noverlap 1\n\nnote 2 +DTEND;TZID=Europe/London:20240123T100000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T090000 +LAST-MODIFIED:20240126T160403Z +SEQUENCE:1 +SUMMARY:overlap2 + overlap 1 +TRANSP:OPAQUE +UID:BDA1550E-2D67-42FC-AC3B-0FEB432BE791 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143055Z +DTEND;TZID=Europe/London:20240123T120000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T110000 +LAST-MODIFIED:20240126T143149Z +SEQUENCE:2 +SUMMARY:overlap3 + overlap 4 + overlap 5 +TRANSP:OPAQUE +UID:8B4E6A07-3F62-4E30-AE65-021FC233AEB4 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143144Z +DTEND;TZID=Europe/London:20240123T143000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T130000 +LAST-MODIFIED:20240126T143223Z +SEQUENCE:1 +SUMMARY:overlap 2 end + overlap 2 start +TRANSP:OPAQUE +UID:965A7E2F-69A3-4235-9D01-5663AC311AE8 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143218Z +DTEND;TZID=Europe/London:20240123T170000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T150000 +LAST-MODIFIED:20240126T143250Z +SEQUENCE:2 +SUMMARY:overlap 3 end + overlap 3 start end + overlap 3 start +TRANSP:OPAQUE +UID:2806BD83-F35D-4451-BF72-1D27D999BE6C +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143332Z +DTEND;TZID=Europe/London:20240125T113000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240124T090000 +LAST-MODIFIED:20240126T143401Z +SEQUENCE:2 +SUMMARY:muldiday not overlap 1 +TRANSP:OPAQUE +UID:4EDED467-5361-4CB5-B183-1A95843DE724 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143357Z +DTEND;TZID=Europe/London:20240126T113000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240125T123000 +LAST-MODIFIED:20240126T143407Z +SEQUENCE:1 +SUMMARY:multiday not overlap 2 +TRANSP:OPAQUE +UID:BFE8498D-3008-4853-8F1E-86207654ADE1 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143429Z +DTEND;TZID=Europe/London:20240131T134500 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240129T091500 +LAST-MODIFIED:20240126T143433Z +SEQUENCE:1 +SUMMARY:multiday overlap 1 + multiday overlap 2 +TRANSP:OPAQUE +UID:5E37FE07-CCA9-4701-8AEB-9466FBADFB50 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143456Z +DTEND;VALUE=DATE:20240203 +DTSTAMP:20240126T160428Z +DTSTART;VALUE=DATE:20240202 +LAST-MODIFIED:20240126T143505Z +SEQUENCE:1 +SUMMARY:allday not overlap +TRANSP:TRANSPARENT +UID:F92F6FC3-F164-46B5-BDE0-77E037300E36 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder +TRIGGER:-PT15H +UID:C48B7E14-AE2C-4C6F-8A9F-1F0F4DFD27AC +X-APPLE-DEFAULT-ALARM:TRUE +X-WR-ALARMUID:C48B7E14-AE2C-4C6F-8A9F-1F0F4DFD27AC +END:VALARM +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143527Z +DTEND;VALUE=DATE:20240210 +DTSTAMP:20240126T160428Z +DTSTART;VALUE=DATE:20240205 +LAST-MODIFIED:20240126T143527Z +SEQUENCE:0 +SUMMARY:allday overlap end + allday overlap start +TRANSP:TRANSPARENT +UID:A85C6B39-2A05-43B6-A142-CEF02F68871D +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder +TRIGGER:-PT15H +UID:C1D10B91-6452-4100-923F-FF421D84EB36 +X-APPLE-DEFAULT-ALARM:TRUE +X-WR-ALARMUID:C1D10B91-6452-4100-923F-FF421D84EB36 +END:VALARM +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143550Z +DTEND;TZID=Europe/London:20240212T100000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240212T090000 +LAST-MODIFIED:20240126T143557Z +SEQUENCE:1 +SUMMARY:overlap contains + overlap contained +TRANSP:OPAQUE +UID:B35AE4BD-AE90-495B-982A-EE1B3EF88894 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +END:VCALENDAR diff --git a/fixtures/calUnmerged.ics b/fixtures/calUnmerged.ics new file mode 100644 index 0000000..5f7fec1 --- /dev/null +++ b/fixtures/calUnmerged.ics @@ -0,0 +1,402 @@ +BEGIN:VCALENDAR +CALSCALE:GREGORIAN +PRODID:-//Apple Inc.//macOS 14.2.1//EN +VERSION:2.0 +X-APPLE-CALENDAR-COLOR:#1BADF8 +X-WR-CALNAME:webcalmerge +BEGIN:VTIMEZONE +TZID:Europe/London +BEGIN:DAYLIGHT +DTSTART:19810329T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:BST +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19961027T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:GMT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20240126T142833Z +DESCRIPTION:note +DTEND;TZID=Europe/London:20240122T100000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T090000 +LAST-MODIFIED:20240126T160342Z +SEQUENCE:1 +SUMMARY:not overlap 1 +TRANSP:OPAQUE +UID:2B91A4FD-54B3-43B0-ADE3-820E82BF4E88 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142844Z +DTEND;TZID=Europe/London:20240122T120000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T110000 +LAST-MODIFIED:20240126T142854Z +SEQUENCE:1 +SUMMARY:not overlap 2 +TRANSP:OPAQUE +UID:8D66C7BF-89E7-4C03-A7EB-6ABA70DFB927 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142904Z +DTEND;TZID=Europe/London:20240122T140000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T130000 +LAST-MODIFIED:20240126T142911Z +SEQUENCE:1 +SUMMARY:not overlap 3 +TRANSP:OPAQUE +UID:8C9B208F-A427-452F-A119-DD95DBC3A93B +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142913Z +DTEND;TZID=Europe/London:20240122T160000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T150000 +LAST-MODIFIED:20240126T142927Z +SEQUENCE:2 +SUMMARY:not overlap 4 +TRANSP:OPAQUE +UID:E755A482-507E-44BD-A15A-5132558E9483 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142929Z +DTEND;TZID=Europe/London:20240122T170000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T160000 +LAST-MODIFIED:20240126T142938Z +SEQUENCE:1 +SUMMARY:not overlap 5 +TRANSP:OPAQUE +UID:26BD1F01-96FD-464F-A2F2-CA73C6822E50 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142941Z +DTEND;TZID=Europe/London:20240122T190000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T180000 +LAST-MODIFIED:20240126T142949Z +SEQUENCE:1 +SUMMARY:not overlap 6 +TRANSP:OPAQUE +UID:3083DEF3-A55B-41B0-8FD0-17EC2C0ADDA1 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T142953Z +DTEND;TZID=Europe/London:20240122T200000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T190000 +LAST-MODIFIED:20240126T143003Z +SEQUENCE:1 +SUMMARY:not overlap 7 +TRANSP:OPAQUE +UID:561BD7D2-46CB-4CA2-BA2C-95D5D8ABB2FF +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143007Z +DTEND;TZID=Europe/London:20240122T210000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240122T200000 +LAST-MODIFIED:20240126T143015Z +SEQUENCE:1 +SUMMARY:not overlap 8 +TRANSP:OPAQUE +UID:6DC6C23E-F3DB-42E3-B55A-AA7C6D99458F +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143019Z +DESCRIPTION:note 3 +DTEND;TZID=Europe/London:20240123T100000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T090000 +LAST-MODIFIED:20240126T160403Z +SEQUENCE:1 +SUMMARY:overlap2 +TRANSP:OPAQUE +UID:BDA1550E-2D67-42FC-AC3B-0FEB432BE791 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143040Z +DESCRIPTION:note 2 +DTEND;TZID=Europe/London:20240123T100000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T090000 +LAST-MODIFIED:20240126T160355Z +SEQUENCE:2 +SUMMARY:overlap 1 +TRANSP:OPAQUE +UID:3F3BECD2-7796-4DE6-83A1-7C03F2BCC268 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143055Z +DTEND;TZID=Europe/London:20240123T120000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T110000 +LAST-MODIFIED:20240126T143149Z +SEQUENCE:2 +SUMMARY:overlap3 +TRANSP:OPAQUE +UID:8B4E6A07-3F62-4E30-AE65-021FC233AEB4 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143116Z +DTEND;TZID=Europe/London:20240123T120000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T110000 +LAST-MODIFIED:20240126T143150Z +SEQUENCE:2 +SUMMARY:overlap 4 +TRANSP:OPAQUE +UID:F3A1EDF1-7F39-425D-8C6D-886CC75CFE33 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143124Z +DTEND;TZID=Europe/London:20240123T120000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T110000 +LAST-MODIFIED:20240126T143153Z +SEQUENCE:2 +SUMMARY:overlap 5 +TRANSP:OPAQUE +UID:9833C67C-BCF7-4B99-80FC-A83EAA65C8EE +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143144Z +DTEND;TZID=Europe/London:20240123T140000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T130000 +LAST-MODIFIED:20240126T143223Z +SEQUENCE:1 +SUMMARY:overlap 2 end +TRANSP:OPAQUE +UID:965A7E2F-69A3-4235-9D01-5663AC311AE8 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143200Z +DTEND;TZID=Europe/London:20240123T143000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T133000 +LAST-MODIFIED:20240126T143231Z +SEQUENCE:1 +SUMMARY:overlap 2 start +TRANSP:OPAQUE +UID:07E3AA23-CD6E-4893-98ED-FCF81CC62F33 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143218Z +DTEND;TZID=Europe/London:20240123T154500 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T150000 +LAST-MODIFIED:20240126T143250Z +SEQUENCE:2 +SUMMARY:overlap 3 end +TRANSP:OPAQUE +UID:2806BD83-F35D-4451-BF72-1D27D999BE6C +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143243Z +DTEND;TZID=Europe/London:20240123T163000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T153000 +LAST-MODIFIED:20240126T143253Z +SEQUENCE:1 +SUMMARY:overlap 3 start end +TRANSP:OPAQUE +UID:4192EB48-95F2-47FB-9E6C-53FCE9162B30 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143305Z +DTEND;TZID=Europe/London:20240123T170000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240123T160000 +LAST-MODIFIED:20240126T143306Z +SEQUENCE:1 +SUMMARY:overlap 3 start +TRANSP:OPAQUE +UID:F566C102-B611-4002-8FEF-2DF32642FA42 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143332Z +DTEND;TZID=Europe/London:20240125T113000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240124T090000 +LAST-MODIFIED:20240126T143401Z +SEQUENCE:2 +SUMMARY:muldiday not overlap 1 +TRANSP:OPAQUE +UID:4EDED467-5361-4CB5-B183-1A95843DE724 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143357Z +DTEND;TZID=Europe/London:20240126T113000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240125T123000 +LAST-MODIFIED:20240126T143407Z +SEQUENCE:1 +SUMMARY:multiday not overlap 2 +TRANSP:OPAQUE +UID:BFE8498D-3008-4853-8F1E-86207654ADE1 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143429Z +DTEND;TZID=Europe/London:20240130T111500 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240129T091500 +LAST-MODIFIED:20240126T143433Z +SEQUENCE:1 +SUMMARY:multiday overlap 1 +TRANSP:OPAQUE +UID:5E37FE07-CCA9-4701-8AEB-9466FBADFB50 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143438Z +DTEND;TZID=Europe/London:20240131T134500 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240130T101500 +LAST-MODIFIED:20240126T143445Z +SEQUENCE:2 +SUMMARY:multiday overlap 2 +TRANSP:OPAQUE +UID:750C2C46-5EBC-4874-8972-C5265446417C +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143456Z +DTEND;VALUE=DATE:20240203 +DTSTAMP:20240126T160428Z +DTSTART;VALUE=DATE:20240202 +LAST-MODIFIED:20240126T143505Z +SEQUENCE:1 +SUMMARY:allday not overlap +TRANSP:TRANSPARENT +UID:F92F6FC3-F164-46B5-BDE0-77E037300E36 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder +TRIGGER:-PT15H +UID:C48B7E14-AE2C-4C6F-8A9F-1F0F4DFD27AC +X-APPLE-DEFAULT-ALARM:TRUE +X-WR-ALARMUID:C48B7E14-AE2C-4C6F-8A9F-1F0F4DFD27AC +END:VALARM +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143527Z +DTEND;VALUE=DATE:20240208 +DTSTAMP:20240126T160428Z +DTSTART;VALUE=DATE:20240205 +LAST-MODIFIED:20240126T143527Z +SEQUENCE:0 +SUMMARY:allday overlap end +TRANSP:TRANSPARENT +UID:A85C6B39-2A05-43B6-A142-CEF02F68871D +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder +TRIGGER:-PT15H +UID:C1D10B91-6452-4100-923F-FF421D84EB36 +X-APPLE-DEFAULT-ALARM:TRUE +X-WR-ALARMUID:C1D10B91-6452-4100-923F-FF421D84EB36 +END:VALARM +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143537Z +DTEND;VALUE=DATE:20240210 +DTSTAMP:20240126T160428Z +DTSTART;VALUE=DATE:20240207 +LAST-MODIFIED:20240126T143543Z +SEQUENCE:2 +SUMMARY:allday overlap start +TRANSP:TRANSPARENT +UID:DF20BEE7-FC69-4E5C-B996-A2B6F9780DDA +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder +TRIGGER:-PT15H +UID:BA477D1E-279F-4303-B13A-52BDA845E99B +X-APPLE-DEFAULT-ALARM:TRUE +X-WR-ALARMUID:BA477D1E-279F-4303-B13A-52BDA845E99B +END:VALARM +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143550Z +DTEND;TZID=Europe/London:20240212T100000 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240212T090000 +LAST-MODIFIED:20240126T143557Z +SEQUENCE:1 +SUMMARY:overlap contains +TRANSP:OPAQUE +UID:B35AE4BD-AE90-495B-982A-EE1B3EF88894 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +BEGIN:VEVENT +CREATED:20240126T143603Z +DTEND;TZID=Europe/London:20240212T094500 +DTSTAMP:20240126T160428Z +DTSTART;TZID=Europe/London:20240212T091500 +LAST-MODIFIED:20240126T143610Z +SEQUENCE:2 +SUMMARY:overlap contained +TRANSP:OPAQUE +UID:61C96A90-80B2-45EB-BE6F-D2A0AE8AC9A5 +X-APPLE-CREATOR-IDENTITY:com.apple.calendar +X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 +END:VEVENT +END:VCALENDAR diff --git a/fixtures_test.go b/fixtures_test.go index 1b19cd3..269ac80 100644 --- a/fixtures_test.go +++ b/fixtures_test.go @@ -17,4 +17,8 @@ var ( calShuffled []byte //go:embed fixtures/calEventWithNoStart.ics calEventWithNoStart []byte + //go:embed fixtures/calUnmerged.ics + calUnmerged []byte + //go:embed fixtures/calMerged.ics + calMerged []byte ) diff --git a/server.go b/server.go index 32665bb..b6fda75 100644 --- a/server.go +++ b/server.go @@ -7,8 +7,10 @@ import ( "net/url" "regexp" "sort" + "strconv" "strings" "sync" + "time" ics "github.com/arran4/golang-ical" "github.com/google/uuid" @@ -83,11 +85,21 @@ func (s *Server) HandleWebcal(w http.ResponseWriter, r *http.Request) { } excludes, err := parseMatchers(r.URL.Query()["exc"]) if err != nil { - log.Errorf("Bad exc %q: %s", r.URL.Query()["exc"], err) + log.Errorf("Bad exc %q: %s", r.URL.Query().Get("exc"), err) http.Error(w, err.Error(), http.StatusBadRequest) return } + var merge bool + if mergeArg := r.URL.Query().Get("mrg"); mergeArg != "" { + merge, err = strconv.ParseBool(mergeArg) + if err != nil { + log.Errorf("Bad mrg argument: %s", err) + http.Error(w, "Invalid mrg argument", http.StatusBadRequest) + return + } + } + log.Info("Fetching: ", upstreamURL) upstream, err := s.fetch(upstreamURL) if err != nil { @@ -128,6 +140,16 @@ func (s *Server) HandleWebcal(w http.ResponseWriter, r *http.Request) { http.Error(w, "Calendar has event without start", http.StatusBadRequest) return } + + if merge { + events, err = mergeEvents(events) + if err != nil { + log.Errorf("Error merging events: %s", err) + http.Error(w, fmt.Sprintf("Invalid calendar: %s", err.Error()), http.StatusBadRequest) + return + } + } + for _, event := range events { downstream.AddVEvent(event) } @@ -197,3 +219,75 @@ func parseMatchers(m []string) (matchGroup, error) { } return matches, nil } + +// mergeEvents will perform the merge algorithm on a slice of events sorted by +// start time. +func mergeEvents(events []*ics.VEvent) ([]*ics.VEvent, error) { + var ( + newEvents []*ics.VEvent + lastEndTime time.Time + ) + + for _, event := range events { + startTime, err := event.GetStartAt() + if err != nil { + return nil, errors.New("event has no start time") + } + endTime, err := event.GetEndAt() + if err != nil { + return nil, errors.New("event has no end time") + } + + if len(newEvents) == 0 || !startTime.Before(lastEndTime) { + lastEndTime = endTime + newEvents = append(newEvents, event) + continue + } + + lastEvent := newEvents[len(newEvents)-1] + + lastSummary := lastEvent.GetProperty(ics.ComponentPropertySummary) + newSummary := "" + if lastSummary != nil { + newSummary = lastSummary.Value + } + summary := event.GetProperty(ics.ComponentPropertySummary) + if summary != nil { + newSummary += " + " + newSummary += summary.Value + } + lastEvent.SetSummary(newSummary) + + lastDescription := lastEvent.GetProperty(ics.ComponentPropertyDescription) + newDescription := "" + if lastDescription != nil { + newDescription = lastDescription.Value + } + description := event.GetProperty(ics.ComponentPropertyDescription) + if description != nil { + newDescription += "\\n\\n---\\n" + if summary != nil { + newDescription += summary.Value + "\\n" + } + newDescription += "\\n" + newDescription += description.Value + } + if newDescription != "" { + lastEvent.SetProperty(ics.ComponentPropertyDescription, newDescription) + } + + if endTime.After(lastEndTime) { + var props []ics.PropertyParameter + for k, v := range event.GetProperty(ics.ComponentPropertyDtEnd).ICalParameters { + props = append(props, &ics.KeyValues{ + Key: k, + Value: v, + }) + } + lastEvent.SetProperty(ics.ComponentPropertyDtEnd, event.GetProperty(ics.ComponentPropertyDtEnd).Value, props...) + lastEndTime = endTime + } + } + + return newEvents, nil +} diff --git a/server_test.go b/server_test.go index e51d023..b7f3ac6 100644 --- a/server_test.go +++ b/server_test.go @@ -118,6 +118,20 @@ func TestRegexFilter(t *testing.T) { allowLoopback: true, wantStatus: 400, }, + "dontmerge": { + source: calUnmerged, + options: "?cal=http://CALURL", + allowLoopback: true, + wantStatus: 200, + want: calUnmerged, + }, + "merge": { + source: calUnmerged, + options: "?cal=http://CALURL&mrg=true", + allowLoopback: true, + wantStatus: 200, + want: calMerged, + }, } { t.Run(name, func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {