feat: CalDAV account connect CLI command

Add
to connect a CalDAV account. Credentials are stored in Secret Service,
server discovery is verified, and the account is upserted in the cache.
Disconnect also erases CalDAV credentials. Provider selection in the TUI
now dispatches to the correct sync provider based on the cached provider ID.
This commit is contained in:
2026-07-23 20:56:56 +01:00
parent 23d95e957c
commit 5805a4f7a4

View File

@@ -3,6 +3,9 @@
#include "nocal/storage/ics_store.hpp"
#include "nocal/storage/sync_cache.hpp"
#include "nocal/sync/curl_http.hpp"
#include "nocal/sync/caldav_account.hpp"
#include "nocal/sync/caldav_sync.hpp"
#include "nocal/sync/caldav_sync_provider.hpp"
#include "nocal/sync/desktop_oauth.hpp"
#include "nocal/sync/microsoft_account.hpp"
#include "nocal/sync/microsoft_sync_provider.hpp"
@@ -101,8 +104,8 @@ int connect_microsoft_account()
}
int disconnect_account_command(nocal::storage::SyncCache& cache,
nocal::sync::OAuthSecretStore& secret_store,
std::string_view account_id)
nocal::sync::OAuthSecretStore& secret_store,
std::string_view account_id)
{
// Verify account exists in cache
const auto snapshot = cache.snapshot();
@@ -111,8 +114,9 @@ int disconnect_account_command(nocal::storage::SyncCache& cache,
if (it == snapshot.accounts.end()) {
throw std::invalid_argument("unknown account: " + std::string{account_id});
}
// Erase from secret store
// Erase from secret store (OAuth tokens and/or CalDAV credentials)
secret_store.erase(std::string{account_id});
secret_store.erase_caldav_credential(std::string{account_id});
// Note: cached account/calendar rows remain in the database.
// This matches the SyncProvider contract: cached provider data is left
// in place so that reconnecting the same account is idempotent.
@@ -134,12 +138,92 @@ int list_accounts_command(const nocal::storage::SyncCache& cache)
return EXIT_SUCCESS;
}
int connect_caldav_account(int argc, char** argv)
{
std::string server_url;
std::string username;
std::string password;
// Parse arguments: --server URL --user USERNAME --pass PASSWORD
for (int i = 1; i < argc; ++i) {
const std::string_view arg = argv[i];
if (arg == "--server" && i + 1 < argc) {
server_url = argv[++i];
} else if (arg == "--user" && i + 1 < argc) {
username = argv[++i];
} else if (arg == "--pass" && i + 1 < argc) {
password = argv[++i];
} else {
throw std::invalid_argument("unknown CalDAV argument: " + std::string{arg});
}
}
if (server_url.empty() || username.empty() || password.empty()) {
throw std::invalid_argument(
"usage: nocal account connect caldav --server URL --user USERNAME --pass PASSWORD\n"
" --server CalDAV server URL (e.g. https://cloud.example.com/remote.php/dav)\n"
" --user CalDAV username\n"
" --pass CalDAV password (or 'stdin' to read from stdin)");
}
// Read password from stdin if requested
if (password == "stdin") {
std::string line;
if (!std::getline(std::cin, line)) {
throw std::invalid_argument("failed to read password from stdin");
}
password = line;
}
const std::filesystem::path cache_path = default_sync_cache_path();
std::error_code error;
std::filesystem::create_directories(cache_path.parent_path(), error);
if (error) {
throw std::runtime_error("cannot create " + cache_path.parent_path().string() +
": " + error.message());
}
nocal::storage::SyncCache cache(cache_path);
nocal::sync::CurlHttpTransport transport;
nocal::sync::LibsecretOAuthSecretStore secrets;
// Store credentials in Secret Service
nocal::sync::CalDAVCredential credential{username, password};
const std::string account_id = nocal::sync::caldav_account_id(server_url, username);
secrets.store_caldav_credential(account_id, credential);
// Verify connectivity by discovering principal and calendar-home-set
nocal::sync::CalDAVAccountConfig account_config;
account_config.server_url = server_url;
account_config.account_id = account_id;
account_config.username = username;
account_config.password = password;
account_config.display_name = username + "@" + server_url;
nocal::sync::CalDAVSync sync(transport, cache, account_config);
sync.discover();
// Upsert account in cache
nocal::storage::CachedAccount cached_account;
cached_account.id = account_id;
cached_account.provider = "caldav";
cached_account.remote_subject = server_url;
cached_account.display_name = account_config.display_name;
cache.upsert_account(cached_account);
std::cout << "Connected " << cached_account.display_name << " (" << account_id << ")\n";
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
}
int account_command(int argc, char** argv)
{
if (argc == 3 && std::string_view{argv[1]} == "connect" &&
std::string_view{argv[2]} == "microsoft") {
return connect_microsoft_account();
}
if (argc >= 3 && std::string_view{argv[1]} == "connect" &&
std::string_view{argv[2]} == "caldav") {
return connect_caldav_account(argc - 2, argv + 2);
}
if (argc == 3 && std::string_view{argv[1]} == "disconnect") {
const std::filesystem::path cache_path = default_sync_cache_path();
std::error_code error;
@@ -165,6 +249,7 @@ int account_command(int argc, char** argv)
}
throw std::invalid_argument(
"usage: nocal account connect microsoft\n"
" nocal account connect caldav --server URL --user USERNAME --pass PASSWORD\n"
" nocal account disconnect <account_id>\n"
" nocal account list");
}
@@ -185,12 +270,14 @@ void print_help(std::ostream& output)
" -h, --help show this help\n"
" -V, --version show the version\n\n"
"Accounts: nocal account connect microsoft\n"
" links a Microsoft account (browser sign-in, tokens stay in\n"
" the Secret Service keyring) and exits.\n"
" nocal account disconnect <account_id>\n"
" removes credentials for a connected account.\n"
" nocal account list\n"
" lists connected accounts.\n\n"
" links a Microsoft account (browser sign-in, tokens stay in\n"
" the Secret Service keyring) and exits.\n"
" nocal account connect caldav --server URL --user USERNAME --pass PASSWORD\n"
" links a CalDAV account (Nextcloud, Radicale, etc.) and exits.\n"
" nocal account disconnect <account_id>\n"
" removes credentials for a connected account.\n"
" nocal account list\n"
" lists connected accounts.\n\n"
"Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
@@ -511,36 +598,49 @@ int main(int argc, char** argv)
if (!sync_snapshot.accounts.empty()) {
app.set_connected_account_id(sync_snapshot.accounts.front().id);
const std::string client_id = nocal::sync::default_microsoft_client_id();
if (client_id != nocal::sync::microsoft_client_id_placeholder) {
nocal::sync::XdgBrowserLauncher browser;
nocal::sync::LoopbackCallbackReceiver callback_receiver;
nocal::sync::SystemEntropySource entropy;
const std::string& provider = sync_snapshot.accounts.front().provider;
if (provider == "microsoft-graph") {
const std::string client_id = nocal::sync::default_microsoft_client_id();
if (client_id != nocal::sync::microsoft_client_id_placeholder) {
nocal::sync::XdgBrowserLauncher browser;
nocal::sync::LoopbackCallbackReceiver callback_receiver;
nocal::sync::SystemEntropySource entropy;
nocal::sync::OAuthTokenBroker token_broker(
nocal::sync::microsoft_oauth_config(client_id),
http_transport,
secret_store,
[] {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
});
nocal::sync::OAuthTokenBroker token_broker(
nocal::sync::microsoft_oauth_config(client_id),
http_transport,
secret_store,
[] {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
});
auto sync_prov = std::make_unique<nocal::sync::MicrosoftAccountSyncProvider>(
token_broker,
secret_store,
sync_cache,
http_transport,
nocal::sync::microsoft_oauth_config(client_id),
[] {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
},
browser,
callback_receiver,
entropy);
auto sync_prov = std::make_unique<nocal::sync::MicrosoftAccountSyncProvider>(
token_broker,
secret_store,
sync_cache,
http_transport,
nocal::sync::microsoft_oauth_config(client_id),
[] {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
},
browser,
callback_receiver,
entropy);
app.set_sync_provider(std::move(sync_prov));
}
} else if (provider == "caldav") {
nocal::sync::CalDAVServerConfig server_config;
server_config.server_url = sync_snapshot.accounts.front().remote_subject;
server_config.account_id = sync_snapshot.accounts.front().id;
server_config.display_name = sync_snapshot.accounts.front().display_name;
auto sync_prov = std::make_unique<nocal::sync::CalDAVSyncProvider>(
http_transport, sync_cache, secret_store, std::move(server_config));
app.set_sync_provider(std::move(sync_prov));
}