feat: support safe recurrence additions

Parse, persist, expand, and round-trip compatible RDATE values while preserving whole-series TUI behavior and conservative rewrite guards.

Verified: normal, warning-as-error, and ASan/UBSan builds pass all 9 suites; storage failure paths preserve calendar and backup bytes; live PTY restores terminal state.
This commit is contained in:
2026-07-18 10:14:53 +01:00
parent 989332ef9a
commit cfca6ab6d0
16 changed files with 498 additions and 69 deletions

View File

@@ -75,8 +75,10 @@ source that fails event validation or that Nocal cannot safely round-trip.
Nocal expands and safely round-trips a focused iCalendar recurrence subset: Nocal expands and safely round-trips a focused iCalendar recurrence subset:
daily, weekly, monthly, and yearly rules with interval, count or until, common daily, weekly, monthly, and yearly rules with interval, count or until, common
weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA weekly/monthly/yearly selectors, compatible DATE or DATE-TIME `RDATE` additions,
`TZID` names. Recurring instances are individually navigable; edit and delete `EXDATE`, floating times, UTC, and system IANA `TZID` names. Occurrences merge
`DTSTART`, rule expansion, and `RDATE`, remove duplicate starts, and then apply
`EXDATE`. Recurring instances are individually navigable; edit and delete
clearly operate on the entire series. clearly operate on the entire series.
Search expands recurring matches within five years on either side of the Search expands recurring matches within five years on either side of the
@@ -88,8 +90,9 @@ agenda, appointment focus, and search. Visibility and agenda position are
session-only presentation state; saves always retain events from hidden session-only presentation state; saves always retain events from hidden
calendars. calendars.
Calendars containing features this version cannot preserve—such as `RDATE`, Calendars containing features this version cannot preserve—such as `RDATE`
detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE` periods or values incompatible with the event's `DTSTART` time basis or `TZID`,
detached `RECURRENCE-ID` overrides, ordinal `BYDAY`, embedded `VTIMEZONE`
definitions, alarms, or attendees—remain browsable but read-only. This guard definitions, alarms, or attendees—remain browsable but read-only. This guard
prevents a local edit from silently discarding unknown data. prevents a local edit from silently discarding unknown data.

View File

@@ -36,8 +36,11 @@ requested time window and return values that point back to their source series,
preserving series-level storage and mutation semantics. The supported rule preserving series-level storage and mutation semantics. The supported rule
surface is deliberately bounded to daily/weekly/monthly/yearly frequency, surface is deliberately bounded to daily/weekly/monthly/yearly frequency,
interval, count or until, weekly `BYDAY`, monthly `BYMONTHDAY`, yearly interval, count or until, weekly `BYDAY`, monthly `BYMONTHDAY`, yearly
`BYMONTH`, and `EXDATE`. Invalid metadata retains at most the base event rather `BYMONTH`, compatible DATE or DATE-TIME `RDATE`, and `EXDATE`. An occurrence set
than crashing a render. merges `DTSTART`, rule expansion, and additional `RDATE` starts, deduplicates by
start, and then subtracts exclusions. Mutations continue to address the source
series as a whole. Invalid metadata retains at most the base event rather than
crashing a render.
Search matching is a pure domain predicate over normalized event text; the TUI Search matching is a pure domain predicate over normalized event text; the TUI
owns result presentation and occurrence identity. Recurring searches expand a owns result presentation and occurrence identity. Recurring searches expand a
@@ -107,12 +110,13 @@ combined with any of the other modes.
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
overrides, `RDATE`, custom `VTIMEZONE` definitions, alarms, attendees, unknown overrides, `RDATE` periods or values incompatible with `DTSTART`'s time basis or
properties, or malformed components. Supported recurrence rules, exclusions, `TZID`, custom `VTIMEZONE` definitions, alarms, attendees, unknown properties,
and system IANA time-zone identifiers round-trip semantically. Browsing remains or malformed components. Supported recurrence rules, compatible additional
available for unsupported files. This conservative boundary is more important starts, exclusions, and system IANA time-zone identifiers round-trip
than partial editing because a successful-looking edit must not erase unrelated semantically. Browsing remains available for unsupported files. This
calendar data. 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

@@ -89,22 +89,28 @@ read-only instead of being discarded. Saves reject external changes, retain a
last-known-good backup, and feed bounded session undo/redo history. Supported last-known-good backup, and feed bounded session undo/redo history. Supported
recurring instances appear throughout the month, preserve civil time across recurring instances appear throughout the month, preserve civil time across
daylight-saving changes, and expose their source zone and whole-series mutation daylight-saving changes, and expose their source zone and whole-series mutation
semantics in the reader. `/` searches occurrence-aware title, description, semantics in the reader. A supported recurrence set combines `DTSTART`, rule
location, and calendar-ID matches in a finite ten-year window centered on the expansion, and compatible DATE or DATE-TIME `RDATE` starts, deduplicates starts,
selected date. `c` toggles session-only calendar visibility consistently across and then removes `EXDATE` exclusions. `/` searches occurrence-aware title,
the grid, appointment focus, and search without filtering the model supplied to description, location, and calendar-ID matches in a finite ten-year window
persistence. `g` opens an occurrence-aware agenda at the selected month date; centered on the selected date. `c` toggles session-only calendar visibility
navigation is bounded to consecutive 42-day windows and `Enter` returns to the consistently across the grid, appointment focus, and search without filtering
exact occurrence in the month grid. `Esc` or `g` closes the agenda without the model supplied to persistence. `g` opens an occurrence-aware agenda at the
changing the month selection unless an occurrence was chosen. Hidden calendars selected month date; navigation is bounded to consecutive 42-day windows and
remain filtered there, while calendar selection and search can overlay the `Enter` returns to the exact occurrence in the month grid. `Esc` or `g` closes
agenda. Agenda navigation never persists state, and add/edit/delete remain in the agenda without changing the month selection unless an occurrence was
the month workflow. Week-start selection rotates only the month grid, header, chosen. Hidden calendars remain filtered there, while calendar selection and
and the visible range used by its month queries; storage, agenda windows, and search can overlay the agenda. Agenda navigation never persists state, and
add/edit/delete remain in the month workflow. Week-start selection rotates only
the month grid, header, and the visible range used by its month queries;
storage, agenda windows, and
recurrence rules are unchanged. The default remains Monday for backward recurrence rules are unchanged. The default remains Monday for backward
compatibility. The CLI accepts the exact lowercase values `monday`, `tuesday`, compatibility. The CLI accepts the exact lowercase values `monday`, `tuesday`,
`wednesday`, `thursday`, `friday`, `saturday`, and `sunday`. The next local-data `wednesday`, `thursday`, `friday`, `saturday`, and `sunday`. `RDATE` periods,
work adds advanced recurrence overrides and rule editing. values incompatible with the event's `DTSTART` time basis or `TZID`, detached
`RECURRENCE-ID` overrides, and embedded `VTIMEZONE` definitions remain
read-only. Remaining local-data work includes detached overrides and rule
editing.
## Local file interchange ## Local file interchange

View File

@@ -8,7 +8,8 @@
- 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 - Windowed recurrence expansion, compatible RDATE, EXDATE, IANA TZID, and
multi-day segment cues
- Occurrence-aware appointment search with exact result-to-grid navigation - Occurrence-aware appointment search with exact result-to-grid navigation
- Session calendar toggles shared by month rendering, focus, and search - Session calendar toggles shared by month rendering, focus, and search
- Read-only 42-day agenda with bounded recurrence and exact return navigation - Read-only 42-day agenda with bounded recurrence and exact return navigation
@@ -19,7 +20,8 @@
## 0.1 — complete local calendar ## 0.1 — complete local calendar
- Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support - Advanced recurrence rule editing and detached RECURRENCE-ID overrides
- Embedded VTIMEZONE support
- Screen-reader-friendly linear view and full Unicode display-width handling - Screen-reader-friendly linear view and full Unicode display-width handling
## 0.2 — durable cache and provider boundary ## 0.2 — durable cache and provider boundary

View File

@@ -39,6 +39,7 @@ struct Event {
TimeBasis time_basis{TimeBasis::utc}; TimeBasis time_basis{TimeBasis::utc};
std::string time_zone; std::string time_zone;
std::optional<RecurrenceRule> recurrence; std::optional<RecurrenceRule> recurrence;
std::vector<TimePoint> recurrence_additions;
std::vector<TimePoint> recurrence_exceptions; std::vector<TimePoint> recurrence_exceptions;
}; };
@@ -55,4 +56,9 @@ struct Calendar {
return event.end >= event.start; return event.end >= event.start;
} }
[[nodiscard]] constexpr bool is_recurring(const Event& event) noexcept
{
return event.recurrence.has_value() || !event.recurrence_additions.empty();
}
} // namespace nocal } // namespace nocal

View File

@@ -20,6 +20,7 @@ namespace {
left.time_basis == right.time_basis && left.time_basis == right.time_basis &&
left.time_zone == right.time_zone && left.time_zone == right.time_zone &&
left.recurrence == right.recurrence && left.recurrence == right.recurrence &&
left.recurrence_additions == right.recurrence_additions &&
left.recurrence_exceptions == right.recurrence_exceptions; left.recurrence_exceptions == right.recurrence_exceptions;
} }

View File

@@ -86,6 +86,24 @@ template <class TimePointType>
event.recurrence_exceptions.end(); event.recurrence_exceptions.end();
} }
void append_occurrence(EventOccurrences& result, const Event& event,
TimePoint start, TimePoint finish, std::size_t ordinal,
TimePoint begin, TimePoint end,
bool apply_exception = true)
{
if ((apply_exception && is_exception(event, start)) ||
!overlaps(start, finish, begin, end)) {
return;
}
const auto duplicate = std::find_if(
result.begin(), result.end(), [&event, start](const EventOccurrence& value) {
return value.source == &event && value.start == start;
});
if (duplicate == result.end()) {
result.push_back({&event, start, finish, ordinal});
}
}
[[nodiscard]] std::tm process_local_tm(std::time_t time) [[nodiscard]] std::tm process_local_tm(std::time_t time)
{ {
std::tm result{}; std::tm result{};
@@ -334,10 +352,8 @@ void append_candidate(EventOccurrences& result, const Event& event,
candidate_end = candidate_end =
project_to_system(event, *civil_end, projection.zone); project_to_system(event, *civil_end, projection.zone);
} }
if (!is_exception(event, candidate_start) && append_occurrence(result, event, candidate_start, candidate_end, ordinal,
overlaps(candidate_start, candidate_end, begin, end)) { begin, end);
result.push_back({&event, candidate_start, candidate_end, ordinal});
}
} catch (const std::exception&) { } catch (const std::exception&) {
// A bad zone, an out-of-range conversion, or a nonexistent civil time // A bad zone, an out-of-range conversion, or a nonexistent civil time
// invalidates only this generated occurrence. Queries remain noexcept // invalidates only this generated occurrence. Queries remain noexcept
@@ -345,6 +361,51 @@ void append_candidate(EventOccurrences& result, const Event& event,
} }
} }
void append_recurrence_additions(EventOccurrences& result, const Event& event,
const Projection& projection, TimePoint begin,
TimePoint end)
{
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;
}
for (std::size_t index = 0; index < event.recurrence_additions.size();
++index) {
try {
const auto candidate_start = event.recurrence_additions[index];
TimePoint candidate_end;
if (event.time_basis == TimeBasis::utc) {
const auto end_value = checked_add(candidate_start, *duration);
if (!end_value) {
continue;
}
candidate_end = *end_value;
} else {
const auto candidate_civil =
project_to_civil(event, candidate_start, projection.zone);
const auto civil_end = checked_add(candidate_civil, *duration);
if (!civil_end) {
continue;
}
candidate_end =
project_to_system(event, *civil_end, projection.zone);
}
const auto ordinal = index + 1U;
append_occurrence(result, event, candidate_start, candidate_end,
ordinal, begin, end);
} catch (const std::exception&) {
// An invalid zone, nonexistent civil end, or out-of-range value
// invalidates only this addition.
}
}
}
[[nodiscard]] std::chrono::sys_days recurrence_scan_end( [[nodiscard]] std::chrono::sys_days recurrence_scan_end(
const Event& event, const RecurrenceRule& rule, TimePoint query_end, const Event& event, const RecurrenceRule& rule, TimePoint query_end,
const Projection& projection) const Projection& projection)
@@ -564,12 +625,10 @@ void append_event_occurrences(EventOccurrences& result, const Event& event,
return; return;
} }
const bool recurring = event.recurrence.has_value(); const bool recurring = is_recurring(event);
if ((!recurring || !is_exception(event, event.start)) && append_occurrence(result, event, event.start, event.end, 0U, begin, end,
overlaps(event.start, event.end, begin, end)) { recurring);
result.push_back({&event, event.start, event.end, 0}); if (!recurring) {
}
if (!recurring || !valid_rule_shape(event, *event.recurrence)) {
return; return;
} }
@@ -578,20 +637,27 @@ void append_event_occurrences(EventOccurrences& result, const Event& event,
if (projection.end < projection.start) { if (projection.end < projection.start) {
return; return;
} }
switch (event.recurrence->frequency) { if (event.recurrence && valid_rule_shape(event, *event.recurrence)) {
case RecurrenceFrequency::daily: switch (event.recurrence->frequency) {
expand_daily(result, event, *event.recurrence, projection, begin, end); case RecurrenceFrequency::daily:
break; expand_daily(result, event, *event.recurrence, projection, begin,
case RecurrenceFrequency::weekly: end);
expand_weekly(result, event, *event.recurrence, projection, begin, end); break;
break; case RecurrenceFrequency::weekly:
case RecurrenceFrequency::monthly: expand_weekly(result, event, *event.recurrence, projection, begin,
expand_monthly(result, event, *event.recurrence, projection, begin, end); end);
break; break;
case RecurrenceFrequency::yearly: case RecurrenceFrequency::monthly:
expand_yearly(result, event, *event.recurrence, projection, begin, end); expand_monthly(result, event, *event.recurrence, projection,
break; begin, end);
break;
case RecurrenceFrequency::yearly:
expand_yearly(result, event, *event.recurrence, projection, begin,
end);
break;
}
} }
append_recurrence_additions(result, event, projection, begin, end);
} catch (const std::exception&) { } catch (const std::exception&) {
// Invalid zones and out-of-range civil projections retain DTSTART only. // Invalid zones and out-of-range civil projections retain DTSTART only.
} }

View File

@@ -51,6 +51,7 @@ struct PendingEvent {
std::optional<ParsedTime> start; std::optional<ParsedTime> start;
std::optional<ParsedTime> end; std::optional<ParsedTime> end;
std::vector<PendingProperty> recurrence_rules; std::vector<PendingProperty> recurrence_rules;
std::vector<PendingProperty> recurrence_dates;
std::vector<PendingProperty> exception_dates; std::vector<PendingProperty> exception_dates;
}; };
@@ -724,6 +725,15 @@ void serialize_calendar(std::ostream& output, std::span<const Event> events) {
if (event.recurrence) { if (event.recurrence) {
write_folded(output, "RRULE:" + serialize_recurrence_rule(event, *event.recurrence)); write_folded(output, "RRULE:" + serialize_recurrence_rule(event, *event.recurrence));
} }
if (!event.recurrence_additions.empty()) {
const std::string parameters = time_property_parameters(event);
const std::string values = join_values(event.recurrence_additions,
[&event](TimePoint addition) {
return event.all_day ? format_date(addition)
: format_event_time(event, addition);
});
write_folded(output, "RDATE" + parameters + ":" + values);
}
if (!event.recurrence_exceptions.empty()) { if (!event.recurrence_exceptions.empty()) {
const std::string parameters = time_property_parameters(event); const std::string parameters = time_property_parameters(event);
const std::string values = join_values(event.recurrence_exceptions, const std::string values = join_values(event.recurrence_exceptions,
@@ -1056,9 +1066,17 @@ void validate_event_for_save(const Event& event) {
throw std::invalid_argument( throw std::invalid_argument(
"cannot save event '" + event.uid + "' with an invalid interval"); "cannot save event '" + event.uid + "' with an invalid interval");
} }
if (!event.recurrence && !event.recurrence_exceptions.empty()) { if (!is_recurring(event) && !event.recurrence_exceptions.empty()) {
throw std::invalid_argument( throw std::invalid_argument(
"cannot save event '" + event.uid + "' with EXDATE values but no recurrence rule"); "cannot save event '" + event.uid
+ "' with EXDATE values but no recurrence set");
}
std::vector<TimePoint> unique_additions = event.recurrence_additions;
std::sort(unique_additions.begin(), unique_additions.end());
if (std::adjacent_find(unique_additions.begin(), unique_additions.end())
!= unique_additions.end()) {
throw std::invalid_argument(
"cannot save event '" + event.uid + "' with duplicate RDATE values");
} }
std::vector<TimePoint> unique_exceptions = event.recurrence_exceptions; std::vector<TimePoint> unique_exceptions = event.recurrence_exceptions;
std::sort(unique_exceptions.begin(), unique_exceptions.end()); std::sort(unique_exceptions.begin(), unique_exceptions.end());
@@ -1221,6 +1239,29 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
} }
} }
} }
for (const PendingProperty& property : pending->recurrence_dates) {
for (const std::string_view addition_value : split(property.value, ',')) {
std::string warning;
const auto parsed = parse_time(
property.parameters, addition_value, warning);
if (!parsed || !same_time_kind(*pending->start, *parsed)) {
add_warning(result, property.line, parsed
? "RDATE 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_additions.begin(),
event.recurrence_additions.end(), parsed->value)
== event.recurrence_additions.end()) {
event.recurrence_additions.push_back(parsed->value);
}
}
}
for (const PendingProperty& property : pending->exception_dates) { for (const PendingProperty& property : pending->exception_dates) {
for (const std::string_view exception_value : split(property.value, ',')) { for (const std::string_view exception_value : split(property.value, ',')) {
std::string warning; std::string warning;
@@ -1244,9 +1285,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
} }
} }
} }
if (!event.recurrence && !event.recurrence_exceptions.empty()) { if (!is_recurring(event) && !event.recurrence_exceptions.empty()) {
add_warning(result, event_start_line, add_warning(result, event_start_line,
"EXDATE without a supported RRULE cannot be edited safely"); "EXDATE without a supported RRULE or RDATE cannot be edited safely");
mark_unsafe_to_rewrite(result); mark_unsafe_to_rewrite(result);
} }
result.events.push_back(std::move(event)); result.events.push_back(std::move(event));
@@ -1275,7 +1316,7 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
continue; continue;
} }
const bool time_property = name == "DTSTART" || name == "DTEND" const bool time_property = name == "DTSTART" || name == "DTEND"
|| name == "EXDATE"; || name == "RDATE" || name == "EXDATE";
const bool recurrence_property = name == "RRULE"; 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"
@@ -1305,6 +1346,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
} else if (name == "RRULE") { } else if (name == "RRULE") {
pending->recurrence_rules.push_back(PendingProperty{ pending->recurrence_rules.push_back(PendingProperty{
std::string(parameters), std::string(value), index + 1}); std::string(parameters), std::string(value), index + 1});
} else if (name == "RDATE") {
pending->recurrence_dates.push_back(PendingProperty{
std::string(parameters), std::string(value), index + 1});
} else if (name == "EXDATE") { } else if (name == "EXDATE") {
pending->exception_dates.push_back(PendingProperty{ pending->exception_dates.push_back(PendingProperty{
std::string(parameters), std::string(value), index + 1}); std::string(parameters), std::string(value), index + 1});

View File

@@ -374,7 +374,7 @@ 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) { if (is_recurring(candidate) && all_day_ != candidate.all_day) {
return fail(EditorField::all_day, return fail(EditorField::all_day,
"Timed/all-day conversion is unavailable for recurring series."); "Timed/all-day conversion is unavailable for recurring series.");
} }
@@ -435,7 +435,7 @@ 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);
const auto heading = editing_ && original_.recurrence const auto heading = editing_ && is_recurring(original_)
? " NOCAL · EDIT ENTIRE RECURRING SERIES" ? " NOCAL · EDIT ENTIRE RECURRING SERIES"
: editing_ ? " NOCAL · EDIT APPOINTMENT" : editing_ ? " NOCAL · EDIT APPOINTMENT"
: " NOCAL · NEW APPOINTMENT"; : " NOCAL · NEW APPOINTMENT";

View File

@@ -802,7 +802,7 @@ std::string occurrence_focus_id(const std::span<const Event> events,
const EventOccurrence& occurrence) { const EventOccurrence& occurrence) {
const auto index = event_source_index(events, occurrence.source); const auto index = event_source_index(events, occurrence.source);
auto identity = source_focus_id(events, index); auto identity = source_focus_id(events, index);
if (occurrence.source->recurrence) { if (is_recurring(*occurrence.source)) {
const auto tick = occurrence.start.time_since_epoch().count(); const auto tick = occurrence.start.time_since_epoch().count();
identity += "@occ:" + std::to_string(tick); identity += "@occ:" + std::to_string(tick);
} }
@@ -1040,7 +1040,7 @@ std::string render_month(const std::span<const Event> events,
? std::nullopt ? std::nullopt
: std::optional{local_start.time_of_day}, : std::optional{local_start.time_of_day},
.segment = segment, .segment = segment,
.recurring = event.recurrence.has_value(), .recurring = is_recurring(event),
.recurrence_summary = event.recurrence .recurrence_summary = event.recurrence
? recurrence_summary(*event.recurrence) : std::string{}, ? recurrence_summary(*event.recurrence) : std::string{},
.source_time_zone = event.time_basis == TimeBasis::zoned .source_time_zone = event.time_basis == TimeBasis::zoned

View File

@@ -233,7 +233,7 @@ void TuiApp::refresh_agenda() {
.all_day = occurrence.source->all_day || continuation, .all_day = occurrence.source->all_day || continuation,
.location = occurrence.source->location, .location = occurrence.source->location,
.calendar_id = occurrence.source->calendar_id, .calendar_id = occurrence.source->calendar_id,
.recurring = occurrence.source->recurrence.has_value(), .recurring = is_recurring(*occurrence.source),
}); });
} }
std::stable_sort(state_.agenda_items.begin(), state_.agenda_items.end(), std::stable_sort(state_.agenda_items.begin(), state_.agenda_items.end(),
@@ -463,7 +463,7 @@ void TuiApp::submit_editor() {
std::span<const Calendar>{calendars_})) { std::span<const Calendar>{calendars_})) {
state_.focused_event_id.reset(); state_.focused_event_id.reset();
state_.show_event_details = false; state_.show_event_details = false;
record_mutation(editing && events_[changed_index].recurrence record_mutation(editing && is_recurring(events_[changed_index])
? "edit recurring series" ? "edit recurring series"
: editing ? "edit appointment" : "add appointment"); : editing ? "edit appointment" : "add appointment");
set_notification(std::move(success)); set_notification(std::move(success));
@@ -489,10 +489,10 @@ void TuiApp::submit_editor() {
: std::optional{occurrence_focus_id(event_span, *first)}; : std::optional{occurrence_focus_id(event_span, *first)};
} }
state_.show_event_details = false; state_.show_event_details = false;
record_mutation(editing && events_[changed_index].recurrence record_mutation(editing && is_recurring(events_[changed_index])
? "edit recurring series" ? "edit recurring series"
: editing ? "edit appointment" : "add appointment"); : editing ? "edit appointment" : "add appointment");
if (editing && events_[changed_index].recurrence) { if (editing && is_recurring(events_[changed_index])) {
success = "Recurring series updated."; success = "Recurring series updated.";
} }
set_notification(std::move(success)); set_notification(std::move(success));
@@ -509,7 +509,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(); const bool recurring = is_recurring(events_[*index]);
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);
@@ -574,7 +574,7 @@ void TuiApp::submit_search() {
: std::optional{local_time_of_day(occurrence.start)}, : std::optional{local_time_of_day(occurrence.start)},
.all_day = occurrence.source->all_day, .all_day = occurrence.source->all_day,
.location = occurrence.source->location, .location = occurrence.source->location,
.recurring = occurrence.source->recurrence.has_value(), .recurring = is_recurring(*occurrence.source),
}); });
} }
std::stable_sort(state_.search_results.begin(), state_.search_results.end(), std::stable_sort(state_.search_results.begin(), state_.search_results.end(),

View File

@@ -65,6 +65,7 @@ void check_invalid_argument(Function&& function, const std::string_view message)
recurrence.by_month_day = {1, -1}; recurrence.by_month_day = {1, -1};
recurrence.by_month = {1, 7}; recurrence.by_month = {1, 7};
event.recurrence = std::move(recurrence); event.recurrence = std::move(recurrence);
event.recurrence_additions = {at(20)};
event.recurrence_exceptions = {at(16), at(18)}; event.recurrence_exceptions = {at(16), at(18)};
return event; return event;
} }
@@ -79,6 +80,7 @@ void check_invalid_argument(Function&& function, const std::string_view message)
left.time_basis == right.time_basis && left.time_basis == right.time_basis &&
left.time_zone == right.time_zone && left.time_zone == right.time_zone &&
left.recurrence == right.recurrence && left.recurrence == right.recurrence &&
left.recurrence_additions == right.recurrence_additions &&
left.recurrence_exceptions == right.recurrence_exceptions; left.recurrence_exceptions == right.recurrence_exceptions;
} }
@@ -163,6 +165,8 @@ void test_every_event_field_participates_in_collision_equality()
{"time basis", [](auto& event) { event.time_basis = nocal::TimeBasis::utc; }}, {"time basis", [](auto& event) { event.time_basis = nocal::TimeBasis::utc; }},
{"time zone", [](auto& event) { event.time_zone = "America/New_York"; }}, {"time zone", [](auto& event) { event.time_zone = "America/New_York"; }},
{"recurrence", [](auto& event) { event.recurrence->interval += 1; }}, {"recurrence", [](auto& event) { event.recurrence->interval += 1; }},
{"recurrence additions",
[](auto& event) { event.recurrence_additions.push_back(at(19)); }},
{"recurrence exclusions", {"recurrence exclusions",
[](auto& event) { event.recurrence_exceptions.push_back(at(19)); }}, [](auto& event) { event.recurrence_exceptions.push_back(at(19)); }},
}; };

View File

@@ -404,6 +404,90 @@ void test_weekly_count_and_exdates()
"weekly BYDAY expansion is chronological across week boundaries"); "weekly BYDAY expansion is chronological across week boundaries");
} }
void test_rdate_only_query_boundaries_and_duration()
{
using namespace std::chrono;
const nocal::Date july_10{2026y / July / 10d};
const nocal::Date july_11{2026y / July / 11d};
nocal::Event added;
added.uid = "rdate-only";
added.title = added.uid;
added.start = utc_time(2026y / July / 1d, 9);
added.end = added.start + minutes{90};
added.recurrence_additions = {utc_time(july_10, 9),
utc_time(july_11, 9)};
check(nocal::is_recurring(added),
"an RDATE-only event is part of a recurrence set");
const std::vector<nocal::Event> events{added};
const auto occurrences = nocal::occurrences_overlapping(
events, utc_time(july_10, 10), utc_time(july_11, 9));
check(occurrences.size() == 1 &&
occurrences.front().start == utc_time(july_10, 9),
"RDATE overlap includes a carrying occurrence and excludes a start at the query end");
check(occurrences.size() == 1 &&
occurrences.front().end == utc_time(july_10, 10, 30),
"UTC RDATE occurrences preserve DTSTART's elapsed duration");
}
void test_rdate_merge_deduplication_and_exdates()
{
using namespace std::chrono;
const nocal::Date july_1{2026y / July / 1d};
nocal::RecurrenceRule daily;
daily.frequency = nocal::RecurrenceFrequency::daily;
daily.count = 3U;
auto merged = utc_recurring("rdate-merge", july_1, 9, july_1, 10, daily);
merged.recurrence_additions = {
merged.start, utc_time(2026y / July / 2d, 9),
utc_time(2026y / July / 4d, 9), utc_time(2026y / July / 4d, 9)};
const auto all = nocal::occurrences_overlapping(
std::span<const nocal::Event>{&merged, 1}, utc_time(july_1, 0),
utc_time(2026y / July / 5d, 0));
check(all.size() == 4,
"DTSTART, RRULE, and RDATE form a set with duplicate starts collapsed");
check(all.size() == 4 && all[0].ordinal == 0U && all[1].ordinal == 1U &&
all[2].ordinal == 2U &&
all[3].start == utc_time(2026y / July / 4d, 9),
"RDATE merging preserves existing RRULE ordinals");
merged.recurrence_exceptions = {merged.start,
utc_time(2026y / July / 4d, 9)};
const auto filtered = nocal::occurrences_overlapping(
std::span<const nocal::Event>{&merged, 1}, utc_time(july_1, 0),
utc_time(2026y / July / 5d, 0));
check(filtered.size() == 2 &&
filtered[0].start == utc_time(2026y / July / 2d, 9) &&
filtered[1].start == utc_time(2026y / July / 3d, 9),
"EXDATE subtracts both DTSTART and RDATE-only starts from the recurrence set");
}
void test_zoned_rdate_wall_duration()
{
using namespace std::chrono;
nocal::Event added;
added.uid = "zoned-rdate";
added.title = added.uid;
added.time_basis = nocal::TimeBasis::zoned;
added.time_zone = "Europe/London";
added.start = zoned_time(added.time_zone, 2026y / March / 28d, 0, 30);
added.end = zoned_time(added.time_zone, 2026y / March / 28d, 2, 30);
added.recurrence_additions = {
zoned_time(added.time_zone, 2026y / March / 29d, 0, 30)};
const auto occurrences = nocal::occurrences_overlapping(
std::span<const nocal::Event>{&added, 1},
utc_time(2026y / March / 29d, 0), utc_time(2026y / March / 30d, 0));
check(occurrences.size() == 1 &&
occurrences[0].start == utc_time(2026y / March / 29d, 0, 30) &&
occurrences[0].end == utc_time(2026y / March / 29d, 1, 30),
"zoned RDATE preserves a two-hour wall duration across the DST gap");
check(occurrences.size() == 1 &&
occurrences[0].end - occurrences[0].start == hours{1},
"zoned RDATE projects its wall-clock end through the DST transition");
}
void test_monthly_and_yearly_selectors() void test_monthly_and_yearly_selectors()
{ {
using namespace std::chrono; using namespace std::chrono;
@@ -482,6 +566,14 @@ void test_occurrence_overlap_sorting_and_invalid_rules()
occurrences[3].source->uid == "later", occurrences[3].source->uid == "later",
"equal occurrence times sort by title and UID"); "equal occurrence times sort by title and UID");
invalid.recurrence_additions = {utc_time(day, 15)};
const auto invalid_rule_addition = nocal::occurrences_overlapping(
std::span<const nocal::Event>{&invalid, 1}, utc_time(day, 0),
utc_time(2026y / July / 18d, 0));
check(invalid_rule_addition.size() == 2 &&
invalid_rule_addition[1].start == utc_time(day, 15),
"a malformed RRULE does not suppress an independently valid RDATE");
invalid.time_basis = nocal::TimeBasis::zoned; invalid.time_basis = nocal::TimeBasis::zoned;
invalid.time_zone = "Not/A_Real_Zone"; invalid.time_zone = "Not/A_Real_Zone";
invalid.recurrence->interval = 1U; invalid.recurrence->interval = 1U;
@@ -504,6 +596,9 @@ int main()
test_daily_dst_recurrence(); test_daily_dst_recurrence();
test_floating_dst_and_until(); test_floating_dst_and_until();
test_weekly_count_and_exdates(); test_weekly_count_and_exdates();
test_rdate_only_query_boundaries_and_duration();
test_rdate_merge_deduplication_and_exdates();
test_zoned_rdate_wall_duration();
test_monthly_and_yearly_selectors(); test_monthly_and_yearly_selectors();
test_occurrence_overlap_sorting_and_invalid_rules(); test_occurrence_overlap_sorting_and_invalid_rules();
if (failures != 0) { if (failures != 0) {

View File

@@ -246,6 +246,8 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap()
}; };
event.recurrence_exceptions.push_back( event.recurrence_exceptions.push_back(
zoned_time(event.time_zone, nocal::Date{2026y / March / 9d}, 2, 30)); zoned_time(event.time_zone, nocal::Date{2026y / March / 9d}, 2, 30));
event.recurrence_additions.push_back(
zoned_time(event.time_zone, nocal::Date{2026y / March / 12d}, 2, 30));
nocal::tui::EventEditor editor{event}; nocal::tui::EventEditor editor{event};
check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-03-07" && check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-03-07" &&
@@ -262,8 +264,9 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap()
check(editor.result().time_basis == nocal::TimeBasis::zoned && check(editor.result().time_basis == nocal::TimeBasis::zoned &&
editor.result().time_zone == "America/New_York" && editor.result().time_zone == "America/New_York" &&
editor.result().recurrence == event.recurrence && editor.result().recurrence == event.recurrence &&
editor.result().recurrence_additions == event.recurrence_additions &&
editor.result().recurrence_exceptions == event.recurrence_exceptions, editor.result().recurrence_exceptions == event.recurrence_exceptions,
"editing preserves time basis, TZID, recurrence, and exceptions"); "editing preserves time basis, TZID, recurrence additions, and exceptions");
nocal::tui::EventEditor series_kind_editor{event}; nocal::tui::EventEditor series_kind_editor{event};
focus(series_kind_editor, nocal::tui::EditorField::all_day); focus(series_kind_editor, nocal::tui::EditorField::all_day);
@@ -273,8 +276,21 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap()
series_kind_editor.error().find("recurring series") != std::string_view::npos, series_kind_editor.error().find("recurring series") != std::string_view::npos,
"a recurring series rejects timed/all-day conversion to preserve its exceptions"); "a recurring series rejects timed/all-day conversion to preserve its exceptions");
auto rdate_only = event;
rdate_only.recurrence.reset();
nocal::tui::EventEditor rdate_editor{rdate_only};
check(rdate_editor.render(80, 18, false).find("EDIT ENTIRE RECURRING SERIES")
!= std::string::npos,
"an RDATE-only event is presented as a recurring series");
focus(rdate_editor, nocal::tui::EditorField::all_day);
(void)rdate_editor.handle_key(" ");
check(rdate_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
rdate_editor.error().find("recurring series") != std::string_view::npos,
"an RDATE-only series rejects timed/all-day conversion");
auto one_off = event; auto one_off = event;
one_off.recurrence.reset(); one_off.recurrence.reset();
one_off.recurrence_additions.clear();
one_off.recurrence_exceptions.clear(); one_off.recurrence_exceptions.clear();
nocal::tui::EventEditor all_day_editor{one_off}; nocal::tui::EventEditor all_day_editor{one_off};
focus(all_day_editor, nocal::tui::EditorField::all_day); focus(all_day_editor, nocal::tui::EditorField::all_day);

View File

@@ -473,6 +473,8 @@ void test_time_basis_round_trips_and_dst(const std::filesystem::path& root) {
&& reloaded.events[index].time_basis == loaded.events[index].time_basis && reloaded.events[index].time_basis == loaded.events[index].time_basis
&& reloaded.events[index].time_zone == loaded.events[index].time_zone && reloaded.events[index].time_zone == loaded.events[index].time_zone
&& reloaded.events[index].recurrence == loaded.events[index].recurrence && reloaded.events[index].recurrence == loaded.events[index].recurrence
&& reloaded.events[index].recurrence_additions
== loaded.events[index].recurrence_additions
&& reloaded.events[index].recurrence_exceptions && reloaded.events[index].recurrence_exceptions
== loaded.events[index].recurrence_exceptions, == loaded.events[index].recurrence_exceptions,
"time-basis semantic round trip differs"); "time-basis semantic round trip differs");
@@ -541,12 +543,115 @@ void test_supported_recurrence_and_exdates(const std::filesystem::path& root) {
"recurrence semantic round-trip event count differs"); "recurrence semantic round-trip event count differs");
for (std::size_t index = 0; index < loaded.events.size(); ++index) { for (std::size_t index = 0; index < loaded.events.size(); ++index) {
require(reloaded.events[index].recurrence == loaded.events[index].recurrence require(reloaded.events[index].recurrence == loaded.events[index].recurrence
&& reloaded.events[index].recurrence_additions
== loaded.events[index].recurrence_additions
&& reloaded.events[index].recurrence_exceptions && reloaded.events[index].recurrence_exceptions
== loaded.events[index].recurrence_exceptions, == loaded.events[index].recurrence_exceptions,
"recurrence semantic round trip differs"); "recurrence semantic round trip differs");
} }
} }
void test_supported_rdates(const std::filesystem::path& root) {
const auto path = root / "rdates.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:utc-rdate\r\nSUMMARY:UTC additions\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
"RDATE:20260718T090000Z,20260720T090000Z\r\n"
"RDATE:20260722T090000Z\r\nEXDATE:20260720T090000Z\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:floating-rdate\r\nSUMMARY:Floating additions\r\n"
"DTSTART:20260717T101500\r\nDTEND:20260717T111500\r\n"
"RDATE:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:zoned-rdate\r\nSUMMARY:Zoned additions\r\n"
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
"DTEND;TZID=Europe/London:20260717T111500\r\n"
"RDATE;TZID=Europe/London:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:all-day-rdate\r\nSUMMARY:All-day additions\r\n"
"DTSTART;VALUE=DATE:20260717\r\nDTEND;VALUE=DATE:20260718\r\n"
"RDATE;VALUE=DATE:20260719,20260721\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:mixed-rdate\r\nSUMMARY:Rule and additions\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
"RRULE:FREQ=DAILY;COUNT=3\r\nRDATE:20260725T090000Z,20260727T090000Z\r\n"
"EXDATE:20260718T090000Z,20260725T090000Z\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 RDATE values were not rewrite-safe");
require(loaded.events.size() == 5, "RDATE fixture event count differs");
require(!loaded.events[0].recurrence.has_value(),
"RDATE-only event acquired an RRULE");
require(loaded.events[0].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 9h,
sys_days{year{2026}/July/20} + 9h,
sys_days{year{2026}/July/22} + 9h},
"multiple or comma-separated UTC RDATE values were not collected");
require(loaded.events[0].recurrence_exceptions
== std::vector<nocal::TimePoint>{sys_days{year{2026}/July/20} + 9h},
"RDATE-only EXDATE value was not retained");
require(loaded.events[1].time_basis == nocal::TimeBasis::floating
&& loaded.events[1].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 10h + 15min,
sys_days{year{2026}/July/19} + 10h + 15min},
"floating RDATE values did not preserve their time basis");
require(loaded.events[2].time_basis == nocal::TimeBasis::zoned
&& loaded.events[2].time_zone == "Europe/London"
&& loaded.events[2].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 9h + 15min,
sys_days{year{2026}/July/19} + 9h + 15min},
"zoned RDATE values were not converted with DTSTART's TZID");
require(loaded.events[3].all_day
&& loaded.events[3].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/19}, sys_days{year{2026}/July/21}},
"all-day RDATE values were not parsed as DATE values");
require(loaded.events[4].recurrence.has_value()
&& loaded.events[4].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/25} + 9h,
sys_days{year{2026}/July/27} + 9h}
&& loaded.events[4].recurrence_exceptions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 9h,
sys_days{year{2026}/July/25} + 9h},
"mixed RRULE, RDATE, and EXDATE values differ");
const auto round_trip = root / "rdates-round-trip.ics";
nocal::storage::IcsStore::save(round_trip, loaded.events);
const std::string serialized = read_fixture(round_trip);
require(serialized.find(
"RDATE:20260718T090000Z,20260720T090000Z,20260722T090000Z")
!= std::string::npos,
"UTC RDATE values were not grouped on serialization");
require(serialized.find("RDATE:20260718T101500,20260719T101500")
!= std::string::npos,
"floating RDATE values were not serialized without a suffix");
require(serialized.find(
"RDATE;TZID=Europe/London:20260718T101500,20260719T101500")
!= std::string::npos,
"zoned RDATE values were not serialized with TZID");
require(serialized.find("RDATE;VALUE=DATE:20260719,20260721")
!= std::string::npos,
"all-day RDATE values were not serialized with VALUE=DATE");
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
require(reloaded.safe_to_rewrite && reloaded.warnings.empty(),
"serialized RDATE calendar did not reload safely");
require(reloaded.events.size() == loaded.events.size(),
"RDATE 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_additions
== loaded.events[index].recurrence_additions
&& reloaded.events[index].recurrence_exceptions
== loaded.events[index].recurrence_exceptions,
"RDATE semantic round trip differs");
}
}
void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) { void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) {
const std::vector<std::string> unsupported_properties{ const std::vector<std::string> unsupported_properties{
"RRULE:FREQ=WEEKLY;BYDAY=1MO", "RRULE:FREQ=WEEKLY;BYDAY=1MO",
@@ -554,7 +659,7 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat
"RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z", "RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z",
"RRULE:FREQ=HOURLY", "RRULE:FREQ=HOURLY",
"RRULE:FREQ=DAILY;WKST=MO", "RRULE:FREQ=DAILY;WKST=MO",
"RDATE:20260718T090000Z", "RDATE;VALUE=PERIOD:20260718T090000Z/PT1H",
"RECURRENCE-ID:20260718T090000Z"}; "RECURRENCE-ID:20260718T090000Z"};
for (std::size_t index = 0; index < unsupported_properties.size(); ++index) { for (std::size_t index = 0; index < unsupported_properties.size(); ++index) {
const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics"); const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics");
@@ -595,6 +700,37 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat
require(!nocal::storage::IcsStore::load(custom_zone).safe_to_rewrite, require(!nocal::storage::IcsStore::load(custom_zone).safe_to_rewrite,
"VTIMEZONE definition was considered rewrite-safe"); "VTIMEZONE definition was considered rewrite-safe");
struct InvalidRdate {
std::string start;
std::string end;
std::string property;
};
const std::vector<InvalidRdate> invalid_rdates{
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
"RDATE:20260718T090000"},
{"DTSTART;TZID=Europe/London:20260717T090000",
"DTEND;TZID=Europe/London:20260717T100000",
"RDATE;TZID=Europe/Paris:20260718T090000"},
{"DTSTART;VALUE=DATE:20260717", "DTEND;VALUE=DATE:20260718",
"RDATE:20260719T090000Z"},
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
"RDATE;TZID=Custom/Mars:20260718T090000"},
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
"RDATE:not-a-date"}};
for (std::size_t index = 0; index < invalid_rdates.size(); ++index) {
const auto path = root / ("invalid-rdate-" + std::to_string(index) + ".ics");
const auto& fixture = invalid_rdates[index];
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:invalid-rdate\r\n"
+ fixture.start + "\r\n" + fixture.end + "\r\n" + fixture.property
+ "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite && !loaded.warnings.empty(),
"invalid RDATE was considered rewrite-safe");
require(loaded.events.size() == 1 && loaded.events[0].recurrence_additions.empty(),
"invalid RDATE prevented base-event browsing or became an occurrence");
}
const std::vector<std::pair<std::string, std::string>> mismatched_ends{ const std::vector<std::pair<std::string, std::string>> mismatched_ends{
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000"}, {"DTSTART:20260717T090000Z", "DTEND:20260717T100000"},
{"DTSTART;TZID=Europe/London:20260717T090000", {"DTSTART;TZID=Europe/London:20260717T090000",
@@ -660,7 +796,20 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path&
} }
require(exdate_failed && read_fixture(path) == original require(exdate_failed && read_fixture(path) == original
&& read_fixture(backup) == original_backup, && read_fixture(backup) == original_backup,
"EXDATE-without-RRULE validation changed calendar or backup bytes"); "EXDATE-without-RRULE-or-RDATE validation changed calendar or backup bytes");
nocal::Event duplicate_rdate = event_named("rdate@example.test", "Duplicate RDATE");
duplicate_rdate.recurrence_additions = {
duplicate_rdate.start + 24h, duplicate_rdate.start + 24h};
bool rdate_failed = false;
try {
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{duplicate_rdate});
} catch (const std::invalid_argument&) {
rdate_failed = true;
}
require(rdate_failed && read_fixture(path) == original
&& read_fixture(backup) == original_backup,
"duplicate RDATE validation changed calendar or backup bytes");
require_no_temporary_files(path); require_no_temporary_files(path);
} }
@@ -691,6 +840,7 @@ int main() {
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_time_basis_round_trips_and_dst(root);
test_supported_recurrence_and_exdates(root); test_supported_recurrence_and_exdates(root);
test_supported_rdates(root);
test_unsupported_recurrence_and_zones_are_unsafe(root); test_unsupported_recurrence_and_zones_are_unsafe(root);
test_save_validation_preserves_existing_bytes(root); test_save_validation_preserves_existing_bytes(root);
std::filesystem::remove_all(root, ignored); std::filesystem::remove_all(root, ignored);

View File

@@ -72,6 +72,7 @@ bool same_event(const nocal::Event& left, const nocal::Event& right)
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.time_basis == right.time_basis && left.time_zone == right.time_zone &&
left.recurrence == right.recurrence && left.recurrence == right.recurrence &&
left.recurrence_additions == right.recurrence_additions &&
left.recurrence_exceptions == right.recurrence_exceptions; left.recurrence_exceptions == right.recurrence_exceptions;
} }
@@ -819,6 +820,36 @@ void test_recurring_occurrence_navigation_and_whole_series_crud()
"undo restores a deleted recurring series"); "undo restores a deleted recurring series");
} }
void test_rdate_only_occurrence_identity_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("rdate-only", "Extra date", previous, 9, 0,
previous, 10, 0);
series.recurrence_additions = {
nocal::make_local_time(selected, 9, 0),
};
std::vector<nocal::Event> events{series};
nocal::tui::TuiApp app{events, -1, -1};
app.dispatch(nocal::tui::Action::next_event);
check(focus_starts_with(app, "rdate-only@occ:"),
"an RDATE-only occurrence has a source-and-start focus identity");
app.dispatch(nocal::tui::Action::edit_event);
replace_editor_title(app, "Edited extra-date series");
app.handle_input("\x13");
check(events.front().title == "Edited extra-date series" &&
events.front().recurrence_additions == series.recurrence_additions &&
app.state().notification == "Recurring series updated.",
"editing an RDATE-only occurrence updates and preserves the whole series");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("y");
check(events.empty() && app.state().notification == "Recurring series deleted.",
"deleting an RDATE-only occurrence removes its entire source series");
}
void test_recurrence_grid_expansion_and_source_zone_display() void test_recurrence_grid_expansion_and_source_zone_display()
{ {
using namespace std::chrono; using namespace std::chrono;
@@ -1241,6 +1272,7 @@ int main()
test_narrow_detail_wrapping_without_ansi(); test_narrow_detail_wrapping_without_ansi();
test_multiday_segment_labels_and_series_messaging(); test_multiday_segment_labels_and_series_messaging();
test_recurring_occurrence_navigation_and_whole_series_crud(); test_recurring_occurrence_navigation_and_whole_series_crud();
test_rdate_only_occurrence_identity_and_whole_series_crud();
test_recurrence_grid_expansion_and_source_zone_display(); test_recurrence_grid_expansion_and_source_zone_display();
test_search_prompt_results_and_occurrence_jump(); test_search_prompt_results_and_occurrence_jump();
test_calendar_visibility_filters_without_partial_saves(); test_calendar_visibility_filters_without_partial_saves();