Files
nocal/src/storage/ics_store.cpp
Bernardo Magri 3b73badf5d feat: RECURRENCE-ID parsing, serialization, and round-trip support
Add recurrence_id field to Event domain model. Parse and serialize
RECURRENCE-ID from/to iCalendar files. Override events (with
RECURRENCE-ID but no RRULE) are safe to rewrite. Save validation
rejects events with both RECURRENCE-ID and RRULE.
2026-07-23 18:55:31 +01:00

1732 lines
70 KiB
C++

#include "nocal/storage/ics_store.hpp"
#include <algorithm>
#include <charconv>
#include <chrono>
#include <cctype>
#include <ctime>
#include <fstream>
#include <iterator>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string_view>
#include <system_error>
#if !defined(_WIN32)
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
#endif
namespace nocal::storage {
namespace {
using namespace std::chrono;
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 {
std::optional<std::string> uid;
std::string title;
std::string location;
std::string description;
std::string calendar_id;
std::string color;
std::optional<ParsedTime> start;
std::optional<ParsedTime> end;
std::optional<ParsedTime> recurrence_id;
std::vector<PendingProperty> recurrence_rules;
std::vector<PendingProperty> recurrence_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) {
std::string result(value);
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char ch) {
return static_cast<char>(std::toupper(ch));
});
return result;
}
[[nodiscard]] std::string unescape_text(std::string_view value) {
std::string result;
result.reserve(value.size());
for (std::size_t i = 0; i < value.size(); ++i) {
if (value[i] != '\\' || i + 1 == value.size()) {
result.push_back(value[i]);
continue;
}
const char escaped = value[++i];
if (escaped == 'n' || escaped == 'N') {
result.push_back('\n');
} else {
// RFC 5545 TEXT escapes '\\', ';', and ','. Keeping the escaped
// character is the least surprising recovery for unknown escapes.
result.push_back(escaped);
}
}
return result;
}
[[nodiscard]] std::string escape_text(std::string_view value) {
std::string result;
result.reserve(value.size());
for (std::size_t i = 0; i < value.size(); ++i) {
switch (value[i]) {
case '\\': result += "\\\\"; break;
case ';': result += "\\;"; break;
case ',': result += "\\,"; break;
case '\r':
if (i + 1 < value.size() && value[i + 1] == '\n') {
++i;
}
result += "\\n";
break;
case '\n': result += "\\n"; break;
default: result.push_back(value[i]); break;
}
}
return result;
}
[[nodiscard]] bool parse_fixed_int(
std::string_view value, std::size_t offset, std::size_t count, int& destination) {
if (offset + count > value.size()) {
return false;
}
const char* first = value.data() + offset;
const char* last = first + count;
if (!std::all_of(first, last, [](unsigned char ch) { return std::isdigit(ch); })) {
return false;
}
const auto [ptr, ec] = std::from_chars(first, last, destination);
return ec == std::errc{} && ptr == last;
}
[[nodiscard]] std::optional<year_month_day> parse_ymd(std::string_view value) {
if (value.size() != 8) {
return std::nullopt;
}
int year_value = 0;
int month_value = 0;
int day_value = 0;
if (!parse_fixed_int(value, 0, 4, year_value)
|| !parse_fixed_int(value, 4, 2, month_value)
|| !parse_fixed_int(value, 6, 2, day_value)) {
return std::nullopt;
}
const year_month_day date{
year{year_value}, month{static_cast<unsigned>(month_value)}, day{static_cast<unsigned>(day_value)}};
return date.ok() ? std::optional{date} : std::nullopt;
}
[[nodiscard]] std::optional<TimePoint> make_local(
year_month_day date, int hour_value, int minute_value, int second_value) {
std::tm local{};
local.tm_year = static_cast<int>(date.year()) - 1900;
local.tm_mon = static_cast<int>(static_cast<unsigned>(date.month())) - 1;
local.tm_mday = static_cast<int>(static_cast<unsigned>(date.day()));
local.tm_hour = hour_value;
local.tm_min = minute_value;
local.tm_sec = second_value;
local.tm_isdst = -1;
const std::time_t converted = std::mktime(&local);
// mktime may normalize invalid or nonexistent local times. Accept timezone
// normalization only when the requested civil fields survive unchanged.
if (local.tm_year != static_cast<int>(date.year()) - 1900
|| local.tm_mon != static_cast<int>(static_cast<unsigned>(date.month())) - 1
|| local.tm_mday != static_cast<int>(static_cast<unsigned>(date.day()))
|| local.tm_hour != hour_value || local.tm_min != minute_value
|| local.tm_sec != second_value) {
return std::nullopt;
}
return system_clock::from_time_t(converted);
}
struct TimeParameters {
bool valid = true;
bool value_is_date = false;
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;
}
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 (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) + "'";
return std::nullopt;
}
const auto local = make_local(*date, 0, 0, 0);
if (!local) {
warning = "DATE does not map to a local midnight: '" + std::string(value) + "'";
return std::nullopt;
}
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) + "'";
return std::nullopt;
}
const auto date = parse_ymd(value.substr(0, 8));
int hour_value = 0;
int minute_value = 0;
int second_value = 0;
if (!date || !parse_fixed_int(value, 9, 2, hour_value)
|| !parse_fixed_int(value, 11, 2, minute_value)
|| !parse_fixed_int(value, 13, 2, second_value)
|| hour_value > 23 || minute_value > 59 || second_value > 60) {
warning = "invalid DATE-TIME value '" + std::string(value) + "'";
return std::nullopt;
}
// POSIX time has no representation for a leap second. Normalize it to the
// 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) {
warning = "DATE-TIME does not exist in the local timezone: '" + std::string(value) + "'";
return std::nullopt;
}
result = *local;
}
if (second_value == 60) {
result += seconds{1};
}
return ParsedTime{result, false, std::nullopt, basis, std::move(zone_name)};
}
[[nodiscard]] std::vector<std::string> unfold(std::istream& input) {
std::vector<std::string> lines;
std::string line;
while (std::getline(input, line)) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
if (!line.empty() && (line.front() == ' ' || line.front() == '\t')) {
if (!lines.empty()) {
lines.back().append(line.begin() + 1, line.end());
}
} else {
lines.push_back(std::move(line));
}
}
return lines;
}
[[nodiscard]] std::size_t content_separator(std::string_view line) {
bool quoted = false;
for (std::size_t i = 0; i < line.size(); ++i) {
if (line[i] == '"') {
quoted = !quoted;
} else if (line[i] == ':' && !quoted) {
return i;
}
}
return std::string_view::npos;
}
void add_warning(LoadResult& result, std::size_t line, std::string message) {
result.warnings.push_back(
"line " + std::to_string(line) + ": " + std::move(message));
}
void mark_unsafe_to_rewrite(LoadResult& result) {
if (result.safe_to_rewrite) {
result.safe_to_rewrite = false;
result.warnings.emplace_back(
"calendar contains data nocal cannot preserve; editing is disabled to prevent data loss");
}
}
[[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;
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, ',')) {
int ordinal = 0;
std::string_view weekday_part = day_name;
// Check for ordinal prefix: optional sign, optional digits
if (!day_name.empty() && (day_name[0] == '+' || day_name[0] == '-' || (day_name[0] >= '0' && day_name[0] <= '9'))) {
std::size_t digit_end = 0;
if (day_name[0] == '+' || day_name[0] == '-') {
digit_end = 1;
}
while (digit_end < day_name.size() && day_name[digit_end] >= '0' && day_name[digit_end] <= '9') {
++digit_end;
}
if (digit_end > 0U && digit_end < day_name.size()) {
std::string number_str(day_name.substr(0, digit_end));
ordinal = std::stoi(number_str);
if (ordinal < -5 || ordinal > 5 || ordinal == 0) {
error = "RRULE BYDAY ordinal must be from -5 to -1 or 1 to 5";
return std::nullopt;
}
weekday_part = day_name.substr(digit_end);
}
}
const auto day = parse_weekday(weekday_part);
if (!day) {
error = "RRULE BYDAY contains an invalid weekday";
return std::nullopt;
}
rule.by_weekday.push_back({ordinal, *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;
}
return rule;
}
[[nodiscard]] std::tm utc_tm(std::time_t time) {
std::tm result{};
#if defined(_WIN32)
gmtime_s(&result, &time);
#else
gmtime_r(&time, &result);
#endif
return result;
}
[[nodiscard]] std::tm local_tm(std::time_t time) {
std::tm result{};
#if defined(_WIN32)
localtime_s(&result, &time);
#else
localtime_r(&time, &result);
#endif
return result;
}
[[nodiscard]] std::string two_digits(int value) {
std::string result(2, '0');
result[0] = static_cast<char>('0' + (value / 10) % 10);
result[1] = static_cast<char>('0' + value % 10);
return result;
}
[[nodiscard]] std::string four_digits(int value) {
std::string result(4, '0');
for (int index = 3; index >= 0; --index) {
result[static_cast<std::size_t>(index)] = static_cast<char>('0' + value % 10);
value /= 10;
}
return result;
}
[[nodiscard]] std::string format_date(TimePoint value) {
const std::tm local = local_tm(system_clock::to_time_t(value));
return four_digits(local.tm_year + 1900) + two_digits(local.tm_mon + 1)
+ two_digits(local.tm_mday);
}
[[nodiscard]] std::string format_utc_time(TimePoint value) {
const auto whole_seconds = floor<seconds>(value);
const std::tm utc = utc_tm(system_clock::to_time_t(whole_seconds));
return four_digits(utc.tm_year + 1900) + two_digits(utc.tm_mon + 1)
+ two_digits(utc.tm_mday) + "T" + two_digits(utc.tm_hour)
+ 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) {
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) {
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,
[](const ByWeekday& bw) {
std::string out;
if (bw.ordinal != 0) {
out += (bw.ordinal > 0 ? "+" : "") + std::to_string(bw.ordinal);
}
out += weekday_name(bw.day);
return out;
});
}
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()
&& (static_cast<unsigned char>(value[end]) & 0xC0U) == 0x80U) {
--end;
}
return end == begin ? std::min(value.size(), begin + limit) : end;
}
void write_folded(std::ostream& output, std::string_view content_line) {
std::size_t offset = 0;
bool first = true;
do {
const std::size_t available = first ? 75 : 74;
const std::size_t end = utf8_boundary(content_line, offset, available);
if (!first) {
output.put(' ');
}
output.write(content_line.data() + offset, static_cast<std::streamsize>(end - offset));
output << "\r\n";
offset = end;
first = false;
} while (offset < content_line.size());
}
void write_text_property(std::ostream& output, std::string_view name, std::string_view value) {
if (!value.empty()) {
write_folded(output, std::string(name) + ":" + escape_text(value));
}
}
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));
if (event.recurrence_id) {
const std::string params = event.all_day ? ";VALUE=DATE" : time_property_parameters(event);
write_folded(output, "RECURRENCE-ID" + params + ":"
+ (event.all_day ? format_date(*event.recurrence_id)
: format_event_time(event, *event.recurrence_id)));
}
if (event.all_day) {
write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start));
write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end));
} else {
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_additions.empty()) {
const std::string parameters = time_property_parameters(event);
const std::string values = join_values(event.recurrence_additions,
[&event](TimePoint addition) {
return event.all_day ? format_date(addition)
: format_event_time(event, addition);
});
write_folded(output, "RDATE" + parameters + ":" + values);
}
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);
write_text_property(output, "DESCRIPTION", event.description);
write_text_property(output, "X-NOCAL-CALENDAR-ID", event.calendar_id);
write_text_property(output, "X-NOCAL-COLOR", event.color);
write_folded(output, "END:VEVENT");
}
write_folded(output, "END:VCALENDAR");
}
struct FileSnapshot {
bool existed = false;
std::string bytes;
};
[[nodiscard]] bool same_file_state(
bool expected_existed, const std::string* expected_bytes,
const FileSnapshot& current) noexcept {
if (expected_existed != current.existed) {
return false;
}
if (!expected_existed) {
return true;
}
return expected_bytes != nullptr && *expected_bytes == current.bytes;
}
#if !defined(_WIN32)
[[noreturn]] void throw_io_error(std::string_view operation, const std::filesystem::path& path) {
const int error = errno;
throw std::runtime_error(
std::string(operation) + " '" + path.string() + "': " + std::strerror(error));
}
class FileDescriptor {
public:
explicit FileDescriptor(int descriptor = -1) noexcept : descriptor_(descriptor) {}
FileDescriptor(const FileDescriptor&) = delete;
FileDescriptor& operator=(const FileDescriptor&) = delete;
FileDescriptor(FileDescriptor&& other) noexcept : descriptor_(other.release()) {}
~FileDescriptor() {
if (descriptor_ >= 0) {
::close(descriptor_);
}
}
[[nodiscard]] int get() const noexcept { return descriptor_; }
int release() noexcept {
const int result = descriptor_;
descriptor_ = -1;
return result;
}
private:
int descriptor_;
};
class TemporaryFile {
public:
TemporaryFile(std::filesystem::path path, int descriptor)
: path_(std::move(path)), descriptor_(descriptor) {}
TemporaryFile(const TemporaryFile&) = delete;
TemporaryFile& operator=(const TemporaryFile&) = delete;
TemporaryFile(TemporaryFile&& other) noexcept
: path_(std::move(other.path_)), descriptor_(std::move(other.descriptor_)),
remove_on_destroy_(other.remove_on_destroy_) {
other.remove_on_destroy_ = false;
}
~TemporaryFile() {
if (descriptor_.get() >= 0) {
::close(descriptor_.release());
}
if (remove_on_destroy_) {
std::error_code ignored;
std::filesystem::remove(path_, ignored);
}
}
[[nodiscard]] int descriptor() const noexcept { return descriptor_.get(); }
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
void close_checked() {
const int descriptor = descriptor_.release();
if (::close(descriptor) != 0) {
throw_io_error("unable to close temporary calendar", path_);
}
}
void committed() noexcept { remove_on_destroy_ = false; }
private:
std::filesystem::path path_;
FileDescriptor descriptor_;
bool remove_on_destroy_ = true;
};
[[nodiscard]] FileSnapshot read_regular_file(
const std::filesystem::path& path, std::string_view purpose) {
const int descriptor = ::open(path.c_str(), O_RDONLY | O_CLOEXEC);
if (descriptor < 0) {
if (errno == ENOENT) {
return {};
}
throw_io_error(purpose, path);
}
const FileDescriptor file(descriptor);
struct stat metadata {};
if (::fstat(file.get(), &metadata) != 0) {
throw_io_error(purpose, path);
}
if (!S_ISREG(metadata.st_mode)) {
throw std::runtime_error(
std::string(purpose) + " '" + path.string() + "': not a regular file");
}
FileSnapshot snapshot;
snapshot.existed = true;
if (metadata.st_size > 0) {
snapshot.bytes.reserve(static_cast<std::size_t>(metadata.st_size));
}
char buffer[16 * 1024];
while (true) {
const ssize_t count = ::read(file.get(), buffer, sizeof(buffer));
if (count < 0) {
if (errno == EINTR) {
continue;
}
throw_io_error(purpose, path);
}
if (count == 0) {
break;
}
snapshot.bytes.append(buffer, static_cast<std::size_t>(count));
}
return snapshot;
}
[[nodiscard]] FileDescriptor lock_destination(const std::filesystem::path& path) {
std::filesystem::path lock_path = path;
lock_path += ".lock";
const int descriptor = ::open(lock_path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, S_IRUSR | S_IWUSR);
if (descriptor < 0) {
throw_io_error("unable to open calendar lock", lock_path);
}
FileDescriptor lock(descriptor);
while (::flock(lock.get(), LOCK_EX) != 0) {
if (errno != EINTR) {
throw_io_error("unable to lock calendar", lock_path);
}
}
return lock;
}
[[nodiscard]] TemporaryFile create_temporary_file(
const std::filesystem::path& directory, const std::filesystem::path& destination) {
const std::string pattern_string =
(directory / ("." + destination.filename().string() + ".tmp.XXXXXX")).string();
std::vector<char> pattern(pattern_string.begin(), pattern_string.end());
pattern.push_back('\0');
const int descriptor = ::mkstemp(pattern.data());
if (descriptor < 0) {
throw_io_error("unable to create temporary calendar", destination);
}
TemporaryFile temporary{std::filesystem::path{pattern.data()}, descriptor};
const int flags = ::fcntl(descriptor, F_GETFD);
if (flags < 0 || ::fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC) != 0) {
throw_io_error("unable to secure temporary calendar", temporary.path());
}
if (::fchmod(descriptor, S_IRUSR | S_IWUSR) != 0) {
throw_io_error("unable to set temporary calendar permissions", temporary.path());
}
return temporary;
}
void write_all(int descriptor, std::string_view bytes, const std::filesystem::path& path) {
std::size_t offset = 0;
while (offset < bytes.size()) {
const ssize_t written = ::write(descriptor, bytes.data() + offset, bytes.size() - offset);
if (written < 0) {
if (errno == EINTR) {
continue;
}
throw_io_error("unable to write temporary calendar", path);
}
if (written == 0) {
throw std::runtime_error("unable to write temporary calendar '" + path.string()
+ "': write made no progress");
}
offset += static_cast<std::size_t>(written);
}
}
[[nodiscard]] TemporaryFile stage_file(
const std::filesystem::path& directory, const std::filesystem::path& destination,
std::string_view bytes) {
TemporaryFile temporary = create_temporary_file(directory, destination);
write_all(temporary.descriptor(), bytes, temporary.path());
if (::fsync(temporary.descriptor()) != 0) {
throw_io_error("unable to flush temporary calendar", temporary.path());
}
temporary.close_checked();
return temporary;
}
[[nodiscard]] FileDescriptor open_directory(const std::filesystem::path& directory) {
const int directory_descriptor = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (directory_descriptor < 0) {
throw_io_error("unable to open calendar directory", directory);
}
return FileDescriptor(directory_descriptor);
}
void replace_with_temporary(
TemporaryFile& temporary, const std::filesystem::path& destination,
std::string_view operation) {
if (::rename(temporary.path().c_str(), destination.c_str()) != 0) {
throw_io_error(operation, destination);
}
temporary.committed();
}
#else
[[nodiscard]] FileSnapshot read_regular_file(
const std::filesystem::path& path, std::string_view purpose) {
std::error_code error;
if (!std::filesystem::exists(path, error)) {
if (!error) {
return {};
}
throw std::runtime_error(
std::string(purpose) + " '" + path.string() + "': " + error.message());
}
if (!std::filesystem::is_regular_file(path, error) || error) {
throw std::runtime_error(
std::string(purpose) + " '" + path.string() + "': not a regular file");
}
std::ifstream input(path, std::ios::binary);
if (!input) {
throw std::runtime_error(std::string(purpose) + " '" + path.string() + "'");
}
FileSnapshot snapshot;
snapshot.existed = true;
snapshot.bytes.assign(std::istreambuf_iterator<char>{input}, {});
if (!input.eof()) {
throw std::runtime_error(std::string(purpose) + " '" + path.string() + "'");
}
return snapshot;
}
#endif
[[noreturn]] void throw_external_change(const std::filesystem::path& path) {
throw std::runtime_error(
"calendar changed externally; reload before saving '" + path.string() + "'");
}
[[nodiscard]] FileSnapshot read_current_file(
const std::filesystem::path& path, bool has_expected_revision) {
try {
return read_regular_file(path, "unable to read current calendar");
} catch (const std::runtime_error&) {
// A file that became unreadable or changed type cannot match an exact
// revision. Keep the optimistic-concurrency diagnostic actionable.
if (has_expected_revision) {
throw_external_change(path);
}
throw;
}
}
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 (const ByWeekday& bw : rule.by_weekday) {
if (!bw.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");
}
}
}
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");
}
if (event.recurrence_id && event.recurrence) {
throw std::invalid_argument(
"cannot save override event '" + event.uid
+ "' with both RECURRENCE-ID and RRULE");
}
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 (!is_recurring(event) && !event.recurrence_exceptions.empty()) {
throw std::invalid_argument(
"cannot save event '" + event.uid
+ "' with EXDATE values but no recurrence set");
}
std::vector<TimePoint> unique_additions = event.recurrence_additions;
std::sort(unique_additions.begin(), unique_additions.end());
if (std::adjacent_find(unique_additions.begin(), unique_additions.end())
!= unique_additions.end()) {
throw std::invalid_argument(
"cannot save event '" + event.uid + "' with duplicate RDATE values");
}
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&) {
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");
}
}
validate_recurrence(event);
}
} // namespace
LoadResult IcsStore::load(const std::filesystem::path& path) {
LoadResult result;
FileSnapshot snapshot;
try {
snapshot = read_regular_file(path, "unable to read calendar");
} catch (const std::runtime_error&) {
std::error_code error;
result.revision.existed_ = std::filesystem::exists(path, error) && !error;
result.warnings.push_back("unable to open '" + path.string() + "' for reading");
result.safe_to_rewrite = false;
return result;
}
result.revision.existed_ = snapshot.existed;
if (!snapshot.existed) {
return result;
}
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;
std::size_t event_start_line = 0;
for (std::size_t index = 0; index < lines.size(); ++index) {
const std::string_view line = lines[index];
const std::size_t separator = content_separator(line);
if (separator == std::string_view::npos) {
if (pending) {
add_warning(result, index + 1, "ignored malformed content line");
}
if (!line.empty()) {
mark_unsafe_to_rewrite(result);
}
continue;
}
const std::string_view head = line.substr(0, separator);
const std::string_view value = line.substr(separator + 1);
const std::size_t semicolon = head.find(';');
const std::string name = upper(head.substr(0, semicolon));
const std::string_view parameters = semicolon == std::string_view::npos
? std::string_view{} : head.substr(semicolon + 1);
if (name == "BEGIN" && upper(value) == "VEVENT") {
if (pending) {
add_warning(result, event_start_line, "nested VEVENT; discarded incomplete event");
mark_unsafe_to_rewrite(result);
}
pending.emplace();
event_start_line = index + 1;
continue;
}
if (name == "END" && upper(value) == "VEVENT") {
if (!pending) {
add_warning(result, index + 1, "END:VEVENT without BEGIN:VEVENT");
mark_unsafe_to_rewrite(result);
continue;
}
if (!pending->uid || pending->uid->empty()) {
add_warning(result, event_start_line, "VEVENT has no UID and was skipped");
mark_unsafe_to_rewrite(result);
} 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 && !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;
TimePoint end = pending->start->value;
if (pending->end) {
end = pending->end->value;
} else if (all_day) {
const auto next_date = year_month_day{
sys_days{*pending->start->date} + days{1}};
const auto next_midnight = make_local(next_date, 0, 0, 0);
if (!next_midnight) {
add_warning(result, event_start_line,
"could not determine implicit all-day DTEND; event was skipped");
mark_unsafe_to_rewrite(result);
pending.reset();
continue;
}
end = *next_midnight;
}
if (end < pending->start->value || (all_day && end == pending->start->value)) {
add_warning(result, event_start_line, "DTEND is before DTSTART; event was skipped");
mark_unsafe_to_rewrite(result);
} else {
Event event;
event.uid = std::move(*pending->uid);
event.title = std::move(pending->title);
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_id) {
event.recurrence_id = pending->recurrence_id->value;
}
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->recurrence_dates) {
for (const std::string_view addition_value : split(property.value, ',')) {
std::string warning;
const auto parsed = parse_time(
property.parameters, addition_value, warning);
if (!parsed || !same_time_kind(*pending->start, *parsed)) {
add_warning(result, property.line, parsed
? "RDATE value type, time basis, or TZID does not match DTSTART"
: std::move(warning));
mark_unsafe_to_rewrite(result);
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()) {
event.recurrence_additions.push_back(parsed->value);
}
}
}
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()) {
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()) {
event.recurrence_exceptions.push_back(parsed->value);
}
}
}
if (!is_recurring(event) && !event.recurrence_exceptions.empty()) {
add_warning(result, event_start_line,
"EXDATE without a supported RRULE or RDATE cannot be edited safely");
mark_unsafe_to_rewrite(result);
}
result.events.push_back(std::move(event));
}
}
pending.reset();
continue;
}
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");
const bool supported_property = name == "VERSION" || name == "PRODID"
|| 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"
|| name == "RDATE" || name == "EXDATE" || name == "RECURRENCE-ID";
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 && !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") {
pending->uid = unescape_text(value);
} else if (name == "SUMMARY") {
pending->title = unescape_text(value);
} else if (name == "LOCATION") {
pending->location = unescape_text(value);
} else if (name == "DESCRIPTION") {
pending->description = unescape_text(value);
} else if (name == "X-NOCAL-CALENDAR-ID") {
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 == "RDATE") {
pending->recurrence_dates.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) {
add_warning(result, index + 1, std::move(warning));
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;
}
} else if (name == "RECURRENCE-ID") {
if (pending->recurrence_id) {
add_warning(result, index + 1, "duplicate RECURRENCE-ID property");
mark_unsafe_to_rewrite(result);
}
std::string warning;
auto parsed = parse_time(parameters, value, warning);
if (!parsed) {
add_warning(result, index + 1, std::move(warning));
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);
}
}
pending->recurrence_id = *parsed;
}
}
}
if (pending) {
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;
}
std::filesystem::path IcsStore::backup_path(const std::filesystem::path& path) {
std::filesystem::path backup = path;
backup += ".bak";
return backup;
}
FileRevision IcsStore::save(
const std::filesystem::path& path, std::span<const Event> events,
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, vtimezones);
}
std::ostringstream serialized(std::ios::out | std::ios::binary);
serialize_calendar(serialized, events, vtimezones);
if (!serialized) {
throw std::runtime_error("unable to serialize calendar '" + path.string() + "'");
}
std::string bytes = serialized.str();
const std::filesystem::path directory =
path.parent_path().empty() ? std::filesystem::path{"."} : path.parent_path();
std::error_code directory_error;
std::filesystem::create_directories(directory, directory_error);
if (directory_error) {
throw std::runtime_error("unable to create calendar directory '" + directory.string()
+ "': " + directory_error.message());
}
#if !defined(_WIN32)
const FileDescriptor lock = lock_destination(path);
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
// Prepare and flush every byte before changing either destination.
TemporaryFile calendar_temporary = stage_file(directory, path, bytes);
std::optional<TemporaryFile> backup_temporary;
const auto backup = backup_path(path);
if (current.existed) {
backup_temporary.emplace(stage_file(directory, backup, current.bytes));
}
const FileDescriptor directory_handle = open_directory(directory);
const FileSnapshot rechecked = read_current_file(path, true);
if (!same_file_state(current.existed, &current.bytes, rechecked)) {
throw_external_change(path);
}
// The advisory lock fully serializes Nocal writers. A program that ignores
// it can still replace the path after this comparison and before rename;
// portable POSIX rename does not provide a true compare-and-swap primitive.
if (backup_temporary) {
replace_with_temporary(*backup_temporary, backup, "unable to replace calendar backup");
(void)::fsync(directory_handle.get());
}
replace_with_temporary(calendar_temporary, path, "unable to replace calendar");
// The calendar rename is the commit point. Reporting a later directory
// fsync failure as an unchanged save would make caller rollback incorrect.
(void)::fsync(directory_handle.get());
#else
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
const auto write_replacement = [](const std::filesystem::path& destination,
std::string_view replacement) {
std::filesystem::path temporary = destination;
temporary += ".tmp";
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) {
throw std::runtime_error("unable to open temporary file '" + temporary.string() + "'");
}
output.write(replacement.data(), static_cast<std::streamsize>(replacement.size()));
output.flush();
if (!output) {
throw std::runtime_error("failed while writing temporary file '" + temporary.string() + "'");
}
output.close();
std::error_code ignored;
std::filesystem::remove(destination, ignored);
std::filesystem::rename(temporary, destination);
};
if (current.existed) {
write_replacement(backup_path(path), current.bytes);
}
write_replacement(path, bytes);
#endif
FileRevision revision;
revision.existed_ = true;
revision.contents_ = std::make_shared<const std::string>(std::move(bytes));
return revision;
}
FileRevision IcsStore::restore_backup(
const std::filesystem::path& path, std::optional<FileRevision> expected) {
if (path.empty() || path.filename().empty()) {
throw std::invalid_argument("calendar path must name a file");
}
const std::filesystem::path directory =
path.parent_path().empty() ? std::filesystem::path{"."} : path.parent_path();
const auto backup = backup_path(path);
#if !defined(_WIN32)
const FileDescriptor lock = lock_destination(path);
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
const FileSnapshot saved = read_regular_file(backup, "unable to read calendar backup");
if (!saved.existed) {
throw std::runtime_error("calendar backup does not exist '" + backup.string() + "'");
}
TemporaryFile temporary = stage_file(directory, path, saved.bytes);
const FileDescriptor directory_handle = open_directory(directory);
const FileSnapshot rechecked = read_current_file(path, true);
if (!same_file_state(current.existed, &current.bytes, rechecked)) {
throw_external_change(path);
}
replace_with_temporary(temporary, path, "unable to restore calendar backup");
(void)::fsync(directory_handle.get());
#else
const FileSnapshot current = read_current_file(path, expected.has_value());
if (expected && !same_file_state(
expected->existed_, expected->contents_.get(), current)) {
throw_external_change(path);
}
const FileSnapshot saved = read_regular_file(backup, "unable to read calendar backup");
if (!saved.existed) {
throw std::runtime_error("calendar backup does not exist '" + backup.string() + "'");
}
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output) {
throw std::runtime_error("unable to open '" + path.string() + "' for restoring");
}
output.write(saved.bytes.data(), static_cast<std::streamsize>(saved.bytes.size()));
output.flush();
if (!output) {
throw std::runtime_error("failed while restoring '" + path.string() + "'");
}
#endif
FileRevision revision;
revision.existed_ = true;
revision.contents_ = std::make_shared<const std::string>(saved.bytes);
return revision;
}
} // namespace nocal::storage