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:
@@ -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});
|
||||
|
||||
Reference in New Issue
Block a user