From c6c23cdef67be365696aa5e8a6fa2d837d1b4513 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:16:25 +0200 Subject: [PATCH] fix: support RDATE for events with explicit future dates An event may have DTSTART/DTEND in the past but RDATE entries listing explicit future occurrences. Previously such events were silently dropped because only DTEND was checked. When an RDATE line is found, parse each comma-separated date value. If any date falls within [now, max], keep the event - mirroring the existing RRULE behaviour. --- ics/with-rdate.ics | 32 +++++++++++++++++++++++ src/index.ts | 45 +++++++++++++++++++++++++++++++++ src/tests/rdate-support.test.ts | 37 +++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 ics/with-rdate.ics create mode 100644 src/tests/rdate-support.test.ts diff --git a/ics/with-rdate.ics b/ics/with-rdate.ics new file mode 100644 index 0000000..6e4f2a3 --- /dev/null +++ b/ics/with-rdate.ics @@ -0,0 +1,32 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:rdate-future@test +DTSTART:20200101T100000Z +DTEND:20200101T110000Z +RDATE:20300101T100000Z,20310101T100000Z +SUMMARY:RDATE with future dates - should be kept +END:VEVENT +BEGIN:VEVENT +UID:rdate-past@test +DTSTART:20180101T100000Z +DTEND:20180101T110000Z +RDATE:20190101T100000Z,20200101T100000Z +SUMMARY:RDATE with only past dates - should be dropped +END:VEVENT +BEGIN:VEVENT +UID:rdate-value-date@test +DTSTART:20200601T100000Z +DTEND:20200601T110000Z +RDATE;VALUE=DATE:20300601,20310601 +SUMMARY:RDATE with VALUE=DATE format - should be kept +END:VEVENT +BEGIN:VEVENT +UID:rdate-period@test +DTSTART:20200601T100000Z +DTEND:20200601T120000Z +RDATE;VALUE=PERIOD:20300601T100000Z/20300601T120000Z +SUMMARY:RDATE with VALUE=PERIOD format - should be kept +END:VEVENT +END:VCALENDAR diff --git a/src/index.ts b/src/index.ts index e69aa2a..fc81eaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,8 @@ export const icsFilter = (content: string, now: Date, max?: Date): string => { let keep: boolean = true; let hasRrule: boolean = false; + let hasRdate: boolean = false; + let rdateKeep: boolean = false; let hasKeptRecurrenceId: boolean = false; let rruleKeep: boolean = true; @@ -57,6 +59,8 @@ export const icsFilter = (content: string, now: Date, max?: Date): string => { keep = true; hasRrule = false; + hasRdate = false; + rdateKeep = false; hasKeptRecurrenceId = false; rruleKeep = true; return; @@ -142,6 +146,14 @@ export const icsFilter = (content: string, now: Date, max?: Date): string => { return; } + if (hasRdate && rdateKeep) { + if (isDevRun) { + console.log("DTEND lower than now, but rdate keeps it:", dtEnd, nowStr, "-->", line); + } + + return; + } + if (hasKeptRecurrenceId) { if (isDevRun) { console.log("DTEND lower than now, but recurrenceId keeps it:", dtEnd, nowStr, "-->", line); @@ -188,6 +200,39 @@ export const icsFilter = (content: string, now: Date, max?: Date): string => { return; } + if (lineUpper.startsWith("RDATE")) { + const value: string | null = extractValue(line); + if (!value) { + return; + } + + hasRdate = true; + + for (const dateRaw of value.split(",")) { + const date: string | null = normalizeICSDateStr(dateRaw.trim()); + if (!date) { + continue; + } + + if (date >= nowStr && (!maxStr || date <= maxStr)) { + rdateKeep = true; + keep = true; + + if (isDevRun) { + console.log("RDATE date in range:", date, "-->", line); + } + + break; + } + } + + if (isDevRun && !rdateKeep) { + console.log("RDATE: no dates in range", "-->", line); + } + + return; + } + if (lineUpper.startsWith("RECURRENCE-ID")) { const value: string | null = extractValue(line); const dtRecurrence: string | null = normalizeICSDateStr(value); diff --git a/src/tests/rdate-support.test.ts b/src/tests/rdate-support.test.ts new file mode 100644 index 0000000..ea1ff13 --- /dev/null +++ b/src/tests/rdate-support.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert"; +import { readFileSync } from "node:fs"; +import { describe, test } from "node:test"; + +import { icsFilter } from "../index.js"; + +const content: string = readFileSync("./ics/with-rdate.ics", "utf-8"); +const now: Date = new Date(); +const max: Date = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000); // 1 year from now +const filteredContent: string = icsFilter(content, now); +const filteredMaxContent: string = icsFilter(content, now, max); + +describe("icsFilter on ics content with RDATE properties", () => { + test("should keep event with RDATE dates in the future", () => { + assert.ok(filteredContent.includes("rdate-future@test"), "Event with future RDATE dates should be kept"); + }); + + test("should drop event with only past RDATE dates", () => { + assert.ok(!filteredContent.includes("rdate-past@test"), "Event with only past RDATE dates should be dropped"); + }); + + test("should keep event with RDATE;VALUE=DATE format dates in the future", () => { + assert.ok(filteredContent.includes("rdate-value-date@test"), "Event with future RDATE;VALUE=DATE dates should be kept"); + }); + + test("should keep event with RDATE;VALUE=PERIOD format dates in the future", () => { + assert.ok(filteredContent.includes("rdate-period@test"), "Event with future RDATE;VALUE=PERIOD dates should be kept"); + }); +}); + +describe("icsFilter on ics content with RDATE properties and max limit", () => { + test("should drop events whose RDATE dates are all beyond max", () => { + assert.ok(!filteredMaxContent.includes("rdate-future@test"), "Event with RDATE dates beyond max should be dropped"); + assert.ok(!filteredMaxContent.includes("rdate-value-date@test"), "Event with RDATE;VALUE=DATE dates beyond max should be dropped"); + assert.ok(!filteredMaxContent.includes("rdate-period@test"), "Event with RDATE;VALUE=PERIOD dates beyond max should be dropped"); + }); +});