diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-24 16:14:13 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-24 16:14:13 +0200 |
| commit | 53bef55d4d608cd15a30727f6aef7626a1de2120 (patch) | |
| tree | 0c40a26907918df73fd816cf298b6c0eab49d54e | |
| parent | 44de97a3724ba5eed3b2c2c4a69a6fd82ffa5fbe (diff) | |
| download | qtmaildir-53bef55d4d608cd15a30727f6aef7626a1de2120.tar.gz qtmaildir-53bef55d4d608cd15a30727f6aef7626a1de2120.zip | |
feat: expand calendar occurrences in each event's own zone
| -rw-r--r-- | src/calendarstore.cpp | 114 | ||||
| -rw-r--r-- | tests/test_calendarstore.cpp | 137 |
2 files changed, 249 insertions, 2 deletions
diff --git a/src/calendarstore.cpp b/src/calendarstore.cpp index fac5bc2..93f21d4 100644 --- a/src/calendarstore.cpp +++ b/src/calendarstore.cpp @@ -122,6 +122,50 @@ QDateTime endOf(icalcomponent *c, const QDateTime &start, bool allDay, bool *unk return allDay ? start.addDays(1) : start; } +/// ponytail: iterates from DTSTART rather than icalrecur_iterator_set_start, +/// which is unsupported with COUNT. Capped per series; raise the cap only if +/// a real rule is measured hitting it. +constexpr int kMaxIterations = 10000; + +bool overlaps(const QDateTime &start, const QDateTime &end, + const QDateTime &from, const QDateTime &to) +{ + // A zero-length event (no DTEND) still shows on its instant. + return start < to && (end > from || (end == start && start >= from)); +} + +/// The occurrence start times of the master in `text`, up to `to`, in the +/// event's own zone and converted after: a weekly 10:00 Rome meeting stays +/// 10:00 in Rome across DST (spec, "Time and recurrence"). +QList<QDateTime> seriesStarts(const QByteArray &text, const QDateTime &to) +{ + QList<QDateTime> starts; + IcalComponent root = parseRoot(text); + icalcomponent *master = root ? masterOf(root.get()) : nullptr; + icalproperty *rrule = master ? icalcomponent_get_first_property(master, ICAL_RRULE_PROPERTY) : nullptr; + if (!rrule) + return starts; + icalproperty *startProp = icalcomponent_get_first_property(master, ICAL_DTSTART_PROPERTY); + const QByteArray tzid = tzidOf(startProp); + const icaltimetype dtstart = icalcomponent_get_dtstart(master); + + IcalRecurIterator it(icalrecur_iterator_new(icalproperty_get_rrule(rrule), dtstart)); + if (!it) + return starts; + for (int i = 0; i < kMaxIterations; ++i) { + icaltimetype t = icalrecur_iterator_next(it.get()); + if (icaltime_is_null_time(t)) + break; + t.zone = dtstart.zone; + t.is_date = dtstart.is_date; + const QDateTime start = toDateTime(t, tzid, nullptr); + if (start >= to) + break; + starts.append(start); + } + return starts; +} + } // namespace namespace { @@ -184,7 +228,34 @@ CalEvent parseEvent(const QByteArray &text, const QString &filePath, a; a = icalcomponent_get_next_property(master, ICAL_ATTENDEE_PROPERTY)) event.attendees.append(personOf(a)); - // Recurrence, exceptions and overrides: Task 4. + if (icalproperty *rrule = icalcomponent_get_first_property(master, ICAL_RRULE_PROPERTY)) { + // The value text rather than icalproperty_get_rrule(): RepeatRule is + // pure text, and libical re-serialising the struct would reorder parts. + IcalString value(icalproperty_get_value_as_string_r(rrule)); + event.repeat = RepeatRule::fromRRule(str(value.get()), event.start.date()); + } + for (icalproperty *ex = icalcomponent_get_first_property(master, ICAL_EXDATE_PROPERTY); + ex; ex = icalcomponent_get_next_property(master, ICAL_EXDATE_PROPERTY)) + event.exdates.append(toDateTime(icalproperty_get_exdate(ex), tzidOf(ex), unknownZone)); + + for (icalcomponent *c = icalcomponent_get_first_component(root.get(), ICAL_VEVENT_COMPONENT); + c; c = icalcomponent_get_next_component(root.get(), ICAL_VEVENT_COMPONENT)) { + icalproperty *rid = icalcomponent_get_first_property(c, ICAL_RECURRENCEID_PROPERTY); + if (c == master || !rid) + continue; + CalOverride ov; + ov.recurrenceId = toDateTime(icalcomponent_get_recurrenceid(c), tzidOf(rid), unknownZone); + icalproperty *sp = icalcomponent_get_first_property(c, ICAL_DTSTART_PROPERTY); + const icaltimetype s = icalcomponent_get_dtstart(c); + ov.allDay = s.is_date; + ov.start = toDateTime(s, tzidOf(sp), unknownZone); + ov.end = endOf(c, ov.start, ov.allDay, unknownZone); + ov.summary = str(icalcomponent_get_summary(c)); + ov.location = str(icalcomponent_get_location(c)); + ov.description = str(icalcomponent_get_description(c)); + ov.cancelled = icalcomponent_get_status(c) == ICAL_STATUS_CANCELLED; + event.overrides.append(ov); + } return event; } @@ -232,7 +303,46 @@ LoadResult load(const QString &dir) }); return result; } -QList<Occurrence> occurrences(const QList<CalEvent> &, const QDateTime &, const QDateTime &) { return {}; } +QList<Occurrence> occurrences(const QList<CalEvent> &events, + const QDateTime &from, const QDateTime &to) +{ + QList<Occurrence> result; + for (int i = 0; i < events.size(); ++i) { + const CalEvent &e = events[i]; + const qint64 length = e.start.msecsTo(e.end); + + if (e.repeat.freq == RepeatRule::Freq::None) { + if (overlaps(e.start, e.end, from, to)) + result.append({ i, e.start, e.end, e.allDay, {}, false, -1 }); + continue; + } + + for (const QDateTime &slot : seriesStarts(e.rawText, to)) { + // An EXDATE or an override takes this slot. Compared as instants: + // an EXDATE may be written in UTC while DTSTART carries a TZID. + const auto sameInstant = [&](const QDateTime &d) { return d == slot; }; + if (std::any_of(e.exdates.cbegin(), e.exdates.cend(), sameInstant)) + continue; + if (std::any_of(e.overrides.cbegin(), e.overrides.cend(), + [&](const CalOverride &o) { return o.recurrenceId == slot; })) + continue; + const QDateTime end = e.allDay ? slot.addDays(e.start.daysTo(e.end)) + : slot.addMSecs(length); + if (overlaps(slot, end, from, to)) + result.append({ i, slot, end, e.allDay, slot, false, -1 }); + } + // Placed by their OWN start: an override may move into a window its + // slot is outside of, or out of the window its slot is in. + for (int o = 0; o < e.overrides.size(); ++o) { + const CalOverride &ov = e.overrides[o]; + if (!ov.cancelled && overlaps(ov.start, ov.end, from, to)) + result.append({ i, ov.start, ov.end, e.allDay, ov.recurrenceId, true, o }); + } + } + std::sort(result.begin(), result.end(), + [](const Occurrence &a, const Occurrence &b) { return a.start < b.start; }); + return result; +} bool isEditable(const CalEvent &, const CalCollection &, const QStringList &) { return false; } QByteArray applyEdit(const QByteArray &, const EventEdit &, Scope, const QDateTime &) { return {}; } QByteArray newEvent(const EventEdit &, const QByteArray &) { return {}; } diff --git a/tests/test_calendarstore.cpp b/tests/test_calendarstore.cpp index 5c177fb..c7e4836 100644 --- a/tests/test_calendarstore.cpp +++ b/tests/test_calendarstore.cpp @@ -64,6 +64,20 @@ void writeFile(const QString &path, const QByteArray &content) file.write(content); } +QList<Occurrence> expand(const QString &body, const QDateTime &from, const QDateTime &to, + CalEvent *out = nullptr) +{ + const CalEvent event = parse(body); + if (out) + *out = event; + return CalendarStore::occurrences({ event }, from, to); +} + +QDateTime utc(int y, int m, int d, int h = 0, int min = 0) +{ + return QDateTime(QDate(y, m, d), QTime(h, min), QTimeZone::utc()); +} + } // namespace class TestCalendarStore : public QObject @@ -79,6 +93,14 @@ private slots: void readsAnAllDayEventWithAnExclusiveEnd(); void loadReadsCollectionsAndFallsBack(); void loadSkipsAndCountsABrokenFile(); + void expandsAWeeklyEventAcrossDstInItsOwnZone(); + void expandsAnAllDayEventOntoOneDay(); + void removesAnExdate(); + void anOverrideReplacesItsSlotAndMovesFreely(); + void anInfiniteSeriesYieldsOnlyTheWindow(); + void honoursCountAndUntil(); + void expandsMonthlyLastFridayAndYearlyByMonth(); + void readsTheRepeatRuleExdatesAndOverrides(); }; void TestCalendarStore::parsesAUtcEvent() @@ -195,5 +217,120 @@ void TestCalendarStore::loadSkipsAndCountsABrokenFile() QCOMPARE(r.unparsable, 1); } +void TestCalendarStore::expandsAWeeklyEventAcrossDstInItsOwnZone() +{ + // 2026-10-25 is the last Sunday of October: Rome leaves CEST (+2) for CET + // (+1). A weekly 10:00 meeting must stay at 10:00 in Rome on both sides, + // which means its UTC time MOVES. Expanding in UTC would keep 08:00Z and + // draw the second one at 09:00 local. + const QTimeZone rome("Europe/Rome"); + const QList<Occurrence> occ = expand(vevent(QStringLiteral( + "UID:w@example.org\r\nDTSTART;TZID=Europe/Rome:20261019T100000\r\n" + "DTEND;TZID=Europe/Rome:20261019T110000\r\nRRULE:FREQ=WEEKLY\r\n")), + utc(2026, 10, 18), utc(2026, 11, 1)); + QCOMPARE(occ.size(), 2); + QCOMPARE(occ[0].start.toTimeZone(rome).time(), QTime(10, 0)); + QCOMPARE(occ[1].start.toTimeZone(rome).time(), QTime(10, 0)); + QCOMPARE(occ[0].start.toUTC().time(), QTime(8, 0)); + QCOMPARE(occ[1].start.toUTC().time(), QTime(9, 0)); + QCOMPARE(occ[1].end.toTimeZone(rome).time(), QTime(11, 0)); +} + +void TestCalendarStore::expandsAnAllDayEventOntoOneDay() +{ + const QList<Occurrence> occ = expand(vevent(QStringLiteral( + "UID:ad@example.org\r\nDTSTART;VALUE=DATE:20260924\r\nDTEND;VALUE=DATE:20260925\r\n")), + QDateTime(QDate(2026, 9, 25), QTime(0, 0)), QDateTime(QDate(2026, 9, 26), QTime(0, 0))); + // The window starts on the 25th, where the event has already ENDED: an + // inclusive end would draw it on the 25th too. + QCOMPARE(occ.size(), 0); +} + +void TestCalendarStore::removesAnExdate() +{ + const QList<Occurrence> occ = expand(vevent(QStringLiteral( + "UID:x@example.org\r\nDTSTART:20260921T080000Z\r\nDTEND:20260921T090000Z\r\n" + "RRULE:FREQ=DAILY\r\nEXDATE:20260922T080000Z\r\n")), + utc(2026, 9, 21), utc(2026, 9, 24)); + QCOMPARE(occ.size(), 2); + QCOMPARE(occ[0].start, utc(2026, 9, 21, 8)); + QCOMPARE(occ[1].start, utc(2026, 9, 23, 8)); +} + +void TestCalendarStore::anOverrideReplacesItsSlotAndMovesFreely() +{ + // A five-day series (21st-25th) whose 22nd is moved to the 30th. A window + // over the 21st-23rd must NOT show the 22nd, since its slot is taken; a + // window over the 30th, where the series has no slot at all, MUST show + // the override, since an override is placed by its own start. + const QString body = vevent(QStringLiteral( + "UID:o@example.org\r\nDTSTART:20260921T080000Z\r\nDTEND:20260921T090000Z\r\n" + "RRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Series\r\n")) + + vevent(QStringLiteral( + "UID:o@example.org\r\nRECURRENCE-ID:20260922T080000Z\r\n" + "DTSTART:20260930T150000Z\r\nDTEND:20260930T160000Z\r\nSUMMARY:Moved\r\n")); + CalEvent event; + QList<Occurrence> occ = expand(body, utc(2026, 9, 21), utc(2026, 9, 24), &event); + QCOMPARE(occ.size(), 2); // the 21st and 23rd + QCOMPARE(event.overrides.size(), 1); + QCOMPARE(event.overrides[0].summary, QStringLiteral("Moved")); + + occ = expand(body, utc(2026, 9, 30), utc(2026, 10, 1)); + QCOMPARE(occ.size(), 1); // COUNT=5 ends on the 25th; only the override is here + QVERIFY(occ[0].isOverride); + QCOMPARE(occ[0].start, utc(2026, 9, 30, 15)); + QCOMPARE(occ[0].recurrenceId, utc(2026, 9, 22, 8)); +} + +void TestCalendarStore::anInfiniteSeriesYieldsOnlyTheWindow() +{ + const QList<Occurrence> occ = expand(vevent(QStringLiteral( + "UID:inf@example.org\r\nDTSTART:20000101T080000Z\r\nRRULE:FREQ=DAILY\r\n")), + utc(2026, 9, 1), utc(2026, 9, 8)); + QCOMPARE(occ.size(), 7); + QCOMPARE(occ.first().start, utc(2026, 9, 1, 8)); +} + +void TestCalendarStore::honoursCountAndUntil() +{ + QCOMPARE(expand(vevent(QStringLiteral( + "UID:c@example.org\r\nDTSTART:20260901T080000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\n")), + utc(2026, 9, 1), utc(2026, 10, 1)).size(), 3); + QCOMPARE(expand(vevent(QStringLiteral( + "UID:u@example.org\r\nDTSTART:20260901T080000Z\r\n" + "RRULE:FREQ=DAILY;UNTIL=20260905T080000Z\r\n")), + utc(2026, 9, 1), utc(2026, 10, 1)).size(), 5); +} + +void TestCalendarStore::expandsMonthlyLastFridayAndYearlyByMonth() +{ + // Last Fridays: 2026-09-25, 2026-10-30. + QList<Occurrence> occ = expand(vevent(QStringLiteral( + "UID:lf@example.org\r\nDTSTART:20260925T080000Z\r\nRRULE:FREQ=MONTHLY;BYDAY=-1FR\r\n")), + utc(2026, 9, 1), utc(2026, 11, 1)); + QCOMPARE(occ.size(), 2); + QCOMPARE(occ[1].start.date(), QDate(2026, 10, 30)); + + // Last Friday of September: 2026-09-25, 2027-09-24. + occ = expand(vevent(QStringLiteral( + "UID:ly@example.org\r\nDTSTART:20260925T080000Z\r\n" + "RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1FR\r\n")), + utc(2026, 1, 1), utc(2028, 1, 1)); + QCOMPARE(occ.size(), 2); + QCOMPARE(occ[1].start.date(), QDate(2027, 9, 24)); +} + +void TestCalendarStore::readsTheRepeatRuleExdatesAndOverrides() +{ + CalEvent e; + expand(vevent(QStringLiteral( + "UID:r@example.org\r\nDTSTART:20260921T080000Z\r\n" + "RRULE:FREQ=MONTHLY;BYDAY=-1FR\r\nEXDATE:20261030T080000Z\r\n")), + utc(2026, 9, 1), utc(2026, 9, 2), &e); + QCOMPARE(e.repeat.freq, RepeatRule::Freq::Monthly); + QCOMPARE(e.repeat.ordinal, -1); + QCOMPARE(e.exdates, QList<QDateTime>{ utc(2026, 10, 30, 8) }); +} + QTEST_MAIN(TestCalendarStore) #include "test_calendarstore.moc" |
