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; 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 { struct LoadResult {
std::vector<Event> events; std::vector<Event> events;
std::vector<std::string> warnings; std::vector<std::string> warnings;
@@ -36,6 +45,9 @@ struct LoadResult {
// Exact bytes that produced this result. Copies share immutable storage and // Exact bytes that produced this result. Copies share immutable storage and
// can be supplied to save/restore as an optimistic concurrency token. // can be supplied to save/restore as an optimistic concurrency token.
FileRevision revision; FileRevision revision;
// Embedded VTIMEZONE definitions from the source calendar, preserved for
// round-trip serialization. Populated only by load().
std::vector<VTimezoneDefinition> vtimezones;
}; };
class IcsStore { class IcsStore {
@@ -49,9 +61,11 @@ public:
// requests durable filesystem flushes. I/O failures before replacement are // requests durable filesystem flushes. I/O failures before replacement are
// reported as std::runtime_error. If expected is present, the destination // reported as std::runtime_error. If expected is present, the destination
// must still contain the exact bytes represented by that revision. // must still contain the exact bytes represented by that revision.
// Supply vtimezones to preserve embedded timezone definitions.
static FileRevision save( static FileRevision save(
const std::filesystem::path& path, std::span<const Event> events, 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. // Restores the exact bytes in <path>.bak without changing the backup.
static FileRevision restore_backup( static FileRevision restore_backup(

View File

@@ -55,6 +55,16 @@ struct PendingEvent {
std::vector<PendingProperty> exception_dates; 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) { [[nodiscard]] std::string upper(std::string_view value) {
std::string result(value); std::string result(value);
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char ch) { 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( [[nodiscard]] std::vector<std::string_view> split(
std::string_view value, char delimiter) { std::string_view value, char delimiter) {
std::vector<std::string_view> parts; 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) { [[nodiscard]] std::string format_zoned_time(TimePoint value, std::string_view zone_name) {
const time_zone* zone = locate_zone(zone_name); try {
const local_seconds local = zone->to_local(floor<seconds>(value)); const time_zone* zone = locate_zone(zone_name);
const local_days date = floor<days>(local); const local_seconds local = zone->to_local(floor<seconds>(value));
const year_month_day ymd{date}; const local_days date = floor<days>(local);
const hh_mm_ss time{local - date}; const year_month_day ymd{date};
return four_digits(static_cast<int>(ymd.year())) const hh_mm_ss time{local - date};
+ two_digits(static_cast<int>(static_cast<unsigned>(ymd.month()))) return four_digits(static_cast<int>(ymd.year()))
+ two_digits(static_cast<int>(static_cast<unsigned>(ymd.day()))) + "T" + two_digits(static_cast<int>(static_cast<unsigned>(ymd.month())))
+ two_digits(static_cast<int>(time.hours().count())) + two_digits(static_cast<int>(static_cast<unsigned>(ymd.day()))) + "T"
+ two_digits(static_cast<int>(time.minutes().count())) + two_digits(static_cast<int>(time.hours().count()))
+ two_digits(static_cast<int>(time.seconds().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) { [[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, "BEGIN:VCALENDAR");
write_folded(output, "VERSION:2.0"); write_folded(output, "VERSION:2.0");
write_folded(output, "PRODID:-//Nomarchy Linux//nocal 1.0//EN"); write_folded(output, "PRODID:-//Nomarchy Linux//nocal 1.0//EN");
write_folded(output, "CALSCALE:GREGORIAN"); 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) { for (const Event& event : events) {
write_folded(output, "BEGIN:VEVENT"); write_folded(output, "BEGIN:VEVENT");
write_folded(output, "UID:" + escape_text(event.uid)); 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()) { if (event.uid.empty()) {
throw std::invalid_argument("cannot save an event with an empty UID"); 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 { try {
(void)locate_zone(event.time_zone); (void)locate_zone(event.time_zone);
} catch (const std::runtime_error&) { } catch (const std::runtime_error&) {
throw std::invalid_argument( bool defined_by_vtimezone = std::any_of(vtimezones.begin(), vtimezones.end(),
"cannot save zoned event '" + event.uid + "' with unknown TZID '" [&event](const VTimezoneDefinition& tz) { return tz.tzid == event.time_zone; });
+ 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()) { } else if (!event.time_zone.empty()) {
throw std::invalid_argument( 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); 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); std::istringstream input(snapshot.bytes, std::ios::in | std::ios::binary);
const auto lines = unfold(input); const auto lines = unfold(input);
std::optional<PendingEvent> pending; std::optional<PendingEvent> pending;
@@ -1252,8 +1294,11 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
continue; continue;
} }
if (!warning.empty()) { if (!warning.empty()) {
const bool is_tzid = is_unknown_tzid_warning(warning);
add_warning(result, property.line, std::move(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(), if (std::find(event.recurrence_additions.begin(),
event.recurrence_additions.end(), parsed->value) event.recurrence_additions.end(), parsed->value)
@@ -1275,8 +1320,11 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
continue; continue;
} }
if (!warning.empty()) { if (!warning.empty()) {
const bool is_tzid = is_unknown_tzid_warning(warning);
add_warning(result, property.line, std::move(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(), if (std::find(event.recurrence_exceptions.begin(),
event.recurrence_exceptions.end(), parsed->value) event.recurrence_exceptions.end(), parsed->value)
@@ -1298,6 +1346,39 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
} }
if (!pending) { 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 std::string normalized_value = upper(value);
const bool structural = (name == "BEGIN" && normalized_value == "VCALENDAR") const bool structural = (name == "BEGIN" && normalized_value == "VCALENDAR")
|| (name == "END" && 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); mark_unsafe_to_rewrite(result);
} else { } else {
if (!warning.empty()) { if (!warning.empty()) {
const bool is_tzid = is_unknown_tzid_warning(warning);
add_warning(result, index + 1, std::move(warning)); add_warning(result, index + 1, std::move(warning));
mark_unsafe_to_rewrite(result); if (!is_tzid) {
mark_unsafe_to_rewrite(result);
}
} }
destination = *parsed; destination = *parsed;
} }
@@ -1378,6 +1462,59 @@ LoadResult IcsStore::load(const std::filesystem::path& path) {
add_warning(result, event_start_line, "unterminated VEVENT was skipped"); add_warning(result, event_start_line, "unterminated VEVENT was skipped");
mark_unsafe_to_rewrite(result); 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; return result;
} }
@@ -1389,16 +1526,17 @@ std::filesystem::path IcsStore::backup_path(const std::filesystem::path& path) {
FileRevision IcsStore::save( FileRevision IcsStore::save(
const std::filesystem::path& path, std::span<const Event> events, 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()) { if (path.empty() || path.filename().empty()) {
throw std::invalid_argument("calendar path must name a file"); throw std::invalid_argument("calendar path must name a file");
} }
for (const Event& event : events) { 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); std::ostringstream serialized(std::ios::out | std::ios::binary);
serialize_calendar(serialized, events); serialize_calendar(serialized, events, vtimezones);
if (!serialized) { if (!serialized) {
throw std::runtime_error("unable to serialize calendar '" + path.string() + "'"); 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" "END:VTIMEZONE\r\nBEGIN:VEVENT\r\nUID:custom\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n" "DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n"); "END:VEVENT\r\nEND:VCALENDAR\r\n");
require(!nocal::storage::IcsStore::load(custom_zone).safe_to_rewrite, // VTIMEZONE is now parsed and preserved for round-trip, so the calendar
"VTIMEZONE definition was considered rewrite-safe"); // 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 { struct InvalidRdate {
std::string start; std::string start;
@@ -813,6 +819,131 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path&
require_no_temporary_files(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 } // namespace
int main() { int main() {
@@ -843,6 +974,9 @@ int main() {
test_supported_rdates(root); test_supported_rdates(root);
test_unsupported_recurrence_and_zones_are_unsafe(root); test_unsupported_recurrence_and_zones_are_unsafe(root);
test_save_validation_preserves_existing_bytes(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::filesystem::remove_all(root, ignored);
std::cout << "ics_tests: ok\n"; std::cout << "ics_tests: ok\n";
return 0; return 0;