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

@@ -27,6 +27,15 @@ private:
friend class IcsStore;
};
// Embedded VTIMEZONE definition preserved for round-trip serialization.
struct VTimezoneDefinition {
std::string tzid;
// Complete raw content between BEGIN:VTIMEZONE and END:VTIMEZONE (exclusive
// of the framing lines). Empty when the definition was not parsed from a
// source calendar.
std::string raw;
};
struct LoadResult {
std::vector<Event> events;
std::vector<std::string> warnings;
@@ -36,6 +45,9 @@ struct LoadResult {
// Exact bytes that produced this result. Copies share immutable storage and
// can be supplied to save/restore as an optimistic concurrency token.
FileRevision revision;
// Embedded VTIMEZONE definitions from the source calendar, preserved for
// round-trip serialization. Populated only by load().
std::vector<VTimezoneDefinition> vtimezones;
};
class IcsStore {
@@ -49,9 +61,11 @@ public:
// requests durable filesystem flushes. I/O failures before replacement are
// reported as std::runtime_error. If expected is present, the destination
// must still contain the exact bytes represented by that revision.
// Supply vtimezones to preserve embedded timezone definitions.
static FileRevision save(
const std::filesystem::path& path, std::span<const Event> events,
std::optional<FileRevision> expected = std::nullopt);
std::optional<FileRevision> expected = std::nullopt,
std::span<const VTimezoneDefinition> vtimezones = {});
// Restores the exact bytes in <path>.bak without changing the backup.
static FileRevision restore_backup(

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,6 +600,7 @@ template <typename Integer>
}
[[nodiscard]] std::string format_zoned_time(TimePoint value, std::string_view zone_name) {
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);
@@ -597,6 +612,10 @@ template <typename Integer>
+ 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,10 +1137,14 @@ void validate_event_for_save(const Event& event) {
try {
(void)locate_zone(event.time_zone);
} catch (const std::runtime_error&) {
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(
"cannot save event '" + event.uid + "' with a TZID but non-zoned time basis");
@@ -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,9 +1294,12 @@ 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));
if (!is_tzid) {
mark_unsafe_to_rewrite(result);
}
}
if (std::find(event.recurrence_additions.begin(),
event.recurrence_additions.end(), parsed->value)
== event.recurrence_additions.end()) {
@@ -1275,9 +1320,12 @@ 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));
if (!is_tzid) {
mark_unsafe_to_rewrite(result);
}
}
if (std::find(event.recurrence_exceptions.begin(),
event.recurrence_exceptions.end(), parsed->value)
== event.recurrence_exceptions.end()) {
@@ -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,9 +1447,12 @@ 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));
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() + "'");
}

View File

