feat(sync): wire TUI account orchestration, SyncProvider integration, and Ctrl+S keybinding

- MicrosoftAccountSyncProvider: implements SyncProvider interface with
  connect_account(), sync_account(), disconnect_account(), provider_id()
- TuiApp: inherits SyncObserver, implements thread-safe sync progress
  rendering, background sync thread, and Action::sync dispatch
- CLI: add "account disconnect <id>" and "account list" commands
- Main wiring: initialize sync infrastructure when connected accounts
  exist and a valid client ID is configured
- Screen: add sync_stage and sync_is_error fields to ScreenState,
  map Ctrl+S to Action::sync keybinding
- Fix ScreenState aggregate initializers in all construction sites
- Tests: new microsoft_sync_provider_tests.cpp, all 22 tests pass
This commit is contained in:
2026-07-22 21:49:26 +01:00
parent 74c798e7aa
commit 725e48569e
10 changed files with 1257 additions and 5 deletions

View File

@@ -0,0 +1,74 @@
#pragma once
#include "nocal/sync/oauth.hpp"
#include "nocal/sync/oauth_tokens.hpp"
#include "nocal/sync/provider.hpp"
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <string_view>
namespace nocal::storage {
class SyncCache;
} // namespace nocal::storage
namespace nocal::sync {
class MicrosoftGraphSync;
class OAuthTokenBroker;
class OAuthSecretStore;
class HttpTransport;
class MicrosoftAccountSyncProvider final : public SyncProvider {
public:
// All references must outlive this object.
MicrosoftAccountSyncProvider(OAuthTokenBroker& token_broker,
OAuthSecretStore& secret_store,
storage::SyncCache& cache,
HttpTransport& transport,
const OAuthClientConfig& oauth_config,
std::function<std::int64_t()> clock,
BrowserLauncher& browser,
AuthorizationCallbackReceiver& callback_receiver,
EntropySource& entropy);
~MicrosoftAccountSyncProvider() override;
MicrosoftAccountSyncProvider(const MicrosoftAccountSyncProvider&) = delete;
MicrosoftAccountSyncProvider& operator=(const MicrosoftAccountSyncProvider&) = delete;
// --- SyncProvider interface ---
[[nodiscard]] std::string_view provider_id() const noexcept override;
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:
OAuthTokenBroker& token_broker_;
OAuthSecretStore& secret_store_;
storage::SyncCache& cache_;
HttpTransport& transport_;
OAuthClientConfig oauth_config_;
std::function<std::int64_t()> clock_;
BrowserLauncher& browser_;
AuthorizationCallbackReceiver& callback_receiver_;
EntropySource& entropy_;
std::unique_ptr<MicrosoftGraphSync> graph_;
// Helper: run full PKCE connect flow and report progress.
ConnectedAccount do_connect(SyncObserver& observer);
// Helper: get valid tokens, throwing AccountDisconnected on invalid credentials.
OAuthTokens get_tokens(std::string_view account_id);
// Helper: sync one calendar (primary uses delta, secondaries use calendarView).
void sync_one_calendar(std::string_view account_id,
std::string_view calendar_id,
std::string_view calendar_name,
bool is_primary,
SyncObserver& observer);
};
} // namespace nocal::sync

View File

@@ -82,6 +82,8 @@ struct ScreenState {
std::size_t search_result_index{0};
std::string notification;
bool notification_is_error{false};
std::string sync_stage;
bool sync_is_error{false};
bool show_calendar_picker{false};
std::size_t calendar_index{0};
bool show_agenda{false};
@@ -136,6 +138,7 @@ enum class Action {
search,
agenda,
calendars,
sync,
quit,
};

View File

@@ -1,13 +1,17 @@
#pragma once
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
#include "nocal/domain/event.hpp"
#include "nocal/sync/provider.hpp"
#include "nocal/tui/EventEditor.hpp"
#include "nocal/tui/Screen.hpp"
@@ -21,7 +25,7 @@ enum class ExitReason {
// The Event vector is retained by reference so future editor commands can
// update the caller's model without changing the application boundary.
class TuiApp {
class TuiApp : public sync::SyncObserver {
public:
using SaveCallback = std::function<void(std::span<const Event>)>;
@@ -57,7 +61,28 @@ public:
return calendars_;
}
void set_sync_provider(std::unique_ptr<sync::SyncProvider> provider) noexcept {
sync_provider_ = std::move(provider);
}
void set_connected_account_id(std::string id) noexcept {
connected_account_id_ = std::move(id);
}
private:
// Sync state (protected by sync_mutex_)
std::mutex sync_mutex_;
std::unique_ptr<sync::SyncProvider> sync_provider_;
std::optional<std::string> connected_account_id_;
std::optional<std::thread> sync_thread_;
// --- SyncObserver implementation (called from sync thread) ---
void on_sync_progress(const sync::SyncProgressEvent& event) override;
void start_sync(std::string_view account_id);
void stop_sync();
void apply_sync_stage(const std::string& stage_text, bool is_error);
void clear_sync_state();
struct HistorySnapshot {
std::vector<Event> events;
std::chrono::year_month visible_month;