feat: RECURRENCE-ID parsing, serialization, and round-trip support
Add recurrence_id field to Event domain model. Parse and serialize RECURRENCE-ID from/to iCalendar files. Override events (with RECURRENCE-ID but no RRULE) are safe to rewrite. Save validation rejects events with both RECURRENCE-ID and RRULE.
This commit is contained in:
@@ -50,6 +50,7 @@ struct Event {
|
|||||||
std::optional<RecurrenceRule> recurrence;
|
std::optional<RecurrenceRule> recurrence;
|
||||||
std::vector<TimePoint> recurrence_additions;
|
std::vector<TimePoint> recurrence_additions;
|
||||||
std::vector<TimePoint> recurrence_exceptions;
|
std::vector<TimePoint> recurrence_exceptions;
|
||||||
|
std::optional<TimePoint> recurrence_id;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Calendar {
|
struct Calendar {
|
||||||
@@ -70,4 +71,11 @@ struct Calendar {
|
|||||||
return event.recurrence.has_value() || !event.recurrence_additions.empty();
|
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
|
} // namespace nocal
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ struct PendingEvent {
|
|||||||
std::string color;
|
std::string color;
|
||||||
std::optional<ParsedTime> start;
|
std::optional<ParsedTime> start;
|
||||||
std::optional<ParsedTime> end;
|
std::optional<ParsedTime> end;
|
||||||
|
std::optional<ParsedTime> recurrence_id;
|
||||||
std::vector<PendingProperty> recurrence_rules;
|
std::vector<PendingProperty> recurrence_rules;
|
||||||
std::vector<PendingProperty> recurrence_dates;
|
std::vector<PendingProperty> recurrence_dates;
|
||||||
std::vector<PendingProperty> exception_dates;
|
std::vector<PendingProperty> exception_dates;
|
||||||
@@ -767,6 +768,12 @@ void serialize_calendar(std::ostream& output, std::span<const Event> events,
|
|||||||
for (const Event& event : events) {
|
for (const Event& event : events) {
|
||||||
write_folded(output, "BEGIN:VEVENT");
|
write_folded(output, "BEGIN:VEVENT");
|
||||||
write_folded(output, "UID:" + escape_text(event.uid));
|
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) {
|
if (event.all_day) {
|
||||||
write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start));
|
write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start));
|
||||||
write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end));
|
write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end));
|
||||||
@@ -1113,6 +1120,11 @@ void validate_event_for_save(
|
|||||||
if (event.uid.empty()) {
|
if (event.uid.empty()) {
|
||||||
throw std::invalid_argument("cannot save an event with an empty UID");
|
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)) {
|
if (event.end < event.start || (event.all_day && event.end == event.start)) {
|
||||||
throw std::invalid_argument(
|
throw std::invalid_argument(
|
||||||
"cannot save event '" + event.uid + "' with an invalid interval");
|
"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.description = std::move(pending->description);
|
||||||
event.calendar_id = std::move(pending->calendar_id);
|
event.calendar_id = std::move(pending->calendar_id);
|
||||||
event.color = std::move(pending->color);
|
event.color = std::move(pending->color);
|
||||||
|
if (pending->recurrence_id) {
|
||||||
|
event.recurrence_id = pending->recurrence_id->value;
|
||||||
|
}
|
||||||
|
|
||||||
if (pending->recurrence_rules.size() > 1) {
|
if (pending->recurrence_rules.size() > 1) {
|
||||||
add_warning(result, pending->recurrence_rules[1].line,
|
add_warning(result, pending->recurrence_rules[1].line,
|
||||||
@@ -1414,7 +1429,7 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const bool time_property = name == "DTSTART" || name == "DTEND"
|
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 recurrence_property = name == "RRULE";
|
||||||
const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION"
|
const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION"
|
||||||
|| name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID"
|
|| name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID"
|
||||||
@@ -1472,6 +1487,26 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
|||||||
}
|
}
|
||||||
destination = *parsed;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
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) {
|
void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) {
|
||||||
const std::vector<std::string> unsupported_properties{
|
const std::vector<std::string> unsupported_properties{
|
||||||
"RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z",
|
"RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z",
|
||||||
"RRULE:FREQ=HOURLY",
|
"RRULE:FREQ=HOURLY",
|
||||||
"RRULE:FREQ=DAILY;WKST=MO",
|
"RRULE:FREQ=DAILY;WKST=MO",
|
||||||
"RDATE;VALUE=PERIOD:20260718T090000Z/PT1H",
|
"RDATE;VALUE=PERIOD:20260718T090000Z/PT1H"};
|
||||||
"RECURRENCE-ID:20260718T090000Z"};
|
|
||||||
for (std::size_t index = 0; index < unsupported_properties.size(); ++index) {
|
for (std::size_t index = 0; index < unsupported_properties.size(); ++index) {
|
||||||
const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics");
|
const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics");
|
||||||
write_fixture(path,
|
write_fixture(path,
|
||||||
@@ -1026,6 +1059,7 @@ int main() {
|
|||||||
test_unsupported_recurrence_and_zones_are_unsafe(root);
|
test_unsupported_recurrence_and_zones_are_unsafe(root);
|
||||||
test_ordinal_byday_round_trip(root);
|
test_ordinal_byday_round_trip(root);
|
||||||
test_daily_with_byday_is_supported(root);
|
test_daily_with_byday_is_supported(root);
|
||||||
|
test_recurrence_id_round_trip(root);
|
||||||
test_save_validation_preserves_existing_bytes(root);
|
test_save_validation_preserves_existing_bytes(root);
|
||||||
test_vtimezone_custom_tzid_safe_and_round_trips(root);
|
test_vtimezone_custom_tzid_safe_and_round_trips(root);
|
||||||
test_vtimezone_multiple_tzids(root);
|
test_vtimezone_multiple_tzids(root);
|
||||||
|
|||||||
Reference in New Issue
Block a user