feat: add recurrence and time zone support
This commit is contained in:
@@ -31,6 +31,14 @@ struct ParsedTime {
|
||||
TimePoint value;
|
||||
bool is_date = false;
|
||||
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 {
|
||||
@@ -42,6 +50,8 @@ struct PendingEvent {
|
||||
std::string color;
|
||||
std::optional<ParsedTime> start;
|
||||
std::optional<ParsedTime> end;
|
||||
std::vector<PendingProperty> recurrence_rules;
|
||||
std::vector<PendingProperty> exception_dates;
|
||||
};
|
||||
|
||||
[[nodiscard]] std::string upper(std::string_view value) {
|
||||
@@ -149,26 +159,84 @@ struct PendingEvent {
|
||||
return system_clock::from_time_t(converted);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<ParsedTime> parse_time(
|
||||
std::string_view parameters, std::string_view value, std::string& warning) {
|
||||
const std::string normalized_parameters = upper(parameters);
|
||||
struct TimeParameters {
|
||||
bool valid = true;
|
||||
bool value_is_date = false;
|
||||
bool has_tzid = false;
|
||||
std::size_t parameter_start = 0;
|
||||
while (parameter_start <= normalized_parameters.size()) {
|
||||
const std::size_t parameter_end = normalized_parameters.find(';', parameter_start);
|
||||
const std::string_view parameter = std::string_view(normalized_parameters).substr(
|
||||
parameter_start, parameter_end == std::string::npos
|
||||
? std::string::npos : parameter_end - parameter_start);
|
||||
value_is_date = value_is_date || parameter == "VALUE=DATE";
|
||||
has_tzid = has_tzid || parameter.starts_with("TZID=");
|
||||
if (parameter_end == std::string::npos) {
|
||||
bool has_value_parameter = false;
|
||||
std::optional<std::string> time_zone;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
[[nodiscard]] TimeParameters parse_time_parameters(std::string_view parameters) {
|
||||
TimeParameters result;
|
||||
std::size_t start = 0;
|
||||
while (start < parameters.size()) {
|
||||
const std::size_t end = parameters.find(';', start);
|
||||
const std::string_view parameter = parameters.substr(
|
||||
start, end == std::string_view::npos ? std::string_view::npos : end - start);
|
||||
const std::size_t equals = parameter.find('=');
|
||||
const std::string name = upper(parameter.substr(0, equals));
|
||||
std::string_view value = equals == std::string_view::npos
|
||||
? std::string_view{} : parameter.substr(equals + 1);
|
||||
if (value.size() >= 2 && value.front() == '"' && value.back() == '"') {
|
||||
value.remove_prefix(1);
|
||||
value.remove_suffix(1);
|
||||
}
|
||||
if (name == "VALUE" && !result.has_value_parameter
|
||||
&& (upper(value) == "DATE" || upper(value) == "DATE-TIME")) {
|
||||
result.has_value_parameter = true;
|
||||
result.value_is_date = upper(value) == "DATE";
|
||||
} else if (name == "TZID" && !value.empty() && !result.time_zone) {
|
||||
result.time_zone = std::string(value);
|
||||
} else {
|
||||
result.valid = false;
|
||||
result.error = "unsupported or duplicate DATE-TIME parameter '"
|
||||
+ std::string(parameter) + "'";
|
||||
return result;
|
||||
}
|
||||
if (end == std::string_view::npos) {
|
||||
break;
|
||||
}
|
||||
parameter_start = parameter_end + 1;
|
||||
start = end + 1;
|
||||
}
|
||||
if (result.value_is_date && result.time_zone) {
|
||||
result.valid = false;
|
||||
result.error = "VALUE=DATE cannot have a TZID";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<TimePoint> make_zoned(
|
||||
year_month_day date, int hour_value, int minute_value, int second_value,
|
||||
std::string_view zone_name, std::string& warning) {
|
||||
try {
|
||||
const time_zone* zone = locate_zone(zone_name);
|
||||
const local_seconds local = local_days{date} + hours{hour_value}
|
||||
+ minutes{minute_value} + seconds{second_value};
|
||||
const local_info info = zone->get_info(local);
|
||||
if (info.result == local_info::unique) {
|
||||
return zone->to_sys(local);
|
||||
}
|
||||
// RFC 5545 resolves a repeated wall time to its first occurrence and a
|
||||
// nonexistent wall time with the UTC offset in force before the gap.
|
||||
// local_info::first describes exactly that side of both transitions.
|
||||
return sys_seconds{local.time_since_epoch() - info.first.offset};
|
||||
} catch (const std::runtime_error&) {
|
||||
warning = "unknown TZID='" + std::string(zone_name)
|
||||
+ "'; DATE-TIME was interpreted in the process local timezone";
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<ParsedTime> parse_time(
|
||||
std::string_view parameters, std::string_view value, std::string& warning) {
|
||||
const TimeParameters parsed_parameters = parse_time_parameters(parameters);
|
||||
if (!parsed_parameters.valid) {
|
||||
warning = parsed_parameters.error;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
if (value_is_date || (parameters.empty() && value.size() == 8)) {
|
||||
if (parsed_parameters.value_is_date || (parameters.empty() && value.size() == 8)) {
|
||||
const auto date = parse_ymd(value);
|
||||
if (!date) {
|
||||
warning = "invalid DATE value '" + std::string(value) + "'";
|
||||
@@ -179,14 +247,14 @@ struct PendingEvent {
|
||||
warning = "DATE does not map to a local midnight: '" + std::string(value) + "'";
|
||||
return std::nullopt;
|
||||
}
|
||||
return ParsedTime{*local, true, *date};
|
||||
}
|
||||
|
||||
if (has_tzid) {
|
||||
warning = "TZID is not supported; DATE-TIME was interpreted in the process local timezone";
|
||||
return ParsedTime{*local, true, *date, TimeBasis::floating, {}};
|
||||
}
|
||||
|
||||
const bool utc = !value.empty() && value.back() == 'Z';
|
||||
if (utc && parsed_parameters.time_zone) {
|
||||
warning = "UTC DATE-TIME cannot also have a TZID";
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::size_t expected_size = utc ? 16 : 15;
|
||||
if (value.size() != expected_size || value[8] != 'T') {
|
||||
warning = "invalid DATE-TIME value '" + std::string(value) + "'";
|
||||
@@ -207,9 +275,31 @@ struct PendingEvent {
|
||||
// first instant of the next minute.
|
||||
const int represented_second = std::min(second_value, 59);
|
||||
TimePoint result;
|
||||
TimeBasis basis = TimeBasis::floating;
|
||||
std::string zone_name;
|
||||
if (utc) {
|
||||
result = sys_days{*date} + hours{hour_value} + minutes{minute_value}
|
||||
+ seconds{represented_second};
|
||||
basis = TimeBasis::utc;
|
||||
} else if (parsed_parameters.time_zone) {
|
||||
zone_name = *parsed_parameters.time_zone;
|
||||
std::string zoned_warning;
|
||||
const auto zoned = make_zoned(
|
||||
*date, hour_value, minute_value, represented_second, zone_name, zoned_warning);
|
||||
if (zoned) {
|
||||
result = *zoned;
|
||||
} else if (zoned_warning.starts_with("unknown TZID=")) {
|
||||
warning = std::move(zoned_warning);
|
||||
const auto local = make_local(*date, hour_value, minute_value, represented_second);
|
||||
if (!local) {
|
||||
return std::nullopt;
|
||||
}
|
||||
result = *local;
|
||||
} else {
|
||||
warning = std::move(zoned_warning);
|
||||
return std::nullopt;
|
||||
}
|
||||
basis = TimeBasis::zoned;
|
||||
} else {
|
||||
const auto local = make_local(*date, hour_value, minute_value, represented_second);
|
||||
if (!local) {
|
||||
@@ -221,7 +311,7 @@ struct PendingEvent {
|
||||
if (second_value == 60) {
|
||||
result += seconds{1};
|
||||
}
|
||||
return ParsedTime{result, false, std::nullopt};
|
||||
return ParsedTime{result, false, std::nullopt, basis, std::move(zone_name)};
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<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) {
|
||||
const std::string normalized = upper(parameters);
|
||||
return normalized.empty() || normalized == "VALUE=DATE";
|
||||
[[nodiscard]] std::vector<std::string_view> split(
|
||||
std::string_view value, char delimiter) {
|
||||
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) {
|
||||
@@ -322,6 +576,102 @@ void mark_unsafe_to_rewrite(LoadResult& result) {
|
||||
+ two_digits(utc.tm_min) + two_digits(utc.tm_sec) + "Z";
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string format_floating_time(TimePoint value) {
|
||||
const auto whole_seconds = floor<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) {
|
||||
std::size_t end = std::min(value.size(), begin + limit);
|
||||
while (end > begin && end < value.size()
|
||||
@@ -365,8 +715,23 @@ void serialize_calendar(std::ostream& output, std::span<const Event> events) {
|
||||
write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start));
|
||||
write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end));
|
||||
} else {
|
||||
write_folded(output, "DTSTART:" + format_utc_time(event.start));
|
||||
write_folded(output, "DTEND:" + format_utc_time(event.end));
|
||||
const std::string parameters = time_property_parameters(event);
|
||||
write_folded(output, "DTSTART" + parameters + ":"
|
||||
+ format_event_time(event, event.start));
|
||||
write_folded(output, "DTEND" + parameters + ":"
|
||||
+ format_event_time(event, event.end));
|
||||
}
|
||||
if (event.recurrence) {
|
||||
write_folded(output, "RRULE:" + serialize_recurrence_rule(event, *event.recurrence));
|
||||
}
|
||||
if (!event.recurrence_exceptions.empty()) {
|
||||
const std::string parameters = time_property_parameters(event);
|
||||
const std::string values = join_values(event.recurrence_exceptions,
|
||||
[&event](TimePoint exception) {
|
||||
return event.all_day ? format_date(exception)
|
||||
: format_event_time(event, exception);
|
||||
});
|
||||
write_folded(output, "EXDATE" + parameters + ":" + values);
|
||||
}
|
||||
write_text_property(output, "SUMMARY", event.title);
|
||||
write_text_property(output, "LOCATION", event.location);
|
||||
@@ -644,6 +1009,94 @@ void replace_with_temporary(
|
||||
}
|
||||
}
|
||||
|
||||
void validate_recurrence(const Event& event) {
|
||||
if (!event.recurrence) {
|
||||
return;
|
||||
}
|
||||
const RecurrenceRule& rule = *event.recurrence;
|
||||
const auto invalid = [&event](std::string_view reason) {
|
||||
throw std::invalid_argument("cannot save event '" + event.uid
|
||||
+ "' with invalid recurrence: " + std::string(reason));
|
||||
};
|
||||
switch (rule.frequency) {
|
||||
case RecurrenceFrequency::daily:
|
||||
case RecurrenceFrequency::weekly:
|
||||
case RecurrenceFrequency::monthly:
|
||||
case RecurrenceFrequency::yearly: break;
|
||||
default: invalid("frequency is invalid");
|
||||
}
|
||||
if (rule.interval == 0) invalid("INTERVAL must be positive");
|
||||
if (rule.count && *rule.count == 0) invalid("COUNT must be positive");
|
||||
if (rule.count && rule.until) invalid("COUNT and UNTIL are mutually exclusive");
|
||||
for (weekday day : rule.by_weekday) {
|
||||
if (!day.ok()) invalid("BYDAY contains an invalid weekday");
|
||||
}
|
||||
for (int day : rule.by_month_day) {
|
||||
if (day == 0 || day < -31 || day > 31) {
|
||||
invalid("BYMONTHDAY contains an out-of-range day");
|
||||
}
|
||||
}
|
||||
for (unsigned month_number : rule.by_month) {
|
||||
if (month_number == 0 || month_number > 12) {
|
||||
invalid("BYMONTH contains an out-of-range month");
|
||||
}
|
||||
}
|
||||
if ((!rule.by_weekday.empty() && rule.frequency != RecurrenceFrequency::weekly)
|
||||
|| (!rule.by_month_day.empty() && rule.frequency != RecurrenceFrequency::monthly)
|
||||
|| (!rule.by_month.empty() && rule.frequency != RecurrenceFrequency::yearly)) {
|
||||
invalid("selector does not apply to the selected frequency");
|
||||
}
|
||||
}
|
||||
|
||||
void validate_event_for_save(const Event& event) {
|
||||
if (event.uid.empty()) {
|
||||
throw std::invalid_argument("cannot save an event with an empty UID");
|
||||
}
|
||||
if (event.end < event.start || (event.all_day && event.end == event.start)) {
|
||||
throw std::invalid_argument(
|
||||
"cannot save event '" + event.uid + "' with an invalid interval");
|
||||
}
|
||||
if (!event.recurrence && !event.recurrence_exceptions.empty()) {
|
||||
throw std::invalid_argument(
|
||||
"cannot save event '" + event.uid + "' with EXDATE values but no recurrence rule");
|
||||
}
|
||||
std::vector<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
|
||||
|
||||
LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
@@ -710,8 +1163,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
} else if (!pending->start) {
|
||||
add_warning(result, event_start_line, "VEVENT has no valid DTSTART and was skipped");
|
||||
mark_unsafe_to_rewrite(result);
|
||||
} else if (pending->end && pending->start->is_date != pending->end->is_date) {
|
||||
add_warning(result, event_start_line, "DTSTART and DTEND value types differ; event was skipped");
|
||||
} else if (pending->end && !same_time_kind(*pending->start, *pending->end)) {
|
||||
add_warning(result, event_start_line,
|
||||
"DTSTART and DTEND value types, time bases, or TZIDs differ; event was skipped");
|
||||
mark_unsafe_to_rewrite(result);
|
||||
} else {
|
||||
const bool all_day = pending->start->is_date;
|
||||
@@ -741,10 +1195,60 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
event.start = pending->start->value;
|
||||
event.end = end;
|
||||
event.all_day = all_day;
|
||||
event.time_basis = all_day ? TimeBasis::floating : pending->start->basis;
|
||||
event.time_zone = all_day ? std::string{} : pending->start->time_zone;
|
||||
event.location = std::move(pending->location);
|
||||
event.description = std::move(pending->description);
|
||||
event.calendar_id = std::move(pending->calendar_id);
|
||||
event.color = std::move(pending->color);
|
||||
|
||||
if (pending->recurrence_rules.size() > 1) {
|
||||
add_warning(result, pending->recurrence_rules[1].line,
|
||||
"multiple RRULE properties are not supported");
|
||||
mark_unsafe_to_rewrite(result);
|
||||
} else if (!pending->recurrence_rules.empty()) {
|
||||
const PendingProperty& property = pending->recurrence_rules.front();
|
||||
if (!property.parameters.empty()) {
|
||||
add_warning(result, property.line, "RRULE parameters are not supported");
|
||||
mark_unsafe_to_rewrite(result);
|
||||
} else {
|
||||
std::string error;
|
||||
event.recurrence = parse_recurrence_rule(
|
||||
property.value, *pending->start, error);
|
||||
if (!event.recurrence) {
|
||||
add_warning(result, property.line, std::move(error));
|
||||
mark_unsafe_to_rewrite(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const PendingProperty& property : pending->exception_dates) {
|
||||
for (const std::string_view exception_value : split(property.value, ',')) {
|
||||
std::string warning;
|
||||
const auto parsed = parse_time(
|
||||
property.parameters, exception_value, warning);
|
||||
if (!parsed || !same_time_kind(*pending->start, *parsed)) {
|
||||
add_warning(result, property.line, parsed
|
||||
? "EXDATE value type, time basis, or TZID does not match DTSTART"
|
||||
: std::move(warning));
|
||||
mark_unsafe_to_rewrite(result);
|
||||
continue;
|
||||
}
|
||||
if (!warning.empty()) {
|
||||
add_warning(result, property.line, std::move(warning));
|
||||
mark_unsafe_to_rewrite(result);
|
||||
}
|
||||
if (std::find(event.recurrence_exceptions.begin(),
|
||||
event.recurrence_exceptions.end(), parsed->value)
|
||||
== event.recurrence_exceptions.end()) {
|
||||
event.recurrence_exceptions.push_back(parsed->value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!event.recurrence && !event.recurrence_exceptions.empty()) {
|
||||
add_warning(result, event_start_line,
|
||||
"EXDATE without a supported RRULE cannot be edited safely");
|
||||
mark_unsafe_to_rewrite(result);
|
||||
}
|
||||
result.events.push_back(std::move(event));
|
||||
}
|
||||
}
|
||||
@@ -760,17 +1264,30 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
|| name == "CALSCALE";
|
||||
if ((!structural && !supported_property)
|
||||
|| (supported_property && !parameters.empty())) {
|
||||
if (!structural && !supported_property) {
|
||||
const std::string feature = (name == "BEGIN" || name == "END")
|
||||
? name + ":" + upper(value) : name;
|
||||
add_warning(result, index + 1,
|
||||
"unsupported calendar component or property '" + feature + "'");
|
||||
}
|
||||
mark_unsafe_to_rewrite(result);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const bool time_property = name == "DTSTART" || name == "DTEND";
|
||||
const bool time_property = name == "DTSTART" || name == "DTEND"
|
||||
|| name == "EXDATE";
|
||||
const bool recurrence_property = name == "RRULE";
|
||||
const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION"
|
||||
|| name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID"
|
||||
|| name == "X-NOCAL-COLOR";
|
||||
if ((!time_property && !text_property)
|
||||
|| (time_property && !supported_time_parameters(parameters))
|
||||
if ((!time_property && !text_property && !recurrence_property)
|
||||
|| (text_property && !parameters.empty())) {
|
||||
if (!time_property && !text_property && !recurrence_property) {
|
||||
const std::string feature = (name == "BEGIN" || name == "END")
|
||||
? name + ":" + upper(value) : name;
|
||||
add_warning(result, index + 1,
|
||||
"unsupported VEVENT component or property '" + feature + "'");
|
||||
}
|
||||
mark_unsafe_to_rewrite(result);
|
||||
}
|
||||
if (name == "UID") {
|
||||
@@ -785,7 +1302,19 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
pending->calendar_id = unescape_text(value);
|
||||
} else if (name == "X-NOCAL-COLOR") {
|
||||
pending->color = unescape_text(value);
|
||||
} else if (name == "RRULE") {
|
||||
pending->recurrence_rules.push_back(PendingProperty{
|
||||
std::string(parameters), std::string(value), index + 1});
|
||||
} else if (name == "EXDATE") {
|
||||
pending->exception_dates.push_back(PendingProperty{
|
||||
std::string(parameters), std::string(value), index + 1});
|
||||
} else if (name == "DTSTART" || name == "DTEND") {
|
||||
std::optional<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;
|
||||
auto parsed = parse_time(parameters, value, warning);
|
||||
if (!parsed) {
|
||||
@@ -794,12 +1323,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||
} else {
|
||||
if (!warning.empty()) {
|
||||
add_warning(result, index + 1, std::move(warning));
|
||||
mark_unsafe_to_rewrite(result);
|
||||
}
|
||||
if (name == "DTSTART") {
|
||||
pending->start = *parsed;
|
||||
} else {
|
||||
pending->end = *parsed;
|
||||
}
|
||||
destination = *parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -824,12 +1350,7 @@ FileRevision IcsStore::save(
|
||||
throw std::invalid_argument("calendar path must name a file");
|
||||
}
|
||||
for (const Event& event : events) {
|
||||
if (event.uid.empty()) {
|
||||
throw std::invalid_argument("cannot save an event with an empty UID");
|
||||
}
|
||||
if (event.end < event.start || (event.all_day && event.end == event.start)) {
|
||||
throw std::invalid_argument("cannot save event '" + event.uid + "' with an invalid interval");
|
||||
}
|
||||
validate_event_for_save(event);
|
||||
}
|
||||
|
||||
std::ostringstream serialized(std::ios::out | std::ios::binary);
|
||||
|
||||
Reference in New Issue
Block a user