From c19098004e1c91218ce29f84d57b5612eca65e1c Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Fri, 17 Jul 2026 21:52:02 +0100 Subject: [PATCH] feat: add recurrence and time zone support --- README.md | 15 +- docs/ARCHITECTURE.md | 25 +- docs/PRODUCT.md | 8 +- docs/ROADMAP.md | 3 +- include/nocal/domain/event.hpp | 22 + include/nocal/domain/event_query.hpp | 25 ++ include/nocal/tui/Screen.hpp | 22 +- include/nocal/tui/TuiApp.hpp | 1 + src/domain/event_query.cpp | 640 ++++++++++++++++++++++++++- src/storage/ics_store.cpp | 605 +++++++++++++++++++++++-- src/tui/EventEditor.cpp | 79 +++- src/tui/Screen.cpp | 135 +++++- src/tui/TuiApp.cpp | 85 ++-- tests/domain_tests.cpp | 258 +++++++++++ tests/editor_tests.cpp | 79 ++++ tests/ics_tests.cpp | 257 +++++++++++ tests/tui_focus_tests.cpp | 186 +++++++- tests/tui_tests.cpp | 8 +- 18 files changed, 2321 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index b2f06e7..1248503 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,16 @@ Run the tests with `meson test -C build --print-errorlogs`. Nocal reads positional argument. `--demo` adds a few unsaved sample appointments and `--print` emits a non-interactive frame. -Nocal will not rewrite a calendar containing iCalendar data this version -cannot preserve, such as recurrence rules, alarms, attendees, or `TZID` -parameters. The calendar remains browsable, while mutation attempts explain -that it is read-only. This guard prevents a local edit from silently discarding -unknown data until those features are represented by the domain model. +Nocal expands and safely round-trips a focused iCalendar recurrence subset: +daily, weekly, monthly, and yearly rules with interval, count or until, common +weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA +`TZID` names. Recurring instances are individually navigable; edit and delete +clearly operate on the entire series. + +Calendars containing features this version cannot preserve—such as `RDATE`, +detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE` +definitions, alarms, or attendees—remain browsable but read-only. This guard +prevents a local edit from silently discarding unknown data. Every replacement checks that the source has not changed since it was loaded. If another Nocal instance or an external editor changes the file, the mutation diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ff5acd4..011af29 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,19 @@ optional descriptive fields, and calendar identity. Queries define overlap as `event.start < range.end && event.end > range.start`; this matters for events crossing midnight. +Recurring events remain one domain object. Occurrence queries expand only the +requested time window and return values that point back to their source series, +preserving series-level storage and mutation semantics. The supported rule +surface is deliberately bounded to daily/weekly/monthly/yearly frequency, +interval, count or until, weekly `BYDAY`, monthly `BYMONTHDAY`, yearly +`BYMONTH`, and `EXDATE`. Invalid metadata retains at most the base event rather +than crashing a render. + +Timed events retain their source basis: UTC, floating process-local time, or an +IANA `TZID`. C++20 time-zone conversion keeps zoned recurrences at their civil +wall time across daylight-saving transitions. The month grid displays instants +in the user's local zone; the reader also names an explicit source zone. + ## Storage Version 0.1 uses a user-owned iCalendar file, defaulting to @@ -50,11 +63,13 @@ rolls it back if persistence fails. Its bounded undo/redo history contains only mutations that crossed the persistence boundary successfully. The current writer deliberately refuses to mutate an existing file when the -loader encounters information it cannot round-trip, including recurrence, -alarms, attendees, time-zone identifiers, unknown properties, or malformed -components. Browsing remains available. This conservative boundary is more -important than partial editing because a successful-looking edit must not -erase unrelated calendar data. +loader encounters information it cannot round-trip, including recurrence +overrides, `RDATE`, custom `VTIMEZONE` definitions, alarms, attendees, unknown +properties, or malformed components. Supported recurrence rules, exclusions, +and system IANA time-zone identifiers round-trip semantically. Browsing remains +available for unsupported files. This conservative boundary is more important +than partial editing because a successful-looking edit must not erase unrelated +calendar data. Provider synchronization will not write directly into this UI file. It will use a transaction-capable local cache and preserve remote ETags/sync tokens in a diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index d0bb0f1..5e736dd 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -78,9 +78,11 @@ full-frame reader, validated add/edit/delete forms, and atomic `.ics` writes. Dense days keep every appointment keyboard-reachable even when all lines do not fit in the cell. Unknown or unsupported iCalendar content makes a source read-only instead of being discarded. Saves reject external changes, retain a -last-known-good backup, and feed bounded session undo/redo history. The next -local-data work adds `/` search, calendar visibility toggles, and recurring-event -and time-zone expansion. +last-known-good backup, and feed bounded session undo/redo history. Supported +recurring instances appear throughout the month, preserve civil time across +daylight-saving changes, and expose their source zone and whole-series mutation +semantics in the reader. The next local-data work adds `/` search, agenda and +calendar visibility views, then advanced recurrence overrides and rule editing. ## Accessibility diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5037e5b..b539495 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,12 +8,13 @@ - Validated add/edit/delete forms with confirmation and failure rollback - Guarded atomic `.ics` persistence, advisory locking, and round-trip tests - Bounded session undo/redo, exact external-change detection, and backup recovery +- Windowed recurrence expansion, EXDATE, IANA TZID, and multi-day segment cues - Meson build, Nix development shell, desktop entry, and Hyprland launcher - Seeded sample data when explicitly requested, never silent data mutation ## 0.1 — complete local calendar -- Recurrence rules, exclusions, time zones, multi-day presentation +- Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support - Search, agenda view, calendar toggles, import/export, configurable week start - Screen-reader-friendly linear view and full Unicode display-width handling diff --git a/include/nocal/domain/event.hpp b/include/nocal/domain/event.hpp index b2f6900..c5b160d 100644 --- a/include/nocal/domain/event.hpp +++ b/include/nocal/domain/event.hpp @@ -1,13 +1,31 @@ #pragma once #include +#include #include +#include namespace nocal { using Clock = std::chrono::system_clock; using TimePoint = Clock::time_point; +enum class TimeBasis { floating, utc, zoned }; + +enum class RecurrenceFrequency { daily, weekly, monthly, yearly }; + +struct RecurrenceRule { + RecurrenceFrequency frequency{RecurrenceFrequency::daily}; + unsigned interval{1}; + std::optional count; + std::optional until; + std::vector by_weekday; + std::vector by_month_day; + std::vector by_month; + + bool operator==(const RecurrenceRule&) const = default; +}; + struct Event { std::string uid; std::string title; @@ -18,6 +36,10 @@ struct Event { std::string description; std::string calendar_id; std::string color; + TimeBasis time_basis{TimeBasis::utc}; + std::string time_zone; + std::optional recurrence; + std::vector recurrence_exceptions; }; struct Calendar { diff --git a/include/nocal/domain/event_query.hpp b/include/nocal/domain/event_query.hpp index cf97c02..f287576 100644 --- a/include/nocal/domain/event_query.hpp +++ b/include/nocal/domain/event_query.hpp @@ -3,6 +3,7 @@ #include "nocal/domain/date.hpp" #include "nocal/domain/event.hpp" +#include #include #include #include @@ -12,6 +13,30 @@ namespace nocal { using EventRef = std::reference_wrapper; using EventRefs = std::vector; +struct EventOccurrence { + const Event* source{}; + TimePoint start{}; + TimePoint end{}; + std::size_t ordinal{}; +}; + +using EventOccurrences = std::vector; + +[[nodiscard]] bool occurrence_less(const EventOccurrence& lhs, + const EventOccurrence& rhs) noexcept; +void sort_occurrences(EventOccurrences& occurrences); + +// Recurrence query intervals are half-open. DTSTART is ordinal zero and COUNT +// includes it. EXDATE can remove any exact generated start, including DTSTART. +// Invalid rules produce no additional occurrences but retain DTSTART unless it +// is explicitly excepted. +[[nodiscard]] EventOccurrences occurrences_overlapping( + std::span events, TimePoint begin, TimePoint end); +[[nodiscard]] EventOccurrences occurrences_on_day(std::span events, + Date date); +[[nodiscard]] EventOccurrences occurrences_in_month(std::span events, + YearMonth month); + [[nodiscard]] bool event_less(const Event& lhs, const Event& rhs) noexcept; void sort_events(EventRefs& events); diff --git a/include/nocal/tui/Screen.hpp b/include/nocal/tui/Screen.hpp index 0c36539..20b03dd 100644 --- a/include/nocal/tui/Screen.hpp +++ b/include/nocal/tui/Screen.hpp @@ -7,9 +7,17 @@ #include #include "nocal/domain/event.hpp" +#include "nocal/domain/event_query.hpp" namespace nocal::tui { +enum class ItemSegment { + single, + start, + continuation, + end, +}; + // A deliberately small view-model keeps the renderer independent of storage and // makes it straightforward to unit test. TuiApp adapts domain Events to this. struct CalendarItem { @@ -26,6 +34,10 @@ struct CalendarItem { std::string detail_title; bool detail_all_day{false}; std::optional detail_start_time_of_day; + ItemSegment segment{ItemSegment::single}; + bool recurring{false}; + std::string recurrence_summary; + std::string source_time_zone; }; struct ScreenState { @@ -50,11 +62,17 @@ struct ScreenState { [[nodiscard]] std::string render_month(std::span items, const ScreenState& state); -// Convenience adapter for the domain model. Event timestamps are projected to -// the process's local civil time until explicit calendar time zones are added. +// Convenience adapter for the domain model. Occurrence instants are projected +// into the user's process-local zone; the reader retains an explicit source TZID. [[nodiscard]] std::string render_month(std::span events, const ScreenState& state); +// Stable focus identity shared by the renderer and controller. Non-recurring +// events retain their historical UID/synthetic identity; recurring instances +// additionally include their concrete start instant. +[[nodiscard]] std::string occurrence_focus_id(std::span events, + const EventOccurrence& occurrence); + enum class Action { none, left, diff --git a/include/nocal/tui/TuiApp.hpp b/include/nocal/tui/TuiApp.hpp index 29189eb..80b976b 100644 --- a/include/nocal/tui/TuiApp.hpp +++ b/include/nocal/tui/TuiApp.hpp @@ -72,6 +72,7 @@ private: std::vector redo_history_; bool running_{true}; + [[nodiscard]] std::optional focused_occurrence() const; [[nodiscard]] std::optional focused_event_index() const; [[nodiscard]] HistorySnapshot capture_history_snapshot() const; void restore_history_snapshot(HistorySnapshot snapshot); diff --git a/src/domain/event_query.cpp b/src/domain/event_query.cpp index 5878f0f..fb911a4 100644 --- a/src/domain/event_query.cpp +++ b/src/domain/event_query.cpp @@ -1,9 +1,584 @@ #include "nocal/domain/event_query.hpp" #include +#include +#include +#include #include namespace nocal { +namespace { + +using CivilTime = std::chrono::local_time; + +struct Projection { + CivilTime start; + CivilTime end; + const std::chrono::time_zone* zone{}; +}; + +[[nodiscard]] std::optional checked_difference( + Clock::duration lhs, Clock::duration rhs) noexcept +{ + using Rep = Clock::duration::rep; + const Rep left = lhs.count(); + const Rep right = rhs.count(); + if ((right > Rep{0} && + left < std::numeric_limits::min() + right) || + (right < Rep{0} && + left > std::numeric_limits::max() + right)) { + return std::nullopt; + } + return Clock::duration{left - right}; +} + +template +[[nodiscard]] std::optional checked_add( + TimePointType point, Clock::duration addition) noexcept +{ + using Rep = Clock::duration::rep; + const Rep value = point.time_since_epoch().count(); + const Rep addend = addition.count(); + if ((addend > Rep{0} && + value > std::numeric_limits::max() - addend) || + (addend < Rep{0} && + value < std::numeric_limits::min() - addend)) { + return std::nullopt; + } + return TimePointType{Clock::duration{value + addend}}; +} + +[[nodiscard]] bool overlaps(TimePoint start, TimePoint finish, TimePoint begin, + TimePoint end) noexcept +{ + if (begin >= end || finish < start) { + return false; + } + if (start == finish) { + return start >= begin && start < end; + } + return start < end && finish > begin; +} + +[[nodiscard]] bool is_exception(const Event& event, TimePoint start) +{ + return std::find(event.recurrence_exceptions.begin(), + event.recurrence_exceptions.end(), start) != + event.recurrence_exceptions.end(); +} + +[[nodiscard]] std::tm process_local_tm(std::time_t time) +{ + std::tm result{}; +#if defined(_WIN32) + if (::localtime_s(&result, &time) != 0) { + throw std::runtime_error("could not project floating date-time"); + } +#else + if (::localtime_r(&time, &result) == nullptr) { + throw std::runtime_error("could not project floating date-time"); + } +#endif + return result; +} + +[[nodiscard]] CivilTime floating_civil(TimePoint point) +{ + using namespace std::chrono; + const auto whole = floor(point); + const auto time = Clock::to_time_t( + TimePoint{duration_cast(whole.time_since_epoch())}); + const auto tm = process_local_tm(time); + const Date date{year{tm.tm_year + 1900}, + month{static_cast(tm.tm_mon + 1)}, + day{static_cast(tm.tm_mday)}}; + if (!date.ok()) { + throw std::runtime_error("floating date-time is outside the civil range"); + } + const auto subsecond = point - + TimePoint{duration_cast( + whole.time_since_epoch())}; + const auto civil = local_days{date} + hours{tm.tm_hour} + minutes{tm.tm_min} + + seconds{tm.tm_sec} + subsecond; + return time_point_cast(civil); +} + +[[nodiscard]] CivilTime project_to_civil(const Event& event, TimePoint point, + const std::chrono::time_zone* zone) +{ + switch (event.time_basis) { + case TimeBasis::floating: + return floating_civil(point); + case TimeBasis::utc: + return CivilTime{point.time_since_epoch()}; + case TimeBasis::zoned: + return time_point_cast(zone->to_local(point)); + } + throw std::runtime_error("unknown event time basis"); +} + +[[nodiscard]] TimePoint floating_system(CivilTime civil) +{ + using namespace std::chrono; + const auto civil_day = floor(civil); + const Date date{local_days{civil_day.time_since_epoch()}}; + const auto since_midnight = civil - civil_day; + const auto whole_seconds = floor(since_midnight); + const hh_mm_ss hms{whole_seconds}; + const auto base = make_local_time(date, static_cast(hms.hours().count()), + static_cast(hms.minutes().count()), + static_cast(hms.seconds().count())); + return base + (since_midnight - whole_seconds); +} + +[[nodiscard]] TimePoint project_to_system( + const Event& event, CivilTime civil, const std::chrono::time_zone* zone) +{ + switch (event.time_basis) { + case TimeBasis::floating: + return floating_system(civil); + case TimeBasis::utc: + return TimePoint{civil.time_since_epoch()}; + case TimeBasis::zoned: { + const auto info = zone->get_info(civil); + if (info.result == std::chrono::local_info::nonexistent) { + throw std::runtime_error("nonexistent zoned local date-time"); + } + return time_point_cast( + zone->to_sys(civil, std::chrono::choose::earliest)); + } + } + throw std::runtime_error("unknown event time basis"); +} + +[[nodiscard]] bool valid_rule_shape(const Event& event, + const RecurrenceRule& rule) noexcept +{ + if (rule.interval == 0U || rule.interval > 32767U || + (rule.count && *rule.count == 0U) || + (rule.count && rule.until)) { + return false; + } + if ((event.time_basis == TimeBasis::zoned) != !event.time_zone.empty()) { + return false; + } + if (rule.frequency != RecurrenceFrequency::weekly && + !rule.by_weekday.empty()) { + return false; + } + if (rule.frequency != RecurrenceFrequency::monthly && + !rule.by_month_day.empty()) { + return false; + } + if (rule.frequency != RecurrenceFrequency::yearly && + !rule.by_month.empty()) { + return false; + } + for (const auto weekday : rule.by_weekday) { + if (!weekday.ok()) { + return false; + } + } + for (const int month_day : rule.by_month_day) { + if (month_day == 0 || month_day < -31 || month_day > 31) { + return false; + } + } + for (const unsigned month : rule.by_month) { + if (month < 1U || month > 12U) { + return false; + } + } + return true; +} + +[[nodiscard]] Projection make_projection(const Event& event) +{ + const std::chrono::time_zone* zone = nullptr; + if (event.time_basis == TimeBasis::zoned) { + zone = std::chrono::locate_zone(event.time_zone); + } + return {project_to_civil(event, event.start, zone), + project_to_civil(event, event.end, zone), zone}; +} + +[[nodiscard]] Date civil_date(CivilTime civil) noexcept +{ + return Date{std::chrono::local_days{ + std::chrono::floor(civil).time_since_epoch()}}; +} + +[[nodiscard]] Clock::duration civil_time_of_day(CivilTime civil) noexcept +{ + return civil - std::chrono::floor(civil); +} + +[[nodiscard]] CivilTime make_civil(Date date, Clock::duration time_of_day) +{ + return std::chrono::time_point_cast( + std::chrono::local_days{date}) + + time_of_day; +} + +[[nodiscard]] std::vector normalized_weekdays( + const RecurrenceRule& rule, Date start_date) +{ + std::vector result; + if (rule.by_weekday.empty()) { + result.push_back(std::chrono::weekday{std::chrono::sys_days{start_date}} + .iso_encoding() - + 1U); + } else { + result.reserve(rule.by_weekday.size()); + for (const auto value : rule.by_weekday) { + result.push_back(value.iso_encoding() - 1U); + } + } + std::sort(result.begin(), result.end()); + result.erase(std::unique(result.begin(), result.end()), result.end()); + return result; +} + +[[nodiscard]] std::vector normalized_month_days( + const RecurrenceRule& rule, Date start_date) +{ + std::vector result = rule.by_month_day; + if (result.empty()) { + result.push_back(static_cast(static_cast(start_date.day()))); + } + std::sort(result.begin(), result.end()); + result.erase(std::unique(result.begin(), result.end()), result.end()); + return result; +} + +[[nodiscard]] std::vector normalized_months(const RecurrenceRule& rule, + Date start_date) +{ + std::vector result = rule.by_month; + if (result.empty()) { + result.push_back(static_cast(start_date.month())); + } + std::sort(result.begin(), result.end()); + result.erase(std::unique(result.begin(), result.end()), result.end()); + return result; +} + +[[nodiscard]] std::optional resolve_month_day(std::chrono::year_month month, + int selector) +{ + using namespace std::chrono; + const auto last = static_cast( + year_month_day_last{month / std::chrono::last}.day()); + const int numeric_day = selector > 0 ? selector + : static_cast(last) + selector + 1; + if (numeric_day < 1 || numeric_day > static_cast(last)) { + return std::nullopt; + } + return Date{month / day{static_cast(numeric_day)}}; +} + +void append_candidate(EventOccurrences& result, const Event& event, + const RecurrenceRule& rule, const Projection& projection, + CivilTime candidate, std::size_t ordinal, TimePoint begin, + TimePoint end) +{ + if (rule.count && ordinal >= static_cast(*rule.count)) { + return; + } + try { + const auto candidate_start = + project_to_system(event, candidate, projection.zone); + if (rule.until && candidate_start > *rule.until) { + return; + } + const auto duration = + event.time_basis == TimeBasis::utc + ? checked_difference(event.end.time_since_epoch(), + event.start.time_since_epoch()) + : checked_difference(projection.end.time_since_epoch(), + projection.start.time_since_epoch()); + if (!duration) { + return; + } + TimePoint candidate_end; + if (event.time_basis == TimeBasis::utc) { + const auto end_value = checked_add(candidate_start, *duration); + if (!end_value) { + return; + } + candidate_end = *end_value; + } else { + const auto civil_end = checked_add(candidate, *duration); + if (!civil_end) { + return; + } + candidate_end = + project_to_system(event, *civil_end, projection.zone); + } + if (!is_exception(event, candidate_start) && + overlaps(candidate_start, candidate_end, begin, end)) { + result.push_back({&event, candidate_start, candidate_end, ordinal}); + } + } catch (const std::exception&) { + // A bad zone, an out-of-range conversion, or a nonexistent civil time + // invalidates only this generated occurrence. Queries remain noexcept + // with respect to recurrence metadata supplied by external calendars. + } +} + +[[nodiscard]] std::chrono::sys_days recurrence_scan_end( + const Event& event, const RecurrenceRule& rule, TimePoint query_end, + const Projection& projection) +{ + try { + const auto effective_end = rule.until && *rule.until < query_end + ? *rule.until + : query_end; + return std::chrono::sys_days{civil_date(project_to_civil( + event, effective_end, projection.zone))} + + std::chrono::days{2}; + } catch (const std::exception&) { + return std::chrono::sys_days{civil_date(projection.start)}; + } +} + +void expand_daily(EventOccurrences& result, const Event& event, + const RecurrenceRule& rule, const Projection& projection, + TimePoint begin, TimePoint end) +{ + using namespace std::chrono; + const Date start_date = civil_date(projection.start); + const auto time_of_day = civil_time_of_day(projection.start); + const auto last_day = recurrence_scan_end(event, rule, end, projection); + const auto interval = static_cast(rule.interval); + const auto day_limit = (last_day - sys_days{start_date}).count(); + if (day_limit < interval) { + return; + } + + long long first_index = 1; + try { + const auto query_day = sys_days{civil_date(project_to_civil( + event, begin, projection.zone))}; + const auto wall_duration = checked_difference( + projection.end.time_since_epoch(), + projection.start.time_since_epoch()); + if (!wall_duration) { + return; + } + const auto whole_days = floor(*wall_duration); + const auto duration_days = whole_days.count() + + (*wall_duration > whole_days ? 1 : 0); + const auto relevant_days = + (query_day - sys_days{start_date}).count() - duration_days - 2; + if (relevant_days > interval) { + first_index = relevant_days / interval; + if (first_index < 1) { + first_index = 1; + } + } + } catch (const std::exception&) { + } + + const auto last_index = day_limit / interval; + for (long long index = first_index; index <= last_index; ++index) { + const auto ordinal_value = static_cast(index); + if (ordinal_value > + static_cast(std::numeric_limits::max())) { + break; + } + const auto ordinal = static_cast(ordinal_value); + if (rule.count && ordinal >= static_cast(*rule.count)) { + break; + } + const auto date = Date{sys_days{start_date} + days{index * interval}}; + append_candidate(result, event, rule, projection, + make_civil(date, time_of_day), ordinal, begin, end); + } +} + +void expand_weekly(EventOccurrences& result, const Event& event, + const RecurrenceRule& rule, const Projection& projection, + TimePoint begin, TimePoint end) +{ + using namespace std::chrono; + const Date start_date = civil_date(projection.start); + const auto start_day = sys_days{start_date}; + const auto time_of_day = civil_time_of_day(projection.start); + const auto weekdays = normalized_weekdays(rule, start_date); + const auto start_offset = weekday{start_day}.iso_encoding() - 1U; + const auto first_monday = start_day - days{start_offset}; + const auto last_day = recurrence_scan_end(event, rule, end, projection); + const long long total_weeks = (last_day - first_monday).count() / 7; + const auto interval = static_cast(rule.interval); + + std::size_t first_week_extras = 0; + for (const unsigned offset : weekdays) { + const auto candidate = first_monday + days{offset}; + if (candidate > start_day) { + ++first_week_extras; + } + } + + for (long long week = 0; week <= total_weeks; week += interval) { + const auto active_index = static_cast(week / interval); + for (std::size_t position = 0; position < weekdays.size(); ++position) { + const auto candidate_day = first_monday + days{week * 7} + + days{weekdays[position]}; + if (candidate_day <= start_day) { + continue; + } + std::size_t ordinal = 0; + if (active_index == 0U) { + ordinal = 1U; + for (std::size_t prior = 0; prior < position; ++prior) { + if (first_monday + days{weekdays[prior]} > start_day) { + ++ordinal; + } + } + } else { + const auto previous_full = active_index - 1U; + const auto calculated = 1ULL + + static_cast( + first_week_extras) + + previous_full * + static_cast( + weekdays.size()) + + static_cast(position); + if (calculated > static_cast( + std::numeric_limits::max())) { + return; + } + ordinal = static_cast(calculated); + } + if (rule.count && ordinal >= static_cast(*rule.count)) { + return; + } + append_candidate(result, event, rule, projection, + make_civil(Date{candidate_day}, time_of_day), ordinal, + begin, end); + } + } +} + +void expand_monthly(EventOccurrences& result, const Event& event, + const RecurrenceRule& rule, const Projection& projection, + TimePoint begin, TimePoint end) +{ + using namespace std::chrono; + const Date start_date = civil_date(projection.start); + const year_month start_month{start_date.year(), start_date.month()}; + const auto last_date = Date{recurrence_scan_end(event, rule, end, projection)}; + const year_month last_month{last_date.year(), last_date.month()}; + const auto selectors = normalized_month_days(rule, start_date); + const auto time_of_day = civil_time_of_day(projection.start); + std::size_t ordinal = 1; + + for (year_month month = start_month; month <= last_month; + month += months{rule.interval}) { + std::vector dates; + for (const int selector : selectors) { + if (const auto date = resolve_month_day(month, selector)) { + dates.push_back(*date); + } + } + std::sort(dates.begin(), dates.end(), [](Date lhs, Date rhs) { + return sys_days{lhs} < sys_days{rhs}; + }); + dates.erase(std::unique(dates.begin(), dates.end()), dates.end()); + for (const Date date : dates) { + const auto candidate = make_civil(date, time_of_day); + if (candidate <= projection.start) { + continue; + } + if (rule.count && ordinal >= static_cast(*rule.count)) { + return; + } + append_candidate(result, event, rule, projection, candidate, ordinal, + begin, end); + ++ordinal; + } + } +} + +void expand_yearly(EventOccurrences& result, const Event& event, + const RecurrenceRule& rule, const Projection& projection, + TimePoint begin, TimePoint end) +{ + using namespace std::chrono; + const Date start_date = civil_date(projection.start); + const auto months = normalized_months(rule, start_date); + const auto time_of_day = civil_time_of_day(projection.start); + const int start_year = static_cast(start_date.year()); + const int last_year = static_cast( + Date{recurrence_scan_end(event, rule, end, projection)}.year()); + std::size_t ordinal = 1; + + for (int year_value = start_year; year_value <= last_year;) { + for (const unsigned month_value : months) { + const Date date{year{year_value}, month{month_value}, start_date.day()}; + if (!date.ok()) { + continue; + } + const auto candidate = make_civil(date, time_of_day); + if (candidate <= projection.start) { + continue; + } + if (rule.count && ordinal >= static_cast(*rule.count)) { + return; + } + append_candidate(result, event, rule, projection, candidate, ordinal, + begin, end); + ++ordinal; + } + if (last_year - year_value < static_cast(rule.interval)) { + break; + } + year_value += static_cast(rule.interval); + } +} + +void append_event_occurrences(EventOccurrences& result, const Event& event, + TimePoint begin, TimePoint end) +{ + if (!has_valid_interval(event)) { + return; + } + + const bool recurring = event.recurrence.has_value(); + if ((!recurring || !is_exception(event, event.start)) && + overlaps(event.start, event.end, begin, end)) { + result.push_back({&event, event.start, event.end, 0}); + } + if (!recurring || !valid_rule_shape(event, *event.recurrence)) { + return; + } + + try { + const auto projection = make_projection(event); + if (projection.end < projection.start) { + return; + } + switch (event.recurrence->frequency) { + case RecurrenceFrequency::daily: + expand_daily(result, event, *event.recurrence, projection, begin, end); + break; + case RecurrenceFrequency::weekly: + expand_weekly(result, event, *event.recurrence, projection, begin, end); + break; + case RecurrenceFrequency::monthly: + expand_monthly(result, event, *event.recurrence, projection, begin, end); + break; + case RecurrenceFrequency::yearly: + expand_yearly(result, event, *event.recurrence, projection, begin, end); + break; + } + } catch (const std::exception&) { + // Invalid zones and out-of-range civil projections retain DTSTART only. + } +} + +} // namespace bool event_less(const Event& lhs, const Event& rhs) noexcept { @@ -29,15 +604,38 @@ void sort_events(EventRefs& events) }); } +bool occurrence_less(const EventOccurrence& lhs, + const EventOccurrence& rhs) noexcept +{ + if (lhs.source == nullptr || rhs.source == nullptr) { + return lhs.source != nullptr; + } + if (lhs.source->all_day != rhs.source->all_day) { + return lhs.source->all_day; + } + if (lhs.start != rhs.start) { + return lhs.start < rhs.start; + } + if (lhs.end != rhs.end) { + return lhs.end < rhs.end; + } + if (lhs.source->title != rhs.source->title) { + return lhs.source->title < rhs.source->title; + } + if (lhs.source->uid != rhs.source->uid) { + return lhs.source->uid < rhs.source->uid; + } + return lhs.ordinal < rhs.ordinal; +} + +void sort_occurrences(EventOccurrences& occurrences) +{ + std::stable_sort(occurrences.begin(), occurrences.end(), occurrence_less); +} + bool event_overlaps(const Event& event, TimePoint begin, TimePoint end) noexcept { - if (begin >= end || !has_valid_interval(event)) { - return false; - } - if (event.start == event.end) { - return event.start >= begin && event.start < end; - } - return event.start < end && event.end > begin; + return overlaps(event.start, event.end, begin, end); } bool event_occurs_on(const Event& event, Date date) @@ -75,4 +673,32 @@ EventRefs events_in_month(std::span events, YearMonth month) return events_overlapping(events, begin, end); } +EventOccurrences occurrences_overlapping(std::span events, + TimePoint begin, TimePoint end) +{ + if (begin > end) { + throw std::invalid_argument("occurrence query begins after it ends"); + } + EventOccurrences result; + for (const auto& event : events) { + append_event_occurrences(result, event, begin, end); + } + sort_occurrences(result); + return result; +} + +EventOccurrences occurrences_on_day(std::span events, Date date) +{ + return occurrences_overlapping(events, local_day_start(date), + local_day_end(date)); +} + +EventOccurrences occurrences_in_month(std::span events, + YearMonth month) +{ + const auto begin = local_day_start(first_day_of_month(month)); + const auto end = local_day_start(first_day_of_month(following_month(month))); + return occurrences_overlapping(events, begin, end); +} + } // namespace nocal diff --git a/src/storage/ics_store.cpp b/src/storage/ics_store.cpp index fcb0d52..e366c6d 100644 --- a/src/storage/ics_store.cpp +++ b/src/storage/ics_store.cpp @@ -31,6 +31,14 @@ struct ParsedTime { TimePoint value; bool is_date = false; std::optional date; + TimeBasis basis = TimeBasis::floating; + std::string time_zone; +}; + +struct PendingProperty { + std::string parameters; + std::string value; + std::size_t line = 0; }; struct PendingEvent { @@ -42,6 +50,8 @@ struct PendingEvent { std::string color; std::optional start; std::optional end; + std::vector recurrence_rules; + std::vector exception_dates; }; [[nodiscard]] std::string upper(std::string_view value) { @@ -149,26 +159,84 @@ struct PendingEvent { return system_clock::from_time_t(converted); } -[[nodiscard]] std::optional parse_time( - std::string_view parameters, std::string_view value, std::string& warning) { - const std::string normalized_parameters = upper(parameters); +struct TimeParameters { + bool valid = true; bool value_is_date = false; - bool has_tzid = false; - std::size_t parameter_start = 0; - while (parameter_start <= normalized_parameters.size()) { - const std::size_t parameter_end = normalized_parameters.find(';', parameter_start); - const std::string_view parameter = std::string_view(normalized_parameters).substr( - parameter_start, parameter_end == std::string::npos - ? std::string::npos : parameter_end - parameter_start); - value_is_date = value_is_date || parameter == "VALUE=DATE"; - has_tzid = has_tzid || parameter.starts_with("TZID="); - if (parameter_end == std::string::npos) { + bool has_value_parameter = false; + std::optional time_zone; + std::string error; +}; + +[[nodiscard]] TimeParameters parse_time_parameters(std::string_view parameters) { + TimeParameters result; + std::size_t start = 0; + while (start < parameters.size()) { + const std::size_t end = parameters.find(';', start); + const std::string_view parameter = parameters.substr( + start, end == std::string_view::npos ? std::string_view::npos : end - start); + const std::size_t equals = parameter.find('='); + const std::string name = upper(parameter.substr(0, equals)); + std::string_view value = equals == std::string_view::npos + ? std::string_view{} : parameter.substr(equals + 1); + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { + value.remove_prefix(1); + value.remove_suffix(1); + } + if (name == "VALUE" && !result.has_value_parameter + && (upper(value) == "DATE" || upper(value) == "DATE-TIME")) { + result.has_value_parameter = true; + result.value_is_date = upper(value) == "DATE"; + } else if (name == "TZID" && !value.empty() && !result.time_zone) { + result.time_zone = std::string(value); + } else { + result.valid = false; + result.error = "unsupported or duplicate DATE-TIME parameter '" + + std::string(parameter) + "'"; + return result; + } + if (end == std::string_view::npos) { break; } - parameter_start = parameter_end + 1; + start = end + 1; + } + if (result.value_is_date && result.time_zone) { + result.valid = false; + result.error = "VALUE=DATE cannot have a TZID"; + } + return result; +} + +[[nodiscard]] std::optional make_zoned( + year_month_day date, int hour_value, int minute_value, int second_value, + std::string_view zone_name, std::string& warning) { + try { + const time_zone* zone = locate_zone(zone_name); + const local_seconds local = local_days{date} + hours{hour_value} + + minutes{minute_value} + seconds{second_value}; + const local_info info = zone->get_info(local); + if (info.result == local_info::unique) { + return zone->to_sys(local); + } + // RFC 5545 resolves a repeated wall time to its first occurrence and a + // nonexistent wall time with the UTC offset in force before the gap. + // local_info::first describes exactly that side of both transitions. + return sys_seconds{local.time_since_epoch() - info.first.offset}; + } catch (const std::runtime_error&) { + warning = "unknown TZID='" + std::string(zone_name) + + "'; DATE-TIME was interpreted in the process local timezone"; + return std::nullopt; + } +} + +[[nodiscard]] std::optional parse_time( + std::string_view parameters, std::string_view value, std::string& warning) { + const TimeParameters parsed_parameters = parse_time_parameters(parameters); + if (!parsed_parameters.valid) { + warning = parsed_parameters.error; + return std::nullopt; } - if (value_is_date || (parameters.empty() && value.size() == 8)) { + if (parsed_parameters.value_is_date || (parameters.empty() && value.size() == 8)) { const auto date = parse_ymd(value); if (!date) { warning = "invalid DATE value '" + std::string(value) + "'"; @@ -179,14 +247,14 @@ struct PendingEvent { warning = "DATE does not map to a local midnight: '" + std::string(value) + "'"; return std::nullopt; } - return ParsedTime{*local, true, *date}; - } - - if (has_tzid) { - warning = "TZID is not supported; DATE-TIME was interpreted in the process local timezone"; + return ParsedTime{*local, true, *date, TimeBasis::floating, {}}; } const bool utc = !value.empty() && value.back() == 'Z'; + if (utc && parsed_parameters.time_zone) { + warning = "UTC DATE-TIME cannot also have a TZID"; + return std::nullopt; + } const std::size_t expected_size = utc ? 16 : 15; if (value.size() != expected_size || value[8] != 'T') { warning = "invalid DATE-TIME value '" + std::string(value) + "'"; @@ -207,9 +275,31 @@ struct PendingEvent { // first instant of the next minute. const int represented_second = std::min(second_value, 59); TimePoint result; + TimeBasis basis = TimeBasis::floating; + std::string zone_name; if (utc) { result = sys_days{*date} + hours{hour_value} + minutes{minute_value} + seconds{represented_second}; + basis = TimeBasis::utc; + } else if (parsed_parameters.time_zone) { + zone_name = *parsed_parameters.time_zone; + std::string zoned_warning; + const auto zoned = make_zoned( + *date, hour_value, minute_value, represented_second, zone_name, zoned_warning); + if (zoned) { + result = *zoned; + } else if (zoned_warning.starts_with("unknown TZID=")) { + warning = std::move(zoned_warning); + const auto local = make_local(*date, hour_value, minute_value, represented_second); + if (!local) { + return std::nullopt; + } + result = *local; + } else { + warning = std::move(zoned_warning); + return std::nullopt; + } + basis = TimeBasis::zoned; } else { const auto local = make_local(*date, hour_value, minute_value, represented_second); if (!local) { @@ -221,7 +311,7 @@ struct PendingEvent { if (second_value == 60) { result += seconds{1}; } - return ParsedTime{result, false, std::nullopt}; + return ParsedTime{result, false, std::nullopt, basis, std::move(zone_name)}; } [[nodiscard]] std::vector unfold(std::istream& input) { @@ -267,9 +357,173 @@ void mark_unsafe_to_rewrite(LoadResult& result) { } } -[[nodiscard]] bool supported_time_parameters(std::string_view parameters) { - const std::string normalized = upper(parameters); - return normalized.empty() || normalized == "VALUE=DATE"; +[[nodiscard]] std::vector split( + std::string_view value, char delimiter) { + std::vector parts; + std::size_t start = 0; + while (start <= value.size()) { + const std::size_t end = value.find(delimiter, start); + parts.push_back(value.substr( + start, end == std::string_view::npos ? std::string_view::npos : end - start)); + if (end == std::string_view::npos) { + break; + } + start = end + 1; + } + return parts; +} + +template +[[nodiscard]] bool parse_integer(std::string_view value, Integer& destination) { + if (value.empty()) { + return false; + } + const auto [end, error] = std::from_chars( + value.data(), value.data() + value.size(), destination); + return error == std::errc{} && end == value.data() + value.size(); +} + +[[nodiscard]] std::optional parse_weekday(std::string_view value) { + if (value == "SU") return Sunday; + if (value == "MO") return Monday; + if (value == "TU") return Tuesday; + if (value == "WE") return Wednesday; + if (value == "TH") return Thursday; + if (value == "FR") return Friday; + if (value == "SA") return Saturday; + return std::nullopt; +} + +[[nodiscard]] std::string weekday_name(weekday value) { + static constexpr std::string_view names[]{"SU", "MO", "TU", "WE", "TH", "FR", "SA"}; + return std::string(names[value.c_encoding()]); +} + +[[nodiscard]] bool same_time_kind(const ParsedTime& first, const ParsedTime& second) { + return first.is_date == second.is_date + && (first.is_date || (first.basis == second.basis + && first.time_zone == second.time_zone)); +} + +[[nodiscard]] std::optional parse_recurrence_rule( + std::string_view value, const ParsedTime& start, std::string& error) { + RecurrenceRule rule; + bool has_frequency = false; + bool has_interval = false; + bool has_count = false; + bool has_until = false; + bool has_by_weekday = false; + bool has_by_month_day = false; + bool has_by_month = false; + for (const std::string_view raw_part : split(value, ';')) { + const std::size_t equals = raw_part.find('='); + if (equals == std::string_view::npos || equals == 0 || equals + 1 >= raw_part.size()) { + error = "malformed RRULE part '" + std::string(raw_part) + "'"; + return std::nullopt; + } + const std::string name = upper(raw_part.substr(0, equals)); + const std::string part_value = upper(raw_part.substr(equals + 1)); + if (name == "FREQ" && !has_frequency) { + has_frequency = true; + if (part_value == "DAILY") rule.frequency = RecurrenceFrequency::daily; + else if (part_value == "WEEKLY") rule.frequency = RecurrenceFrequency::weekly; + else if (part_value == "MONTHLY") rule.frequency = RecurrenceFrequency::monthly; + else if (part_value == "YEARLY") rule.frequency = RecurrenceFrequency::yearly; + else { + error = "unsupported RRULE frequency '" + part_value + "'"; + return std::nullopt; + } + } else if (name == "INTERVAL" && !has_interval) { + has_interval = true; + if (!parse_integer(part_value, rule.interval) || rule.interval == 0) { + error = "RRULE INTERVAL must be a positive integer"; + return std::nullopt; + } + } else if (name == "COUNT" && !has_count) { + has_count = true; + unsigned count = 0; + if (!parse_integer(part_value, count) || count == 0) { + error = "RRULE COUNT must be a positive integer"; + return std::nullopt; + } + rule.count = count; + } else if (name == "UNTIL" && !has_until) { + has_until = true; + std::string until_warning; + const std::string_view until_parameters = start.is_date ? "VALUE=DATE" : ""; + auto parsed = parse_time(until_parameters, part_value, until_warning); + if (!parsed || !until_warning.empty()) { + error = "invalid RRULE UNTIL: " + (until_warning.empty() + ? std::string(part_value) : until_warning); + return std::nullopt; + } + if (start.is_date) { + if (!parsed->is_date) { + error = "all-day RRULE UNTIL must be a DATE"; + return std::nullopt; + } + } else if (start.basis == TimeBasis::floating) { + if (parsed->is_date || parsed->basis != TimeBasis::floating) { + error = "floating RRULE UNTIL must be a floating DATE-TIME"; + return std::nullopt; + } + } else if (parsed->is_date || parsed->basis != TimeBasis::utc) { + error = "UTC and zoned RRULE UNTIL must be a UTC DATE-TIME"; + return std::nullopt; + } + rule.until = parsed->value; + } 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); + if (!day) { + error = "RRULE BYDAY supports only non-ordinal weekdays"; + return std::nullopt; + } + rule.by_weekday.push_back(*day); + } + } else if (name == "BYMONTHDAY" && !has_by_month_day) { + has_by_month_day = true; + for (const std::string_view number : split(part_value, ',')) { + int day_number = 0; + if (!parse_integer(number, day_number) || day_number == 0 + || day_number < -31 || day_number > 31) { + error = "RRULE BYMONTHDAY must contain integers from -31 to -1 or 1 to 31"; + return std::nullopt; + } + rule.by_month_day.push_back(day_number); + } + } else if (name == "BYMONTH" && !has_by_month) { + has_by_month = true; + for (const std::string_view number : split(part_value, ',')) { + unsigned month_number = 0; + if (!parse_integer(number, month_number) + || month_number == 0 || month_number > 12) { + error = "RRULE BYMONTH must contain month numbers from 1 to 12"; + return std::nullopt; + } + rule.by_month.push_back(month_number); + } + } else { + error = "unsupported or duplicate RRULE part '" + name + "'"; + return std::nullopt; + } + } + if (!has_frequency) { + error = "RRULE requires FREQ"; + return std::nullopt; + } + if (has_count && has_until) { + 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; } [[nodiscard]] std::tm utc_tm(std::time_t time) { @@ -322,6 +576,102 @@ void mark_unsafe_to_rewrite(LoadResult& result) { + two_digits(utc.tm_min) + two_digits(utc.tm_sec) + "Z"; } +[[nodiscard]] std::string format_floating_time(TimePoint value) { + const auto whole_seconds = floor(value); + const std::tm local = local_tm(system_clock::to_time_t(whole_seconds)); + return four_digits(local.tm_year + 1900) + two_digits(local.tm_mon + 1) + + two_digits(local.tm_mday) + "T" + two_digits(local.tm_hour) + + two_digits(local.tm_min) + two_digits(local.tm_sec); +} + +[[nodiscard]] std::string format_zoned_time(TimePoint value, std::string_view zone_name) { + const time_zone* zone = locate_zone(zone_name); + const local_seconds local = zone->to_local(floor(value)); + const local_days date = floor(local); + const year_month_day ymd{date}; + const hh_mm_ss time{local - date}; + return four_digits(static_cast(ymd.year())) + + two_digits(static_cast(static_cast(ymd.month()))) + + two_digits(static_cast(static_cast(ymd.day()))) + "T" + + two_digits(static_cast(time.hours().count())) + + two_digits(static_cast(time.minutes().count())) + + two_digits(static_cast(time.seconds().count())); +} + +[[nodiscard]] std::string format_event_time(const Event& event, TimePoint value) { + switch (event.time_basis) { + case TimeBasis::floating: return format_floating_time(value); + case TimeBasis::utc: return format_utc_time(value); + case TimeBasis::zoned: return format_zoned_time(value, event.time_zone); + } + throw std::logic_error("unknown event time basis"); +} + +[[nodiscard]] std::string time_property_parameters(const Event& event) { + if (event.all_day) { + return ";VALUE=DATE"; + } + if (event.time_basis == TimeBasis::zoned) { + return ";TZID=" + event.time_zone; + } + return {}; +} + +[[nodiscard]] std::string recurrence_frequency_name(RecurrenceFrequency frequency) { + switch (frequency) { + case RecurrenceFrequency::daily: return "DAILY"; + case RecurrenceFrequency::weekly: return "WEEKLY"; + case RecurrenceFrequency::monthly: return "MONTHLY"; + case RecurrenceFrequency::yearly: return "YEARLY"; + } + throw std::logic_error("unknown recurrence frequency"); +} + +template +[[nodiscard]] std::string join_values(const Range& values, Formatter formatter) { + std::string result; + for (const auto& value : values) { + if (!result.empty()) { + result += ','; + } + result += formatter(value); + } + return result; +} + +[[nodiscard]] std::string serialize_recurrence_rule( + const Event& event, const RecurrenceRule& rule) { + std::string result = "FREQ=" + recurrence_frequency_name(rule.frequency); + if (rule.interval != 1) { + result += ";INTERVAL=" + std::to_string(rule.interval); + } + if (rule.count) { + result += ";COUNT=" + std::to_string(*rule.count); + } else if (rule.until) { + result += ";UNTIL="; + if (event.all_day) { + result += format_date(*rule.until); + } else if (event.time_basis == TimeBasis::floating) { + result += format_floating_time(*rule.until); + } else { + result += format_utc_time(*rule.until); + } + } + if (!rule.by_weekday.empty()) { + result += ";BYDAY=" + join_values(rule.by_weekday, + [](weekday day) { return weekday_name(day); }); + } + if (!rule.by_month_day.empty()) { + result += ";BYMONTHDAY=" + join_values(rule.by_month_day, + [](int day) { return std::to_string(day); }); + } + if (!rule.by_month.empty()) { + result += ";BYMONTH=" + join_values(rule.by_month, + [](unsigned month_number) { return std::to_string(month_number); }); + } + return result; +} + [[nodiscard]] std::size_t utf8_boundary(std::string_view value, std::size_t begin, std::size_t limit) { std::size_t end = std::min(value.size(), begin + limit); while (end > begin && end < value.size() @@ -365,8 +715,23 @@ void serialize_calendar(std::ostream& output, std::span events) { write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start)); write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end)); } else { - write_folded(output, "DTSTART:" + format_utc_time(event.start)); - write_folded(output, "DTEND:" + format_utc_time(event.end)); + const std::string parameters = time_property_parameters(event); + write_folded(output, "DTSTART" + parameters + ":" + + format_event_time(event, event.start)); + write_folded(output, "DTEND" + parameters + ":" + + format_event_time(event, event.end)); + } + if (event.recurrence) { + write_folded(output, "RRULE:" + serialize_recurrence_rule(event, *event.recurrence)); + } + if (!event.recurrence_exceptions.empty()) { + const std::string parameters = time_property_parameters(event); + const std::string values = join_values(event.recurrence_exceptions, + [&event](TimePoint exception) { + return event.all_day ? format_date(exception) + : format_event_time(event, exception); + }); + write_folded(output, "EXDATE" + parameters + ":" + values); } write_text_property(output, "SUMMARY", event.title); write_text_property(output, "LOCATION", event.location); @@ -644,6 +1009,94 @@ void replace_with_temporary( } } +void validate_recurrence(const Event& event) { + if (!event.recurrence) { + return; + } + const RecurrenceRule& rule = *event.recurrence; + const auto invalid = [&event](std::string_view reason) { + throw std::invalid_argument("cannot save event '" + event.uid + + "' with invalid recurrence: " + std::string(reason)); + }; + switch (rule.frequency) { + case RecurrenceFrequency::daily: + case RecurrenceFrequency::weekly: + case RecurrenceFrequency::monthly: + case RecurrenceFrequency::yearly: break; + default: invalid("frequency is invalid"); + } + 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 (int day : rule.by_month_day) { + if (day == 0 || day < -31 || day > 31) { + invalid("BYMONTHDAY contains an out-of-range day"); + } + } + for (unsigned month_number : rule.by_month) { + if (month_number == 0 || month_number > 12) { + 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) { + if (event.uid.empty()) { + throw std::invalid_argument("cannot save an event with an empty UID"); + } + if (event.end < event.start || (event.all_day && event.end == event.start)) { + throw std::invalid_argument( + "cannot save event '" + event.uid + "' with an invalid interval"); + } + if (!event.recurrence && !event.recurrence_exceptions.empty()) { + throw std::invalid_argument( + "cannot save event '" + event.uid + "' with EXDATE values but no recurrence rule"); + } + std::vector unique_exceptions = event.recurrence_exceptions; + std::sort(unique_exceptions.begin(), unique_exceptions.end()); + if (std::adjacent_find(unique_exceptions.begin(), unique_exceptions.end()) + != unique_exceptions.end()) { + throw std::invalid_argument( + "cannot save event '" + event.uid + "' with duplicate EXDATE values"); + } + if (!event.all_day) { + switch (event.time_basis) { + case TimeBasis::floating: + case TimeBasis::utc: + case TimeBasis::zoned: break; + default: + throw std::invalid_argument( + "cannot save event '" + event.uid + "' with an invalid time basis"); + } + if (event.time_basis == TimeBasis::zoned) { + if (event.time_zone.empty() + || event.time_zone.find_first_of(";:\r\n") != std::string::npos) { + throw std::invalid_argument( + "cannot save zoned event '" + event.uid + "' without a valid TZID"); + } + try { + (void)locate_zone(event.time_zone); + } catch (const std::runtime_error&) { + throw std::invalid_argument( + "cannot save zoned event '" + event.uid + "' with unknown TZID '" + + event.time_zone + "'"); + } + } else if (!event.time_zone.empty()) { + throw std::invalid_argument( + "cannot save event '" + event.uid + "' with a TZID but non-zoned time basis"); + } + } + validate_recurrence(event); +} + } // namespace LoadResult IcsStore::load(const std::filesystem::path& path) { @@ -710,8 +1163,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { } else if (!pending->start) { add_warning(result, event_start_line, "VEVENT has no valid DTSTART and was skipped"); mark_unsafe_to_rewrite(result); - } else if (pending->end && pending->start->is_date != pending->end->is_date) { - add_warning(result, event_start_line, "DTSTART and DTEND value types differ; event was skipped"); + } else if (pending->end && !same_time_kind(*pending->start, *pending->end)) { + add_warning(result, event_start_line, + "DTSTART and DTEND value types, time bases, or TZIDs differ; event was skipped"); mark_unsafe_to_rewrite(result); } else { const bool all_day = pending->start->is_date; @@ -741,10 +1195,60 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { event.start = pending->start->value; event.end = end; event.all_day = all_day; + event.time_basis = all_day ? TimeBasis::floating : pending->start->basis; + event.time_zone = all_day ? std::string{} : pending->start->time_zone; event.location = std::move(pending->location); event.description = std::move(pending->description); event.calendar_id = std::move(pending->calendar_id); event.color = std::move(pending->color); + + if (pending->recurrence_rules.size() > 1) { + add_warning(result, pending->recurrence_rules[1].line, + "multiple RRULE properties are not supported"); + mark_unsafe_to_rewrite(result); + } else if (!pending->recurrence_rules.empty()) { + const PendingProperty& property = pending->recurrence_rules.front(); + if (!property.parameters.empty()) { + add_warning(result, property.line, "RRULE parameters are not supported"); + mark_unsafe_to_rewrite(result); + } else { + std::string error; + event.recurrence = parse_recurrence_rule( + property.value, *pending->start, error); + if (!event.recurrence) { + add_warning(result, property.line, std::move(error)); + mark_unsafe_to_rewrite(result); + } + } + } + for (const PendingProperty& property : pending->exception_dates) { + for (const std::string_view exception_value : split(property.value, ',')) { + std::string warning; + const auto parsed = parse_time( + property.parameters, exception_value, warning); + if (!parsed || !same_time_kind(*pending->start, *parsed)) { + add_warning(result, property.line, parsed + ? "EXDATE 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_exceptions.begin(), + event.recurrence_exceptions.end(), parsed->value) + == event.recurrence_exceptions.end()) { + event.recurrence_exceptions.push_back(parsed->value); + } + } + } + if (!event.recurrence && !event.recurrence_exceptions.empty()) { + add_warning(result, event_start_line, + "EXDATE without a supported RRULE cannot be edited safely"); + mark_unsafe_to_rewrite(result); + } result.events.push_back(std::move(event)); } } @@ -760,17 +1264,30 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { || name == "CALSCALE"; if ((!structural && !supported_property) || (supported_property && !parameters.empty())) { + if (!structural && !supported_property) { + const std::string feature = (name == "BEGIN" || name == "END") + ? name + ":" + upper(value) : name; + add_warning(result, index + 1, + "unsupported calendar component or property '" + feature + "'"); + } mark_unsafe_to_rewrite(result); } continue; } - const bool time_property = name == "DTSTART" || name == "DTEND"; + const bool time_property = name == "DTSTART" || name == "DTEND" + || 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" || name == "X-NOCAL-COLOR"; - if ((!time_property && !text_property) - || (time_property && !supported_time_parameters(parameters)) + if ((!time_property && !text_property && !recurrence_property) || (text_property && !parameters.empty())) { + if (!time_property && !text_property && !recurrence_property) { + const std::string feature = (name == "BEGIN" || name == "END") + ? name + ":" + upper(value) : name; + add_warning(result, index + 1, + "unsupported VEVENT component or property '" + feature + "'"); + } mark_unsafe_to_rewrite(result); } if (name == "UID") { @@ -785,7 +1302,19 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { pending->calendar_id = unescape_text(value); } else if (name == "X-NOCAL-COLOR") { pending->color = unescape_text(value); + } else if (name == "RRULE") { + pending->recurrence_rules.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}); } else if (name == "DTSTART" || name == "DTEND") { + std::optional& destination = name == "DTSTART" + ? pending->start : pending->end; + if (destination) { + add_warning(result, index + 1, "duplicate " + name + " property"); + mark_unsafe_to_rewrite(result); + } std::string warning; auto parsed = parse_time(parameters, value, warning); if (!parsed) { @@ -794,12 +1323,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { } else { if (!warning.empty()) { add_warning(result, index + 1, std::move(warning)); + mark_unsafe_to_rewrite(result); } - if (name == "DTSTART") { - pending->start = *parsed; - } else { - pending->end = *parsed; - } + destination = *parsed; } } } @@ -824,12 +1350,7 @@ FileRevision IcsStore::save( throw std::invalid_argument("calendar path must name a file"); } for (const Event& event : events) { - if (event.uid.empty()) { - throw std::invalid_argument("cannot save an event with an empty UID"); - } - if (event.end < event.start || (event.all_day && event.end == event.start)) { - throw std::invalid_argument("cannot save event '" + event.uid + "' with an invalid interval"); - } + validate_event_for_save(event); } std::ostringstream serialized(std::ios::out | std::ios::binary); diff --git a/src/tui/EventEditor.cpp b/src/tui/EventEditor.cpp index e49cc9d..3a8c0be 100644 --- a/src/tui/EventEditor.cpp +++ b/src/tui/EventEditor.cpp @@ -66,10 +66,41 @@ std::pair local_hour_minute(const TimePoint point) return {static_cast(value.tm_hour), static_cast(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(local); + const std::chrono::year_month_day date{local_day}; + const auto time = std::chrono::duration_cast(local - local_day); + const auto hour = static_cast(time.count() / 60); + const auto minute = static_cast(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 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(index_of(focused_)); + const int selected_line = 5 + static_cast(index_of(focused_)); const int maximum_start = static_cast(lines.size()) - 1 - available; const int start = std::clamp(selected_line - available / 2, 1, std::max(1, maximum_start)); diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp index 5c6119a..1335934 100644 --- a/src/tui/Screen.cpp +++ b/src/tui/Screen.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace nocal::tui { @@ -157,8 +158,15 @@ std::string iso_date(const year_month_day day) { std::string event_label(const CalendarItem& item, const int width) { const auto title = item.title.empty() ? std::string{"(untitled)"} : printable_text(item.title); + std::string segment; + switch (item.segment) { + case ItemSegment::single: break; + case ItemSegment::start: segment = "┌ "; break; + case ItemSegment::continuation: segment = "│ "; break; + case ItemSegment::end: segment = "└ "; break; + } if (item.all_day || !item.time_of_day) { - return fit_text("• " + title, width); + return fit_text((segment.empty() ? "• " : segment) + title, width); } const auto total = item.time_of_day->count(); const auto hour = static_cast((total / 60 + 24) % 24); @@ -166,7 +174,7 @@ std::string event_label(const CalendarItem& item, const int width) { if (width < 8) { return fit_text(title, width); } - return fit_text(two_digits(hour) + ":" + two_digits(minute) + " " + title, width); + return fit_text(two_digits(hour) + ":" + two_digits(minute) + " " + segment + title, width); } std::string event_label(const CalendarItem& item, const int width, const bool focused) { @@ -338,7 +346,7 @@ std::string detail_frame(std::span items, const ScreenState& const auto add = [&](std::string text, std::string style = {}) { content.emplace_back(fit_text(text, inner, false), std::move(style)); }; - add(" APPOINTMENT", "2;1"); + add(focused.recurring ? " RECURRING APPOINTMENT" : " APPOINTMENT", "2;1"); const auto& title = focused.detail_title.empty() ? focused.title : focused.detail_title; add(" " + (title.empty() ? std::string{"(untitled)"} : printable_text(title)), "1"); add(""); @@ -359,6 +367,17 @@ std::string detail_frame(std::span items, const ScreenState& add(" Time " + value + " local"); } if (!focused.location.empty()) add(" Location " + printable_text(focused.location)); + if (focused.recurring) { + add(" Repeats " + (focused.recurrence_summary.empty() + ? std::string{"Recurring series"} + : printable_text(focused.recurrence_summary))); + if (!focused.source_time_zone.empty()) { + add(" Source TZ " + printable_text(focused.source_time_zone)); + } + add(" Series Edit and delete affect every occurrence", "1;33"); + } else if (!focused.source_time_zone.empty()) { + add(" Source TZ " + printable_text(focused.source_time_zone)); + } add(""); if (!focused.description.empty()) { add(" NOTES", "2;1"); @@ -402,7 +421,10 @@ std::string delete_confirmation_frame(const ScreenState& state, const CalendarIt const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title; const auto title = detail_title.empty() ? std::string{"(untitled)"} : printable_text(detail_title); - const auto question = fit_text(" Delete “" + title + "”?", inner); + const auto question = fit_text(focused.recurring + ? " Delete entire recurring series “" + title + "”?" + : " Delete “" + title + "”?", + inner); const auto controls = fit_text(" y confirm n/Esc cancel", inner); const int question_line = std::max(1, height / 2 - 1); std::ostringstream output; @@ -437,6 +459,60 @@ LocalMoment to_local(const system_clock::time_point point) { }; } +std::string recurrence_summary(const RecurrenceRule& rule) { + std::string unit; + switch (rule.frequency) { + case RecurrenceFrequency::daily: unit = rule.interval == 1 ? "day" : "days"; break; + case RecurrenceFrequency::weekly: unit = rule.interval == 1 ? "week" : "weeks"; break; + case RecurrenceFrequency::monthly: unit = rule.interval == 1 ? "month" : "months"; break; + case RecurrenceFrequency::yearly: unit = rule.interval == 1 ? "year" : "years"; break; + } + auto result = rule.interval == 1 ? "Every " + unit + : "Every " + std::to_string(rule.interval) + " " + unit; + if (!rule.by_weekday.empty()) { + result += " on "; + for (std::size_t index = 0; index < rule.by_weekday.size(); ++index) { + if (index != 0) result += ", "; + const auto weekday_index = rule.by_weekday[index].iso_encoding() - 1U; + if (weekday_index < weekday_short.size()) result += weekday_short[weekday_index]; + } + } + if (!rule.by_month_day.empty()) { + result += " on day "; + for (std::size_t index = 0; index < rule.by_month_day.size(); ++index) { + if (index != 0) result += ", "; + result += std::to_string(rule.by_month_day[index]); + } + } + if (!rule.by_month.empty()) { + result += " in "; + for (std::size_t index = 0; index < rule.by_month.size(); ++index) { + if (index != 0) result += ", "; + const auto month = rule.by_month[index]; + result += month > 0 && month <= month_names.size() + ? std::string{month_names[month - 1U]} : std::to_string(month); + } + } + if (rule.count) result += ", " + std::to_string(*rule.count) + " occurrences"; + return result; +} + +std::size_t event_source_index(const std::span events, const Event* source) { + for (std::size_t index = 0; index < events.size(); ++index) { + if (&events[index] == source) return index; + } + throw std::invalid_argument("occurrence source is outside the event collection"); +} + +std::string source_focus_id(const std::span events, const std::size_t index) { + const auto& uid = events[index].uid; + const bool unique = !uid.empty() && + std::count_if(events.begin(), events.end(), [&uid](const Event& event) { + return event.uid == uid; + }) == 1; + return unique ? uid : "@nocal:" + std::to_string(index); +} + std::string compact_frame(std::span items, const ScreenState& state) { const int width = std::max(1, state.width); const int height = std::max(1, state.height); @@ -515,6 +591,17 @@ std::string compact_frame(std::span items, const ScreenState } // namespace +std::string occurrence_focus_id(const std::span events, + const EventOccurrence& occurrence) { + const auto index = event_source_index(events, occurrence.source); + auto identity = source_focus_id(events, index); + if (occurrence.source->recurrence) { + const auto tick = occurrence.start.time_since_epoch().count(); + identity += "@occ:" + std::to_string(tick); + } + return identity; +} + Action decode_key(const std::string_view sequence) noexcept { if (sequence == "h" || sequence == "\x1b[D" || sequence == "\x1bOD") return Action::left; if (sequence == "l" || sequence == "\x1b[C" || sequence == "\x1bOC") return Action::right; @@ -681,22 +768,22 @@ std::string render_month(std::span items, const ScreenState& std::string render_month(const std::span events, const ScreenState& state) { std::vector items; items.reserve(events.size() * 2); - std::map uid_counts; - for (const auto& event : events) { - if (!event.uid.empty()) ++uid_counts[event.uid]; - } const auto visible_start = monday_on_or_before(sys_days{state.visible_month / day{1}}); const auto visible_end = visible_start + days{41}; - for (std::size_t index = 0; index < events.size(); ++index) { - const auto& event = events[index]; - const auto identity = !event.uid.empty() && uid_counts[event.uid] == 1 - ? event.uid : "@nocal:" + std::to_string(index); - const auto local_start = to_local(event.start); - const auto local_finish = to_local(event.end); + const auto occurrences = occurrences_overlapping( + events, local_day_start(year_month_day{visible_start}), + local_day_start(year_month_day{visible_end + days{1}})); + for (const auto& occurrence : occurrences) { + if (occurrence.source == nullptr) continue; + const auto& event = *occurrence.source; + const auto identity = occurrence_focus_id(events, occurrence); + const auto local_start = to_local(occurrence.start); + const auto local_finish = to_local(occurrence.end); const auto one_second = duration_cast(seconds{1}); - const auto final_instant = event.end > event.start - ? event.end - std::min(event.end - event.start, one_second) - : event.start; + const auto final_instant = occurrence.end > occurrence.start + ? occurrence.end - + std::min(occurrence.end - occurrence.start, one_second) + : occurrence.start; const auto local_end = to_local(final_instant); const auto event_start = sys_days{local_start.date}; const auto event_last = sys_days{local_end.date}; @@ -704,9 +791,15 @@ std::string render_month(const std::span events, const ScreenState& const auto last_day = std::min(event_last, visible_end); for (auto date = first_day; date <= last_day; date += days{1}) { const bool continuation = date != event_start; + ItemSegment segment = ItemSegment::single; + if (event_start != event_last) { + if (date == event_start) segment = ItemSegment::start; + else if (date == event_last) segment = ItemSegment::end; + else segment = ItemSegment::continuation; + } items.push_back(CalendarItem{ .id = identity, - .title = continuation ? "↳ " + event.title : event.title, + .title = event.title, .day = year_month_day{date}, .time_of_day = event.all_day || continuation ? std::nullopt @@ -723,6 +816,12 @@ std::string render_month(const std::span events, const ScreenState& .detail_start_time_of_day = event.all_day ? std::nullopt : std::optional{local_start.time_of_day}, + .segment = segment, + .recurring = event.recurrence.has_value(), + .recurrence_summary = event.recurrence + ? recurrence_summary(*event.recurrence) : std::string{}, + .source_time_zone = event.time_basis == TimeBasis::zoned + ? event.time_zone : std::string{}, }); } } diff --git a/src/tui/TuiApp.cpp b/src/tui/TuiApp.cpp index ac56eb9..5e56d1a 100644 --- a/src/tui/TuiApp.cpp +++ b/src/tui/TuiApp.cpp @@ -46,20 +46,12 @@ std::size_t complete_sequence_length(const std::string& input) { return 1; } -std::string event_identity(const std::span events, const std::size_t index) { - const auto& uid = events[index].uid; - const bool unique = !uid.empty() && - std::count_if(events.begin(), events.end(), [&uid](const Event& event) { - return event.uid == uid; - }) == 1; - return unique ? uid : "@nocal:" + std::to_string(index); -} - -std::size_t source_index(const std::span events, const Event& event) { - const auto found = std::find_if(events.begin(), events.end(), [&event](const Event& candidate) { - return &candidate == &event; - }); - return static_cast(std::distance(events.begin(), found)); +std::optional source_index(const std::span events, + const Event* source) { + for (std::size_t index = 0; index < events.size(); ++index) { + if (&events[index] == source) return index; + } + return std::nullopt; } } // namespace @@ -77,15 +69,23 @@ TuiApp::TuiApp(std::vector& events, SaveCallback saver, const int input_f state_.visible_month = state_.today.year() / state_.today.month(); } -std::optional TuiApp::focused_event_index() const { +std::optional TuiApp::focused_occurrence() const { if (!state_.focused_event_id) return std::nullopt; const auto event_span = std::span{events_}; - for (std::size_t index = 0; index < events_.size(); ++index) { - if (event_identity(event_span, index) == *state_.focused_event_id) return index; + for (const auto& occurrence : occurrences_on_day(event_span, state_.selected_day)) { + if (occurrence_focus_id(event_span, occurrence) == *state_.focused_event_id) { + return occurrence; + } } return std::nullopt; } +std::optional TuiApp::focused_event_index() const { + const auto occurrence = focused_occurrence(); + return occurrence ? source_index(std::span{events_}, occurrence->source) + : std::nullopt; +} + void TuiApp::set_notification(std::string message, const bool error) { state_.notification = std::move(message); state_.notification_is_error = error; @@ -190,8 +190,13 @@ void TuiApp::begin_edit() { set_notification("Focus an appointment before editing.", true); return; } + try { + editor_.emplace(events_[*index]); + } catch (const std::exception& error) { + set_notification("Cannot edit this appointment: " + std::string{error.what()}, true); + return; + } pending_mutation_before_ = capture_history_snapshot(); - editor_.emplace(events_[*index]); editing_index_ = index; state_.show_event_details = false; state_.show_help = false; @@ -240,11 +245,33 @@ void TuiApp::submit_editor() { editor_.reset(); editing_index_.reset(); - state_.selected_day = local_date(events_[changed_index].start); - state_.visible_month = state_.selected_day.year() / state_.selected_day.month(); - state_.focused_event_id = event_identity(std::span{events_}, changed_index); + const auto event_span = std::span{events_}; + auto preferred = occurrences_on_day(event_span, state_.selected_day); + const auto matching = std::find_if(preferred.begin(), preferred.end(), [&](const auto& value) { + const auto index = source_index(event_span, value.source); + return index && *index == changed_index; + }); + if (matching != preferred.end()) { + state_.focused_event_id = occurrence_focus_id(event_span, *matching); + } else { + state_.selected_day = local_date(events_[changed_index].start); + state_.visible_month = state_.selected_day.year() / state_.selected_day.month(); + preferred = occurrences_on_day(event_span, state_.selected_day); + const auto first = std::find_if(preferred.begin(), preferred.end(), [&](const auto& value) { + const auto index = source_index(event_span, value.source); + return index && *index == changed_index; + }); + state_.focused_event_id = first == preferred.end() + ? std::nullopt + : std::optional{occurrence_focus_id(event_span, *first)}; + } state_.show_event_details = false; - record_mutation(editing ? "edit appointment" : "add appointment"); + record_mutation(editing && events_[changed_index].recurrence + ? "edit recurring series" + : editing ? "edit appointment" : "add appointment"); + if (editing && events_[changed_index].recurrence) { + success = "Recurring series updated."; + } set_notification(std::move(success)); } @@ -259,6 +286,7 @@ void TuiApp::confirm_delete() { auto original = events_; const auto original_focus = state_.focused_event_id; + const bool recurring = events_[*index].recurrence.has_value(); events_.erase(events_.begin() + static_cast(*index)); if (!persist(std::span{events_})) { events_.swap(original); @@ -271,8 +299,8 @@ void TuiApp::confirm_delete() { state_.focused_event_id.reset(); state_.show_event_details = false; state_.confirm_delete = false; - record_mutation("delete appointment"); - set_notification("Appointment deleted."); + record_mutation(recurring ? "delete recurring series" : "delete appointment"); + set_notification(recurring ? "Recurring series deleted." : "Appointment deleted."); } void TuiApp::cancel_delete() { @@ -347,7 +375,7 @@ void TuiApp::dispatch(const Action action) { }; const auto cycle_event = [this](const int direction) { const auto event_span = std::span{events_}; - const auto day_events = events_on_day(event_span, state_.selected_day); + const auto day_events = occurrences_on_day(event_span, state_.selected_day); if (day_events.empty()) { state_.focused_event_id.reset(); return; @@ -356,8 +384,8 @@ void TuiApp::dispatch(const Action action) { std::size_t current = day_events.size(); if (state_.focused_event_id) { for (std::size_t index = 0; index < day_events.size(); ++index) { - const auto event_index = source_index(event_span, day_events[index].get()); - if (event_identity(event_span, event_index) == *state_.focused_event_id) { + if (occurrence_focus_id(event_span, day_events[index]) == + *state_.focused_event_id) { current = index; break; } @@ -372,8 +400,7 @@ void TuiApp::dispatch(const Action action) { } else { target = (current + 1) % day_events.size(); } - const auto event_index = source_index(event_span, day_events[target].get()); - state_.focused_event_id = event_identity(event_span, event_index); + state_.focused_event_id = occurrence_focus_id(event_span, day_events[target]); }; if (state_.show_event_details) { diff --git a/tests/domain_tests.cpp b/tests/domain_tests.cpp index 077ea15..d13ea2e 100644 --- a/tests/domain_tests.cpp +++ b/tests/domain_tests.cpp @@ -2,8 +2,12 @@ #include #include +#include +#include #include +#include #include +#include #include #include #include @@ -79,6 +83,39 @@ nocal::Event event(std::string uid, nocal::Date start_date, unsigned start_hour, return result; } +nocal::TimePoint utc_time(nocal::Date date, unsigned hour, unsigned minute = 0) +{ + using namespace std::chrono; + return nocal::TimePoint{duration_cast( + nocal::to_sys_days(date).time_since_epoch() + hours{hour} + + minutes{minute})}; +} + +nocal::TimePoint zoned_time(std::string_view zone_name, nocal::Date date, + unsigned hour, unsigned minute = 0) +{ + using namespace std::chrono; + const auto* zone = locate_zone(zone_name); + const local_time local{ + duration_cast(local_days{date}.time_since_epoch() + + hours{hour} + minutes{minute})}; + return time_point_cast( + zone->to_sys(local, choose::earliest)); +} + +nocal::Event utc_recurring(std::string uid, nocal::Date start_date, + unsigned start_hour, nocal::Date end_date, + unsigned end_hour, nocal::RecurrenceRule rule) +{ + nocal::Event result; + result.uid = std::move(uid); + result.title = result.uid; + result.start = utc_time(start_date, start_hour); + result.end = utc_time(end_date, end_hour); + result.recurrence = std::move(rule); + return result; +} + void test_event_queries() { using namespace std::chrono; @@ -118,6 +155,222 @@ void test_event_queries() "invalid backwards event never overlaps"); } +void test_daily_dst_recurrence() +{ + using namespace std::chrono; + const nocal::Date march_28{2026y / March / 28d}; + const nocal::Date march_29{2026y / March / 29d}; + const nocal::Date march_30{2026y / March / 30d}; + nocal::Event event; + event.uid = "london-daily"; + event.title = event.uid; + event.time_basis = nocal::TimeBasis::zoned; + event.time_zone = "Europe/London"; + event.start = zoned_time(event.time_zone, march_28, 9); + event.end = zoned_time(event.time_zone, march_28, 10); + nocal::RecurrenceRule rule; + rule.frequency = nocal::RecurrenceFrequency::daily; + rule.count = 3U; + event.recurrence = rule; + + const std::vector events{event}; + const auto occurrences = nocal::occurrences_overlapping( + events, utc_time(march_28, 0), utc_time(2026y / March / 31d, 0)); + check(occurrences.size() == 3, + "daily recurrence includes DTSTART and honors COUNT"); + check(occurrences.size() == 3 && + occurrences[0].start == utc_time(march_28, 9) && + occurrences[1].start == utc_time(march_29, 8) && + occurrences[2].start == utc_time(march_30, 8), + "zoned daily recurrence preserves 09:00 wall time across DST"); + check(occurrences.size() == 3 && + occurrences[1].end - occurrences[1].start == hours{1}, + "zoned recurrence preserves the wall-clock end relationship"); +} + +void test_floating_dst_and_until() +{ + using namespace std::chrono; + const char* previous_tz_value = std::getenv("TZ"); + const std::optional previous_tz = + previous_tz_value == nullptr + ? std::nullopt + : std::optional{previous_tz_value}; + std::string london_zone_file = "/usr/share/zoneinfo/Europe/London"; + if (!std::filesystem::exists(london_zone_file)) { + const auto local_zone_file = std::filesystem::canonical("/etc/localtime"); + if (local_zone_file.string().ends_with("/Europe/London")) { + london_zone_file = local_zone_file.string(); + } + } + const std::string london_tz = ':' + london_zone_file; + if (!std::filesystem::exists(london_zone_file) || + ::setenv("TZ", london_tz.c_str(), 1) != 0) { + check(false, "test can select the process local time zone"); + return; + } + ::tzset(); + + const nocal::Date march_28{2026y / March / 28d}; + nocal::Event floating; + floating.uid = "floating-daily"; + floating.title = floating.uid; + floating.time_basis = nocal::TimeBasis::floating; + floating.start = nocal::make_local_time(march_28, 9); + floating.end = nocal::make_local_time(march_28, 10); + nocal::RecurrenceRule daily; + daily.frequency = nocal::RecurrenceFrequency::daily; + daily.count = 3U; + floating.recurrence = daily; + + auto until = utc_recurring("until", 2026y / July / 1d, 9, + 2026y / July / 1d, 10, + nocal::RecurrenceRule{}); + until.recurrence->until = utc_time(2026y / July / 3d, 9); + + const std::vector floating_events{floating}; + const auto floating_occurrences = nocal::occurrences_overlapping( + floating_events, utc_time(march_28, 0), + utc_time(2026y / March / 31d, 0)); + check(floating_occurrences.size() == 3 && + floating_occurrences[0].start == utc_time(march_28, 9) && + floating_occurrences[1].start == + utc_time(2026y / March / 29d, 8) && + floating_occurrences[2].start == + utc_time(2026y / March / 30d, 8), + "floating recurrence follows the selected process-local DST rules"); + + const std::vector until_events{until}; + const auto until_occurrences = nocal::occurrences_overlapping( + until_events, utc_time(2026y / July / 1d, 0), + utc_time(2026y / July / 10d, 0)); + check(until_occurrences.size() == 3 && + until_occurrences.back().start == until.recurrence->until, + "UNTIL is inclusive and bounds uncounted recurrence expansion"); + + if (previous_tz) { + (void)::setenv("TZ", previous_tz->c_str(), 1); + } else { + (void)::unsetenv("TZ"); + } + ::tzset(); +} + +void test_weekly_count_and_exdates() +{ + using namespace std::chrono; + const nocal::Date monday{2026y / July / 6d}; + nocal::RecurrenceRule rule; + rule.frequency = nocal::RecurrenceFrequency::weekly; + rule.count = 6U; + rule.by_weekday = {Monday, Wednesday, Friday}; + auto weekly = utc_recurring("weekly", monday, 9, monday, 10, rule); + weekly.recurrence_exceptions = {weekly.start, + utc_time(2026y / July / 10d, 9)}; + + const std::vector events{weekly}; + const auto occurrences = nocal::occurrences_overlapping( + events, utc_time(monday, 0), utc_time(2026y / July / 20d, 0)); + check(occurrences.size() == 4, + "EXDATE removes DTSTART and another generated weekly start"); + check(occurrences.size() == 4 && occurrences[0].ordinal == 1 && + occurrences[1].ordinal == 3 && occurrences[2].ordinal == 4 && + occurrences[3].ordinal == 5, + "weekly ordinals and COUNT include excluded DTSTART occurrences"); + check(occurrences.size() == 4 && + occurrences[0].start == utc_time(2026y / July / 8d, 9) && + occurrences[1].start == utc_time(2026y / July / 13d, 9), + "weekly BYDAY expansion is chronological across week boundaries"); +} + +void test_monthly_and_yearly_selectors() +{ + using namespace std::chrono; + nocal::RecurrenceRule monthly_rule; + monthly_rule.frequency = nocal::RecurrenceFrequency::monthly; + monthly_rule.count = 4U; + monthly_rule.by_month_day = {-1}; + auto monthly = utc_recurring("month-end", 2027y / January / 31d, 9, + 2027y / January / 31d, 10, monthly_rule); + + nocal::RecurrenceRule yearly_rule; + yearly_rule.frequency = nocal::RecurrenceFrequency::yearly; + yearly_rule.count = 4U; + yearly_rule.by_month = {1U, 6U}; + auto yearly = utc_recurring("selected-months", 2024y / June / 15d, 9, + 2024y / June / 15d, 10, yearly_rule); + const std::vector events{monthly, yearly}; + + const auto month_occurrences = nocal::occurrences_overlapping( + std::span{events}.first(1), + utc_time(2027y / January / 1d, 0), utc_time(2027y / May / 1d, 0)); + check(month_occurrences.size() == 4, + "negative BYMONTHDAY expands to each month's final day"); + check(month_occurrences.size() == 4 && + month_occurrences[1].start == utc_time(2027y / February / 28d, 9) && + month_occurrences[2].start == utc_time(2027y / March / 31d, 9) && + month_occurrences[3].start == utc_time(2027y / April / 30d, 9), + "monthly negative selectors account for varying month lengths"); + + const auto year_occurrences = nocal::occurrences_overlapping( + std::span{events}.subspan(1), + utc_time(2024y / January / 1d, 0), utc_time(2026y / February / 1d, 0)); + check(year_occurrences.size() == 4, + "yearly BYMONTH count includes DTSTART"); + check(year_occurrences.size() == 4 && + year_occurrences[1].start == utc_time(2025y / January / 15d, 9) && + year_occurrences[2].start == utc_time(2025y / June / 15d, 9) && + year_occurrences[3].start == utc_time(2026y / January / 15d, 9), + "yearly BYMONTH reuses DTSTART's day and wall time"); +} + +void test_occurrence_overlap_sorting_and_invalid_rules() +{ + using namespace std::chrono; + const nocal::Date day{2026y / July / 17d}; + nocal::RecurrenceRule daily; + daily.frequency = nocal::RecurrenceFrequency::daily; + daily.count = 3U; + auto overnight = utc_recurring("overnight", 2026y / July / 16d, 23, day, 2, + daily); + nocal::RecurrenceRule once; + once.frequency = nocal::RecurrenceFrequency::daily; + once.count = 1U; + auto later = utc_recurring("later", day, 12, day, 13, once); + auto all_day = utc_recurring("all-day", day, 0, 2026y / July / 18d, 0, + once); + all_day.all_day = true; + auto invalid = later; + invalid.uid = "invalid"; + invalid.title = invalid.uid; + invalid.recurrence->interval = 0U; + + std::vector events{later, overnight, invalid, all_day}; + const auto occurrences = nocal::occurrences_overlapping( + events, utc_time(day, 0), utc_time(2026y / July / 18d, 0)); + check(occurrences.size() == 5, + "occurrence overlap includes prior multi-day starts and invalid-rule base"); + check(occurrences.size() == 5 && occurrences[0].source->uid == "all-day" && + occurrences[1].source->uid == "overnight" && + occurrences[1].ordinal == 0 && + occurrences[4].source->uid == "overnight" && + occurrences[4].ordinal == 1, + "occurrences sort all-day first, then by generated start"); + check(occurrences.size() == 5 && + occurrences[2].source->uid == "invalid" && + occurrences[3].source->uid == "later", + "equal occurrence times sort by title and UID"); + + invalid.time_basis = nocal::TimeBasis::zoned; + invalid.time_zone = "Not/A_Real_Zone"; + invalid.recurrence->interval = 1U; + const std::vector bad_zone{invalid}; + const auto retained = nocal::occurrences_overlapping( + bad_zone, utc_time(day, 0), utc_time(2026y / July / 18d, 0)); + check(retained.size() == 1 && retained[0].ordinal == 0, + "unrepresentable recurrence metadata safely retains DTSTART"); +} + } // namespace int main() @@ -125,6 +378,11 @@ int main() test_dates(); test_month_layout(); test_event_queries(); + test_daily_dst_recurrence(); + test_floating_dst_and_until(); + test_weekly_count_and_exdates(); + test_monthly_and_yearly_selectors(); + test_occurrence_overlap_sorting_and_invalid_rules(); if (failures != 0) { std::cerr << failures << " domain test(s) failed\n"; return EXIT_FAILURE; diff --git a/tests/editor_tests.cpp b/tests/editor_tests.cpp index 8dfbbb5..c827028 100644 --- a/tests/editor_tests.cpp +++ b/tests/editor_tests.cpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace { @@ -215,6 +216,83 @@ void test_rendering() check(exact_width, "wide Unicode input retains exact terminal-cell width"); } +nocal::TimePoint zoned_time(const std::string_view zone_name, const nocal::Date date, + const unsigned hour, const unsigned minute) +{ + const auto* zone = std::chrono::locate_zone(zone_name); + const auto local = std::chrono::local_days{date} + std::chrono::hours{hour} + + std::chrono::minutes{minute}; + return zone->to_sys(local, std::chrono::choose::earliest); +} + +void test_zoned_edit_preserves_series_and_rejects_dst_gap() +{ + using namespace std::chrono; + nocal::Event event; + event.uid = "zoned-series"; + event.title = "New York standup"; + event.time_basis = nocal::TimeBasis::zoned; + event.time_zone = "America/New_York"; + event.start = zoned_time(event.time_zone, nocal::Date{2026y / March / 7d}, 2, 30); + event.end = zoned_time(event.time_zone, nocal::Date{2026y / March / 7d}, 3, 30); + event.recurrence = nocal::RecurrenceRule{ + .frequency = nocal::RecurrenceFrequency::daily, + .interval = 1, + .count = 4, + .until = std::nullopt, + .by_weekday = {}, + .by_month_day = {}, + .by_month = {}, + }; + event.recurrence_exceptions.push_back( + zoned_time(event.time_zone, nocal::Date{2026y / March / 9d}, 2, 30)); + + nocal::tui::EventEditor editor{event}; + check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-03-07" && + editor.field_value(nocal::tui::EditorField::start_time) == "02:30", + "a zoned editor displays civil values in the event source timezone"); + const auto form = editor.render(80, 18, false); + check(form.find("America/New_York") != std::string::npos, + "a zoned editor explicitly labels its source timezone"); + check(form.find("EDIT ENTIRE RECURRING SERIES") != std::string::npos, + "the editor clearly states that edits affect the recurring series"); + replace_field(editor, "Renamed standup"); + check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit, + "a valid zoned series edit submits"); + check(editor.result().time_basis == nocal::TimeBasis::zoned && + editor.result().time_zone == "America/New_York" && + editor.result().recurrence == event.recurrence && + editor.result().recurrence_exceptions == event.recurrence_exceptions, + "editing preserves time basis, TZID, recurrence, and exceptions"); + + nocal::tui::EventEditor series_kind_editor{event}; + focus(series_kind_editor, nocal::tui::EditorField::all_day); + (void)series_kind_editor.handle_key(" "); + check(series_kind_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active && + series_kind_editor.focused_field() == nocal::tui::EditorField::all_day && + series_kind_editor.error().find("recurring series") != std::string_view::npos, + "a recurring series rejects timed/all-day conversion to preserve its exceptions"); + + auto one_off = event; + one_off.recurrence.reset(); + one_off.recurrence_exceptions.clear(); + nocal::tui::EventEditor all_day_editor{one_off}; + focus(all_day_editor, nocal::tui::EditorField::all_day); + (void)all_day_editor.handle_key(" "); + check(all_day_editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit && + all_day_editor.result().all_day && + all_day_editor.result().time_basis == nocal::TimeBasis::floating && + all_day_editor.result().time_zone.empty(), + "a one-off zoned event converted to all-day normalizes to floating DATE semantics"); + + nocal::tui::EventEditor gap_editor{event}; + focus(gap_editor, nocal::tui::EditorField::start_date); + replace_field(gap_editor, "2026-03-08"); + check(gap_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active && + gap_editor.error().find("cannot be represented") != std::string_view::npos, + "a nonexistent source-zone civil time in the DST gap is rejected"); +} + } // namespace int main() @@ -225,6 +303,7 @@ int main() test_all_day_exclusive_end(); test_unicode_backspace_navigation_and_cancel(); test_rendering(); + test_zoned_edit_preserves_series_and_rejects_dst_gap(); if (failures != 0) { std::cerr << failures << " editor test(s) failed\n"; return EXIT_FAILURE; diff --git a/tests/ics_tests.cpp b/tests/ics_tests.cpp index 0e42dc6..8bcb63e 100644 --- a/tests/ics_tests.cpp +++ b/tests/ics_tests.cpp @@ -411,6 +411,259 @@ void test_unsupported_data_is_not_rewrite_safe(const std::filesystem::path& root require(!loaded.warnings.empty(), "unsafe calendar did not explain why editing is disabled"); } +void test_time_basis_round_trips_and_dst(const std::filesystem::path& root) { + const auto path = root / "time-bases.ics"; + write_fixture(path, + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "BEGIN:VEVENT\r\nUID:floating\r\nSUMMARY:Floating\r\n" + "DTSTART:20260717T101500\r\nDTEND:20260717T111500\r\nEND:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:utc-basis\r\nSUMMARY:UTC\r\n" + "DTSTART:20260717T101500Z\r\nDTEND:20260717T111500Z\r\nEND:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:london\r\nSUMMARY:London\r\n" + "DTSTART;TZID=Europe/London:20260717T101500\r\n" + "DTEND;TZID=Europe/London:20260717T111500\r\nRRULE:FREQ=DAILY;COUNT=3\r\n" + "EXDATE;TZID=Europe/London:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:dst\r\nSUMMARY:DST transition\r\n" + "DTSTART;TZID=Europe/London:20260329T003000\r\n" + "DTEND;TZID=Europe/London:20260329T023000\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(), + "supported time bases were not rewrite-safe"); + require(loaded.events.size() == 4, "time-basis fixture event count differs"); + require(loaded.events[0].time_basis == nocal::TimeBasis::floating, + "floating DATE-TIME lost its basis"); + require(loaded.events[1].time_basis == nocal::TimeBasis::utc, + "UTC DATE-TIME lost its basis"); + require(loaded.events[2].time_basis == nocal::TimeBasis::zoned + && loaded.events[2].time_zone == "Europe/London", + "TZID DATE-TIME lost its zone"); + require(loaded.events[2].start == sys_days{year{2026}/July/17} + 9h + 15min, + "Europe/London summer offset was not applied"); + require(loaded.events[2].recurrence_exceptions + == std::vector{ + sys_days{year{2026}/July/18} + 9h + 15min, + sys_days{year{2026}/July/19} + 9h + 15min}, + "zoned EXDATE values were not converted with TZID"); + require(loaded.events[3].end - loaded.events[3].start == 1h, + "Europe/London DST transition was not converted correctly"); + + const auto round_trip = root / "time-bases-round-trip.ics"; + nocal::storage::IcsStore::save(round_trip, loaded.events); + const std::string serialized = read_fixture(round_trip); + require(serialized.find("DTSTART:20260717T101500\r\n") != std::string::npos, + "floating DTSTART was not serialized without a suffix"); + require(serialized.find("DTSTART:20260717T101500Z\r\n") != std::string::npos, + "UTC DTSTART was not serialized with Z"); + require(serialized.find("DTSTART;TZID=Europe/London:20260717T101500\r\n") + != std::string::npos, + "zoned DTSTART was not serialized with TZID and local fields"); + require(serialized.find( + "EXDATE;TZID=Europe/London:20260718T101500,20260719T101500") + != std::string::npos, + "zoned EXDATE values were not grouped with TZID"); + const auto reloaded = nocal::storage::IcsStore::load(round_trip); + require(reloaded.safe_to_rewrite && reloaded.events.size() == loaded.events.size(), + "time-basis output did not reload safely"); + for (std::size_t index = 0; index < loaded.events.size(); ++index) { + require(reloaded.events[index].start == loaded.events[index].start + && reloaded.events[index].end == loaded.events[index].end + && reloaded.events[index].time_basis == loaded.events[index].time_basis + && reloaded.events[index].time_zone == loaded.events[index].time_zone + && reloaded.events[index].recurrence == loaded.events[index].recurrence + && reloaded.events[index].recurrence_exceptions + == loaded.events[index].recurrence_exceptions, + "time-basis semantic round trip differs"); + } +} + +void test_supported_recurrence_and_exdates(const std::filesystem::path& root) { + const auto path = root / "recurrence.ics"; + write_fixture(path, + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" + "BEGIN:VEVENT\r\nUID:daily\r\nDTSTART:20260701T090000Z\r\n" + "DTEND:20260701T100000Z\r\nRRULE:FREQ=DAILY;INTERVAL=2;COUNT=5\r\n" + "EXDATE:20260703T090000Z,20260705T090000Z\r\n" + "EXDATE:20260707T090000Z\r\nEND:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:weekly\r\nDTSTART:20260701T090000\r\n" + "DTEND:20260701T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;" + "UNTIL=20260801T090000\r\nEND:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:monthly\r\nDTSTART:20260701T090000Z\r\n" + "DTEND:20260701T100000Z\r\nRRULE:FREQ=MONTHLY;BYMONTHDAY=1,-1\r\n" + "END:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:yearly\r\nDTSTART;VALUE=DATE:20260701\r\n" + "DTEND;VALUE=DATE:20260702\r\nRRULE:FREQ=YEARLY;BYMONTH=1,7;" + "UNTIL=20290701\r\nEXDATE;VALUE=DATE:20270701,20280701\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(), + "supported recurrence was not rewrite-safe"); + require(loaded.events.size() == 4, "recurrence fixture event count differs"); + const auto& daily = *loaded.events[0].recurrence; + require(daily.frequency == nocal::RecurrenceFrequency::daily + && daily.interval == 2 && daily.count == 5 && !daily.until, + "daily RRULE fields differ"); + require(loaded.events[0].recurrence_exceptions.size() == 3, + "multiple/comma-separated EXDATE values were not collected"); + const auto& weekly = *loaded.events[1].recurrence; + require(weekly.frequency == nocal::RecurrenceFrequency::weekly + && weekly.by_weekday == std::vector{Monday, Wednesday, Friday} + && weekly.until.has_value(), + "weekly RRULE fields differ"); + const auto& monthly = *loaded.events[2].recurrence; + require(monthly.frequency == nocal::RecurrenceFrequency::monthly + && monthly.by_month_day == std::vector{1, -1}, + "monthly RRULE fields differ"); + const auto& yearly = *loaded.events[3].recurrence; + require(yearly.frequency == nocal::RecurrenceFrequency::yearly + && yearly.by_month == std::vector{1, 7} && yearly.until, + "yearly RRULE fields differ"); + require(loaded.events[3].recurrence_exceptions.size() == 2, + "all-day EXDATE values differ"); + + const auto round_trip = root / "recurrence-round-trip.ics"; + nocal::storage::IcsStore::save(round_trip, loaded.events); + const std::string serialized = read_fixture(round_trip); + require(serialized.find("RRULE:FREQ=DAILY;INTERVAL=2;COUNT=5") != std::string::npos, + "daily RRULE was not serialized"); + require(serialized.find("EXDATE:20260703T090000Z,20260705T090000Z,20260707T090000Z") + != std::string::npos, + "timed EXDATE values were not grouped"); + require(serialized.find("EXDATE;VALUE=DATE:20270701,20280701") != std::string::npos, + "all-day EXDATE values were not grouped with VALUE=DATE"); + const auto reloaded = nocal::storage::IcsStore::load(round_trip); + require(reloaded.safe_to_rewrite && reloaded.warnings.empty(), + "serialized recurrence did not reload safely"); + require(reloaded.events.size() == loaded.events.size(), + "recurrence semantic round-trip event count differs"); + for (std::size_t index = 0; index < loaded.events.size(); ++index) { + require(reloaded.events[index].recurrence == loaded.events[index].recurrence + && reloaded.events[index].recurrence_exceptions + == loaded.events[index].recurrence_exceptions, + "recurrence semantic round trip differs"); + } +} + +void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) { + const std::vector unsupported_properties{ + "RRULE:FREQ=WEEKLY;BYDAY=1MO", + "RRULE:FREQ=DAILY;BYDAY=MO", + "RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z", + "RRULE:FREQ=HOURLY", + "RRULE:FREQ=DAILY;WKST=MO", + "RDATE:20260718T090000Z", + "RECURRENCE-ID:20260718T090000Z"}; + for (std::size_t index = 0; index < unsupported_properties.size(); ++index) { + const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics"); + write_fixture(path, + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:unsupported\r\n" + "DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n" + + unsupported_properties[index] + "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"); + const auto loaded = nocal::storage::IcsStore::load(path); + require(!loaded.safe_to_rewrite && !loaded.warnings.empty(), + "unsupported recurrence feature was considered rewrite-safe"); + require(loaded.events.size() == 1, + "unsupported recurrence prevented base-event browseability"); + } + + const auto multiple = root / "multiple-rrule.ics"; + write_fixture(multiple, + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:multiple\r\n" + "DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n" + "RRULE:FREQ=DAILY\r\nRRULE:FREQ=WEEKLY\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"); + require(!nocal::storage::IcsStore::load(multiple).safe_to_rewrite, + "multiple RRULE properties were considered rewrite-safe"); + + const auto unknown_zone = root / "unknown-zone.ics"; + write_fixture(unknown_zone, + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:unknown-zone\r\n" + "DTSTART;TZID=Custom/Mars:20260717T090000\r\n" + "DTEND;TZID=Custom/Mars:20260717T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"); + const auto unknown = nocal::storage::IcsStore::load(unknown_zone); + require(!unknown.safe_to_rewrite && unknown.events.size() == 1, + "unknown TZID was not read-only and browseable"); + + const auto custom_zone = root / "vtimezone.ics"; + write_fixture(custom_zone, + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTIMEZONE\r\nTZID:Custom/Test\r\n" + "END:VTIMEZONE\r\nBEGIN:VEVENT\r\nUID:custom\r\n" + "DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n"); + require(!nocal::storage::IcsStore::load(custom_zone).safe_to_rewrite, + "VTIMEZONE definition was considered rewrite-safe"); + + const std::vector> mismatched_ends{ + {"DTSTART:20260717T090000Z", "DTEND:20260717T100000"}, + {"DTSTART;TZID=Europe/London:20260717T090000", + "DTEND;TZID=Europe/Paris:20260717T100000"}, + {"DTSTART;VALUE=DATE:20260717", "DTEND:20260718T100000Z"}}; + for (std::size_t index = 0; index < mismatched_ends.size(); ++index) { + const auto path = root / ("mismatched-time-" + std::to_string(index) + ".ics"); + write_fixture(path, + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:mismatch\r\n" + + mismatched_ends[index].first + "\r\n" + mismatched_ends[index].second + + "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"); + const auto loaded = nocal::storage::IcsStore::load(path); + require(!loaded.safe_to_rewrite && loaded.events.empty(), + "mismatched DTSTART/DTEND kinds were accepted"); + } +} + +void test_save_validation_preserves_existing_bytes(const std::filesystem::path& root) { + const auto path = root / "validation-preserves.ics"; + const std::string original = "existing bytes must survive validation"; + const std::string original_backup = "existing backup must survive validation"; + write_fixture(path, original); + const auto backup = nocal::storage::IcsStore::backup_path(path); + write_fixture(backup, original_backup); + + nocal::Event invalid_zone = event_named("zone@example.test", "Invalid zone"); + invalid_zone.time_basis = nocal::TimeBasis::zoned; + invalid_zone.time_zone = "Custom/Unknown"; + bool zone_failed = false; + try { + nocal::storage::IcsStore::save(path, std::vector{invalid_zone}); + } catch (const std::invalid_argument&) { + zone_failed = true; + } + require(zone_failed && read_fixture(path) == original, + "invalid TZID validation changed existing bytes"); + require(read_fixture(backup) == original_backup, + "invalid TZID validation changed existing backup bytes"); + + nocal::Event invalid_rule = event_named("rule@example.test", "Invalid rule"); + nocal::RecurrenceRule rule; + rule.frequency = nocal::RecurrenceFrequency::daily; + rule.by_weekday.push_back(Monday); + invalid_rule.recurrence = rule; + bool rule_failed = false; + try { + nocal::storage::IcsStore::save(path, std::vector{invalid_rule}); + } catch (const std::invalid_argument&) { + rule_failed = true; + } + require(rule_failed && read_fixture(path) == original, + "invalid RRULE validation changed existing bytes"); + require(read_fixture(backup) == original_backup, + "invalid RRULE validation changed existing backup bytes"); + + nocal::Event lone_exception = event_named("exdate@example.test", "Lone exception"); + lone_exception.recurrence_exceptions.push_back(lone_exception.start + 24h); + bool exdate_failed = false; + try { + nocal::storage::IcsStore::save(path, std::vector{lone_exception}); + } catch (const std::invalid_argument&) { + exdate_failed = true; + } + require(exdate_failed && read_fixture(path) == original + && read_fixture(backup) == original_backup, + "EXDATE-without-RRULE validation changed calendar or backup bytes"); + require_no_temporary_files(path); +} + } // namespace int main() { @@ -436,6 +689,10 @@ int main() { test_lock_failure_preserves_existing_bytes(root); test_rename_failure_cleans_temporary_file(root); test_unsupported_data_is_not_rewrite_safe(root); + test_time_basis_round_trips_and_dst(root); + test_supported_recurrence_and_exdates(root); + test_unsupported_recurrence_and_zones_are_unsafe(root); + test_save_validation_preserves_existing_bytes(root); std::filesystem::remove_all(root, ignored); std::cout << "ics_tests: ok\n"; return 0; diff --git a/tests/tui_focus_tests.cpp b/tests/tui_focus_tests.cpp index 027e4c0..553f834 100644 --- a/tests/tui_focus_tests.cpp +++ b/tests/tui_focus_tests.cpp @@ -2,9 +2,12 @@ #include "nocal/tui/Screen.hpp" #include "nocal/tui/TuiApp.hpp" +#include #include #include +#include #include +#include #include #include #include @@ -56,12 +59,20 @@ bool focus_is(const nocal::tui::TuiApp& app, const std::string_view uid) return app.state().focused_event_id && *app.state().focused_event_id == uid; } +bool focus_starts_with(const nocal::tui::TuiApp& app, const std::string_view prefix) +{ + return app.state().focused_event_id && app.state().focused_event_id->starts_with(prefix); +} + bool same_event(const nocal::Event& left, const nocal::Event& right) { return left.uid == right.uid && left.title == right.title && left.start == right.start && left.end == right.end && left.all_day == right.all_day && left.location == right.location && left.description == right.description && - left.calendar_id == right.calendar_id && left.color == right.color; + left.calendar_id == right.calendar_id && left.color == right.color && + left.time_basis == right.time_basis && left.time_zone == right.time_zone && + left.recurrence == right.recurrence && + left.recurrence_exceptions == right.recurrence_exceptions; } void replace_editor_title(nocal::tui::TuiApp& app, const std::string_view title) @@ -564,7 +575,11 @@ void test_focused_line_rendering() .description = {}, .detail_title = "Plain item", .detail_all_day = false, - .detail_start_time_of_day = hours{8}}, + .detail_start_time_of_day = hours{8}, + .segment = nocal::tui::ItemSegment::single, + .recurring = false, + .recurrence_summary = {}, + .source_time_zone = {}}, {.id = "focused", .title = "Focused item", .day = day, @@ -577,7 +592,11 @@ void test_focused_line_rendering() .description = {}, .detail_title = "Focused item", .detail_all_day = false, - .detail_start_time_of_day = hours{9} + minutes{15}}, + .detail_start_time_of_day = hours{9} + minutes{15}, + .segment = nocal::tui::ItemSegment::single, + .recurring = false, + .recurrence_summary = {}, + .source_time_zone = {}}, }; const auto frame = nocal::tui::render_month(items, state); @@ -698,6 +717,164 @@ void test_narrow_detail_wrapping_without_ansi() "narrow detail rendering emits no ANSI when styling is disabled"); } +void test_multiday_segment_labels_and_series_messaging() +{ + using namespace std::chrono; + const auto first = year{2026} / July / std::chrono::day{16}; + const auto middle = year{2026} / July / std::chrono::day{17}; + const auto last = year{2026} / July / std::chrono::day{18}; + std::vector items{ + {.id = "series@occ:1", .title = "Migration", .day = first, + .time_of_day = std::nullopt, .all_day = true, + .end_time_of_day = std::nullopt, .start_day = first, .end_day = last, + .location = {}, .description = {}, .detail_title = "Migration", + .detail_all_day = true, .detail_start_time_of_day = std::nullopt, + .segment = nocal::tui::ItemSegment::start, + .recurring = true, .recurrence_summary = "Every week", + .source_time_zone = "Europe/London"}, + {.id = "series@occ:1", .title = "Migration", .day = middle, + .time_of_day = std::nullopt, .all_day = true, + .end_time_of_day = std::nullopt, .start_day = first, .end_day = last, + .location = {}, .description = {}, .detail_title = "Migration", + .detail_all_day = true, .detail_start_time_of_day = std::nullopt, + .segment = nocal::tui::ItemSegment::continuation, + .recurring = true, .recurrence_summary = "Every week", + .source_time_zone = "Europe/London"}, + {.id = "series@occ:1", .title = "Migration", .day = last, + .time_of_day = std::nullopt, .all_day = true, + .end_time_of_day = std::nullopt, .start_day = first, .end_day = last, + .location = {}, .description = {}, .detail_title = "Migration", + .detail_all_day = true, .detail_start_time_of_day = std::nullopt, + .segment = nocal::tui::ItemSegment::end, + .recurring = true, .recurrence_summary = "Every week", + .source_time_zone = "Europe/London"}, + }; + auto state = screen_for(middle, 112, 38); + state.focused_event_id = "series@occ:1"; + const auto month = nocal::tui::render_month(items, state); + check(month.find("│ Migration") != std::string::npos, + "a middle multi-day segment has a visible continuation marker without ANSI"); + + state.show_event_details = true; + const auto reader = nocal::tui::render_month(items, state); + check(reader.find("RECURRING APPOINTMENT") != std::string::npos && + reader.find("Every week") != std::string::npos && + reader.find("Europe/London") != std::string::npos && + reader.find("every occurrence") != std::string::npos, + "the reader identifies recurrence, source timezone, and whole-series edits"); + + state.show_event_details = false; + state.confirm_delete = true; + const auto confirmation = nocal::tui::render_month(items, state); + check(confirmation.find("entire recurring series") != std::string::npos, + "recurring delete confirmation explicitly targets the whole series"); +} + +void test_recurring_occurrence_navigation_and_whole_series_crud() +{ + using namespace std::chrono; + const auto selected = nocal::today(); + const auto previous = nocal::from_sys_days(nocal::to_sys_days(selected) - days{1}); + auto series = make_event("duplicate", "Daily series", previous, 9, 0, previous, 10, 0); + series.recurrence = nocal::RecurrenceRule{ + .frequency = nocal::RecurrenceFrequency::daily, .interval = 1, .count = 4, + .until = std::nullopt, .by_weekday = {}, .by_month_day = {}, .by_month = {}}; + auto duplicate = make_event("duplicate", "Duplicate UID", selected, 11, 0, selected, 12, 0); + std::vector events{series, duplicate}; + nocal::tui::TuiApp app{events, -1, -1}; + + app.dispatch(nocal::tui::Action::next_event); + check(focus_starts_with(app, "@nocal:0@occ:"), + "a recurring duplicate-UID occurrence has a stable source-and-start identity"); + const auto series_focus = app.state().focused_event_id; + app.dispatch(nocal::tui::Action::next_event); + check(focus_is(app, "@nocal:1"), + "Tab navigates from a recurring instance to a duplicate-UID one-off event"); + app.dispatch(nocal::tui::Action::previous_event); + check(app.state().focused_event_id == series_focus, + "occurrence navigation returns to the exact recurring instance"); + + app.dispatch(nocal::tui::Action::edit_event); + replace_editor_title(app, "Edited entire series"); + app.handle_input("\x13"); + check(events.front().title == "Edited entire series" && events.front().recurrence.has_value() && + app.state().notification == "Recurring series updated." && + focus_starts_with(app, "@nocal:0@occ:"), + "editing an occurrence updates its source series and retains coherent focus"); + app.dispatch(nocal::tui::Action::undo); + check(events.front().title == "Daily series" && events.front().recurrence == series.recurrence, + "undo restores the complete recurring series metadata"); + app.dispatch(nocal::tui::Action::redo); + check(events.front().title == "Edited entire series" && events.front().recurrence.has_value(), + "redo reapplies the whole-series edit"); + + app.dispatch(nocal::tui::Action::delete_event); + app.handle_input("y"); + check(events.size() == 1 && events.front().title == "Duplicate UID" && + app.state().notification == "Recurring series deleted.", + "deleting a recurring occurrence removes only its entire source series"); + app.dispatch(nocal::tui::Action::undo); + check(events.size() == 2 && events.front().recurrence.has_value(), + "undo restores a deleted recurring series"); +} + +void test_recurrence_grid_expansion_and_source_zone_display() +{ + using namespace std::chrono; + const auto first = year{2026} / July / std::chrono::day{17}; + auto recurring = make_event("grid-series", "Grid repeat", first, 9, 0, first, 10, 0); + recurring.recurrence = nocal::RecurrenceRule{ + .frequency = nocal::RecurrenceFrequency::daily, .interval = 1, .count = 3, + .until = std::nullopt, .by_weekday = {}, .by_month_day = {}, .by_month = {}}; + std::vector events{recurring}; + auto state = screen_for(first, 140, 38); + const auto frame = nocal::tui::render_month(events, state); + std::size_t repeats = 0; + for (std::size_t at = 0; (at = frame.find("Grid repeat", at)) != std::string::npos; + at += std::string_view{"Grid repeat"}.size()) { + ++repeats; + } + check(repeats == 3, "the month adapter expands recurring instances only into visible days"); + auto compact_state = screen_for(first, 32, 12); + compact_state.ansi = false; + const auto compact = nocal::tui::render_month(events, compact_state); + check(compact.find("\x1b[") == std::string::npos && + std::count(compact.begin(), compact.end(), '\n') == 11, + "recurrence rendering preserves compact NO_COLOR frame geometry"); + + const char* prior_tz_value = std::getenv("TZ"); + const std::optional prior_tz = prior_tz_value + ? std::optional{std::string{prior_tz_value}} + : std::nullopt; + (void)::setenv("TZ", "UTC0", 1); + ::tzset(); + const auto* zone = std::chrono::locate_zone("America/New_York"); + nocal::Event zoned; + zoned.uid = "zoned-reader"; + zoned.title = "Zoned meeting"; + zoned.time_basis = nocal::TimeBasis::zoned; + zoned.time_zone = "America/New_York"; + zoned.start = zone->to_sys(local_days{first} + hours{9}, choose::earliest); + zoned.end = zone->to_sys(local_days{first} + hours{10}, choose::earliest); + std::vector zoned_events{zoned}; + auto zoned_state = screen_for(first, 100, 28); + const auto occurrences = nocal::occurrences_on_day(zoned_events, first); + check(!occurrences.empty(), "the zoned test event occurs on the user's displayed day"); + if (!occurrences.empty()) { + zoned_state.focused_event_id = + nocal::tui::occurrence_focus_id(zoned_events, occurrences.front()); + zoned_state.show_event_details = true; + const auto reader = nocal::tui::render_month(zoned_events, zoned_state); + check(reader.find("America/New_York") != std::string::npos && + reader.find("13:00 – 14:00") != std::string::npos && + reader.find("09:00 – 10:00") == std::string::npos, + "the reader names the source TZID while displaying instants in the user zone"); + } + if (prior_tz) (void)::setenv("TZ", prior_tz->c_str(), 1); + else (void)::unsetenv("TZ"); + ::tzset(); +} + } // namespace int main() @@ -721,6 +898,9 @@ int main() test_timed_event_details(); test_all_day_and_multiday_details(); test_narrow_detail_wrapping_without_ansi(); + test_multiday_segment_labels_and_series_messaging(); + test_recurring_occurrence_navigation_and_whole_series_crud(); + test_recurrence_grid_expansion_and_source_zone_display(); if (failures != 0) { std::cerr << failures << " TUI focus test(s) failed\n"; return EXIT_FAILURE; diff --git a/tests/tui_tests.cpp b/tests/tui_tests.cpp index 73709d7..7518723 100644 --- a/tests/tui_tests.cpp +++ b/tests/tui_tests.cpp @@ -44,12 +44,16 @@ void test_full_month_render() .time_of_day = hours{9} + minutes{30}, .all_day = false, .end_time_of_day = std::nullopt, .start_day = {}, .end_day = {}, .location = {}, .description = {}, .detail_title = {}, - .detail_all_day = false, .detail_start_time_of_day = std::nullopt}, + .detail_all_day = false, .detail_start_time_of_day = std::nullopt, + .segment = nocal::tui::ItemSegment::single, .recurring = false, + .recurrence_summary = {}, .source_time_zone = {}}, {.id = "2", .title = "Release day", .day = year{2026} / July / day{17}, .time_of_day = std::nullopt, .all_day = true, .end_time_of_day = std::nullopt, .start_day = {}, .end_day = {}, .location = {}, .description = {}, .detail_title = {}, - .detail_all_day = true, .detail_start_time_of_day = std::nullopt}, + .detail_all_day = true, .detail_start_time_of_day = std::nullopt, + .segment = nocal::tui::ItemSegment::single, .recurring = false, + .recurrence_summary = {}, .source_time_zone = {}}, }; const auto frame = nocal::tui::render_month(items, state);