@@ -697,8 +697,14 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat
"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");
// VTIMEZONE is now parsed and preserved for round-trip, so the calendar
// is safe to rewrite even though the custom TZID is unused by the event.
const auto vtz_result = nocal::storage::IcsStore::load(custom_zone);
require(vtz_result.safe_to_rewrite,
"VTIMEZONE definition should be safe when round-trippable");
require(vtz_result.vtimezones.size() == 1 &&
vtz_result.vtimezones[0].tzid == "Custom/Test",
"VTIMEZONE should be captured even when no event references it");
struct InvalidRdate {
std::string start;
@@ -813,6 +819,131 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path&
require_no_temporary_files(path);
}
void test_vtimezone_custom_tzid_safe_and_round_trips(const std::filesystem::path& root) {
const auto path = root / "vtimezone-custom.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VTIMEZONE\r\n"
"TZID:Custom/Example\r\n"
"BEGIN:STANDARD\r\n"
"TZOFFSETFROM:+0100\r\n"
"TZOFFSETTO:+0000\r\n"
"DTSTART:19701025T030000\r\n"
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\r\n"
"END:STANDARD\r\n"
"BEGIN:DAYLIGHT\r\n"
"TZOFFSETFROM:+0000\r\n"
"TZOFFSETTO:+0100\r\n"
"DTSTART:19700329T020000\r\n"
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r\n"
"END:DAYLIGHT\r\n"
"END:VTIMEZONE\r\n"
"BEGIN:VEVENT\r\n"
"UID:custom-tz\r\n"
"DTSTART;TZID=Custom/Example:20260717T100000\r\n"
"DTEND;TZID=Custom/Example:20260717T110000\r\n"
"SUMMARY:Custom zone event\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite,
"VTIMEZONE-defined TZID should make the calendar safe to rewrite");
require(loaded.events.size() == 1, "VTIMEZONE calendar should contain the event");
require(loaded.events[0].time_zone == "Custom/Example",
"VTIMEZONE-defined TZID should be retained on the event");
require(loaded.events[0].time_basis == nocal::TimeBasis::zoned,
"zoned time basis should be preserved");
require(loaded.vtimezones.size() == 1,
"VTIMEZONE definition should be captured");
require(loaded.vtimezones[0].tzid == "Custom/Example",
"VTIMEZONE TZID should match");
require(!loaded.vtimezones[0].raw.empty(),
"VTIMEZONE raw content should be captured");
// Verify a VTIMEZONE-DST warning was emitted for unknown TZID.
bool has_dst_warning = false;
for (const auto& w : loaded.warnings) {
if (w.find("DST") != std::string::npos) {
has_dst_warning = true;
break;
}
}
require(has_dst_warning,
"should warn that DST handling is approximate for custom VTIMEZONE");
// Round-trip: save with VTIMEZONE preserved, then reload.
const auto saved_revision = nocal::storage::IcsStore::save(
path, loaded.events, loaded.revision, loaded.vtimezones);
require(saved_revision.existed(), "save should return an existing revision");
const std::string bytes = read_fixture(path);
require(bytes.find("BEGIN:VTIMEZONE") != std::string::npos,
"VTIMEZONE block should be serialized on save");
require(bytes.find("TZID:Custom/Example") != std::string::npos,
"VTIMEZONE TZID should appear in serialized output");
require(bytes.find("BEGIN:STANDARD") != std::string::npos,
"VTIMEZONE sub-components should be preserved");
const auto reloaded = nocal::storage::IcsStore::load(path);
require(reloaded.safe_to_rewrite, "round-tripped VTIMEZONE calendar should be safe");
require(reloaded.events.size() == 1, "round-trip event count should match");
require(reloaded.events[0].time_zone == "Custom/Example",
"round-trip TZID should be preserved");
require(reloaded.vtimezones.size() == 1,
"round-trip VTIMEZONE count should match");
require(reloaded.vtimezones[0].tzid == "Custom/Example",
"round-trip VTIMEZONE TZID should match");
require_no_temporary_files(path);
}
void test_vtimezone_multiple_tzids(const std::filesystem::path& root) {
const auto path = root / "vtimezone-multiple.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VTIMEZONE\r\nTZID:Custom/A\r\nEND:VTIMEZONE\r\n"
"BEGIN:VTIMEZONE\r\nTZID:Custom/B\r\nEND:VTIMEZONE\r\n"
"BEGIN:VEVENT\r\n"
"UID:evt-a\r\n"
"DTSTART;TZID=Custom/A:20260717T100000\r\n"
"DTEND;TZID=Custom/A:20260717T110000\r\n"
"SUMMARY:Event A\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:evt-b\r\n"
"DTSTART;TZID=Custom/B:20260718T100000\r\n"
"DTEND;TZID=Custom/B:20260718T110000\r\n"
"SUMMARY:Event B\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite,
"multiple VTIMEZONE-defined TZIDs should be safe");
require(loaded.events.size() == 2, "should parse all events");
require(loaded.vtimezones.size() == 2, "should capture both VTIMEZONE definitions");
}
void test_vtimezone_undefined_tzid_still_unsafe(const std::filesystem::path& root) {
const auto path = root / "vtimezone-undefined.ics";
// Event uses Custom/Unknown which is NOT defined in any VTIMEZONE.
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VTIMEZONE\r\nTZID:Custom/Defined\r\nEND:VTIMEZONE\r\n"
"BEGIN:VEVENT\r\nUID:undef\r\n"
"DTSTART;TZID=Custom/Unknown:20260717T100000\r\n"
"DTEND;TZID=Custom/Unknown:20260717T110000\r\n"
"SUMMARY:Undefined TZID\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite,
"undefined TZID should still be unsafe even when other VTIMEZONEs exist");
require(loaded.events.size() == 1, "event should still be browseable");
}
} // namespace
int main() {
@@ -843,6 +974,9 @@ int main() {
test_supported_rdates(root);
test_unsupported_recurrence_and_zones_are_unsafe(root);
test_save_validation_preserves_existing_bytes(root);
test_vtimezone_custom_tzid_safe_and_round_trips(root);
test_vtimezone_multiple_tzids(root);
test_vtimezone_undefined_tzid_still_unsafe(root);
std::filesystem::remove_all(root, ignored);
std::cout << "ics_tests: ok\n";
return 0;