feat: support safe recurrence additions

Parse, persist, expand, and round-trip compatible RDATE values while preserving whole-series TUI behavior and conservative rewrite guards.

Verified: normal, warning-as-error, and ASan/UBSan builds pass all 9 suites; storage failure paths preserve calendar and backup bytes; live PTY restores terminal state.
This commit is contained in:
2026-07-18 10:14:53 +01:00
parent 989332ef9a
commit cfca6ab6d0
16 changed files with 498 additions and 69 deletions

View File

@@ -51,6 +51,7 @@ struct PendingEvent {
std::optional<ParsedTime> start;
std::optional<ParsedTime> end;
std::vector<PendingProperty> recurrence_rules;
std::vector<PendingProperty> recurrence_dates;
std::vector<PendingProperty> exception_dates;
};
@@ -724,6 +725,15 @@ void serialize_calendar(std::ostream& output, std::span<const Event> events) {
if (event.recurrence) {
write_folded(output, "RRULE:" + serialize_recurrence_rule(event, *event.recurrence));
}
if (!event.recurrence_additions.empty()) {
const std::string parameters = time_property_parameters(event);
const std::string values = join_values(event.recurrence_additions,
[&event](TimePoint addition) {
return event.all_day ? format_date(addition)
: format_event_time(event, addition);
});
write_folded(output, "RDATE" + parameters + ":" + values);
}
if (!event.recurrence_exceptions.empty()) {
const std::string parameters = time_property_parameters(event);
const std::string values = join_values(event.recurrence_exceptions,
@@ -1056,9 +1066,17 @@ void validate_event_for_save(const Event& event) {
throw std::invalid_argument(
"cannot save event '" + event.uid + "' with an invalid interval");
}
if (!event.recurrence && !event.recurrence_exceptions.empty()) {
if (!is_recurring(event) && !event.recurrence_exceptions.empty()) {
throw std::invalid_argument(
"cannot save event '" + event.uid + "' with EXDATE values but no recurrence rule");
"cannot save event '" + event.uid
+ "' with EXDATE values but no recurrence set");
}
std::vector<TimePoint> unique_additions = event.recurrence_additions;
std::sort(unique_additions.begin(), unique_additions.end());
if (std::adjacent_find(unique_additions.begin(), unique_additions.end())
!= unique_additions.end()) {
throw std::invalid_argument(
"cannot save event '" + event.uid + "' with duplicate RDATE values");
}
std::vector<TimePoint> unique_exceptions = event.recurrence_exceptions;
std::sort(unique_exceptions.begin(), unique_exceptions.end());
@@ -1221,6 +1239,29 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
}
}
}
for (const PendingProperty& property : pending->recurrence_dates) {
for (const std::string_view addition_value : split(property.value, ',')) {
std::string warning;
const auto parsed = parse_time(
property.parameters, addition_value, warning);
if (!parsed || !same_time_kind(*pending->start, *parsed)) {
add_warning(result, property.line, parsed
? "RDATE value type, time basis, or TZID does not match DTSTART"
: std::move(warning));
mark_unsafe_to_rewrite(result);
continue;
}
if (!warning.empty()) {
add_warning(result, property.line, std::move(warning));
mark_unsafe_to_rewrite(result);
}
if (std::find(event.recurrence_additions.begin(),
event.recurrence_additions.end(), parsed->value)
== event.recurrence_additions.end()) {
event.recurrence_additions.push_back(parsed->value);
}
}
}
for (const PendingProperty& property : pending->exception_dates) {
for (const std::string_view exception_value : split(property.value, ',')) {
std::string warning;
@@ -1244,9 +1285,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
}
}
}
if (!event.recurrence && !event.recurrence_exceptions.empty()) {
if (!is_recurring(event) && !event.recurrence_exceptions.empty()) {
add_warning(result, event_start_line,
"EXDATE without a supported RRULE cannot be edited safely");
"EXDATE without a supported RRULE or RDATE cannot be edited safely");
mark_unsafe_to_rewrite(result);
}
result.events.push_back(std::move(event));
@@ -1275,7 +1316,7 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
continue;
}
const bool time_property = name == "DTSTART" || name == "DTEND"
|| name == "EXDATE";
|| name == "RDATE" || name == "EXDATE";
const bool recurrence_property = name == "RRULE";
const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION"
|| name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID"
@@ -1305,6 +1346,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
} else if (name == "RRULE") {
pending->recurrence_rules.push_back(PendingProperty{
std::string(parameters), std::string(value), index + 1});
} else if (name == "RDATE") {
pending->recurrence_dates.push_back(PendingProperty{
std::string(parameters), std::string(value), index + 1});
} else if (name == "EXDATE") {
pending->exception_dates.push_back(PendingProperty{
std::string(parameters), std::string(value), index + 1});