feat: add recurrence and time zone support

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

View File

@@ -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);