feat: embedded VTIMEZONE parsing, round-trip serialization

This commit is contained in:
Bernardo Magri
2026-07-23 15:39:41 +01:00
committed by Bernardo Magri
parent 725e48569e
commit 673a2c66c9
3 changed files with 311 additions and 25 deletions

View File

@@ -55,6 +55,16 @@ struct PendingEvent {
std::vector<PendingProperty> exception_dates;
};
// Accumulated VTIMEZONE definitions from the source calendar (populated during load).
std::vector<VTimezoneDefinition> parsed_vtimezones;
bool inside_vtimezone = false;
bool vtimezone_has_errors = false;
[[nodiscard]] bool vtimezone_defined(std::string_view tzid) noexcept {
return std::any_of(parsed_vtimezones.begin(), parsed_vtimezones.end(),
[&tzid](const VTimezoneDefinition& tz) { return tz.tzid == tzid; });
}
[[nodiscard]] std::string upper(std::string_view value) {
std::string result(value);
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char ch) {
@@ -358,6 +368,10 @@ void mark_unsafe_to_rewrite(LoadResult& result) {
}
}
[[nodiscard]] bool is_unknown_tzid_warning(std::string_view warning) noexcept {
return warning.find("unknown TZID=") != std::string::npos;
}
[[nodiscard]] std::vector<std::string_view> split(
std::string_view value, char delimiter) {
std::vector<std::string_view> parts;
@@ -586,17 +600,22 @@ template <typename Integer>
}
[[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()));
try {
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()));
} catch (const std::runtime_error&) {
// Custom TZID not known to the system; fall back to local formatting.
return format_floating_time(value);
}
}
[[nodiscard]] std::string format_event_time(const Event& event, TimePoint value) {
@@ -704,11 +723,25 @@ void write_text_property(std::ostream& output, std::string_view name, std::strin
}
}
void serialize_calendar(std::ostream& output, std::span<const Event> events) {
void serialize_calendar(std::ostream& output, std::span<const Event> events,
std::span<const VTimezoneDefinition> vtimezones) {
write_folded(output, "BEGIN:VCALENDAR");
write_folded(output, "VERSION:2.0");
write_folded(output, "PRODID:-//Nomarchy Linux//nocal 1.0//EN");
write_folded(output, "CALSCALE:GREGORIAN");
// Preserve embedded VTIMEZONE definitions for round-trip safety.
for (const auto& vt : vtimezones) {
if (vt.tzid.empty()) continue;
write_folded(output, "BEGIN:VTIMEZONE");
write_folded(output, "TZID:" + vt.tzid);
if (!vt.raw.empty()) {
std::istringstream raw_stream(vt.raw);
for (const auto& line : unfold(raw_stream)) {
write_folded(output, line);
}
}
write_folded(output, "END:VTIMEZONE");
}
for (const Event& event : events) {
write_folded(output, "BEGIN:VEVENT");
write_folded(output, "UID:" + escape_text(event.uid));
@@ -1058,7 +1091,8 @@ void validate_recurrence(const Event& event) {
}
}
void validate_event_for_save(const Event& event) {
void validate_event_for_save(
const Event& event, std::span<const VTimezoneDefinition> vtimezones) {
if (event.uid.empty()) {
throw std::invalid_argument("cannot save an event with an empty UID");
}
@@ -1103,9 +1137,13 @@ void validate_event_for_save(const Event& event) {
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 + "'");
bool defined_by_vtimezone = std::any_of(vtimezones.begin(), vtimezones.end(),
[&event](const VTimezoneDefinition& tz) { return tz.tzid == event.time_zone; });
if (!defined_by_vtimezone) {
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(
@@ -1135,6 +1173,10 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
}
result.revision.contents_ = std::make_shared<const std::string>(snapshot.bytes);
parsed_vtimezones.clear();
inside_vtimezone = false;
vtimezone_has_errors = false;
std::istringstream input(snapshot.bytes, std::ios::in | std::ios::binary);
const auto lines = unfold(input);
std::optional<PendingEvent> pending;
@@ -1252,8 +1294,11 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
continue;
}
if (!warning.empty()) {
const bool is_tzid = is_unknown_tzid_warning(warning);
add_warning(result, property.line, std::move(warning));
mark_unsafe_to_rewrite(result);
if (!is_tzid) {
mark_unsafe_to_rewrite(result);
}
}
if (std::find(event.recurrence_additions.begin(),
event.recurrence_additions.end(), parsed->value)
@@ -1275,8 +1320,11 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
continue;
}
if (!warning.empty()) {
const bool is_tzid = is_unknown_tzid_warning(warning);
add_warning(result, property.line, std::move(warning));
mark_unsafe_to_rewrite(result);
if (!is_tzid) {
mark_unsafe_to_rewrite(result);
}
}
if (std::find(event.recurrence_exceptions.begin(),
event.recurrence_exceptions.end(), parsed->value)
@@ -1298,6 +1346,39 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
}
if (!pending) {
// Handle embedded VTIMEZONE for safety analysis and round-trip.
if (name == "BEGIN" && upper(value) == "VTIMEZONE") {
if (inside_vtimezone) {
add_warning(result, index + 1, "nested VTIMEZONE is not supported");
mark_unsafe_to_rewrite(result);
} else {
inside_vtimezone = true;
vtimezone_has_errors = false;
parsed_vtimezones.emplace_back();
}
continue;
}
if (name == "END" && upper(value) == "VTIMEZONE") {
if (!inside_vtimezone) {
add_warning(result, index + 1, "END:VTIMEZONE without BEGIN:VTIMEZONE");
mark_unsafe_to_rewrite(result);
} else {
inside_vtimezone = false;
}
continue;
}
if (inside_vtimezone) {
auto& vt = parsed_vtimezones.back();
if (!vt.raw.empty()) vt.raw += "\r\n";
vt.raw += std::string(line);
if (name == "TZID" && vt.tzid.empty()) {
vt.tzid = std::string(value);
}
// Sub-components and their properties are tracked but not deeply
// parsed; unsupported content inside VTIMEZONE is deferred.
continue;
}
const std::string normalized_value = upper(value);
const bool structural = (name == "BEGIN" && normalized_value == "VCALENDAR")
|| (name == "END" && normalized_value == "VCALENDAR");
@@ -1366,8 +1447,11 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
mark_unsafe_to_rewrite(result);
} else {
if (!warning.empty()) {
const bool is_tzid = is_unknown_tzid_warning(warning);
add_warning(result, index + 1, std::move(warning));
mark_unsafe_to_rewrite(result);
if (!is_tzid) {
mark_unsafe_to_rewrite(result);
}
}
destination = *parsed;
}
@@ -1378,6 +1462,59 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
add_warning(result, event_start_line, "unterminated VEVENT was skipped");
mark_unsafe_to_rewrite(result);
}
if (inside_vtimezone) {
add_warning(result, 0, "unterminated VTIMEZONE; timezone rules may be incomplete");
mark_unsafe_to_rewrite(result);
}
// VTIMEZONE-defined TZIDs that are unknown to the system are safe to edit
// (we can round-trip the definition) but DST handling is approximate.
for (const auto& event : result.events) {
if (event.time_basis == TimeBasis::zoned && !event.time_zone.empty()) {
const std::string& tzid = event.time_zone;
try {
(void)locate_zone(tzid);
} catch (const std::runtime_error&) {
if (vtimezone_defined(tzid)) {
add_warning(result, 0,
"TZID '" + tzid + "' is defined by VTIMEZONE but not by the system; "
"DST transitions use local time as an approximation");
}
}
}
}
// Remove "unknown TZID" warnings only for TZIDs defined in VTIMEZONE.
auto safe_end = std::remove_if(result.warnings.begin(), result.warnings.end(),
[](const std::string& w) {
const auto marker = w.find("unknown TZID=");
if (marker == std::string::npos) return false;
// Extract TZID from warning like "line N: unknown TZID='Custom/Test'; ..."
const std::size_t start = w.find('\'', marker);
const std::size_t end = w.find('\'', start + 1);
if (start == std::string::npos || end == std::string::npos) return false;
const std::string tzid = w.substr(start + 1, end - start - 1);
return vtimezone_defined(tzid);
});
result.warnings.erase(safe_end, result.warnings.end());
// If no undefined-TZID warnings remain and only VTIMEZONE-DST warnings
// (safe to edit) are present, mark the calendar as safe to rewrite.
bool has_undefined_tzid = std::any_of(result.warnings.begin(), result.warnings.end(),
[](const std::string& w) { return w.find("unknown TZID=") != std::string::npos; });
if (has_undefined_tzid) {
mark_unsafe_to_rewrite(result);
} else if (!result.warnings.empty()) {
bool has_unsafe_warning = std::any_of(result.warnings.begin(), result.warnings.end(),
[](const std::string& w) {
return w.find("VTIMEZONE") == std::string::npos || w.find("DST") == std::string::npos;
});
if (!has_unsafe_warning) {
result.safe_to_rewrite = true;
}
}
result.vtimezones = std::move(parsed_vtimezones);
return result;
}
@@ -1389,16 +1526,17 @@ std::filesystem::path IcsStore::backup_path(const std::filesystem::path& path) {
FileRevision IcsStore::save(
const std::filesystem::path& path, std::span<const Event> events,
std::optional<FileRevision> expected) {
std::optional<FileRevision> expected,
std::span<const VTimezoneDefinition> vtimezones) {
if (path.empty() || path.filename().empty()) {
throw std::invalid_argument("calendar path must name a file");
}
for (const Event& event : events) {
validate_event_for_save(event);
validate_event_for_save(event, vtimezones);
}
std::ostringstream serialized(std::ios::out | std::ios::binary);
serialize_calendar(serialized, events);
serialize_calendar(serialized, events, vtimezones);
if (!serialized) {
throw std::runtime_error("unable to serialize calendar '" + path.string() + "'");
}