feat: initialize Nocal terminal calendar

This commit is contained in:
2026-07-17 21:24:06 +01:00
commit 22c6399056
37 changed files with 6146 additions and 0 deletions

976
src/storage/ics_store.cpp Normal file
View File

@@ -0,0 +1,976 @@
#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;
};
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;
};
[[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);
}
[[nodiscard]] std::optional<ParsedTime> parse_time(
std::string_view parameters, std::string_view value, std::string& warning) {
const std::string normalized_parameters = upper(parameters);
bool value_is_date = false;
bool has_tzid = false;
std::size_t parameter_start = 0;
while (parameter_start <= normalized_parameters.size()) {
const std::size_t parameter_end = normalized_parameters.find(';', parameter_start);
const std::string_view parameter = std::string_view(normalized_parameters).substr(
parameter_start, parameter_end == std::string::npos
? std::string::npos : parameter_end - parameter_start);
value_is_date = value_is_date || parameter == "VALUE=DATE";
has_tzid = has_tzid || parameter.starts_with("TZID=");
if (parameter_end == std::string::npos) {
break;
}
parameter_start = parameter_end + 1;
}
if (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};
}
if (has_tzid) {
warning = "TZID is not supported; DATE-TIME was interpreted in the process local timezone";
}
const bool utc = !value.empty() && value.back() == 'Z';
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;
if (utc) {
result = sys_days{*date} + hours{hour_value} + minutes{minute_value}
+ seconds{represented_second};
} 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};
}
[[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 supported_time_parameters(std::string_view parameters) {
const std::string normalized = upper(parameters);
return normalized.empty() || normalized == "VALUE=DATE";
}
[[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::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) {
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");
for (const Event& event : events) {
write_folded(output, "BEGIN:VEVENT");
write_folded(output, "UID:" + escape_text(event.uid));
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 {
write_folded(output, "DTSTART:" + format_utc_time(event.start));
write_folded(output, "DTEND:" + format_utc_time(event.end));
}
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;
}
}
} // 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);
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 && pending->start->is_date != pending->end->is_date) {
add_warning(result, event_start_line, "DTSTART and DTEND value types 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.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);
result.events.push_back(std::move(event));
}
}
pending.reset();
continue;
}
if (!pending) {
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())) {
mark_unsafe_to_rewrite(result);
}
continue;
}
const bool time_property = name == "DTSTART" || name == "DTEND";
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)
|| (time_property && !supported_time_parameters(parameters))
|| (text_property && !parameters.empty())) {
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 == "DTSTART" || name == "DTEND") {
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()) {
add_warning(result, index + 1, std::move(warning));
}
if (name == "DTSTART") {
pending->start = *parsed;
} else {
pending->end = *parsed;
}
}
}
}
if (pending) {
add_warning(result, event_start_line, "unterminated VEVENT was skipped");
mark_unsafe_to_rewrite(result);
}
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) {
if (path.empty() || path.filename().empty()) {
throw std::invalid_argument("calendar path must name a file");
}
for (const Event& event : events) {
if (event.uid.empty()) {
throw std::invalid_argument("cannot save an event with an empty UID");
}
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");
}
}
std::ostringstream serialized(std::ios::out | std::ios::binary);
serialize_calendar(serialized, events);
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