diff --git a/include/nocal/storage/ics_store.hpp b/include/nocal/storage/ics_store.hpp index 636a4c6..c2f741a 100644 --- a/include/nocal/storage/ics_store.hpp +++ b/include/nocal/storage/ics_store.hpp @@ -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 events; std::vector 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 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 events, - std::optional expected = std::nullopt); + std::optional expected = std::nullopt, + std::span vtimezones = {}); // Restores the exact bytes in .bak without changing the backup. static FileRevision restore_backup( diff --git a/src/storage/ics_store.cpp b/src/storage/ics_store.cpp index 9b8b91f..f02d8b9 100644 --- a/src/storage/ics_store.cpp +++ b/src/storage/ics_store.cpp @@ -55,6 +55,16 @@ struct PendingEvent { std::vector exception_dates; }; +// Accumulated VTIMEZONE definitions from the source calendar (populated during load). +std::vector 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 split( std::string_view value, char delimiter) { std::vector parts; @@ -586,17 +600,22 @@ template } [[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(value)); - const local_days date = floor(local); - const year_month_day ymd{date}; - const hh_mm_ss time{local - date}; - return four_digits(static_cast(ymd.year())) - + two_digits(static_cast(static_cast(ymd.month()))) - + two_digits(static_cast(static_cast(ymd.day()))) + "T" - + two_digits(static_cast(time.hours().count())) - + two_digits(static_cast(time.minutes().count())) - + two_digits(static_cast(time.seconds().count())); + try { + const time_zone* zone = locate_zone(zone_name); + const local_seconds local = zone->to_local(floor(value)); + const local_days date = floor(local); + const year_month_day ymd{date}; + const hh_mm_ss time{local - date}; + return four_digits(static_cast(ymd.year())) + + two_digits(static_cast(static_cast(ymd.month()))) + + two_digits(static_cast(static_cast(ymd.day()))) + "T" + + two_digits(static_cast(time.hours().count())) + + two_digits(static_cast(time.minutes().count())) + + two_digits(static_cast(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 events) { +void serialize_calendar(std::ostream& output, std::span events, + std::span 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 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(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 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 events, - std::optional expected) { + std::optional expected, + std::span 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() + "'"); } diff --git a/tests/ics_tests.cpp b/tests/ics_tests.cpp index 261e05c..036c868 100644 --- a/tests/ics_tests.cpp +++ b/tests/ics_tests.cpp @@ -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;