From fa9df158416d5b12d04e7ab74e6793d77fe6a703 Mon Sep 17 00:00:00 2001 From: naimo84 Date: Mon, 16 Oct 2023 08:55:29 +0200 Subject: [PATCH] fix: issue #11 and naimo84/node-red-contrib-ical-events/#133 --- src/convert.ts | 1 + src/ical.ts | 67 +++++++++++---- src/interfaces/event.ts | 2 + src/lib.ts | 26 ++++-- test/issues_spec.ts | 50 ++++++++++- test/mocks/11.ics | 17 ++++ test/mocks/133.ics | 186 ++++++++++++++++++++++++++++++++++++++++ test/mocks/167.ics | 58 +++++++++++++ 8 files changed, 384 insertions(+), 23 deletions(-) create mode 100644 test/mocks/11.ics create mode 100644 test/mocks/133.ics create mode 100644 test/mocks/167.ics diff --git a/src/convert.ts b/src/convert.ts index 4853de2..62a2855 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -118,6 +118,7 @@ export function convertEvent(event: iCalEvent, config: Config): IKalenderEvent | location: event.location || '', organizer: event.organizer || '', rrule: event.rrule, + rdate: event.rdate, rruleText: rruleText, uid: uid, isRecurring: !!recurrence || !!event.rrule, diff --git a/src/ical.ts b/src/ical.ts index 1fc5561..67e2273 100644 --- a/src/ical.ts +++ b/src/ical.ts @@ -8,6 +8,7 @@ import axios from 'axios'; const { v4: uuid } = require('uuid'); const rrule = require('rrule').RRule; import moment = require('moment-timezone'); +import RRule, { RRuleSet } from 'rrule'; function text(t = '') { return t @@ -113,7 +114,7 @@ function getIanaTZFromMS(msTZName: string | number) { return he ? he.iana[0] : null; } -function getTimeZone(value: any) { +function getTimeZone(value: any,config:any) { let tz = value; let found = ''; // If this is the custom timezone from MS Outlook @@ -140,8 +141,13 @@ function getTimeZone(value: any) { if (tz && tz.startsWith('(')) { // Extract just the offset const regex = /[+|-]\d*:\d*/; - tz = null; found = tz.match(regex); + // guess Timezone, because RRule's TZID only accepts IANA tz + // possible fix for issue https://github.com/naimo84/node-red-contrib-ical-events/issues/133 + if (found) { + tz = config.timezone; + found = ''; + } } // Timezone not confirmed yet @@ -369,6 +375,35 @@ function recurrenceParameter(name: string) { return dateParameter(name); }; +//@ts-ignore +function rdateParameter(name: string) { + //@ts-ignore + return function (value: any, parameters: any, curr: any) { + + //@ts-ignore + storeParameter(value, parameters, curr); + if (parameters.length > 0 && parameters[0].indexOf('PERIOD') >= 0) { + let values = value.split(','); + const rruleSet = new RRuleSet() + for (const rdate of values) { + const rdateSplit = rdate.split('/') + + + const dtstart = moment(rdateSplit[0]) + const until = moment(rdateSplit[1]) + rruleSet.rrule( + new RRule({ + dtstart: dtstart.toDate(), + until:until.toDate() + })) + } + curr[name] = rruleSet.rrules(); + } + return curr; + }; +}; + + function addFBType(fb: { type?: any; }, parameters: any) { const p = parseParameters(parameters); @@ -418,7 +453,8 @@ const objectHandlers: any = { return { type: component, params: parameters }; }, - END(value: string, parameters: any, curr: { rrule: string; start: any; }, stack: any) { + //@ts-ignore + END(value: string, parameters: any, curr: { rrule: string; start: any; }, stack: any, line: any, config: any) { // Original end function //@ts-ignore function originalEnd(component: string, parameters_: any, curr: { [x: string]: any; end: Date; datetype: string; start: any; duration: string | undefined; uid: string | number; recurrenceid: { toISOString: () => string; } | undefined; }, stack: any[]) { @@ -607,7 +643,7 @@ const objectHandlers: any = { try { // If the original date has a TZID, add it if (curr.start.tz) { - const tz = getTimeZone(curr.start.tz); + const tz = getTimeZone(curr.start.tz,config); rule += `;DTSTART;TZID=${tz}:${curr.start.toISOString().replace(/[-:]/g, '')}`; } else { rule += `;DTSTART=${curr.start.toISOString().replace(/[-:]/g, '')}`; @@ -655,6 +691,7 @@ const objectHandlers: any = { CREATED: dateParameter('created'), 'LAST-MODIFIED': dateParameter('lastmodified'), 'RECURRENCE-ID': recurrenceParameter('recurrenceid'), + 'RDATE': rdateParameter('rdate'), //@ts-ignore RRULE(value: any, parameters: any, curr: { rrule: any; }, stack: any, line: any) { curr.rrule = line; @@ -662,9 +699,9 @@ const objectHandlers: any = { } } -export function handleObject(name: string, value: any, parameters: any, ctx: any, stack: string | any[], line: any) { +export function handleObject(name: string, value: any, parameters: any, ctx: any, stack: string | any[], line: any, config: any) { if (objectHandlers[name]) { - return objectHandlers[name](value, parameters, ctx, stack, line); + return objectHandlers[name](value, parameters, ctx, stack, line, config); } // Handling custom properties @@ -679,7 +716,7 @@ export function handleObject(name: string, value: any, parameters: any, ctx: any return storeParameter(name.toLowerCase())(value, parameters, ctx); } -export function parseLines(lines: string | any[], limit: number, ctx?: { type?: any; params?: any; } | undefined, stack?: never[], lastIndex?: number, cb?: (arg0: null, arg1: any) => void) { +export function parseLines(lines: string | any[], limit: number, config:any , ctx?: { type?: any; params?: any; } | undefined, stack?: never[], lastIndex?: number, cb?: (arg0: null, arg1: any) => void) { if (!cb && typeof ctx === 'function') { cb = ctx; ctx = undefined; @@ -718,7 +755,7 @@ export function parseLines(lines: string | any[], limit: number, ctx?: { type?: const name = kv[0]; const parameters = kv[1] ? kv[1].split(';').slice(1) : []; - ctx = handleObject(name, value, parameters, ctx, stack, l) || {}; + ctx = handleObject(name, value, parameters, ctx, stack, l,config) || {}; if (++limitCounter > limit) { break; } @@ -733,7 +770,7 @@ export function parseLines(lines: string | any[], limit: number, ctx?: { type?: if (cb) { if (i < lines.length) { setImmediate(() => { - parseLines(lines, limit, ctx, stack, i + 1, cb); + parseLines(lines, limit, ctx, config, stack, i + 1, cb); }); } else { setImmediate(() => { @@ -746,22 +783,22 @@ export function parseLines(lines: string | any[], limit: number, ctx?: { type?: return null; } -export function parseICS(string: string) :any{ +export function parseICS(string: string, config:any): any { const lineEndType = getLineBreakChar(string); const lines = string.split(lineEndType === '\n' ? /\n/ : /\r?\n/); - let ctx = parseLines(lines, lines.length); + let ctx = parseLines(lines, lines.length, config); return ctx; } -export async function fromURL(url: any, options: any) { +export async function fromURL(url: any, options: any, config: any) { const response = await axios.get(url, options) if (Math.floor(response.status / 100) !== 2) { throw new Error(`${response.status} ${response.statusText}`); } - return parseICS(response.data); + return parseICS(response.data, config); } -export async function parseFile(filename: any) { +export async function parseFile(filename: any, config: any) { const data = await fs.promises.readFile(filename, 'utf8') - return parseICS(data) + return parseICS(data, config) } \ No newline at end of file diff --git a/src/interfaces/event.ts b/src/interfaces/event.ts index 49d2506..0bbda79 100644 --- a/src/interfaces/event.ts +++ b/src/interfaces/event.ts @@ -7,6 +7,7 @@ export interface iCalEvent { exdate: any; recurrences: any; rrule?: any; + rdate?: any; startDate?: any; endDate?: any; recurrenceId?: any; @@ -49,6 +50,7 @@ export interface IKalenderEvent { id?: string, allDay?: boolean, rrule?: any, + rdate?: any, rruleText?: string, countdown?: object, calendarName?: string, diff --git a/src/lib.ts b/src/lib.ts index f369a32..5e0a042 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -119,6 +119,7 @@ export class KalenderEvents { const { preview, pastview } = getPreviews(this.config as Config); + debug(`getEvents - config: ${this.config}`) debug(`getEvents - pastview: ${pastview}`) debug(`getEvents - preview: ${preview}`) let processedData = this.processData(data, realnow, pastview.toDate(), preview.toDate()); @@ -139,6 +140,7 @@ export class KalenderEvents { } private async getCal(): Promise { + debug(this.config) if (this.config.type && this.config.type === 'icloud') { debug('getCal - icloud'); @@ -189,7 +191,7 @@ export class KalenderEvents { }; } - let data = await fromURL(this.config.url, header); + let data = await fromURL(this.config.url, header, this.config); debug(data) let converted = await convertEvents(data, this.config); @@ -199,7 +201,7 @@ export class KalenderEvents { if (!this.config.url) { throw "URL/File is not defined"; } - let data = await parseFile(this.config.url); + let data = await parseFile(this.config.url, this.config); debug(data) let converted = await convertEvents(data, this.config); return converted; @@ -207,10 +209,14 @@ export class KalenderEvents { } } - private processRRule(ev: IKalenderEvent, preview: Date, pastview: Date) { + private processRRule(ev: IKalenderEvent, preview: Date, pastview: Date, rdate: boolean = false) { var eventLength = ev.eventEnd!.getTime() - ev.eventStart!.getTime(); var options = RRule.parseString(ev.rrule.toString()); - options.dtstart = this.addOffset(ev.eventStart!, -getTimezoneOffset(ev.eventStart!)); + if (!rdate) + options.dtstart = this.addOffset(ev.eventStart!, -getTimezoneOffset(ev.eventStart!)); + else + options.dtstart = this.addOffset(options.dtstart, -getTimezoneOffset(options.dtstart)); + if (options.until) { options.until = this.addOffset(options.until, -getTimezoneOffset(options.until)); } @@ -339,7 +345,17 @@ export class KalenderEvents { } if (ev.rrule === undefined) { - this.checkDates(ev, preview, pastview, realnow, ' ', reslist); + if (ev.rdate === undefined) { + this.checkDates(ev, preview, pastview, realnow, ' ', reslist); + } else { + for (let rdate of ev.rdate) { + ev.rrule = rdate; + let evlist = this.processRRule(ev, preview, pastview, true); + for (let ev2 of evlist) { + this.checkDates(ev2 as IKalenderEvent, preview, pastview, realnow, ev.rrule, reslist); + } + } + } } else { let evlist = this.processRRule(ev, preview, pastview); for (let ev2 of evlist) { diff --git a/test/issues_spec.ts b/test/issues_spec.ts index a736ca7..bb557dc 100644 --- a/test/issues_spec.ts +++ b/test/issues_spec.ts @@ -7,10 +7,54 @@ use(require('chai-things')); describe('issues', () => { + + it('#133', async () => { + return new Promise(async (resolve, reject) => { + try { + + let ke = new KalenderEvents({ + url: "./test/mocks/133.ics" + }); + let events = await ke.getEvents({ + now: moment('20160919').toDate(), + pastview: 1, + preview: 2, + includeTodo: true, + timezone: 'Europe/Amsterdam' + }); + expect(events).to.have.lengthOf(1) + resolve(); + } catch (err) { + reject(err); + } + }); + }); + + it('#11', async () => { + return new Promise(async (resolve, reject) => { + try { + + let ke = new KalenderEvents({ + url: "./test/mocks/11.ics" + }); + let events = await ke.getEvents({ + now: moment('20221108').toDate(), + pastview: 10, + preview: 10, + includeTodo: true + }); + expect(events).to.have.lengthOf(1) + resolve(); + } catch (err) { + reject(err); + } + }); + }); + it('#131', async () => { return new Promise(async (resolve, reject) => { try { - if (!process.env.CALDAV1_URL) resolve(); + let ke = new KalenderEvents({ url: "./test/mocks/131.ics" }); @@ -31,7 +75,7 @@ describe('issues', () => { it('#15', async () => { return new Promise(async (resolve, reject) => { try { - if (!process.env.CALDAV1_URL) resolve(); + let ke = new KalenderEvents({ url: "./test/mocks/15.ics" }); @@ -83,7 +127,7 @@ describe('issues', () => { it('#77', async () => { return new Promise(async (resolve, reject) => { try { - if (!process.env.CALDAV1_URL) resolve(); + let ke = new KalenderEvents({ url: "./test/mocks/77.ics" }); diff --git a/test/mocks/11.ics b/test/mocks/11.ics new file mode 100644 index 0000000..afef3c7 --- /dev/null +++ b/test/mocks/11.ics @@ -0,0 +1,17 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:3375 +DESCRIPTION: test description +DTSTART:20220913T133001 +DTEND:20220913T160002 +RDATE;VALUE=PERIOD:20221018T133001/20221018T160002,20221115T133001/20221115T160002,20230221T133001/20230221T160002,20230321T133001/20230321T160002,20230418T133001/20230418T160002 +UID:2022-09-13 13:30:01_3375@demo.icalendar.org +DTSTAMP:20221114T224954 +CREATED:20220223T112042 +LAST-MODIFIED:20220223T112042 +STATUS:CONFIRMED +ATTENDEE;CN=:MAILTO:TEST@TEST.com +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/test/mocks/133.ics b/test/mocks/133.ics new file mode 100644 index 0000000..40bf89c --- /dev/null +++ b/test/mocks/133.ics @@ -0,0 +1,186 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Zarafa//7.2.0-48204//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VTIMEZONE +TZID:Europe/Zurich +BEGIN:STANDARD +DTSTART:19700101T030000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=9;WKST=SU +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700101T010000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3;WKST=SU +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VTIMEZONE +TZID:Europe/Amsterdam +BEGIN:STANDARD +DTSTART:19700101T030000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=9;WKST=SU +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700101T010000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;WKST=SU +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VTIMEZONE +TZID:Africa/Tunis +BEGIN:STANDARD +DTSTART:19700101T000000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +BEGIN:VTIMEZONE +TZID:(GMT +01:00) +BEGIN:STANDARD +DTSTART:19700101T030000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10;WKST=SU +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700101T020000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;BYDAY=4SU;BYMONTH=3;WKST=SU +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +TRANSP:OPAQUE +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +CREATED:20230819T223005Z +LAST-MODIFIED:20230819T223003Z +DTSTAMP:20230819T223003Z +DTSTART:20230909T183000Z +DTEND:20230909T205900Z +SUMMARY:Els +LOCATION:Rembrandtstraat +CLASS:PUBLIC +UID:3a3c004b-8a5c-4766-87c6-3eb231a240ef +X-MICROSOFT-CDO-OWNER-CRITICAL-CHANGE:20230920T184348Z +X-MICROSOFT-CDO-ATTENDEE-CRITICAL-CHANGE:20230920T184348Z +X-MICROSOFT-CDO-APPT-SEQUENCE:0 +X-MICROSOFT-CDO-OWNERAPPTID:-1 +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +END:VEVENT +BEGIN:VEVENT +TRANSP:OPAQUE +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +CREATED:20230729T090609Z +LAST-MODIFIED:20230729T090608Z +DTSTAMP:20230729T090608Z +DTSTART:20230803T090000Z +DTEND:20230803T100000Z +SUMMARY:voor nagels. +CLASS:PUBLIC +UID:ca24fc5d-1538-4a1d-b303-2789f3043c23 +X-MICROSOFT-CDO-OWNER-CRITICAL-CHANGE:20230920T184348Z +X-MICROSOFT-CDO-ATTENDEE-CRITICAL-CHANGE:20230920T184348Z +X-MICROSOFT-CDO-APPT-SEQUENCE:0 +X-MICROSOFT-CDO-OWNERAPPTID:-1 +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +END:VEVENT +BEGIN:VEVENT +TRANSP:OPAQUE +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +CREATED:20160917T222201Z +LAST-MODIFIED:20160917T222200Z +DTSTAMP:20160917T222200Z +DTSTART:20161112T193000Z +DTEND:20161112T225900Z +SUMMARY:Naar Hanneke en Frans +LOCATION:Ughelen +CLASS:PUBLIC +UID:d546c217-1395-4f14-810a-c5bc5b081620 +X-MICROSOFT-CDO-OWNER-CRITICAL-CHANGE:20230920T184351Z +X-MICROSOFT-CDO-ATTENDEE-CRITICAL-CHANGE:20230920T184351Z +X-MICROSOFT-CDO-APPT-SEQUENCE:0 +X-MICROSOFT-CDO-OWNERAPPTID:-1 +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +END:VEVENT +BEGIN:VEVENT +TRANSP:OPAQUE +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +CREATED:20160909T090008Z +LAST-MODIFIED:20160909T090006Z +DTSTAMP:20160909T090006Z +DTSTART:20160915T130000Z +DTEND:20160915T140000Z +SUMMARY:Romy +LOCATION:Voorthuizen +CLASS:PUBLIC +UID:e1d6bf0b-64cd-449d-b382-6afce64e8241 +X-MICROSOFT-CDO-OWNER-CRITICAL-CHANGE:20230920T184351Z +X-MICROSOFT-CDO-ATTENDEE-CRITICAL-CHANGE:20230920T184351Z +X-MICROSOFT-CDO-APPT-SEQUENCE:0 +X-MICROSOFT-CDO-OWNERAPPTID:-1 +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +END:VEVENT +BEGIN:VEVENT +TRANSP:OPAQUE +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +CREATED:20160908T184657Z +LAST-MODIFIED:20160908T184812Z +DTSTAMP:20160908T184812Z +DTSTART:20160930T183000Z +DTEND:20160930T215900Z +SUMMARY:Willempje +LOCATION:Voorthuizen +CLASS:PUBLIC +UID:aa42967f-e44d-4c32-86c3-207540abfbeb +X-MICROSOFT-CDO-OWNER-CRITICAL-CHANGE:20230920T184351Z +X-MICROSOFT-CDO-ATTENDEE-CRITICAL-CHANGE:20230920T184351Z +X-MICROSOFT-CDO-APPT-SEQUENCE:0 +X-MICROSOFT-CDO-OWNERAPPTID:-1 +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +END:VEVENT +BEGIN:VEVENT +TRANSP:OPAQUE +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +CREATED:20160907T181108Z +LAST-MODIFIED:20171013T202422Z +DTSTAMP:20171013T202422Z +DTSTART:20160927T070000Z +DTEND:20160927T100000Z +SUMMARY:Tom kinderdagverblijf +CLASS:PUBLIC +UID:131dbbfe-1ff1-4fdf-adcc-8b3c3c5243e9 +X-MICROSOFT-CDO-OWNER-CRITICAL-CHANGE:20230920T184351Z +X-MICROSOFT-CDO-ATTENDEE-CRITICAL-CHANGE:20230920T184351Z +X-MICROSOFT-CDO-APPT-SEQUENCE:0 +X-MICROSOFT-CDO-OWNERAPPTID:-1 +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +END:VEVENT +BEGIN:VEVENT +TRANSP:OPAQUE +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +CREATED:20160907T181108Z +LAST-MODIFIED:20171013T202422Z +DTSTAMP:20171013T202422Z +DTSTART;TZID="(GMT +01:00)":20160919T190000 +DTEND;TZID="(GMT +01:00)":20160919T220000 +SUMMARY:Oppassen Lotte en Tom +LOCATION:Voorthuizen +CLASS:PUBLIC +UID:3ffcd4b9-3e7d-49cc-a5cd-c09dcb081a7f +X-MICROSOFT-CDO-OWNER-CRITICAL-CHANGE:20230920T184351Z +X-MICROSOFT-CDO-ATTENDEE-CRITICAL-CHANGE:20230920T184351Z +X-MICROSOFT-CDO-APPT-SEQUENCE:0 +X-MICROSOFT-CDO-OWNERAPPTID:-1 +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-ZARAFA-REC-PATTERN:Occurs every week effective 19-09-2016 from 19:00 to + 1:14. +RRULE:FREQ=WEEKLY;UNTIL=20160926T190000;BYDAY=MO +END:VEVENT +END:VCALENDAR diff --git a/test/mocks/167.ics b/test/mocks/167.ics new file mode 100644 index 0000000..d41fa94 --- /dev/null +++ b/test/mocks/167.ics @@ -0,0 +1,58 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//PYVOBJECT//NONSGML Version 1//EN +X-WR-CALDESC;VALUE=TEXT:Heating +X-WR-CALNAME;VALUE=TEXT:Heating +BEGIN:VTIMEZONE +TZID:Europe/London +BEGIN:STANDARD +DTSTART:19701025T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:GMT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700329T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:BST +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:04ad6587-57af-4440-9a67-b6fc8fb39316 +DTSTART;TZID=Europe/London:20230501T053000 +DTEND;TZID=Europe/London:20230501T063000 +CREATED:20230508T125045Z +DTSTAMP:20230508T125131Z +LAST-MODIFIED:20230508T125131Z +RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR +SEQUENCE:1 +SUMMARY:Heat +TRANSP:OPAQUE +X-MOZ-GENERATION:2 +END:VEVENT +BEGIN:VEVENT +UID:044b2ce2-9a9e-42ba-ab21-270e9a55207f +DTSTART;TZID=Europe/London:20230922T101500 +DTEND;TZID=Europe/London:20230922T104500 +CREATED:20230922T082039Z +DTSTAMP:20230922T082042Z +LAST-MODIFIED:20230922T082042Z +SUMMARY:Heat +TRANSP:OPAQUE +X-MOZ-GENERATION:1 +END:VEVENT +BEGIN:VEVENT +UID:daf56b71-9b88-435f-be44-74f395785f50 +DTSTART;TZID=Europe/London:20230506T070000 +DTEND;TZID=Europe/London:20230506T080000 +CREATED:20230508T130920Z +DTSTAMP:20230508T130954Z +LAST-MODIFIED:20230508T130954Z +RRULE:FREQ=WEEKLY;BYDAY=SA,SU +SUMMARY:Heat +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR \ No newline at end of file