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:
11
README.md
11
README.md
@@ -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:
|
||||
daily, weekly, monthly, and yearly rules with interval, count or until, common
|
||||
weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA
|
||||
`TZID` names. Recurring instances are individually navigable; edit and delete
|
||||
weekly/monthly/yearly selectors, compatible DATE or DATE-TIME `RDATE` additions,
|
||||
`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.
|
||||
|
||||
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
|
||||
calendars.
|
||||
|
||||
Calendars containing features this version cannot preserve—such as `RDATE`,
|
||||
detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE`
|
||||
Calendars containing features this version cannot preserve—such as `RDATE`
|
||||
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
|
||||
prevents a local edit from silently discarding unknown data.
|
||||
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
`BYMONTH`, compatible DATE or DATE-TIME `RDATE`, and `EXDATE`. An occurrence set
|
||||
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
|
||||
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
|
||||
loader encounters information it cannot round-trip, including recurrence
|
||||
overrides, `RDATE`, custom `VTIMEZONE` definitions, alarms, attendees, unknown
|
||||
properties, or malformed components. Supported recurrence rules, exclusions,
|
||||
and system IANA time-zone identifiers round-trip semantically. Browsing remains
|
||||
available for unsupported files. This conservative boundary is more important
|
||||
than partial editing because a successful-looking edit must not erase unrelated
|
||||
calendar data.
|
||||
overrides, `RDATE` periods or values incompatible with `DTSTART`'s time basis or
|
||||
`TZID`, custom `VTIMEZONE` definitions, alarms, attendees, unknown properties,
|
||||
or malformed components. Supported recurrence rules, compatible additional
|
||||
starts, exclusions, and system IANA time-zone identifiers round-trip
|
||||
semantically. Browsing remains available for unsupported files. This
|
||||
conservative boundary is more important than partial editing because a
|
||||
successful-looking edit must not erase unrelated calendar data.
|
||||
|
||||
Provider synchronization will not write directly into this UI file. It will use
|
||||
a transaction-capable local cache and preserve remote ETags/sync tokens in a
|
||||
|
||||
@@ -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
|
||||
recurring instances appear throughout the month, preserve civil time across
|
||||
daylight-saving changes, and expose their source zone and whole-series mutation
|
||||
semantics in the reader. `/` searches occurrence-aware title, description,
|
||||
location, and calendar-ID matches in a finite ten-year window centered on the
|
||||
selected date. `c` toggles session-only calendar visibility consistently across
|
||||
the grid, appointment focus, and search without filtering the model supplied to
|
||||
persistence. `g` opens an occurrence-aware agenda at the selected month date;
|
||||
navigation is bounded to consecutive 42-day windows and `Enter` returns to the
|
||||
exact occurrence in the month grid. `Esc` or `g` closes the agenda without
|
||||
changing the month selection unless an occurrence was chosen. Hidden calendars
|
||||
remain filtered there, while calendar selection 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
|
||||
semantics in the reader. A supported recurrence set combines `DTSTART`, rule
|
||||
expansion, and compatible DATE or DATE-TIME `RDATE` starts, deduplicates starts,
|
||||
and then removes `EXDATE` exclusions. `/` searches occurrence-aware title,
|
||||
description, location, and calendar-ID matches in a finite ten-year window
|
||||
centered on the selected date. `c` toggles session-only calendar visibility
|
||||
consistently across the grid, appointment focus, and search without filtering
|
||||
the model supplied to persistence. `g` opens an occurrence-aware agenda at the
|
||||
selected month date; navigation is bounded to consecutive 42-day windows and
|
||||
`Enter` returns to the exact occurrence in the month grid. `Esc` or `g` closes
|
||||
the agenda without changing the month selection unless an occurrence was
|
||||
chosen. Hidden calendars remain filtered there, while calendar selection 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
|
||||
compatibility. The CLI accepts the exact lowercase values `monday`, `tuesday`,
|
||||
`wednesday`, `thursday`, `friday`, `saturday`, and `sunday`. The next local-data
|
||||
work adds advanced recurrence overrides and rule editing.
|
||||
`wednesday`, `thursday`, `friday`, `saturday`, and `sunday`. `RDATE` periods,
|
||||
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
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
- Validated add/edit/delete forms with confirmation and failure rollback
|
||||
- Guarded atomic `.ics` persistence, advisory locking, and round-trip tests
|
||||
- Bounded session undo/redo, exact external-change detection, and backup recovery
|
||||
- Windowed recurrence expansion, EXDATE, IANA TZID, and multi-day segment cues
|
||||
- Windowed recurrence expansion, compatible RDATE, EXDATE, IANA TZID, and
|
||||
multi-day segment cues
|
||||
- Occurrence-aware appointment search with exact result-to-grid navigation
|
||||
- Session calendar toggles shared by month rendering, focus, and search
|
||||
- Read-only 42-day agenda with bounded recurrence and exact return navigation
|
||||
@@ -19,7 +20,8 @@
|
||||
|
||||
## 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
|
||||
|
||||
## 0.2 — durable cache and provider boundary
|
||||
|
||||
@@ -39,6 +39,7 @@ struct Event {
|
||||
TimeBasis time_basis{TimeBasis::utc};
|
||||
std::string time_zone;
|
||||
std::optional<RecurrenceRule> recurrence;
|
||||
std::vector<TimePoint> recurrence_additions;
|
||||
std::vector<TimePoint> recurrence_exceptions;
|
||||
};
|
||||
|
||||
@@ -55,4 +56,9 @@ struct Calendar {
|
||||
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
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace {
|
||||
left.time_basis == right.time_basis &&
|
||||
left.time_zone == right.time_zone &&
|
||||
left.recurrence == right.recurrence &&
|
||||
left.recurrence_additions == right.recurrence_additions &&
|
||||
left.recurrence_exceptions == right.recurrence_exceptions;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,24 @@ template <class TimePointType>
|
||||
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)
|
||||
{
|
||||
std::tm result{};
|
||||
@@ -334,10 +352,8 @@ void append_candidate(EventOccurrences& result, const Event& event,
|
||||
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});
|
||||
}
|
||||
append_occurrence(result, event, candidate_start, candidate_end, ordinal,
|
||||
begin, end);
|
||||
} 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
|
||||
@@ -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(
|
||||
const Event& event, const RecurrenceRule& rule, TimePoint query_end,
|
||||
const Projection& projection)
|
||||
@@ -564,12 +625,10 @@ void append_event_occurrences(EventOccurrences& result, const Event& 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)) {
|
||||
const bool recurring = is_recurring(event);
|
||||
append_occurrence(result, event, event.start, event.end, 0U, begin, end,
|
||||
recurring);
|
||||
if (!recurring) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -578,20 +637,27 @@ void append_event_occurrences(EventOccurrences& result, const Event& 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;
|
||||
if (event.recurrence && valid_rule_shape(event, *event.recurrence)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
append_recurrence_additions(result, event, projection, begin, end);
|
||||
} catch (const std::exception&) {
|
||||
// Invalid zones and out-of-range civil projections retain DTSTART only.
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ struct PendingEvent {
|
||||
std::optional<ParsedTime> start;
|
||||
std::optional<ParsedTime> end;
|
||||
std::vector<PendingProperty> recurrence_rules;
|
||||
std::vector<PendingProperty> recurrence_dates;
|
||||
std::vector<PendingProperty> exception_dates;
|
||||
};
|
||||
|
||||
@@ -724,6 +725,15 @@ void serialize_calendar(std::ostream& output, std::span<const Event> events) {
|
||||
if (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()) {
|
||||
const std::string parameters = time_property_parameters(event);
|
||||
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(
|
||||
"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(
|
||||
"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::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 std::string_view exception_value : split(property.value, ',')) {
|
||||
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,
|
||||
"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);
|
||||
}
|
||||
result.events.push_back(std::move(event));
|
||||
@@ -1275,7 +1316,7 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
continue;
|
||||
}
|
||||
const bool time_property = name == "DTSTART" || name == "DTEND"
|
||||
|| name == "EXDATE";
|
||||
|| name == "RDATE" || name == "EXDATE";
|
||||
const bool recurrence_property = name == "RRULE";
|
||||
const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION"
|
||||
|| name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID"
|
||||
@@ -1305,6 +1346,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
} else if (name == "RRULE") {
|
||||
pending->recurrence_rules.push_back(PendingProperty{
|
||||
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") {
|
||||
pending->exception_dates.push_back(PendingProperty{
|
||||
std::string(parameters), std::string(value), index + 1});
|
||||
|
||||
@@ -374,7 +374,7 @@ bool EventEditor::validate()
|
||||
|
||||
Event candidate = original_;
|
||||
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,
|
||||
"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;
|
||||
lines.reserve(16);
|
||||
lines.push_back(top);
|
||||
const auto heading = editing_ && original_.recurrence
|
||||
const auto heading = editing_ && is_recurring(original_)
|
||||
? " NOCAL · EDIT ENTIRE RECURRING SERIES"
|
||||
: editing_ ? " NOCAL · EDIT APPOINTMENT"
|
||||
: " NOCAL · NEW APPOINTMENT";
|
||||
|
||||
@@ -802,7 +802,7 @@ 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) {
|
||||
if (is_recurring(*occurrence.source)) {
|
||||
const auto tick = occurrence.start.time_since_epoch().count();
|
||||
identity += "@occ:" + std::to_string(tick);
|
||||
}
|
||||
@@ -1040,7 +1040,7 @@ std::string render_month(const std::span<const Event> events,
|
||||
? std::nullopt
|
||||
: std::optional{local_start.time_of_day},
|
||||
.segment = segment,
|
||||
.recurring = event.recurrence.has_value(),
|
||||
.recurring = is_recurring(event),
|
||||
.recurrence_summary = event.recurrence
|
||||
? recurrence_summary(*event.recurrence) : std::string{},
|
||||
.source_time_zone = event.time_basis == TimeBasis::zoned
|
||||
|
||||
@@ -233,7 +233,7 @@ void TuiApp::refresh_agenda() {
|
||||
.all_day = occurrence.source->all_day || continuation,
|
||||
.location = occurrence.source->location,
|
||||
.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(),
|
||||
@@ -463,7 +463,7 @@ void TuiApp::submit_editor() {
|
||||
std::span<const Calendar>{calendars_})) {
|
||||
state_.focused_event_id.reset();
|
||||
state_.show_event_details = false;
|
||||
record_mutation(editing && events_[changed_index].recurrence
|
||||
record_mutation(editing && is_recurring(events_[changed_index])
|
||||
? "edit recurring series"
|
||||
: editing ? "edit appointment" : "add appointment");
|
||||
set_notification(std::move(success));
|
||||
@@ -489,10 +489,10 @@ void TuiApp::submit_editor() {
|
||||
: std::optional{occurrence_focus_id(event_span, *first)};
|
||||
}
|
||||
state_.show_event_details = false;
|
||||
record_mutation(editing && events_[changed_index].recurrence
|
||||
record_mutation(editing && is_recurring(events_[changed_index])
|
||||
? "edit recurring series"
|
||||
: editing ? "edit appointment" : "add appointment");
|
||||
if (editing && events_[changed_index].recurrence) {
|
||||
if (editing && is_recurring(events_[changed_index])) {
|
||||
success = "Recurring series updated.";
|
||||
}
|
||||
set_notification(std::move(success));
|
||||
@@ -509,7 +509,7 @@ void TuiApp::confirm_delete() {
|
||||
|
||||
auto original = events_;
|
||||
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));
|
||||
if (!persist(std::span<const Event>{events_})) {
|
||||
events_.swap(original);
|
||||
@@ -574,7 +574,7 @@ void TuiApp::submit_search() {
|
||||
: std::optional{local_time_of_day(occurrence.start)},
|
||||
.all_day = occurrence.source->all_day,
|
||||
.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(),
|
||||
|
||||
@@ -65,6 +65,7 @@ void check_invalid_argument(Function&& function, const std::string_view message)
|
||||
recurrence.by_month_day = {1, -1};
|
||||
recurrence.by_month = {1, 7};
|
||||
event.recurrence = std::move(recurrence);
|
||||
event.recurrence_additions = {at(20)};
|
||||
event.recurrence_exceptions = {at(16), at(18)};
|
||||
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_zone == right.time_zone &&
|
||||
left.recurrence == right.recurrence &&
|
||||
left.recurrence_additions == right.recurrence_additions &&
|
||||
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 zone", [](auto& event) { event.time_zone = "America/New_York"; }},
|
||||
{"recurrence", [](auto& event) { event.recurrence->interval += 1; }},
|
||||
{"recurrence additions",
|
||||
[](auto& event) { event.recurrence_additions.push_back(at(19)); }},
|
||||
{"recurrence exclusions",
|
||||
[](auto& event) { event.recurrence_exceptions.push_back(at(19)); }},
|
||||
};
|
||||
|
||||
@@ -404,6 +404,90 @@ void test_weekly_count_and_exdates()
|
||||
"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()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
@@ -482,6 +566,14 @@ void test_occurrence_overlap_sorting_and_invalid_rules()
|
||||
occurrences[3].source->uid == "later",
|
||||
"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_zone = "Not/A_Real_Zone";
|
||||
invalid.recurrence->interval = 1U;
|
||||
@@ -504,6 +596,9 @@ int main()
|
||||
test_daily_dst_recurrence();
|
||||
test_floating_dst_and_until();
|
||||
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_occurrence_overlap_sorting_and_invalid_rules();
|
||||
if (failures != 0) {
|
||||
|
||||
@@ -246,6 +246,8 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap()
|
||||
};
|
||||
event.recurrence_exceptions.push_back(
|
||||
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};
|
||||
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 &&
|
||||
editor.result().time_zone == "America/New_York" &&
|
||||
editor.result().recurrence == event.recurrence &&
|
||||
editor.result().recurrence_additions == event.recurrence_additions &&
|
||||
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};
|
||||
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,
|
||||
"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;
|
||||
one_off.recurrence.reset();
|
||||
one_off.recurrence_additions.clear();
|
||||
one_off.recurrence_exceptions.clear();
|
||||
nocal::tui::EventEditor all_day_editor{one_off};
|
||||
focus(all_day_editor, nocal::tui::EditorField::all_day);
|
||||
|
||||
@@ -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_zone == loaded.events[index].time_zone
|
||||
&& 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,
|
||||
"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");
|
||||
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,
|
||||
"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) {
|
||||
const std::vector<std::string> unsupported_properties{
|
||||
"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=HOURLY",
|
||||
"RRULE:FREQ=DAILY;WKST=MO",
|
||||
"RDATE:20260718T090000Z",
|
||||
"RDATE;VALUE=PERIOD:20260718T090000Z/PT1H",
|
||||
"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");
|
||||
@@ -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,
|
||||
"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{
|
||||
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000"},
|
||||
{"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
|
||||
&& 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);
|
||||
}
|
||||
|
||||
@@ -691,6 +840,7 @@ int main() {
|
||||
test_unsupported_data_is_not_rewrite_safe(root);
|
||||
test_time_basis_round_trips_and_dst(root);
|
||||
test_supported_recurrence_and_exdates(root);
|
||||
test_supported_rdates(root);
|
||||
test_unsupported_recurrence_and_zones_are_unsafe(root);
|
||||
test_save_validation_preserves_existing_bytes(root);
|
||||
std::filesystem::remove_all(root, ignored);
|
||||
|
||||
@@ -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.time_basis == right.time_basis && left.time_zone == right.time_zone &&
|
||||
left.recurrence == right.recurrence &&
|
||||
left.recurrence_additions == right.recurrence_additions &&
|
||||
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");
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
@@ -1241,6 +1272,7 @@ int main()
|
||||
test_narrow_detail_wrapping_without_ansi();
|
||||
test_multiday_segment_labels_and_series_messaging();
|
||||
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_search_prompt_results_and_occurrence_jump();
|
||||
test_calendar_visibility_filters_without_partial_saves();
|
||||
|
||||
Reference in New Issue
Block a user