feat: add recurrence and time zone support
This commit is contained in:
@@ -66,10 +66,41 @@ std::pair<unsigned, unsigned> local_hour_minute(const TimePoint point)
|
||||
return {static_cast<unsigned>(value.tm_hour), static_cast<unsigned>(value.tm_min)};
|
||||
}
|
||||
|
||||
std::string format_time(const TimePoint point)
|
||||
struct CivilMoment {
|
||||
Date date;
|
||||
unsigned hour;
|
||||
unsigned minute;
|
||||
};
|
||||
|
||||
CivilMoment event_civil_moment(const Event& event, const TimePoint point)
|
||||
{
|
||||
const auto [hour, minute] = local_hour_minute(point);
|
||||
return two_digits(hour) + ':' + two_digits(minute);
|
||||
if (event.time_basis != TimeBasis::zoned || event.time_zone.empty()) {
|
||||
const auto [hour, minute] = local_hour_minute(point);
|
||||
return {local_date(point), hour, minute};
|
||||
}
|
||||
const auto* zone = std::chrono::locate_zone(event.time_zone);
|
||||
const auto local = zone->to_local(point);
|
||||
const auto local_day = std::chrono::floor<std::chrono::days>(local);
|
||||
const std::chrono::year_month_day date{local_day};
|
||||
const auto time = std::chrono::duration_cast<std::chrono::minutes>(local - local_day);
|
||||
const auto hour = static_cast<unsigned>(time.count() / 60);
|
||||
const auto minute = static_cast<unsigned>(time.count() % 60);
|
||||
return {date, hour, minute};
|
||||
}
|
||||
|
||||
TimePoint make_event_time(const Event& event, const Date date, const unsigned hour,
|
||||
const unsigned minute)
|
||||
{
|
||||
if (event.time_basis != TimeBasis::zoned || event.time_zone.empty()) {
|
||||
return make_local_time(date, hour, minute);
|
||||
}
|
||||
const auto* zone = std::chrono::locate_zone(event.time_zone);
|
||||
const auto value = std::chrono::local_days{date} + std::chrono::hours{hour} +
|
||||
std::chrono::minutes{minute};
|
||||
if (zone->get_info(value).result == std::chrono::local_info::nonexistent) {
|
||||
throw std::runtime_error("local date-time does not exist in source time zone");
|
||||
}
|
||||
return zone->to_sys(value, std::chrono::choose::earliest);
|
||||
}
|
||||
|
||||
bool parse_time(const std::string_view text, unsigned& hour, unsigned& minute) noexcept
|
||||
@@ -240,11 +271,14 @@ EventEditor::EventEditor(const Date selected_day)
|
||||
EventEditor::EventEditor(const Event& event)
|
||||
: original_{event}, result_{event}, all_day_{event.all_day}, editing_{true}
|
||||
{
|
||||
const auto start = event_civil_moment(event, event.start);
|
||||
const auto end = event_civil_moment(event, event.end);
|
||||
values_[index_of(EditorField::title)] = event.title;
|
||||
values_[index_of(EditorField::start_date)] = format_date(local_date(event.start));
|
||||
values_[index_of(EditorField::start_time)] = format_time(event.start);
|
||||
values_[index_of(EditorField::end_date)] = format_date(local_date(event.end));
|
||||
values_[index_of(EditorField::end_time)] = format_time(event.end);
|
||||
values_[index_of(EditorField::start_date)] = format_date(start.date);
|
||||
values_[index_of(EditorField::start_time)] = two_digits(start.hour) + ':' +
|
||||
two_digits(start.minute);
|
||||
values_[index_of(EditorField::end_date)] = format_date(end.date);
|
||||
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;
|
||||
}
|
||||
@@ -340,6 +374,10 @@ bool EventEditor::validate()
|
||||
|
||||
Event candidate = original_;
|
||||
if (candidate.uid.empty()) candidate.uid = make_uid();
|
||||
if (candidate.recurrence && all_day_ != candidate.all_day) {
|
||||
return fail(EditorField::all_day,
|
||||
"Timed/all-day conversion is unavailable for recurring series.");
|
||||
}
|
||||
candidate.title = title;
|
||||
candidate.location = values_[index_of(EditorField::location)];
|
||||
candidate.description = values_[index_of(EditorField::notes)];
|
||||
@@ -351,8 +389,12 @@ bool EventEditor::validate()
|
||||
return fail(EditorField::end_date,
|
||||
"All-day end is exclusive and must be at least the next day.");
|
||||
}
|
||||
candidate.start = local_day_start(start_day);
|
||||
candidate.end = local_day_start(end_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);
|
||||
candidate.end = make_event_time(candidate, end_day, 0, 0);
|
||||
} else {
|
||||
unsigned start_hour = 0;
|
||||
unsigned start_minute = 0;
|
||||
@@ -364,8 +406,8 @@ bool EventEditor::validate()
|
||||
if (!parse_time(values_[index_of(EditorField::end_time)], end_hour, end_minute)) {
|
||||
return fail(EditorField::end_time, "End time must be a valid HH:MM time.");
|
||||
}
|
||||
candidate.start = make_local_time(start_day, start_hour, start_minute);
|
||||
candidate.end = make_local_time(end_day, end_hour, end_minute);
|
||||
candidate.start = make_event_time(candidate, start_day, start_hour, start_minute);
|
||||
candidate.end = make_event_time(candidate, end_day, end_hour, end_minute);
|
||||
if (candidate.end <= candidate.start) {
|
||||
return fail(EditorField::end_time, "End must be after start.");
|
||||
}
|
||||
@@ -393,11 +435,18 @@ std::string EventEditor::render(const int requested_width, const int requested_h
|
||||
std::vector<std::string> lines;
|
||||
lines.reserve(16);
|
||||
lines.push_back(top);
|
||||
lines.push_back(styled_interior_line(editing_ ? " NOCAL · EDIT APPOINTMENT"
|
||||
: " NOCAL · NEW APPOINTMENT",
|
||||
width, "1", ansi));
|
||||
const auto heading = editing_ && original_.recurrence
|
||||
? " NOCAL · EDIT ENTIRE RECURRING SERIES"
|
||||
: editing_ ? " NOCAL · EDIT APPOINTMENT"
|
||||
: " NOCAL · NEW APPOINTMENT";
|
||||
lines.push_back(styled_interior_line(heading, width, "1", ansi));
|
||||
lines.push_back(styled_interior_line(" Tab/↓ next Shift-Tab/↑ back Space toggle",
|
||||
width, "2", ansi));
|
||||
lines.push_back(styled_interior_line(
|
||||
original_.time_basis == TimeBasis::zoned && !original_.time_zone.empty()
|
||||
? " Times shown in source zone: " + original_.time_zone
|
||||
: " Times shown in your local zone",
|
||||
width, "2", ansi));
|
||||
lines.push_back(rule);
|
||||
|
||||
for (std::size_t index = 0; index < field_count; ++index) {
|
||||
@@ -433,7 +482,7 @@ std::string EventEditor::render(const int requested_width, const int requested_h
|
||||
compact.push_back(top);
|
||||
if (height > 2) {
|
||||
const int available = height - 2;
|
||||
const int selected_line = 4 + static_cast<int>(index_of(focused_));
|
||||
const int selected_line = 5 + static_cast<int>(index_of(focused_));
|
||||
const int maximum_start = static_cast<int>(lines.size()) - 1 - available;
|
||||
const int start = std::clamp(selected_line - available / 2, 1,
|
||||
std::max(1, maximum_start));
|
||||
|
||||
Reference in New Issue
Block a user