feat: CalDAV sync adapter with calendar discovery and delta sync

Add CalDAV protocol implementation: principal discovery, calendar-home-set
lookup, calendar listing, and sync-collection delta sync. CalDAVSync parses
iCalendar events from CalDAV resources and populates the sync cache.
CalDAVSyncProvider implements SyncProvider for account-level orchestration.
This commit is contained in:
2026-07-23 20:01:28 +01:00
parent 2dab5b97b6
commit 1a69950f93
7 changed files with 689 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
#pragma once
#include <string>
#include <string_view>
namespace nocal::sync {
// CalDAV account configuration.
struct CalDAVAccountConfig {
std::string server_url;
std::string username;
std::string password;
std::string account_id;
std::string display_name;
};
// Derive a deterministic account ID from server URL and username.
[[nodiscard]] std::string caldav_account_id(
std::string_view server_url, std::string_view username);
} // namespace nocal::sync

View File

@@ -0,0 +1,52 @@
#pragma once
#include "nocal/sync/caldav_account.hpp"
#include "nocal/sync/http.hpp"
#include "nocal/storage/sync_cache.hpp"
#include <string>
#include <vector>
namespace nocal::sync {
// CalDAV calendar listing entry.
struct CalDAVCalendar {
std::string href;
std::string name;
std::string description;
bool read_only{false};
};
class CalDAVSync {
public:
CalDAVSync(HttpTransport& transport, storage::SyncCache& cache,
const CalDAVAccountConfig& config);
// Discover principal URL and calendar-home-set.
void discover();
// List calendars under the calendar-home-set.
std::vector<CalDAVCalendar> list_calendars();
// Sync one calendar using sync-collection (delta) or full PROPFIND.
// `sync_token` is empty for initial sync. Returns new sync token.
std::string sync_calendar(const CalDAVCalendar& calendar,
std::string_view sync_token);
private:
HttpTransport& transport_;
storage::SyncCache& cache_;
CalDAVAccountConfig config_;
std::string principal_url_;
std::string calendar_home_set_;
std::string send_request(int method, const std::string& url,
const std::string& body, const std::vector<HttpHeader>& extra_headers);
std::string calendar_resource_url(const std::string& calendar_href) const;
std::string stable_calendar_id(const std::string& remote_id) const;
std::string stable_event_id(const std::string& calendar_id, const std::string& remote_uid) const;
std::string encode_basic_auth() const;
};
} // namespace nocal::sync

View File

@@ -0,0 +1,31 @@
#pragma once
#include "nocal/sync/caldav_account.hpp"
#include "nocal/sync/http.hpp"
#include "nocal/sync/provider.hpp"
namespace nocal::storage {
class SyncCache;
}
#include <string>
namespace nocal::sync {
class CalDAVSyncProvider final : public SyncProvider {
public:
CalDAVSyncProvider(HttpTransport& transport, storage::SyncCache& cache,
CalDAVAccountConfig config);
std::string_view provider_id() const noexcept override { return "caldav"; }
ConnectedAccount connect_account() override;
void sync_account(std::string_view account_id, SyncObserver& observer) override;
void disconnect_account(std::string_view account_id) override;
private:
HttpTransport& transport_;
storage::SyncCache& cache_;
CalDAVAccountConfig config_;
};
} // namespace nocal::sync

View File

@@ -26,6 +26,9 @@ nocal_sources = files(
'src/domain/month_layout.cpp', 'src/domain/month_layout.cpp',
'src/storage/ics_store.cpp', 'src/storage/ics_store.cpp',
'src/storage/sync_cache.cpp', 'src/storage/sync_cache.cpp',
'src/sync/caldav_account.cpp',
'src/sync/caldav_sync.cpp',
'src/sync/caldav_sync_provider.cpp',
'src/sync/caldav_xml.cpp', 'src/sync/caldav_xml.cpp',
'src/sync/curl_http.cpp', 'src/sync/curl_http.cpp',
'src/sync/desktop_oauth.cpp', 'src/sync/desktop_oauth.cpp',

View File

@@ -0,0 +1,42 @@
#include "nocal/sync/caldav_account.hpp"
#include <openssl/evp.h>
#include <array>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <string_view>
namespace nocal::sync {
namespace {
[[nodiscard]] std::string sha256_hex(std::string_view input) {
std::array<unsigned char, 32> digest{};
unsigned int size = 0;
if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1
|| size != digest.size()) {
throw std::runtime_error("cryptographic failure");
}
static constexpr char hexadecimal[] = "0123456789abcdef";
std::string output;
output.reserve(digest.size() * 2U);
for (const auto byte : digest) {
output.push_back(hexadecimal[(byte >> 4) & 0xf]);
output.push_back(hexadecimal[byte & 0xf]);
}
return output;
}
} // namespace
std::string caldav_account_id(const std::string_view server_url,
const std::string_view username) {
std::string input("caldav-account\0");
input += server_url;
input.push_back('\0');
input += username;
return std::string("caldav-account-") + sha256_hex(input);
}
} // namespace nocal::sync

