feat: recurrence rule editing in EventEditor and ordinal BYDAY support

Add recurrence_frequency, recurrence_interval, recurrence_count, and
recurrence_by_weekday editor fields. Frequency cycles with Space.
BYDAY input accepts comma-separated names and ordinal prefixes (1MO).

Extend RecurrenceRule::by_weekday from std::chrono::weekday to ByWeekday
{ordinal, day} struct. Parser accepts ordinal BYDAY (+1MO, -1FR).
Remove strict BY*/FREQ pairing constraint to match RFC 5545.
This commit is contained in:
2026-07-23 18:45:50 +01:00
parent 40fc4651cb
commit 6c1f58ffb0
10 changed files with 499 additions and 34 deletions

View File

@@ -490,12 +490,33 @@ template <typename Integer>
} else if (name == "BYDAY" && !has_by_weekday) {
has_by_weekday = true;
for (const std::string_view day_name : split(part_value, ',')) {
const auto day = parse_weekday(day_name);
int ordinal = 0;
std::string_view weekday_part = day_name;
// Check for ordinal prefix: optional sign, optional digits
if (!day_name.empty() && (day_name[0] == '+' || day_name[0] == '-' || (day_name[0] >= '0' && day_name[0] <= '9'))) {
std::size_t digit_end = 0;
if (day_name[0] == '+' || day_name[0] == '-') {
digit_end = 1;
}
while (digit_end < day_name.size() && day_name[digit_end] >= '0' && day_name[digit_end] <= '9') {
++digit_end;
}
if (digit_end > 0U && digit_end < day_name.size()) {
std::string number_str(day_name.substr(0, digit_end));
ordinal = std::stoi(number_str);
if (ordinal < -5 || ordinal > 5 || ordinal == 0) {
error = "RRULE BYDAY ordinal must be from -5 to -1 or 1 to 5";
return std::nullopt;
}
weekday_part = day_name.substr(digit_end);
}
}
const auto day = parse_weekday(weekday_part);
if (!day) {
error = "RRULE BYDAY supports only non-ordinal weekdays";
error = "RRULE BYDAY contains an invalid weekday";
return std::nullopt;
}
rule.by_weekday.push_back(*day);
rule.by_weekday.push_back({ordinal, *day});
}
} else if (name == "BYMONTHDAY" && !has_by_month_day) {
has_by_month_day = true;
@@ -532,12 +553,6 @@ template <typename Integer>
error = "RRULE COUNT and UNTIL are mutually exclusive";
return std::nullopt;
}
if ((has_by_weekday && rule.frequency != RecurrenceFrequency::weekly)
|| (has_by_month_day && rule.frequency != RecurrenceFrequency::monthly)
|| (has_by_month && rule.frequency != RecurrenceFrequency::yearly)) {
error = "RRULE selector is not supported for this frequency";
return std::nullopt;
}
return rule;
}
@@ -679,7 +694,14 @@ template <typename Range, typename Formatter>
}
if (!rule.by_weekday.empty()) {
result += ";BYDAY=" + join_values(rule.by_weekday,
[](weekday day) { return weekday_name(day); });
[](const ByWeekday& bw) {
std::string out;
if (bw.ordinal != 0) {
out += (bw.ordinal > 0 ? "+" : "") + std::to_string(bw.ordinal);
}
out += weekday_name(bw.day);
return out;
});
}
if (!rule.by_month_day.empty()) {
result += ";BYMONTHDAY=" + join_values(rule.by_month_day,
@@ -1071,8 +1093,8 @@ void validate_recurrence(const Event& event) {
if (rule.interval == 0) invalid("INTERVAL must be positive");
if (rule.count && *rule.count == 0) invalid("COUNT must be positive");
if (rule.count && rule.until) invalid("COUNT and UNTIL are mutually exclusive");
for (weekday day : rule.by_weekday) {
if (!day.ok()) invalid("BYDAY contains an invalid weekday");
for (const ByWeekday& bw : rule.by_weekday) {
if (!bw.day.ok()) invalid("BYDAY contains an invalid weekday");
}
for (int day : rule.by_month_day) {
if (day == 0 || day < -31 || day > 31) {
@@ -1084,12 +1106,7 @@ void validate_recurrence(const Event& event) {
invalid("BYMONTH contains an out-of-range month");
}
}
if ((!rule.by_weekday.empty() && rule.frequency != RecurrenceFrequency::weekly)
|| (!rule.by_month_day.empty() && rule.frequency != RecurrenceFrequency::monthly)
|| (!rule.by_month.empty() && rule.frequency != RecurrenceFrequency::yearly)) {
invalid("selector does not apply to the selected frequency");
}
}
void validate_event_for_save(
const Event& event, std::span<const VTimezoneDefinition> vtimezones) {