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:
@@ -20,7 +20,9 @@ namespace {
|
||||
|
||||
constexpr std::size_t field_count = static_cast<std::size_t>(EditorField::count);
|
||||
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
|
||||
@@ -223,6 +225,81 @@ std::string hints_interior_line(const std::initializer_list<KeyHint> hints, cons
|
||||
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
|
||||
|
||||
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::end_date)] = format_date(selected_day);
|
||||
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)
|
||||
@@ -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::location)] = event.location;
|
||||
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
|
||||
{
|
||||
if (field == EditorField::all_day || field == EditorField::count) return {};
|
||||
if (field == EditorField::all_day) return {};
|
||||
return values_[index_of(field)];
|
||||
}
|
||||
|
||||
@@ -306,6 +412,18 @@ EditorOutcome EventEditor::handle_key(const std::string_view key)
|
||||
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_)];
|
||||
if (key == "\x7f" || key == "\b") {
|
||||
erase_last_codepoint(value);
|
||||
@@ -359,8 +477,6 @@ bool EventEditor::validate()
|
||||
return fail(EditorField::end_date,
|
||||
"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_zone.clear();
|
||||
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.");
|
||||
}
|
||||
|
||||
// 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);
|
||||
error_.clear();
|
||||
return true;
|
||||
@@ -420,15 +598,37 @@ std::string EventEditor::render(const int requested_width, const int requested_h
|
||||
width, "2", ansi));
|
||||
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) {
|
||||
const auto field = static_cast<EditorField>(index);
|
||||
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;
|
||||
if (field == EditorField::all_day) {
|
||||
value = all_day_ ? "[x] spans whole days (end date is exclusive)"
|
||||
: "[ ] timed appointment";
|
||||
} else if (all_day_ && (field == EditorField::start_time || field == EditorField::end_time)) {
|
||||
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 {
|
||||
value = single_line(values_[index]);
|
||||
if (selected) value += "_";
|
||||
@@ -478,4 +678,4 @@ std::string EventEditor::render(const int requested_width, const int requested_h
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace nocal::tui
|
||||
} // namespace nocal::tui
|
||||
Reference in New Issue
Block a user