434
src/sync/caldav_sync.cpp Normal file
View File

@@ -0,0 +1,434 @@
#include "nocal/sync/caldav_sync.hpp"
#include "nocal/sync/caldav_xml.hpp"
#include "nocal/storage/ics_store.hpp"
#include <openssl/evp.h>
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace nocal::sync {
namespace {
[[nodiscard]] std::string sha256_hex(std::string_view input) {
std::array<unsigned char, 32> digest{};
unsigned int size = 0;
if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1
|| size != digest.size()) {
throw std::runtime_error("cryptographic failure");
}
static constexpr char hexadecimal[] = "0123456789abcdef";
std::string output;
output.reserve(digest.size() * 2U);
for (const auto byte : digest) {
output.push_back(hexadecimal[(byte >> 4) & 0xf]);
output.push_back(hexadecimal[byte & 0xf]);
}
return output;
}
[[nodiscard]] std::string base64_encode(std::string_view input) {
static constexpr char table[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string output;
output.reserve(((input.size() + 2) / 3) * 4);
std::size_t i = 0;
while (i + 2 < input.size()) {
const std::uint32_t octet = (static_cast<std::uint32_t>(
static_cast<unsigned char>(input[i]))
<< 16)
| (static_cast<std::uint32_t>(static_cast<unsigned char>(input[i + 1])) << 8)
| static_cast<std::uint32_t>(static_cast<unsigned char>(input[i + 2]));
output.push_back(table[(octet >> 18) & 0x3f]);
output.push_back(table[(octet >> 12) & 0x3f]);
output.push_back(table[(octet >> 6) & 0x3f]);
output.push_back(table[octet & 0x3f]);
i += 3;
}
const std::size_t remaining = input.size() - i;
if (remaining > 0) {
const std::uint32_t octet = (static_cast<std::uint32_t>(
static_cast<unsigned char>(input[i]))
<< 16);
if (remaining == 2) {
const std::uint32_t second = static_cast<std::uint32_t>(
static_cast<unsigned char>(input[i + 1]))
<< 8;
output.push_back(table[((octet | second) >> 18) & 0x3f]);
output.push_back(table[((octet | second) >> 12) & 0x3f]);
output.push_back(table[((octet | second) >> 6) & 0x3f]);
output.push_back('=');
} else {
output.push_back(table[(octet >> 18) & 0x3f]);
output.push_back(table[(octet >> 12) & 0x3f]);
output.push_back('=');
output.push_back('=');
}
}
return output;
}
std::string strip_href_prefix(const std::string_view href,
const std::string_view base) {
if (href.size() >= base.size() && href.substr(0, base.size()) == base) {
return std::string(href.substr(base.size()));
}
return std::string(href);
}
} // namespace
CalDAVSync::CalDAVSync(HttpTransport& transport, storage::SyncCache& cache,
const CalDAVAccountConfig& config)
: transport_(transport), cache_(cache), config_(config) {}
void CalDAVSync::discover() {
// Step 1: Find principal URL
const std::string principal_body = R"(<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:principal-URL/>
</D:prop>
</D:propfind>)";
std::vector<HttpHeader> headers{
{"Depth", "0"},
{"Content-Type", "application/xml; charset=utf-8"},
{"Authorization", "Basic " + encode_basic_auth()},
};
const std::string principal_response = send_request(
static_cast<int>(HttpMethod::propfind), config_.server_url + "/",
principal_body, headers);
const auto principal_root = parse_xml(principal_response);
const auto responses = find_children(principal_root, "D:response");
if (responses.empty()) {
throw std::runtime_error("CalDAV: no response in principal discovery");
}
for (const auto* response : responses) {
const auto href_results = find_children(*response, "D:href");
if (href_results.empty()) continue;
const std::string href = std::string(href_results[0]->text);
const auto url_results = find_children(*response, "D:principal-URL");
if (url_results.empty()) continue;
principal_url_ = std::string(url_results[0]->text);
break;
}
if (principal_url_.empty()) {
throw std::runtime_error("CalDAV: principal URL not found");
}
// Step 2: Find calendar-home-set
const std::string home_body = R"(<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<C:calendar-home-set xmlns:C="urn:ietf:params:xml:ns:caldav"/>
</D:prop>
</D:propfind>)";
const std::string home_response = send_request(
static_cast<int>(HttpMethod::propfind), principal_url_, home_body, headers);
const auto home_root = parse_xml(home_response);
const auto home_responses = find_children(home_root, "D:response");
if (home_responses.empty()) {
throw std::runtime_error("CalDAV: no response in calendar-home-set discovery");
}
for (const auto* response : home_responses) {
const auto home_set = find_children(*response, "C:calendar-home-set");
if (!home_set.empty()) {
calendar_home_set_ = std::string(home_set[0]->text);
break;
}
}
if (calendar_home_set_.empty()) {
throw std::runtime_error("CalDAV: calendar-home-set not found");
}
}
std::vector<CalDAVCalendar> CalDAVSync::list_calendars() {
const std::string calendar_body = R"(<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:displayname/>
<D:resourcetype/>
<D:sync-token/>
<C:calendar-description/>
</D:prop>
</D:propfind>)";
std::vector<HttpHeader> headers{
{"Depth", "1"},
{"Content-Type", "application/xml; charset=utf-8"},
{"Authorization", "Basic " + encode_basic_auth()},
};
const std::string response = send_request(
static_cast<int>(HttpMethod::propfind), calendar_home_set_,
calendar_body, headers);
const auto root = parse_xml(response);
const auto responses = find_children(root, "D:response");
std::vector<CalDAVCalendar> calendars;
for (const auto* resp : responses) {
const auto hrefs = find_children(*resp, "D:href");
if (hrefs.empty()) continue;
const std::string href = std::string(hrefs[0]->text);
// Skip the calendar-home-set itself
if (href == calendar_home_set_) continue;
// Check if it's a calendar collection
const auto types = find_children(*resp, "D:resourcetype");
bool is_calendar = false;
for (const auto* type : types) {
for (const auto& child : type->children) {
if (child.tag == "C:calendar" || child.tag == "calendar") {
is_calendar = true;
break;
}
}
if (is_calendar) break;
}
if (!is_calendar) continue;
CalDAVCalendar calendar;
calendar.href = href;
const auto names = find_children(*resp, "D:displayname");
if (!names.empty()) {
calendar.name = std::string(names[0]->text);
}
const auto descs = find_children(*resp, "C:calendar-description");
if (!descs.empty()) {
calendar.description = std::string(descs[0]->text);
}
calendars.push_back(std::move(calendar));
}
// Upsert calendars in cache
std::vector<storage::CachedCalendar> cached;
for (const auto& cal : calendars) {
storage::CachedCalendar c;
c.id = stable_calendar_id(cal.href);
c.account_id = config_.account_id;
c.remote_id = cal.href;
c.name = cal.name;
c.read_only = cal.read_only;
c.active = true;
cached.push_back(std::move(c));
}
if (!cached.empty()) {
cache_.replace_calendars_after_complete_listing(config_.account_id, cached);
}
return calendars;
}
std::string CalDAVSync::sync_calendar(const CalDAVCalendar& calendar,
const std::string_view sync_token) {
const std::string token_xml = sync_token.empty()
? std::string{}
: "<D:sync-token>" + std::string(sync_token) + "</D:sync-token>";
const std::string sync_body =
"<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n"
"<D:sync-collection xmlns:D=\"DAV:\">\n"
" <D:sync-level>1</D:sync-level>\n"
+ token_xml + "\n"
" <D:prop>\n"
" <D:getetag/>\n"
" </D:prop>\n"
"</D:sync-collection>";
std::vector<HttpHeader> headers{
{"Depth", "1"},
{"Content-Type", "application/xml; charset=utf-8"},
{"Authorization", "Basic " + encode_basic_auth()},
};
const std::string response = send_request(
static_cast<int>(HttpMethod::report), calendar.href,
sync_body, headers);
const auto root = parse_xml(response);
const auto responses = find_children(root, "D:response");
std::string new_sync_token;
std::vector<std::string> event_hrefs;
std::vector<std::string> event_etags;
for (const auto* resp : responses) {
const auto hrefs = find_children(*resp, "D:href");
if (hrefs.empty()) continue;
const std::string href = std::string(hrefs[0]->text);
const auto tokens = find_children(*resp, "D:sync-token");
if (!tokens.empty()) {
new_sync_token = std::string(tokens[0]->text);
continue;
}
const auto etags = find_children(*resp, "D:getetag");
if (!etags.empty()) {
std::string etag = std::string(etags[0]->text);
if (!etag.empty() && etag.front() == '"') {
etag = etag.substr(1, etag.size() - 2);
}
event_hrefs.push_back(href);
event_etags.push_back(std::move(etag));
continue;
}
const auto status = find_children(*resp, "D:status");
if (!status.empty() && status[0]->text.find("404") != std::string::npos) {
// Deleted event — remove from cache
const std::string path = strip_href_prefix(href, calendar_resource_url(""));
// TODO: mark event as deleted in cache
continue;
}
}
// Fetch and parse each event
storage::PullPage page;
const std::string cal_id = stable_calendar_id(calendar.href);
for (std::size_t i = 0; i < event_hrefs.size(); ++i) {
const std::string event_url = calendar_resource_url(event_hrefs[i]);
HttpRequest get_req;
get_req.method = HttpMethod::get;
get_req.url = event_url;
get_req.headers = {
{"Authorization", "Basic " + encode_basic_auth()},
};
const auto get_response = transport_.send(get_req);
if (get_response.status != 200) {
continue;
}
// Parse iCalendar content
std::filesystem::path temp_path = std::filesystem::temp_directory_path()
/ ("caldav-sync-XXXXXX");
std::string temp_str = temp_path.string();
char* temp_chars = temp_str.data();
const int fd = ::mkstemp(temp_chars);
if (fd < 0) continue;
temp_path = std::string(temp_str);
(void)::write(fd, get_response.body.data(), get_response.body.size());
::close(fd);
const auto loaded = storage::IcsStore::load(temp_path);
std::filesystem::remove(temp_path);
for (const auto& event : loaded.events) {
storage::CachedEvent cached_event;
cached_event.id = stable_event_id(cal_id, event.uid);
cached_event.calendar_id = cal_id;
cached_event.remote_id = event_hrefs[i];
cached_event.uid = event.uid;
cached_event.etag = event_etags[i];
cached_event.raw_payload = get_response.body;
cached_event.deleted = false;
page.events.push_back(std::move(cached_event));
storage::CachedEventInstance instance;
instance.event_id = cached_event.id;
const auto start_tp = std::chrono::time_point_cast<std::chrono::microseconds>(event.start);
instance.start_epoch_microseconds = start_tp.time_since_epoch().count();
const auto end_tp = std::chrono::time_point_cast<std::chrono::microseconds>(event.end);
instance.end_epoch_microseconds = end_tp.time_since_epoch().count();
instance.all_day = event.all_day;
instance.title = event.title;
instance.location = event.location;
instance.description = event.description;
instance.time_zone = event.time_zone;
page.instances.push_back(std::move(instance));
}
}
page.checkpoint.calendar_id = cal_id;
page.checkpoint.cursor = new_sync_token;
page.checkpoint.complete = true;
if (!page.events.empty() || !new_sync_token.empty()) {
cache_.apply_pull_page(page);
}
return new_sync_token;
}
std::string CalDAVSync::send_request(const int method, const std::string& url,
const std::string& body,
const std::vector<HttpHeader>& extra_headers) {
HttpRequest request;
request.method = static_cast<HttpMethod>(method);
request.url = url;
request.headers = extra_headers;
request.body = body;
const auto response = transport_.send(request);
if (response.status == 401) {
throw std::runtime_error("CalDAV: authentication failed");
}
if (response.status == 403) {
throw std::runtime_error("CalDAV: forbidden");
}
if (response.status == 404) {
throw std::runtime_error("CalDAV: resource not found: " + url);
}
if (response.status != 200 && response.status != 207) {
throw std::runtime_error("CalDAV: unexpected status " + std::to_string(response.status)
+ " from " + url);
}
return response.body;
}
std::string CalDAVSync::calendar_resource_url(const std::string& calendar_href) const {
return calendar_href;
}
std::string CalDAVSync::stable_calendar_id(const std::string& remote_id) const {
std::string input("caldav-calendar\0");
input += config_.account_id;
input.push_back('\0');
input += remote_id;
return std::string("caldav-calendar-") + sha256_hex(input);
}
std::string CalDAVSync::stable_event_id(const std::string& calendar_id,
const std::string& remote_uid) const {
std::string input("caldav-event\0");
input += calendar_id;
input.push_back('\0');
input += remote_uid;
return std::string("caldav-event-") + sha256_hex(input);
}
std::string CalDAVSync::encode_basic_auth() const {
const std::string credentials = config_.username + ":" + config_.password;
return "Basic " + base64_encode(credentials);
}
} // namespace nocal::sync

