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.
1732 lines
70 KiB
C++
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, ¤t.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, ¤t.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
|