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:
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -14,12 +15,20 @@ enum class TimeBasis { floating, utc, zoned };
|
|||||||
|
|
||||||
enum class RecurrenceFrequency { daily, weekly, monthly, yearly };
|
enum class RecurrenceFrequency { daily, weekly, monthly, yearly };
|
||||||
|
|
||||||
|
// BYDAY selector. For non-ordinal: use {0, weekday}. For ordinal: {n, weekday}.
|
||||||
|
struct ByWeekday {
|
||||||
|
int ordinal{0}; // 0 = non-ordinal; 1-5 or -5 to -1 = ordinal position
|
||||||
|
std::chrono::weekday day;
|
||||||
|
|
||||||
|
bool operator==(const ByWeekday&) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
struct RecurrenceRule {
|
struct RecurrenceRule {
|
||||||
RecurrenceFrequency frequency{RecurrenceFrequency::daily};
|
RecurrenceFrequency frequency{RecurrenceFrequency::daily};
|
||||||
unsigned interval{1};
|
unsigned interval{1};
|
||||||
std::optional<unsigned> count;
|
std::optional<unsigned> count;
|
||||||
std::optional<TimePoint> until;
|
std::optional<TimePoint> until;
|
||||||
std::vector<std::chrono::weekday> by_weekday;
|
std::vector<ByWeekday> by_weekday;
|
||||||
std::vector<int> by_month_day;
|
std::vector<int> by_month_day;
|
||||||
std::vector<unsigned> by_month;
|
std::vector<unsigned> by_month;
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ enum class EditorField : std::size_t {
|
|||||||
all_day,
|
all_day,
|
||||||
location,
|
location,
|
||||||
notes,
|
notes,
|
||||||
|
// Recurrence fields
|
||||||
|
recurrence_frequency, // none/daily/weekly/monthly/yearly
|
||||||
|
recurrence_interval, // number
|
||||||
|
recurrence_count, // number or empty for unlimited
|
||||||
|
recurrence_by_weekday, // comma-separated: MO,TU,WE,TH,FR
|
||||||
count,
|
count,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -211,8 +211,8 @@ void append_occurrence(EventOccurrences& result, const Event& event,
|
|||||||
!rule.by_month.empty()) {
|
!rule.by_month.empty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (const auto weekday : rule.by_weekday) {
|
for (const auto& bw : rule.by_weekday) {
|
||||||
if (!weekday.ok()) {
|
if (!bw.day.ok()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,8 +267,8 @@ void append_occurrence(EventOccurrences& result, const Event& event,
|
|||||||
1U);
|
1U);
|
||||||
} else {
|
} else {
|
||||||
result.reserve(rule.by_weekday.size());
|
result.reserve(rule.by_weekday.size());
|
||||||
for (const auto value : rule.by_weekday) {
|
for (const auto& bw : rule.by_weekday) {
|
||||||
result.push_back(value.iso_encoding() - 1U);
|
result.push_back(bw.day.iso_encoding() - 1U);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
std::sort(result.begin(), result.end());
|
std::sort(result.begin(), result.end());
|
||||||
|
|||||||
@@ -490,12 +490,33 @@ template <typename Integer>
|
|||||||
} else if (name == "BYDAY" && !has_by_weekday) {
|
} else if (name == "BYDAY" && !has_by_weekday) {
|
||||||
has_by_weekday = true;
|
has_by_weekday = true;
|
||||||
for (const std::string_view day_name : split(part_value, ',')) {
|
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) {
|
if (!day) {
|
||||||
error = "RRULE BYDAY supports only non-ordinal weekdays";
|
error = "RRULE BYDAY contains an invalid weekday";
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
rule.by_weekday.push_back(*day);
|
rule.by_weekday.push_back({ordinal, *day});
|
||||||
}
|
}
|
||||||
} else if (name == "BYMONTHDAY" && !has_by_month_day) {
|
} else if (name == "BYMONTHDAY" && !has_by_month_day) {
|
||||||
has_by_month_day = true;
|
has_by_month_day = true;
|
||||||
@@ -532,12 +553,6 @@ template <typename Integer>
|
|||||||
error = "RRULE COUNT and UNTIL are mutually exclusive";
|
error = "RRULE COUNT and UNTIL are mutually exclusive";
|
||||||
return std::nullopt;
|
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;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,7 +694,14 @@ template <typename Range, typename Formatter>
|
|||||||
}
|
}
|
||||||
if (!rule.by_weekday.empty()) {
|
if (!rule.by_weekday.empty()) {
|
||||||
result += ";BYDAY=" + join_values(rule.by_weekday,
|
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()) {
|
if (!rule.by_month_day.empty()) {
|
||||||
result += ";BYMONTHDAY=" + join_values(rule.by_month_day,
|
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.interval == 0) invalid("INTERVAL must be positive");
|
||||||
if (rule.count && *rule.count == 0) invalid("COUNT 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");
|
if (rule.count && rule.until) invalid("COUNT and UNTIL are mutually exclusive");
|
||||||
for (weekday day : rule.by_weekday) {
|
for (const ByWeekday& bw : rule.by_weekday) {
|
||||||
if (!day.ok()) invalid("BYDAY contains an invalid weekday");
|
if (!bw.day.ok()) invalid("BYDAY contains an invalid weekday");
|
||||||
}
|
}
|
||||||
for (int day : rule.by_month_day) {
|
for (int day : rule.by_month_day) {
|
||||||
if (day == 0 || day < -31 || day > 31) {
|
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");
|
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(
|
void validate_event_for_save(
|
||||||
const Event& event, std::span<const VTimezoneDefinition> vtimezones) {
|
const Event& event, std::span<const VTimezoneDefinition> vtimezones) {
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ namespace {
|
|||||||
|
|
||||||
constexpr std::size_t field_count = static_cast<std::size_t>(EditorField::count);
|
constexpr std::size_t field_count = static_cast<std::size_t>(EditorField::count);
|
||||||
constexpr std::array<std::string_view, field_count> labels{
|
constexpr std::array<std::string_view, field_count> labels{
|
||||||
"Title", "Start date", "Start time", "End date", "End time", "All day", "Location", "Notes",
|
"Title", "Start date", "Start time", "End date", "End time",
|
||||||
|
"All day", "Location", "Notes",
|
||||||
|
"Repeat", "Interval", "Count", "On days",
|
||||||
};
|
};
|
||||||
|
|
||||||
std::size_t index_of(const EditorField field) noexcept
|
std::size_t index_of(const EditorField field) noexcept
|
||||||
@@ -223,6 +225,81 @@ std::string hints_interior_line(const std::initializer_list<KeyHint> hints, cons
|
|||||||
return "│ " + render_hints(hints, width - 4, ansi) + "│";
|
return "│ " + render_hints(hints, width - 4, ansi) + "│";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recurrence frequency display names
|
||||||
|
constexpr std::array<std::string_view, 5> frequency_names{
|
||||||
|
"none", "daily", "weekly", "monthly", "yearly",
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] int frequency_name_to_index(const std::string_view name) noexcept
|
||||||
|
{
|
||||||
|
for (int i = 0; i < static_cast<int>(frequency_names.size()); ++i) {
|
||||||
|
if (frequency_names[static_cast<std::size_t>(i)] == name) return i;
|
||||||
|
}
|
||||||
|
return 0; // default to none
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string weekday_short_name(const std::chrono::weekday day) noexcept
|
||||||
|
{
|
||||||
|
static constexpr std::string_view names[]{"SU", "MO", "TU", "WE", "TH", "FR", "SA"};
|
||||||
|
return std::string(names[day.c_encoding()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string by_weekday_display(const std::vector<ByWeekday>& weekdays)
|
||||||
|
{
|
||||||
|
std::string result;
|
||||||
|
for (std::size_t i = 0; i < weekdays.size(); ++i) {
|
||||||
|
if (i != 0) result += ",";
|
||||||
|
result += weekday_short_name(weekdays[i].day);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a comma-separated list of weekday short names (MO,TU,WE) into ByWeekday entries.
|
||||||
|
// Returns empty vector on any parse failure.
|
||||||
|
[[nodiscard]] std::vector<ByWeekday> parse_by_weekday_input(const std::string_view text)
|
||||||
|
{
|
||||||
|
std::vector<ByWeekday> result;
|
||||||
|
if (text.empty()) return result;
|
||||||
|
std::size_t start = 0;
|
||||||
|
while (start < text.size()) {
|
||||||
|
while (start < text.size() && (text[start] == ',' || text[start] == ' ')) ++start;
|
||||||
|
if (start >= text.size()) break;
|
||||||
|
std::size_t end = start;
|
||||||
|
while (end < text.size() && text[end] != ',' && text[end] != ' ') ++end;
|
||||||
|
const auto token = text.substr(start, end - start);
|
||||||
|
// Check for ordinal prefix (e.g., 1MO, -1SU)
|
||||||
|
int ordinal = 0;
|
||||||
|
std::string_view weekday_part = token;
|
||||||
|
if (!token.empty() && (token[0] == '+' || token[0] == '-' || (token[0] >= '0' && token[0] <= '9'))) {
|
||||||
|
std::size_t digit_end = 0;
|
||||||
|
if (token[0] == '+' || token[0] == '-') digit_end = 1;
|
||||||
|
while (digit_end < token.size() && token[digit_end] >= '0' && token[digit_end] <= '9') ++digit_end;
|
||||||
|
if (digit_end > 0U && digit_end < token.size()) {
|
||||||
|
try {
|
||||||
|
ordinal = std::stoi(std::string(token.substr(0, digit_end)));
|
||||||
|
} catch (...) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (ordinal < -5 || ordinal > 5) return {};
|
||||||
|
weekday_part = token.substr(digit_end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::chrono::weekday day{0};
|
||||||
|
bool found = false;
|
||||||
|
if (weekday_part == "SU") { day = std::chrono::Sunday; found = true; }
|
||||||
|
else if (weekday_part == "MO") { day = std::chrono::Monday; found = true; }
|
||||||
|
else if (weekday_part == "TU") { day = std::chrono::Tuesday; found = true; }
|
||||||
|
else if (weekday_part == "WE") { day = std::chrono::Wednesday; found = true; }
|
||||||
|
else if (weekday_part == "TH") { day = std::chrono::Thursday; found = true; }
|
||||||
|
else if (weekday_part == "FR") { day = std::chrono::Friday; found = true; }
|
||||||
|
else if (weekday_part == "SA") { day = std::chrono::Saturday; found = true; }
|
||||||
|
if (!found) return {};
|
||||||
|
result.push_back({ordinal, day});
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
EventEditor::EventEditor(const Date selected_day)
|
EventEditor::EventEditor(const Date selected_day)
|
||||||
@@ -236,6 +313,12 @@ EventEditor::EventEditor(const Date selected_day)
|
|||||||
values_[index_of(EditorField::start_time)] = "09:00";
|
values_[index_of(EditorField::start_time)] = "09:00";
|
||||||
values_[index_of(EditorField::end_date)] = format_date(selected_day);
|
values_[index_of(EditorField::end_date)] = format_date(selected_day);
|
||||||
values_[index_of(EditorField::end_time)] = "10:00";
|
values_[index_of(EditorField::end_time)] = "10:00";
|
||||||
|
values_[index_of(EditorField::recurrence_frequency)] = "none";
|
||||||
|
values_[index_of(EditorField::recurrence_interval)] = "1";
|
||||||
|
values_[index_of(EditorField::recurrence_count)] = "";
|
||||||
|
// Default to days of week for weekly: the start day's weekday
|
||||||
|
const auto start_wd = std::chrono::weekday{std::chrono::sys_days{selected_day}};
|
||||||
|
values_[index_of(EditorField::recurrence_by_weekday)] = weekday_short_name(start_wd);
|
||||||
}
|
}
|
||||||
|
|
||||||
EventEditor::EventEditor(const Event& event)
|
EventEditor::EventEditor(const Event& event)
|
||||||
@@ -251,11 +334,34 @@ EventEditor::EventEditor(const Event& event)
|
|||||||
values_[index_of(EditorField::end_time)] = two_digits(end.hour) + ':' + two_digits(end.minute);
|
values_[index_of(EditorField::end_time)] = two_digits(end.hour) + ':' + two_digits(end.minute);
|
||||||
values_[index_of(EditorField::location)] = event.location;
|
values_[index_of(EditorField::location)] = event.location;
|
||||||
values_[index_of(EditorField::notes)] = event.description;
|
values_[index_of(EditorField::notes)] = event.description;
|
||||||
|
|
||||||
|
// Populate recurrence fields from existing rule
|
||||||
|
if (event.recurrence) {
|
||||||
|
const auto& rule = *event.recurrence;
|
||||||
|
std::string freq_name;
|
||||||
|
switch (rule.frequency) {
|
||||||
|
case RecurrenceFrequency::daily: freq_name = "daily"; break;
|
||||||
|
case RecurrenceFrequency::weekly: freq_name = "weekly"; break;
|
||||||
|
case RecurrenceFrequency::monthly: freq_name = "monthly"; break;
|
||||||
|
case RecurrenceFrequency::yearly: freq_name = "yearly"; break;
|
||||||
|
}
|
||||||
|
values_[index_of(EditorField::recurrence_frequency)] = freq_name;
|
||||||
|
values_[index_of(EditorField::recurrence_interval)] = std::to_string(rule.interval);
|
||||||
|
values_[index_of(EditorField::recurrence_count)] = rule.count
|
||||||
|
? std::to_string(*rule.count) : "";
|
||||||
|
values_[index_of(EditorField::recurrence_by_weekday)] = by_weekday_display(rule.by_weekday);
|
||||||
|
} else {
|
||||||
|
values_[index_of(EditorField::recurrence_frequency)] = "none";
|
||||||
|
values_[index_of(EditorField::recurrence_interval)] = "1";
|
||||||
|
values_[index_of(EditorField::recurrence_count)] = "";
|
||||||
|
const auto start_wd = std::chrono::weekday{std::chrono::sys_days{start.date}};
|
||||||
|
values_[index_of(EditorField::recurrence_by_weekday)] = weekday_short_name(start_wd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string_view EventEditor::field_value(const EditorField field) const noexcept
|
std::string_view EventEditor::field_value(const EditorField field) const noexcept
|
||||||
{
|
{
|
||||||
if (field == EditorField::all_day || field == EditorField::count) return {};
|
if (field == EditorField::all_day) return {};
|
||||||
return values_[index_of(field)];
|
return values_[index_of(field)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +412,18 @@ EditorOutcome EventEditor::handle_key(const std::string_view key)
|
|||||||
return EditorOutcome::active;
|
return EditorOutcome::active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recurrence frequency cycling: Space or arrow keys cycle through options
|
||||||
|
if (focused_ == EditorField::recurrence_frequency) {
|
||||||
|
if (key == " " || key == "\r" || key == "\n") {
|
||||||
|
auto& value = values_[index_of(focused_)];
|
||||||
|
const int current = frequency_name_to_index(value);
|
||||||
|
const int next = (current + 1) % static_cast<int>(frequency_names.size());
|
||||||
|
value = frequency_names[static_cast<std::size_t>(next)];
|
||||||
|
error_.clear();
|
||||||
|
}
|
||||||
|
return EditorOutcome::active;
|
||||||
|
}
|
||||||
|
|
||||||
auto& value = values_[index_of(focused_)];
|
auto& value = values_[index_of(focused_)];
|
||||||
if (key == "\x7f" || key == "\b") {
|
if (key == "\x7f" || key == "\b") {
|
||||||
erase_last_codepoint(value);
|
erase_last_codepoint(value);
|
||||||
@@ -359,8 +477,6 @@ bool EventEditor::validate()
|
|||||||
return fail(EditorField::end_date,
|
return fail(EditorField::end_date,
|
||||||
"All-day end is exclusive and must be at least the next day.");
|
"All-day end is exclusive and must be at least the next day.");
|
||||||
}
|
}
|
||||||
// DATE values have no timezone in iCalendar. Normalizing here keeps
|
|
||||||
// the in-memory result identical to a save/reload round trip.
|
|
||||||
candidate.time_basis = TimeBasis::floating;
|
candidate.time_basis = TimeBasis::floating;
|
||||||
candidate.time_zone.clear();
|
candidate.time_zone.clear();
|
||||||
candidate.start = make_event_time(candidate, start_day, 0, 0);
|
candidate.start = make_event_time(candidate, start_day, 0, 0);
|
||||||
@@ -387,6 +503,68 @@ bool EventEditor::validate()
|
|||||||
"That local date and time cannot be represented in this time zone.");
|
"That local date and time cannot be represented in this time zone.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build recurrence rule from editor fields
|
||||||
|
const auto& freq_text = values_[index_of(EditorField::recurrence_frequency)];
|
||||||
|
if (freq_text != "none") {
|
||||||
|
RecurrenceRule rule;
|
||||||
|
|
||||||
|
// Parse frequency
|
||||||
|
if (freq_text == "daily") rule.frequency = RecurrenceFrequency::daily;
|
||||||
|
else if (freq_text == "weekly") rule.frequency = RecurrenceFrequency::weekly;
|
||||||
|
else if (freq_text == "monthly") rule.frequency = RecurrenceFrequency::monthly;
|
||||||
|
else if (freq_text == "yearly") rule.frequency = RecurrenceFrequency::yearly;
|
||||||
|
else {
|
||||||
|
return fail(EditorField::recurrence_frequency, "Invalid recurrence frequency.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse interval
|
||||||
|
const auto& interval_text = values_[index_of(EditorField::recurrence_interval)];
|
||||||
|
if (interval_text.empty()) {
|
||||||
|
return fail(EditorField::recurrence_interval, "Interval is required.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const auto interval_val = std::stoul(interval_text);
|
||||||
|
if (interval_val < 1U || interval_val > 32767U) {
|
||||||
|
return fail(EditorField::recurrence_interval, "Interval must be between 1 and 32767.");
|
||||||
|
}
|
||||||
|
rule.interval = static_cast<unsigned>(interval_val);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return fail(EditorField::recurrence_interval, "Interval must be a positive integer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse count (optional)
|
||||||
|
const auto& count_text = values_[index_of(EditorField::recurrence_count)];
|
||||||
|
if (!count_text.empty()) {
|
||||||
|
try {
|
||||||
|
const auto count_val = std::stoul(count_text);
|
||||||
|
if (count_val < 1U) {
|
||||||
|
return fail(EditorField::recurrence_count, "Count must be at least 1.");
|
||||||
|
}
|
||||||
|
rule.count = static_cast<unsigned>(count_val);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return fail(EditorField::recurrence_count, "Count must be a positive integer or empty.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse BYDAY
|
||||||
|
const auto& by_weekday_text = values_[index_of(EditorField::recurrence_by_weekday)];
|
||||||
|
const auto parsed = parse_by_weekday_input(by_weekday_text);
|
||||||
|
if (parsed.empty() && !by_weekday_text.empty()) {
|
||||||
|
return fail(EditorField::recurrence_by_weekday, "Use comma-separated day names: MO,TU,WE");
|
||||||
|
}
|
||||||
|
rule.by_weekday = parsed;
|
||||||
|
|
||||||
|
// If frequency is weekly and by_weekday is empty, fill with DTSTART's weekday
|
||||||
|
if (rule.frequency == RecurrenceFrequency::weekly && rule.by_weekday.empty()) {
|
||||||
|
const auto start_wd = std::chrono::weekday{std::chrono::sys_days{start_day}};
|
||||||
|
rule.by_weekday.push_back({0, start_wd});
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.recurrence = std::move(rule);
|
||||||
|
} else {
|
||||||
|
candidate.recurrence = std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
result_ = std::move(candidate);
|
result_ = std::move(candidate);
|
||||||
error_.clear();
|
error_.clear();
|
||||||
return true;
|
return true;
|
||||||
@@ -420,15 +598,37 @@ std::string EventEditor::render(const int requested_width, const int requested_h
|
|||||||
width, "2", ansi));
|
width, "2", ansi));
|
||||||
lines.push_back(rule);
|
lines.push_back(rule);
|
||||||
|
|
||||||
|
const auto& freq_text = values_[index_of(EditorField::recurrence_frequency)];
|
||||||
|
const bool has_recurrence = freq_text != "none";
|
||||||
|
|
||||||
for (std::size_t index = 0; index < field_count; ++index) {
|
for (std::size_t index = 0; index < field_count; ++index) {
|
||||||
const auto field = static_cast<EditorField>(index);
|
const auto field = static_cast<EditorField>(index);
|
||||||
const bool selected = focused_ == field;
|
const bool selected = focused_ == field;
|
||||||
|
|
||||||
|
// Suppress recurrence fields (except frequency) when frequency is none
|
||||||
|
if (field == EditorField::recurrence_interval && !has_recurrence) continue;
|
||||||
|
if (field == EditorField::recurrence_count && !has_recurrence) continue;
|
||||||
|
if (field == EditorField::recurrence_by_weekday && !has_recurrence) continue;
|
||||||
|
|
||||||
std::string value;
|
std::string value;
|
||||||
if (field == EditorField::all_day) {
|
if (field == EditorField::all_day) {
|
||||||
value = all_day_ ? "[x] spans whole days (end date is exclusive)"
|
value = all_day_ ? "[x] spans whole days (end date is exclusive)"
|
||||||
: "[ ] timed appointment";
|
: "[ ] timed appointment";
|
||||||
} else if (all_day_ && (field == EditorField::start_time || field == EditorField::end_time)) {
|
} else if (all_day_ && (field == EditorField::start_time || field == EditorField::end_time)) {
|
||||||
value = "— not used for all-day appointments —";
|
value = "— not used for all-day appointments —";
|
||||||
|
} else if (field == EditorField::recurrence_frequency) {
|
||||||
|
value = std::string{freq_text};
|
||||||
|
if (selected) value += " (Space to cycle)";
|
||||||
|
} else if (field == EditorField::recurrence_interval) {
|
||||||
|
value = values_[index];
|
||||||
|
if (selected) value += "_";
|
||||||
|
} else if (field == EditorField::recurrence_count) {
|
||||||
|
const auto& count_val = values_[index];
|
||||||
|
value = count_val.empty() ? "(unlimited)" : count_val;
|
||||||
|
if (selected) value += "_";
|
||||||
|
} else if (field == EditorField::recurrence_by_weekday) {
|
||||||
|
value = values_[index];
|
||||||
|
if (selected) value += "_";
|
||||||
} else {
|
} else {
|
||||||
value = single_line(values_[index]);
|
value = single_line(values_[index]);
|
||||||
if (selected) value += "_";
|
if (selected) value += "_";
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ std::string recurrence_summary(const RecurrenceRule& rule) {
|
|||||||
result += " on ";
|
result += " on ";
|
||||||
for (std::size_t index = 0; index < rule.by_weekday.size(); ++index) {
|
for (std::size_t index = 0; index < rule.by_weekday.size(); ++index) {
|
||||||
if (index != 0) result += ", ";
|
if (index != 0) result += ", ";
|
||||||
const auto weekday_index = rule.by_weekday[index].iso_encoding() - 1U;
|
const auto weekday_index = rule.by_weekday[index].day.iso_encoding() - 1U;
|
||||||
if (weekday_index < weekday_short.size()) result += weekday_short[weekday_index];
|
if (weekday_index < weekday_short.size()) result += weekday_short[weekday_index];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ void check_invalid_argument(Function&& function, const std::string_view message)
|
|||||||
recurrence.interval = 2;
|
recurrence.interval = 2;
|
||||||
recurrence.count = 6;
|
recurrence.count = 6;
|
||||||
recurrence.until = at(24);
|
recurrence.until = at(24);
|
||||||
recurrence.by_weekday = {Monday, Wednesday};
|
recurrence.by_weekday = {{0, Monday}, {0, Wednesday}};
|
||||||
recurrence.by_month_day = {1, -1};
|
recurrence.by_month_day = {1, -1};
|
||||||
recurrence.by_month = {1, 7};
|
recurrence.by_month = {1, 7};
|
||||||
event.recurrence = std::move(recurrence);
|
event.recurrence = std::move(recurrence);
|
||||||
|
|||||||
@@ -384,7 +384,7 @@ void test_weekly_count_and_exdates()
|
|||||||
nocal::RecurrenceRule rule;
|
nocal::RecurrenceRule rule;
|
||||||
rule.frequency = nocal::RecurrenceFrequency::weekly;
|
rule.frequency = nocal::RecurrenceFrequency::weekly;
|
||||||
rule.count = 6U;
|
rule.count = 6U;
|
||||||
rule.by_weekday = {Monday, Wednesday, Friday};
|
rule.by_weekday = {{0, Monday}, {0, Wednesday}, {0, Friday}};
|
||||||
auto weekly = utc_recurring("weekly", monday, 9, monday, 10, rule);
|
auto weekly = utc_recurring("weekly", monday, 9, monday, 10, rule);
|
||||||
weekly.recurrence_exceptions = {weekly.start,
|
weekly.recurrence_exceptions = {weekly.start,
|
||||||
utc_time(2026y / July / 10d, 9)};
|
utc_time(2026y / July / 10d, 9)};
|
||||||
|
|||||||
@@ -315,6 +315,182 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap()
|
|||||||
"a nonexistent source-zone civil time in the DST gap is rejected");
|
"a nonexistent source-zone civil time in the DST gap is rejected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test_recurrence_frequency_cycling()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
|
||||||
|
(void)editor.handle_key("Weekly meeting");
|
||||||
|
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||||
|
|
||||||
|
// Default is "none"
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "none",
|
||||||
|
"new event starts with no recurrence");
|
||||||
|
|
||||||
|
// Space cycles through frequencies
|
||||||
|
(void)editor.handle_key(" ");
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "daily",
|
||||||
|
"Space cycles to daily");
|
||||||
|
|
||||||
|
(void)editor.handle_key(" ");
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "weekly",
|
||||||
|
"Space cycles to weekly");
|
||||||
|
|
||||||
|
(void)editor.handle_key(" ");
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "monthly",
|
||||||
|
"Space cycles to monthly");
|
||||||
|
|
||||||
|
(void)editor.handle_key(" ");
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "yearly",
|
||||||
|
"Space cycles to yearly");
|
||||||
|
|
||||||
|
(void)editor.handle_key(" ");
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "none",
|
||||||
|
"Space wraps around to none");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_recurrence_submit()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const nocal::Date day{2026y / July / 17d};
|
||||||
|
|
||||||
|
// Test: daily recurrence with count
|
||||||
|
{
|
||||||
|
nocal::tui::EventEditor editor{day};
|
||||||
|
(void)editor.handle_key("Daily standup");
|
||||||
|
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||||
|
(void)editor.handle_key(" "); // -> daily
|
||||||
|
focus(editor, nocal::tui::EditorField::recurrence_count);
|
||||||
|
replace_field(editor, "5");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
|
||||||
|
"daily with count=5 submits");
|
||||||
|
check(editor.result().recurrence.has_value(), "recurrence should be set");
|
||||||
|
check(editor.result().recurrence->frequency == nocal::RecurrenceFrequency::daily,
|
||||||
|
"frequency is daily");
|
||||||
|
check(editor.result().recurrence->interval == 1, "default interval is 1");
|
||||||
|
check(editor.result().recurrence->count == 5, "count is 5");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: weekly recurrence (no by_weekday -> fills with DTSTART's weekday)
|
||||||
|
{
|
||||||
|
nocal::tui::EventEditor editor{day};
|
||||||
|
(void)editor.handle_key("Weekly sync");
|
||||||
|
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||||
|
(void)editor.handle_key(" "); // -> daily
|
||||||
|
(void)editor.handle_key(" "); // -> weekly
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
|
||||||
|
"weekly without BYDAY submits");
|
||||||
|
check(editor.result().recurrence.has_value(), "recurrence should be set");
|
||||||
|
check(editor.result().recurrence->frequency == nocal::RecurrenceFrequency::weekly,
|
||||||
|
"frequency is weekly");
|
||||||
|
check(!editor.result().recurrence->by_weekday.empty(),
|
||||||
|
"weekly without BYDAY should auto-fill with start weekday");
|
||||||
|
// July 17, 2026 is a Friday
|
||||||
|
check(editor.result().recurrence->by_weekday[0].day == std::chrono::Friday,
|
||||||
|
"auto-filled weekday should be Friday for 2026-07-17");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: no recurrence (none)
|
||||||
|
{
|
||||||
|
nocal::tui::EventEditor editor{day};
|
||||||
|
(void)editor.handle_key("One-off meeting");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
|
||||||
|
"event with frequency=none submits");
|
||||||
|
check(!editor.result().recurrence.has_value(),
|
||||||
|
"frequency=none should clear recurrence");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: invalid interval
|
||||||
|
{
|
||||||
|
nocal::tui::EventEditor editor{day};
|
||||||
|
(void)editor.handle_key("Bad interval");
|
||||||
|
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||||
|
(void)editor.handle_key(" "); // -> daily
|
||||||
|
focus(editor, nocal::tui::EditorField::recurrence_interval);
|
||||||
|
replace_field(editor, "0");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
|
||||||
|
editor.error().find("Interval") != std::string_view::npos,
|
||||||
|
"interval 0 is rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_edit_recurring_event_populates_fields()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
nocal::Event event;
|
||||||
|
event.uid = "recurring-edit-test";
|
||||||
|
event.title = "Original recurring";
|
||||||
|
event.start = sys_days{year{2026}/July/17} + 9h;
|
||||||
|
event.end = sys_days{year{2026}/July/17} + 10h;
|
||||||
|
event.recurrence = nocal::RecurrenceRule{
|
||||||
|
.frequency = nocal::RecurrenceFrequency::weekly,
|
||||||
|
.interval = 2,
|
||||||
|
.count = 10,
|
||||||
|
.until = std::nullopt,
|
||||||
|
.by_weekday = {{0, std::chrono::Monday}, {0, std::chrono::Wednesday}, {0, std::chrono::Friday}},
|
||||||
|
.by_month_day = {},
|
||||||
|
.by_month = {},
|
||||||
|
};
|
||||||
|
|
||||||
|
nocal::tui::EventEditor editor{event};
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "weekly",
|
||||||
|
"edit populates recurrence frequency from existing rule");
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::recurrence_interval) == "2",
|
||||||
|
"edit populates recurrence interval from existing rule");
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::recurrence_count) == "10",
|
||||||
|
"edit populates recurrence count from existing rule");
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::recurrence_by_weekday) == "MO,WE,FR",
|
||||||
|
"edit populates BYDAY from existing rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_rendering_shows_recurrence()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
|
||||||
|
const auto plain = editor.render(72, 22, false);
|
||||||
|
check(plain.find("Repeat") != std::string::npos,
|
||||||
|
"render shows Recurrence label");
|
||||||
|
check(plain.find("none") != std::string::npos,
|
||||||
|
"render shows default frequency as none");
|
||||||
|
|
||||||
|
// Cycle to weekly and check render
|
||||||
|
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||||
|
(void)editor.handle_key(" "); // -> daily
|
||||||
|
(void)editor.handle_key(" "); // -> weekly
|
||||||
|
const auto with_recurrence = editor.render(72, 22, false);
|
||||||
|
check(with_recurrence.find("weekly") != std::string::npos,
|
||||||
|
"render shows weekly when set");
|
||||||
|
check(with_recurrence.find("Interval") != std::string::npos,
|
||||||
|
"render shows Interval when frequency is set");
|
||||||
|
check(with_recurrence.find("Count") != std::string::npos,
|
||||||
|
"render shows Count when frequency is set");
|
||||||
|
check(with_recurrence.find("On days") != std::string::npos,
|
||||||
|
"render shows On days when frequency is set");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_recurrence_weekly_by_weekday_input()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const nocal::Date day{2026y / July / 17d};
|
||||||
|
nocal::tui::EventEditor editor{day};
|
||||||
|
(void)editor.handle_key("Custom weekly");
|
||||||
|
focus(editor, nocal::tui::EditorField::recurrence_frequency);
|
||||||
|
(void)editor.handle_key(" "); // -> daily
|
||||||
|
(void)editor.handle_key(" "); // -> weekly
|
||||||
|
focus(editor, nocal::tui::EditorField::recurrence_by_weekday);
|
||||||
|
replace_field(editor, "MO,WE,FR");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
|
||||||
|
"custom weekly with MO,WE,FR submits");
|
||||||
|
check(editor.result().recurrence.has_value(), "recurrence should be set");
|
||||||
|
check(editor.result().recurrence->by_weekday.size() == 3,
|
||||||
|
"three BYDAY entries expected");
|
||||||
|
check(editor.result().recurrence->by_weekday[0].day == std::chrono::Monday,
|
||||||
|
"first BYDAY is Monday");
|
||||||
|
check(editor.result().recurrence->by_weekday[1].day == std::chrono::Wednesday,
|
||||||
|
"second BYDAY is Wednesday");
|
||||||
|
check(editor.result().recurrence->by_weekday[2].day == std::chrono::Friday,
|
||||||
|
"third BYDAY is Friday");
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main()
|
int main()
|
||||||
@@ -326,6 +502,11 @@ int main()
|
|||||||
test_unicode_backspace_navigation_and_cancel();
|
test_unicode_backspace_navigation_and_cancel();
|
||||||
test_rendering();
|
test_rendering();
|
||||||
test_zoned_edit_preserves_series_and_rejects_dst_gap();
|
test_zoned_edit_preserves_series_and_rejects_dst_gap();
|
||||||
|
test_recurrence_frequency_cycling();
|
||||||
|
test_recurrence_submit();
|
||||||
|
test_edit_recurring_event_populates_fields();
|
||||||
|
test_rendering_shows_recurrence();
|
||||||
|
test_recurrence_weekly_by_weekday_input();
|
||||||
if (failures != 0) {
|
if (failures != 0) {
|
||||||
std::cerr << failures << " editor test(s) failed\n";
|
std::cerr << failures << " editor test(s) failed\n";
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ void test_supported_recurrence_and_exdates(const std::filesystem::path& root) {
|
|||||||
"multiple/comma-separated EXDATE values were not collected");
|
"multiple/comma-separated EXDATE values were not collected");
|
||||||
const auto& weekly = *loaded.events[1].recurrence;
|
const auto& weekly = *loaded.events[1].recurrence;
|
||||||
require(weekly.frequency == nocal::RecurrenceFrequency::weekly
|
require(weekly.frequency == nocal::RecurrenceFrequency::weekly
|
||||||
&& weekly.by_weekday == std::vector<weekday>{Monday, Wednesday, Friday}
|
&& weekly.by_weekday == std::vector<nocal::ByWeekday>{{0, Monday}, {0, Wednesday}, {0, Friday}}
|
||||||
&& weekly.until.has_value(),
|
&& weekly.until.has_value(),
|
||||||
"weekly RRULE fields differ");
|
"weekly RRULE fields differ");
|
||||||
const auto& monthly = *loaded.events[2].recurrence;
|
const auto& monthly = *loaded.events[2].recurrence;
|
||||||
@@ -652,10 +652,61 @@ void test_supported_rdates(const std::filesystem::path& root) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test_ordinal_byday_round_trip(const std::filesystem::path& root) {
|
||||||
|
const auto path = root / "ordinal-byday.ics";
|
||||||
|
write_fixture(path,
|
||||||
|
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
|
||||||
|
"BEGIN:VEVENT\r\nUID:ordinal-weekly\r\nDTSTART:20260701T090000Z\r\n"
|
||||||
|
"DTEND:20260701T100000Z\r\n"
|
||||||
|
"RRULE:FREQ=MONTHLY;BYDAY=+1MO,-1FR\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(),
|
||||||
|
"ordinal BYDAY should be rewrite-safe");
|
||||||
|
require(loaded.events.size() == 1, "ordinal BYDAY fixture event count differs");
|
||||||
|
const auto& rule = *loaded.events[0].recurrence;
|
||||||
|
require(rule.frequency == nocal::RecurrenceFrequency::monthly,
|
||||||
|
"monthly frequency should be preserved");
|
||||||
|
require(rule.by_weekday.size() == 2, "two BYDAY entries expected");
|
||||||
|
require(rule.by_weekday[0].ordinal == 1 && rule.by_weekday[0].day == std::chrono::Monday,
|
||||||
|
"first BYDAY should be +1MO");
|
||||||
|
require(rule.by_weekday[1].ordinal == -1 && rule.by_weekday[1].day == std::chrono::Friday,
|
||||||
|
"second BYDAY should be -1FR");
|
||||||
|
|
||||||
|
const auto round_trip = root / "ordinal-byday-round-trip.ics";
|
||||||
|
nocal::storage::IcsStore::save(round_trip, loaded.events);
|
||||||
|
const std::string serialized = read_fixture(round_trip);
|
||||||
|
require(serialized.find("BYDAY=+1MO,-1FR") != std::string::npos,
|
||||||
|
"ordinal BYDAY should serialize as +1MO,-1FR");
|
||||||
|
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
|
||||||
|
require(reloaded.safe_to_rewrite && reloaded.warnings.empty(),
|
||||||
|
"ordinal BYDAY round-trip should be rewrite-safe");
|
||||||
|
require(reloaded.events[0].recurrence == loaded.events[0].recurrence,
|
||||||
|
"ordinal BYDAY semantic round trip differs");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_daily_with_byday_is_supported(const std::filesystem::path& root) {
|
||||||
|
const auto path = root / "daily-byday.ics";
|
||||||
|
write_fixture(path,
|
||||||
|
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
|
||||||
|
"BEGIN:VEVENT\r\nUID:daily-byday\r\nDTSTART:20260701T090000Z\r\n"
|
||||||
|
"DTEND:20260701T100000Z\r\n"
|
||||||
|
"RRULE:FREQ=DAILY;BYDAY=MO,WE,FR\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(),
|
||||||
|
"DAILY with BYDAY should now be rewrite-safe");
|
||||||
|
require(loaded.events.size() == 1, "daily-byday fixture event count differs");
|
||||||
|
const auto& rule = *loaded.events[0].recurrence;
|
||||||
|
require(rule.frequency == nocal::RecurrenceFrequency::daily,
|
||||||
|
"daily frequency should be preserved");
|
||||||
|
require(rule.by_weekday.size() == 3, "three BYDAY entries expected");
|
||||||
|
}
|
||||||
|
|
||||||
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=WEEKLY;BYDAY=1MO",
|
|
||||||
"RRULE:FREQ=DAILY;BYDAY=MO",
|
|
||||||
"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",
|
||||||
@@ -779,7 +830,7 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path&
|
|||||||
nocal::Event invalid_rule = event_named("rule@example.test", "Invalid rule");
|
nocal::Event invalid_rule = event_named("rule@example.test", "Invalid rule");
|
||||||
nocal::RecurrenceRule rule;
|
nocal::RecurrenceRule rule;
|
||||||
rule.frequency = nocal::RecurrenceFrequency::daily;
|
rule.frequency = nocal::RecurrenceFrequency::daily;
|
||||||
rule.by_weekday.push_back(Monday);
|
rule.interval = 0;
|
||||||
invalid_rule.recurrence = rule;
|
invalid_rule.recurrence = rule;
|
||||||
bool rule_failed = false;
|
bool rule_failed = false;
|
||||||
try {
|
try {
|
||||||
@@ -973,6 +1024,8 @@ int main() {
|
|||||||
test_supported_recurrence_and_exdates(root);
|
test_supported_recurrence_and_exdates(root);
|
||||||
test_supported_rdates(root);
|
test_supported_rdates(root);
|
||||||
test_unsupported_recurrence_and_zones_are_unsafe(root);
|
test_unsupported_recurrence_and_zones_are_unsafe(root);
|
||||||
|
test_ordinal_byday_round_trip(root);
|
||||||
|
test_daily_with_byday_is_supported(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