feat: initialize Nocal terminal calendar
This commit is contained in:
976
src/storage/ics_store.cpp
Normal file
976
src/storage/ics_store.cpp
Normal 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, ¤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
|
||||
Reference in New Issue
Block a user