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