feat: add recurrence and time zone support

This commit is contained in:
2026-07-17 21:52:02 +01:00
parent 22c6399056
commit c19098004e
18 changed files with 2321 additions and 132 deletions

View File

@@ -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 positional argument. `--demo` adds a few unsaved sample appointments and
`--print` emits a non-interactive frame. `--print` emits a non-interactive frame.
Nocal will not rewrite a calendar containing iCalendar data this version Nocal expands and safely round-trips a focused iCalendar recurrence subset:
cannot preserve, such as recurrence rules, alarms, attendees, or `TZID` daily, weekly, monthly, and yearly rules with interval, count or until, common
parameters. The calendar remains browsable, while mutation attempts explain weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA
that it is read-only. This guard prevents a local edit from silently discarding `TZID` names. Recurring instances are individually navigable; edit and delete
unknown data until those features are represented by the domain model. 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. 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 If another Nocal instance or an external editor changes the file, the mutation

View File

@@ -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 `event.start < range.end && event.end > range.start`; this matters for events
crossing midnight. 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 ## Storage
Version 0.1 uses a user-owned iCalendar file, defaulting to 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. mutations that crossed the persistence boundary successfully.
The current writer deliberately refuses to mutate an existing file when the The current writer deliberately refuses to mutate an existing file when the
loader encounters information it cannot round-trip, including recurrence, loader encounters information it cannot round-trip, including recurrence
alarms, attendees, time-zone identifiers, unknown properties, or malformed overrides, `RDATE`, custom `VTIMEZONE` definitions, alarms, attendees, unknown
components. Browsing remains available. This conservative boundary is more properties, or malformed components. Supported recurrence rules, exclusions,
important than partial editing because a successful-looking edit must not and system IANA time-zone identifiers round-trip semantically. Browsing remains
erase unrelated calendar data. 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 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 a transaction-capable local cache and preserve remote ETags/sync tokens in a

View File

@@ -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 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 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 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 last-known-good backup, and feed bounded session undo/redo history. Supported
local-data work adds `/` search, calendar visibility toggles, and recurring-event recurring instances appear throughout the month, preserve civil time across
and time-zone expansion. 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 ## Accessibility

View File

@@ -8,12 +8,13 @@
- Validated add/edit/delete forms with confirmation and failure rollback - Validated add/edit/delete forms with confirmation and failure rollback
- Guarded atomic `.ics` persistence, advisory locking, and round-trip tests - Guarded atomic `.ics` persistence, advisory locking, and round-trip tests
- Bounded session undo/redo, exact external-change detection, and backup recovery - 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 - Meson build, Nix development shell, desktop entry, and Hyprland launcher
- Seeded sample data when explicitly requested, never silent data mutation - Seeded sample data when explicitly requested, never silent data mutation
## 0.1 — complete local calendar ## 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 - Search, agenda view, calendar toggles, import/export, configurable week start
- Screen-reader-friendly linear view and full Unicode display-width handling - Screen-reader-friendly linear view and full Unicode display-width handling

View File

