diff --git a/include/nocal/domain/event.hpp b/include/nocal/domain/event.hpp index f184105..a40c354 100644 --- a/include/nocal/domain/event.hpp +++ b/include/nocal/domain/event.hpp @@ -50,6 +50,7 @@ struct Event { std::optional recurrence; std::vector recurrence_additions; std::vector recurrence_exceptions; + std::optional recurrence_id; }; struct Calendar { @@ -70,4 +71,11 @@ struct Calendar { return event.recurrence.has_value() || !event.recurrence_additions.empty(); } +// True when this event is a detached override of a recurring series +// (has a RECURRENCE-ID but is not itself a recurring series). +[[nodiscard]] constexpr bool is_override(const Event& event) noexcept +{ + return event.recurrence_id.has_value(); +} + } // namespace nocal diff --git a/src/storage/ics_store.cpp b/src/storage/ics_store.cpp index 2116278..31ed944 100644 --- a/src/storage/ics_store.cpp +++ b/src/storage/ics_store.cpp @@ -50,6 +50,7 @@ struct PendingEvent { std::string color; std::optional start; std::optional end; + std::optional recurrence_id; std::vector recurrence_rules; std::vector recurrence_dates; std::vector exception_dates; @@ -767,6 +768,12 @@ void serialize_calendar(std::ostream& output, std::span events, for (const Event& event : events) { write_folded(output, "BEGIN:VEVENT"); write_folded(output, "UID:" + escape_text(event.uid)); + if (event.recurrence_id) { + const std::string params = event.all_day ? ";VALUE=DATE" : time_property_parameters(event); + write_folded(output, "RECURRENCE-ID" + params + ":" + + (event.all_day ? format_date(*event.recurrence_id) + : format_event_time(event, *event.recurrence_id))); + } if (event.all_day) { write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start)); write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end)); @@ -1113,6 +1120,11 @@ void validate_event_for_save( if (event.uid.empty()) { throw std::invalid_argument("cannot save an event with an empty UID"); } + if (event.recurrence_id && event.recurrence) { + throw std::invalid_argument( + "cannot save override event '" + event.uid + + "' with both RECURRENCE-ID and RRULE"); + } if (event.end < event.start || (event.all_day && event.end == event.start)) { throw std::invalid_argument( "cannot save event '" + event.uid + "' with an invalid interval"); @@ -1278,6 +1290,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { event.description = std::move(pending->description); event.calendar_id = std::move(pending->calendar_id); event.color = std::move(pending->color); + if (pending->recurrence_id) { + event.recurrence_id = pending->recurrence_id->value; + } if (pending->recurrence_rules.size() > 1) { add_warning(result, pending->recurrence_rules[1].line, @@ -1414,7 +1429,7 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { continue; } const bool time_property = name == "DTSTART" || name == "DTEND" - || name == "RDATE" || name == "EXDATE"; + || name == "RDATE" || name == "EXDATE" || name == "RECURRENCE-ID"; const bool recurrence_property = name == "RRULE"; const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION" || name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID" @@ -1472,6 +1487,26 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { } destination = *parsed; } + } else if (name == "RECURRENCE-ID") { + if (pending->recurrence_id) { + add_warning(result, index + 1, "duplicate RECURRENCE-ID property"); + mark_unsafe_to_rewrite(result); + } + std::string warning; + auto parsed = parse_time(parameters, value, warning); + if (!parsed) { + add_warning(result, index + 1, std::move(warning)); + mark_unsafe_to_rewrite(result); + } else { + if (!warning.empty()) { + const bool is_tzid = is_unknown_tzid_warning(warning); + add_warning(result, index + 1, std::move(warning)); + if (!is_tzid) { + mark_unsafe_to_rewrite(result); + } + } + pending->recurrence_id = *parsed; + } } } diff --git a/tests/ics_tests.cpp b/tests/ics_tests.cpp index 713cff8..790eabb 100644 --- a/tests/ics_tests.cpp +++ b/tests/ics_tests.cpp @@ -705,13 +705,46 @@ void test_daily_with_byday_is_supported(const std::filesystem::path& root) { require(rule.by_weekday.size() == 3, "three BYDAY entries expected"); } +void test_recurrence_id_round_trip(const std::filesystem::path& root) { + const auto path = root / "recurrence-id.ics"; + write_fixture(path, + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" + "BEGIN:VEVENT\r\nUID:series-1@example.test\r\n" + "DTSTART:20260701T090000Z\r\nDTEND:20260701T100000Z\r\n" + "RRULE:FREQ=DAILY;COUNT=5\r\n" + "SUMMARY:Series\r\nEND:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:series-1@example.test\r\n" + "RECURRENCE-ID:20260703T090000Z\r\n" + "DTSTART:20260703T110000Z\r\nDTEND:20260703T120000Z\r\n" + "SUMMARY:Moved occurrence\r\nEND:VEVENT\r\n" + "END:VCALENDAR\r\n"); + + const auto loaded = nocal::storage::IcsStore::load(path); + require(loaded.safe_to_rewrite && loaded.warnings.empty(), + "RECURRENCE-ID should be rewrite-safe"); + require(loaded.events.size() == 2, "should parse series and override"); + require(loaded.events[0].recurrence.has_value(), "series should have RRULE"); + require(loaded.events[1].recurrence_id.has_value(), "override should have recurrence_id"); + require(!loaded.events[1].recurrence.has_value(), "override should not have RRULE"); + + const auto round_trip = root / "recurrence-id-round-trip.ics"; + nocal::storage::IcsStore::save(round_trip, loaded.events); + const std::string serialized = read_fixture(round_trip); + require(serialized.find("RECURRENCE-ID:20260703T090000Z") != std::string::npos, + "RECURRENCE-ID should be serialized"); + const auto reloaded = nocal::storage::IcsStore::load(round_trip); + require(reloaded.safe_to_rewrite && reloaded.events.size() == 2, + "RECURRENCE-ID round-trip should be rewrite-safe"); + require(reloaded.events[1].recurrence_id.has_value(), + "round-trip override should retain recurrence_id"); +} + void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) { const std::vector unsupported_properties{ "RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z", "RRULE:FREQ=HOURLY", "RRULE:FREQ=DAILY;WKST=MO", - "RDATE;VALUE=PERIOD:20260718T090000Z/PT1H", - "RECURRENCE-ID:20260718T090000Z"}; + "RDATE;VALUE=PERIOD:20260718T090000Z/PT1H"}; for (std::size_t index = 0; index < unsupported_properties.size(); ++index) { const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics"); write_fixture(path, @@ -1026,6 +1059,7 @@ int main() { test_unsupported_recurrence_and_zones_are_unsafe(root); test_ordinal_byday_round_trip(root); test_daily_with_byday_is_supported(root); + test_recurrence_id_round_trip(root); test_save_validation_preserves_existing_bytes(root); test_vtimezone_custom_tzid_safe_and_round_trips(root); test_vtimezone_multiple_tzids(root);