View File

@@ -0,0 +1,106 @@
#include "nocal/sync/caldav_sync_provider.hpp"
#include "nocal/sync/caldav_sync.hpp"
#include "nocal/storage/sync_cache.hpp"
#include <openssl/evp.h>
#include <array>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
namespace nocal::sync {
namespace {
[[nodiscard]] std::string sha256_hex(std::string_view input) {
std::array<unsigned char, 32> digest{};
unsigned int size = 0;
if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1
|| size != digest.size()) {
throw std::runtime_error("cryptographic failure");
}
static constexpr char hexadecimal[] = "0123456789abcdef";
std::string output;
output.reserve(digest.size() * 2U);
for (const auto byte : digest) {
output.push_back(hexadecimal[(byte >> 4) & 0xf]);
output.push_back(hexadecimal[byte & 0xf]);
}
return output;
}
[[nodiscard]] std::string caldav_calendar_id(std::string_view account_id,
std::string_view remote_id) {
std::string input("caldav-calendar\0");
input += account_id;
input.push_back('\0');
input += remote_id;
return std::string("caldav-calendar-") + sha256_hex(input);
}
} // namespace
CalDAVSyncProvider::CalDAVSyncProvider(HttpTransport& transport,
storage::SyncCache& cache,
CalDAVAccountConfig config)
: transport_(transport), cache_(cache), config_(std::move(config)) {}
ConnectedAccount CalDAVSyncProvider::connect_account() {
// Account is configured via constructor. Verify connectivity.
CalDAVSync sync(transport_, cache_, config_);
sync.discover();
return {config_.account_id, config_.display_name};
}
void CalDAVSyncProvider::sync_account(const std::string_view account_id,
SyncObserver& observer) {
if (account_id != config_.account_id) {
throw std::runtime_error("CalDAV: account ID mismatch");
}
observer.on_sync_progress(SyncProgressEvent{
SyncStage::authenticating, config_.account_id, {}, 0, 0});
CalDAVSync sync(transport_, cache_, config_);
observer.on_sync_progress(SyncProgressEvent{
SyncStage::discovering_calendars, config_.account_id, {}, 0, 0});
const auto calendars = sync.list_calendars();
const std::size_t total = calendars.size();
for (std::size_t i = 0; i < total; ++i) {
const auto& calendar = calendars[i];
observer.on_sync_progress(SyncProgressEvent{
SyncStage::syncing_calendar, config_.account_id,
calendar.name, static_cast<std::size_t>(i + 1), total});
const auto snapshot = cache_.snapshot();
std::string cursor;
for (const auto& cp : snapshot.checkpoints) {
if (cp.calendar_id == caldav_calendar_id(config_.account_id, calendar.href)) {
cursor = cp.cursor;
break;
}
}
const std::string new_token = sync.sync_calendar(calendar, cursor);
(void)new_token;
}
observer.on_sync_progress(SyncProgressEvent{
SyncStage::completed, config_.account_id, {}, static_cast<std::size_t>(total), total});
}
void CalDAVSyncProvider::disconnect_account(const std::string_view account_id) {
if (account_id != config_.account_id) {
throw std::runtime_error("CalDAV: account ID mismatch");
}
// Credentials are stored externally; nothing to erase in this provider.
// The cache retains provider data (deterministic IDs make re-add idempotent).
}
} // namespace nocal::sync