@@ -1,13 +1,31 @@
#pragma once #pragma once
#include <chrono> #include <chrono>
#include <optional>
#include <string> #include <string>
#include <vector>
namespace nocal { namespace nocal {
using Clock = std::chrono::system_clock; using Clock = std::chrono::system_clock;
using TimePoint = Clock::time_point; 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<unsigned> count;
std::optional<TimePoint> until;
std::vector<std::chrono::weekday> by_weekday;
std::vector<int> by_month_day;
std::vector<unsigned> by_month;
bool operator==(const RecurrenceRule&) const = default;
};
struct Event { struct Event {
std::string uid; std::string uid;
std::string title; std::string title;
@@ -18,6 +36,10 @@ struct Event {
std::string description; std::string description;
std::string calendar_id; std::string calendar_id;
std::string color; std::string color;
TimeBasis time_basis{TimeBasis::utc};
std::string time_zone;
std::optional<RecurrenceRule> recurrence;
std::vector<TimePoint> recurrence_exceptions;
}; };
struct Calendar { struct Calendar {

View File

@@ -3,6 +3,7 @@
#include "nocal/domain/date.hpp" #include "nocal/domain/date.hpp"
#include "nocal/domain/event.hpp" #include "nocal/domain/event.hpp"
#include <cstddef>
#include <functional> #include <functional>
#include <span> #include <span>
#include <vector> #include <vector>
@@ -12,6 +13,30 @@ namespace nocal {
using EventRef = std::reference_wrapper<const Event>; using EventRef = std::reference_wrapper<const Event>;
using EventRefs = std::vector<EventRef>; using EventRefs = std::vector<EventRef>;
struct EventOccurrence {
const Event* source{};
TimePoint start{};
TimePoint end{};
std::size_t ordinal{};
};
using EventOccurrences = std::vector<EventOccurrence>;
[[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<const Event> events, TimePoint begin, TimePoint end);
[[nodiscard]] EventOccurrences occurrences_on_day(std::span<const Event> events,
Date date);
[[nodiscard]] EventOccurrences occurrences_in_month(std::span<const Event> events,
YearMonth month);
[[nodiscard]] bool event_less(const Event& lhs, const Event& rhs) noexcept; [[nodiscard]] bool event_less(const Event& lhs, const Event& rhs) noexcept;
void sort_events(EventRefs& events); void sort_events(EventRefs& events);

View File

@@ -7,9 +7,17 @@
#include <string_view> #include <string_view>
#include "nocal/domain/event.hpp" #include "nocal/domain/event.hpp"
#include "nocal/domain/event_query.hpp"
namespace nocal::tui { namespace nocal::tui {
enum class ItemSegment {
single,
start,
continuation,
end,
};
// A deliberately small view-model keeps the renderer independent of storage and // A deliberately small view-model keeps the renderer independent of storage and
// makes it straightforward to unit test. TuiApp adapts domain Events to this. // makes it straightforward to unit test. TuiApp adapts domain Events to this.
struct CalendarItem { struct CalendarItem {
@@ -26,6 +34,10 @@ struct CalendarItem {
std::string detail_title; std::string detail_title;
bool detail_all_day{false}; bool detail_all_day{false};
std::optional<std::chrono::minutes> detail_start_time_of_day; std::optional<std::chrono::minutes> detail_start_time_of_day;
ItemSegment segment{ItemSegment::single};
bool recurring{false};
std::string recurrence_summary;
std::string source_time_zone;
}; };
struct ScreenState { struct ScreenState {
@@ -50,11 +62,17 @@ struct ScreenState {
[[nodiscard]] std::string render_month(std::span<const CalendarItem> items, [[nodiscard]] std::string render_month(std::span<const CalendarItem> items,
const ScreenState& state); const ScreenState& state);
// Convenience adapter for the domain model. Event timestamps are projected to // Convenience adapter for the domain model. Occurrence instants are projected
// the process's local civil time until explicit calendar time zones are added. // into the user's process-local zone; the reader retains an explicit source TZID.
[[nodiscard]] std::string render_month(std::span<const Event> events, [[nodiscard]] std::string render_month(std::span<const Event> events,
const ScreenState& state); 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<const Event> events,
const EventOccurrence& occurrence);
enum class Action { enum class Action {
none, none,
left, left,

View File

@@ -72,6 +72,7 @@ private:
std::vector<HistoryEntry> redo_history_; std::vector<HistoryEntry> redo_history_;
bool running_{true}; bool running_{true};
[[nodiscard]] std::optional<EventOccurrence> focused_occurrence() const;
[[nodiscard]] std::optional<std::size_t> focused_event_index() const; [[nodiscard]] std::optional<std::size_t> focused_event_index() const;
[[nodiscard]] HistorySnapshot capture_history_snapshot() const; [[nodiscard]] HistorySnapshot capture_history_snapshot() const;
void restore_history_snapshot(HistorySnapshot snapshot); void restore_history_snapshot(HistorySnapshot snapshot);

View File

@@ -1,9 +1,584 @@
#include "nocal/domain/event_query.hpp" #include "nocal/domain/event_query.hpp"
#include <algorithm> #include <algorithm>
#include <chrono>
#include <ctime>
#include <limits>
#include <stdexcept> #include <stdexcept>
namespace nocal { namespace nocal {
namespace {
using CivilTime = std::chrono::local_time<Clock::duration>;
struct Projection {
CivilTime start;
CivilTime end;
const std::chrono::time_zone* zone{};
};
[[nodiscard]] std::optional<Clock::duration> 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<Rep>::min() + right) ||
(right < Rep{0} &&
left > std::numeric_limits<Rep>::max() + right)) {
return std::nullopt;
}
return Clock::duration{left - right};
}
template <class TimePointType>
[[nodiscard]] std::optional<TimePointType> 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<Rep>::max() - addend) ||
(addend < Rep{0} &&
value < std::numeric_limits<Rep>::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<seconds>(point);
const auto time = Clock::to_time_t(
TimePoint{duration_cast<Clock::duration>(whole.time_since_epoch())});
const auto tm = process_local_tm(time);
const Date date{year{tm.tm_year + 1900},
month{static_cast<unsigned>(tm.tm_mon + 1)},
day{static_cast<unsigned>(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<Clock::duration>(
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<Clock::duration>(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<Clock::duration>(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<days>(civil);
const Date date{local_days{civil_day.time_since_epoch()}};
const auto since_midnight = civil - civil_day;
const auto whole_seconds = floor<seconds>(since_midnight);
const hh_mm_ss hms{whole_seconds};
const auto base = make_local_time(date, static_cast<unsigned>(hms.hours().count()),
static_cast<unsigned>(hms.minutes().count()),
static_cast<unsigned>(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<Clock::duration>(
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<std::chrono::days>(civil).time_since_epoch()}};
}
[[nodiscard]] Clock::duration civil_time_of_day(CivilTime civil) noexcept
{
return civil - std::chrono::floor<std::chrono::days>(civil);
}
[[nodiscard]] CivilTime make_civil(Date date, Clock::duration time_of_day)
{
return std::chrono::time_point_cast<Clock::duration>(
std::chrono::local_days{date}) +
time_of_day;
}
[[nodiscard]] std::vector<unsigned> normalized_weekdays(
const RecurrenceRule& rule, Date start_date)
{
std::vector<unsigned> 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<int> normalized_month_days(
const RecurrenceRule& rule, Date start_date)
{
std::vector<int> result = rule.by_month_day;
if (result.empty()) {
result.push_back(static_cast<int>(static_cast<unsigned>(start_date.day())));
}
std::sort(result.begin(), result.end());
result.erase(std::unique(result.begin(), result.end()), result.end());
return result;
}
[[nodiscard]] std::vector<unsigned> normalized_months(const RecurrenceRule& rule,
Date start_date)
{
std::vector<unsigned> result = rule.by_month;
if (result.empty()) {
result.push_back(static_cast<unsigned>(start_date.month()));
}
std::sort(result.begin(), result.end());
result.erase(std::unique(result.begin(), result.end()), result.end());
return result;
}
[[nodiscard]] std::optional<Date> resolve_month_day(std::chrono::year_month month,
int selector)
{
using namespace std::chrono;
const auto last = static_cast<unsigned>(
year_month_day_last{month / std::chrono::last}.day());
const int numeric_day = selector > 0 ? selector
: static_cast<int>(last) + selector + 1;
if (numeric_day < 1 || numeric_day > static_cast<int>(last)) {
return std::nullopt;
}
return Date{month / day{static_cast<unsigned>(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<std::size_t>(*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<long long>(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<days>(*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<unsigned long long>(index);
if (ordinal_value >
static_cast<unsigned long long>(std::numeric_limits<std::size_t>::max())) {
break;
}
const auto ordinal = static_cast<std::size_t>(ordinal_value);
if (rule.count && ordinal >= static_cast<std::size_t>(*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<long long>(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<unsigned long long>(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<unsigned long long>(
first_week_extras) +
previous_full *
static_cast<unsigned long long>(
weekdays.size()) +
static_cast<unsigned long long>(position);
if (calculated > static_cast<unsigned long long>(
std::numeric_limits<std::size_t>::max())) {
return;
}
ordinal = static_cast<std::size_t>(calculated);
}
if (rule.count && ordinal >= static_cast<std::size_t>(*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<Date> 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<std::size_t>(*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<int>(start_date.year());
const int last_year = static_cast<int>(
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<std::size_t>(*rule.count)) {
return;
}
append_candidate(result, event, rule, projection, candidate, ordinal,
begin, end);
++ordinal;
}
if (last_year - year_value < static_cast<int>(rule.interval)) {
break;
}
year_value += static_cast<int>(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 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 bool event_overlaps(const Event& event, TimePoint begin, TimePoint end) noexcept
{ {
if (begin >= end || !has_valid_interval(event)) { return overlaps(event.start, event.end, begin, end);
return false;
}
if (event.start == event.end) {
return event.start >= begin && event.start < end;
}
return event.start < end && event.end > begin;
} }
bool event_occurs_on(const Event& event, Date date) bool event_occurs_on(const Event& event, Date date)
@@ -75,4 +673,32 @@ EventRefs events_in_month(std::span<const Event> events, YearMonth month)
return events_overlapping(events, begin, end); return events_overlapping(events, begin, end);
} }
EventOccurrences occurrences_overlapping(std::span<const Event> 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<const Event> events, Date date)
{
return occurrences_overlapping(events, local_day_start(date),
local_day_end(date));
}
EventOccurrences occurrences_in_month(std::span<const Event> 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 } // namespace nocal

View File

@@ -31,6 +31,14 @@ struct ParsedTime {
TimePoint value; TimePoint value;
bool is_date = false; bool is_date = false;
std::optional<year_month_day> date; std::optional<year_month_day> date;
TimeBasis basis = TimeBasis::floating;
std::string time_zone;
};
struct PendingProperty {
std::string parameters;
std::string value;
std::size_t line = 0;
}; };
struct PendingEvent { struct PendingEvent {
@@ -42,6 +50,8 @@ struct PendingEvent {
std::string color; std::string color;
std::optional<ParsedTime> start; std::optional<ParsedTime> start;
std::optional<ParsedTime> end; std::optional<ParsedTime> end;
std::vector<PendingProperty> recurrence_rules;
std::vector<PendingProperty> exception_dates;
}; };
[[nodiscard]] std::string upper(std::string_view value) { [[nodiscard]] std::string upper(std::string_view value) {
@@ -149,26 +159,84 @@ struct PendingEvent {
return system_clock::from_time_t(converted); return system_clock::from_time_t(converted);
} }
[[nodiscard]] std::optional<ParsedTime> parse_time( struct TimeParameters {
std::string_view parameters, std::string_view value, std::string& warning) { bool valid = true;
const std::string normalized_parameters = upper(parameters);
bool value_is_date = false; bool value_is_date = false;
bool has_tzid = false; bool has_value_parameter = false;
std::size_t parameter_start = 0; std::optional<std::string> time_zone;
while (parameter_start <= normalized_parameters.size()) { std::string error;
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 [[nodiscard]] TimeParameters parse_time_parameters(std::string_view parameters) {
? std::string::npos : parameter_end - parameter_start); TimeParameters result;
value_is_date = value_is_date || parameter == "VALUE=DATE"; std::size_t start = 0;
has_tzid = has_tzid || parameter.starts_with("TZID="); while (start < parameters.size()) {
if (parameter_end == std::string::npos) { 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; 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<TimePoint> 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<ParsedTime> 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); const auto date = parse_ymd(value);
if (!date) { if (!date) {
warning = "invalid DATE value '" + std::string(value) + "'"; 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) + "'"; warning = "DATE does not map to a local midnight: '" + std::string(value) + "'";
return std::nullopt; return std::nullopt;
} }
return ParsedTime{*local, true, *date}; return ParsedTime{*local, true, *date, TimeBasis::floating, {}};
}
if (has_tzid) {
warning = "TZID is not supported; DATE-TIME was interpreted in the process local timezone";
} }
const bool utc = !value.empty() && value.back() == 'Z'; 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; const std::size_t expected_size = utc ? 16 : 15;
if (value.size() != expected_size || value[8] != 'T') { if (value.size() != expected_size || value[8] != 'T') {
warning = "invalid DATE-TIME value '" + std::string(value) + "'"; warning = "invalid DATE-TIME value '" + std::string(value) + "'";
@@ -207,9 +275,31 @@ struct PendingEvent {
// first instant of the next minute. // first instant of the next minute.
const int represented_second = std::min(second_value, 59); const int represented_second = std::min(second_value, 59);
TimePoint result; TimePoint result;
TimeBasis basis = TimeBasis::floating;
std::string zone_name;
if (utc) { if (utc) {
result = sys_days{*date} + hours{hour_value} + minutes{minute_value} result = sys_days{*date} + hours{hour_value} + minutes{minute_value}
+ seconds{represented_second}; + 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 { } else {
const auto local = make_local(*date, hour_value, minute_value, represented_second); const auto local = make_local(*date, hour_value, minute_value, represented_second);
if (!local) { if (!local) {
@@ -221,7 +311,7 @@ struct PendingEvent {
if (second_value == 60) { if (second_value == 60) {
result += seconds{1}; result += seconds{1};
} }
return ParsedTime{result, false, std::nullopt}; return ParsedTime{result, false, std::nullopt, basis, std::move(zone_name)};
} }
[[nodiscard]] std::vector<std::string> unfold(std::istream& input) { [[nodiscard]] std::vector<std::string> unfold(std::istream& input) {
@@ -267,9 +357,173 @@ void mark_unsafe_to_rewrite(LoadResult& result) {
} }
} }
[[nodiscard]] bool supported_time_parameters(std::string_view parameters) { [[nodiscard]] std::vector<std::string_view> split(
const std::string normalized = upper(parameters); std::string_view value, char delimiter) {
return normalized.empty() || normalized == "VALUE=DATE"; std::vector<std::string_view> 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 <typename Integer>
[[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<weekday> 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<RecurrenceRule> 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) { [[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"; + two_digits(utc.tm_min) + two_digits(utc.tm_sec) + "Z";
} }
[[nodiscard]] std::string format_floating_time(TimePoint value) {
const auto whole_seconds = floor<seconds>(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<seconds>(value));
const local_days date = floor<days>(local);
const year_month_day ymd{date};
const hh_mm_ss time{local - date};
return four_digits(static_cast<int>(ymd.year()))
+ two_digits(static_cast<int>(static_cast<unsigned>(ymd.month())))
+ two_digits(static_cast<int>(static_cast<unsigned>(ymd.day()))) + "T"
+ two_digits(static_cast<int>(time.hours().count()))
+ two_digits(static_cast<int>(time.minutes().count()))
+ two_digits(static_cast<int>(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 <typename Range, typename Formatter>
[[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) { [[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); std::size_t end = std::min(value.size(), begin + limit);
while (end > begin && end < value.size() while (end > begin && end < value.size()
@@ -365,8 +715,23 @@ void serialize_calendar(std::ostream& output, std::span<const Event> events) {
write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start)); write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start));
write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end)); write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end));
} else { } else {
write_folded(output, "DTSTART:" + format_utc_time(event.start)); const std::string parameters = time_property_parameters(event);
write_folded(output, "DTEND:" + format_utc_time(event.end)); 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, "SUMMARY", event.title);
write_text_property(output, "LOCATION", event.location); 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<TimePoint> 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 } // namespace
LoadResult IcsStore::load(const std::filesystem::path& path) { LoadResult IcsStore::load(const std::filesystem::path& path) {
@@ -710,8 +1163,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
} else if (!pending->start) { } else if (!pending->start) {
add_warning(result, event_start_line, "VEVENT has no valid DTSTART and was skipped"); add_warning(result, event_start_line, "VEVENT has no valid DTSTART and was skipped");
mark_unsafe_to_rewrite(result); mark_unsafe_to_rewrite(result);
} else if (pending->end && pending->start->is_date != pending->end->is_date) { } else if (pending->end && !same_time_kind(*pending->start, *pending->end)) {
add_warning(result, event_start_line, "DTSTART and DTEND value types differ; event was skipped"); add_warning(result, event_start_line,
"DTSTART and DTEND value types, time bases, or TZIDs differ; event was skipped");
mark_unsafe_to_rewrite(result); mark_unsafe_to_rewrite(result);
} else { } else {
const bool all_day = pending->start->is_date; 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.start = pending->start->value;
event.end = end; event.end = end;
event.all_day = all_day; 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.location = std::move(pending->location);
event.description = std::move(pending->description); event.description = std::move(pending->description);
event.calendar_id = std::move(pending->calendar_id); event.calendar_id = std::move(pending->calendar_id);
event.color = std::move(pending->color); 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)); result.events.push_back(std::move(event));
} }
} }
@@ -760,17 +1264,30 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|| name == "CALSCALE"; || name == "CALSCALE";
if ((!structural && !supported_property) if ((!structural && !supported_property)
|| (supported_property && !parameters.empty())) { || (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); mark_unsafe_to_rewrite(result);
} }
continue; 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" const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION"
|| name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID" || name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID"
|| name == "X-NOCAL-COLOR"; || name == "X-NOCAL-COLOR";
if ((!time_property && !text_property) if ((!time_property && !text_property && !recurrence_property)
|| (time_property && !supported_time_parameters(parameters))
|| (text_property && !parameters.empty())) { || (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); mark_unsafe_to_rewrite(result);
} }
if (name == "UID") { if (name == "UID") {
@@ -785,7 +1302,19 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
pending->calendar_id = unescape_text(value); pending->calendar_id = unescape_text(value);
} else if (name == "X-NOCAL-COLOR") { } else if (name == "X-NOCAL-COLOR") {
pending->color = unescape_text(value); 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") { } else if (name == "DTSTART" || name == "DTEND") {
std::optional<ParsedTime>& 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; std::string warning;
auto parsed = parse_time(parameters, value, warning); auto parsed = parse_time(parameters, value, warning);
if (!parsed) { if (!parsed) {
@@ -794,12 +1323,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
} else { } else {
if (!warning.empty()) { if (!warning.empty()) {
add_warning(result, index + 1, std::move(warning)); add_warning(result, index + 1, std::move(warning));
mark_unsafe_to_rewrite(result);
} }
if (name == "DTSTART") { destination = *parsed;
pending->start = *parsed;
} else {
pending->end = *parsed;
}
} }
} }
} }
@@ -824,12 +1350,7 @@ FileRevision IcsStore::save(
throw std::invalid_argument("calendar path must name a file"); throw std::invalid_argument("calendar path must name a file");
} }
for (const Event& event : events) { for (const Event& event : events) {
if (event.uid.empty()) { validate_event_for_save(event);
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");
}
} }
std::ostringstream serialized(std::ios::out | std::ios::binary); std::ostringstream serialized(std::ios::out | std::ios::binary);

View File

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

View File

@@ -6,6 +6,7 @@
#include <ctime> #include <ctime>
#include <map> #include <map>
#include <sstream> #include <sstream>
#include <stdexcept>
#include <vector> #include <vector>
namespace nocal::tui { 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) { std::string event_label(const CalendarItem& item, const int width) {
const auto title = item.title.empty() ? std::string{"(untitled)"} : printable_text(item.title); 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) { 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 total = item.time_of_day->count();
const auto hour = static_cast<unsigned>((total / 60 + 24) % 24); const auto hour = static_cast<unsigned>((total / 60 + 24) % 24);
@@ -166,7 +174,7 @@ std::string event_label(const CalendarItem& item, const int width) {
if (width < 8) { if (width < 8) {
return fit_text(title, width); 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) { std::string event_label(const CalendarItem& item, const int width, const bool focused) {
@@ -338,7 +346,7 @@ std::string detail_frame(std::span<const CalendarItem> items, const ScreenState&
const auto add = [&](std::string text, std::string style = {}) { const auto add = [&](std::string text, std::string style = {}) {
content.emplace_back(fit_text(text, inner, false), std::move(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; const auto& title = focused.detail_title.empty() ? focused.title : focused.detail_title;
add(" " + (title.empty() ? std::string{"(untitled)"} : printable_text(title)), "1"); add(" " + (title.empty() ? std::string{"(untitled)"} : printable_text(title)), "1");
add(""); add("");
@@ -359,6 +367,17 @@ std::string detail_frame(std::span<const CalendarItem> items, const ScreenState&
add(" Time " + value + " local"); add(" Time " + value + " local");
} }
if (!focused.location.empty()) add(" Location " + printable_text(focused.location)); 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(""); add("");
if (!focused.description.empty()) { if (!focused.description.empty()) {
add(" NOTES", "2;1"); 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& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
const auto title = detail_title.empty() ? std::string{"(untitled)"} const auto title = detail_title.empty() ? std::string{"(untitled)"}
: printable_text(detail_title); : 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 auto controls = fit_text(" y confirm n/Esc cancel", inner);
const int question_line = std::max(1, height / 2 - 1); const int question_line = std::max(1, height / 2 - 1);
std::ostringstream output; 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<const Event> 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<const Event> 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<const CalendarItem> items, const ScreenState& state) { std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
const int width = std::max(1, state.width); const int width = std::max(1, state.width);
const int height = std::max(1, state.height); const int height = std::max(1, state.height);
@@ -515,6 +591,17 @@ std::string compact_frame(std::span<const CalendarItem> items, const ScreenState
} // namespace } // namespace
std::string occurrence_focus_id(const std::span<const Event> 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 { Action decode_key(const std::string_view sequence) noexcept {
if (sequence == "h" || sequence == "\x1b[D" || sequence == "\x1bOD") return Action::left; if (sequence == "h" || sequence == "\x1b[D" || sequence == "\x1bOD") return Action::left;
if (sequence == "l" || sequence == "\x1b[C" || sequence == "\x1bOC") return Action::right; if (sequence == "l" || sequence == "\x1b[C" || sequence == "\x1bOC") return Action::right;
@@ -681,22 +768,22 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
std::string render_month(const std::span<const Event> events, const ScreenState& state) { std::string render_month(const std::span<const Event> events, const ScreenState& state) {
std::vector<CalendarItem> items; std::vector<CalendarItem> items;
items.reserve(events.size() * 2); items.reserve(events.size() * 2);
std::map<std::string, std::size_t> 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_start = monday_on_or_before(sys_days{state.visible_month / day{1}});
const auto visible_end = visible_start + days{41}; const auto visible_end = visible_start + days{41};
for (std::size_t index = 0; index < events.size(); ++index) { const auto occurrences = occurrences_overlapping(
const auto& event = events[index]; events, local_day_start(year_month_day{visible_start}),
const auto identity = !event.uid.empty() && uid_counts[event.uid] == 1 local_day_start(year_month_day{visible_end + days{1}}));
? event.uid : "@nocal:" + std::to_string(index); for (const auto& occurrence : occurrences) {
const auto local_start = to_local(event.start); if (occurrence.source == nullptr) continue;
const auto local_finish = to_local(event.end); 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<system_clock::duration>(seconds{1}); const auto one_second = duration_cast<system_clock::duration>(seconds{1});
const auto final_instant = event.end > event.start const auto final_instant = occurrence.end > occurrence.start
? event.end - std::min(event.end - event.start, one_second) ? occurrence.end -
: event.start; std::min(occurrence.end - occurrence.start, one_second)
: occurrence.start;
const auto local_end = to_local(final_instant); const auto local_end = to_local(final_instant);
const auto event_start = sys_days{local_start.date}; const auto event_start = sys_days{local_start.date};
const auto event_last = sys_days{local_end.date}; const auto event_last = sys_days{local_end.date};
@@ -704,9 +791,15 @@ std::string render_month(const std::span<const Event> events, const ScreenState&
const auto last_day = std::min(event_last, visible_end); const auto last_day = std::min(event_last, visible_end);
for (auto date = first_day; date <= last_day; date += days{1}) { for (auto date = first_day; date <= last_day; date += days{1}) {
const bool continuation = date != event_start; 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{ items.push_back(CalendarItem{
.id = identity, .id = identity,
.title = continuation ? "" + event.title : event.title, .title = event.title,
.day = year_month_day{date}, .day = year_month_day{date},
.time_of_day = event.all_day || continuation .time_of_day = event.all_day || continuation
? std::nullopt ? std::nullopt
@@ -723,6 +816,12 @@ std::string render_month(const std::span<const Event> events, const ScreenState&
.detail_start_time_of_day = event.all_day .detail_start_time_of_day = event.all_day
? std::nullopt ? std::nullopt
: std::optional{local_start.time_of_day}, : 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{},
}); });
} }
} }

View File

@@ -46,20 +46,12 @@ std::size_t complete_sequence_length(const std::string& input) {
return 1; return 1;
} }
std::string event_identity(const std::span<const Event> events, const std::size_t index) { std::optional<std::size_t> source_index(const std::span<const Event> events,
const auto& uid = events[index].uid; const Event* source) {
const bool unique = !uid.empty() && for (std::size_t index = 0; index < events.size(); ++index) {
std::count_if(events.begin(), events.end(), [&uid](const Event& event) { if (&events[index] == source) return index;
return event.uid == uid; }
}) == 1; return std::nullopt;
return unique ? uid : "@nocal:" + std::to_string(index);
}
std::size_t source_index(const std::span<const Event> 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::size_t>(std::distance(events.begin(), found));
} }
} // namespace } // namespace
@@ -77,15 +69,23 @@ TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver, const int input_f
state_.visible_month = state_.today.year() / state_.today.month(); state_.visible_month = state_.today.year() / state_.today.month();
} }
std::optional<std::size_t> TuiApp::focused_event_index() const { std::optional<EventOccurrence> TuiApp::focused_occurrence() const {
if (!state_.focused_event_id) return std::nullopt; if (!state_.focused_event_id) return std::nullopt;
const auto event_span = std::span<const Event>{events_}; const auto event_span = std::span<const Event>{events_};
for (std::size_t index = 0; index < events_.size(); ++index) { for (const auto& occurrence : occurrences_on_day(event_span, state_.selected_day)) {
if (event_identity(event_span, index) == *state_.focused_event_id) return index; if (occurrence_focus_id(event_span, occurrence) == *state_.focused_event_id) {
return occurrence;
}
} }
return std::nullopt; return std::nullopt;
} }
std::optional<std::size_t> TuiApp::focused_event_index() const {
const auto occurrence = focused_occurrence();
return occurrence ? source_index(std::span<const Event>{events_}, occurrence->source)
: std::nullopt;
}
void TuiApp::set_notification(std::string message, const bool error) { void TuiApp::set_notification(std::string message, const bool error) {
state_.notification = std::move(message); state_.notification = std::move(message);
state_.notification_is_error = error; state_.notification_is_error = error;
@@ -190,8 +190,13 @@ void TuiApp::begin_edit() {
set_notification("Focus an appointment before editing.", true); set_notification("Focus an appointment before editing.", true);
return; return;
} }
pending_mutation_before_ = capture_history_snapshot(); try {
editor_.emplace(events_[*index]); 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();
editing_index_ = index; editing_index_ = index;
state_.show_event_details = false; state_.show_event_details = false;
state_.show_help = false; state_.show_help = false;
@@ -240,11 +245,33 @@ void TuiApp::submit_editor() {
editor_.reset(); editor_.reset();
editing_index_.reset(); editing_index_.reset();
const auto event_span = std::span<const Event>{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_.selected_day = local_date(events_[changed_index].start);
state_.visible_month = state_.selected_day.year() / state_.selected_day.month(); state_.visible_month = state_.selected_day.year() / state_.selected_day.month();
state_.focused_event_id = event_identity(std::span<const Event>{events_}, changed_index); 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; 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)); set_notification(std::move(success));
} }
@@ -259,6 +286,7 @@ void TuiApp::confirm_delete() {
auto original = events_; auto original = events_;
const auto original_focus = state_.focused_event_id; const auto original_focus = state_.focused_event_id;
const bool recurring = events_[*index].recurrence.has_value();
events_.erase(events_.begin() + static_cast<std::ptrdiff_t>(*index)); events_.erase(events_.begin() + static_cast<std::ptrdiff_t>(*index));
if (!persist(std::span<const Event>{events_})) { if (!persist(std::span<const Event>{events_})) {
events_.swap(original); events_.swap(original);
@@ -271,8 +299,8 @@ void TuiApp::confirm_delete() {
state_.focused_event_id.reset(); state_.focused_event_id.reset();
state_.show_event_details = false; state_.show_event_details = false;
state_.confirm_delete = false; state_.confirm_delete = false;
record_mutation("delete appointment"); record_mutation(recurring ? "delete recurring series" : "delete appointment");
set_notification("Appointment deleted."); set_notification(recurring ? "Recurring series deleted." : "Appointment deleted.");
} }
void TuiApp::cancel_delete() { void TuiApp::cancel_delete() {
@@ -347,7 +375,7 @@ void TuiApp::dispatch(const Action action) {
}; };
const auto cycle_event = [this](const int direction) { const auto cycle_event = [this](const int direction) {
const auto event_span = std::span<const Event>{events_}; const auto event_span = std::span<const Event>{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()) { if (day_events.empty()) {
state_.focused_event_id.reset(); state_.focused_event_id.reset();
return; return;
@@ -356,8 +384,8 @@ void TuiApp::dispatch(const Action action) {
std::size_t current = day_events.size(); std::size_t current = day_events.size();
if (state_.focused_event_id) { if (state_.focused_event_id) {
for (std::size_t index = 0; index < day_events.size(); ++index) { for (std::size_t index = 0; index < day_events.size(); ++index) {
const auto event_index = source_index(event_span, day_events[index].get()); if (occurrence_focus_id(event_span, day_events[index]) ==
if (event_identity(event_span, event_index) == *state_.focused_event_id) { *state_.focused_event_id) {
current = index; current = index;
break; break;
} }
@@ -372,8 +400,7 @@ void TuiApp::dispatch(const Action action) {
} else { } else {
target = (current + 1) % day_events.size(); target = (current + 1) % day_events.size();
} }
const auto event_index = source_index(event_span, day_events[target].get()); state_.focused_event_id = occurrence_focus_id(event_span, day_events[target]);
state_.focused_event_id = event_identity(event_span, event_index);
}; };
if (state_.show_event_details) { if (state_.show_event_details) {

View File

@@ -2,8 +2,12 @@
#include <chrono> #include <chrono>
#include <cstdlib> #include <cstdlib>
#include <ctime>
#include <filesystem>
#include <iostream> #include <iostream>
#include <optional>
#include <stdexcept> #include <stdexcept>
#include <string>
#include <string_view> #include <string_view>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -79,6 +83,39 @@ nocal::Event event(std::string uid, nocal::Date start_date, unsigned start_hour,
return result; return result;
} }
nocal::TimePoint utc_time(nocal::Date date, unsigned hour, unsigned minute = 0)
{
using namespace std::chrono;
return nocal::TimePoint{duration_cast<nocal::Clock::duration>(
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<nocal::Clock::duration> local{
duration_cast<nocal::Clock::duration>(local_days{date}.time_since_epoch() +
hours{hour} + minutes{minute})};
return time_point_cast<nocal::Clock::duration>(
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() void test_event_queries()
{ {
using namespace std::chrono; using namespace std::chrono;
@@ -118,6 +155,222 @@ void test_event_queries()
"invalid backwards event never overlaps"); "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<nocal::Event> 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<std::string> previous_tz =
previous_tz_value == nullptr
? std::nullopt
: std::optional<std::string>{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<nocal::Event> 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<nocal::Event> 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<nocal::Event> 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<nocal::Event> events{monthly, yearly};
const auto month_occurrences = nocal::occurrences_overlapping(
std::span<const nocal::Event>{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<const nocal::Event>{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<nocal::Event> 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<nocal::Event> 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 } // namespace
int main() int main()
@@ -125,6 +378,11 @@ int main()
test_dates(); test_dates();
test_month_layout(); test_month_layout();
test_event_queries(); 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) { if (failures != 0) {
std::cerr << failures << " domain test(s) failed\n"; std::cerr << failures << " domain test(s) failed\n";
return EXIT_FAILURE; return EXIT_FAILURE;

View File

@@ -7,6 +7,7 @@
#include <iostream> #include <iostream>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <vector>
namespace { namespace {
@@ -215,6 +216,83 @@ void test_rendering()
check(exact_width, "wide Unicode input retains exact terminal-cell width"); 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 } // namespace
int main() int main()
@@ -225,6 +303,7 @@ int main()
test_all_day_exclusive_end(); test_all_day_exclusive_end();
test_unicode_backspace_navigation_and_cancel(); test_unicode_backspace_navigation_and_cancel();
test_rendering(); test_rendering();
test_zoned_edit_preserves_series_and_rejects_dst_gap();
if (failures != 0) { if (failures != 0) {
std::cerr << failures << " editor test(s) failed\n"; std::cerr << failures << " editor test(s) failed\n";
return EXIT_FAILURE; return EXIT_FAILURE;

View File

@@ -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"); 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<nocal::TimePoint>{
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<weekday>{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<int>{1, -1},
"monthly RRULE fields differ");
const auto& yearly = *loaded.events[3].recurrence;
require(yearly.frequency == nocal::RecurrenceFrequency::yearly
&& yearly.by_month == std::vector<unsigned>{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<std::string> 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<std::pair<std::string, std::string>> 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<nocal::Event>{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<nocal::Event>{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<nocal::Event>{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 } // namespace
int main() { int main() {
@@ -436,6 +689,10 @@ int main() {
test_lock_failure_preserves_existing_bytes(root); test_lock_failure_preserves_existing_bytes(root);
test_rename_failure_cleans_temporary_file(root); test_rename_failure_cleans_temporary_file(root);
test_unsupported_data_is_not_rewrite_safe(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::filesystem::remove_all(root, ignored);
std::cout << "ics_tests: ok\n"; std::cout << "ics_tests: ok\n";
return 0; return 0;

View File

@@ -2,9 +2,12 @@
#include "nocal/tui/Screen.hpp" #include "nocal/tui/Screen.hpp"
#include "nocal/tui/TuiApp.hpp" #include "nocal/tui/TuiApp.hpp"
#include <algorithm>
#include <chrono> #include <chrono>
#include <cstdlib> #include <cstdlib>
#include <ctime>
#include <iostream> #include <iostream>
#include <optional>
#include <span> #include <span>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
@@ -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; 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) bool same_event(const nocal::Event& left, const nocal::Event& right)
{ {
return left.uid == right.uid && left.title == right.title && left.start == right.start && return left.uid == right.uid && left.title == right.title && left.start == right.start &&
left.end == right.end && left.all_day == right.all_day && left.end == right.end && left.all_day == right.all_day &&
left.location == right.location && left.description == right.description && 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) void replace_editor_title(nocal::tui::TuiApp& app, const std::string_view title)
@@ -564,7 +575,11 @@ void test_focused_line_rendering()
.description = {}, .description = {},
.detail_title = "Plain item", .detail_title = "Plain item",
.detail_all_day = false, .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", {.id = "focused",
.title = "Focused item", .title = "Focused item",
.day = day, .day = day,
@@ -577,7 +592,11 @@ void test_focused_line_rendering()
.description = {}, .description = {},
.detail_title = "Focused item", .detail_title = "Focused item",
.detail_all_day = false, .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); 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"); "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<nocal::tui::CalendarItem> 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<nocal::Event> 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<nocal::Event> 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<std::string> 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<nocal::Event> 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 } // namespace
int main() int main()
@@ -721,6 +898,9 @@ int main()
test_timed_event_details(); test_timed_event_details();
test_all_day_and_multiday_details(); test_all_day_and_multiday_details();
test_narrow_detail_wrapping_without_ansi(); 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) { if (failures != 0) {
std::cerr << failures << " TUI focus test(s) failed\n"; std::cerr << failures << " TUI focus test(s) failed\n";
return EXIT_FAILURE; return EXIT_FAILURE;

View File

@@ -44,12 +44,16 @@ void test_full_month_render()
.time_of_day = hours{9} + minutes{30}, .all_day = false, .time_of_day = hours{9} + minutes{30}, .all_day = false,
.end_time_of_day = std::nullopt, .start_day = {}, .end_day = {}, .end_time_of_day = std::nullopt, .start_day = {}, .end_day = {},
.location = {}, .description = {}, .detail_title = {}, .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}, {.id = "2", .title = "Release day", .day = year{2026} / July / day{17},
.time_of_day = std::nullopt, .all_day = true, .time_of_day = std::nullopt, .all_day = true,
.end_time_of_day = std::nullopt, .start_day = {}, .end_day = {}, .end_time_of_day = std::nullopt, .start_day = {}, .end_day = {},
.location = {}, .description = {}, .detail_title = {}, .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); const auto frame = nocal::tui::render_month(items, state);